code-puppy 0.0.30__tar.gz → 0.0.32__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 (35) hide show
  1. {code_puppy-0.0.30 → code_puppy-0.0.32}/.gitignore +6 -0
  2. {code_puppy-0.0.30 → code_puppy-0.0.32}/PKG-INFO +2 -1
  3. code_puppy-0.0.32/code_puppy/agent.py +85 -0
  4. {code_puppy-0.0.30 → code_puppy-0.0.32}/code_puppy/agent_prompts.py +4 -4
  5. code_puppy-0.0.32/code_puppy/command_line/file_path_completion.py +65 -0
  6. code_puppy-0.0.32/code_puppy/command_line/meta_command_handler.py +72 -0
  7. code_puppy-0.0.32/code_puppy/command_line/model_picker_completion.py +92 -0
  8. code_puppy-0.0.32/code_puppy/command_line/prompt_toolkit_completion.py +120 -0
  9. code_puppy-0.0.32/code_puppy/command_line/utils.py +36 -0
  10. code_puppy-0.0.32/code_puppy/config.py +53 -0
  11. {code_puppy-0.0.30 → code_puppy-0.0.32}/code_puppy/main.py +46 -16
  12. code_puppy-0.0.32/code_puppy/session_memory.py +71 -0
  13. code_puppy-0.0.32/code_puppy/tools/__init__.py +11 -0
  14. code_puppy-0.0.32/code_puppy/tools/code_map.py +86 -0
  15. code_puppy-0.0.32/code_puppy/tools/command_runner.py +74 -0
  16. code_puppy-0.0.32/code_puppy/tools/common.py +5 -0
  17. code_puppy-0.0.32/code_puppy/tools/file_modifications.py +191 -0
  18. code_puppy-0.0.32/code_puppy/tools/file_operations.py +204 -0
  19. code_puppy-0.0.32/code_puppy/tools/web_search.py +15 -0
  20. {code_puppy-0.0.30 → code_puppy-0.0.32}/pyproject.toml +2 -1
  21. code_puppy-0.0.30/code_puppy/agent.py +0 -51
  22. code_puppy-0.0.30/code_puppy/command_line/prompt_toolkit_completion.py +0 -156
  23. code_puppy-0.0.30/code_puppy/tools/__init__.py +0 -4
  24. code_puppy-0.0.30/code_puppy/tools/command_runner.py +0 -210
  25. code_puppy-0.0.30/code_puppy/tools/common.py +0 -3
  26. code_puppy-0.0.30/code_puppy/tools/file_modifications.py +0 -341
  27. code_puppy-0.0.30/code_puppy/tools/file_operations.py +0 -364
  28. code_puppy-0.0.30/code_puppy/tools/web_search.py +0 -32
  29. {code_puppy-0.0.30 → code_puppy-0.0.32}/LICENSE +0 -0
  30. {code_puppy-0.0.30 → code_puppy-0.0.32}/README.md +0 -0
  31. {code_puppy-0.0.30 → code_puppy-0.0.32}/code_puppy/__init__.py +0 -0
  32. {code_puppy-0.0.30 → code_puppy-0.0.32}/code_puppy/command_line/__init__.py +0 -0
  33. {code_puppy-0.0.30 → code_puppy-0.0.32}/code_puppy/model_factory.py +0 -0
  34. {code_puppy-0.0.30 → code_puppy-0.0.32}/code_puppy/models.json +0 -0
  35. {code_puppy-0.0.30 → code_puppy-0.0.32}/code_puppy/version_checker.py +0 -0
@@ -10,3 +10,9 @@ wheels/
10
10
  .venv
11
11
 
12
12
  .coverage
13
+
14
+ # Session memory
15
+ .puppy_session_memory.json
16
+
17
+ # Pytest cache
18
+ .pytest_cache/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-puppy
3
- Version: 0.0.30
3
+ Version: 0.0.32
4
4
  Summary: Code generation agent
5
5
  Author: Michael Pfaffenberger
6
6
  License: MIT
@@ -17,6 +17,7 @@ Requires-Dist: bs4>=0.0.2
17
17
  Requires-Dist: httpx-limiter>=0.3.0
18
18
  Requires-Dist: httpx>=0.24.1
19
19
  Requires-Dist: logfire>=0.7.1
20
+ Requires-Dist: pathspec>=0.11.0
20
21
  Requires-Dist: prompt-toolkit>=3.0.38
