messygit 0.3.0__tar.gz → 0.3.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.
@@ -0,0 +1,27 @@
1
+ name: Tests
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
15
+
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+
19
+ - uses: actions/setup-python@v5
20
+ with:
21
+ python-version: ${{ matrix.python-version }}
22
+
23
+ - name: Install package with dev dependencies
24
+ run: pip install -e ".[dev]"
25
+
26
+ - name: Run tests
27
+ run: pytest -q
@@ -1,12 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: messygit
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: An AI-powered interactive git CLI with agentic tools for commits, code suggestions, and workflow automation.
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
7
7
  Requires-Dist: anthropic>=0.39.0
8
8
  Requires-Dist: click>=8.0
9
9
  Requires-Dist: rich>=13.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=8.0; extra == 'dev'
10
12
  Description-Content-Type: text/markdown
11
13
 
12
14
  # messygit
@@ -114,6 +116,7 @@ Commands are grouped on the `help` screen:
114
116
 
115
117
  | Command | Description |
116
118
  |---------|-------------|
119
+ | `todo` | Open your todo list in `$EDITOR` (saved to `~/.messygit/todo.md`) |
117
120
  | `theme` or `theme <name>` | Change the UI color (run `theme` to list presets) |
118
121
  | `help` | List available commands |
119
122
  | `quit` / `exit` | Exit messygit |
@@ -103,6 +103,7 @@ Commands are grouped on the `help` screen:
103
103
 
104
104
  | Command | Description |
105
105
  |---------|-------------|
106
+ | `todo` | Open your todo list in `$EDITOR` (saved to `~/.messygit/todo.md`) |
106
107
  | `theme` or `theme <name>` | Change the UI color (run `theme` to list presets) |
107
108
  | `help` | List available commands |
108
109
  | `quit` / `exit` | Exit messygit |
@@ -53,8 +53,25 @@ class Agent:
53
53
 
54
54
  tool_results = []
55
55
  for block in tool_use_blocks:
56
- tool = next(t for t in self.tools if t.name == block.name)
57
- result = tool.run(**block.input)
56
+ tool = next((t for t in self.tools if t.name == block.name), None)
57
+ if tool is None:
58
+ tool_results.append({
59
+ "type": "tool_result",
60
+ "tool_use_id": block.id,
61
+ "content": f"Unknown tool: {block.name!r}.",
62
+ "is_error": True,
63
+ })
64
+ continue
65
+ try:
66
+ result = tool.run(**block.input)
67
+ except Exception as e:
68
+ tool_results.append({
69
+ "type": "tool_result",
70
+ "tool_use_id": block.id,
71
+ "content": f"Error running tool {block.name!r}: {e}",
72
+ "is_error": True,
73
+ })
74
+ continue
58
75
  tool_results.append({
59
76
  "type": "tool_result",
60
77
  "tool_use_id": block.id,
@@ -12,17 +12,21 @@ class Tool:
12
12
  description: str
13
13
  function: Callable[..., Any]
14
14
  parameters: dict[str, Any] = field(default_factory=dict)
15
+ required: list[str] = field(default_factory=list)
15
16
 
16
17
  def run(self, **kwargs: Any) -> Any:
17
18
  return self.function(**kwargs)
18
19
 
19
20
  def to_schema(self) -> dict[str, Any]:
20
21
  """Return an Anthropic-compatible tool schema for API calls."""
22
+ input_schema: dict[str, Any] = {
23
+ "type": "object",
24
+ "properties": self.parameters,
25
+ }
26
+ if self.required:
27
+ input_schema["required"] = self.required
21
28
  return {
22
29
  "name": self.name,
23
30
  "description": self.description,
24
- "input_schema": {
25
- "type": "object",
26
- "properties": self.parameters,
27
- },
31
+ "input_schema": input_schema,
28
32
  }
@@ -0,0 +1,137 @@
1
+ import os
2
+ from .tool import Tool
3
+ from ..git import get_staged_diff, get_staged_files
4
+ import subprocess
5
+
6
+ ALLOWED_GIT_COMMANDS = ["log", "diff", "status", "show", "status", "shortlog", "blame"]
7
+
8
+
9
+ def _repo_root() -> str:
10
+ """Absolute, symlink-resolved path to the git repository root."""
11
+ result = subprocess.run(
12
+ ["git", "rev-parse", "--show-toplevel"],
13
+ capture_output=True,
14
+ text=True,
15
+ )
16
+ root = result.stdout.strip() or os.getcwd()
17
+ return os.path.realpath(root)
18
+
19
+
20
+ def run_git(args: list[str]) -> str:
21
+ if not args or args[0] not in ALLOWED_GIT_COMMANDS:
22
+ return "Invalid git command."
23
+ result = subprocess.run(["git", *args], capture_output=True, text=True)
24
+ return result.stdout or result.stderr
25
+
26
+ run_git_tool = Tool(
27
+ name="run_git",
28
+ description=(
29
+ "Run a read-only git command and return its output. The first element "
30
+ "of `args` must be one of: "
31
+ + ", ".join(sorted(set(ALLOWED_GIT_COMMANDS)))
32
+ + ". Example: [\"log\", \"--oneline\", \"-n\", \"10\"]."
33
+ ),
34
+ function=run_git,
35
+ parameters={
36
+ "args": {
37
+ "type": "array",
38
+ "items": {"type": "string"},
39
+ "description": (
40
+ "git subcommand and its arguments as separate strings, e.g. "
41
+ "[\"diff\", \"--stat\"]. The subcommand (first item) must be allowed."
42
+ ),
43
+ },
44
+ },
45
+ required=["args"],
46
+ )
47
+
48
+ def read_file(path: str) -> str:
49
+ root = _repo_root()
50
+ target = os.path.realpath(os.path.join(root, path))
51
+ if target != root and not target.startswith(root + os.sep):
52
+ return f"Access denied: '{path}' is outside the repository root."
53
+ try:
54
+ with open(target, "r") as file:
55
+ return file.read()
56
+ except FileNotFoundError:
57
+ return "File not found."
58
+ except IsADirectoryError:
59
+ return "Path is a directory, not a file."
60
+ except PermissionError:
61
+ return "Permission denied."
62
+ except UnicodeDecodeError:
63
+ return "File is not valid UTF-8 text and cannot be read."
64
+ except Exception as e:
65
+ return f"Error reading file: {e}"
66
+
67
+ read_file_tool = Tool(
68
+ name="read_file",
69
+ description=(
70
+ "Read and return the UTF-8 text contents of a file inside the repository. "
71
+ "Paths are resolved relative to the repository root; paths that escape the "
72
+ "repository (e.g. via '..' or absolute paths) are rejected."
73
+ ),
74
+ function=read_file,
75
+ parameters={
76
+ "path": {
77
+ "type": "string",
78
+ "description": (
79
+ "File path relative to the repository root, e.g. "
80
+ "\"messygit/cli.py\"."
81
+ ),
82
+ },
83
+ },
84
+ required=["path"],
85
+ )
86
+
87
+ def list_directory(path: str) -> list[str]:
88
+ root = _repo_root()
89
+ target = os.path.realpath(os.path.join(root, path))
90
+ if target != root and not target.startswith(root + os.sep):
91
+ return f"Access denied: '{path}' is outside the repository root."
92
+ try:
93
+ return os.listdir(target)
94
+ except FileNotFoundError:
95
+ return "Directory not found."
96
+ except NotADirectoryError:
97
+ return "Path is a file, not a directory."
98
+ except PermissionError:
99
+ return "Permission denied."
100
+ except Exception as e:
101
+ return f"Error listing directory: {e}"
102
+
103
+ list_directory_tool = Tool(
104
+ name="list_directory",
105
+ description=(
106
+ "List the names of entries (files and subdirectories) in a directory. "
107
+ "Use \".\" for the current directory."
108
+ ),
109
+ function=list_directory,
110
+ parameters={
111
+ "path": {
112
+ "type": "string",
113
+ "description": "Directory path to list, e.g. \"messygit\" or \".\".",
114
+ },
115
+ },
116
+ required=["path"],
117
+ )
118
+
119
+ def search_code(query: str) -> str:
120
+ result = subprocess.run(["git", "grep", "-n", query], capture_output=True, text=True)
121
+ return result.stdout or result.stderr
122
+
123
+ search_code_tool = Tool(
124
+ name="search_code",
125
+ description=(
126
+ "Search tracked files in the repository for a string or regex using "
127
+ "`git grep`. Returns matching lines prefixed with file path and line number."
128
+ ),
129
+ function=search_code,
130
+ parameters={
131
+ "query": {
132
+ "type": "string",
133
+ "description": "The text or basic regex pattern to search for.",
134
+ },
135
+ },
136
+ required=["query"],
137
+ )
@@ -21,15 +21,18 @@ from .config import (
21
21
  MissingApiKeyError,
22
22
  load_api_key,
23
23
  load_theme,
24
+ load_todo,
24
25
  mask_api_key,
25
26
  save_api_key,
26
27
  save_model,
27
28
  save_theme,
29
+ save_todo,
28
30
  )
29
31
  from .git import (
30
32
  get_current_branch,
31
33
  get_repo_status,
32
34
  get_staged_diff,
35
+ get_unpushed_commits,
33
36
  git_add,
34
37
  git_commit,
35
38
  git_push,
@@ -110,6 +113,7 @@ HELP_GROUPS = [
110
113
  ("add", "stage files", "add . or add <file> ..."),
111
114
  ("commit", "generate a commit message from staged changes", "commit"),
112
115
  ("push", "push commits to remote", "push"),
116
+ ("outbox", "show committed but unpushed commits", "outbox"),
113
117
  ]),
114
118
  ("messyagent", [
115
119
  ("suggest", "suggest next steps for your project", "suggest"),
@@ -121,6 +125,7 @@ HELP_GROUPS = [
121
125
  ("tokens", "show session token usage / open billing", "tokens"),
122
126
  ]),
123
127
  ("app", [
128
+ ("todo", "open your todo list in your editor", "todo"),
124
129
  ("theme", "change the UI color", "theme or theme <name>"),
125
130
  ("help", "show this help message", "help"),
126
131
  ("quit/exit", "exit messygit", "quit"),
@@ -410,6 +415,33 @@ def _handle_push() -> None:
410
415
  _success(output if output else "Pushed successfully.")
411
416
 
412
417
 
418
+ def _handle_outbox() -> None:
419
+ if not is_git_repo():
420
+ _print_error("Not a git repository.")
421
+ return
422
+ box = get_unpushed_commits()
423
+ if box.upstream is None:
424
+ _print_error(
425
+ "No upstream branch set — push once with 'git push -u' to start tracking."
426
+ )
427
+ return
428
+ if not box.commits:
429
+ _success(f"Up to date with [{BRAND}]{box.upstream}[/] — nothing to push.")
430
+ return
431
+
432
+ n = len(box.commits)
433
+ body = Text()
434
+ body.append(
435
+ f"{n} commit{'s' if n != 1 else ''} ahead of {box.upstream}\n\n", style=MUTED
436
+ )
437
+ for commit in box.commits:
438
+ body.append(f"{commit.short_hash} ", style=BRAND)
439
+ body.append(f"{commit.subject}\n", style="default")
440
+ console.print(
441
+ Panel(body, title="outbox", border_style=BRAND, title_align="left")
442
+ )
443
+
444
+
413
445
  def _handle_commit() -> None:
414
446
  diff = get_staged_diff()
415
447
  if not diff.strip():
@@ -611,6 +643,25 @@ def _handle_theme(args: list[str]) -> None:
611
643
  console.print(Text.assemble("Theme set to ", (name, BRAND), " ") + _swatch(THEMES[name]))
612
644
 
613
645
 
646
+ TODO_SEED = "# messygit todo\n\n- \n"
647
+
648
+
649
+ def _handle_todo() -> None:
650
+ """Open the persisted todo file in $EDITOR; save whatever comes back."""
651
+ current = load_todo()
652
+ seed = current if current.strip() else TODO_SEED
653
+ edited = click.edit(seed)
654
+ if edited is None:
655
+ _warn("Editor exited without saving; todo unchanged.")
656
+ return
657
+ save_todo(edited)
658
+ open_items = sum(
659
+ 1 for line in edited.splitlines() if line.lstrip().startswith(("- ", "* "))
660
+ and line.strip() not in ("-", "*")
661
+ )
662
+ _success(f"Todo saved [{MUTED}]({open_items} item{'s' if open_items != 1 else ''})[/]")
663
+
664
+
614
665
  def _handle_suggestion() -> None:
615
666
  agent = Agent(
616
667
  name="suggestion_agent",
@@ -635,11 +686,13 @@ COMMANDS = {
635
686
  "add": _handle_add,
636
687
  "commit": lambda args: _handle_commit(),
637
688
  "push": lambda args: _handle_push(),
689
+ "outbox": lambda args: _handle_outbox(),
638
690
  "config": _handle_config,
639
691
  "show": lambda args: _handle_show(),
640
692
  "suggest": lambda args: _handle_suggestion(),
641
693
  "tokens": lambda args: _handle_tokens(),
642
694
  "model": _handle_model,
695
+ "todo": lambda args: _handle_todo(),
643
696
  "theme": _handle_theme,
644
697
  "help": lambda args: _print_help(),
645
698
  }
@@ -4,6 +4,7 @@ from pathlib import Path
4
4
 
5
5
  CONFIG_DIR = Path.home() / ".messygit"
6
6
  CONFIG_FILE = CONFIG_DIR / "config.json"
7
+ TODO_FILE = CONFIG_DIR / "todo.md"
7
8
  ANTHROPIC_ENV_VAR = "ANTHROPIC_API_KEY"
8
9
 
9
10
  MISSING_API_KEY_MESSAGE = (
@@ -117,6 +118,22 @@ def save_model(name: str) -> None:
117
118
  _write_config(config)
118
119
 
119
120
 
121
+ def load_todo() -> str:
122
+ if not TODO_FILE.exists():
123
+ return ""
124
+ try:
125
+ with open(TODO_FILE) as f:
126
+ return f.read()
127
+ except OSError:
128
+ return ""
129
+
130
+
131
+ def save_todo(text: str) -> None:
132
+ CONFIG_DIR.mkdir(exist_ok=True)
133
+ with open(TODO_FILE, "w") as f:
134
+ f.write(text)
135
+
136
+
120
137
  def resolve_api_key() -> str:
121
138
  """Return API key from ANTHROPIC_API_KEY or ~/.messygit/config.json."""
122
139
  env_set = ANTHROPIC_ENV_VAR in os.environ
@@ -313,4 +313,48 @@ def git_commit(message: str) -> CompletedProcess[str]:
313
313
  ["git", "commit", "-m", message],
314
314
  capture_output=True,
315
315
  text=True,
316
- )
316
+ )
317
+
318
+
319
+ @dataclass
320
+ class UnpushedCommit:
321
+ short_hash: str
322
+ subject: str
323
+
324
+
325
+ @dataclass
326
+ class Outbox:
327
+ """Commits made locally but not yet on the upstream branch."""
328
+
329
+ upstream: str | None
330
+ commits: list[UnpushedCommit]
331
+
332
+
333
+ def get_unpushed_commits() -> Outbox:
334
+ """Return commits that are ahead of the current branch's upstream.
335
+
336
+ `upstream` is None when the branch has no configured upstream (e.g. it was
337
+ never pushed); in that case `commits` is empty and the caller should tell the
338
+ user to push/track the branch first.
339
+ """
340
+ upstream = subprocess.run(
341
+ ["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
342
+ capture_output=True,
343
+ text=True,
344
+ )
345
+ if upstream.returncode != 0:
346
+ return Outbox(upstream=None, commits=[])
347
+ upstream_ref = upstream.stdout.strip()
348
+
349
+ log = subprocess.run(
350
+ ["git", "log", "--format=%h%x00%s", "@{upstream}..HEAD"],
351
+ capture_output=True,
352
+ text=True,
353
+ )
354
+ commits: list[UnpushedCommit] = []
355
+ for line in log.stdout.splitlines():
356
+ if not line:
357
+ continue
358
+ short_hash, _, subject = line.partition("\x00")
359
+ commits.append(UnpushedCommit(short_hash=short_hash, subject=subject))
360
+ return Outbox(upstream=upstream_ref, commits=commits)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "messygit"
7
- version = "0.3.0"
7
+ version = "0.3.2"
8
8
  description = "An AI-powered interactive git CLI with agentic tools for commits, code suggestions, and workflow automation."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -17,3 +17,6 @@ dependencies = [
17
17
 
18
18
  [project.scripts]
19
19
  messygit = "messygit.cli:main"
20
+
21
+ [project.optional-dependencies]
22
+ dev = ["pytest>=8.0"]
@@ -0,0 +1,245 @@
1
+ """Drive Agent.run against a simulated Anthropic client.
2
+
3
+ We replace the SDK client with a FakeClient that returns a preset queue of
4
+ responses (text and/or tool_use blocks) and records every messages.create()
5
+ call, so we can assert the agent actually executes tools, feeds results back,
6
+ handles errors, and respects its iteration cap — all without a network call.
7
+ """
8
+
9
+ from types import SimpleNamespace
10
+
11
+ import pytest
12
+
13
+ from messygit.agent import agent as agent_mod
14
+ from messygit.agent.agent import Agent
15
+ from messygit.agent.tool import Tool
16
+ from messygit.usage import SESSION_USAGE
17
+
18
+
19
+ # --- fakes for the Anthropic SDK surface the agent touches ----------------
20
+
21
+ class FakeUsage:
22
+ def __init__(self, input_tokens=10, output_tokens=5):
23
+ self.input_tokens = input_tokens
24
+ self.output_tokens = output_tokens
25
+ self.cache_creation_input_tokens = 0
26
+ self.cache_read_input_tokens = 0
27
+
28
+
29
+ def text_block(text):
30
+ return SimpleNamespace(type="text", text=text)
31
+
32
+
33
+ def tool_use_block(name, tool_input, id="tu_1"):
34
+ return SimpleNamespace(type="tool_use", name=name, input=tool_input, id=id)
35
+
36
+
37
+ def response(*blocks, usage=None):
38
+ return SimpleNamespace(content=list(blocks), usage=usage or FakeUsage())
39
+
40
+
41
+ class FakeMessages:
42
+ def __init__(self, scripted):
43
+ self._scripted = list(scripted)
44
+ self.calls = [] # every kwargs dict passed to create()
45
+
46
+ def create(self, **kwargs):
47
+ # The agent mutates one `messages` list in place across iterations, so
48
+ # snapshot it here to capture what was actually sent on *this* call.
49
+ snapshot = dict(kwargs)
50
+ snapshot["messages"] = list(kwargs["messages"])
51
+ self.calls.append(snapshot)
52
+ if not self._scripted:
53
+ raise AssertionError("agent made more API calls than were scripted")
54
+ return self._scripted.pop(0)
55
+
56
+
57
+ class FakeClient:
58
+ def __init__(self, scripted):
59
+ self.messages = FakeMessages(scripted)
60
+
61
+
62
+ # --- test helpers ---------------------------------------------------------
63
+
64
+ def make_spy_tool(name="read_file", returns="FILE CONTENTS"):
65
+ """A real Tool wrapping a spy fn that records the kwargs it was called with."""
66
+ calls = []
67
+
68
+ def fn(**kwargs):
69
+ calls.append(kwargs)
70
+ return returns
71
+
72
+ tool = Tool(
73
+ name=name,
74
+ description="spy tool",
75
+ function=fn,
76
+ parameters={"path": {"type": "string"}},
77
+ required=["path"],
78
+ )
79
+ return tool, calls
80
+
81
+
82
+ def last_tool_results(client):
83
+ """The tool_result list the agent sent on its most recent create() call."""
84
+ return client.messages.calls[-1]["messages"][-1]["content"]
85
+
86
+
87
+ @pytest.fixture(autouse=True)
88
+ def reset_usage():
89
+ SESSION_USAGE.input = SESSION_USAGE.output = SESSION_USAGE.requests = 0
90
+ SESSION_USAGE.cost = 0.0
91
+ yield
92
+
93
+
94
+ @pytest.fixture
95
+ def run_agent(monkeypatch):
96
+ """Patch the agent's SDK dependencies and run it against scripted responses."""
97
+ model = SimpleNamespace(
98
+ id="claude-test", input_cost_per_token=0.0, output_cost_per_token=0.0
99
+ )
100
+ monkeypatch.setattr(agent_mod, "current_model", lambda: model)
101
+ monkeypatch.setattr(agent_mod, "resolve_api_key", lambda: "test-key")
102
+
103
+ def _run(scripted, tools, user_input="go", max_iterations=8):
104
+ client = FakeClient(scripted)
105
+ monkeypatch.setattr(agent_mod, "Anthropic", lambda **kwargs: client)
106
+ agent = Agent(
107
+ name="t",
108
+ system_prompt="sys",
109
+ max_iterations=max_iterations,
110
+ tools=tools,
111
+ )
112
+ result = agent.run(user_input)
113
+ return result, client
114
+
115
+ return _run
116
+
117
+
118
+ # --- the happy path: no tools, then with tools ----------------------------
119
+
120
+ def test_returns_text_without_calling_tools(run_agent):
121
+ tool, calls = make_spy_tool()
122
+ result, client = run_agent([response(text_block("just an answer"))], [tool])
123
+ assert result == "just an answer"
124
+ assert calls == [] # tool never invoked
125
+ assert len(client.messages.calls) == 1 # only one round-trip
126
+
127
+
128
+ def test_executes_tool_then_returns_final_text(run_agent):
129
+ tool, calls = make_spy_tool(returns="print('hi')")
130
+ scripted = [
131
+ response(tool_use_block("read_file", {"path": "x.py"}, id="tu_1")),
132
+ response(text_block("the file prints hi")),
133
+ ]
134
+ result, client = run_agent(scripted, [tool])
135
+ assert calls == [{"path": "x.py"}] # tool ran with the model's args
136
+ assert result == "the file prints hi"
137
+ assert len(client.messages.calls) == 2
138
+
139
+
140
+ def test_tool_result_is_sent_back_to_model(run_agent):
141
+ tool, _ = make_spy_tool(returns="RESULT-DATA")
142
+ scripted = [
143
+ response(tool_use_block("read_file", {"path": "x.py"}, id="tu_42")),
144
+ response(text_block("done")),
145
+ ]
146
+ _, client = run_agent(scripted, [tool])
147
+ tr = last_tool_results(client)[0]
148
+ assert tr["type"] == "tool_result"
149
+ assert tr["tool_use_id"] == "tu_42" # id is threaded through correctly
150
+ assert tr["content"] == "RESULT-DATA"
151
+ assert "is_error" not in tr
152
+
153
+
154
+ def test_first_call_sends_user_input_and_tool_schemas(run_agent):
155
+ tool, _ = make_spy_tool()
156
+ _, client = run_agent([response(text_block("ok"))], [tool], user_input="analyze repo")
157
+ first = client.messages.calls[0]
158
+ assert first["messages"][0] == {"role": "user", "content": "analyze repo"}
159
+ assert first["system"] == "sys"
160
+ assert first["tool_choice"] == {"type": "auto"}
161
+ assert first["tools"][0]["name"] == "read_file"
162
+ assert first["tools"][0]["input_schema"]["type"] == "object"
163
+
164
+
165
+ # --- multiple tool calls in one assistant turn ----------------------------
166
+
167
+ def test_multiple_tool_uses_in_one_turn_all_execute(run_agent):
168
+ tool, calls = make_spy_tool()
169
+ scripted = [
170
+ response(
171
+ tool_use_block("read_file", {"path": "a"}, id="t1"),
172
+ tool_use_block("read_file", {"path": "b"}, id="t2"),
173
+ ),
174
+ response(text_block("both read")),
175
+ ]
176
+ result, client = run_agent(scripted, [tool])
177
+ assert calls == [{"path": "a"}, {"path": "b"}]
178
+ assert [tr["tool_use_id"] for tr in last_tool_results(client)] == ["t1", "t2"]
179
+ assert result == "both read"
180
+
181
+
182
+ # --- error handling -------------------------------------------------------
183
+
184
+ def test_unknown_tool_returns_error_result_and_continues(run_agent):
185
+ tool, calls = make_spy_tool(name="read_file")
186
+ scripted = [
187
+ response(tool_use_block("does_not_exist", {}, id="tu_x")),
188
+ response(text_block("recovered")),
189
+ ]
190
+ result, client = run_agent(scripted, [tool])
191
+ assert result == "recovered"
192
+ assert calls == [] # the real tool was never called
193
+ tr = client.messages.calls[1]["messages"][-1]["content"][0]
194
+ assert tr["is_error"] is True
195
+ assert "does_not_exist" in tr["content"]
196
+
197
+
198
+ def test_tool_exception_becomes_error_result(run_agent):
199
+ def boom(**kwargs):
200
+ raise RuntimeError("kaboom")
201
+
202
+ tool = Tool(
203
+ name="read_file",
204
+ description="explodes",
205
+ function=boom,
206
+ parameters={"path": {"type": "string"}},
207
+ required=["path"],
208
+ )
209
+ scripted = [
210
+ response(tool_use_block("read_file", {"path": "x"}, id="tu_e")),
211
+ response(text_block("handled")),
212
+ ]
213
+ result, client = run_agent(scripted, [tool])
214
+ assert result == "handled"
215
+ tr = client.messages.calls[1]["messages"][-1]["content"][0]
216
+ assert tr["is_error"] is True
217
+ assert "kaboom" in tr["content"]
218
+
219
+
220
+ # --- iteration cap --------------------------------------------------------
221
+
222
+ def test_stops_at_max_iterations(run_agent):
223
+ tool, calls = make_spy_tool()
224
+ # The model never yields plain text, so only the cap can end the loop.
225
+ scripted = [
226
+ response(tool_use_block("read_file", {"path": str(i)}, id=f"tu_{i}"))
227
+ for i in range(10)
228
+ ]
229
+ result, client = run_agent(scripted, [tool], max_iterations=3)
230
+ assert len(client.messages.calls) == 3 # never exceeds the cap
231
+ assert len(calls) == 3
232
+
233
+
234
+ # --- usage accounting -----------------------------------------------------
235
+
236
+ def test_usage_is_recorded_per_api_call(run_agent):
237
+ tool, _ = make_spy_tool()
238
+ scripted = [
239
+ response(tool_use_block("read_file", {"path": "a"}, id="t1"), usage=FakeUsage(7, 3)),
240
+ response(text_block("done"), usage=FakeUsage(4, 2)),
241
+ ]
242
+ run_agent(scripted, [tool])
243
+ assert SESSION_USAGE.requests == 2
244
+ assert SESSION_USAGE.input == 11 # 7 + 4
245
+ assert SESSION_USAGE.output == 5 # 3 + 2
@@ -0,0 +1,128 @@
1
+ import json
2
+
3
+ import pytest
4
+
5
+ from messygit import config
6
+
7
+
8
+ @pytest.fixture(autouse=True)
9
+ def isolated_config(tmp_path, monkeypatch):
10
+ """Redirect config/todo files to a temp dir and clear the API-key env var.
11
+
12
+ autouse=True means every test in this module runs against a throwaway
13
+ directory instead of the real ~/.messygit, and never sees a key that
14
+ happens to be exported in the developer's (or CI runner's) environment.
15
+ """
16
+ config_dir = tmp_path / ".messygit"
17
+ monkeypatch.setattr(config, "CONFIG_DIR", config_dir)
18
+ monkeypatch.setattr(config, "CONFIG_FILE", config_dir / "config.json")
19
+ monkeypatch.setattr(config, "TODO_FILE", config_dir / "todo.md")
20
+ monkeypatch.delenv(config.ANTHROPIC_ENV_VAR, raising=False)
21
+ return config_dir
22
+
23
+
24
+ # --- save_api_key / load_api_key ------------------------------------------
25
+
26
+ def test_save_then_load_api_key_roundtrips():
27
+ config.save_api_key("sk-ant-secret")
28
+ assert config.load_api_key() == "sk-ant-secret"
29
+
30
+
31
+ def test_save_api_key_strips_surrounding_whitespace():
32
+ config.save_api_key(" sk-ant-secret\n")
33
+ assert config.load_api_key() == "sk-ant-secret"
34
+
35
+
36
+ @pytest.mark.parametrize("bad", ["", " ", "\n\t"])
37
+ def test_save_api_key_rejects_blank(bad):
38
+ with pytest.raises(ValueError):
39
+ config.save_api_key(bad)
40
+
41
+
42
+ def test_load_api_key_returns_none_when_unset():
43
+ assert config.load_api_key() is None
44
+
45
+
46
+ def test_save_api_key_preserves_other_keys():
47
+ config.save_theme("midnight")
48
+ config.save_api_key("sk-ant-secret")
49
+ assert config.load_theme() == "midnight"
50
+ assert config.load_api_key() == "sk-ant-secret"
51
+
52
+
53
+ # --- resolve_api_key ------------------------------------------------------
54
+
55
+ def test_resolve_prefers_env_over_file(monkeypatch):
56
+ config.save_api_key("file-key")
57
+ monkeypatch.setenv(config.ANTHROPIC_ENV_VAR, "env-key")
58
+ assert config.resolve_api_key() == "env-key"
59
+
60
+
61
+ def test_resolve_falls_back_to_file_when_env_absent():
62
+ config.save_api_key("file-key")
63
+ assert config.resolve_api_key() == "file-key"
64
+
65
+
66
+ def test_resolve_raises_when_env_set_but_blank(monkeypatch):
67
+ monkeypatch.setenv(config.ANTHROPIC_ENV_VAR, " ")
68
+ with pytest.raises(config.MissingApiKeyError) as exc:
69
+ config.resolve_api_key()
70
+ assert exc.value.args[0] == config.EMPTY_ENV_API_KEY_MESSAGE
71
+
72
+
73
+ def test_resolve_raises_when_nothing_configured():
74
+ with pytest.raises(config.MissingApiKeyError) as exc:
75
+ config.resolve_api_key()
76
+ assert exc.value.args[0] == config.MISSING_API_KEY_MESSAGE
77
+
78
+
79
+ # --- theme / model --------------------------------------------------------
80
+
81
+ def test_theme_roundtrips():
82
+ config.save_theme("midnight")
83
+ assert config.load_theme() == "midnight"
84
+
85
+
86
+ def test_model_roundtrips():
87
+ config.save_model("claude-haiku-4-5")
88
+ assert config.load_model() == "claude-haiku-4-5"
89
+
90
+
91
+ # --- todo -----------------------------------------------------------------
92
+
93
+ def test_todo_roundtrips():
94
+ config.save_todo("- ship outbox command\n")
95
+ assert config.load_todo() == "- ship outbox command\n"
96
+
97
+
98
+ def test_load_todo_empty_when_missing():
99
+ assert config.load_todo() == ""
100
+
101
+
102
+ # --- corrupt / malformed config ------------------------------------------
103
+
104
+ def test_corrupt_config_is_ignored(isolated_config):
105
+ isolated_config.mkdir(parents=True, exist_ok=True)
106
+ (isolated_config / "config.json").write_text("{not valid json")
107
+ # _read_config swallows the error and returns {}, so loads are None.
108
+ assert config.load_api_key() is None
109
+ # ...and a subsequent save overwrites the garbage cleanly.
110
+ config.save_api_key("sk-ant-secret")
111
+ assert json.loads((isolated_config / "config.json").read_text()) == {
112
+ "api_key": "sk-ant-secret"
113
+ }
114
+
115
+
116
+ # --- mask_api_key ---------------------------------------------------------
117
+
118
+ def test_mask_api_key_hides_middle():
119
+ assert config.mask_api_key("sk-ant-abcdefghijklmnop") == "sk-ant-a...mnop"
120
+
121
+
122
+ @pytest.mark.parametrize("value,expected", [
123
+ (None, "(not set)"),
124
+ ("", "(not set)"),
125
+ ("short", "(set)"),
126
+ ])
127
+ def test_mask_api_key_edge_cases(value, expected):
128
+ assert config.mask_api_key(value) == expected
@@ -0,0 +1,199 @@
1
+ import textwrap
2
+
3
+ import pytest
4
+
5
+ from messygit.git import (
6
+ FileStat,
7
+ _compact_diff_for_files,
8
+ _is_noise_file,
9
+ _parse_compact_diff,
10
+ )
11
+
12
+
13
+ def diff(text: str) -> str:
14
+ """Dedent a triple-quoted diff fixture and strip the leading newline."""
15
+ return textwrap.dedent(text).lstrip("\n")
16
+
17
+
18
+ # --- _is_noise_file -------------------------------------------------------
19
+
20
+ @pytest.mark.parametrize("path", [
21
+ "package-lock.json",
22
+ "frontend/yarn.lock",
23
+ "go.sum",
24
+ "app/bundle.min.js",
25
+ "styles/site.min.css",
26
+ "src/api.pb.go",
27
+ "schema.generated.ts",
28
+ "components/Button.snap",
29
+ "nested/dir/.DS_Store",
30
+ ])
31
+ def test_noise_files_are_detected(path):
32
+ assert _is_noise_file(path) is True
33
+
34
+
35
+ @pytest.mark.parametrize("path", [
36
+ "messygit/cli.py",
37
+ "README.md",
38
+ "src/index.js",
39
+ "lockfile_reader.py", # 'lock' in name but not an actual lockfile
40
+ "minify.py", # 'min' substring, not *.min.js
41
+ ])
42
+ def test_real_source_files_are_not_noise(path):
43
+ assert _is_noise_file(path) is False
44
+
45
+
46
+ # --- _parse_compact_diff --------------------------------------------------
47
+
48
+ def test_parse_single_file_keeps_only_changed_lines():
49
+ raw = diff("""
50
+ diff --git a/messygit/cli.py b/messygit/cli.py
51
+ index 1111111..2222222 100644
52
+ --- a/messygit/cli.py
53
+ +++ b/messygit/cli.py
54
+ @@ -10,1 +10,1 @@ def main():
55
+ - old_call()
56
+ + new_call()
57
+ """)
58
+ assert _parse_compact_diff(raw) == diff("""
59
+ === messygit/cli.py ===
60
+ - old_call()
61
+ + new_call()
62
+ """).rstrip()
63
+
64
+
65
+ def test_parse_drops_metadata_lines():
66
+ """index, ---, +++ and @@ hunk headers must never appear in output."""
67
+ raw = diff("""
68
+ diff --git a/a.py b/a.py
69
+ index 1111111..2222222 100644
70
+ --- a/a.py
71
+ +++ b/a.py
72
+ @@ -1,0 +1,1 @@
73
+ +added
74
+ """)
75
+ out = _parse_compact_diff(raw)
76
+ assert "index" not in out
77
+ assert "@@" not in out
78
+ assert "---" not in out
79
+ assert "+++" not in out
80
+ assert out == "=== a.py ===\n+added"
81
+
82
+
83
+ def test_parse_multiple_files():
84
+ raw = diff("""
85
+ diff --git a/one.py b/one.py
86
+ --- a/one.py
87
+ +++ b/one.py
88
+ @@ -1 +1 @@
89
+ -a
90
+ +b
91
+ diff --git a/two.py b/two.py
92
+ --- a/two.py
93
+ +++ b/two.py
94
+ @@ -1 +0,0 @@
95
+ -gone
96
+ """)
97
+ # A blank line separates files: each header is emitted with a leading "\n",
98
+ # and the very first one is removed by the trailing .strip().
99
+ assert _parse_compact_diff(raw) == diff("""
100
+ === one.py ===
101
+ -a
102
+ +b
103
+
104
+ === two.py ===
105
+ -gone
106
+ """).rstrip()
107
+
108
+
109
+ def test_parse_skips_noise_file_entirely():
110
+ """A noise file should not even get a === header, but real files still do."""
111
+ raw = diff("""
112
+ diff --git a/package-lock.json b/package-lock.json
113
+ --- a/package-lock.json
114
+ +++ b/package-lock.json
115
+ @@ -1 +1 @@
116
+ - "version": "1.0.0"
117
+ + "version": "1.0.1"
118
+ diff --git a/src/app.py b/src/app.py
119
+ --- a/src/app.py
120
+ +++ b/src/app.py
121
+ @@ -1 +1 @@
122
+ -x = 1
123
+ +x = 2
124
+ """)
125
+ out = _parse_compact_diff(raw)
126
+ assert "package-lock.json" not in out
127
+ assert "version" not in out
128
+ assert out == "=== src/app.py ===\n-x = 1\n+x = 2"
129
+
130
+
131
+ def test_parse_empty_diff_returns_empty_string():
132
+ assert _parse_compact_diff("") == ""
133
+
134
+
135
+ def test_parse_new_file_only_additions():
136
+ raw = diff("""
137
+ diff --git a/new.py b/new.py
138
+ new file mode 100644
139
+ index 0000000..3333333
140
+ --- /dev/null
141
+ +++ b/new.py
142
+ @@ -0,0 +1,2 @@
143
+ +line one
144
+ +line two
145
+ """)
146
+ assert _parse_compact_diff(raw) == diff("""
147
+ === new.py ===
148
+ +line one
149
+ +line two
150
+ """).rstrip()
151
+
152
+
153
+ # --- _compact_diff_for_files ---------------------------------------------
154
+
155
+ TWO_FILE_DIFF = diff("""
156
+ diff --git a/keep.py b/keep.py
157
+ --- a/keep.py
158
+ +++ b/keep.py
159
+ @@ -1 +1 @@
160
+ -old
161
+ +new
162
+ diff --git a/drop.py b/drop.py
163
+ --- a/drop.py
164
+ +++ b/drop.py
165
+ @@ -1 +1 @@
166
+ -foo
167
+ +bar
168
+ """)
169
+
170
+
171
+ def test_compact_for_files_includes_only_selected_paths():
172
+ out = _compact_diff_for_files({"keep.py"}, TWO_FILE_DIFF)
173
+ assert out == "=== keep.py ===\n-old\n+new"
174
+ assert "drop.py" not in out
175
+ assert "bar" not in out
176
+
177
+
178
+ def test_compact_for_files_selects_multiple():
179
+ out = _compact_diff_for_files({"keep.py", "drop.py"}, TWO_FILE_DIFF)
180
+ assert "=== keep.py ===" in out
181
+ assert "=== drop.py ===" in out
182
+
183
+
184
+ def test_compact_for_files_empty_selection_returns_empty():
185
+ assert _compact_diff_for_files(set(), TWO_FILE_DIFF) == ""
186
+
187
+
188
+ def test_compact_for_files_unknown_path_returns_empty():
189
+ assert _compact_diff_for_files({"does/not/exist.py"}, TWO_FILE_DIFF) == ""
190
+
191
+
192
+ # --- FileStat -------------------------------------------------------------
193
+
194
+ def test_filestat_total_changed_sums_added_and_removed():
195
+ assert FileStat(path="a.py", added=3, removed=2).total_changed == 5
196
+
197
+
198
+ def test_filestat_total_changed_zero():
199
+ assert FileStat(path="a.py", added=0, removed=0).total_changed == 0
@@ -0,0 +1,105 @@
1
+ import pytest
2
+ from anthropic import APIStatusError, BadRequestError
3
+
4
+ from messygit import llm
5
+ from messygit.config import ANTHROPIC_INSUFFICIENT_BALANCE_MESSAGE
6
+
7
+
8
+ class FakeBadRequest(BadRequestError):
9
+ """A BadRequestError we can build without a live httpx response.
10
+
11
+ The real SDK error needs an httpx.Response; the balance helpers only read
12
+ .status_code/.body/.message/.request_id and check isinstance(BadRequestError),
13
+ so we override __init__ and set just those attributes.
14
+ """
15
+
16
+ def __init__(self, *, body=None, message="", status_code=400, request_id=None):
17
+ self.body = body
18
+ self.message = message
19
+ self.status_code = status_code
20
+ self.request_id = request_id
21
+
22
+
23
+ class FakeAPIStatusError(APIStatusError):
24
+ """A non-BadRequest APIStatusError (e.g. a 402 or 5xx)."""
25
+
26
+ def __init__(self, *, body=None, message="", status_code=500, request_id=None):
27
+ self.body = body
28
+ self.message = message
29
+ self.status_code = status_code
30
+ self.request_id = request_id
31
+
32
+
33
+ def billing_body(message="Your credit balance is too low"):
34
+ return {"type": "error", "error": {"type": "billing_error", "message": message}}
35
+
36
+
37
+ # --- _is_insufficient_balance_or_billing ----------------------------------
38
+
39
+ def test_status_402_is_billing_even_without_billing_type():
40
+ exc = FakeAPIStatusError(status_code=402, body={"error": {"type": "invalid_request_error"}})
41
+ assert llm._is_insufficient_balance_or_billing(exc) is True
42
+
43
+
44
+ def test_nested_billing_error_type_is_detected():
45
+ exc = FakeAPIStatusError(status_code=400, body=billing_body())
46
+ assert llm._is_insufficient_balance_or_billing(exc) is True
47
+
48
+
49
+ @pytest.mark.parametrize("hint", [
50
+ "Your credit balance is too low to access the API.",
51
+ "balance too low",
52
+ "insufficient credit on this account",
53
+ "the account has no credit",
54
+ "you are out of credit",
55
+ ])
56
+ def test_bad_request_balance_hints_in_message(hint):
57
+ exc = FakeBadRequest(status_code=400, message=hint)
58
+ assert llm._is_insufficient_balance_or_billing(exc) is True
59
+
60
+
61
+ def test_bad_request_hint_in_nested_body_message():
62
+ exc = FakeBadRequest(
63
+ status_code=400,
64
+ message="Bad request",
65
+ body={"error": {"type": "invalid_request_error", "message": "credit balance is too low"}},
66
+ )
67
+ assert llm._is_insufficient_balance_or_billing(exc) is True
68
+
69
+
70
+ def test_unrelated_bad_request_is_not_billing():
71
+ exc = FakeBadRequest(status_code=400, message="max_tokens: must be greater than 0")
72
+ assert llm._is_insufficient_balance_or_billing(exc) is False
73
+
74
+
75
+ def test_generic_non_billing_status_error_is_not_billing():
76
+ # A 500 with no billing markers: must not be misclassified as a balance issue.
77
+ exc = FakeAPIStatusError(status_code=500, message="internal server error")
78
+ assert llm._is_insufficient_balance_or_billing(exc) is False
79
+
80
+
81
+ def test_hint_matching_is_case_insensitive():
82
+ exc = FakeBadRequest(status_code=400, message="CREDIT BALANCE TOO LOW")
83
+ assert llm._is_insufficient_balance_or_billing(exc) is True
84
+
85
+
86
+ # --- _insufficient_balance_user_message -----------------------------------
87
+
88
+ def test_user_message_without_request_id_is_the_base_message():
89
+ exc = FakeBadRequest(status_code=400, message="balance too low", request_id=None)
90
+ assert llm._insufficient_balance_user_message(exc) == ANTHROPIC_INSUFFICIENT_BALANCE_MESSAGE
91
+
92
+
93
+ def test_user_message_appends_request_id_when_present():
94
+ exc = FakeBadRequest(status_code=400, message="balance too low", request_id="req_abc123")
95
+ msg = llm._insufficient_balance_user_message(exc)
96
+ assert msg.startswith(ANTHROPIC_INSUFFICIENT_BALANCE_MESSAGE)
97
+ assert msg.endswith("Request ID for support: req_abc123.")
98
+
99
+
100
+ # --- nested-body helpers (defensive parsing) ------------------------------
101
+
102
+ @pytest.mark.parametrize("body", [None, "a string", 42, {"error": "not a dict"}, {}])
103
+ def test_nested_helpers_tolerate_malformed_bodies(body):
104
+ assert llm._nested_api_error_type(body) is None
105
+ assert llm._nested_api_error_message(body) == ""
@@ -0,0 +1,86 @@
1
+ import pytest
2
+
3
+ from messygit.agent import tools as tools_mod
4
+ from messygit.agent.tool import Tool
5
+
6
+ # Every Tool exposed to the model. The suggestion agent passes the schemas of
7
+ # these straight to client.messages.create(tools=[...]), so each must match the
8
+ # shape the Anthropic Messages API expects for a tool definition.
9
+ ALL_TOOLS = [
10
+ tools_mod.run_git_tool,
11
+ tools_mod.read_file_tool,
12
+ tools_mod.list_directory_tool,
13
+ tools_mod.search_code_tool,
14
+ ]
15
+
16
+
17
+ @pytest.fixture(params=ALL_TOOLS, ids=lambda t: t.name)
18
+ def schema(request):
19
+ return request.param.to_schema()
20
+
21
+
22
+ # --- top-level shape ------------------------------------------------------
23
+
24
+ def test_schema_has_exactly_the_expected_top_level_keys(schema):
25
+ # Anthropic tool definitions are {name, description, input_schema}.
26
+ # "required" belongs *inside* input_schema, never at the top level.
27
+ assert set(schema) == {"name", "description", "input_schema"}
28
+
29
+
30
+ def test_name_is_nonempty_string(schema):
31
+ assert isinstance(schema["name"], str) and schema["name"]
32
+
33
+
34
+ def test_description_is_nonempty_string(schema):
35
+ # A description is optional to the API but we want every tool to have one.
36
+ assert isinstance(schema["description"], str) and schema["description"].strip()
37
+
38
+
39
+ # --- input_schema (JSON Schema object) ------------------------------------
40
+
41
+ def test_input_schema_is_a_json_schema_object(schema):
42
+ input_schema = schema["input_schema"]
43
+ assert input_schema["type"] == "object"
44
+ assert isinstance(input_schema["properties"], dict)
45
+
46
+
47
+ def test_every_property_declares_a_type(schema):
48
+ for name, prop in schema["input_schema"]["properties"].items():
49
+ assert isinstance(prop, dict), name
50
+ assert "type" in prop, name
51
+
52
+
53
+ def test_required_is_a_list_referencing_real_properties(schema):
54
+ input_schema = schema["input_schema"]
55
+ if "required" not in input_schema:
56
+ return
57
+ required = input_schema["required"]
58
+ assert isinstance(required, list)
59
+ properties = input_schema["properties"]
60
+ for field in required:
61
+ assert field in properties, f"required field {field!r} missing from properties"
62
+
63
+
64
+ def test_tool_names_are_unique():
65
+ names = [t.name for t in ALL_TOOLS]
66
+ assert len(names) == len(set(names))
67
+
68
+
69
+ # --- to_schema behaviour for the `required` field -------------------------
70
+
71
+ def test_required_omitted_when_not_set():
72
+ bare = Tool(name="noop", description="does nothing", function=lambda: None)
73
+ assert "required" not in bare.to_schema()["input_schema"]
74
+
75
+
76
+ def test_required_included_when_set():
77
+ t = Tool(
78
+ name="echo",
79
+ description="echo text",
80
+ function=lambda text: text,
81
+ parameters={"text": {"type": "string"}},
82
+ required=["text"],
83
+ )
84
+ schema = t.to_schema()
85
+ assert schema["input_schema"]["required"] == ["text"]
86
+ assert schema["input_schema"]["properties"]["text"]["type"] == "string"
@@ -1,82 +0,0 @@
1
- import os
2
- from .tool import Tool
3
- from ..git import get_staged_diff, get_staged_files
4
- import subprocess
5
-
6
- ALLOWED_GIT_COMMANDS = ["log", "diff", "status", "show", "status", "shortlog", "blame"]
7
-
8
- def run_git(args: list[str]) -> str:
9
- if not args or args[0] not in ALLOWED_GIT_COMMANDS:
10
- return "Invalid git command."
11
- result = subprocess.run(["git", *args], capture_output=True, text=True)
12
- return result.stdout or result.stderr
13
-
14
- run_git_tool = Tool(
15
- name="run_git",
16
- description="Run a git command",
17
- function=run_git,
18
- parameters={
19
- "args": {
20
- "type": "array",
21
- "items": {"type": "string"},
22
- },
23
- },
24
- )
25
-
26
- def read_file(path: str) -> str:
27
- try:
28
- with open(path, "r") as file:
29
- return file.read()
30
- except FileNotFoundError:
31
- return "File not found."
32
- except PermissionError:
33
- return "Permission denied."
34
- except Exception as e:
35
- return f"Error reading file: {e}"
36
-
37
- read_file_tool = Tool(
38
- name="read_file",
39
- description="Read a file",
40
- function=read_file,
41
- parameters={
42
- "path": {
43
- "type": "string",
44
- },
45
- },
46
- )
47
-
48
- def list_directory(path: str) -> list[str]:
49
- try:
50
- return os.listdir(path)
51
- except FileNotFoundError:
52
- return "Directory not found."
53
- except PermissionError:
54
- return "Permission denied."
55
- except Exception as e:
56
- return f"Error listing directory: {e}"
57
-
58
- list_directory_tool = Tool(
59
- name="list_directory",
60
- description="List a directory",
61
- function=list_directory,
62
- parameters={
63
- "path": {
64
- "type": "string",
65
- },
66
- },
67
- )
68
-
69
- def search_code(query: str) -> str:
70
- result = subprocess.run(["git", "grep", "-n", query], capture_output=True, text=True)
71
- return result.stdout or result.stderr
72
-
73
- search_code_tool = Tool(
74
- name="search_code",
75
- description="Search the codebase for a query",
76
- function=search_code,
77
- parameters={
78
- "query": {
79
- "type": "string",
80
- },
81
- },
82
- )
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes