code-puppy 0.0.74__tar.gz → 0.0.76__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 (29) hide show
  1. {code_puppy-0.0.74 → code_puppy-0.0.76}/PKG-INFO +4 -2
  2. {code_puppy-0.0.74 → code_puppy-0.0.76}/README.md +2 -0
  3. {code_puppy-0.0.74 → code_puppy-0.0.76}/code_puppy/agent.py +2 -4
  4. code_puppy-0.0.76/code_puppy/command_line/motd.py +49 -0
  5. {code_puppy-0.0.74 → code_puppy-0.0.76}/code_puppy/main.py +2 -26
  6. {code_puppy-0.0.74 → code_puppy-0.0.76}/code_puppy/model_factory.py +12 -1
  7. {code_puppy-0.0.74 → code_puppy-0.0.76}/code_puppy/models.json +15 -2
  8. {code_puppy-0.0.74 → code_puppy-0.0.76}/code_puppy/tools/__init__.py +1 -2
  9. {code_puppy-0.0.74 → code_puppy-0.0.76}/code_puppy/tools/command_runner.py +30 -14
  10. {code_puppy-0.0.74 → code_puppy-0.0.76}/code_puppy/tools/file_modifications.py +14 -5
  11. {code_puppy-0.0.74 → code_puppy-0.0.76}/code_puppy/tools/file_operations.py +59 -71
  12. {code_puppy-0.0.74 → code_puppy-0.0.76}/pyproject.toml +2 -2
  13. code_puppy-0.0.74/code_puppy/command_line/motd.py +0 -57
  14. code_puppy-0.0.74/code_puppy/tools/web_search.py +0 -32
  15. {code_puppy-0.0.74 → code_puppy-0.0.76}/.gitignore +0 -0
  16. {code_puppy-0.0.74 → code_puppy-0.0.76}/LICENSE +0 -0
  17. {code_puppy-0.0.74 → code_puppy-0.0.76}/code_puppy/__init__.py +0 -0
  18. {code_puppy-0.0.74 → code_puppy-0.0.76}/code_puppy/agent_prompts.py +0 -0
  19. {code_puppy-0.0.74 → code_puppy-0.0.76}/code_puppy/command_line/__init__.py +0 -0
  20. {code_puppy-0.0.74 → code_puppy-0.0.76}/code_puppy/command_line/file_path_completion.py +0 -0
  21. {code_puppy-0.0.74 → code_puppy-0.0.76}/code_puppy/command_line/meta_command_handler.py +0 -0
  22. {code_puppy-0.0.74 → code_puppy-0.0.76}/code_puppy/command_line/model_picker_completion.py +0 -0
  23. {code_puppy-0.0.74 → code_puppy-0.0.76}/code_puppy/command_line/prompt_toolkit_completion.py +0 -0
  24. {code_puppy-0.0.74 → code_puppy-0.0.76}/code_puppy/command_line/utils.py +0 -0
  25. {code_puppy-0.0.74 → code_puppy-0.0.76}/code_puppy/config.py +0 -0
  26. {code_puppy-0.0.74 → code_puppy-0.0.76}/code_puppy/session_memory.py +0 -0
  27. {code_puppy-0.0.74 → code_puppy-0.0.76}/code_puppy/tools/common.py +0 -0
  28. {code_puppy-0.0.74 → code_puppy-0.0.76}/code_puppy/tools/ts_code_map.py +0 -0
  29. {code_puppy-0.0.74 → code_puppy-0.0.76}/code_puppy/version_checker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-puppy
3
- Version: 0.0.74
3
+ Version: 0.0.76
4
4
  Summary: Code generation agent
5
5
  Author: Michael Pfaffenberger
6
6
  License: MIT
@@ -20,7 +20,7 @@ Requires-Dist: json-repair>=0.46.2
20
20
  Requires-Dist: logfire>=0.7.1
21
21
  Requires-Dist: pathspec>=0.11.0
22
22
  Requires-Dist: prompt-toolkit>=3.0.38
23
- Requires-Dist: pydantic-ai>=0.3.2
23
+ Requires-Dist: pydantic-ai>=0.4.8
24
24
  Requires-Dist: pydantic>=2.4.0
25
25
  Requires-Dist: pytest-cov>=6.1.1
26
26
  Requires-Dist: python-dotenv>=1.0.0
@@ -106,6 +106,8 @@ export MODELS_JSON_PATH=/path/to/custom/models.json
106
106
  }
107
107
  }
108
108
  ```
109
+ Note that the `OPENAI_API_KEY` env variable must be set when using `custom_openai` endpoints.
110
+
109
111
  Open an issue if your environment is somehow weirder than mine.
110
112
 
111
113
  Run specific tasks or engage in interactive mode:
@@ -73,6 +73,8 @@ export MODELS_JSON_PATH=/path/to/custom/models.json
73
73
  }
74
74
  }
75
75
  ```
76
+ Note that the `OPENAI_API_KEY` env variable must be set when using `custom_openai` endpoints.
77
+
76
78
  Open an issue if your environment is somehow weirder than mine.
77
79
 
78
80
  Run specific tasks or engage in interactive mode:
@@ -74,7 +74,7 @@ def _load_mcp_servers():
74
74
  url = conf.get("url")
75
75
  if url:
76
76
  console.print(f"Registering MCP Server - {url}")
77
- servers.append(MCPServerSSE(url))
77
+ servers.append(MCPServerSSE(url=url))
78
78
  return servers
79
79
 
80
80
 
@@ -100,13 +100,11 @@ def reload_code_generation_agent():
100
100
  if PUPPY_RULES:
101
101
  instructions += f"\n{PUPPY_RULES}"
102
102
 
103
- mcp_servers = _load_mcp_servers()
104
103
  agent = Agent(
105
104
  model=model,
106
105
  instructions=instructions,
107
- output_type=AgentResponse,
106
+ output_type=str,
108
107
  retries=3,
109
- mcp_servers=mcp_servers,
110
108
  )
111
109
  register_all_tools(agent)
112
110
  _code_generation_agent = agent
@@ -0,0 +1,49 @@
1
+ """
2
+ MOTD (Message of the Day) feature for code-puppy.
3
+ Stores seen versions in ~/.puppy_cfg/motd.txt.
4
+ """
5
+
6
+ import os
7
+
8
+ MOTD_VERSION = "20250802"
9
+ MOTD_MESSAGE = """
10
+ /¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯\\
11
+ | 🐾 Happy Sat-urday, Aug 2, 2025! |
12
+ | |
13
+ | Biscuit the code puppy is on full zoomie mode! |
14
+ | Major paws-up: We now integrate Cerebras Qwen3 Coder |
15
+ | 480b! YES, that’s 480 billion parameters of tail-wagging|
16
+ | code speed. It’s so fast, even my fetch can’t keep up! |
17
+ | |
18
+ | • Take stretch breaks – you’ll need ‘em! |
19
+ | • DRY your code, but keep your pup hydrated. |
20
+ | • If you hit a bug, treat yourself for finding it! |
21
+ | |
22
+ | Today: sniff, code, roll over, and let Cerebras Qwen3 |
23
+ | Coder 480b do the heavy lifting. Fire up a ~motd anytime|
24
+ | you need some puppy hype! |
25
+ \___________________________________________________________/
26
+ """
27
+ MOTD_TRACK_FILE = os.path.expanduser("~/.puppy_cfg/motd.txt")
28
+
29
+
30
+ def has_seen_motd(version: str) -> bool:
31
+ if not os.path.exists(MOTD_TRACK_FILE):
32
+ return False
33
+ with open(MOTD_TRACK_FILE, "r") as f:
34
+ seen_versions = {line.strip() for line in f if line.strip()}
35
+ return version in seen_versions
36
+
37
+
38
+ def mark_motd_seen(version: str):
39
+ os.makedirs(os.path.dirname(MOTD_TRACK_FILE), exist_ok=True)
40
+ with open(MOTD_TRACK_FILE, "a") as f:
41
+ f.write(f"{version}\n")
42
+
43
+
44
+ def print_motd(console, force: bool = False) -> bool:
45
+ if force or not has_seen_motd(MOTD_VERSION):
46
+ console.print(MOTD_MESSAGE)
47
+ mark_motd_seen(MOTD_VERSION)
48
+ return True
49
+ return False
@@ -69,19 +69,7 @@ async def main():
69
69
  async with agent.run_mcp_servers():
70
70
  response = await agent.run(command)
71
71
  agent_response = response.output
72
- console.print(agent_response.output_message)
73
- # Log to session memory
74
- session_memory().log_task(
75
- f"Command executed: {command}",
76
- extras={
77
- "output": agent_response.output_message,
78
- "awaiting_user_input": agent_response.awaiting_user_input,
79
- },
80
- )
81
- if agent_response.awaiting_user_input:
82
- console.print(
83
- "[bold red]The agent requires further input. Interactive mode is recommended for such tasks."
84
- )
72
+ console.print(agent_response)
85
73
  break
86
74
  except AttributeError as e:
87
75
  console.print(f"[bold red]AttributeError:[/bold red] {str(e)}")
@@ -215,15 +203,8 @@ async def interactive_mode(history_file_path: str) -> None:
215
203
  result = await agent.run(task, message_history=message_history)
216
204
  # Get the structured response
217
205
  agent_response = result.output
218
- console.print(agent_response.output_message)
206
+ console.print(agent_response)
219
207
  # Log to session memory
220
- session_memory().log_task(
221
- f"Interactive task: {task}",
222
- extras={
223
- "output": agent_response.output_message,
224
- "awaiting_user_input": agent_response.awaiting_user_input,
225
- },
226
- )
227
208
 
228
209
  # Update message history but apply filters & limits
229
210
  new_msgs = result.new_messages()
@@ -273,11 +254,6 @@ async def interactive_mode(history_file_path: str) -> None:
273
254
  message_history = truncated
274
255
  # --- END GROUP-AWARE TRUNCATION LOGIC ---
275
256
 
276
- if agent_response and agent_response.awaiting_user_input:
277
- console.print(
278
- "\n[bold yellow]\u26a0 Agent needs your input to continue.[/bold yellow]"
279
- )
280
-
281
257
  # Show context status
282
258
  console.print(
283
259
  f"[dim]Context: {len(message_history)} messages in history[/dim]\n"
@@ -11,6 +11,7 @@ from pydantic_ai.models.openai import OpenAIModel
11
11
  from pydantic_ai.providers.anthropic import AnthropicProvider
12
12
  from pydantic_ai.providers.google_gla import GoogleGLAProvider
13
13
  from pydantic_ai.providers.openai import OpenAIProvider
14
+ from pydantic_ai.providers.openrouter import OpenRouterProvider
14
15
 
15
16
  # Environment variables used in this module:
16
17
  # - GEMINI_API_KEY: API key for Google's Gemini models. Required when using Gemini models.
@@ -173,6 +174,16 @@ class ModelFactory:
173
174
  model = OpenAIModel(model_name=model_config["name"], provider=provider)
174
175
  setattr(model, "provider", provider)
175
176
  return model
176
-
177
+ elif model_type == "openrouter":
178
+ api_key = None
179
+ if "api_key" in model_config:
180
+ if model_config["api_key"].startswith("$"):
181
+ api_key = os.environ.get(model_config["api_key"][1:])
182
+ else:
183
+ api_key = model_config["api_key"]
184
+ provider = OpenRouterProvider(api_key=api_key)
185
+ model_name = model_config.get("name")
186
+ model = OpenAIModel(model_name, provider=provider)
187
+ return model
177
188
  else:
178
189
  raise ValueError(f"Unsupported model type: {model_type}")
@@ -37,9 +37,9 @@
37
37
  "url": "http://localhost:11434/v1"
38
38
  }
39
39
  },
40
- "meta-llama/Llama-3.3-70B-Instruct-Turbo": {
40
+ "Qwen/Qwen3-235B-A22B-fp8-tput": {
41
41
  "type": "custom_openai",
42
- "name": "meta-llama/Llama-3.3-70B-Instruct-Turbo",
42
+ "name": "Qwen/Qwen3-235B-A22B-fp8-tput",
43
43
  "custom_endpoint": {
44
44
  "url": "https://api.together.xyz/v1",
45
45
  "api_key": "$TOGETHER_API_KEY"
@@ -53,6 +53,11 @@
53
53
  "api_key": "$XAI_API_KEY"
54
54
  }
55
55
  },
56
+ "openrouter": {
57
+ "type": "openrouter",
58
+ "name": "meta-llama/llama-4-maverick:free",
59
+ "api_key": "$OPENROUTER_API_KEY"
60
+ },
56
61
  "azure-gpt-4.1": {
57
62
  "type": "azure_openai",
58
63
  "name": "gpt-4.1",
@@ -66,5 +71,13 @@
66
71
  "api_version": "2024-12-01-preview",
67
72
  "api_key": "$AZURE_OPENAI_API_KEY",
68
73
  "azure_endpoint": "$AZURE_OPENAI_ENDPOINT"
74
+ },
75
+ "Cerebras-Qwen3-Coder-480b": {
76
+ "type": "custom_openai",
77
+ "name": "qwen-3-coder-480b",
78
+ "custom_endpoint": {
79
+ "url": "https://api.cerebras.ai/v1",
80
+ "api_key": "$CEREBRAS_API_KEY"
81
+ }
69
82
  }
70
83
  }
@@ -1,12 +1,11 @@
1
1
  from code_puppy.tools.command_runner import register_command_runner_tools
2
2
  from code_puppy.tools.file_modifications import register_file_modifications_tools
3
3
  from code_puppy.tools.file_operations import register_file_operations_tools
4
- from code_puppy.tools.web_search import register_web_search_tools
5
4
 
6
5
 
7
6
  def register_all_tools(agent):
8
7
  """Register all available tools to the provided agent."""
8
+
9
9
  register_file_operations_tools(agent)
10
10
  register_file_modifications_tools(agent)
11
11
  register_command_runner_tools(agent)
12
- register_web_search_tools(agent)
@@ -2,6 +2,7 @@ import subprocess
2
2
  import time
3
3
  from typing import Any, Dict
4
4
 
5
+ from pydantic import BaseModel
5
6
  from pydantic_ai import RunContext
6
7
  from rich.markdown import Markdown
7
8
  from rich.syntax import Syntax
@@ -9,12 +10,22 @@ from rich.syntax import Syntax
9
10
  from code_puppy.tools.common import console
10
11
 
11
12
 
13
+ class ShellCommandOutput(BaseModel):
14
+ success: bool
15
+ command: str | None
16
+ error: str | None = ""
17
+ stdout: str | None
18
+ stderr: str | None
19
+ exit_code: int | None
20
+ execution_time: float | None
21
+ timeout: bool | None = False
22
+
12
23
  def run_shell_command(
13
24
  context: RunContext, command: str, cwd: str = None, timeout: int = 60
14
- ) -> Dict[str, Any]:
25
+ ) -> ShellCommandOutput:
15
26
  if not command or not command.strip():
16
27
  console.print("[bold red]Error:[/bold red] Command cannot be empty")
17
- return {"error": "Command cannot be empty"}
28
+ return ShellCommandOutput(**{"success": False, "error": "Command cannot be empty"})
18
29
  console.print(
19
30
  f"\n[bold white on blue] SHELL COMMAND [/bold white on blue] \U0001f4c2 [bold green]$ {command}[/bold green]"
20
31
  )
@@ -30,11 +41,11 @@ def run_shell_command(
30
41
  console.print(
31
42
  "[bold yellow]Command execution canceled by user.[/bold yellow]"
32
43
  )
33
- return {
44
+ return ShellCommandOutput(**{
34
45
  "success": False,
35
46
  "command": command,
36
47
  "error": "User canceled command execution",
37
- }
48
+ })
38
49
  try:
39
50
  start_time = time.time()
40
51
  process = subprocess.Popen(
@@ -84,7 +95,7 @@ def run_shell_command(
84
95
  "[bold yellow]This command produced no output at all![/bold yellow]"
85
96
  )
86
97
  console.print("[dim]" + "-" * 60 + "[/dim]\n")
87
- return {
98
+ return ShellCommandOutput(**{
88
99
  "success": exit_code == 0,
89
100
  "command": command,
90
101
  "stdout": stdout,
@@ -92,7 +103,7 @@ def run_shell_command(
92
103
  "exit_code": exit_code,
93
104
  "execution_time": execution_time,
94
105
  "timeout": False,
95
- }
106
+ })
96
107
  except subprocess.TimeoutExpired:
97
108
  process.kill()
98
109
  stdout, stderr = process.communicate()
@@ -123,7 +134,7 @@ def run_shell_command(
123
134
  f"[bold red]⏱ Command timed out after {timeout} seconds[/bold red] [dim](ran for {execution_time:.2f}s)[/dim]"
124
135
  )
125
136
  console.print("[dim]" + "-" * 60 + "[/dim]\n")
126
- return {
137
+ return ShellCommandOutput(**{
127
138
  "success": False,
128
139
  "command": command,
129
140
  "stdout": stdout[-1000:],
@@ -132,7 +143,7 @@ def run_shell_command(
132
143
  "execution_time": execution_time,
133
144
  "timeout": True,
134
145
  "error": f"Command timed out after {timeout} seconds",
135
- }
146
+ })
136
147
  except Exception as e:
137
148
  console.print_exception(show_locals=True)
138
149
  console.print("[dim]" + "-" * 60 + "[/dim]\n")
@@ -141,7 +152,7 @@ def run_shell_command(
141
152
  stdout = None
142
153
  if "stderr" not in locals():
143
154
  stderr = None
144
- return {
155
+ return ShellCommandOutput(**{
145
156
  "success": False,
146
157
  "command": command,
147
158
  "error": f"Error executing command: {str(e)}",
@@ -149,12 +160,17 @@ def run_shell_command(
149
160
  "stderr": stderr[-1000:] if stderr else None,
150
161
  "exit_code": -1,
151
162
  "timeout": False,
152
- }
163
+ })
164
+
165
+ class ReasoningOutput(BaseModel):
166
+ success: bool = True
167
+ reasoning: str = ""
168
+ next_steps: str = ""
153
169
 
154
170
 
155
171
  def share_your_reasoning(
156
172
  context: RunContext, reasoning: str, next_steps: str = None
157
- ) -> Dict[str, Any]:
173
+ ) -> ReasoningOutput:
158
174
  console.print("\n[bold white on purple] AGENT REASONING [/bold white on purple]")
159
175
  console.print("[bold cyan]Current reasoning:[/bold cyan]")
160
176
  console.print(Markdown(reasoning))
@@ -162,18 +178,18 @@ def share_your_reasoning(
162
178
  console.print("\n[bold cyan]Planned next steps:[/bold cyan]")
163
179
  console.print(Markdown(next_steps))
164
180
  console.print("[dim]" + "-" * 60 + "[/dim]\n")
165
- return {"success": True, "reasoning": reasoning, "next_steps": next_steps}
181
+ return ReasoningOutput(**{"success": True, "reasoning": reasoning, "next_steps": next_steps})
166
182
 
167
183
 
168
184
  def register_command_runner_tools(agent):
169
185
  @agent.tool
170
186
  def agent_run_shell_command(
171
187
  context: RunContext, command: str, cwd: str = None, timeout: int = 60
172
- ) -> Dict[str, Any]:
188
+ ) -> ShellCommandOutput:
173
189
  return run_shell_command(context, command, cwd, timeout)
174
190
 
175
191
  @agent.tool
176
192
  def agent_share_your_reasoning(
177
193
  context: RunContext, reasoning: str, next_steps: str = None
178
- ) -> Dict[str, Any]:
194
+ ) -> ReasoningOutput:
179
195
  return share_your_reasoning(context, reasoning, next_steps)
@@ -17,6 +17,7 @@ import traceback
17
17
  from typing import Any, Dict, List
18
18
 
19
19
  from json_repair import repair_json
20
+ from pydantic import BaseModel
20
21
  from pydantic_ai import RunContext
21
22
 
22
23
  from code_puppy.tools.common import _find_best_window, console
@@ -311,7 +312,7 @@ def _edit_file(context: RunContext, path: str, diff: str) -> Dict[str, Any]:
311
312
  }
312
313
 
313
314
 
314
- def _delete_file(context: RunContext, file_path: str) -> Dict[str, Any]:
315
+ def _delete_file(context: RunContext, file_path: str = "") -> Dict[str, Any]:
315
316
  console.log(f"🗑️ Deleting file [bold red]{file_path}[/bold red]")
316
317
  file_path = os.path.abspath(file_path)
317
318
  try:
@@ -344,13 +345,21 @@ def _delete_file(context: RunContext, file_path: str) -> Dict[str, Any]:
344
345
  return res
345
346
 
346
347
 
348
+ class EditFileOutput(BaseModel):
349
+ success: bool | None
350
+ file_path: str | None
351
+ message: str | None
352
+ changed: bool | None
353
+ diff: str | None
354
+
355
+
347
356
  def register_file_modifications_tools(agent):
348
357
  """Attach file-editing tools to *agent* with mandatory diff rendering."""
349
358
 
350
359
  @agent.tool(retries=5)
351
- def edit_file(context: RunContext, path: str, diff: str) -> Dict[str, Any]:
352
- return _edit_file(context, path, diff)
360
+ def edit_file(context: RunContext, path: str = "", diff: str = "") -> EditFileOutput:
361
+ return EditFileOutput(**_edit_file(context, path, diff))
353
362
 
354
363
  @agent.tool(retries=5)
355
- def delete_file(context: RunContext, file_path: str) -> Dict[str, Any]:
356
- return _delete_file(context, file_path)
364
+ def delete_file(context: RunContext, file_path: str = "") -> EditFileOutput:
365
+ return EditFileOutput(**_delete_file(context, file_path))
@@ -3,6 +3,7 @@
3
3
  import os
4
4
  from typing import Any, Dict, List
5
5
 
6
+ from pydantic import BaseModel, StrictStr, StrictInt
6
7
  from pydantic_ai import RunContext
7
8
 
8
9
  from code_puppy.tools.common import console
@@ -13,9 +14,21 @@ from code_puppy.tools.common import console
13
14
  from code_puppy.tools.common import should_ignore_path
14
15
 
15
16
 
17
+ class ListedFile(BaseModel):
18
+ path: str | None
19
+ type: str | None
20
+ size: int = 0
21
+ full_path: str | None
22
+ depth: int | None
23
+
24
+
25
+ class ListFileOutput(BaseModel):
26
+ files: List[ListedFile]
27
+
28
+
16
29
  def _list_files(
17
30
  context: RunContext, directory: str = ".", recursive: bool = True
18
- ) -> List[Dict[str, Any]]:
31
+ ) -> ListFileOutput:
19
32
  results = []
20
33
  directory = os.path.abspath(directory)
21
34
  console.print("\n[bold white on blue] DIRECTORY LISTING [/bold white on blue]")
@@ -28,11 +41,11 @@ def _list_files(
28
41
  f"[bold red]Error:[/bold red] Directory '{directory}' does not exist"
29
42
  )
30
43
  console.print("[dim]" + "-" * 60 + "[/dim]\n")
31
- return [{"error": f"Directory '{directory}' does not exist"}]
44
+ return ListFileOutput(files=[ListedFile(**{"error": f"Directory '{directory}' does not exist"})])
32
45
  if not os.path.isdir(directory):
33
46
  console.print(f"[bold red]Error:[/bold red] '{directory}' is not a directory")
34
47
  console.print("[dim]" + "-" * 60 + "[/dim]\n")
35
- return [{"error": f"'{directory}' is not a directory"}]
48
+ return ListFileOutput(files=[ListedFile(**{"error": f"'{directory}' is not a directory"})])
36
49
  folder_structure = {}
37
50
  file_list = []
38
51
  for root, dirs, files in os.walk(directory):
@@ -44,13 +57,13 @@ def _list_files(
44
57
  if rel_path:
45
58
  dir_path = os.path.join(directory, rel_path)
46
59
  results.append(
47
- {
60
+ ListedFile(**{
48
61
  "path": rel_path,
49
62
  "type": "directory",
50
63
  "size": 0,
51
64
  "full_path": dir_path,
52
65
  "depth": depth,
53
- }
66
+ })
54
67
  )
55
68
  folder_structure[rel_path] = {
56
69
  "path": rel_path,
@@ -71,7 +84,7 @@ def _list_files(
71
84
  "full_path": file_path,
72
85
  "depth": depth,
73
86
  }
74
- results.append(file_info)
87
+ results.append(ListedFile(**file_info))
75
88
  file_list.append(file_info)
76
89
  except (FileNotFoundError, PermissionError):
77
90
  continue
@@ -119,74 +132,81 @@ def _list_files(
119
132
 
120
133
  if results:
121
134
  files = sorted(
122
- [f for f in results if f["type"] == "file"], key=lambda x: x["path"]
135
+ [f for f in results if f.type == "file"], key=lambda x: x.path
123
136
  )
124
137
  console.print(
125
138
  f"\U0001f4c1 [bold blue]{os.path.basename(directory) or directory}[/bold blue]"
126
139
  )
127
- all_items = sorted(results, key=lambda x: x["path"])
140
+ all_items = sorted(results, key=lambda x: x.path)
128
141
  parent_dirs_with_content = set()
129
142
  for i, item in enumerate(all_items):
130
- if item["type"] == "directory" and not item["path"]:
143
+ if item.type == "directory" and not item.path:
131
144
  continue
132
- if os.sep in item["path"]:
133
- parent_path = os.path.dirname(item["path"])
145
+ if os.sep in item.path:
146
+ parent_path = os.path.dirname(item.path)
134
147
  parent_dirs_with_content.add(parent_path)
135
- depth = item["path"].count(os.sep) + 1 if item["path"] else 0
148
+ depth = item.path.count(os.sep) + 1 if item.path else 0
136
149
  prefix = ""
137
150
  for d in range(depth):
138
151
  if d == depth - 1:
139
152
  prefix += "\u2514\u2500\u2500 "
140
153
  else:
141
154
  prefix += " "
142
- name = os.path.basename(item["path"]) or item["path"]
143
- if item["type"] == "directory":
155
+ name = os.path.basename(item.path) or item.path
156
+ if item.type == "directory":
144
157
  console.print(f"{prefix}\U0001f4c1 [bold blue]{name}/[/bold blue]")
145
158
  else:
146
- icon = get_file_icon(item["path"])
147
- size_str = format_size(item["size"])
159
+ icon = get_file_icon(item.path)
160
+ size_str = format_size(item.size)
148
161
  console.print(
149
162
  f"{prefix}{icon} [green]{name}[/green] [dim]({size_str})[/dim]"
150
163
  )
151
164
  else:
152
165
  console.print("[yellow]Directory is empty[/yellow]")
153
- dir_count = sum(1 for item in results if item["type"] == "directory")
154
- file_count = sum(1 for item in results if item["type"] == "file")
155
- total_size = sum(item["size"] for item in results if item["type"] == "file")
166
+ dir_count = sum(1 for item in results if item.type == "directory")
167
+ file_count = sum(1 for item in results if item.type == "file")
168
+ total_size = sum(item.size for item in results if item.type == "file")
156
169
  console.print("\n[bold cyan]Summary:[/bold cyan]")
157
170
  console.print(
158
171
  f"\U0001f4c1 [blue]{dir_count} directories[/blue], \U0001f4c4 [green]{file_count} files[/green] [dim]({format_size(total_size)} total)[/dim]"
159
172
  )
160
173
  console.print("[dim]" + "-" * 60 + "[/dim]\n")
161
- return results
174
+ return ListFileOutput(files=results)
162
175
 
163
176
 
164
- def _read_file(context: RunContext, file_path: str) -> Dict[str, Any]:
177
+ class ReadFileOutput(BaseModel):
178
+ content: str | None
179
+
180
+ def _read_file(context: RunContext, file_path: str) -> ReadFileOutput:
165
181
  file_path = os.path.abspath(file_path)
166
182
  console.print(
167
183
  f"\n[bold white on blue] READ FILE [/bold white on blue] \U0001f4c2 [bold cyan]{file_path}[/bold cyan]"
168
184
  )
169
185
  console.print("[dim]" + "-" * 60 + "[/dim]")
170
186
  if not os.path.exists(file_path):
171
- return {"error": f"File '{file_path}' does not exist"}
187
+ return ReadFileOutput(content=f"File '{file_path}' does not exist")
172
188
  if not os.path.isfile(file_path):
173
- return {"error": f"'{file_path}' is not a file"}
189
+ return ReadFileOutput(content=f"'{file_path}' is not a file")
174
190
  try:
175
191
  with open(file_path, "r", encoding="utf-8") as f:
176
192
  content = f.read()
177
- return {
178
- "content": content,
179
- "path": file_path,
180
- "total_lines": len(content.splitlines()),
181
- }
193
+ return ReadFileOutput(content=content)
182
194
  except Exception as exc:
183
- return {"error": str(exc)}
195
+ return ReadFileOutput(content="FILE NOT FOUND")
196
+
197
+
198
+ class MatchInfo(BaseModel):
199
+ file_path: str | None
200
+ line_number: int | None
201
+ line_content: str | None
184
202
 
203
+ class GrepOutput(BaseModel):
204
+ matches: List[MatchInfo]
185
205
 
186
206
  def _grep(
187
207
  context: RunContext, search_string: str, directory: str = "."
188
- ) -> List[Dict[str, Any]]:
189
- matches: List[Dict[str, Any]] = []
208
+ ) -> GrepOutput:
209
+ matches: List[MatchInfo] = []
190
210
  directory = os.path.abspath(directory)
191
211
  console.print(
192
212
  f"\n[bold white on blue] GREP [/bold white on blue] \U0001f4c2 [bold cyan]{directory}[/bold cyan] [dim]for '{search_string}'[/dim]"
@@ -209,11 +229,11 @@ def _grep(
209
229
  with open(file_path, "r", encoding="utf-8", errors="ignore") as fh:
210
230
  for line_number, line_content in enumerate(fh, 1):
211
231
  if search_string in line_content:
212
- match_info = {
232
+ match_info = MatchInfo(**{
213
233
  "file_path": file_path,
214
234
  "line_number": line_number,
215
235
  "line_content": line_content.strip(),
216
- }
236
+ })
217
237
  matches.append(match_info)
218
238
  # console.print(
219
239
  # f"[green]Match:[/green] {file_path}:{line_number} - {line_content.strip()}"
@@ -222,7 +242,7 @@ def _grep(
222
242
  console.print(
223
243
  "[yellow]Limit of 200 matches reached. Stopping search.[/yellow]"
224
244
  )
225
- return matches
245
+ return GrepOutput(matches=matches)
226
246
  except FileNotFoundError:
227
247
  console.print(
228
248
  f"[yellow]File not found (possibly a broken symlink): {file_path}[/yellow]"
@@ -246,54 +266,22 @@ def _grep(
246
266
  f"[green]Found {len(matches)} match(es) for '{search_string}' in {directory}[/green]"
247
267
  )
248
268
 
249
- return matches
250
-
251
-
252
- # Exported top-level functions for direct import by tests and other code
253
-
254
-
255
- def list_files(context, directory=".", recursive=True):
256
- return _list_files(context, directory, recursive)
257
-
258
-
259
- def read_file(context, file_path):
260
- return _read_file(context, file_path)
261
-
262
-
263
- def grep(context, search_string, directory="."):
264
- return _grep(context, search_string, directory)
269
+ return GrepOutput(matches=[])
265
270
 
266
271
 
267
272
  def register_file_operations_tools(agent):
268
273
  @agent.tool
269
274
  def list_files(
270
275
  context: RunContext, directory: str = ".", recursive: bool = True
271
- ) -> List[Dict[str, Any]]:
276
+ ) -> ListFileOutput:
272
277
  return _list_files(context, directory, recursive)
273
278
 
274
279
  @agent.tool
275
- def read_file(context: RunContext, file_path: str) -> Dict[str, Any]:
280
+ def read_file(context: RunContext, file_path: str = "") -> ReadFileOutput:
276
281
  return _read_file(context, file_path)
277
282
 
278
283
  @agent.tool
279
284
  def grep(
280
- context: RunContext, search_string: str, directory: str = "."
281
- ) -> List[Dict[str, Any]]:
285
+ context: RunContext, search_string: str = "", directory: str = "."
286
+ ) -> GrepOutput:
282
287
  return _grep(context, search_string, directory)
283
-
284
- @agent.tool
285
- def code_map(context: RunContext, directory: str = ".") -> str:
286
- """Generate a code map for the specified directory.
287
- This will have a list of all function / class names and nested structure
288
- Args:
289
- context: The context object.
290
- directory: The directory to generate the code map for.
291
-
292
- Returns:
293
- A string containing the code map.
294
- """
295
- console.print("[bold white on blue] CODE MAP [/bold white on blue]")
296
- from code_puppy.tools.ts_code_map import make_code_map
297
-
298
- result = make_code_map(directory, ignore_tests=True)
299
- return result
@@ -4,12 +4,12 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "code-puppy"
7
- version = "0.0.74"
7
+ version = "0.0.76"
8
8
  description = "Code generation agent"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
11
  dependencies = [
12
- "pydantic-ai>=0.3.2",
12
+ "pydantic-ai>=0.4.8",
13
13
  "httpx>=0.24.1",
14
14
  "rich>=13.4.2",
15
15
  "logfire>=0.7.1",
@@ -1,57 +0,0 @@
1
- """
2
- MOTD (Message of the Day) feature for code-puppy.
3
- Stores seen versions in ~/.puppy_cfg/motd.txt.
4
- """
5
-
6
- import os
7
-
8
- MOTD_VERSION = "20240621"
9
- MOTD_MESSAGE = """
10
- June 21th, 2025 - 🚀 Woof-tastic news! Code Puppy now supports **MCP (Model Context Protocol) servers** for EXTREME PUPPY POWER!!!!.
11
-
12
- You can now connect plugins like doc search, Context7 integration, and more by simply dropping their info in your `~/.code_puppy/mcp_servers.json`. I’ll bark at remote docs or wrangle code tools for you—no extra fetches needed.
13
-
14
- Setup is easy:
15
- 1. Add your MCP config to `~/.code_puppy/mcp_servers.json`.
16
- 2. Fire up something like Context7, or any MCP server you want.
17
- 3. Ask me to search docs, analyze, and more.
18
-
19
- The following example will let code_puppy use Context7!
20
- Example config (+ more details in the README):
21
-
22
- {
23
- "mcp_servers": {
24
- "context7": {
25
- "url": "https://mcp.context7.com/sse"
26
- }
27
- }
28
- }
29
-
30
- I fetch docs and power-ups via those servers. If you break stuff, please file an issue—bonus treat for reproducible bugs! 🦴
31
-
32
- This message-of-the-day won’t bug you again unless you run ~motd. Stay fluffy!
33
-
34
- """
35
- MOTD_TRACK_FILE = os.path.expanduser("~/.puppy_cfg/motd.txt")
36
-
37
-
38
- def has_seen_motd(version: str) -> bool:
39
- if not os.path.exists(MOTD_TRACK_FILE):
40
- return False
41
- with open(MOTD_TRACK_FILE, "r") as f:
42
- seen_versions = {line.strip() for line in f if line.strip()}
43
- return version in seen_versions
44
-
45
-
46
- def mark_motd_seen(version: str):
47
- os.makedirs(os.path.dirname(MOTD_TRACK_FILE), exist_ok=True)
48
- with open(MOTD_TRACK_FILE, "a") as f:
49
- f.write(f"{version}\n")
50
-
51
-
52
- def print_motd(console, force: bool = False) -> bool:
53
- if force or not has_seen_motd(MOTD_VERSION):
54
- console.print(MOTD_MESSAGE)
55
- mark_motd_seen(MOTD_VERSION)
56
- return True
57
- return False
@@ -1,32 +0,0 @@
1
- from typing import Dict
2
-
3
- import requests
4
- from pydantic_ai import RunContext
5
-
6
-
7
- def register_web_search_tools(agent):
8
- @agent.tool
9
- def grab_json_from_url(context: RunContext, url: str) -> Dict:
10
- from code_puppy.tools.common import console
11
-
12
- try:
13
- response = requests.get(url)
14
- response.raise_for_status()
15
- ct = response.headers.get("Content-Type")
16
- if "json" not in str(ct):
17
- console.print(
18
- f"[bold red]Error:[/bold red] Response from {url} is not JSON (got {ct})"
19
- )
20
- return {"error": f"Response from {url} is not of type application/json"}
21
- json_data = response.json()
22
- if isinstance(json_data, list) and len(json_data) > 1000:
23
- console.print("[yellow]Result list truncated to 1000 items[/yellow]")
24
- return json_data[:1000]
25
- if not json_data:
26
- console.print("[yellow]No data found for URL:[/yellow]", url)
27
- else:
28
- console.print(f"[green]Successfully fetched JSON from:[/green] {url}")
29
- return json_data
30
- except Exception as exc:
31
- console.print(f"[bold red]Error:[/bold red] {exc}")
32
- return {"error": str(exc)}
File without changes
File without changes