micoli 0.0.1

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 ADDED
@@ -0,0 +1,49 @@
1
+ # micoli
2
+
3
+ a mini mini experimental coding agent
4
+
5
+ ## Getting Started
6
+
7
+ ```bash
8
+ export OPENROUTER_API_KEY=your-key-here
9
+ npx micoli
10
+ ```
11
+
12
+ Get your API key at [openrouter.ai/keys](https://openrouter.ai/keys).
13
+
14
+ ### Requirements
15
+
16
+ - `Node.js`
17
+ - `Docker`
18
+ - `ripgrep`
19
+
20
+ ## Features
21
+
22
+ ```md
23
+ - Coding agent with minimal tools
24
+ - Read `AGENTS.md`
25
+ - File tools:
26
+ - list
27
+ - find
28
+ - read
29
+ - edit
30
+ - Exec tools:
31
+ - exec: It runs on Docker container
32
+ - Web tools:
33
+ - fetch
34
+ ```
35
+
36
+ ## Development
37
+
38
+ ```bash
39
+ npm install
40
+ npm start
41
+ ```
42
+
43
+ ## License
44
+
45
+ MIT
46
+
47
+ ## Support
48
+
49
+ [https://x.com/jtakahashi0604](https://x.com/jtakahashi0604)
package/dist/index.js ADDED
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env node
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { readFileSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ import { Box, render, Text, useInput } from "ink";
6
+ import TextInput from "ink-text-input";
7
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
8
+ import { send } from "@/lib/llm";
9
+ import { createExecTools } from "@/tools/exec";
10
+ import { fileTools } from "@/tools/file";
11
+ import { webTools } from "@/tools/web";
12
+ function loadAgentsMd() {
13
+ try {
14
+ return readFileSync(resolve(process.cwd(), "AGENTS.md"), "utf-8");
15
+ }
16
+ catch {
17
+ return undefined;
18
+ }
19
+ }
20
+ function App() {
21
+ const [logs, setLogs] = useState([]);
22
+ const [history, setHistory] = useState([]);
23
+ const [input, setInput] = useState("");
24
+ const [loading, setLoading] = useState(false);
25
+ const [chatOutput, setChatOutput] = useState("");
26
+ const [toolOutput, setToolOutput] = useState("");
27
+ const [status, setStatus] = useState("");
28
+ const [confirmPrompt, setConfirmPrompt] = useState(null);
29
+ const [confirmSelected, setConfirmSelected] = useState("yes");
30
+ const confirmResolveRef = useRef(null);
31
+ const [charOffset, setCharOffset] = useState(null);
32
+ const [systemPrompt] = useState(() => loadAgentsMd());
33
+ useEffect(() => {
34
+ const cols = process.stdout.columns || 80;
35
+ const charWidth = 8;
36
+ const startOffset = Math.max(0, Math.floor((cols - charWidth) / 2));
37
+ setCharOffset(startOffset);
38
+ let current = startOffset;
39
+ const timer = setInterval(() => {
40
+ current -= 2;
41
+ if (current <= 0) {
42
+ current = 0;
43
+ clearInterval(timer);
44
+ }
45
+ setCharOffset(current);
46
+ }, 50);
47
+ return () => clearInterval(timer);
48
+ }, []);
49
+ const confirmExec = useCallback((command) => {
50
+ return new Promise((resolve) => {
51
+ confirmResolveRef.current = resolve;
52
+ setConfirmPrompt(command);
53
+ setConfirmSelected("yes");
54
+ });
55
+ }, []);
56
+ useInput((_input, key) => {
57
+ if (confirmPrompt == null)
58
+ return;
59
+ if (key.leftArrow || key.rightArrow) {
60
+ setConfirmSelected((prev) => (prev === "yes" ? "no" : "yes"));
61
+ }
62
+ if (key.return) {
63
+ const resolve = confirmResolveRef.current;
64
+ confirmResolveRef.current = null;
65
+ setConfirmPrompt(null);
66
+ resolve?.(confirmSelected === "yes");
67
+ }
68
+ });
69
+ const flushChatOutput = useCallback(() => {
70
+ setChatOutput((prev) => {
71
+ if (prev !== "") {
72
+ setLogs((logs) => [...logs, { type: "reasoning", text: prev }]);
73
+ }
74
+ return "";
75
+ });
76
+ }, []);
77
+ const flushToolOutput = useCallback(() => {
78
+ setToolOutput((prev) => {
79
+ if (prev !== "") {
80
+ setLogs((logs) => [...logs, { type: "output", text: prev }]);
81
+ }
82
+ return "";
83
+ });
84
+ }, []);
85
+ const addLog = useCallback((entry) => {
86
+ setLogs((prev) => [...prev, entry]);
87
+ }, []);
88
+ const historyRef = useRef(history);
89
+ historyRef.current = history;
90
+ const setToolOutputRef = useRef(setToolOutput);
91
+ setToolOutputRef.current = setToolOutput;
92
+ const tools = useMemo(() => ({
93
+ ...fileTools,
94
+ ...createExecTools({
95
+ onStdout: (chunk) => setToolOutputRef.current((prev) => prev + chunk),
96
+ onStderr: (chunk) => setToolOutputRef.current((prev) => prev + chunk),
97
+ confirm: confirmExec,
98
+ }),
99
+ ...webTools,
100
+ }), [confirmExec]);
101
+ // biome-ignore lint/correctness/useExhaustiveDependencies: ignore
102
+ const handleSubmit = useCallback(async (value) => {
103
+ if (value.trim() === "" || loading)
104
+ return;
105
+ if (value.trim() === "/exit") {
106
+ process.exit(0);
107
+ }
108
+ setInput("");
109
+ addLog({ type: "user", text: value });
110
+ setLoading(true);
111
+ setChatOutput("");
112
+ setToolOutput("");
113
+ setStatus("thinking...");
114
+ try {
115
+ const userMessage = { role: "user", content: value };
116
+ const messages = [...historyRef.current, userMessage];
117
+ const { text, responseMessages } = await send({
118
+ messages,
119
+ system: systemPrompt,
120
+ onReasoningDelta: (delta) => {
121
+ setStatus("");
122
+ setChatOutput((prev) => prev + delta);
123
+ },
124
+ onToolCall: ({ toolName, args }) => {
125
+ flushChatOutput();
126
+ flushToolOutput();
127
+ addLog({
128
+ type: "tool",
129
+ text: `[tool] ${toolName} ${JSON.stringify(args)}`,
130
+ });
131
+ setStatus(`running ${toolName}...`);
132
+ },
133
+ onToolResult: ({ toolName, result }) => {
134
+ flushToolOutput();
135
+ addLog({
136
+ type: "output",
137
+ text: `[${toolName}] ${JSON.stringify(result, null, 2)}`,
138
+ });
139
+ },
140
+ tools,
141
+ });
142
+ flushChatOutput();
143
+ flushToolOutput();
144
+ addLog({ type: "assistant", text });
145
+ setHistory((prev) => [...prev, userMessage, ...responseMessages]);
146
+ }
147
+ catch (err) {
148
+ addLog({
149
+ type: "error",
150
+ text: `Error: ${err instanceof Error ? err.message : String(err)}`,
151
+ });
152
+ }
153
+ finally {
154
+ setChatOutput("");
155
+ setToolOutput("");
156
+ setStatus("");
157
+ setLoading(false);
158
+ }
159
+ }, [loading, addLog, flushChatOutput, flushToolOutput, tools]);
160
+ return (_jsxs(Box, { flexDirection: "column", width: "100%", children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "#8BE9FD", paddingX: 1, children: [logs.length === 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginLeft: charOffset ?? 0, children: _jsx(Text, { bold: true, color: "#8BE9FD", children: "█ █\n████████\n██ ██ ██\n████████\n████████" }) }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, color: "#8BE9FD", children: "micoli" })] })) : (
161
+ // biome-ignore lint/suspicious/useIterableCallbackReturn: ignore
162
+ logs.map((log, i) => {
163
+ const key = `${i}`;
164
+ switch (log.type) {
165
+ case "user":
166
+ return (_jsxs(Text, { color: "#50FA7B", children: [">", " ", log.text] }, key));
167
+ case "assistant":
168
+ return (_jsx(Text, { color: "#F8F8F2", children: log.text }, key));
169
+ case "reasoning":
170
+ return (_jsx(Text, { color: "#6272A4", dimColor: true, children: log.text }, key));
171
+ case "output":
172
+ return (_jsx(Text, { color: "#BD93F9", dimColor: true, children: log.text }, key));
173
+ case "tool":
174
+ return (_jsx(Text, { color: "#F1FA8C", dimColor: true, children: log.text }, key));
175
+ case "error":
176
+ return (_jsx(Text, { color: "#FF5555", children: log.text }, key));
177
+ }
178
+ })), chatOutput !== "" && (_jsx(Text, { color: "#6272A4", dimColor: true, children: chatOutput })), toolOutput !== "" && (_jsx(Text, { color: "#BD93F9", dimColor: true, children: toolOutput })), status !== "" && _jsx(Text, { color: "#F1FA8C", children: status })] }), confirmPrompt != null ? (_jsxs(Box, { paddingX: 1, flexDirection: "column", children: [_jsxs(Text, { color: "#FFB86C", children: ["Run command: ", _jsx(Text, { bold: true, children: confirmPrompt })] }), _jsxs(Box, { gap: 2, children: [_jsxs(Text, { color: confirmSelected === "yes" ? "#50FA7B" : "#6272A4", bold: confirmSelected === "yes", children: [confirmSelected === "yes" ? "▸ " : " ", "yes"] }), _jsxs(Text, { color: confirmSelected === "no" ? "#FF5555" : "#6272A4", bold: confirmSelected === "no", children: [confirmSelected === "no" ? "▸ " : " ", "no"] })] })] })) : (_jsxs(Box, { paddingX: 1, children: [_jsxs(Text, { color: "#8BE9FD", bold: true, children: [">", " "] }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: handleSubmit, placeholder: "Type a message... (/exit to quit)" })] }))] }));
179
+ }
180
+ render(_jsx(App, {}));
@@ -0,0 +1,92 @@
1
+ import { createOpenRouter } from "@openrouter/ai-sdk-provider";
2
+ import { generateText, stepCountIs, streamText, } from "ai";
3
+ const DEFAULT_MODEL = "openai/gpt-4o-mini";
4
+ export async function send({ apiKey, maxRetries, messages, model, onReasoningDelta, onToolCall, onToolResult, prompt, stopWhen, system, temperature, tools, }) {
5
+ const resolvedModel = model ?? getDefaultModel({ apiKey });
6
+ const input = messages ? { messages } : { prompt: prompt ?? "" };
7
+ if (onReasoningDelta == null) {
8
+ return generateResult({
9
+ ...input,
10
+ maxRetries,
11
+ model: resolvedModel,
12
+ onToolCall,
13
+ onToolResult,
14
+ stopWhen,
15
+ system,
16
+ temperature,
17
+ tools,
18
+ });
19
+ }
20
+ const result = streamText({
21
+ ...input,
22
+ maxRetries,
23
+ model: resolvedModel,
24
+ system,
25
+ onChunk({ chunk }) {
26
+ if (chunk.type === "reasoning-delta") {
27
+ onReasoningDelta(chunk.text);
28
+ }
29
+ if (chunk.type === "tool-call" && onToolCall) {
30
+ onToolCall({ toolName: chunk.toolName, args: chunk.input });
31
+ }
32
+ if (chunk.type === "tool-result" && onToolResult) {
33
+ onToolResult({ toolName: chunk.toolName, result: chunk.output });
34
+ }
35
+ },
36
+ stopWhen: stopWhen ?? stepCountIs(10),
37
+ temperature,
38
+ tools,
39
+ });
40
+ const [reasoningText, text, response] = await Promise.all([
41
+ result.reasoningText,
42
+ result.text,
43
+ result.response,
44
+ ]);
45
+ return {
46
+ text,
47
+ reasoningText,
48
+ responseMessages: response.messages,
49
+ };
50
+ }
51
+ async function generateResult({ maxRetries, messages, model, onToolCall, onToolResult, prompt, stopWhen, system, temperature, tools, }) {
52
+ const input = messages ? { messages } : { prompt: prompt ?? "" };
53
+ const { reasoningText, response, text } = await generateText({
54
+ ...input,
55
+ maxRetries,
56
+ model,
57
+ system,
58
+ onStepFinish({ toolCalls, toolResults }) {
59
+ if (onToolCall) {
60
+ for (const tc of toolCalls) {
61
+ onToolCall({ toolName: tc.toolName, args: tc.input });
62
+ }
63
+ }
64
+ if (onToolResult) {
65
+ for (const tr of toolResults) {
66
+ onToolResult({ toolName: tr.toolName, result: tr.output });
67
+ }
68
+ }
69
+ },
70
+ stopWhen: stopWhen ?? stepCountIs(10),
71
+ temperature,
72
+ tools,
73
+ });
74
+ return {
75
+ text,
76
+ reasoningText,
77
+ responseMessages: response.messages,
78
+ };
79
+ }
80
+ function getDefaultModel({ apiKey }) {
81
+ const openrouter = createOpenRouter({ apiKey: getApiKey({ apiKey }) });
82
+ return openrouter.chat(DEFAULT_MODEL);
83
+ }
84
+ function getApiKey({ apiKey = process.env.OPENROUTER_API_KEY, }) {
85
+ if (!apiKey) {
86
+ throw new Error("OPENROUTER_API_KEY is not set.\n\n" +
87
+ "Get your API key at https://openrouter.ai/keys and run:\n\n" +
88
+ " export OPENROUTER_API_KEY=your-key-here\n" +
89
+ " npx micoli\n");
90
+ }
91
+ return apiKey;
92
+ }
@@ -0,0 +1,148 @@
1
+ import { spawn } from "node:child_process";
2
+ import { posix, relative, resolve, sep } from "node:path";
3
+ import { jsonSchema, tool } from "ai";
4
+ const CONTAINER_WORKSPACE_ROOT = "/workspace";
5
+ const DOCKER_IMAGE = "node:lts";
6
+ export async function execCommand({ root = process.cwd(), command, cwd, networkEnabled, onStdout, onStderr, confirm, }) {
7
+ const { containerCwd, workspaceCwd, workspaceRoot } = resolveExecPaths({
8
+ cwd,
9
+ root,
10
+ });
11
+ if (confirm) {
12
+ const approved = await confirm(command);
13
+ if (!approved) {
14
+ return {
15
+ blocked: true,
16
+ command,
17
+ cwd: workspaceCwd,
18
+ stdout: "",
19
+ stderr: "Execution denied by user.\n",
20
+ exitCode: 126,
21
+ success: false,
22
+ };
23
+ }
24
+ }
25
+ const args = createDockerArgs({
26
+ command,
27
+ containerCwd,
28
+ networkEnabled: networkEnabled ?? false,
29
+ workspaceRoot,
30
+ });
31
+ if (args[0] == null) {
32
+ throw new Error("Failed to construct Docker command arguments.");
33
+ }
34
+ const spawnedProcess = spawn(args[0], args.slice(1), {
35
+ stdio: ["ignore", "pipe", "pipe"],
36
+ });
37
+ const exitCodePromise = new Promise((resolve) => {
38
+ spawnedProcess.on("close", (code) => resolve(code ?? 1));
39
+ });
40
+ const [stdout, stderr, exitCode] = await Promise.all([
41
+ readAndMirrorStream({
42
+ stream: spawnedProcess.stdout,
43
+ write: (chunk) => {
44
+ if (onStdout)
45
+ onStdout(chunk.toString("utf8"));
46
+ else
47
+ process.stdout.write(chunk);
48
+ },
49
+ }),
50
+ readAndMirrorStream({
51
+ stream: spawnedProcess.stderr,
52
+ write: (chunk) => {
53
+ if (onStderr)
54
+ onStderr(chunk.toString("utf8"));
55
+ else
56
+ process.stderr.write(chunk);
57
+ },
58
+ }),
59
+ exitCodePromise,
60
+ ]);
61
+ return {
62
+ blocked: false,
63
+ command,
64
+ cwd: workspaceCwd,
65
+ stdout,
66
+ stderr,
67
+ exitCode,
68
+ success: exitCode === 0,
69
+ };
70
+ }
71
+ async function readAndMirrorStream({ stream, write, }) {
72
+ if (stream == null) {
73
+ return "";
74
+ }
75
+ const chunks = [];
76
+ for await (const chunk of stream) {
77
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
78
+ chunks.push(buf);
79
+ write(buf);
80
+ }
81
+ return Buffer.concat(chunks).toString("utf8");
82
+ }
83
+ function createDockerArgs({ command, containerCwd, networkEnabled, workspaceRoot, }) {
84
+ return [
85
+ "docker",
86
+ "run",
87
+ "--rm",
88
+ "--interactive=false",
89
+ ...(networkEnabled ? [] : ["--network", "none"]),
90
+ "--env",
91
+ "CI=true",
92
+ "--volume",
93
+ `${workspaceRoot}:${CONTAINER_WORKSPACE_ROOT}`,
94
+ "--workdir",
95
+ containerCwd,
96
+ "--entrypoint",
97
+ "sh",
98
+ DOCKER_IMAGE,
99
+ "-lc",
100
+ command,
101
+ ];
102
+ }
103
+ function resolveExecPaths({ cwd, root = process.cwd(), }) {
104
+ const workspaceRoot = resolve(root);
105
+ const hostCwd = cwd ? resolve(workspaceRoot, cwd) : workspaceRoot;
106
+ const relativeCwd = relative(workspaceRoot, hostCwd);
107
+ if (relativeCwd === ".." || relativeCwd.startsWith(`..${sep}`)) {
108
+ throw new Error("Path must stay inside the current working directory");
109
+ }
110
+ const workspaceCwd = relativeCwd || ".";
111
+ const containerCwd = workspaceCwd === "."
112
+ ? CONTAINER_WORKSPACE_ROOT
113
+ : posix.join(CONTAINER_WORKSPACE_ROOT, workspaceCwd.split(sep).join(posix.sep));
114
+ return {
115
+ workspaceRoot,
116
+ workspaceCwd,
117
+ containerCwd,
118
+ };
119
+ }
120
+ const execInputSchema = jsonSchema({
121
+ type: "object",
122
+ properties: {
123
+ command: {
124
+ type: "string",
125
+ description: "Shell command to execute.",
126
+ },
127
+ cwd: {
128
+ type: "string",
129
+ description: "Working directory relative to the current workspace.",
130
+ },
131
+ networkEnabled: {
132
+ type: "boolean",
133
+ description: "Whether to allow outbound network access from the Docker sandbox.",
134
+ },
135
+ },
136
+ required: ["command", "networkEnabled"],
137
+ additionalProperties: false,
138
+ });
139
+ export function createExecTools({ root, onStdout, onStderr, confirm, } = {}) {
140
+ return {
141
+ exec: tool({
142
+ description: "Execute a shell command inside a Docker sandbox.",
143
+ inputSchema: execInputSchema,
144
+ execute: async (input) => execCommand({ ...input, root, onStdout, onStderr, confirm }),
145
+ }),
146
+ };
147
+ }
148
+ export const execTools = createExecTools();
@@ -0,0 +1,304 @@
1
+ import { spawn } from "node:child_process";
2
+ import { readFile as fsReadFile, writeFile as fsWriteFile, mkdir, } from "node:fs/promises";
3
+ import { dirname, relative, resolve, sep } from "node:path";
4
+ import { jsonSchema, tool } from "ai";
5
+ import { globby } from "globby";
6
+ export function resolveFilePath({ root = process.cwd(), filePath, }) {
7
+ const workspaceRoot = resolve(root);
8
+ const targetPath = resolve(workspaceRoot, filePath);
9
+ const relativePath = relative(workspaceRoot, targetPath);
10
+ if (relativePath === ".." || relativePath.startsWith(`..${sep}`)) {
11
+ throw new Error("Path must stay inside the current working directory");
12
+ }
13
+ return targetPath;
14
+ }
15
+ export async function listFiles({ path = ".", recursive = true, maxDepth = 3, root = process.cwd(), } = {}) {
16
+ if (maxDepth < 0) {
17
+ throw new Error("maxDepth must be greater than or equal to 0");
18
+ }
19
+ const targetPath = resolveFilePath({ filePath: path, root });
20
+ const basePath = toWorkspaceRelativePath({ targetPath, root });
21
+ const matches = await globby(recursive ? ["**/*"] : ["*"], {
22
+ cwd: targetPath,
23
+ deep: recursive ? maxDepth + 1 : 1,
24
+ expandDirectories: false,
25
+ markDirectories: true,
26
+ onlyFiles: false,
27
+ });
28
+ const entries = matches
29
+ .sort((left, right) => left.localeCompare(right))
30
+ .map((match) => {
31
+ const isDirectory = match.endsWith("/");
32
+ const relativePath = joinRelativePath({
33
+ basePath,
34
+ entryPath: isDirectory ? match.slice(0, -1) : match,
35
+ });
36
+ return {
37
+ path: relativePath,
38
+ type: isDirectory ? "directory" : "file",
39
+ };
40
+ });
41
+ return {
42
+ path: toWorkspaceRelativePath({ targetPath, root }),
43
+ recursive,
44
+ maxDepth,
45
+ entries,
46
+ };
47
+ }
48
+ export async function findFiles({ path = ".", pattern, glob, ignoreCase = false, maxResults = 100, root = process.cwd(), }) {
49
+ const targetPath = resolveFilePath({ filePath: path, root });
50
+ const args = [
51
+ "--no-heading",
52
+ "--line-number",
53
+ "--color",
54
+ "never",
55
+ ...(ignoreCase ? ["--ignore-case"] : []),
56
+ ...(glob ? ["--glob", glob] : []),
57
+ "--max-count",
58
+ String(maxResults),
59
+ "--",
60
+ pattern,
61
+ targetPath,
62
+ ];
63
+ const { stdout, exitCode } = await spawnRipgrep(args);
64
+ const matches = [];
65
+ if (exitCode === 0 && stdout !== "") {
66
+ for (const line of stdout.split("\n")) {
67
+ if (line === "")
68
+ continue;
69
+ const match = line.match(/^(.+?):(\d+):(.*)$/);
70
+ if (match == null)
71
+ continue;
72
+ matches.push({
73
+ path: toWorkspaceRelativePath({
74
+ targetPath: match[1],
75
+ root,
76
+ }),
77
+ line: Number(match[2]),
78
+ content: match[3],
79
+ });
80
+ }
81
+ }
82
+ const truncated = matches.length >= maxResults;
83
+ return {
84
+ pattern,
85
+ path: toWorkspaceRelativePath({ targetPath, root }),
86
+ totalMatches: matches.length,
87
+ truncated,
88
+ matches,
89
+ };
90
+ }
91
+ export async function readFile({ path, beginLine = 1, closeLine, root = process.cwd(), }) {
92
+ if (beginLine < 1) {
93
+ throw new Error("beginLine must be greater than or equal to 1");
94
+ }
95
+ if (closeLine !== undefined && closeLine < beginLine) {
96
+ throw new Error("closeLine must be greater than or equal to beginLine");
97
+ }
98
+ const targetPath = resolveFilePath({ filePath: path, root });
99
+ const text = await fsReadFile(targetPath, "utf8");
100
+ const lines = toLines({ content: text });
101
+ const totalLines = lines.length;
102
+ const resultBeginLine = totalLines === 0 ? 0 : beginLine;
103
+ const resultCloseLine = totalLines === 0 ? 0 : Math.min(closeLine ?? totalLines, totalLines);
104
+ const selectedLines = totalLines === 0
105
+ ? []
106
+ : lines.slice(Math.max(resultBeginLine - 1, 0), resultCloseLine);
107
+ return {
108
+ path: toWorkspaceRelativePath({ targetPath, root }),
109
+ totalLines,
110
+ beginLine: resultBeginLine,
111
+ closeLine: resultCloseLine,
112
+ content: selectedLines
113
+ .map((line, index) => `${resultBeginLine + index}|${line}`)
114
+ .join("\n"),
115
+ };
116
+ }
117
+ export async function editFile({ path, content, oldString, newString, root = process.cwd(), }) {
118
+ const targetPath = resolveFilePath({ filePath: path, root });
119
+ if (oldString != null && newString != null) {
120
+ const current = await fsReadFile(targetPath, "utf8");
121
+ const count = current.split(oldString).length - 1;
122
+ if (count === 0) {
123
+ throw new Error("oldString not found in file");
124
+ }
125
+ if (count > 1) {
126
+ throw new Error(`oldString found ${count} times in file — provide more context to make it unique`);
127
+ }
128
+ const nextContent = current.replace(oldString, newString);
129
+ await fsWriteFile(targetPath, nextContent, "utf8");
130
+ return {
131
+ path: toWorkspaceRelativePath({ targetPath, root }),
132
+ operation: "edit",
133
+ bytesWritten: Buffer.byteLength(nextContent, "utf8"),
134
+ };
135
+ }
136
+ if (content == null) {
137
+ throw new Error("Either content or oldString/newString must be provided");
138
+ }
139
+ await mkdir(dirname(targetPath), { recursive: true });
140
+ await fsWriteFile(targetPath, content, "utf8");
141
+ return {
142
+ path: toWorkspaceRelativePath({ targetPath, root }),
143
+ operation: "write",
144
+ bytesWritten: Buffer.byteLength(content, "utf8"),
145
+ };
146
+ }
147
+ function spawnRipgrep(args) {
148
+ return new Promise((resolve) => {
149
+ const proc = spawn("rg", args, { stdio: ["ignore", "pipe", "pipe"] });
150
+ const stdoutChunks = [];
151
+ const stderrChunks = [];
152
+ proc.stdout.on("data", (chunk) => stdoutChunks.push(chunk));
153
+ proc.stderr.on("data", (chunk) => stderrChunks.push(chunk));
154
+ proc.on("close", (code) => {
155
+ resolve({
156
+ stdout: Buffer.concat(stdoutChunks).toString("utf8"),
157
+ stderr: Buffer.concat(stderrChunks).toString("utf8"),
158
+ exitCode: code ?? 1,
159
+ });
160
+ });
161
+ });
162
+ }
163
+ export function createFileTools({ root } = {}) {
164
+ return {
165
+ listFiles: tool({
166
+ description: "List files and directories.",
167
+ inputSchema: listFilesInputSchema,
168
+ execute: async (input) => listFiles({ ...input, root }),
169
+ }),
170
+ findFiles: tool({
171
+ description: "Find files and directories.",
172
+ inputSchema: findFilesInputSchema,
173
+ execute: async (input) => findFiles({ ...input, root }),
174
+ }),
175
+ readFile: tool({
176
+ description: "Read a file with optional begin and close lines.",
177
+ inputSchema: readFileInputSchema,
178
+ execute: async (input) => readFile({ ...input, root }),
179
+ }),
180
+ editFile: tool({
181
+ description: "Write or edit a file. To create/overwrite, provide content. To edit an existing file, provide oldString and newString for search-and-replace. oldString must match exactly one location in the file.",
182
+ inputSchema: editFileInputSchema,
183
+ execute: async (input) => editFile({ ...input, root }),
184
+ }),
185
+ };
186
+ }
187
+ function toWorkspaceRelativePath({ targetPath, root, }) {
188
+ const relativePath = relative(resolve(root), targetPath);
189
+ return relativePath === "" ? "." : relativePath;
190
+ }
191
+ function joinRelativePath({ basePath, entryPath, }) {
192
+ if (basePath === ".") {
193
+ return entryPath;
194
+ }
195
+ return `${basePath}/${entryPath}`;
196
+ }
197
+ function toLines({ content }) {
198
+ if (content === "") {
199
+ return [];
200
+ }
201
+ const lines = content.split(/\r?\n/u);
202
+ if (content.endsWith("\n") || content.endsWith("\r")) {
203
+ lines.pop();
204
+ }
205
+ return lines;
206
+ }
207
+ const listFilesInputSchema = jsonSchema({
208
+ type: "object",
209
+ properties: {
210
+ path: {
211
+ type: "string",
212
+ description: "Directory path relative to the current workspace.",
213
+ },
214
+ recursive: {
215
+ type: "boolean",
216
+ description: "Whether to include nested files and directories.",
217
+ default: true,
218
+ },
219
+ maxDepth: {
220
+ type: "integer",
221
+ description: "Maximum directory depth when recursive is true.",
222
+ minimum: 0,
223
+ default: 3,
224
+ },
225
+ },
226
+ additionalProperties: false,
227
+ });
228
+ const findFilesInputSchema = jsonSchema({
229
+ type: "object",
230
+ properties: {
231
+ path: {
232
+ type: "string",
233
+ description: "Directory path relative to the current workspace.",
234
+ default: ".",
235
+ },
236
+ pattern: {
237
+ type: "string",
238
+ description: "Regex pattern to search for in file contents.",
239
+ },
240
+ glob: {
241
+ type: "string",
242
+ description: 'Glob pattern to filter files (e.g. "*.ts", "*.{js,jsx}").',
243
+ },
244
+ ignoreCase: {
245
+ type: "boolean",
246
+ description: "Whether to perform case-insensitive matching.",
247
+ default: false,
248
+ },
249
+ maxResults: {
250
+ type: "integer",
251
+ description: "Maximum number of matching lines per file.",
252
+ minimum: 1,
253
+ default: 100,
254
+ },
255
+ },
256
+ required: ["pattern"],
257
+ additionalProperties: false,
258
+ });
259
+ const readFileInputSchema = jsonSchema({
260
+ type: "object",
261
+ properties: {
262
+ path: {
263
+ type: "string",
264
+ description: "File path relative to the current workspace.",
265
+ },
266
+ beginLine: {
267
+ type: "integer",
268
+ description: "1-based begin line number, inclusive.",
269
+ minimum: 1,
270
+ default: 1,
271
+ },
272
+ closeLine: {
273
+ type: "integer",
274
+ description: "1-based close line number, inclusive.",
275
+ minimum: 1,
276
+ },
277
+ },
278
+ required: ["path"],
279
+ additionalProperties: false,
280
+ });
281
+ const editFileInputSchema = jsonSchema({
282
+ type: "object",
283
+ properties: {
284
+ path: {
285
+ type: "string",
286
+ description: "File path relative to the current workspace.",
287
+ },
288
+ content: {
289
+ type: "string",
290
+ description: "Full file content to write. Use this to create a new file or overwrite an existing file.",
291
+ },
292
+ oldString: {
293
+ type: "string",
294
+ description: "The exact text to find in the file for search-and-replace editing. Must match exactly one location.",
295
+ },
296
+ newString: {
297
+ type: "string",
298
+ description: "The replacement text. Used together with oldString.",
299
+ },
300
+ },
301
+ required: ["path"],
302
+ additionalProperties: false,
303
+ });
304
+ export const fileTools = createFileTools();
@@ -0,0 +1,58 @@
1
+ import { jsonSchema, tool } from "ai";
2
+ export async function fetchPage({ url, maxLength = 50000, headers, }) {
3
+ if (!/^https?:\/\//i.test(url)) {
4
+ throw new Error("URL must start with http:// or https://");
5
+ }
6
+ const response = await fetch(url, {
7
+ headers: {
8
+ "User-Agent": "micoli/1.0",
9
+ Accept: "text/html,application/xhtml+xml,text/plain,application/json",
10
+ ...headers,
11
+ },
12
+ redirect: "follow",
13
+ signal: AbortSignal.timeout(30_000),
14
+ });
15
+ const contentType = response.headers.get("content-type") ?? "";
16
+ const text = await response.text();
17
+ const truncated = text.length > maxLength;
18
+ const content = truncated ? text.slice(0, maxLength) : text;
19
+ return {
20
+ url: response.url,
21
+ status: response.status,
22
+ contentType,
23
+ truncated,
24
+ content,
25
+ };
26
+ }
27
+ const fetchPageInputSchema = jsonSchema({
28
+ type: "object",
29
+ properties: {
30
+ url: {
31
+ type: "string",
32
+ description: "URL to fetch. Must start with http:// or https://.",
33
+ },
34
+ maxLength: {
35
+ type: "integer",
36
+ description: "Maximum character length of the returned content.",
37
+ minimum: 1,
38
+ default: 50000,
39
+ },
40
+ headers: {
41
+ type: "object",
42
+ description: "Additional HTTP headers to send with the request.",
43
+ additionalProperties: { type: "string" },
44
+ },
45
+ },
46
+ required: ["url"],
47
+ additionalProperties: false,
48
+ });
49
+ export function createWebTools() {
50
+ return {
51
+ fetchPage: tool({
52
+ description: "Fetch a web page and return its content as text. Supports HTML, JSON, and plain text.",
53
+ inputSchema: fetchPageInputSchema,
54
+ execute: async (input) => fetchPage(input),
55
+ }),
56
+ };
57
+ }
58
+ export const webTools = createWebTools();
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "micoli",
3
+ "version": "0.0.1",
4
+ "description": "a mini mini experimental coding agent",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/andraindrops/micoli.git"
10
+ },
11
+ "files": [
12
+ "dist/",
13
+ "README.md"
14
+ ],
15
+ "bin": {
16
+ "micoli": "./dist/index.js"
17
+ },
18
+ "scripts": {
19
+ "lint": "biome check .",
20
+ "test": "vitest run",
21
+ "format": "biome check --fix .",
22
+ "start": "dotenvx run --env-file=.env -- tsx src/index.tsx",
23
+ "build": "tsc",
24
+ "prepublishOnly": "npm run build"
25
+ },
26
+ "devDependencies": {
27
+ "@biomejs/biome": "^2.4.6",
28
+ "@dotenvx/dotenvx": "^1.40.1",
29
+ "@types/json-schema": "^7.0.15",
30
+ "@types/node": "^22.15.0",
31
+ "@types/react": "^19.2.14",
32
+ "msw": "^2.12.13",
33
+ "tsx": "^4.19.0",
34
+ "vitest": "^3.2.1"
35
+ },
36
+ "peerDependencies": {
37
+ "typescript": "^5"
38
+ },
39
+ "dependencies": {
40
+ "@openrouter/ai-sdk-provider": "^2.3.0",
41
+ "ai": "^6.0.116",
42
+ "diff": "^8.0.3",
43
+ "globby": "^16.1.1",
44
+ "ink": "^6.8.0",
45
+ "ink-text-input": "^6.0.0",
46
+ "outdent": "^0.8.0",
47
+ "react": "^19.2.4"
48
+ }
49
+ }