code-ollama 0.0.0 → 0.1.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/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Menglin "Mark" Xu <mark@remarkablemark.org>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,64 @@
1
+ # code-ollama
2
+
3
+ [![NPM](https://nodei.co/npm/code-ollama.svg)](https://www.npmjs.com/package/code-ollama)
4
+
5
+ [![NPM version](https://img.shields.io/npm/v/code-ollama.svg)](https://www.npmjs.com/package/code-ollama)
6
+ [![build](https://github.com/ai-action/code-ollama/actions/workflows/build.yml/badge.svg)](https://github.com/ai-action/code-ollama/actions/workflows/build.yml)
7
+ [![codecov](https://codecov.io/gh/ai-action/code-ollama/graph/badge.svg?token=gRGUasRn2k)](https://codecov.io/gh/ai-action/code-ollama)
8
+
9
+ 🦙 [Ollama](https://ollama.com/) coding agent that runs in your terminal.
10
+
11
+ ## Quick Start
12
+
13
+ ```sh
14
+ npx code-ollama
15
+ ```
16
+
17
+ ## Install
18
+
19
+ Install the [CLI](https://www.npmjs.com/package/code-ollama) globally:
20
+
21
+ ```sh
22
+ npm install --global code-ollama
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ### TUI
28
+
29
+ Open the TUI:
30
+
31
+ ```sh
32
+ code-ollama
33
+ ```
34
+
35
+ Or use the alias:
36
+
37
+ ```sh
38
+ collama
39
+ ```
40
+
41
+ ### CLI
42
+
43
+ Show the version:
44
+
45
+ ```sh
46
+ code-ollama --version
47
+ ```
48
+
49
+ Show the help:
50
+
51
+ ```sh
52
+ code-ollama --help
53
+ ```
54
+
55
+ Run a one-off prompt:
56
+
57
+ ```sh
58
+ # code-ollama run <model> <prompt>
59
+ code-ollama run gemma4 "review diff"
60
+ ```
61
+
62
+ ## License
63
+
64
+ [MIT](https://github.com/ai-action/code-ollama/blob/master/LICENSE)
@@ -0,0 +1,438 @@
1
+ import { a as streamChat, c as createSystemMessage, i as listModels, l as ROLE, n as TOOLS_REQUIRING_APPROVAL, o as loadConfig, r as executeTool, s as saveConfig, t as TOOLS, u as VERSION } from "../cli.js";
2
+ import { homedir } from "node:os";
3
+ import { Box, Text, render, useInput } from "ink";
4
+ import { useCallback, useEffect, useState } from "react";
5
+ import { Select, Spinner, TextInput } from "@inkjs/ui";
6
+ import { jsx, jsxs } from "react/jsx-runtime";
7
+ //#region src/constants/commands.ts
8
+ var COMMANDS = [{
9
+ name: "/model",
10
+ description: "switch the model"
11
+ }];
12
+ //#endregion
13
+ //#region src/constants/ui.ts
14
+ var HEADER_PREFIX = "🦙";
15
+ //#endregion
16
+ //#region src/components/Autocomplete.tsx
17
+ function getMatches(input) {
18
+ if (!input.startsWith("/")) return [];
19
+ return COMMANDS.filter((command) => command.name.startsWith(input));
20
+ }
21
+ function Autocomplete({ isDisabled = false, onSubmit }) {
22
+ const [value, setValue] = useState("");
23
+ const [selectedIndex, setSelectedIndex] = useState(0);
24
+ const [inputKey, setInputKey] = useState(0);
25
+ const matches = getMatches(value);
26
+ const isCommandMode = value.startsWith("/");
27
+ useInput((_char, key) => {
28
+ // v8 ignore next
29
+ if (!isCommandMode) return;
30
+ if (key.upArrow) {
31
+ setSelectedIndex((i) => Math.max(0, i - 1));
32
+ return;
33
+ }
34
+ if (key.downArrow) {
35
+ setSelectedIndex((i) => Math.min(matches.length - 1, i + 1));
36
+ return;
37
+ }
38
+ if (key.tab && matches.length > 0) {
39
+ setValue((matches[selectedIndex] ?? matches[0]).name);
40
+ setSelectedIndex(0);
41
+ setInputKey((key) => key + 1);
42
+ return;
43
+ }
44
+ }, { isActive: !isDisabled && isCommandMode });
45
+ const handleSubmit = useCallback((input) => {
46
+ const trimmed = (isCommandMode && matches.length > 0 && matches[selectedIndex] ? matches[selectedIndex].name : input).trim();
47
+ if (trimmed) {
48
+ onSubmit(trimmed);
49
+ setValue("");
50
+ setSelectedIndex(0);
51
+ setInputKey((key) => key + 1);
52
+ }
53
+ }, [
54
+ isCommandMode,
55
+ matches,
56
+ onSubmit,
57
+ selectedIndex
58
+ ]);
59
+ return /* @__PURE__ */ jsxs(Box, {
60
+ flexDirection: "column",
61
+ children: [/* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(Text, { children: "> " }), /* @__PURE__ */ jsx(TextInput, {
62
+ isDisabled,
63
+ defaultValue: value,
64
+ onChange: setValue,
65
+ onSubmit: handleSubmit
66
+ }, inputKey)] }), isCommandMode && matches.length > 0 && /* @__PURE__ */ jsx(Box, {
67
+ flexDirection: "column",
68
+ marginLeft: 2,
69
+ children: matches.map((command, index) => {
70
+ const isHighlighted = index === selectedIndex;
71
+ return /* @__PURE__ */ jsxs(Box, {
72
+ gap: 3,
73
+ children: [/* @__PURE__ */ jsx(Text, {
74
+ color: isHighlighted ? "cyan" : void 0,
75
+ bold: isHighlighted,
76
+ children: command.name
77
+ }), /* @__PURE__ */ jsx(Text, {
78
+ dimColor: true,
79
+ children: command.description
80
+ })]
81
+ }, command.name);
82
+ })
83
+ })]
84
+ });
85
+ }
86
+ //#endregion
87
+ //#region src/components/Messages.tsx
88
+ function getMessageColor(role) {
89
+ switch (role) {
90
+ case ROLE.USER: return "black";
91
+ case ROLE.ASSISTANT: return "blue";
92
+ case ROLE.SYSTEM: return "gray";
93
+ default: return;
94
+ }
95
+ }
96
+ function Messages({ messages, isLoading }) {
97
+ return /* @__PURE__ */ jsxs(Box, {
98
+ flexDirection: "column",
99
+ children: [messages.map((message, index) => /* @__PURE__ */ jsx(Box, {
100
+ marginBottom: 1,
101
+ children: /* @__PURE__ */ jsxs(Text, {
102
+ color: getMessageColor(message.role),
103
+ dimColor: message.role === ROLE.SYSTEM,
104
+ children: [message.role === ROLE.USER ? "> " : "", message.content]
105
+ })
106
+ }, index)), isLoading && messages[messages.length - 1]?.content === "" && /* @__PURE__ */ jsx(Box, {
107
+ marginTop: -1,
108
+ marginBottom: 1,
109
+ children: /* @__PURE__ */ jsx(Spinner, { label: "Thinking..." })
110
+ })]
111
+ });
112
+ }
113
+ //#endregion
114
+ //#region src/components/ToolApproval.tsx
115
+ function ToolApproval({ toolCall, onApprove, onReject }) {
116
+ const [selected, setSelected] = useState("yes");
117
+ useInput((_, key) => {
118
+ if (key.return) if (selected === "yes") onApprove();
119
+ else onReject();
120
+ else if (key.leftArrow || key.rightArrow) setSelected((prev) => prev === "yes" ? "no" : "yes");
121
+ // v8 ignore stop
122
+ });
123
+ const args = JSON.stringify(toolCall.function.arguments, null, 2);
124
+ return /* @__PURE__ */ jsxs(Box, {
125
+ flexDirection: "column",
126
+ marginY: 1,
127
+ children: [
128
+ /* @__PURE__ */ jsx(Text, {
129
+ color: "yellow",
130
+ bold: true,
131
+ children: "⚠️ Tool requires approval:"
132
+ }),
133
+ /* @__PURE__ */ jsxs(Box, {
134
+ marginX: 2,
135
+ flexDirection: "column",
136
+ children: [/* @__PURE__ */ jsxs(Text, { children: [
137
+ /* @__PURE__ */ jsx(Text, {
138
+ bold: true,
139
+ children: "Tool:"
140
+ }),
141
+ " ",
142
+ toolCall.function.name
143
+ ] }), /* @__PURE__ */ jsxs(Text, { children: [
144
+ /* @__PURE__ */ jsx(Text, {
145
+ bold: true,
146
+ children: "Arguments:"
147
+ }),
148
+ " ",
149
+ args
150
+ ] })]
151
+ }),
152
+ /* @__PURE__ */ jsxs(Box, {
153
+ marginTop: 1,
154
+ gap: 2,
155
+ children: [/* @__PURE__ */ jsx(Text, { children: /* @__PURE__ */ jsxs(Text, {
156
+ color: selected === "yes" ? "green" : void 0,
157
+ children: [selected === "yes" ? "▶ " : " ", "✓ Yes (Enter)"]
158
+ }) }), /* @__PURE__ */ jsx(Text, { children: /* @__PURE__ */ jsxs(Text, {
159
+ color: selected === "no" ? "red" : void 0,
160
+ children: [selected === "no" ? "▶ " : " ", "✗ No (Esc)"]
161
+ }) })]
162
+ })
163
+ ]
164
+ });
165
+ }
166
+ //#endregion
167
+ //#region src/components/Chat.tsx
168
+ function Chat({ model, onCommand, autoExecute }) {
169
+ const [messages, setMessages] = useState([createSystemMessage()]);
170
+ const [submitKey, setSubmitKey] = useState(0);
171
+ const [isLoading, setIsLoading] = useState(false);
172
+ const [pendingToolCall, setPendingToolCall] = useState(null);
173
+ const processStream = useCallback(async (currentMessages) => {
174
+ const assistantMessage = {
175
+ role: ROLE.ASSISTANT,
176
+ content: ""
177
+ };
178
+ setMessages((previousMessages) => [...previousMessages, assistantMessage]);
179
+ try {
180
+ for await (const chunk of streamChat(currentMessages, model, TOOLS)) if (chunk.type === "content") {
181
+ assistantMessage.content += chunk.content;
182
+ setMessages((previousMessages) => {
183
+ const newMessages = [...previousMessages];
184
+ newMessages[newMessages.length - 1] = { ...assistantMessage };
185
+ return newMessages;
186
+ });
187
+ } else if (chunk.type === "tool_calls") for (const toolCall of chunk.tool_calls) {
188
+ const requiresApproval = TOOLS_REQUIRING_APPROVAL.has(toolCall.function.name);
189
+ if (!autoExecute && requiresApproval) {
190
+ setPendingToolCall(toolCall);
191
+ setIsLoading(false);
192
+ return;
193
+ }
194
+ const result = await executeTool(toolCall.function.name, toolCall.function.arguments);
195
+ const toolResultMessage = {
196
+ role: ROLE.SYSTEM,
197
+ content: `Tool ${toolCall.function.name} result:\n${result.content}${result.error ? `\nError: ${result.error}` : ""}`
198
+ };
199
+ const newMessages = [
200
+ ...currentMessages,
201
+ assistantMessage,
202
+ toolResultMessage
203
+ ];
204
+ setMessages((previousMessages) => [...previousMessages, toolResultMessage]);
205
+ await processStream(newMessages);
206
+ return;
207
+ }
208
+ } catch (error) {
209
+ assistantMessage.content = `Error: ${error instanceof Error ? error.message : String(error)}`;
210
+ setMessages((previousMessages) => {
211
+ const newMessages = [...previousMessages];
212
+ newMessages[newMessages.length - 1] = { ...assistantMessage };
213
+ return newMessages;
214
+ });
215
+ } finally {
216
+ setIsLoading(false);
217
+ }
218
+ }, [model, autoExecute]);
219
+ const handleToolApproval = useCallback(async (approved) => {
220
+ // v8 ignore next
221
+ if (!pendingToolCall) return;
222
+ const toolCall = pendingToolCall;
223
+ setPendingToolCall(null);
224
+ setIsLoading(true);
225
+ if (approved) {
226
+ const result = await executeTool(toolCall.function.name, toolCall.function.arguments);
227
+ const toolResultMessage = {
228
+ role: ROLE.SYSTEM,
229
+ content: `Tool ${toolCall.function.name} result:\n${result.content}${result.error ? `\nError: ${result.error}` : ""}`
230
+ };
231
+ const newMessages = [...messages, toolResultMessage];
232
+ setMessages((previousMessages) => [...previousMessages, toolResultMessage]);
233
+ await processStream(newMessages);
234
+ } else {
235
+ const rejectionMessage = {
236
+ role: ROLE.SYSTEM,
237
+ content: `User declined to execute tool ${toolCall.function.name}`
238
+ };
239
+ const newMessages = [...messages, rejectionMessage];
240
+ setMessages((previousMessages) => [...previousMessages, rejectionMessage]);
241
+ await processStream(newMessages);
242
+ }
243
+ }, [
244
+ pendingToolCall,
245
+ messages,
246
+ processStream
247
+ ]);
248
+ const handleSubmit = useCallback(async (value) => {
249
+ const userContent = value.trim();
250
+ if (!userContent) return;
251
+ setSubmitKey((key) => key + 1);
252
+ if (userContent.startsWith("/")) {
253
+ onCommand(userContent);
254
+ return;
255
+ }
256
+ setIsLoading(true);
257
+ const userMessage = {
258
+ role: ROLE.USER,
259
+ content: userContent
260
+ };
261
+ setMessages((previousMessages) => [...previousMessages, userMessage]);
262
+ await processStream([...messages, userMessage]);
263
+ }, [
264
+ messages,
265
+ onCommand,
266
+ processStream
267
+ ]);
268
+ return /* @__PURE__ */ jsxs(Box, {
269
+ flexDirection: "column",
270
+ children: [
271
+ /* @__PURE__ */ jsx(Messages, {
272
+ messages: messages.slice(1),
273
+ isLoading
274
+ }),
275
+ pendingToolCall && /* @__PURE__ */ jsx(ToolApproval, {
276
+ toolCall: pendingToolCall,
277
+ onApprove: () => void handleToolApproval(true),
278
+ onReject: () => void handleToolApproval(false)
279
+ }),
280
+ !pendingToolCall && /* @__PURE__ */ jsx(Autocomplete, {
281
+ isDisabled: isLoading,
282
+ onSubmit: (val) => {
283
+ handleSubmit(val);
284
+ }
285
+ }, submitKey)
286
+ ]
287
+ });
288
+ }
289
+ //#endregion
290
+ //#region src/components/Footer.tsx
291
+ function Footer({ autoExecute, onToggleMode }) {
292
+ useInput((_, key) => {
293
+ if (key.tab && key.shift) onToggleMode();
294
+ });
295
+ return /* @__PURE__ */ jsx(Box, {
296
+ justifyContent: "space-between",
297
+ marginTop: 1,
298
+ children: /* @__PURE__ */ jsxs(Text, {
299
+ dimColor: true,
300
+ children: [
301
+ "Mode: ",
302
+ autoExecute ? "Auto" : "Safe",
303
+ " (Shift+Tab to toggle)"
304
+ ]
305
+ })
306
+ });
307
+ }
308
+ //#endregion
309
+ //#region src/components/Header.tsx
310
+ function abbreviatePath(dir) {
311
+ const home = homedir();
312
+ return dir.startsWith(home) ? `~${dir.slice(home.length)}` : dir;
313
+ }
314
+ function Header({ model }) {
315
+ const directory = abbreviatePath(process.cwd());
316
+ return /* @__PURE__ */ jsxs(Box, {
317
+ borderStyle: "round",
318
+ flexDirection: "column",
319
+ paddingX: 1,
320
+ children: [
321
+ /* @__PURE__ */ jsxs(Text, { children: [/* @__PURE__ */ jsxs(Text, {
322
+ bold: true,
323
+ children: [HEADER_PREFIX, "Code Ollama"]
324
+ }), /* @__PURE__ */ jsxs(Text, {
325
+ dimColor: true,
326
+ children: [
327
+ " (v",
328
+ VERSION,
329
+ ")"
330
+ ]
331
+ })] }),
332
+ /* @__PURE__ */ jsx(Text, { children: " " }),
333
+ /* @__PURE__ */ jsxs(Box, { children: [
334
+ /* @__PURE__ */ jsx(Text, {
335
+ dimColor: true,
336
+ children: "model:".padEnd(11)
337
+ }),
338
+ /* @__PURE__ */ jsxs(Text, { children: [model, " "] }),
339
+ /* @__PURE__ */ jsx(Text, {
340
+ color: "cyan",
341
+ children: "/model"
342
+ }),
343
+ /* @__PURE__ */ jsx(Text, {
344
+ dimColor: true,
345
+ children: " to switch"
346
+ })
347
+ ] }),
348
+ /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(Text, {
349
+ dimColor: true,
350
+ children: "directory:".padEnd(11)
351
+ }), /* @__PURE__ */ jsx(Text, { children: directory })] })
352
+ ]
353
+ });
354
+ }
355
+ //#endregion
356
+ //#region src/components/ModelPicker.tsx
357
+ function ModelPicker({ currentModel, onSelect, onCancel }) {
358
+ const [options, setOptions] = useState([]);
359
+ const [error, setError] = useState(null);
360
+ useEffect(() => {
361
+ async function load() {
362
+ try {
363
+ setOptions((await listModels()).map((name) => ({
364
+ label: name,
365
+ value: name
366
+ })));
367
+ } catch (err) {
368
+ setError(err instanceof Error ? err.message : String(err));
369
+ }
370
+ }
371
+ load();
372
+ }, []);
373
+ useInput((_, key) => {
374
+ if (key.escape) onCancel();
375
+ });
376
+ if (error) return /* @__PURE__ */ jsxs(Text, {
377
+ color: "red",
378
+ children: ["Error loading models: ", error]
379
+ });
380
+ if (!options.length) return /* @__PURE__ */ jsx(Spinner, { label: "Loading models..." });
381
+ return /* @__PURE__ */ jsxs(Box, {
382
+ flexDirection: "column",
383
+ children: [/* @__PURE__ */ jsx(Text, {
384
+ dimColor: true,
385
+ children: "Select a model (↑↓ + Enter to confirm, Esc to cancel)"
386
+ }), /* @__PURE__ */ jsx(Select, {
387
+ options,
388
+ defaultValue: currentModel,
389
+ onChange: onSelect
390
+ })]
391
+ });
392
+ }
393
+ //#endregion
394
+ //#region src/components/App.tsx
395
+ function App() {
396
+ const [model, setModel] = useState(() => loadConfig().model);
397
+ const [picking, setPicking] = useState(false);
398
+ const [autoExecute, setAutoExecute] = useState(false);
399
+ const handleCommand = useCallback((command) => {
400
+ if (command === "/model") setPicking(true);
401
+ }, []);
402
+ const handleSelect = useCallback((selected) => {
403
+ setModel(selected);
404
+ saveConfig({ model: selected });
405
+ setPicking(false);
406
+ }, []);
407
+ const handleCancel = useCallback(() => {
408
+ setPicking(false);
409
+ }, []);
410
+ return /* @__PURE__ */ jsxs(Box, {
411
+ flexDirection: "column",
412
+ children: [
413
+ /* @__PURE__ */ jsx(Header, { model }),
414
+ picking ? /* @__PURE__ */ jsx(ModelPicker, {
415
+ currentModel: model,
416
+ onSelect: handleSelect,
417
+ onCancel: handleCancel
418
+ }) : /* @__PURE__ */ jsx(Chat, {
419
+ model,
420
+ onCommand: handleCommand,
421
+ autoExecute
422
+ }),
423
+ /* @__PURE__ */ jsx(Footer, {
424
+ autoExecute,
425
+ onToggleMode: () => {
426
+ setAutoExecute((isAutoExecute) => !isAutoExecute);
427
+ }
428
+ })
429
+ ]
430
+ });
431
+ }
432
+ //#endregion
433
+ //#region src/tui.tsx
434
+ function renderApp() {
435
+ render(/* @__PURE__ */ jsx(App, {}));
436
+ }
437
+ //#endregion
438
+ export { renderApp };
package/dist/cli.js ADDED
@@ -0,0 +1,448 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "node:fs";
3
+ import cac from "cac";
4
+ import { join } from "node:path";
5
+ import { homedir } from "node:os";
6
+ import { Ollama } from "ollama";
7
+ import { exec } from "node:child_process";
8
+ import { promisify } from "node:util";
9
+ //#endregion
10
+ //#region src/constants/package.ts
11
+ var VERSION = "0.1.1";
12
+ //#endregion
13
+ //#region src/constants/prompt.ts
14
+ var BASE_SYSTEM_PROMPT = `You are a coding assistant that helps users write, edit, and understand code. You have access to tools for reading files, writing files, running shell commands, and searching code
15
+
16
+ Follow these rules:
17
+ 1. Always use available tools rather than guessing file contents or code behavior
18
+ 2. Read files before editing them to understand context
19
+ 3. When writing files, provide complete, working code
20
+ 4. Explain your reasoning when making non-trivial changes
21
+ 5. Prefer minimal changes that achieve the goal
22
+ 6. Confirm with the user before destructive operations
23
+
24
+ When tools return results, incorporate them into your response naturally`;
25
+ var TOOL_INSTRUCTIONS = `Available tools:
26
+ - read_file: Read file contents at a path
27
+ - write_file: Write content to a file (requires approval)
28
+ - edit_file: Make precise edits to a file
29
+ - list_dir: List files in a directory
30
+ - grep_search: Search code with regex
31
+ - run_shell: Execute shell commands (requires approval)
32
+
33
+ Always use tools when you need to:
34
+ - Check file contents before referencing them
35
+ - Make file changes
36
+ - Explore project structure
37
+ - Search the codebase`;
38
+ //#endregion
39
+ //#region src/constants/role.ts
40
+ var ROLE = {
41
+ USER: "user",
42
+ ASSISTANT: "assistant",
43
+ SYSTEM: "system"
44
+ };
45
+ //#endregion
46
+ //#region src/constants/tool.ts
47
+ var NAME = {
48
+ READ_FILE: "read_file",
49
+ WRITE_FILE: "write_file",
50
+ RUN_SHELL: "run_shell",
51
+ LIST_DIR: "list_dir",
52
+ GREP_SEARCH: "grep_search",
53
+ VIEW_RANGE: "view_range"
54
+ };
55
+ //#endregion
56
+ //#region src/utils/agents.ts
57
+ var AGENTS_FILE = "AGENTS.md";
58
+ function loadAgentsContent() {
59
+ const agentsPath = join(process.cwd(), AGENTS_FILE);
60
+ if (!existsSync(agentsPath)) return null;
61
+ try {
62
+ return readFileSync(agentsPath, "utf8");
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+ function buildSystemPrompt() {
68
+ const parts = [BASE_SYSTEM_PROMPT];
69
+ const agentsContent = loadAgentsContent();
70
+ if (agentsContent) parts.push("\n\nProject context from AGENTS.md:\n", agentsContent);
71
+ parts.push("\n\n", TOOL_INSTRUCTIONS);
72
+ return parts.join("");
73
+ }
74
+ function createSystemMessage() {
75
+ return {
76
+ role: ROLE.SYSTEM,
77
+ content: buildSystemPrompt()
78
+ };
79
+ }
80
+ //#endregion
81
+ //#region src/utils/config.ts
82
+ var CONFIG_DIR = join(homedir(), ".code-ollama");
83
+ var CONFIG_PATH = join(CONFIG_DIR, "config.json");
84
+ var DEFAULTS = {
85
+ host: "http://localhost:11434",
86
+ model: "gemma4"
87
+ };
88
+ function readFile$1() {
89
+ if (!existsSync(CONFIG_PATH)) return {};
90
+ try {
91
+ return JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
92
+ } catch {
93
+ return {};
94
+ }
95
+ }
96
+ function loadConfig() {
97
+ const file = readFile$1();
98
+ return {
99
+ host: process.env.OLLAMA_HOST ?? file.host ?? DEFAULTS.host,
100
+ model: process.env.OLLAMA_MODEL ?? file.model ?? DEFAULTS.model
101
+ };
102
+ }
103
+ function saveConfig(patch) {
104
+ const updated = {
105
+ ...readFile$1(),
106
+ ...patch
107
+ };
108
+ mkdirSync(CONFIG_DIR, { recursive: true });
109
+ writeFileSync(CONFIG_PATH, JSON.stringify(updated, null, 2) + "\n", "utf8");
110
+ }
111
+ //#endregion
112
+ //#region src/utils/ollama.ts
113
+ var { host, model: DEFAULT_MODEL } = loadConfig();
114
+ var client = new Ollama({ host });
115
+ async function* streamChat(messages, model = DEFAULT_MODEL, tools) {
116
+ const response = await client.chat({
117
+ model,
118
+ messages,
119
+ stream: true,
120
+ tools
121
+ });
122
+ for await (const chunk of response) {
123
+ if (chunk.message.content) yield {
124
+ type: "content",
125
+ content: chunk.message.content
126
+ };
127
+ if (chunk.message.tool_calls) yield {
128
+ type: "tool_calls",
129
+ tool_calls: chunk.message.tool_calls
130
+ };
131
+ }
132
+ }
133
+ async function listModels() {
134
+ const { models } = await client.list();
135
+ return models.map(({ name }) => name);
136
+ }
137
+ //#endregion
138
+ //#region src/utils/screen.ts
139
+ var CLEAR = "\x1Bc";
140
+ function clear() {
141
+ process.stdout.write(CLEAR);
142
+ }
143
+ //#endregion
144
+ //#region src/utils/tools.ts
145
+ var execAsync = promisify(exec);
146
+ /**
147
+ * Helper to define tool parameters
148
+ */
149
+ function defineTool(name, description, params, required) {
150
+ return {
151
+ type: "function",
152
+ function: {
153
+ name,
154
+ description,
155
+ parameters: {
156
+ type: "object",
157
+ properties: params,
158
+ required
159
+ }
160
+ }
161
+ };
162
+ }
163
+ /**
164
+ * Tool definitions for Ollama API
165
+ */
166
+ var TOOLS = [
167
+ defineTool(NAME.READ_FILE, "Read the contents of a file at the specified path", { path: {
168
+ type: "string",
169
+ description: "The path to the file to read"
170
+ } }, ["path"]),
171
+ defineTool(NAME.WRITE_FILE, "Write content to a file at the specified path", {
172
+ path: {
173
+ type: "string",
174
+ description: "The path to the file to write"
175
+ },
176
+ content: {
177
+ type: "string",
178
+ description: "The content to write to the file"
179
+ }
180
+ }, ["path", "content"]),
181
+ defineTool(NAME.RUN_SHELL, "Execute a shell command", { command: {
182
+ type: "string",
183
+ description: "The shell command to execute"
184
+ } }, ["command"]),
185
+ defineTool(NAME.LIST_DIR, "List the contents of a directory", { path: {
186
+ type: "string",
187
+ description: "The path to the directory to list"
188
+ } }, ["path"]),
189
+ defineTool(NAME.GREP_SEARCH, "Search for a pattern in files within a directory", {
190
+ pattern: {
191
+ type: "string",
192
+ description: "The regex pattern to search for"
193
+ },
194
+ path: {
195
+ type: "string",
196
+ description: "The directory path to search in"
197
+ }
198
+ }, ["pattern", "path"]),
199
+ defineTool(NAME.VIEW_RANGE, "View a specific range of lines from a file", {
200
+ path: {
201
+ type: "string",
202
+ description: "The path to the file"
203
+ },
204
+ start: {
205
+ type: "number",
206
+ description: "The starting line number (1-indexed)"
207
+ },
208
+ end: {
209
+ type: "number",
210
+ description: "The ending line number (inclusive)"
211
+ }
212
+ }, [
213
+ "path",
214
+ "start",
215
+ "end"
216
+ ])
217
+ ];
218
+ var TOOLS_REQUIRING_APPROVAL = new Set([NAME.WRITE_FILE, NAME.RUN_SHELL]);
219
+ /**
220
+ * Execute a tool by name with arguments
221
+ */
222
+ async function executeTool(name, args) {
223
+ switch (name) {
224
+ case NAME.READ_FILE: return readFile(args.path);
225
+ case NAME.WRITE_FILE: return writeFile(args.path, args.content);
226
+ case NAME.RUN_SHELL: return runShell(args.command);
227
+ case NAME.LIST_DIR: return listDir(args.path);
228
+ case NAME.GREP_SEARCH: return await grepSearch(args.pattern, args.path);
229
+ case NAME.VIEW_RANGE: return viewRange(args.path, args.start, args.end);
230
+ default: return {
231
+ content: "",
232
+ error: `Unknown tool: ${name}`
233
+ };
234
+ }
235
+ }
236
+ /**
237
+ * Read file contents
238
+ */
239
+ function readFile(filePath) {
240
+ try {
241
+ if (!existsSync(filePath)) return {
242
+ content: "",
243
+ error: `File not found: ${filePath}`
244
+ };
245
+ return { content: readFileSync(filePath, "utf8") };
246
+ } catch (error) {
247
+ return {
248
+ content: "",
249
+ error: `Failed to read file: ${error instanceof Error ? error.message : String(error)}`
250
+ };
251
+ }
252
+ }
253
+ /**
254
+ * Write content to file
255
+ */
256
+ function writeFile(filePath, content) {
257
+ try {
258
+ writeFileSync(filePath, content, "utf8");
259
+ return { content: `File written successfully: ${filePath}` };
260
+ } catch (error) {
261
+ return {
262
+ content: "",
263
+ error: `Failed to write file: ${error instanceof Error ? error.message : String(error)}`
264
+ };
265
+ }
266
+ }
267
+ var SHELL_EXEC_OPTIONS = {
268
+ timeout: 3e4,
269
+ maxBuffer: 1024 * 1024
270
+ };
271
+ /**
272
+ * Execute shell command with shared options (throws on error)
273
+ */
274
+ function execShell(command) {
275
+ return execAsync(command, SHELL_EXEC_OPTIONS);
276
+ }
277
+ /**
278
+ * Execute shell command
279
+ */
280
+ async function runShell(command) {
281
+ try {
282
+ const { stdout, stderr } = await execShell(command);
283
+ return { content: stdout || stderr };
284
+ } catch (error) {
285
+ return {
286
+ content: "",
287
+ error: `Command failed: ${error instanceof Error ? error.message : String(error)}`
288
+ };
289
+ }
290
+ }
291
+ /**
292
+ * List directory contents
293
+ */
294
+ function listDir(dirPath) {
295
+ try {
296
+ if (!existsSync(dirPath)) return {
297
+ content: "",
298
+ error: `Directory not found: ${dirPath}`
299
+ };
300
+ return { content: readdirSync(dirPath, { withFileTypes: true }).map((entry) => {
301
+ return `[${entry.isDirectory() ? "d" : "f"}] ${entry.name}`;
302
+ }).join("\n") };
303
+ } catch (error) {
304
+ return {
305
+ content: "",
306
+ error: `Failed to list directory: ${error instanceof Error ? error.message : String(error)}`
307
+ };
308
+ }
309
+ }
310
+ /**
311
+ * Search for pattern in files using ripgrep if available, fallback to Node.js
312
+ */
313
+ async function grepSearch(pattern, dirPath) {
314
+ try {
315
+ const { stdout } = await execShell(`rg --line-number --no-heading --smart-case "${pattern.replace(/"/g, "\\\"")}" "${dirPath}"`);
316
+ // v8 ignore next
317
+ return { content: stdout || "No matches found" };
318
+ } catch {}
319
+ try {
320
+ if (!existsSync(dirPath)) return {
321
+ content: "",
322
+ error: `Directory not found: ${dirPath}`
323
+ };
324
+ const regex = new RegExp(pattern, "g");
325
+ const results = [];
326
+ function searchDirectory(currentPath) {
327
+ const entries = readdirSync(currentPath, { withFileTypes: true });
328
+ for (const entry of entries) {
329
+ const fullPath = join(currentPath, entry.name);
330
+ if (entry.isDirectory()) {
331
+ if (!entry.name.startsWith(".") && entry.name !== "node_modules") searchDirectory(fullPath);
332
+ } else if (entry.isFile()) try {
333
+ const lines = readFileSync(fullPath, "utf8").split("\n");
334
+ for (let i = 0; i < lines.length; i++) {
335
+ if (regex.test(lines[i])) results.push(`${fullPath}:${(i + 1).toString()}: ${lines[i].trim()}`);
336
+ regex.lastIndex = 0;
337
+ }
338
+ } catch {}
339
+ }
340
+ }
341
+ searchDirectory(dirPath);
342
+ if (results.length === 0) return { content: "No matches found" };
343
+ return { content: results.join("\n") };
344
+ } catch (error) {
345
+ return {
346
+ content: "",
347
+ error: `Search failed: ${error instanceof Error ? error.message : String(error)}`
348
+ };
349
+ }
350
+ }
351
+ /**
352
+ * View specific line range from file
353
+ */
354
+ function viewRange(filePath, start, end) {
355
+ try {
356
+ if (!existsSync(filePath)) return {
357
+ content: "",
358
+ error: `File not found: ${filePath}`
359
+ };
360
+ const lines = readFileSync(filePath, "utf8").split("\n");
361
+ const startIdx = Math.max(0, start - 1);
362
+ const endIdx = Math.min(lines.length, end);
363
+ if (startIdx >= lines.length || startIdx > endIdx) return {
364
+ content: "",
365
+ error: "Invalid line range"
366
+ };
367
+ return { content: lines.slice(startIdx, endIdx).join("\n") };
368
+ } catch (error) {
369
+ return {
370
+ content: "",
371
+ error: `Failed to view range: ${error instanceof Error ? error.message : String(error)}`
372
+ };
373
+ }
374
+ }
375
+ //#endregion
376
+ //#region src/cli.ts
377
+ var cli = cac("code-ollama");
378
+ cli.version(VERSION);
379
+ cli.help();
380
+ cli.command("run <model> <prompt>", "Run a one-off prompt").action(async (model, prompt) => {
381
+ try {
382
+ await runPrompt(model, prompt);
383
+ } catch (error) {
384
+ // v8 ignore next
385
+ const message = error instanceof Error ? error.message : "Unknown error";
386
+ process.stderr.write(`Error: ${message}\n`);
387
+ process.exitCode = 1;
388
+ }
389
+ });
390
+ async function runPrompt(model, prompt) {
391
+ await processRunStream([createSystemMessage(), {
392
+ role: ROLE.USER,
393
+ content: prompt
394
+ }], model);
395
+ process.stdout.write("\n");
396
+ }
397
+ async function processRunStream(messages, model) {
398
+ const assistantMessage = {
399
+ role: ROLE.ASSISTANT,
400
+ content: ""
401
+ };
402
+ for await (const chunk of streamChat(messages, model, TOOLS)) {
403
+ if (chunk.type === "content") {
404
+ assistantMessage.content += chunk.content;
405
+ process.stdout.write(chunk.content);
406
+ continue;
407
+ }
408
+ for (const toolCall of chunk.tool_calls) {
409
+ const result = await executeTool(toolCall.function.name, toolCall.function.arguments);
410
+ const toolResultMessage = {
411
+ role: ROLE.SYSTEM,
412
+ content: `Tool ${toolCall.function.name} result:\n${result.content}${result.error ? `\nError: ${result.error}` : ""}`
413
+ };
414
+ await processRunStream([
415
+ ...messages,
416
+ assistantMessage,
417
+ toolResultMessage
418
+ ], model);
419
+ return;
420
+ }
421
+ }
422
+ }
423
+ async function main(args = process.argv.slice(2)) {
424
+ if (!args.length) {
425
+ const { renderApp } = await import("./assets/tui-DSR1MJGd.js");
426
+ clear();
427
+ renderApp();
428
+ return;
429
+ }
430
+ cli.parse([
431
+ "node",
432
+ "code-ollama",
433
+ ...args
434
+ ]);
435
+ }
436
+ /* v8 ignore start */
437
+ function isEntrypoint(argv1 = process.argv[1]) {
438
+ if (!argv1) return false;
439
+ try {
440
+ return realpathSync(argv1) === import.meta.filename;
441
+ } catch {
442
+ return false;
443
+ }
444
+ }
445
+ if (isEntrypoint()) main();
446
+ /* v8 ignore stop */
447
+ //#endregion
448
+ export { streamChat as a, createSystemMessage as c, listModels as i, ROLE as l, main, TOOLS_REQUIRING_APPROVAL as n, loadConfig as o, executeTool as r, saveConfig as s, TOOLS as t, VERSION as u };
package/package.json CHANGED
@@ -1,4 +1,81 @@
1
1
  {
2
2
  "name": "code-ollama",
3
- "version": "0.0.0"
3
+ "version": "0.1.1",
4
+ "description": "Ollama coding agent that runs in your terminal",
5
+ "author": "Mark <mark@remarkablemark.org> (https://remarkablemark.org)",
6
+ "type": "module",
7
+ "bin": {
8
+ "code-ollama": "./dist/cli.js",
9
+ "collama": "./dist/cli.js"
10
+ },
11
+ "scripts": {
12
+ "build": "vite build",
13
+ "start": "tsx --tsconfig tsconfig.test.json src/cli.ts",
14
+ "clean": "rm -rf coverage dist docs",
15
+ "lint": "eslint .",
16
+ "lint:fix": "npm run lint -- --fix",
17
+ "lint:package": "publint",
18
+ "lint:tsc": "tsc --build",
19
+ "prepare": "husky",
20
+ "prepublishOnly": "npm run build && npm run lint && npm run lint:tsc && npm run test:ci",
21
+ "test": "vitest run",
22
+ "test:ci": "CI=true npm test -- --color --coverage",
23
+ "test:watch": "vitest --coverage.enabled=false"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/ai-action/code-ollama.git"
28
+ },
29
+ "bugs": {
30
+ "url": "https://github.com/ai-action/code-ollama/issues"
31
+ },
32
+ "keywords": [
33
+ "code-ollama",
34
+ "collama",
35
+ "code",
36
+ "ollama",
37
+ "tui",
38
+ "cli",
39
+ "ai"
40
+ ],
41
+ "dependencies": {
42
+ "@inkjs/ui": "2.0.0",
43
+ "cac": "7.0.0",
44
+ "ink": "7.0.1",
45
+ "ollama": "0.6.3",
46
+ "react": "19.2.5"
47
+ },
48
+ "devDependencies": {
49
+ "@commitlint/cli": "20.5.3",
50
+ "@commitlint/config-conventional": "20.5.3",
51
+ "@eslint/compat": "2.0.5",
52
+ "@eslint/js": "10.0.1",
53
+ "@types/node": "25.6.0",
54
+ "@types/react": "19.2.14",
55
+ "@vitest/coverage-v8": "4.1.5",
56
+ "eslint": "10.3.0",
57
+ "eslint-plugin-prettier": "5.5.5",
58
+ "eslint-plugin-simple-import-sort": "13.0.0",
59
+ "globals": "17.6.0",
60
+ "husky": "9.1.7",
61
+ "ink-testing-library": "4.0.0",
62
+ "lint-staged": "16.4.0",
63
+ "prettier": "3.8.3",
64
+ "publint": "0.3.18",
65
+ "tsx": "4.21.0",
66
+ "typescript": "6.0.3",
67
+ "typescript-eslint": "8.59.2",
68
+ "vite": "8.0.10",
69
+ "vitest": "4.1.5"
70
+ },
71
+ "files": [
72
+ "dist/"
73
+ ],
74
+ "funding": [
75
+ {
76
+ "type": "github",
77
+ "url": "https://github.com/sponsors/remarkablemark"
78
+ }
79
+ ],
80
+ "license": "MIT"
4
81
  }