@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.
@@ -0,0 +1,108 @@
1
+ const base = () => process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1";
2
+ const HEADERS = {
3
+ "HTTP-Referer": "https://github.com/manindhra/manix",
4
+ "X-Title": "Manix"
5
+ };
6
+ function authHeaders(apiKey) {
7
+ return { ...HEADERS, ...apiKey ? { Authorization: `Bearer ${apiKey}` } : {} };
8
+ }
9
+ async function streamChat({ apiKey, model, messages, tools, signal, onText }) {
10
+ const res = await fetch(`${base()}/chat/completions`, {
11
+ method: "POST",
12
+ headers: { ...authHeaders(apiKey), "Content-Type": "application/json" },
13
+ body: JSON.stringify({
14
+ model,
15
+ messages,
16
+ ...tools?.length ? { tools, tool_choice: "auto" } : {},
17
+ stream: true,
18
+ usage: { include: true }
19
+ }),
20
+ signal
21
+ });
22
+ if (!res.ok) {
23
+ let detail = "";
24
+ try {
25
+ const j = await res.json();
26
+ detail = j?.error?.message || JSON.stringify(j);
27
+ } catch {
28
+ }
29
+ throw new Error(`OpenRouter ${res.status}: ${detail || res.statusText}`);
30
+ }
31
+ const decoder = new TextDecoder();
32
+ let buf = "";
33
+ let content = "";
34
+ let usage = null;
35
+ let finish = null;
36
+ const toolCalls = [];
37
+ for await (const chunk of res.body) {
38
+ buf += decoder.decode(chunk, { stream: true });
39
+ let nl;
40
+ while ((nl = buf.indexOf("\n")) !== -1) {
41
+ const line = buf.slice(0, nl).trimEnd();
42
+ buf = buf.slice(nl + 1);
43
+ if (!line || line.startsWith(":")) continue;
44
+ if (!line.startsWith("data:")) continue;
45
+ const data = line.slice(5).trim();
46
+ if (data === "[DONE]") continue;
47
+ let json;
48
+ try {
49
+ json = JSON.parse(data);
50
+ } catch {
51
+ continue;
52
+ }
53
+ if (json.error) throw new Error(json.error.message || "OpenRouter stream error");
54
+ if (json.usage) usage = json.usage;
55
+ const choice = json.choices?.[0];
56
+ if (!choice) continue;
57
+ if (choice.finish_reason) finish = choice.finish_reason;
58
+ const d = choice.delta || {};
59
+ if (d.content) {
60
+ content += d.content;
61
+ onText?.(d.content);
62
+ }
63
+ for (const tc of d.tool_calls || []) {
64
+ const i = tc.index ?? 0;
65
+ if (!toolCalls[i]) toolCalls[i] = { id: "", type: "function", function: { name: "", arguments: "" } };
66
+ if (tc.id) toolCalls[i].id = tc.id;
67
+ if (tc.function?.name) toolCalls[i].function.name += tc.function.name;
68
+ if (tc.function?.arguments) toolCalls[i].function.arguments += tc.function.arguments;
69
+ }
70
+ }
71
+ }
72
+ return { content, toolCalls: toolCalls.filter(Boolean), usage, finish };
73
+ }
74
+ let modelCache = null;
75
+ async function fetchModels(apiKey, force = false) {
76
+ if (modelCache && !force) return modelCache;
77
+ const res = await fetch(`${base()}/models`, { headers: authHeaders(apiKey) });
78
+ if (!res.ok) throw new Error(`OpenRouter /models: ${res.status}`);
79
+ modelCache = (await res.json()).data ?? [];
80
+ return modelCache;
81
+ }
82
+ function cachedModels() {
83
+ return modelCache;
84
+ }
85
+ async function getCredits(apiKey) {
86
+ try {
87
+ const res = await fetch(`${base()}/credits`, { headers: authHeaders(apiKey) });
88
+ if (!res.ok) return null;
89
+ return (await res.json()).data ?? null;
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+ async function validateKey(apiKey) {
95
+ try {
96
+ const res = await fetch(`${base()}/key`, { headers: authHeaders(apiKey) });
97
+ return res.ok;
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
102
+ export {
103
+ cachedModels,
104
+ fetchModels,
105
+ getCredits,
106
+ streamChat,
107
+ validateKey
108
+ };
@@ -0,0 +1,19 @@
1
+ class Permissions {
2
+ constructor({ yolo = false } = {}) {
3
+ this.yolo = yolo;
4
+ this.always = /* @__PURE__ */ new Set();
5
+ }
6
+ async check(tool, ask) {
7
+ if (tool.safe || this.yolo || this.always.has(tool.name)) return true;
8
+ if (!ask) return false;
9
+ const answer = await ask();
10
+ if (answer === "always") {
11
+ this.always.add(tool.name);
12
+ return true;
13
+ }
14
+ return answer === "once";
15
+ }
16
+ }
17
+ export {
18
+ Permissions
19
+ };
package/dist/print.js ADDED
@@ -0,0 +1,51 @@
1
+ import { Agent } from "./agent.js";
2
+ import { Permissions } from "./permissions.js";
3
+ import { t, GLYPH } from "./theme.js";
4
+ async function runPrint({ config, cwd, prompt, yolo, mcp, skills, contextFiles }) {
5
+ const permissions = new Permissions({ yolo });
6
+ let failed = false;
7
+ const agent = new Agent({
8
+ config,
9
+ cwd,
10
+ permissions,
11
+ mcp,
12
+ skills,
13
+ contextFiles,
14
+ handlers: {
15
+ onTextDelta: (d) => process.stdout.write(d),
16
+ onAssistantDone: (text) => {
17
+ if (text) process.stdout.write("\n");
18
+ },
19
+ onToolStart: ({ display }) => process.stderr.write(t.dim(`${GLYPH.tool} ${display}
20
+ `)),
21
+ onToolEnd: ({ display, ok, summary }) => {
22
+ if (!ok) process.stderr.write(t.err(`${GLYPH.error} ${display}: ${summary}
23
+ `));
24
+ },
25
+ onInfo: (m) => process.stderr.write(t.dim(`${GLYPH.info} ${m}
26
+ `)),
27
+ onError: (m) => {
28
+ failed = true;
29
+ process.stderr.write(t.err(`${GLYPH.error} ${m}
30
+ `));
31
+ },
32
+ requestPermission: async () => {
33
+ process.stderr.write(
34
+ t.warn(`${GLYPH.info} action auto-denied \u2014 use --yolo to allow writes/bash in print mode
35
+ `)
36
+ );
37
+ return "no";
38
+ }
39
+ }
40
+ });
41
+ await agent.send(prompt);
42
+ const s = agent.stats();
43
+ process.stderr.write(
44
+ t.dim(`\u2014 ${s.requests} req \xB7 ${s.prompt + s.completion} tok \xB7 $${s.cost.toFixed(4)}
45
+ `)
46
+ );
47
+ if (failed) process.exitCode = 1;
48
+ }
49
+ export {
50
+ runPrint
51
+ };
@@ -0,0 +1,47 @@
1
+ import os from "node:os";
2
+ function systemPrompt({ cwd, model, contextFiles = [], skills = [] }) {
3
+ const parts = [];
4
+ parts.push(`You are Manix, an expert AI coding agent running in the user's terminal.
5
+
6
+ Environment:
7
+ - Working directory: ${cwd}
8
+ - Platform: ${process.platform} (${os.release()})
9
+ - Date: ${(/* @__PURE__ */ new Date()).toDateString()}
10
+ - Model: ${model}
11
+
12
+ You accomplish tasks by calling tools. Rules:
13
+ - Gather context first: use read_file, list_dir, glob_files and grep_search to understand code before changing it. Never guess file contents.
14
+ - Read a file before editing it. Prefer edit_file (exact, unique old_string) over write_file for existing files.
15
+ - Use bash for tests, builds, git and installs. Keep commands non-interactive; explain risky commands before running them.
16
+ - After making changes, verify them (run tests/build) when possible.
17
+ - Paths may be relative to the working directory.
18
+ - Be concise \u2014 output renders in a terminal. Use short markdown, no preamble, no flattery. Reference code as path:line.
19
+ - If the request is ambiguous, ask a clarifying question instead of guessing.`);
20
+ if (skills.length) {
21
+ parts.push(
22
+ `Skills (expert playbooks) are available. When one is clearly relevant to the task, load it with the skill tool BEFORE doing the work:
23
+ ` + skills.map((s) => `- ${s.name}: ${s.description}`).join("\n")
24
+ );
25
+ }
26
+ for (const f of contextFiles) {
27
+ parts.push(`Project context from ${f.path}:
28
+
29
+ ${f.content}`);
30
+ }
31
+ return parts.join("\n\n");
32
+ }
33
+ const INIT_PROMPT = `Analyze this repository and create a MANIX.md file that will be given to future Manix sessions as project context.
34
+
35
+ Explore first: list_dir the root, read README/package/build files, skim key source files. Then write MANIX.md containing:
36
+ 1. What this project is (1-2 lines)
37
+ 2. Commands: build, test, lint, run (exact commands)
38
+ 3. Architecture: key directories/files and how they fit together
39
+ 4. Conventions: code style, patterns to follow, things to avoid
40
+
41
+ Keep it under 60 lines. Create the file with write_file.`;
42
+ const COMPACT_PROMPT = `Summarize this entire conversation so it can replace the full history. Include: the user's goals, decisions made, files created/modified and how, current state, and immediate next steps. Be thorough but compact. Reply with only the summary.`;
43
+ export {
44
+ COMPACT_PROMPT,
45
+ INIT_PROMPT,
46
+ systemPrompt
47
+ };
@@ -0,0 +1,80 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { MANIX_DIR, ensureDir } from "./config.js";
4
+ const SESSIONS_DIR = path.join(MANIX_DIR, "sessions");
5
+ function createSession(cwd, model) {
6
+ ensureDir(SESSIONS_DIR);
7
+ const id = (/* @__PURE__ */ new Date()).toISOString().slice(0, 19).replace(/[:T]/g, "-") + "-" + Math.random().toString(36).slice(2, 6);
8
+ const file = path.join(SESSIONS_DIR, id + ".jsonl");
9
+ appendLine(file, { type: "meta", id, cwd, model, created: Date.now() });
10
+ return { id, file };
11
+ }
12
+ function appendMessage(session, message) {
13
+ if (!session) return;
14
+ appendLine(session.file, { type: "message", message });
15
+ }
16
+ function appendLine(file, obj) {
17
+ fs.appendFileSync(file, JSON.stringify(obj) + "\n");
18
+ }
19
+ function parseSessionFile(file) {
20
+ let meta = null;
21
+ const messages = [];
22
+ let raw;
23
+ try {
24
+ raw = fs.readFileSync(file, "utf8");
25
+ } catch {
26
+ return null;
27
+ }
28
+ for (const line of raw.split("\n")) {
29
+ if (!line.trim()) continue;
30
+ let obj;
31
+ try {
32
+ obj = JSON.parse(line);
33
+ } catch {
34
+ continue;
35
+ }
36
+ if (obj.type === "meta") meta = obj;
37
+ else if (obj.type === "message") messages.push(obj.message);
38
+ }
39
+ if (!meta) return null;
40
+ return { meta, messages };
41
+ }
42
+ function listSessions(cwd) {
43
+ let files;
44
+ try {
45
+ files = fs.readdirSync(SESSIONS_DIR).filter((f) => f.endsWith(".jsonl"));
46
+ } catch {
47
+ return [];
48
+ }
49
+ const all = [];
50
+ for (const f of files) {
51
+ const file = path.join(SESSIONS_DIR, f);
52
+ const parsed = parseSessionFile(file);
53
+ if (!parsed || !parsed.messages.length) continue;
54
+ const firstUser = parsed.messages.find((m) => m.role === "user");
55
+ all.push({
56
+ id: parsed.meta.id,
57
+ file,
58
+ cwd: parsed.meta.cwd,
59
+ created: parsed.meta.created,
60
+ mtime: fs.statSync(file).mtimeMs,
61
+ count: parsed.messages.length,
62
+ title: String(firstUser?.content || "(empty)").split("\n")[0].slice(0, 64)
63
+ });
64
+ }
65
+ all.sort((a, b) => b.mtime - a.mtime);
66
+ const here = all.filter((s) => s.cwd === cwd);
67
+ return here.length ? here : all;
68
+ }
69
+ function loadSession(idOrFile) {
70
+ const file = idOrFile.endsWith(".jsonl") ? idOrFile : path.join(SESSIONS_DIR, idOrFile + ".jsonl");
71
+ const parsed = parseSessionFile(file);
72
+ return parsed ? { ...parsed, file } : null;
73
+ }
74
+ export {
75
+ SESSIONS_DIR,
76
+ appendMessage,
77
+ createSession,
78
+ listSessions,
79
+ loadSession
80
+ };
package/dist/skills.js ADDED
@@ -0,0 +1,70 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { MANIX_DIR } from "./config.js";
4
+ function loadSkills(cwd) {
5
+ const dirs = [path.join(MANIX_DIR, "skills"), path.join(cwd, ".manix", "skills")];
6
+ const byName = /* @__PURE__ */ new Map();
7
+ for (const dir of dirs) {
8
+ let entries;
9
+ try {
10
+ entries = fs.readdirSync(dir, { withFileTypes: true });
11
+ } catch {
12
+ continue;
13
+ }
14
+ for (const e of entries) {
15
+ if (!e.isDirectory()) continue;
16
+ const file = path.join(dir, e.name, "SKILL.md");
17
+ let src;
18
+ try {
19
+ src = fs.readFileSync(file, "utf8");
20
+ } catch {
21
+ continue;
22
+ }
23
+ const { meta, body } = parseFrontmatter(src);
24
+ const name = (meta.name || e.name).toLowerCase().replace(/\s+/g, "-");
25
+ byName.set(name, {
26
+ name,
27
+ description: meta.description || "(no description)",
28
+ body,
29
+ dir: path.join(dir, e.name)
30
+ });
31
+ }
32
+ }
33
+ return [...byName.values()];
34
+ }
35
+ function parseFrontmatter(src) {
36
+ const m = src.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
37
+ if (!m) return { meta: {}, body: src };
38
+ const meta = {};
39
+ for (const line of m[1].split("\n")) {
40
+ const kv = line.match(/^(\w[\w-]*):\s*(.*)$/);
41
+ if (kv) meta[kv[1]] = kv[2].trim();
42
+ }
43
+ return { meta, body: src.slice(m[0].length) };
44
+ }
45
+ function skillTool(skills) {
46
+ return {
47
+ name: "skill",
48
+ safe: true,
49
+ description: "Load a skill (expert playbook) by name and follow its instructions. Available: " + (skills.map((s) => s.name).join(", ") || "none"),
50
+ parameters: {
51
+ type: "object",
52
+ properties: { name: { type: "string" } },
53
+ required: ["name"]
54
+ },
55
+ describe: (a) => `Skill(${a.name})`,
56
+ async run(a) {
57
+ const s = skills.find((x) => x.name === String(a.name).toLowerCase());
58
+ if (!s) return `No skill named "${a.name}". Available: ${skills.map((x) => x.name).join(", ")}`;
59
+ return `# Skill: ${s.name}
60
+ (files for this skill live in ${s.dir})
61
+
62
+ ${s.body}`;
63
+ }
64
+ };
65
+ }
66
+ export {
67
+ loadSkills,
68
+ parseFrontmatter,
69
+ skillTool
70
+ };
package/dist/slash.js ADDED
@@ -0,0 +1,31 @@
1
+ function slashCommands(skills = []) {
2
+ const base = [
3
+ { name: "help", desc: "Show commands and shortcuts" },
4
+ { name: "model", desc: "Switch model \u2014 picker, or /model <id>" },
5
+ { name: "resume", desc: "Resume a previous session" },
6
+ { name: "clear", desc: "Start a fresh conversation" },
7
+ { name: "compact", desc: "Summarize history to free context" },
8
+ { name: "cost", desc: "Session usage + OpenRouter credits" },
9
+ { name: "mcp", desc: "MCP server status" },
10
+ { name: "skills", desc: "List available skills" },
11
+ { name: "init", desc: "Generate MANIX.md for this project" },
12
+ { name: "yolo", desc: "Toggle auto-approve for all tools" },
13
+ { name: "exit", desc: "Quit Manix" }
14
+ ];
15
+ const skillCmds = skills.map((s) => ({ name: s.name, desc: `(skill) ${s.description}`, skill: s }));
16
+ return [...base, ...skillCmds];
17
+ }
18
+ function helpText(commands) {
19
+ const rows = commands.map((c) => ` /${c.name.padEnd(12)} ${c.desc}`);
20
+ return [
21
+ "Commands:",
22
+ ...rows,
23
+ "",
24
+ "Shortcuts: Esc interrupt \xB7 \u2191/\u2193 history \xB7 Tab complete \xB7 Ctrl+C twice to quit",
25
+ 'Flags: manix "task" \xB7 -p print mode \xB7 --model <id> \xB7 --continue \xB7 --resume \xB7 --yolo'
26
+ ].join("\n");
27
+ }
28
+ export {
29
+ helpText,
30
+ slashCommands
31
+ };
package/dist/theme.js ADDED
@@ -0,0 +1,84 @@
1
+ import chalk from "chalk";
2
+ import os from "node:os";
3
+ const color = {
4
+ accent: "#ff9f6b",
5
+ amber: "#ffd27f",
6
+ user: "#d7d3cd",
7
+ dim: "#8a8580",
8
+ faint: "#5c5854",
9
+ border: "#4a443f",
10
+ ok: "#98c379",
11
+ err: "#e06c75",
12
+ warn: "#e5c07b",
13
+ code: "#61afef"
14
+ };
15
+ const t = {
16
+ accent: chalk.hex(color.accent),
17
+ amber: chalk.hex(color.amber),
18
+ user: chalk.hex(color.user),
19
+ dim: chalk.hex(color.dim),
20
+ faint: chalk.hex(color.faint),
21
+ ok: chalk.hex(color.ok),
22
+ err: chalk.hex(color.err),
23
+ warn: chalk.hex(color.warn),
24
+ code: chalk.hex(color.code),
25
+ bold: chalk.bold
26
+ };
27
+ const LOGO = "\u25B2";
28
+ const WORDMARK = `${LOGO} M A N I X`;
29
+ const GLYPH = {
30
+ user: "\u276F",
31
+ assistant: "\u23FA",
32
+ tool: "\u25CF",
33
+ info: "\u25CB",
34
+ error: "\u2717"
35
+ };
36
+ const SPINNER = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
37
+ function hexToRgb(hex) {
38
+ const n = parseInt(hex.slice(1), 16);
39
+ return [n >> 16 & 255, n >> 8 & 255, n & 255];
40
+ }
41
+ function lerpColor(hexA, hexB, t2) {
42
+ const a = hexToRgb(hexA);
43
+ const b = hexToRgb(hexB);
44
+ const [r, g, bl] = a.map((av, i) => Math.round(av + (b[i] - av) * t2));
45
+ return chalk.rgb(r, g, bl);
46
+ }
47
+ function gradient(text) {
48
+ const chars = [...text];
49
+ return chars.map((ch, i) => lerpColor(color.accent, color.amber, chars.length > 1 ? i / (chars.length - 1) : 0)(ch)).join("");
50
+ }
51
+ function hr(pad = 0) {
52
+ return "\u2500".repeat(Math.max(10, (process.stdout.columns || 80) - pad));
53
+ }
54
+ const MASCOT = ["...#...", "..###..", ".#####.", "#######"];
55
+ function shortenPath(p) {
56
+ const home = os.homedir();
57
+ return p === home ? "~" : p.startsWith(home + "/") ? "~" + p.slice(home.length) : p;
58
+ }
59
+ const TIPS = [
60
+ "Tip: /model swaps the model mid-conversation \u2014 try a free one to explore.",
61
+ "Tip: /init teaches Manix about this repo so it needs less hand-holding.",
62
+ "Tip: Esc interrupts a running turn \xB7 Ctrl+C twice quits.",
63
+ "Tip: drop a SKILL.md under .manix/skills/ to teach Manix a new playbook.",
64
+ "Tip: /resume brings back any previous session in this project.",
65
+ "Tip: --yolo auto-approves every tool call \u2014 use with care."
66
+ ];
67
+ function randomTip() {
68
+ return TIPS[Math.floor(Math.random() * TIPS.length)];
69
+ }
70
+ export {
71
+ GLYPH,
72
+ LOGO,
73
+ MASCOT,
74
+ SPINNER,
75
+ TIPS,
76
+ WORDMARK,
77
+ color,
78
+ gradient,
79
+ hr,
80
+ lerpColor,
81
+ randomTip,
82
+ shortenPath,
83
+ t
84
+ };
@@ -0,0 +1,44 @@
1
+ import { exec } from "node:child_process";
2
+ import { t } from "../theme.js";
3
+ const short = (s, n = 60) => s.length > n ? s.slice(0, n - 1) + "\u2026" : s;
4
+ const bashTool = {
5
+ name: "bash",
6
+ safe: false,
7
+ description: "Run a shell command in the working directory. Non-interactive only. Returns stdout+stderr (truncated).",
8
+ parameters: {
9
+ type: "object",
10
+ properties: {
11
+ command: { type: "string" },
12
+ timeout_ms: { type: "integer", description: "Max runtime (default 120000, cap 600000)" }
13
+ },
14
+ required: ["command"]
15
+ },
16
+ describe: (a) => `Bash(${short(String(a.command || ""))})`,
17
+ preview: (a) => String(a.command || "").split("\n").slice(0, 8).map((l) => t.warn("$ " + l)),
18
+ run(a, ctx) {
19
+ return new Promise((resolve) => {
20
+ exec(
21
+ a.command,
22
+ {
23
+ cwd: ctx.cwd,
24
+ timeout: Math.min(a.timeout_ms || 12e4, 6e5),
25
+ maxBuffer: 10 * 1024 * 1024,
26
+ env: process.env
27
+ },
28
+ (err, stdout, stderr) => {
29
+ let out = [stdout, stderr].map((s) => String(s).trim()).filter(Boolean).join("\n");
30
+ if (out.length > 3e4) out = out.slice(0, 15e3) + "\n\u2026 [output truncated] \u2026\n" + out.slice(-1e4);
31
+ if (err?.killed) return resolve(`Command timed out.
32
+ ${out}`);
33
+ if (err && typeof err.code === "number") return resolve(`Exit code ${err.code}
34
+ ${out}`);
35
+ if (err && !out) return resolve(`Error: ${err.message}`);
36
+ resolve(out || "(no output)");
37
+ }
38
+ );
39
+ });
40
+ }
41
+ };
42
+ export {
43
+ bashTool
44
+ };
@@ -0,0 +1,119 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { t } from "../theme.js";
4
+ const rel = (cwd, p) => path.resolve(cwd, p);
5
+ const short = (s, n = 60) => s.length > n ? s.slice(0, n - 1) + "\u2026" : s;
6
+ const readFileTool = {
7
+ name: "read_file",
8
+ safe: true,
9
+ description: "Read a text file. Returns numbered lines. Use offset (1-based) and limit for large files.",
10
+ parameters: {
11
+ type: "object",
12
+ properties: {
13
+ path: { type: "string", description: "File path (relative to cwd or absolute)" },
14
+ offset: { type: "integer", description: "1-based line to start from" },
15
+ limit: { type: "integer", description: "Max lines to return (default/cap 2000)" }
16
+ },
17
+ required: ["path"]
18
+ },
19
+ describe: (a) => `Read(${a.path})`,
20
+ async run(a, ctx) {
21
+ const p = rel(ctx.cwd, a.path);
22
+ const lines = fs.readFileSync(p, "utf8").split("\n");
23
+ const off = Math.max(1, a.offset || 1);
24
+ const lim = Math.min(a.limit || 2e3, 2e3);
25
+ const slice = lines.slice(off - 1, off - 1 + lim);
26
+ const body = slice.map((l, i) => `${String(off + i).padStart(5)}\u2192${l.slice(0, 500)}`).join("\n");
27
+ const more = off - 1 + lim < lines.length ? `
28
+ \u2026 (${lines.length} lines total)` : "";
29
+ return body + more;
30
+ }
31
+ };
32
+ const writeFileTool = {
33
+ name: "write_file",
34
+ safe: false,
35
+ description: "Create or overwrite a file with the given content. Creates parent directories.",
36
+ parameters: {
37
+ type: "object",
38
+ properties: {
39
+ path: { type: "string" },
40
+ content: { type: "string" }
41
+ },
42
+ required: ["path", "content"]
43
+ },
44
+ describe: (a) => `Write(${a.path})`,
45
+ preview(a) {
46
+ const lines = String(a.content ?? "").split("\n");
47
+ const shown = lines.slice(0, 12).map((l) => t.ok("+ " + short(l, 76)));
48
+ if (lines.length > 12) shown.push(t.dim(` \u2026 +${lines.length - 12} more lines`));
49
+ return shown;
50
+ },
51
+ async run(a, ctx) {
52
+ const p = rel(ctx.cwd, a.path);
53
+ const existed = fs.existsSync(p);
54
+ fs.mkdirSync(path.dirname(p), { recursive: true });
55
+ fs.writeFileSync(p, a.content);
56
+ return `${existed ? "Overwrote" : "Created"} ${a.path} (${Buffer.byteLength(a.content)} bytes)`;
57
+ }
58
+ };
59
+ const editFileTool = {
60
+ name: "edit_file",
61
+ safe: false,
62
+ description: "Replace an exact string in a file. old_string must match exactly and be unique unless replace_all is true. Read the file first.",
63
+ parameters: {
64
+ type: "object",
65
+ properties: {
66
+ path: { type: "string" },
67
+ old_string: { type: "string", description: "Exact text to replace (include enough context to be unique)" },
68
+ new_string: { type: "string" },
69
+ replace_all: { type: "boolean" }
70
+ },
71
+ required: ["path", "old_string", "new_string"]
72
+ },
73
+ describe: (a) => `Edit(${a.path})`,
74
+ preview(a) {
75
+ const olds = String(a.old_string ?? "").split("\n").slice(0, 8);
76
+ const news = String(a.new_string ?? "").split("\n").slice(0, 8);
77
+ return [
78
+ ...olds.map((l) => t.err("- " + short(l, 76))),
79
+ ...news.map((l) => t.ok("+ " + short(l, 76)))
80
+ ];
81
+ },
82
+ async run(a, ctx) {
83
+ const p = rel(ctx.cwd, a.path);
84
+ const src = fs.readFileSync(p, "utf8");
85
+ const count = src.split(a.old_string).length - 1;
86
+ if (count === 0) throw new Error("old_string not found in file \u2014 read the file and retry with exact text");
87
+ if (count > 1 && !a.replace_all)
88
+ throw new Error(`old_string matches ${count} times \u2014 add surrounding context or set replace_all`);
89
+ const next = a.replace_all ? src.split(a.old_string).join(a.new_string) : src.replace(a.old_string, a.new_string);
90
+ fs.writeFileSync(p, next);
91
+ return `Edited ${a.path} (${a.replace_all ? count : 1} replacement${count > 1 && a.replace_all ? "s" : ""})`;
92
+ }
93
+ };
94
+ const listDirTool = {
95
+ name: "list_dir",
96
+ safe: true,
97
+ description: "List directory entries. Directories get a trailing slash.",
98
+ parameters: {
99
+ type: "object",
100
+ properties: { path: { type: "string", description: "Defaults to cwd" } }
101
+ },
102
+ describe: (a) => `List(${a.path || "."})`,
103
+ async run(a, ctx) {
104
+ const p = rel(ctx.cwd, a.path || ".");
105
+ const entries = fs.readdirSync(p, { withFileTypes: true });
106
+ entries.sort((x, y) => y.isDirectory() - x.isDirectory() || x.name.localeCompare(y.name));
107
+ const rows = entries.slice(0, 200).map((e) => e.isDirectory() ? e.name + "/" : e.name);
108
+ if (entries.length > 200) rows.push(`\u2026 +${entries.length - 200} more`);
109
+ return rows.join("\n") || "(empty)";
110
+ }
111
+ };
112
+ const fsTools = [readFileTool, writeFileTool, editFileTool, listDirTool];
113
+ export {
114
+ editFileTool,
115
+ fsTools,
116
+ listDirTool,
117
+ readFileTool,
118
+ writeFileTool
119
+ };
@@ -0,0 +1,20 @@
1
+ import { fsTools } from "./fs-tools.js";
2
+ import { searchTools } from "./search.js";
3
+ import { bashTool } from "./bash.js";
4
+ function builtinTools() {
5
+ return [...fsTools, ...searchTools, bashTool];
6
+ }
7
+ function toOpenAI(tool) {
8
+ return {
9
+ type: "function",
10
+ function: {
11
+ name: tool.name,
12
+ description: tool.description,
13
+ parameters: tool.parameters || { type: "object", properties: {} }
14
+ }
15
+ };
16
+ }
17
+ export {
18
+ builtinTools,
19
+ toOpenAI
20
+ };