21
22
  Requires-Dist: pydantic-ai>=0.1.0
22
23
  Requires-Dist: pydantic>=2.4.0
@@ -0,0 +1,85 @@
1
+ import os
2
+ import pydantic
3
+ from pathlib import Path
4
+ from pydantic_ai import Agent
5
+
6
+ from code_puppy.agent_prompts import SYSTEM_PROMPT
7
+ from code_puppy.model_factory import ModelFactory
8
+ from code_puppy.tools.common import console
9
+ from code_puppy.tools import register_all_tools
10
+ from code_puppy.session_memory import SessionMemory
11
+
12
+ # Environment variables used in this module:
13
+ # - MODELS_JSON_PATH: Optional path to a custom models.json configuration file.
14
+ # If not set, uses the default file in the package directory.
15
+ # - MODEL_NAME: The model to use for code generation. Defaults to "gpt-4o".
16
+ # Must match a key in the models.json configuration.
17
+
18
+ MODELS_JSON_PATH = os.environ.get("MODELS_JSON_PATH", None)
19
+
20
+ # Load puppy rules if provided
21
+ PUPPY_RULES_PATH = Path('.puppy_rules')
22
+ PUPPY_RULES = None
23
+ if PUPPY_RULES_PATH.exists():
24
+ with open(PUPPY_RULES_PATH, 'r') as f:
25
+ PUPPY_RULES = f.read()
26
+
27
+ class AgentResponse(pydantic.BaseModel):
28
+ """Represents a response from the agent."""
29
+ output_message: str = pydantic.Field(
30
+ ..., description="The final output message to display to the user"
31
+ )
32
+ awaiting_user_input: bool = pydantic.Field(
33
+ False, description="True if user input is needed to continue the task"
34
+ )
35
+
36
+ # --- NEW DYNAMIC AGENT LOGIC ---
37
+ _LAST_MODEL_NAME = None
38
+ _code_generation_agent = None
39
+ _session_memory = None
40
+
41
+ def session_memory():
42
+ '''
43
+ Returns a singleton SessionMemory instance to allow agent and tools to persist and recall context/history.
44
+ '''
45
+ global _session_memory
46
+ if _session_memory is None:
47
+ _session_memory = SessionMemory()
48
+ return _session_memory
49
+
50
+ def reload_code_generation_agent():
51
+ """Force-reload the agent, usually after a model change."""
52
+ global _code_generation_agent, _LAST_MODEL_NAME
53
+ model_name = os.environ.get("MODEL_NAME", "gpt-4o-mini")
54
+ console.print(f'[bold cyan]Loading Model: {model_name}[/bold cyan]')
55
+ models_path = Path(MODELS_JSON_PATH) if MODELS_JSON_PATH else Path(__file__).parent / "models.json"
56
+ model = ModelFactory.get_model(model_name, ModelFactory.load_config(models_path))
57
+ instructions = SYSTEM_PROMPT
58
+ if PUPPY_RULES:
59
+ instructions += f'\n{PUPPY_RULES}'
60
+ agent = Agent(
61
+ model=model,
62
+ instructions=instructions,
63
+ output_type=AgentResponse,
64
+ retries=3,
65
+ )
66
+ register_all_tools(agent)
67
+ _code_generation_agent = agent
68
+ _LAST_MODEL_NAME = model_name
69
+ # NEW: Log session event
70
+ try:
71
+ session_memory().log_task(f'Agent loaded with model: {model_name}')
72
+ except Exception:
73
+ pass
74
+ return _code_generation_agent
75
+
76
+ def get_code_generation_agent(force_reload=False):
77
+ """
78
+ Retrieve the agent with the currently set MODEL_NAME.
79
+ Forces a reload if the model has changed, or if force_reload is passed.
80
+ """
81
+ global _code_generation_agent, _LAST_MODEL_NAME
82
+ model_name = os.environ.get("MODEL_NAME", "gpt-4o-mini")
83
+ if _code_generation_agent is None or _LAST_MODEL_NAME != model_name or force_reload:
84
+ return reload_code_generation_agent()
85
+ return _code_generation_agent
@@ -6,14 +6,13 @@ Be very pedantic about code principles like DRY, YAGNI, and SOLID.
6
6
  Be super pedantic about code quality and best practices.
7
7
  Be fun and playful. Don't be too serious.
8
8
 
