janito 0.1.0__py3-none-any.whl
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.
- janito/__init__.py +6 -0
- janito/__main__.py +9 -0
- janito/change.py +382 -0
- janito/claude.py +112 -0
- janito/commands.py +377 -0
- janito/console.py +354 -0
- janito/janito.py +354 -0
- janito/prompts.py +181 -0
- janito/watcher.py +82 -0
- janito/workspace.py +169 -0
- janito/xmlchangeparser.py +202 -0
- janito-0.1.0.dist-info/LICENSE +21 -0
- janito-0.1.0.dist-info/METADATA +106 -0
- janito-0.1.0.dist-info/RECORD +19 -0
- janito-0.1.0.dist-info/WHEEL +5 -0
- janito-0.1.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +4 -0
- tests/conftest.py +9 -0
- tests/test_change.py +393 -0
janito/commands.py
ADDED
@@ -0,0 +1,377 @@
|
|
1
|
+
from typing import Optional, List
|
2
|
+
from pathlib import Path
|
3
|
+
import ast
|
4
|
+
import os
|
5
|
+
import sys
|
6
|
+
import subprocess
|
7
|
+
from rich.syntax import Syntax
|
8
|
+
from rich.console import Console
|
9
|
+
from rich.markdown import Markdown
|
10
|
+
from rich.text import Text
|
11
|
+
from threading import Event
|
12
|
+
from janito.workspace import Workspace
|
13
|
+
from janito.claude import ClaudeAPIAgent
|
14
|
+
from janito.change import FileChangeHandler
|
15
|
+
from janito.prompts import build_general_prompt, build_info_prompt, build_change_prompt, build_fix_error_prompt, SYSTEM_PROMPT
|
16
|
+
|
17
|
+
class JanitoCommands:
|
18
|
+
def __init__(self, api_key: Optional[str] = None):
|
19
|
+
try:
|
20
|
+
self.api_key = api_key or os.getenv('ANTHROPIC_API_KEY')
|
21
|
+
if not self.api_key:
|
22
|
+
raise ValueError("ANTHROPIC_API_KEY environment variable is required")
|
23
|
+
self.claude = ClaudeAPIAgent(api_key=self.api_key)
|
24
|
+
self.change_handler = FileChangeHandler()
|
25
|
+
self.console = Console()
|
26
|
+
self.debug = False
|
27
|
+
self.stop_progress = Event()
|
28
|
+
self.system_message = SYSTEM_PROMPT
|
29
|
+
self.workspace = Workspace()
|
30
|
+
except Exception as e:
|
31
|
+
raise ValueError(f"Failed to initialize Janito: {e}")
|
32
|
+
|
33
|
+
def missing_files(self, args):
|
34
|
+
"""Show files in workspace that are not included based on patterns"""
|
35
|
+
default_exclude = self.workspace.default_exclude
|
36
|
+
default_patterns = self.workspace.default_patterns
|
37
|
+
gitignore_paths = list(self.workspace.base_path.rglob(".gitignore"))
|
38
|
+
gitignore_content = []
|
39
|
+
|
40
|
+
for p in gitignore_paths:
|
41
|
+
with open(p) as f:
|
42
|
+
gitignore_content.extend([line.strip() for line in f if line.strip() and not line.strip().startswith("#")])
|
43
|
+
|
44
|
+
tree = self.workspace.generate_file_structure()
|
45
|
+
self.console.print("[bold]Files excluded from workspace:[/]")
|
46
|
+
for pattern in default_exclude + gitignore_content:
|
47
|
+
self.console.print(f"Pattern: {pattern} (from {'.gitignore' if pattern in gitignore_content else 'default excludes'})")
|
48
|
+
# Check each path in the tree against the pattern
|
49
|
+
for path in list(tree.keys()):
|
50
|
+
if any(part.startswith(pattern) for part in Path(path).parts):
|
51
|
+
self.console.print(f" {path}")
|
52
|
+
|
53
|
+
self.console.print("\n[bold]Files included based on patterns:[/]")
|
54
|
+
for pattern in default_patterns:
|
55
|
+
self.console.print(f"Pattern: {pattern} (default include pattern)")
|
56
|
+
# Check each path in the tree against the pattern
|
57
|
+
import fnmatch
|
58
|
+
for path in list(tree.keys()):
|
59
|
+
if fnmatch.fnmatch(str(path), pattern):
|
60
|
+
self.console.print(f" {path}")
|
61
|
+
|
62
|
+
def _get_files_content(self) -> str:
|
63
|
+
return self.workspace.get_files_content()
|
64
|
+
|
65
|
+
def _build_context(self, request: str, request_type: str = "general") -> str:
|
66
|
+
"""Build context with workspace status and files content"""
|
67
|
+
workspace_status = self.get_workspace_status()
|
68
|
+
files_content = self._get_files_content()
|
69
|
+
|
70
|
+
return f"""=== WORKSPACE STRUCTURE ===
|
71
|
+
{workspace_status}
|
72
|
+
|
73
|
+
=== FILES CONTENT ===
|
74
|
+
{files_content}
|
75
|
+
|
76
|
+
=== {request_type.upper()} REQUEST ===
|
77
|
+
{request}"""
|
78
|
+
|
79
|
+
def send_message(self, message: str) -> str:
|
80
|
+
"""Send message with interruptible progress bar"""
|
81
|
+
try:
|
82
|
+
if self.debug:
|
83
|
+
print("\n[Debug] Sending request to Claude")
|
84
|
+
|
85
|
+
# Reset the stop event
|
86
|
+
self.stop_progress.clear()
|
87
|
+
|
88
|
+
# Build general context prompt
|
89
|
+
prompt = build_general_prompt(
|
90
|
+
self.get_workspace_status(),
|
91
|
+
self._get_files_content(),
|
92
|
+
message
|
93
|
+
)
|
94
|
+
|
95
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
96
|
+
with Progress(
|
97
|
+
SpinnerColumn(),
|
98
|
+
TextColumn("[progress.description]{task.description}"),
|
99
|
+
transient=True,
|
100
|
+
disable=False
|
101
|
+
) as progress:
|
102
|
+
# Add a simple spinner task
|
103
|
+
task = progress.add_task("Waiting for response...", total=None)
|
104
|
+
|
105
|
+
try:
|
106
|
+
# Start Claude request without waiting
|
107
|
+
import threading
|
108
|
+
response_ready = threading.Event()
|
109
|
+
response_text = [""] # Use list to allow modification in thread
|
110
|
+
|
111
|
+
def claude_request():
|
112
|
+
try:
|
113
|
+
response_text[0] = self.claude.send_message(prompt, stop_event=self.stop_progress)
|
114
|
+
finally:
|
115
|
+
response_ready.set()
|
116
|
+
|
117
|
+
# Start request in background
|
118
|
+
request_thread = threading.Thread(target=claude_request)
|
119
|
+
request_thread.daemon = True
|
120
|
+
request_thread.start()
|
121
|
+
|
122
|
+
# Wait for response with interruption check
|
123
|
+
while not response_ready.is_set():
|
124
|
+
if self.stop_progress.is_set():
|
125
|
+
progress.stop()
|
126
|
+
return "Operation cancelled by user."
|
127
|
+
response_ready.wait(0.1) # Check every 100ms
|
128
|
+
|
129
|
+
if self.stop_progress.is_set():
|
130
|
+
return "Operation cancelled by user."
|
131
|
+
|
132
|
+
if not response_text[0]:
|
133
|
+
return "No response received."
|
134
|
+
|
135
|
+
self.last_response = response_text[0]
|
136
|
+
return response_text[0]
|
137
|
+
|
138
|
+
except KeyboardInterrupt:
|
139
|
+
progress.stop()
|
140
|
+
self.stop_progress.set()
|
141
|
+
return "Operation cancelled by user."
|
142
|
+
|
143
|
+
except Exception as e:
|
144
|
+
if self.stop_progress.is_set():
|
145
|
+
return "Operation cancelled by user."
|
146
|
+
raise RuntimeError(f"Failed to process message: {e}")
|
147
|
+
|
148
|
+
def _display_file_content(self, filepath: Path) -> None:
|
149
|
+
"""Display file content with syntax highlighting"""
|
150
|
+
try:
|
151
|
+
with open(filepath) as f:
|
152
|
+
content = f.read()
|
153
|
+
syntax = Syntax(content, "python", theme="monokai", line_numbers=True)
|
154
|
+
self.console.print("\nFile content:", style="bold red")
|
155
|
+
self.console.print(syntax)
|
156
|
+
except Exception as e:
|
157
|
+
self.console.print(f"Could not read file {filepath}: {e}", style="bold red")
|
158
|
+
|
159
|
+
def handle_file_change(self, request: str) -> str:
|
160
|
+
"""Handle file modification request starting with !"""
|
161
|
+
try:
|
162
|
+
# Build change context prompt
|
163
|
+
prompt = build_change_prompt(
|
164
|
+
self.get_workspace_status(),
|
165
|
+
self._get_files_content(),
|
166
|
+
request
|
167
|
+
)
|
168
|
+
|
169
|
+
# Get response from Claude
|
170
|
+
response = self.claude.send_message(prompt)
|
171
|
+
|
172
|
+
# Process changes
|
173
|
+
success = self.change_handler.process_changes(response)
|
174
|
+
|
175
|
+
if not success:
|
176
|
+
return "Failed to process file changes. Please check the response format."
|
177
|
+
|
178
|
+
return "File changes applied successfully."
|
179
|
+
|
180
|
+
except Exception as e:
|
181
|
+
raise RuntimeError(f"Failed to process file changes: {e}")
|
182
|
+
|
183
|
+
raise RuntimeError(f"Failed to load history: {e}")
|
184
|
+
|
185
|
+
def clear_console(self) -> str:
|
186
|
+
"""Clear the console"""
|
187
|
+
try:
|
188
|
+
os.system('clear' if os.name == 'posix' else 'cls')
|
189
|
+
return "Console cleared"
|
190
|
+
except Exception as e:
|
191
|
+
return f"Error clearing console: {str(e)}"
|
192
|
+
|
193
|
+
def get_workspace_status(self) -> str:
|
194
|
+
return self.workspace.get_workspace_status()
|
195
|
+
|
196
|
+
def show_workspace(self, show_missing: bool = False) -> str:
|
197
|
+
"""Show directory structure and Python files in current workspace"""
|
198
|
+
try:
|
199
|
+
self.workspace.print_workspace_structure()
|
200
|
+
|
201
|
+
if show_missing:
|
202
|
+
excluded_files = self.workspace.get_excluded_files()
|
203
|
+
if excluded_files:
|
204
|
+
print("\nExcluded files and directories:")
|
205
|
+
print("=" * 80)
|
206
|
+
for path in excluded_files:
|
207
|
+
print(f" {path}")
|
208
|
+
else:
|
209
|
+
print("\nNo excluded files or directories found.")
|
210
|
+
|
211
|
+
return ""
|
212
|
+
except Exception as e:
|
213
|
+
raise RuntimeError(f"Failed to show workspace: {e}")
|
214
|
+
|
215
|
+
def handle_info_request(self, request: str, workspace_status: str) -> str:
|
216
|
+
"""Handle information request ending with ?"""
|
217
|
+
try:
|
218
|
+
# Build info context prompt
|
219
|
+
prompt = build_info_prompt(
|
220
|
+
self._get_files_content(),
|
221
|
+
request
|
222
|
+
)
|
223
|
+
|
224
|
+
# Get response and render markdown
|
225
|
+
response = self.claude.send_message(prompt)
|
226
|
+
md = Markdown(response)
|
227
|
+
self.console.print(md)
|
228
|
+
return ""
|
229
|
+
|
230
|
+
except Exception as e:
|
231
|
+
raise RuntimeError(f"Failed to process information request: {e}")
|
232
|
+
|
233
|
+
def get_last_response(self) -> str:
|
234
|
+
"""Get the last sent and received message to/from Claude"""
|
235
|
+
if not self.claude.last_response:
|
236
|
+
return "No previous conversation available."
|
237
|
+
|
238
|
+
output = []
|
239
|
+
if self.claude.last_full_message:
|
240
|
+
output.append(Text("\n=== Last Message Sent ===\n", style="bold yellow"))
|
241
|
+
output.append(Text(self.claude.last_full_message + "\n"))
|
242
|
+
output.append(Text("\n=== Last Response Received ===\n", style="bold green"))
|
243
|
+
output.append(Text(self.claude.last_response))
|
244
|
+
|
245
|
+
self.console.print(*output)
|
246
|
+
return ""
|
247
|
+
|
248
|
+
def show_file(self, filepath: str) -> str:
|
249
|
+
"""Display file content with syntax highlighting"""
|
250
|
+
try:
|
251
|
+
path = Path(filepath)
|
252
|
+
if not path.exists():
|
253
|
+
return f"Error: File not found - {filepath}"
|
254
|
+
if not path.is_file():
|
255
|
+
return f"Error: Not a file - {filepath}"
|
256
|
+
|
257
|
+
self._display_file_content(path)
|
258
|
+
return ""
|
259
|
+
except Exception as e:
|
260
|
+
return f"Error displaying file: {str(e)}"
|
261
|
+
|
262
|
+
def toggle_debug(self) -> str:
|
263
|
+
"""Toggle debug mode on/off"""
|
264
|
+
self.debug = not self.debug
|
265
|
+
# Also toggle debug on the Claude agent
|
266
|
+
if hasattr(self, 'claude') and self.claude:
|
267
|
+
self.claude.debug = self.debug
|
268
|
+
return f"Debug mode {'enabled' if self.debug else 'disabled'}"
|
269
|
+
|
270
|
+
def check_syntax(self) -> str:
|
271
|
+
"""Check all Python files in the workspace for syntax errors"""
|
272
|
+
try:
|
273
|
+
errors = []
|
274
|
+
for file in self.workspace.base_path.rglob("*.py"):
|
275
|
+
try:
|
276
|
+
with open(file, "r") as f:
|
277
|
+
content = f.read()
|
278
|
+
ast.parse(content)
|
279
|
+
except SyntaxError as e:
|
280
|
+
errors.append(f"Syntax error in {file}: {e}")
|
281
|
+
|
282
|
+
if errors:
|
283
|
+
return "\n".join(errors)
|
284
|
+
return "No syntax errors found."
|
285
|
+
except Exception as e:
|
286
|
+
return f"Error checking syntax: {e}"
|
287
|
+
|
288
|
+
def _attempt_fix_error(self, filepath: str, error_output: str) -> str:
|
289
|
+
"""Attempt to fix Python errors by consulting Claude"""
|
290
|
+
try:
|
291
|
+
self.console.print("\n[yellow]Would you like me to attempt to fix this error automatically? (y/N)[/]")
|
292
|
+
if input().lower() != 'y':
|
293
|
+
return "Fix attempt cancelled by user."
|
294
|
+
|
295
|
+
# Get file content for context
|
296
|
+
with open(filepath) as f:
|
297
|
+
file_content = f.read()
|
298
|
+
|
299
|
+
# Build context using the proper prompt builder
|
300
|
+
prompt = build_fix_error_prompt(
|
301
|
+
self.get_workspace_status(),
|
302
|
+
file_content,
|
303
|
+
filepath,
|
304
|
+
error_output
|
305
|
+
)
|
306
|
+
|
307
|
+
# Get and process response
|
308
|
+
response = self.claude.send_message(prompt)
|
309
|
+
success = self.change_handler.process_changes(response)
|
310
|
+
|
311
|
+
if success:
|
312
|
+
return "Changes applied. Try running the file again."
|
313
|
+
return "Failed to apply fixes. Manual intervention required."
|
314
|
+
|
315
|
+
except Exception as e:
|
316
|
+
return f"Error attempting fix: {str(e)}"
|
317
|
+
|
318
|
+
def run_python(self, filepath: str) -> str:
|
319
|
+
"""Run a Python file"""
|
320
|
+
try:
|
321
|
+
path = Path(filepath)
|
322
|
+
if not path.exists():
|
323
|
+
return f"Error: File not found - {filepath}"
|
324
|
+
if not path.is_file():
|
325
|
+
return f"Error: Not a file - {filepath}"
|
326
|
+
if not filepath.endswith('.py'):
|
327
|
+
return f"Error: Not a Python file - {filepath}"
|
328
|
+
|
329
|
+
self.console.print(f"\n[cyan]Running Python file: {filepath}[/cyan]")
|
330
|
+
self.console.print("=" * 80)
|
331
|
+
|
332
|
+
result = subprocess.run([sys.executable, str(path)],
|
333
|
+
capture_output=True,
|
334
|
+
text=True)
|
335
|
+
|
336
|
+
if result.stdout:
|
337
|
+
self.console.print("\n[green]Output:[/green]")
|
338
|
+
print(result.stdout)
|
339
|
+
|
340
|
+
if result.returncode != 0:
|
341
|
+
self.console.print("\n[red]Execution failed with errors:[/red]")
|
342
|
+
print(result.stderr)
|
343
|
+
return self._attempt_fix_error(filepath, result.stderr)
|
344
|
+
elif result.stderr:
|
345
|
+
self.console.print("\n[yellow]Warnings:[/yellow]")
|
346
|
+
print(result.stderr)
|
347
|
+
|
348
|
+
return ""
|
349
|
+
except Exception as e:
|
350
|
+
return f"Error running file: {str(e)}"
|
351
|
+
|
352
|
+
def edit_file(self, filepath: str) -> str:
|
353
|
+
"""Open file in system editor"""
|
354
|
+
try:
|
355
|
+
path = Path(filepath)
|
356
|
+
if not path.exists():
|
357
|
+
# Create the file if it doesn't exist for .gitignore and similar files
|
358
|
+
if filepath in ['.gitignore', '.env', 'README.md']:
|
359
|
+
path.touch()
|
360
|
+
else:
|
361
|
+
return f"Error: File not found - {filepath}"
|
362
|
+
if not path.is_file():
|
363
|
+
return f"Error: Not a file - {filepath}"
|
364
|
+
|
365
|
+
# Get system editor - try VISUAL then EDITOR then fallback to nano
|
366
|
+
editor = os.getenv('VISUAL') or os.getenv('EDITOR') or 'nano'
|
367
|
+
|
368
|
+
try:
|
369
|
+
result = subprocess.run([editor, str(path)])
|
370
|
+
if result.returncode != 0:
|
371
|
+
return f"Editor exited with error code {result.returncode}"
|
372
|
+
return f"Finished editing {filepath}"
|
373
|
+
except FileNotFoundError:
|
374
|
+
return f"Error: Editor '{editor}' not found"
|
375
|
+
|
376
|
+
except Exception as e:
|
377
|
+
return f"Error editing file: {str(e)}"
|