ollama-coding-agent 0.5.0__tar.gz → 0.5.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. {ollama_coding_agent-0.5.0 → ollama_coding_agent-0.5.2}/PKG-INFO +2 -1
  2. {ollama_coding_agent-0.5.0 → ollama_coding_agent-0.5.2}/coding_agent/cli.py +63 -46
  3. {ollama_coding_agent-0.5.0 → ollama_coding_agent-0.5.2}/coding_agent/config.py +4 -1
  4. {ollama_coding_agent-0.5.0 → ollama_coding_agent-0.5.2}/coding_agent/mcp_server.py +55 -0
  5. {ollama_coding_agent-0.5.0 → ollama_coding_agent-0.5.2}/coding_agent/tools.py +133 -0
  6. {ollama_coding_agent-0.5.0 → ollama_coding_agent-0.5.2}/coding_agent/ui.py +13 -3
  7. {ollama_coding_agent-0.5.0 → ollama_coding_agent-0.5.2}/ollama_coding_agent.egg-info/PKG-INFO +2 -1
  8. {ollama_coding_agent-0.5.0 → ollama_coding_agent-0.5.2}/ollama_coding_agent.egg-info/requires.txt +1 -0
  9. {ollama_coding_agent-0.5.0 → ollama_coding_agent-0.5.2}/pyproject.toml +2 -1
  10. {ollama_coding_agent-0.5.0 → ollama_coding_agent-0.5.2}/LICENSE +0 -0
  11. {ollama_coding_agent-0.5.0 → ollama_coding_agent-0.5.2}/PYPI_README.md +0 -0
  12. {ollama_coding_agent-0.5.0 → ollama_coding_agent-0.5.2}/README.md +0 -0
  13. {ollama_coding_agent-0.5.0 → ollama_coding_agent-0.5.2}/coding_agent/__init__.py +0 -0
  14. {ollama_coding_agent-0.5.0 → ollama_coding_agent-0.5.2}/coding_agent/__main__.py +0 -0
  15. {ollama_coding_agent-0.5.0 → ollama_coding_agent-0.5.2}/coding_agent/agent.py +0 -0
  16. {ollama_coding_agent-0.5.0 → ollama_coding_agent-0.5.2}/coding_agent/intent.py +0 -0
  17. {ollama_coding_agent-0.5.0 → ollama_coding_agent-0.5.2}/coding_agent/mcp_client.py +0 -0
  18. {ollama_coding_agent-0.5.0 → ollama_coding_agent-0.5.2}/coding_agent/ollama_client.py +0 -0
  19. {ollama_coding_agent-0.5.0 → ollama_coding_agent-0.5.2}/coding_agent/session_store.py +0 -0
  20. {ollama_coding_agent-0.5.0 → ollama_coding_agent-0.5.2}/ollama_coding_agent.egg-info/SOURCES.txt +0 -0
  21. {ollama_coding_agent-0.5.0 → ollama_coding_agent-0.5.2}/ollama_coding_agent.egg-info/dependency_links.txt +0 -0
  22. {ollama_coding_agent-0.5.0 → ollama_coding_agent-0.5.2}/ollama_coding_agent.egg-info/entry_points.txt +0 -0
  23. {ollama_coding_agent-0.5.0 → ollama_coding_agent-0.5.2}/ollama_coding_agent.egg-info/top_level.txt +0 -0
  24. {ollama_coding_agent-0.5.0 → ollama_coding_agent-0.5.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ollama-coding-agent
3
- Version: 0.5.0
3
+ Version: 0.5.2
4
4
  Summary: AI coding agent driving Qwen Coder (or any Ollama-compatible model) through a scoped toolset via MCP
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/HarryChen1995/coding_agent
@@ -17,6 +17,7 @@ Requires-Dist: httpx>=0.28
17
17
  Requires-Dist: rich>=15.0
18
18
  Requires-Dist: typer>=0.27
19
19
  Requires-Dist: mcp>=1.27
20
+ Requires-Dist: prompt_toolkit>=3.0
20
21
  Dynamic: license-file
21
22
 
22
23
  # Ollama Coding Agent
@@ -32,6 +32,7 @@ Examples:
32
32
 
33
33
  import asyncio
34
34
  import os
35
+ from contextlib import nullcontext
35
36
  from typing import List, Optional
36
37
 
37
38
  import typer
@@ -220,61 +221,78 @@ def main(
220
221
  async def _interactive(cfg: AgentConfig, resume: Optional[str], session_name: Optional[str]):
221
222
  """REPL: keep one MCP client open across turns (avoids re-spawning the
222
223
  tool-server subprocess every turn) and keep resuming the same session
223
- (fresh on turn 1, then whatever session that turn created/resumed)."""
224
+ (fresh on turn 1, then whatever session that turn created/resumed).
225
+
226
+ Input is read through a prompt_toolkit PromptSession wrapped in
227
+ patch_stdout(), so the input line stays pinned to the bottom of the
228
+ terminal — parsing/thinking spinners, panels, and results all scroll in
229
+ the region above it instead of interleaving with the prompt. Falls back
230
+ to a plain input() loop if rich/prompt_toolkit aren't installed."""
224
231
  agent = CodingAgent(cfg)
225
232
  session_id = resume
226
233
 
227
234
  try:
228
235
  from . import ui
236
+ from prompt_toolkit import PromptSession
237
+ from prompt_toolkit.patch_stdout import patch_stdout
229
238
  ui.interactive_banner(cfg.model, resumed=resume)
239
+ prompt_session = PromptSession()
240
+ # raw=True: pass Rich's ANSI-coded output straight through instead of
241
+ # patch_stdout()'s default write() path, which sanitizes/escapes text
242
+ # (it assumes plain text) and mangles embedded escape codes into
243
+ # literal garbage like "?[32m" on the screen.
244
+ stdout_cm = patch_stdout(raw=True)
230
245
  except ImportError:
231
246
  typer.echo(f"Interactive mode (model: {cfg.model}). Type a task, /sessions to list, /exit to quit.\n")
247
+ prompt_session = None
248
+ stdout_cm = nullcontext()
232
249
 
233
250
  if resume:
234
251
  _show_resumed_history(cfg.db_path, resume)
235
252
 
236
253
  async with MCPToolClient(cfg.project_root, mcp_config_path=cfg.mcp_config_path or None,
237
254
  extra_servers=cfg.mcp_servers or None) as client:
238
- while True:
239
- try:
240
- task = _read_task()
241
- except (EOFError, KeyboardInterrupt):
242
- typer.echo()
243
- break
244
-
245
- task = task.strip()
246
- if not task:
247
- continue
248
- if task in ("/exit", "/quit"):
249
- break
250
- if task == "/sessions":
251
- _print_sessions(agent.store.list_sessions())
252
- continue
253
- if task.startswith("/delete "):
254
- target = task[len("/delete "):].strip()
255
- if agent.store.delete_session(target):
256
- typer.echo(f"Deleted session {target!r}.")
257
- if session_id is not None and agent.store.resolve_session_id(session_id) is None:
258
- session_id = None # the session we were resuming just got deleted
259
- else:
260
- typer.echo(f"No session found with id or name {target!r}.", err=True)
261
- continue
262
-
263
- try:
264
- result = await agent.run(task, resume_session_id=session_id, client=client,
265
- session_name=session_name, show_banner=False)
266
- except ValueError as e:
267
- typer.echo(f"Error: {e}", err=True)
268
- continue
269
-
270
- session_id = agent.session_id
271
-
272
- try:
273
- from . import ui
274
- ui.final_result(result)
275
- except ImportError:
276
- typer.echo("\n=== RESULT ===")
277
- typer.echo(result)
255
+ with stdout_cm:
256
+ while True:
257
+ try:
258
+ task = await _read_task(prompt_session)
259
+ except (EOFError, KeyboardInterrupt):
260
+ typer.echo()
261
+ break
262
+
263
+ task = task.strip()
264
+ if not task:
265
+ continue
266
+ if task in ("/exit", "/quit"):
267
+ break
268
+ if task == "/sessions":
269
+ _print_sessions(agent.store.list_sessions())
270
+ continue
271
+ if task.startswith("/delete "):
272
+ target = task[len("/delete "):].strip()
273
+ if agent.store.delete_session(target):
274
+ typer.echo(f"Deleted session {target!r}.")
275
+ if session_id is not None and agent.store.resolve_session_id(session_id) is None:
276
+ session_id = None # the session we were resuming just got deleted
277
+ else:
278
+ typer.echo(f"No session found with id or name {target!r}.", err=True)
279
+ continue
280
+
281
+ try:
282
+ result = await agent.run(task, resume_session_id=session_id, client=client,
283
+ session_name=session_name, show_banner=False)
284
+ except ValueError as e:
285
+ typer.echo(f"Error: {e}", err=True)
286
+ continue
287
+
288
+ session_id = agent.session_id
289
+
290
+ try:
291
+ from . import ui
292
+ ui.final_result(result)
293
+ except ImportError:
294
+ typer.echo("\n=== RESULT ===")
295
+ typer.echo(result)
278
296
 
279
297
 
280
298
  def _show_resumed_history(db_path: str, resume: str):
@@ -298,12 +316,11 @@ def _show_resumed_history(db_path: str, resume: str):
298
316
  typer.echo("--- end history ---\n")
299
317
 
300
318
 
301
- def _read_task() -> str:
302
- try:
319
+ async def _read_task(prompt_session) -> str:
320
+ if prompt_session is not None:
303
321
  from . import ui
304
- return ui.prompt_task()
305
- except ImportError:
306
- return input("> ")
322
+ return await ui.prompt_task_async(prompt_session)
323
+ return input("> ")
307
324
 
308
325
 
309
326
  def _print_sessions(sessions: list):
@@ -17,7 +17,10 @@ class AgentConfig:
17
17
  # Anything not listed here (write_file, edit_file, run_shell by default)
18
18
  # will print what it's about to do and wait for confirmation, unless
19
19
  # auto_approve=True.
20
- safe_tools: tuple = ("read_file", "list_dir", "search_files", "glob_files", "git_diff")
20
+ safe_tools: tuple = (
21
+ "read_file", "list_dir", "search_files", "glob_files",
22
+ "git_diff", "git_status", "git_log", "git_show", "git_branch", "git_fetch",
23
+ )
21
24
 
22
25
  auto_approve: bool = False # True = never prompt (use in CI with care)
23
26
  max_steps: int = 25 # hard cap on agent loop iterations
@@ -59,6 +59,61 @@ def git_diff(path: str = ".") -> str:
59
59
  return impl.git_diff(path)
60
60
 
61
61
 
62
+ @mcp.tool()
63
+ def git_status(path: str = ".") -> str:
64
+ """Show the working tree status (short format) and current branch."""
65
+ return impl.git_status(path)
66
+
67
+
68
+ @mcp.tool()
69
+ def git_log(path: str = ".", max_count: int = 20) -> str:
70
+ """Show recent commit history (hash, date, author, subject)."""
71
+ return impl.git_log(path, max_count)
72
+
73
+
74
+ @mcp.tool()
75
+ def git_show(ref: str = "HEAD", path: str = ".") -> str:
76
+ """Show a commit's metadata and diff (defaults to HEAD)."""
77
+ return impl.git_show(ref, path)
78
+
79
+
80
+ @mcp.tool()
81
+ def git_branch(path: str = ".") -> str:
82
+ """List local branches, marking the current one and its upstream tracking info."""
83
+ return impl.git_branch(path)
84
+
85
+
86
+ @mcp.tool()
87
+ def git_fetch(remote: str = "origin") -> str:
88
+ """Update remote-tracking refs from a remote without touching the working tree."""
89
+ return impl.git_fetch(remote)
90
+
91
+
92
+ @mcp.tool()
93
+ def git_add(paths: str = ".") -> str:
94
+ """Stage files for commit. `paths` is a space-separated list of file \
95
+ paths relative to the project root, or "." to stage all changes."""
96
+ return impl.git_add(paths)
97
+
98
+
99
+ @mcp.tool()
100
+ def git_commit(message: str) -> str:
101
+ """Commit staged changes with the given commit message."""
102
+ return impl.git_commit(message)
103
+
104
+
105
+ @mcp.tool()
106
+ def git_pull(remote: str = "origin", branch: str = "") -> str:
107
+ """Fetch and merge from a remote into the current branch."""
108
+ return impl.git_pull(remote, branch)
109
+
110
+
111
+ @mcp.tool()
112
+ def git_push(remote: str = "origin", branch: str = "") -> str:
113
+ """Push commits to a remote (defaults to pushing the current branch to origin)."""
114
+ return impl.git_push(remote, branch)
115
+
116
+
62
117
  @mcp.tool()
63
118
  def write_file(path: str, content: str, overwrite: bool = False) -> str:
64
119
  """Create a NEW file with content. Fails if the file already exists
@@ -157,8 +157,141 @@ class Tools:
157
157
  except Exception as e:
158
158
  return f"ERROR: {e}"
159
159
 
160
+ def git_status(self, path: str = ".") -> str:
161
+ p = _resolve_in_scope(self.cfg.project_root, path)
162
+ try:
163
+ result = subprocess.run(
164
+ ["git", "status", "--short", "--branch"], cwd=p, capture_output=True, text=True, timeout=15,
165
+ )
166
+ return _truncate(result.stdout or "(clean)", self.cfg.max_output_chars)
167
+ except Exception as e:
168
+ return f"ERROR: {e}"
169
+
170
+ def git_log(self, path: str = ".", max_count: int = 20) -> str:
171
+ p = _resolve_in_scope(self.cfg.project_root, path)
172
+ try:
173
+ result = subprocess.run(
174
+ ["git", "log", f"-{max_count}", "--pretty=format:%h %ad %an: %s", "--date=short"],
175
+ cwd=p, capture_output=True, text=True, timeout=15,
176
+ )
177
+ if result.returncode != 0:
178
+ return f"ERROR: {result.stderr.strip()}"
179
+ return _truncate(result.stdout or "(no commits)", self.cfg.max_output_chars)
180
+ except Exception as e:
181
+ return f"ERROR: {e}"
182
+
183
+ def git_show(self, ref: str = "HEAD", path: str = ".") -> str:
184
+ p = _resolve_in_scope(self.cfg.project_root, path)
185
+ try:
186
+ result = subprocess.run(
187
+ ["git", "show", ref], cwd=p, capture_output=True, text=True, timeout=15,
188
+ )
189
+ if result.returncode != 0:
190
+ return f"ERROR: {result.stderr.strip()}"
191
+ return _truncate(result.stdout, self.cfg.max_output_chars)
192
+ except Exception as e:
193
+ return f"ERROR: {e}"
194
+
195
+ def git_branch(self, path: str = ".") -> str:
196
+ p = _resolve_in_scope(self.cfg.project_root, path)
197
+ try:
198
+ result = subprocess.run(
199
+ ["git", "branch", "-vv"], cwd=p, capture_output=True, text=True, timeout=15,
200
+ )
201
+ if result.returncode != 0:
202
+ return f"ERROR: {result.stderr.strip()}"
203
+ return _truncate(result.stdout or "(no branches)", self.cfg.max_output_chars)
204
+ except Exception as e:
205
+ return f"ERROR: {e}"
206
+
207
+ def git_fetch(self, remote: str = "origin") -> str:
208
+ """Update remote-tracking refs without touching the working tree —
209
+ safe to run without approval, unlike pull/push."""
210
+ try:
211
+ result = subprocess.run(
212
+ ["git", "fetch", remote], cwd=self.cfg.project_root,
213
+ capture_output=True, text=True, timeout=self.cfg.shell_timeout_s,
214
+ )
215
+ out = f"exit_code: {result.returncode}\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}"
216
+ return _truncate(out, self.cfg.max_output_chars)
217
+ except subprocess.TimeoutExpired:
218
+ return f"ERROR: git fetch timed out after {self.cfg.shell_timeout_s}s"
219
+ except Exception as e:
220
+ return f"ERROR: {e}"
221
+
160
222
  # ---- write tools (require approval unless auto_approve) ----
161
223
 
224
+ def git_add(self, paths: str = ".") -> str:
225
+ """Stage files for commit. `paths` is a space-separated list of file
226
+ paths relative to the project root, or "." to stage all changes."""
227
+ if paths.strip() == ".":
228
+ args = ["git", "add", "-A"]
229
+ else:
230
+ resolved = []
231
+ for part in paths.split():
232
+ p = _resolve_in_scope(self.cfg.project_root, part)
233
+ resolved.append(os.path.relpath(p, self.cfg.project_root))
234
+ args = ["git", "add"] + resolved
235
+ try:
236
+ result = subprocess.run(
237
+ args, cwd=self.cfg.project_root, capture_output=True, text=True, timeout=15,
238
+ )
239
+ if result.returncode != 0:
240
+ return f"ERROR: {result.stderr.strip()}"
241
+ return f"Staged: {paths}"
242
+ except Exception as e:
243
+ return f"ERROR: {e}"
244
+
245
+ def git_commit(self, message: str) -> str:
246
+ if not message.strip():
247
+ return "ERROR: commit message cannot be empty"
248
+ try:
249
+ result = subprocess.run(
250
+ ["git", "commit", "-m", message], cwd=self.cfg.project_root,
251
+ capture_output=True, text=True, timeout=15,
252
+ )
253
+ out = f"exit_code: {result.returncode}\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}"
254
+ return _truncate(out, self.cfg.max_output_chars)
255
+ except Exception as e:
256
+ return f"ERROR: {e}"
257
+
258
+ def git_pull(self, remote: str = "origin", branch: str = "") -> str:
259
+ """Fetch and merge from a remote into the current branch. Touches
260
+ the working tree, so this always requires approval (never auto_approve
261
+ -exempt like the read-only git tools)."""
262
+ args = ["git", "pull", remote]
263
+ if branch:
264
+ args.append(branch)
265
+ try:
266
+ result = subprocess.run(
267
+ args, cwd=self.cfg.project_root, capture_output=True, text=True,
268
+ timeout=self.cfg.shell_timeout_s,
269
+ )
270
+ out = f"exit_code: {result.returncode}\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}"
271
+ return _truncate(out, self.cfg.max_output_chars)
272
+ except subprocess.TimeoutExpired:
273
+ return f"ERROR: git pull timed out after {self.cfg.shell_timeout_s}s"
274
+ except Exception as e:
275
+ return f"ERROR: {e}"
276
+
277
+ def git_push(self, remote: str = "origin", branch: str = "") -> str:
278
+ """Push commits to a remote. No force-push option is exposed —
279
+ this tool only ever does a plain push."""
280
+ args = ["git", "push", remote]
281
+ if branch:
282
+ args.append(branch)
283
+ try:
284
+ result = subprocess.run(
285
+ args, cwd=self.cfg.project_root, capture_output=True, text=True,
286
+ timeout=self.cfg.shell_timeout_s,
287
+ )
288
+ out = f"exit_code: {result.returncode}\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}"
289
+ return _truncate(out, self.cfg.max_output_chars)
290
+ except subprocess.TimeoutExpired:
291
+ return f"ERROR: git push timed out after {self.cfg.shell_timeout_s}s"
292
+ except Exception as e:
293
+ return f"ERROR: {e}"
294
+
162
295
  def write_file(self, path: str, content: str, overwrite: bool = False) -> str:
163
296
  p = _resolve_in_scope(self.cfg.project_root, path)
164
297
  if os.path.exists(p) and not overwrite:
@@ -7,10 +7,11 @@ isn't installed, agent.py falls back to plain print() (see its import guard).
7
7
  import json
8
8
  import os
9
9
 
10
+ from prompt_toolkit.formatted_text import HTML
10
11
  from rich.console import Console
11
12
  from rich.markdown import Markdown
12
13
  from rich.panel import Panel
13
- from rich.prompt import Confirm, Prompt
14
+ from rich.prompt import Confirm
14
15
  from rich.syntax import Syntax
15
16
  from rich.rule import Rule
16
17
  from rich.table import Table
@@ -46,9 +47,18 @@ def interactive_banner(model: str, resumed: str = None):
46
47
  console.print("[dim]Type a task, /sessions to list saved sessions, /exit to quit.[/dim]\n")
47
48
 
48
49
 
49
- def prompt_task() -> str:
50
+ async def prompt_task_async(session) -> str:
51
+ """Read one line of input via a prompt_toolkit PromptSession, so the
52
+ input line stays pinned to the bottom of the terminal — all other
53
+ output (parsing/thinking spinners, panels, results) scrolls in the
54
+ region above it instead of interleaving with the prompt. `session` is
55
+ a prompt_toolkit.PromptSession the caller creates once and reuses
56
+ across turns (so up-arrow history works). Must be called inside the
57
+ caller's `patch_stdout()` context so Rich's output (which resolves
58
+ sys.stdout lazily on every print) is redrawn above the pinned prompt
59
+ instead of corrupting it."""
50
60
  console.print(Rule(style="dim"))
51
- return Prompt.ask("[bold green]❯[/bold green]")
61
+ return await session.prompt_async(HTML("<ansigreen><b>❯</b></ansigreen> "))
52
62
 
53
63
 
54
64
  def thinking(label: str = "Thinking…"):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ollama-coding-agent
3
- Version: 0.5.0
3
+ Version: 0.5.2
4
4
  Summary: AI coding agent driving Qwen Coder (or any Ollama-compatible model) through a scoped toolset via MCP
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/HarryChen1995/coding_agent
@@ -17,6 +17,7 @@ Requires-Dist: httpx>=0.28
17
17
  Requires-Dist: rich>=15.0
18
18
  Requires-Dist: typer>=0.27
19
19
  Requires-Dist: mcp>=1.27
20
+ Requires-Dist: prompt_toolkit>=3.0
20
21
  Dynamic: license-file
21
22
 
22
23
  # Ollama Coding Agent
@@ -2,3 +2,4 @@ httpx>=0.28
2
2
  rich>=15.0
3
3
  typer>=0.27
4
4
  mcp>=1.27
5
+ prompt_toolkit>=3.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ollama-coding-agent"
7
- version = "0.5.0"
7
+ version = "0.5.2"
8
8
  description = "AI coding agent driving Qwen Coder (or any Ollama-compatible model) through a scoped toolset via MCP"
9
9
  readme = "PYPI_README.md"
10
10
  requires-python = ">=3.10"
@@ -20,6 +20,7 @@ dependencies = [
20
20
  "rich>=15.0",
21
21
  "typer>=0.27",
22
22
  "mcp>=1.27",
23
+ "prompt_toolkit>=3.0",
23
24
  ]
24
25
 
25
26
  [project.urls]