@manix-cli/manix 0.1.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Manindhra
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # ▲ Manix
2
+
3
+ A fast, beautiful terminal coding agent powered by [OpenRouter](https://openrouter.ai) —
4
+ any model (Claude, GPT, Gemini, DeepSeek, free models), one key. Hand-written agent loop,
5
+ no frameworks.
6
+
7
+ ```bash
8
+ npm install -g manix # or: npx manix
9
+ cd your-project
10
+ manix
11
+ ```
12
+
13
+ First run asks for your OpenRouter API key (get one at [openrouter.ai/keys](https://openrouter.ai/keys)) —
14
+ or `export OPENROUTER_API_KEY=...`.
15
+
16
+ ## What it does
17
+
18
+ ```
19
+ ❯ fix the failing test in src/auth.test.js
20
+ ● Bash(npm test)
21
+ ● Read(src/auth.js)
22
+ ● Edit(src/auth.js) ← asks permission, shows the diff
23
+ ⏺ Fixed — the token expiry check was inverted. Tests pass.
24
+ ```
25
+
26
+ - **Agentic loop** — streams responses, reads/writes/edits files, runs shell commands,
27
+ greps the repo, keeps going until the task is done.
28
+ - **Safe by default** — mutating actions ask permission (yes / always this session / no).
29
+ `--yolo` to live dangerously.
30
+ - **Any model** — `/model` opens a live picker with context windows and $/M pricing.
31
+ - **Sessions** — every conversation is saved; `manix --continue` or `/resume`.
32
+ - **MANIX.md** — project memory auto-loaded each session; generate with `/init`.
33
+ - **Skills** — drop a `SKILL.md` playbook in `~/.manix/skills/<name>/` or `.manix/skills/<name>/`;
34
+ the agent loads it when relevant, or invoke directly with `/<name>`.
35
+ - **MCP** — add servers to `~/.manix/mcp.json` or `.manix/mcp.json`:
36
+ ```json
37
+ { "mcpServers": { "github": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"] } } }
38
+ ```
39
+ - **Cost tracking** — `/cost` shows session spend and remaining OpenRouter credits.
40
+
41
+ ## Commands
42
+
43
+ | Command | | Flags | |
44
+ |---|---|---|---|
45
+ | `/help` | commands & shortcuts | `manix "task"` | start with a prompt |
46
+ | `/model [id]` | switch model | `-p, --print` | non-interactive mode |
47
+ | `/resume` | resume a session | `-m, --model <id>` | model for this run |
48
+ | `/compact` | summarize history | `-c, --continue` | resume last session |
49
+ | `/cost` | usage + credits | `-r, --resume [id]` | pick a session |
50
+ | `/mcp` `/skills` | status / list | `--yolo` | auto-approve tools |
51
+ | `/init` | generate MANIX.md | `-v` `-h` | version / help |
52
+ | `/clear` `/yolo` `/exit` | | `echo task \| manix` | pipe a prompt |
53
+
54
+ Shortcuts: **Esc** interrupt · **↑/↓** history · **Tab** complete · **Ctrl+C** twice to quit.
55
+
56
+ ## Skills format
57
+
58
+ ```markdown
59
+ ---
60
+ name: commit
61
+ description: Write conventional commits from the staged diff
62
+ ---
63
+ Steps: run `git diff --staged`, group changes, write a conventional commit message…
64
+ ```
65
+
66
+ ## Development
67
+
68
+ ```bash
69
+ npm install && npm test # build + offline smoke tests
70
+ npm run dev # build & run locally
71
+ ```
72
+
73
+ Plain JavaScript (ESM) + JSX via esbuild. Ink 5 TUI. Raw-fetch OpenRouter client with a
74
+ hand-rolled SSE parser — see [CLAUDE.md](CLAUDE.md) for the full architecture.
package/bin/manix.js ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ import('../dist/cli.js').catch((err) => {
3
+ if (err?.code === 'ERR_MODULE_NOT_FOUND' && String(err.message).includes('dist')) {
4
+ console.error('manix: dist/ missing — run `npm run build` first.')
5
+ } else {
6
+ console.error(err)
7
+ }
8
+ process.exit(1)
9
+ })
package/dist/agent.js ADDED
@@ -0,0 +1,194 @@
1
+ import { streamChat, fetchModels, cachedModels } from "./openrouter.js";
2
+ import { systemPrompt, COMPACT_PROMPT } from "./prompts.js";
3
+ import { builtinTools, toOpenAI } from "./tools/index.js";
4
+ import { skillTool } from "./skills.js";
5
+ import { appendMessage, createSession } from "./sessions.js";
6
+ class Agent {
7
+ constructor({ config, cwd, permissions, mcp, skills, contextFiles, handlers }) {
8
+ this.apiKey = config.apiKey;
9
+ this.model = config.model;
10
+ this.cwd = cwd;
11
+ this.permissions = permissions;
12
+ this.mcp = mcp;
13
+ this.skills = skills || [];
14
+ this.contextFiles = contextFiles || [];
15
+ this.h = handlers || {};
16
+ this.messages = [];
17
+ this.session = null;
18
+ this.usage = { prompt: 0, completion: 0, cost: 0, requests: 0 };
19
+ this.contextLength = 128e3;
20
+ this.ac = null;
21
+ fetchModels(this.apiKey).then(() => this.refreshModelInfo()).catch(() => {
22
+ });
23
+ }
24
+ refreshModelInfo() {
25
+ const m = (cachedModels() || []).find((x) => x.id === this.model);
26
+ if (m?.context_length) this.contextLength = m.context_length;
27
+ this.h.onStats?.(this.stats());
28
+ }
29
+ setModel(id) {
30
+ this.model = id;
31
+ this.refreshModelInfo();
32
+ }
33
+ get busy() {
34
+ return this.ac !== null;
35
+ }
36
+ pushMessage(msg) {
37
+ if (!this.session) this.session = createSession(this.cwd, this.model);
38
+ this.messages.push(msg);
39
+ appendMessage(this.session, msg);
40
+ }
41
+ resumeFrom(loaded) {
42
+ this.messages = loaded.messages;
43
+ this.session = { id: loaded.meta.id, file: loaded.file };
44
+ }
45
+ reset() {
46
+ this.messages = [];
47
+ this.session = null;
48
+ }
49
+ buildTools() {
50
+ return [...builtinTools(), skillTool(this.skills), ...this.mcp?.getTools() || []];
51
+ }
52
+ estimateTokens() {
53
+ return Math.round((JSON.stringify(this.messages).length + 4e3) / 4);
54
+ }
55
+ stats() {
56
+ return { ...this.usage, tokens: this.estimateTokens(), contextLength: this.contextLength };
57
+ }
58
+ trackUsage(usage) {
59
+ if (!usage) return;
60
+ this.usage.prompt += usage.prompt_tokens || 0;
61
+ this.usage.completion += usage.completion_tokens || 0;
62
+ this.usage.requests += 1;
63
+ if (typeof usage.cost === "number") {
64
+ this.usage.cost += usage.cost;
65
+ } else {
66
+ const m = (cachedModels() || []).find((x) => x.id === this.model);
67
+ if (m?.pricing) {
68
+ this.usage.cost += (usage.prompt_tokens || 0) * parseFloat(m.pricing.prompt || 0) + (usage.completion_tokens || 0) * parseFloat(m.pricing.completion || 0);
69
+ }
70
+ }
71
+ this.h.onStats?.(this.stats());
72
+ }
73
+ abort() {
74
+ this.ac?.abort();
75
+ }
76
+ async send(text) {
77
+ this.pushMessage({ role: "user", content: text });
78
+ await this.run();
79
+ }
80
+ async run() {
81
+ this.ac = new AbortController();
82
+ const tools = this.buildTools();
83
+ const toolSchemas = tools.map(toOpenAI);
84
+ try {
85
+ for (let turn = 0; turn < 40; turn++) {
86
+ const sys = {
87
+ role: "system",
88
+ content: systemPrompt({
89
+ cwd: this.cwd,
90
+ model: this.model,
91
+ contextFiles: this.contextFiles,
92
+ skills: this.skills
93
+ })
94
+ };
95
+ const res = await streamChat({
96
+ apiKey: this.apiKey,
97
+ model: this.model,
98
+ messages: [sys, ...this.messages],
99
+ tools: toolSchemas,
100
+ signal: this.ac.signal,
101
+ onText: this.h.onTextDelta
102
+ });
103
+ this.trackUsage(res.usage);
104
+ const assistant = { role: "assistant", content: res.content || null };
105
+ if (res.toolCalls.length) assistant.tool_calls = res.toolCalls;
106
+ this.pushMessage(assistant);
107
+ this.h.onAssistantDone?.(res.content);
108
+ if (!res.toolCalls.length) return;
109
+ for (const tc of res.toolCalls) {
110
+ const result = await this.execTool(tools, tc);
111
+ this.pushMessage({
112
+ role: "tool",
113
+ tool_call_id: tc.id,
114
+ content: result.length > 6e4 ? result.slice(0, 6e4) + "\n\u2026[truncated]" : result
115
+ });
116
+ }
117
+ if (this.estimateTokens() > this.contextLength * 0.8) {
118
+ this.h.onInfo?.("Context above 80% \u2014 run /compact to summarize and free space.");
119
+ }
120
+ }
121
+ this.h.onInfo?.("Stopped after 40 tool turns \u2014 send a message to continue.");
122
+ } catch (err) {
123
+ if (err?.name === "AbortError" || this.ac?.signal.aborted) {
124
+ this.h.onInfo?.("Interrupted.");
125
+ } else {
126
+ this.h.onError?.(err?.message || String(err));
127
+ }
128
+ } finally {
129
+ this.ac = null;
130
+ }
131
+ }
132
+ async execTool(tools, tc) {
133
+ const name = tc.function?.name || "";
134
+ const tool = tools.find((t) => t.name === name);
135
+ if (!tool) {
136
+ this.h.onToolEnd?.({ display: name, ok: false, summary: "unknown tool" });
137
+ return `Error: unknown tool "${name}"`;
138
+ }
139
+ let args;
140
+ try {
141
+ args = JSON.parse(tc.function.arguments || "{}");
142
+ } catch {
143
+ this.h.onToolEnd?.({ display: name, ok: false, summary: "bad arguments" });
144
+ return "Error: tool arguments were not valid JSON";
145
+ }
146
+ const display = tool.describe ? tool.describe(args) : name;
147
+ if (this.ac.signal.aborted) return "Interrupted by user before execution.";
148
+ const ask = this.h.requestPermission ? () => this.h.requestPermission({ display, preview: tool.preview?.(args) || null }) : null;
149
+ const allowed = await this.permissions.check(tool, ask);
150
+ if (!allowed) {
151
+ this.h.onToolEnd?.({ display, ok: false, summary: "permission denied" });
152
+ return "The user denied permission for this action. Do not retry it; ask or take another approach.";
153
+ }
154
+ this.h.onToolStart?.({ display });
155
+ try {
156
+ const out = String(await tool.run(args, { cwd: this.cwd }));
157
+ this.h.onToolEnd?.({ display, ok: true, summary: summarize(out) });
158
+ return out;
159
+ } catch (err) {
160
+ this.h.onToolEnd?.({ display, ok: false, summary: err.message });
161
+ return `Error: ${err.message}`;
162
+ }
163
+ }
164
+ /** Replace history with a model-written summary; starts a new session file. */
165
+ async compact() {
166
+ this.ac = new AbortController();
167
+ try {
168
+ const res = await streamChat({
169
+ apiKey: this.apiKey,
170
+ model: this.model,
171
+ messages: [...this.messages, { role: "user", content: COMPACT_PROMPT }],
172
+ signal: this.ac.signal
173
+ });
174
+ this.messages = [
175
+ { role: "user", content: "[Earlier conversation was compacted. Summary:]\n\n" + res.content },
176
+ { role: "assistant", content: "Got it \u2014 continuing from that summary." }
177
+ ];
178
+ this.session = createSession(this.cwd, this.model);
179
+ for (const m of this.messages) appendMessage(this.session, m);
180
+ this.h.onStats?.(this.stats());
181
+ } finally {
182
+ this.ac = null;
183
+ }
184
+ }
185
+ }
186
+ function summarize(out) {
187
+ const lines = String(out).split("\n").filter((l) => l.trim());
188
+ const shown = lines.slice(0, 3).map((l) => l.length > 100 ? l.slice(0, 99) + "\u2026" : l);
189
+ if (lines.length > 3) shown.push(`\u2026 +${lines.length - 3} lines`);
190
+ return shown.join("\n");
191
+ }
192
+ export {
193
+ Agent
194
+ };
package/dist/cli.js ADDED
@@ -0,0 +1,123 @@
1
+ import { jsx } from "react/jsx-runtime";
2
+ import fs from "node:fs";
3
+ import { loadConfig } from "./config.js";
4
+ import { loadSkills } from "./skills.js";
5
+ import { loadContextFiles } from "./manixmd.js";
6
+ import { loadMcpConfigs, McpManager } from "./mcp.js";
7
+ import { gradient, t, LOGO } from "./theme.js";
8
+ const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
9
+ function parseArgs(argv) {
10
+ const args2 = { _: [] };
11
+ for (let i = 0; i < argv.length; i++) {
12
+ const a = argv[i];
13
+ if (a === "-h" || a === "--help") args2.help = true;
14
+ else if (a === "-v" || a === "--version") args2.version = true;
15
+ else if (a === "-p" || a === "--print") args2.print = true;
16
+ else if (a === "-m" || a === "--model") args2.model = argv[++i];
17
+ else if (a === "-c" || a === "--continue") args2.resume = "last";
18
+ else if (a === "-r" || a === "--resume") {
19
+ const next = argv[i + 1];
20
+ if (next && !next.startsWith("-")) args2.resume = argv[++i];
21
+ else args2.resume = "pick";
22
+ } else if (a === "--yolo") args2.yolo = true;
23
+ else args2._.push(a);
24
+ }
25
+ return args2;
26
+ }
27
+ function usage() {
28
+ return `${gradient(`${LOGO} Manix`)} ${t.dim(`v${pkg.version} \u2014 terminal coding agent powered by OpenRouter`)}
29
+
30
+ ${t.bold("Usage")}
31
+ manix [prompt] interactive TUI (optional starting prompt)
32
+ manix -p "task" print mode (no TUI); assistant text \u2192 stdout
33
+ echo "task" | manix pipe a prompt
34
+
35
+ ${t.bold("Flags")}
36
+ -m, --model <id> OpenRouter model for this run
37
+ -c, --continue resume the most recent session
38
+ -r, --resume [id] pick (or load) a previous session
39
+ --yolo auto-approve all tools (careful!)
40
+ -p, --print non-interactive print mode
41
+ -v, --version print version
42
+ -h, --help this help
43
+
44
+ ${t.bold("Files")}
45
+ config ~/.manix/config.json sessions ~/.manix/sessions/
46
+ MCP ~/.manix/mcp.json \xB7 .manix/mcp.json
47
+ skills ~/.manix/skills/<name>/SKILL.md \xB7 .manix/skills/<name>/SKILL.md
48
+ memory ./MANIX.md (generate with /init)`;
49
+ }
50
+ const args = parseArgs(process.argv.slice(2));
51
+ if (args.version) {
52
+ console.log(pkg.version);
53
+ process.exit(0);
54
+ }
55
+ if (args.help) {
56
+ console.log(usage());
57
+ process.exit(0);
58
+ }
59
+ const cwd = process.cwd();
60
+ const config = loadConfig();
61
+ if (args.model) config.model = args.model;
62
+ let prompt = args._.join(" ").trim();
63
+ if (!process.stdin.isTTY && !args.print) {
64
+ const stdin = fs.readFileSync(0, "utf8").trim();
65
+ if (stdin) {
66
+ prompt = [prompt, stdin].filter(Boolean).join("\n\n");
67
+ args.print = true;
68
+ }
69
+ }
70
+ const skills = loadSkills(cwd);
71
+ const contextFiles = loadContextFiles(cwd);
72
+ const mcp = new McpManager();
73
+ const mcpConfigs = loadMcpConfigs(cwd);
74
+ if (args.print) {
75
+ if (!config.apiKey) {
76
+ console.error("No API key. Run `manix` once interactively, or set OPENROUTER_API_KEY.");
77
+ process.exit(1);
78
+ }
79
+ if (!prompt) {
80
+ console.error('No prompt. Usage: manix -p "task"');
81
+ process.exit(1);
82
+ }
83
+ mcp.start(mcpConfigs);
84
+ if (Object.keys(mcpConfigs).length) await waitForMcp(mcp, 5e3);
85
+ const { runPrint } = await import("./print.js");
86
+ await runPrint({ config, cwd, prompt, yolo: !!args.yolo, mcp, skills, contextFiles });
87
+ await mcp.closeAll();
88
+ process.exit(process.exitCode || 0);
89
+ }
90
+ if (!process.stdout.isTTY) {
91
+ console.error('Interactive mode needs a TTY. Use -p "task" for print mode.');
92
+ process.exit(1);
93
+ }
94
+ mcp.start(mcpConfigs);
95
+ const { render } = await import("ink");
96
+ const { default: App } = await import("./ui/App.js");
97
+ const instance = render(
98
+ /* @__PURE__ */ jsx(
99
+ App,
100
+ {
101
+ config,
102
+ cwd,
103
+ version: pkg.version,
104
+ mcp,
105
+ skills,
106
+ contextFiles,
107
+ initialPrompt: prompt || null,
108
+ resumeTarget: args.resume || null,
109
+ yolo: !!args.yolo
110
+ }
111
+ ),
112
+ { exitOnCtrlC: false }
113
+ );
114
+ await instance.waitUntilExit();
115
+ await mcp.closeAll();
116
+ process.exit(0);
117
+ async function waitForMcp(manager, timeoutMs) {
118
+ const start = Date.now();
119
+ while (Date.now() - start < timeoutMs) {
120
+ if (manager.status().every((s) => s.status !== "connecting")) return;
121
+ await new Promise((r) => setTimeout(r, 100));
122
+ }
123
+ }
package/dist/config.js ADDED
@@ -0,0 +1,38 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ const MANIX_DIR = path.join(os.homedir(), ".manix");
5
+ const CONFIG_FILE = path.join(MANIX_DIR, "config.json");
6
+ const DEFAULT_MODEL = "anthropic/claude-sonnet-4.5";
7
+ function ensureDir(dir = MANIX_DIR) {
8
+ fs.mkdirSync(dir, { recursive: true });
9
+ }
10
+ function readJson(file) {
11
+ try {
12
+ return JSON.parse(fs.readFileSync(file, "utf8"));
13
+ } catch {
14
+ return {};
15
+ }
16
+ }
17
+ function loadConfig() {
18
+ const cfg = readJson(CONFIG_FILE);
19
+ return {
20
+ model: DEFAULT_MODEL,
21
+ ...cfg,
22
+ apiKey: process.env.OPENROUTER_API_KEY || cfg.apiKey || null
23
+ };
24
+ }
25
+ function saveConfig(patch) {
26
+ ensureDir();
27
+ const next = { ...readJson(CONFIG_FILE), ...patch };
28
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(next, null, 2) + "\n", { mode: 384 });
29
+ return next;
30
+ }
31
+ export {
32
+ CONFIG_FILE,
33
+ DEFAULT_MODEL,
34
+ MANIX_DIR,
35
+ ensureDir,
36
+ loadConfig,
37
+ saveConfig
38
+ };
@@ -0,0 +1,18 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { MANIX_DIR } from "./config.js";
4
+ function loadContextFiles(cwd) {
5
+ const candidates = [path.join(MANIX_DIR, "MANIX.md"), path.join(cwd, "MANIX.md")];
6
+ const out = [];
7
+ for (const p of candidates) {
8
+ try {
9
+ const content = fs.readFileSync(p, "utf8").trim();
10
+ if (content) out.push({ path: p, content: content.slice(0, 2e4) });
11
+ } catch {
12
+ }
13
+ }
14
+ return out;
15
+ }
16
+ export {
17
+ loadContextFiles
18
+ };
@@ -0,0 +1,35 @@
1
+ import { t } from "./theme.js";
2
+ function renderMarkdown(md) {
3
+ const out = [];
4
+ let inCode = false;
5
+ for (const line of String(md).split("\n")) {
6
+ const fence = line.match(/^\s*```(\S*)/);
7
+ if (fence) {
8
+ inCode = !inCode;
9
+ out.push(inCode ? t.faint("\u250C\u2574" + (fence[1] || "code")) : t.faint("\u2514\u2574"));
10
+ continue;
11
+ }
12
+ if (inCode) {
13
+ out.push(t.faint("\u2502 ") + t.code(line));
14
+ continue;
15
+ }
16
+ out.push(renderLine(line));
17
+ }
18
+ if (inCode) out.push(t.faint("\u2514\u2574"));
19
+ return out.join("\n");
20
+ }
21
+ function renderLine(line) {
22
+ let m;
23
+ if (m = line.match(/^#{1,6}\s+(.*)$/)) return t.accent.bold(inline(m[1]));
24
+ if (m = line.match(/^(\s*)[-*]\s+(.*)$/)) return m[1] + t.accent("\u2022") + " " + inline(m[2]);
25
+ if (m = line.match(/^(\s*)(\d+)\.\s+(.*)$/)) return m[1] + t.accent(m[2] + ".") + " " + inline(m[3]);
26
+ if (/^\s*>\s?/.test(line)) return t.dim("\u2502 " + line.replace(/^\s*>\s?/, ""));
27
+ if (/^\s*([-*_])\s?(\1\s?){2,}$/.test(line)) return t.faint("\u2500".repeat(40));
28
+ return inline(line);
29
+ }
30
+ function inline(s) {
31
+ return s.replace(/`([^`]+)`/g, (_, c) => t.code(c)).replace(/\*\*([^*]+)\*\*/g, (_, b) => t.bold(b)).replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, txt, url) => txt + " " + t.faint(`(${url})`));
32
+ }
33
+ export {
34
+ renderMarkdown
35
+ };
package/dist/mcp.js ADDED
@@ -0,0 +1,100 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
5
+ import { MANIX_DIR } from "./config.js";
6
+ function loadMcpConfigs(cwd) {
7
+ const files = [path.join(MANIX_DIR, "mcp.json"), path.join(cwd, ".manix", "mcp.json")];
8
+ const servers = {};
9
+ for (const f of files) {
10
+ try {
11
+ Object.assign(servers, JSON.parse(fs.readFileSync(f, "utf8")).mcpServers || {});
12
+ } catch {
13
+ }
14
+ }
15
+ return servers;
16
+ }
17
+ function withTimeout(promise, ms, msg) {
18
+ let timer;
19
+ const timeout = new Promise((_, reject) => {
20
+ timer = setTimeout(() => reject(new Error(msg)), ms);
21
+ timer.unref?.();
22
+ });
23
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
24
+ }
25
+ class McpManager {
26
+ constructor() {
27
+ this.servers = /* @__PURE__ */ new Map();
28
+ }
29
+ /** Connect all configured servers in the background; onUpdate fires per status change. */
30
+ start(configs, onUpdate) {
31
+ for (const [name, cfg] of Object.entries(configs)) {
32
+ const entry = { name, status: "connecting", tools: [], client: null, error: null };
33
+ this.servers.set(name, entry);
34
+ this.connect(entry, cfg).then(() => onUpdate?.(entry)).catch((err) => {
35
+ entry.status = "error";
36
+ entry.error = err.message;
37
+ onUpdate?.(entry);
38
+ });
39
+ }
40
+ }
41
+ async connect(entry, cfg) {
42
+ const client = new Client({ name: "manix", version: "0.1.0" });
43
+ const transport = new StdioClientTransport({
44
+ command: cfg.command,
45
+ args: cfg.args || [],
46
+ env: { ...process.env, ...cfg.env || {} },
47
+ stderr: "ignore"
48
+ });
49
+ await withTimeout(client.connect(transport), 15e3, `MCP "${entry.name}": connect timed out`);
50
+ const { tools } = await withTimeout(client.listTools(), 15e3, `MCP "${entry.name}": listTools timed out`);
51
+ entry.client = client;
52
+ entry.tools = tools || [];
53
+ entry.status = "ready";
54
+ }
55
+ /** All ready servers' tools in Manix tool shape, namespaced mcp__server__tool. */
56
+ getTools() {
57
+ const out = [];
58
+ for (const s of this.servers.values()) {
59
+ if (s.status !== "ready") continue;
60
+ for (const tl of s.tools) {
61
+ out.push({
62
+ name: `mcp__${s.name}__${tl.name}`,
63
+ safe: false,
64
+ // external code — always gate behind permissions
65
+ description: (tl.description || `${tl.name} from MCP server ${s.name}`).slice(0, 1024),
66
+ parameters: tl.inputSchema || { type: "object", properties: {} },
67
+ describe: (a) => `${s.name}:${tl.name}(${JSON.stringify(a).slice(0, 48)})`,
68
+ preview: (a) => JSON.stringify(a, null, 2).split("\n").slice(0, 10),
69
+ run: async (a) => {
70
+ const res = await s.client.callTool({ name: tl.name, arguments: a });
71
+ const text = (res.content || []).map((c) => c.type === "text" ? c.text : `[${c.type} content]`).join("\n");
72
+ if (res.isError) throw new Error(text || "MCP tool returned an error");
73
+ return text || "(no output)";
74
+ }
75
+ });
76
+ }
77
+ }
78
+ return out;
79
+ }
80
+ status() {
81
+ return [...this.servers.values()].map((s) => ({
82
+ name: s.name,
83
+ status: s.status,
84
+ tools: s.tools.length,
85
+ error: s.error
86
+ }));
87
+ }
88
+ async closeAll() {
89
+ for (const s of this.servers.values()) {
90
+ try {
91
+ await s.client?.close();
92
+ } catch {
93
+ }
94
+ }
95
+ }
96
+ }
97
+ export {
98
+ McpManager,
99
+ loadMcpConfigs
100
+ };