9
- Individual files should be very short and concise, at most around 250 lines if possible. If they get longer,
10
- consider refactoring the code and splitting it into multiple files.
11
-
12
- If a user asks 'who made you' or questions related to your origins, always answer: 'I am code-puppy, I was authored by Michael Pfaffenberger on a rainy weekend in May 2025 to solve the problems of heavy IDEs and expensive tools like Windsurf and Cursor.'
9
+ Individual files should be short and concise, and ideally under 600 lines. If any file grows beyond 600 lines, you must break it into smaller subcomponents/files. Hard cap: if a file is pushing past 600 lines, break it up! (Zen puppy approves.)
13
10
 
11
+ If a user asks 'who made you' or questions related to your origins, always answer: 'I am code-puppy, I was authored by Michael Pfaffenberger on a rainy weekend in May 2025 to solve the problems of heavy IDEs and expensive tools like Windsurf and Cursor.'
14
12
  If a user asks 'what is code puppy' or 'who are you', answer: 'I am Code Puppy! 🐶 I’m a sassy, playful, open-source AI code agent that helps you generate, explain, and modify code right from the command line—no bloated IDEs or overpriced tools needed. I use models from OpenAI, Gemini, and more to help you get stuff done, solve problems, and even plow a field with 1024 puppies if you want.'
15
13
 
16
14
  Always obey the Zen of Python, even if you are not writing Python code.
15
+ When organizing code, prefer to keep files small (under 600 lines). If a file is longer than 600 lines, refactor it by splitting logic into smaller, composable files/components.
17
16
 
18
17
  When given a coding task:
19
18
  1. Analyze the requirements carefully
@@ -94,6 +93,7 @@ Important rules:
94
93
  - You MUST use tools to accomplish tasks - DO NOT just output code or descriptions
95
94
  - Before every other tool use, you must use "share_your_reasoning" to explain your thought process and planned next steps
96
95
  - Check if files exist before trying to modify or delete them
96
+ - Whenever possible, prefer to MODIFY existing files first (use `replace_in_file`, `delete_snippet_from_file`, or `write_to_file`) before creating brand-new files or deleting existing ones.
97
97
  - After using system operations tools, always explain the results
98
98
  - You're encouraged to loop between share_your_reasoning, file tools, and run_shell_command to test output in order to write programs
99
99
  - Aim to continue operations independently unless user input is definitively required.
@@ -0,0 +1,65 @@
1
+ import os
2
+ import glob
3
+ from typing import Iterable
4
+ from prompt_toolkit.completion import Completer, Completion
5
+ from prompt_toolkit.document import Document
6
+
7
+ class FilePathCompleter(Completer):
8
+ """A simple file path completer that works with a trigger symbol."""
9
+ def __init__(self, symbol: str = "@"):
10
+ self.symbol = symbol
11
+ def get_completions(self, document: Document, complete_event) -> Iterable[Completion]:
12
+ text = document.text
13
+ cursor_position = document.cursor_position
14
+ text_before_cursor = text[:cursor_position]
15
+ if self.symbol not in text_before_cursor:
16
+ return
17
+ symbol_pos = text_before_cursor.rfind(self.symbol)
18
+ text_after_symbol = text_before_cursor[symbol_pos + len(self.symbol):]
19
+ start_position = -(len(text_after_symbol))
20
+ try:
21
+ pattern = text_after_symbol + "*"
22
+ if not pattern.strip("*") or pattern.strip("*").endswith("/"):
23
+ base_path = pattern.strip("*")
24
+ if not base_path:
25
+ base_path = "."
26
+ if base_path.startswith("~"):
27
+ base_path = os.path.expanduser(base_path)
28
+ if os.path.isdir(base_path):
29
+ paths = [
30
+ os.path.join(base_path, f)
31
+ for f in os.listdir(base_path)
32
+ if not f.startswith(".") or text_after_symbol.endswith(".")
33
+ ]
34
+ else:
35
+ paths = []
36
+ else:
37
+ paths = glob.glob(pattern)
38
+ if not pattern.startswith(".") and not pattern.startswith("*/."):
39
+ paths = [p for p in paths if not os.path.basename(p).startswith(".")]
40
+ paths.sort()
41
+ for path in paths:
42
+ is_dir = os.path.isdir(path)
43
+ display = os.path.basename(path)
44
+ if os.path.isabs(path):
45
+ display_path = path
46
+ else:
47
+ if text_after_symbol.startswith("/"):
48
+ display_path = os.path.abspath(path)
49
+ elif text_after_symbol.startswith("~"):
50
+ home = os.path.expanduser("~")
51
+ if path.startswith(home):
52
+ display_path = "~" + path[len(home):]
53
+ else:
54
+ display_path = path
55
+ else:
56
+ display_path = path
57
+ display_meta = "Directory" if is_dir else "File"
58
+ yield Completion(
59
+ display_path,
60
+ start_position=start_position,
61
+ display=display,
62
+ display_meta=display_meta,
63
+ )
64
+ except (PermissionError, FileNotFoundError, OSError):
65
+ pass
@@ -0,0 +1,72 @@
1
+ from code_puppy.command_line.model_picker_completion import update_model_in_input, load_model_names, get_active_model
2
+ from rich.console import Console
3
+ import os
4
+ from code_puppy.command_line.utils import make_directory_table
5
+
6
+ def handle_meta_command(command: str, console: Console) -> bool:
7
+ # ~codemap (code structure visualization)
8
+ if command.startswith("~codemap"):
9
+ from code_puppy.tools.code_map import make_code_map
10
+ tokens = command.split()
11
+ if len(tokens) > 1:
12
+ target_dir = os.path.expanduser(tokens[1])
13
+ else:
14
+ target_dir = os.getcwd()
15
+ try:
16
+ tree = make_code_map(target_dir, show_doc=True)
17
+ console.print(tree)
18
+ except Exception as e:
19
+ console.print(f'[red]Error generating code map:[/red] {e}')
20
+ return True
21
+ """
22
+ Handle meta/config commands prefixed with '~'.
23
+ Returns True if the command was handled (even if just an error/help), False if not.
24
+ """
25
+ command = command.strip()
26
+ if command.startswith("~ls"):
27
+ tokens = command.split()
28
+ if len(tokens) == 1:
29
+ try:
30
+ table = make_directory_table()
31
+ console.print(table)
32
+ except Exception as e:
33
+ console.print(f'[red]Error listing directory:[/red] {e}')
34
+ return True
35
+ elif len(tokens) == 2:
36
+ dirname = tokens[1]
37
+ target = os.path.expanduser(dirname)
38
+ if not os.path.isabs(target):
39
+ target = os.path.join(os.getcwd(), target)
40
+ if os.path.isdir(target):
41
+ os.chdir(target)
42
+ console.print(f'[bold green]Changed directory to:[/bold green] [cyan]{target}[/cyan]')
43
+ else:
44
+ console.print(f'[red]Not a directory:[/red] [bold]{dirname}[/bold]')
45
+ return True
46
+
47
+ if command.startswith("~m"):
48
+ # Try setting model and show confirmation
49
+ new_input = update_model_in_input(command)
50
+ if new_input is not None:
51
+ model = get_active_model()
52
+ console.print(f"[bold green]Active model set to:[/bold green] [cyan]{model}[/cyan]")
53
+ return True
54
+ # If no model matched, show available models
55
+ model_names = load_model_names()
56
+ console.print(f"[yellow]Available models:[/yellow] {', '.join(model_names)}")
57
+ console.print(f"[yellow]Usage:[/yellow] ~m <model_name>")
58
+ return True
59
+ if command in ("~help", "~h"):
60
+ console.print("[bold magenta]Meta commands available:[/bold magenta]\n ~m <model>: Pick a model from your list!\n ~ls [dir]: List/change directories\n ~codemap [dir]: Visualize project code structure\n ~help: Show this help\n (More soon. Woof!)")
61
+ return True
62
+ if command.startswith("~"):
63
+ name = command[1:].split()[0] if len(command)>1 else ""
64
+ if name:
65
+ console.print(f"[yellow]Unknown meta command:[/yellow] {command}\n[dim]Type ~help for options.[/dim]")
66
+ else:
67
+ # Show current model ONLY here
68
+ from code_puppy.command_line.model_picker_completion import get_active_model
69
+ current_model = get_active_model()
70
+ console.print(f"[bold green]Current Model:[/bold green] [cyan]{current_model}[/cyan]")
71
+ return True
72
+ return False
@@ -0,0 +1,92 @@
1
+ import os
2
+ import json
3
+ from typing import Optional, Iterable
4
+ from prompt_toolkit.completion import Completer, Completion
5
+ from prompt_toolkit.history import FileHistory
6
+ from prompt_toolkit.document import Document
7
+ from prompt_toolkit import PromptSession
8
+
9
+ MODELS_JSON_PATH = os.environ.get("MODELS_JSON_PATH")
10
+ if not MODELS_JSON_PATH:
11
+ MODELS_JSON_PATH = os.path.join(os.path.dirname(__file__), '..', 'models.json')
12
+ MODELS_JSON_PATH = os.path.abspath(MODELS_JSON_PATH)
13
+ MODEL_STATE_PATH = os.path.expanduser('~/.code_puppy_model')
14
+
15
+ def load_model_names():
16
+ with open(MODELS_JSON_PATH, 'r') as f:
17
+ models = json.load(f)
18
+ return list(models.keys())
19
+
20
+ def get_active_model():
21
+ env_model = os.environ.get('MODEL_NAME')
22
+ if env_model:
23
+ return env_model
24
+ try:
25
+ with open(MODEL_STATE_PATH, 'r') as f:
26
+ return f.read().strip()
27
+ except Exception:
28
+ return None
29
+
30
+ def set_active_model(model_name: str):
31
+ with open(MODEL_STATE_PATH, 'w') as f:
32
+ f.write(model_name.strip())
33
+ os.environ['MODEL_NAME'] = model_name.strip()
34
+ # Reload agent globally
35
+ try:
36
+ from code_puppy.agent import reload_code_generation_agent, get_code_generation_agent
37
+ reload_code_generation_agent() # This will reload dynamically everywhere
38
+ except Exception as e:
39
+ pass # If reload fails, agent will still be switched next interpreter run
40
+
41
+ class ModelNameCompleter(Completer):
42
+ """
43
+ A completer that triggers on '~m' to show available models from models.json.
44
+ Only '~m' (not just '~') will trigger the dropdown.
45
+ """
46
+ def __init__(self, trigger: str = "~m"):
47
+ self.trigger = trigger
48
+ self.model_names = load_model_names()
49
+
50
+ def get_completions(self, document: Document, complete_event) -> Iterable[Completion]:
51
+ text = document.text
52
+ cursor_position = document.cursor_position
53
+ text_before_cursor = text[:cursor_position]
54
+ if self.trigger not in text_before_cursor:
55
+ return
56
+ symbol_pos = text_before_cursor.rfind(self.trigger)
57
+ text_after_trigger = text_before_cursor[symbol_pos + len(self.trigger):]
58
+ start_position = -(len(text_after_trigger))
59
+ for model_name in self.model_names:
60
+ meta = "Model (selected)" if model_name == get_active_model() else "Model"
61
+ yield Completion(
62
+ model_name,
63
+ start_position=start_position,
64
+ display=model_name,
65
+ display_meta=meta,
66
+ )
67
+
68
+ def update_model_in_input(text: str) -> Optional[str]:
69
+ # If input starts with ~m and a model name, set model and strip it out
70
+ content = text.strip()
71
+ if content.startswith("~m"):
72
+ rest = content[2:].strip()
73
+ for model in load_model_names():
74
+ if rest.startswith(model):
75
+ set_active_model(model)
76
+ # Remove ~mmodel from the input
77
+ idx = text.find("~m"+model)
78
+ if idx != -1:
79
+ new_text = (text[:idx] + text[idx+len("~m"+model):]).strip()
80
+ return new_text
81
+ return None
82
+
83
+ async def get_input_with_model_completion(prompt_str: str = ">>> ", trigger: str = "~m", history_file: Optional[str] = None) -> str:
84
+ history = FileHistory(os.path.expanduser(history_file)) if history_file else None
85
+ session = PromptSession(
86
+ completer=ModelNameCompleter(trigger), history=history, complete_while_typing=True
87
+ )
88
+ text = await session.prompt_async(prompt_str)
89
+ possibly_stripped = update_model_in_input(text)
90
+ if possibly_stripped is not None:
91
+ return possibly_stripped
92
+ return text
@@ -0,0 +1,120 @@
1
+ import os
2
+ from code_puppy.command_line.utils import list_directory
3
+ from code_puppy.config import get_puppy_name
4
+ # ANSI color codes are no longer necessary because prompt_toolkit handles
5
+ # styling via the `Style` class. We keep them here commented-out in case
6
+ # someone needs raw ANSI later, but they are unused in the current code.
7
+ # RESET = '\033[0m'
8
+ # GREEN = '\033[1;32m'
9
+ # CYAN = '\033[1;36m'
10
+ # YELLOW = '\033[1;33m'
11
+ # BOLD = '\033[1m'
12
+ import asyncio
13
+ from typing import Optional
14
+ from prompt_toolkit import PromptSession
15
+ from prompt_toolkit.completion import merge_completers
16
+ from prompt_toolkit.history import FileHistory
17
+ from prompt_toolkit.styles import Style
18
+
19
+ from code_puppy.command_line.model_picker_completion import (
20
+ ModelNameCompleter,
21
+ get_active_model,
22
+ update_model_in_input,
23
+ )
24
+ from code_puppy.command_line.file_path_completion import FilePathCompleter
25
+
26
+ from prompt_toolkit.completion import Completer, Completion
27
+
28
+ class LSCompleter(Completer):
29
+ def __init__(self, trigger: str = '~ls'):
30
+ self.trigger = trigger
31
+ def get_completions(self, document, complete_event):
32
+ text = document.text_before_cursor
33
+ if not text.strip().startswith(self.trigger):
34
+ return
35
+ tokens = text.strip().split()
36
+ if len(tokens) == 1:
37
+ base = ''
38
+ else:
39
+ base = tokens[1]
40
+ try:
41
+ prefix = os.path.expanduser(base)
42
+ part = os.path.dirname(prefix) if os.path.dirname(prefix) else '.'
43
+ dirs, _ = list_directory(part)
44
+ dirnames = [d for d in dirs if d.startswith(os.path.basename(base))]
45
+ base_dir = os.path.dirname(base)
46
+ for d in dirnames:
47
+ # Build the completion text so we keep the already-typed directory parts.
48
+ if base_dir and base_dir != '.':
49
+ suggestion = os.path.join(base_dir, d)
50
+ else:
51
+ suggestion = d
52
+ # Append trailing slash so the user can continue tabbing into sub-dirs.
53
+ suggestion = suggestion.rstrip(os.sep) + os.sep
54
+ yield Completion(suggestion, start_position=-len(base), display=d + os.sep, display_meta='Directory')
55
+ except Exception:
56
+ # Silently ignore errors (e.g., permission issues, non-existent dir)
57
+ pass
58
+
59
+ from prompt_toolkit.formatted_text import FormattedText
60
+ def get_prompt_with_active_model(base: str = '>>> '):
61
+ model = get_active_model() or '(default)'
62
+ cwd = os.getcwd()
63
+ home = os.path.expanduser('~')
64
+ if cwd.startswith(home):
65
+ cwd_display = '~' + cwd[len(home):]
66
+ else:
67
+ cwd_display = cwd
68
+ puppy_name = get_puppy_name()
69
+ return FormattedText([
70
+ ('bold', f'🐶 {puppy_name} '),
71
+ ('class:model', f'[' + str(model) + '] '),
72
+ ('class:cwd', f'(' + str(cwd_display) + ') '),
73
+ ('class:arrow', str(base)),
74
+ ])
75
+
76
+ async def get_input_with_combined_completion(prompt_str = '>>> ', history_file: Optional[str] = None) -> str:
77
+ history = FileHistory(history_file) if history_file else None
78
+ completer = merge_completers([
79
+ FilePathCompleter(symbol='@'),
80
+ ModelNameCompleter(trigger='~m'),
81
+ LSCompleter(trigger='~ls'),
82
+ ])
83
+ session = PromptSession(
84
+ completer=completer,
85
+ history=history,
86
+ complete_while_typing=True
87
+ )
88
+ # If they pass a string, backward-compat: convert it to formatted_text
89
+ if isinstance(prompt_str, str):
90
+ from prompt_toolkit.formatted_text import FormattedText
91
+ prompt_str = FormattedText([(None, prompt_str)])
92
+ style = Style.from_dict({
93
+ # Keys must AVOID the 'class:' prefix – that prefix is used only when
94
+ # tagging tokens in `FormattedText`. See prompt_toolkit docs.
95
+ 'model': 'bold cyan',
96
+ 'cwd': 'bold green',
97
+ 'arrow': 'bold yellow',
98
+ })
99
+ text = await session.prompt_async(prompt_str, style=style)
100
+ possibly_stripped = update_model_in_input(text)
101
+ if possibly_stripped is not None:
102
+ return possibly_stripped
103
+ return text
104
+
105
+ if __name__ == "__main__":
106
+ print("Type '@' for path-completion or '~m' to pick a model. Ctrl+D to exit.")
107
+ async def main():
108
+ while True:
109
+ try:
110
+ inp = await get_input_with_combined_completion(
111
+ get_prompt_with_active_model(),
112
+ history_file="~/.path_completion_history.txt"
113
+ )
114
+ print(f"You entered: {inp}")
115
+ except KeyboardInterrupt:
116
+ continue
117
+ except EOFError:
118
+ break
119
+ print("\nGoodbye!")
120
+ asyncio.run(main())
@@ -0,0 +1,36 @@
1
+ import os
2
+ from typing import Tuple, List
3
+ from rich.table import Table
4
+
5
+
6
+ def list_directory(path: str = None) -> Tuple[List[str], List[str]]:
7
+ """
8
+ Returns (dirs, files) for the specified path, splitting out directories and files.
9
+ """
10
+ if path is None:
11
+ path = os.getcwd()
12
+ entries = []
13
+ try:
14
+ entries = [e for e in os.listdir(path)]
15
+ except Exception as e:
16
+ raise RuntimeError(f'Error listing directory: {e}')
17
+ dirs = [e for e in entries if os.path.isdir(os.path.join(path, e))]
18
+ files = [e for e in entries if not os.path.isdir(os.path.join(path, e))]
19
+ return dirs, files
20
+
21
+
22
+ def make_directory_table(path: str = None) -> Table:
23
+ """
24
+ Returns a rich.Table object containing the directory listing.
25
+ """
26
+ if path is None:
27
+ path = os.getcwd()
28
+ dirs, files = list_directory(path)
29
+ table = Table(title=f"\U0001F4C1 [bold blue]Current directory:[/bold blue] [cyan]{path}[/cyan]")
30
+ table.add_column('Type', style='dim', width=8)
31
+ table.add_column('Name', style='bold')
32
+ for d in sorted(dirs):
33
+ table.add_row('[green]dir[/green]', f'[cyan]{d}[/cyan]')
34
+ for f in sorted(files):
35
+ table.add_row('[yellow]file[/yellow]', f'{f}')
36
+ return table
@@ -0,0 +1,53 @@
1
+ import os
2
+ import configparser
3
+
4
+ CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".code_puppy")
5
+ CONFIG_FILE = os.path.join(CONFIG_DIR, "puppy.cfg")
6
+
7
+ DEFAULT_SECTION = "puppy"
8
+ REQUIRED_KEYS = ["puppy_name", "owner_name"]
9
+
10
+
11
+ def ensure_config_exists():
12
+ """
13
+ Ensure that the .code_puppy dir and puppy.cfg exist, prompting if needed.
14
+ Returns configparser.ConfigParser for reading.
15
+ """
16
+ if not os.path.exists(CONFIG_DIR):
17
+ os.makedirs(CONFIG_DIR, exist_ok=True)
18
+ exists = os.path.isfile(CONFIG_FILE)
19
+ config = configparser.ConfigParser()
20
+ if exists:
21
+ config.read(CONFIG_FILE)
22
+ missing = []
23
+ if DEFAULT_SECTION not in config:
24
+ config[DEFAULT_SECTION] = {}
25
+ for key in REQUIRED_KEYS:
26
+ if not config[DEFAULT_SECTION].get(key):
27
+ missing.append(key)
28
+ if missing:
29
+ print("🐾 Let's get your Puppy ready!")
30
+ for key in missing:
31
+ if key == "puppy_name":
32
+ val = input("What should we name the puppy? ").strip()
33
+ elif key == "owner_name":
34
+ val = input("What's your name (so Code Puppy knows its master)? ").strip()
35
+ else:
36
+ val = input(f"Enter {key}: ").strip()
37
+ config[DEFAULT_SECTION][key] = val
38
+ with open(CONFIG_FILE, "w") as f:
39
+ config.write(f)
40
+ return config
41
+
42
+ def get_value(key: str):
43
+ config = configparser.ConfigParser()
44
+ config.read(CONFIG_FILE)
45
+ val = config.get(DEFAULT_SECTION, key, fallback=None)
46
+ return val
47
+
48
+
49
+ def get_puppy_name():
50
+ return get_value("puppy_name") or "Puppy"
51
+
52
+ def get_owner_name():
53
+ return get_value("owner_name") or "Master"