janito 0.3.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 ADDED
@@ -0,0 +1,2 @@
1
+
2
+ # Empty file to make the directory a Python package
janito/__main__.py ADDED
@@ -0,0 +1,260 @@
1
+ import typer
2
+ from typing import Optional, Dict, Any, List
3
+ from pathlib import Path
4
+ from janito.claude import ClaudeAPIAgent
5
+ import shutil
6
+ from janito.prompts import (
7
+ build_request_analisys_prompt,
8
+ build_selected_option_prompt,
9
+ SYSTEM_PROMPT,
10
+ parse_options
11
+ )
12
+ from rich.console import Console
13
+ from rich.markdown import Markdown
14
+ import re
15
+ import tempfile
16
+ import json
17
+ from rich.syntax import Syntax
18
+ from janito.contentchange import (
19
+ handle_changes_file,
20
+ get_file_type,
21
+ parse_block_changes,
22
+ preview_and_apply_changes,
23
+ format_parsed_changes,
24
+ )
25
+ from rich.table import Table
26
+ from rich.columns import Columns
27
+ from rich.panel import Panel
28
+ from rich.text import Text
29
+ from rich.rule import Rule
30
+ from rich import box
31
+ from datetime import datetime, timezone
32
+ from itertools import chain
33
+ from janito.scan import collect_files_content, is_dir_empty, preview_scan
34
+ from janito.qa import ask_question, display_answer
35
+ from rich.prompt import Prompt, Confirm
36
+ from janito.config import config
37
+ from importlib.metadata import version
38
+
39
+ def get_version() -> str:
40
+ try:
41
+ return version("janito")
42
+ except:
43
+ return "dev"
44
+
45
+ def format_analysis(analysis: str, raw: bool = False, claude: Optional[ClaudeAPIAgent] = None) -> None:
46
+ """Format and display the analysis output"""
47
+ console = Console()
48
+ if raw and claude:
49
+ console.print("\n=== Message History ===")
50
+ for role, content in claude.messages_history:
51
+ console.print(f"\n[bold cyan]{role.upper()}:[/bold cyan]")
52
+ console.print(content)
53
+ console.print("\n=== End Message History ===\n")
54
+ else:
55
+ md = Markdown(analysis)
56
+ console.print(md)
57
+
58
+ def prompt_user(message: str, choices: List[str] = None) -> str:
59
+ """Display a prominent user prompt with optional choices"""
60
+ console = Console()
61
+ console.print()
62
+ console.print(Rule(" User Input Required ", style="bold cyan"))
63
+
64
+ if choices:
65
+ choice_text = f"[cyan]Options: {', '.join(choices)}[/cyan]"
66
+ console.print(Panel(choice_text, box=box.ROUNDED))
67
+
68
+ return Prompt.ask(f"[bold cyan]> {message}[/bold cyan]")
69
+
70
+ def get_option_selection() -> int:
71
+ """Get user input for option selection"""
72
+ while True:
73
+ try:
74
+ option = int(prompt_user("Select option number"))
75
+ return option
76
+ except ValueError:
77
+ console = Console()
78
+ console.print("[red]Please enter a valid number[/red]")
79
+
80
+ def get_history_path(workdir: Path) -> Path:
81
+ """Create and return the history directory path"""
82
+ history_dir = workdir / '.janito' / 'history'
83
+ history_dir.mkdir(parents=True, exist_ok=True)
84
+ return history_dir
85
+
86
+ def get_timestamp() -> str:
87
+ """Get current UTC timestamp in YMD_HMS format with leading zeros"""
88
+ return datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')
89
+
90
+ def save_prompt_to_file(prompt: str) -> Path:
91
+ """Save prompt to a named temporary file that won't be deleted"""
92
+ temp_file = tempfile.NamedTemporaryFile(prefix='selected_', suffix='.txt', delete=False)
93
+ temp_path = Path(temp_file.name)
94
+ temp_path.write_text(prompt)
95
+ return temp_path
96
+
97
+ def save_to_file(content: str, prefix: str, workdir: Path) -> Path:
98
+ """Save content to a timestamped file in history directory"""
99
+ history_dir = get_history_path(workdir)
100
+ timestamp = get_timestamp()
101
+ filename = f"{timestamp}_{prefix}.txt"
102
+ file_path = history_dir / filename
103
+ file_path.write_text(content)
104
+ return file_path
105
+
106
+ def handle_option_selection(claude: ClaudeAPIAgent, initial_response: str, request: str, raw: bool = False, workdir: Optional[Path] = None, include: Optional[List[Path]] = None) -> None:
107
+ """Handle option selection and implementation details"""
108
+ option = get_option_selection()
109
+ paths_to_scan = [workdir] if workdir else []
110
+ if include:
111
+ paths_to_scan.extend(include)
112
+ files_content = collect_files_content(paths_to_scan, workdir) if paths_to_scan else ""
113
+
114
+ selected_prompt = build_selected_option_prompt(option, request, initial_response, files_content)
115
+ prompt_file = save_to_file(selected_prompt, 'selected', workdir)
116
+ if config.verbose:
117
+ print(f"\nSelected prompt saved to: {prompt_file}")
118
+
119
+ selected_response = claude.send_message(selected_prompt)
120
+ changes_file = save_to_file(selected_response, 'changes', workdir)
121
+ if config.verbose:
122
+ print(f"\nChanges saved to: {changes_file}")
123
+
124
+ changes = parse_block_changes(selected_response)
125
+ preview_and_apply_changes(changes, workdir)
126
+
127
+ def replay_saved_file(filepath: Path, claude: ClaudeAPIAgent, workdir: Path, raw: bool = False) -> None:
128
+ """Process a saved prompt file and display the response"""
129
+ if not filepath.exists():
130
+ raise FileNotFoundError(f"File {filepath} not found")
131
+
132
+ file_type = get_file_type(filepath)
133
+ content = filepath.read_text()
134
+
135
+ if file_type == 'changes':
136
+ changes = parse_block_changes(content)
137
+ preview_and_apply_changes(changes, workdir)
138
+ elif file_type == 'analysis':
139
+ format_analysis(content, raw, claude)
140
+ handle_option_selection(claude, content, content, raw, workdir)
141
+ elif file_type == 'selected':
142
+ if raw:
143
+ console = Console()
144
+ console.print("\n=== Prompt Content ===")
145
+ console.print(content)
146
+ console.print("=== End Prompt Content ===\n")
147
+ response = claude.send_message(content)
148
+ changes_file = save_to_file(response, 'changes_', workdir)
149
+ print(f"\nChanges saved to: {changes_file}")
150
+
151
+ changes = parse_block_changes(response)
152
+ preview_and_apply_changes(preview_changes, workdir)
153
+ else:
154
+ response = claude.send_message(content)
155
+ format_analysis(response, raw)
156
+
157
+ def process_question(question: str, workdir: Path, include: List[Path], raw: bool, claude: ClaudeAPIAgent) -> None:
158
+ """Process a question about the codebase"""
159
+ paths_to_scan = [workdir] if workdir else []
160
+ if include:
161
+ paths_to_scan.extend(include)
162
+ files_content = collect_files_content(paths_to_scan, workdir)
163
+
164
+ answer = ask_question(question, files_content, claude)
165
+ display_answer(answer, raw)
166
+
167
+ def ensure_workdir(workdir: Path) -> Path:
168
+ """Ensure working directory exists, prompt for creation if it doesn't"""
169
+ if workdir.exists():
170
+ return workdir
171
+
172
+ console = Console()
173
+ console.print(f"\n[yellow]Directory does not exist:[/yellow] {workdir}")
174
+ if Confirm.ask("Create directory?"):
175
+ workdir.mkdir(parents=True)
176
+ console.print(f"[green]Created directory:[/green] {workdir}")
177
+ return workdir
178
+ raise typer.Exit(1)
179
+
180
+ def typer_main(
181
+ request: Optional[str] = typer.Argument(None, help="The modification request"),
182
+ ask: Optional[str] = typer.Option(None, "--ask", help="Ask a question about the codebase"),
183
+ workdir: Optional[Path] = typer.Option(None, "-w", "--workdir",
184
+ help="Working directory (defaults to current directory)",
185
+ file_okay=False, dir_okay=True),
186
+ raw: bool = typer.Option(False, "--raw", help="Print raw response instead of markdown format"),
187
+ play: Optional[Path] = typer.Option(None, "--play", help="Replay a saved prompt file"),
188
+ include: Optional[List[Path]] = typer.Option(None, "-i", "--include", help="Additional paths to include in analysis", exists=True),
189
+ debug: bool = typer.Option(False, "--debug", help="Show debug information"),
190
+ debug_line: Optional[int] = typer.Option(None, "--debug-line", help="Show debug information only for specific line number"),
191
+ verbose: bool = typer.Option(False, "-v", "--verbose", help="Show verbose output"),
192
+ scan: bool = typer.Option(False, "--scan", help="Preview files that would be analyzed"),
193
+ version: bool = typer.Option(False, "--version", help="Show version and exit"),
194
+ ) -> None:
195
+ """
196
+ Analyze files and provide modification instructions.
197
+ """
198
+ if version:
199
+ console = Console()
200
+ console.print(f"Janito v{get_version()}")
201
+ raise typer.Exit()
202
+
203
+ config.set_debug(debug)
204
+ config.set_verbose(verbose)
205
+ config.set_debug_line(debug_line)
206
+
207
+ claude = ClaudeAPIAgent(system_prompt=SYSTEM_PROMPT)
208
+
209
+ if not any([request, ask, play, scan]):
210
+ workdir = workdir or Path.cwd()
211
+ workdir = ensure_workdir(workdir)
212
+ from janito.console import start_console_session
213
+ start_console_session(workdir, include)
214
+ return
215
+
216
+ workdir = workdir or Path.cwd()
217
+ workdir = ensure_workdir(workdir)
218
+
219
+ if include:
220
+ include = [
221
+ path if path.is_absolute() else (workdir / path).resolve()
222
+ for path in include
223
+ ]
224
+
225
+ if ask:
226
+ process_question(ask, workdir, include, raw, claude)
227
+ return
228
+
229
+ if scan:
230
+ paths_to_scan = include if include else [workdir]
231
+ preview_scan(paths_to_scan, workdir)
232
+ return
233
+
234
+ if play:
235
+ replay_saved_file(play, claude, workdir, raw)
236
+ return
237
+
238
+ paths_to_scan = include if include else [workdir]
239
+
240
+ is_empty = is_dir_empty(workdir)
241
+ if is_empty and not include:
242
+ console = Console()
243
+ console.print("\n[bold blue]Empty directory - will create new files as needed[/bold blue]")
244
+ files_content = ""
245
+ else:
246
+ files_content = collect_files_content(paths_to_scan, workdir)
247
+
248
+ initial_prompt = build_request_analisys_prompt(files_content, request)
249
+ initial_response = claude.send_message(initial_prompt)
250
+ analysis_file = save_to_file(initial_response, 'analysis', workdir)
251
+
252
+ format_analysis(initial_response, raw, claude)
253
+
254
+ handle_option_selection(claude, initial_response, request, raw, workdir, include)
255
+
256
+ def main():
257
+ typer.run(typer_main)
258
+
259
+ if __name__ == "__main__":
260
+ main()
janito/changeviewer.py ADDED
@@ -0,0 +1,64 @@
1
+ from pathlib import Path
2
+ from rich.console import Console
3
+ from rich.text import Text
4
+ from typing import TypedDict
5
+ import difflib
6
+
7
+ class FileChange(TypedDict):
8
+ """Type definition for a file change"""
9
+ description: str
10
+ new_content: str
11
+
12
+ def show_file_changes(console: Console, filepath: Path, original: str, new_content: str, description: str) -> None:
13
+ """Display side by side comparison of file changes"""
14
+ half_width = (console.width - 3) // 2
15
+
16
+ # Show header
17
+ console.print(f"\n[bold blue]Changes for {filepath}[/bold blue]")
18
+ console.print(f"[dim]{description}[/dim]\n")
19
+
20
+ # Show side by side content
21
+ console.print(Text("OLD".center(half_width) + "│" + "NEW".center(half_width), style="blue bold"))
22
+ console.print(Text("─" * half_width + "┼" + "─" * half_width, style="blue"))
23
+
24
+ old_lines = original.splitlines()
25
+ new_lines = new_content.splitlines()
26
+
27
+ for i in range(max(len(old_lines), len(new_lines))):
28
+ old = old_lines[i] if i < len(old_lines) else ""
29
+ new = new_lines[i] if i < len(new_lines) else ""
30
+
31
+ old_text = Text(f"{old:<{half_width}}", style="red" if old != new else None)
32
+ new_text = Text(f"{new:<{half_width}}", style="green" if old != new else None)
33
+ console.print(old_text + Text("│", style="blue") + new_text)
34
+
35
+ def show_diff_changes(console: Console, filepath: Path, original: str, new_content: str, description: str) -> None:
36
+ """Display file changes using unified diff format"""
37
+ # Show header
38
+ console.print(f"\n[bold blue]Changes for {filepath}[/bold blue]")
39
+ console.print(f"[dim]{description}[/dim]\n")
40
+
41
+ # Generate diff
42
+ diff = difflib.unified_diff(
43
+ original.splitlines(keepends=True),
44
+ new_content.splitlines(keepends=True),
45
+ fromfile='old',
46
+ tofile='new',
47
+ lineterm=''
48
+ )
49
+
50
+ # Print diff with colors
51
+ for line in diff:
52
+ if line.startswith('+++'):
53
+ console.print(Text(line.rstrip(), style="bold green"))
54
+ elif line.startswith('---'):
55
+ console.print(Text(line.rstrip(), style="bold red"))
56
+ elif line.startswith('+'):
57
+ console.print(Text(line.rstrip(), style="green"))
58
+ elif line.startswith('-'):
59
+ console.print(Text(line.rstrip(), style="red"))
60
+ elif line.startswith('@@'):
61
+ console.print(Text(line.rstrip(), style="cyan"))
62
+ else:
63
+ console.print(Text(line.rstrip()))
64
+
janito/claude.py ADDED
@@ -0,0 +1,74 @@
1
+ from rich.traceback import install
2
+ import anthropic
3
+ import os
4
+ from typing import Optional
5
+ from rich.progress import Progress, SpinnerColumn, TextColumn
6
+ from threading import Event
7
+
8
+ # Install rich traceback handler
9
+ install(show_locals=True)
10
+
11
+ class ClaudeAPIAgent:
12
+ """Handles interaction with Claude API, including message handling"""
13
+ def __init__(self, api_key: Optional[str] = None, system_prompt: str = None):
14
+ if not system_prompt:
15
+ raise ValueError("system_prompt is required")
16
+ self.api_key = api_key or os.getenv('ANTHROPIC_API_KEY')
17
+ if not self.api_key:
18
+ raise ValueError("ANTHROPIC_API_KEY environment variable is required")
19
+ self.client = anthropic.Client(api_key=self.api_key)
20
+ self.model = "claude-3-5-sonnet-20241022"
21
+ self.stop_progress = Event()
22
+ self.system_message = system_prompt
23
+ self.last_prompt = None
24
+ self.last_full_message = None
25
+ self.last_response = None
26
+ self.messages_history = []
27
+ if system_prompt:
28
+ self.messages_history.append(("system", system_prompt))
29
+
30
+ def send_message(self, message: str, stop_event: Event = None) -> str:
31
+ """Send message to Claude API and return response"""
32
+ try:
33
+ self.messages_history.append(("user", message))
34
+ # Store the full message
35
+ self.last_full_message = message
36
+
37
+ try:
38
+ # Check if already cancelled
39
+ if stop_event and stop_event.is_set():
40
+ return ""
41
+
42
+ # Start API request
43
+ response = self.client.messages.create(
44
+ model=self.model, # Use discovered model
45
+ system=self.system_message,
46
+ max_tokens=4000,
47
+ messages=[
48
+ {"role": "user", "content": message}
49
+ ],
50
+ temperature=0,
51
+ )
52
+
53
+ # Handle response
54
+ response_text = response.content[0].text
55
+
56
+ # Only store and process response if not cancelled
57
+ if not (stop_event and stop_event.is_set()):
58
+ self.last_response = response_text
59
+ self.messages_history.append(("assistant", response_text))
60
+
61
+ # Always return the response, let caller handle cancellation
62
+ return response_text
63
+
64
+ except KeyboardInterrupt:
65
+ if stop_event:
66
+ stop_event.set()
67
+ return ""
68
+
69
+ except Exception as e:
70
+ error_msg = f"Error: {str(e)}"
71
+ self.messages_history.append(("error", error_msg))
72
+ if stop_event and stop_event.is_set():
73
+ return ""
74
+ return error_msg
janito/config.py ADDED
@@ -0,0 +1,32 @@
1
+ from typing import Optional
2
+ import os
3
+
4
+ class ConfigManager:
5
+ _instance = None
6
+
7
+ def __init__(self):
8
+ self.debug = False
9
+ self.verbose = False
10
+ self.debug_line = None # Add this line
11
+
12
+ @classmethod
13
+ def get_instance(cls) -> "ConfigManager":
14
+ if cls._instance is None:
15
+ cls._instance = cls()
16
+ return cls._instance
17
+
18
+ def set_debug(self, enabled: bool) -> None:
19
+ self.debug = enabled
20
+
21
+ def set_verbose(self, enabled: bool) -> None:
22
+ self.verbose = enabled
23
+
24
+ def set_debug_line(self, line: Optional[int]) -> None: # Add this method
25
+ self.debug_line = line
26
+
27
+ def should_debug_line(self, line: int) -> bool: # Add this method
28
+ """Return True if we should show debug for this line number"""
29
+ return self.debug and (self.debug_line is None or self.debug_line == line)
30
+
31
+ # Create a singleton instance
32
+ config = ConfigManager.get_instance()
janito/console.py ADDED
@@ -0,0 +1,60 @@
1
+ from prompt_toolkit import PromptSession
2
+ from prompt_toolkit.history import FileHistory
3
+ from pathlib import Path
4
+ from rich.console import Console
5
+ from janito.claude import ClaudeAPIAgent
6
+ from janito.prompts import build_request_analisys_prompt, SYSTEM_PROMPT
7
+ from janito.scan import collect_files_content
8
+ from janito.__main__ import handle_option_selection
9
+
10
+ def start_console_session(workdir: Path, include: list[Path] = None) -> None:
11
+ """Start an interactive console session using prompt_toolkit"""
12
+ console = Console()
13
+ claude = ClaudeAPIAgent(system_prompt=SYSTEM_PROMPT)
14
+
15
+ # Setup prompt session with history
16
+ history_file = workdir / '.janito' / 'console_history'
17
+ history_file.parent.mkdir(parents=True, exist_ok=True)
18
+ session = PromptSession(history=FileHistory(str(history_file)))
19
+
20
+ from importlib.metadata import version
21
+ try:
22
+ ver = version("janito")
23
+ except:
24
+ ver = "dev"
25
+
26
+ console.print("\n[bold blue]╔═══════════════════════════════════════════╗[/bold blue]")
27
+ console.print("[bold blue]║ Janito AI Assistant ║[/bold blue]")
28
+ console.print("[bold blue]║ v" + ver.ljust(8) + " ║[/bold blue]")
29
+ console.print("[bold blue]╠═══════════════════════════════════════════╣[/bold blue]")
30
+ console.print("[bold blue]║ Your AI-powered development companion ║[/bold blue]")
31
+ console.print("[bold blue]╚═══════════════════════════════════════════╝[/bold blue]")
32
+ console.print("\n[cyan]Type your requests or 'exit' to quit[/cyan]\n")
33
+
34
+ while True:
35
+ try:
36
+ request = session.prompt("janito> ")
37
+ if request.lower() in ('exit', 'quit'):
38
+ break
39
+
40
+ if not request.strip():
41
+ continue
42
+
43
+ # Get current files content
44
+ paths_to_scan = [workdir] if workdir else []
45
+ if include:
46
+ paths_to_scan.extend(include)
47
+ files_content = collect_files_content(paths_to_scan, workdir)
48
+
49
+ # Get initial analysis
50
+ initial_prompt = build_request_analisys_prompt(files_content, request)
51
+ initial_response = claude.send_message(initial_prompt)
52
+
53
+ # Show response and handle options
54
+ console.print(initial_response)
55
+ handle_option_selection(claude, initial_response, request, False, workdir, include)
56
+
57
+ except KeyboardInterrupt:
58
+ continue
59
+ except EOFError:
60
+ break
@@ -0,0 +1,165 @@
1
+ import re
2
+ from pathlib import Path
3
+ from typing import Dict, Tuple, TypedDict, List
4
+ from rich.console import Console
5
+ from rich.prompt import Confirm
6
+ import tempfile
7
+ from janito.changeviewer import show_file_changes, FileChange, show_diff_changes
8
+ import ast
9
+ from datetime import datetime
10
+ import shutil
11
+
12
+ def get_file_type(filepath: Path) -> str:
13
+ """Determine the type of saved file based on its name"""
14
+ name = filepath.name.lower()
15
+ if 'changes' in name:
16
+ return 'changes'
17
+ elif 'selected' in name:
18
+ return 'selected'
19
+ elif 'analysis' in name:
20
+ return 'analysis'
21
+ elif 'response' in name:
22
+ return 'response'
23
+ return 'unknown'
24
+
25
+ def parse_block_changes(content: str) -> Dict[Path, FileChange]:
26
+ """Parse file changes from code blocks in the content.
27
+ Returns dict mapping filepath -> FileChange"""
28
+ changes = {}
29
+ pattern = r'##\s*([\da-f-]+)\s+([^\n]+)\s+begin\s*"([^"]*)"[^\n]*##\n(.*?)##\s*\1\s+\2\s+end\s*##'
30
+ matches = re.finditer(pattern, content, re.DOTALL)
31
+
32
+ for match in matches:
33
+ filepath = Path(match.group(2))
34
+ description = match.group(3)
35
+ file_content = match.group(4).strip()
36
+ changes[filepath] = FileChange(
37
+ description=description,
38
+ new_content=file_content
39
+ )
40
+
41
+ return changes
42
+
43
+ def save_changes_to_history(content: str, request: str, workdir: Path) -> Path:
44
+ """Save change content to history folder with timestamp and request info"""
45
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") # Already in the correct format
46
+ history_dir = workdir / '.janito' / 'history'
47
+ history_dir.mkdir(parents=True, exist_ok=True)
48
+
49
+ # Create history entry with request and changes
50
+ history_file = history_dir / f"changes_{timestamp}.txt"
51
+
52
+ history_content = f"""Request: {request}
53
+ Timestamp: {timestamp}
54
+
55
+ Changes:
56
+ {content}
57
+ """
58
+ history_file.write_text(history_content)
59
+ return history_file
60
+
61
+ def process_and_save_changes(content: str, request: str, workdir: Path) -> Tuple[Dict[Path, Tuple[str, str]], Path]:
62
+ """Parse changes and save to history, returns (changes_dict, history_file)"""
63
+ changes = parse_block_changes(content)
64
+ history_file = save_changes_to_history(content, request, workdir)
65
+ return changes, history_file
66
+
67
+ def validate_python_syntax(content: str, filepath: Path) -> Tuple[bool, str]:
68
+ """Validate Python syntax and return (is_valid, error_message)"""
69
+ try:
70
+ ast.parse(content)
71
+ return True, ""
72
+ except SyntaxError as e:
73
+ return False, f"Line {e.lineno}: {e.msg}"
74
+ except Exception as e:
75
+ return False, str(e)
76
+
77
+ def format_parsed_changes(changes: Dict[Path, Tuple[str, str]]) -> str:
78
+ """Format parsed changes to show only file change descriptions"""
79
+ result = []
80
+ for filepath, (_, description) in changes.items(): # Updated tuple unpacking
81
+ result.append(f"=== {filepath} ===\n{description}\n")
82
+ return "\n".join(result)
83
+
84
+ def validate_changes(changes: Dict[Path, FileChange]) -> Tuple[bool, List[Tuple[Path, str]]]:
85
+ """Validate all changes, returns (is_valid, list of errors)"""
86
+ errors = []
87
+ for filepath, change in changes.items():
88
+ if filepath.suffix == '.py':
89
+ is_valid, error = validate_python_syntax(change['new_content'], filepath)
90
+ if not is_valid:
91
+ errors.append((filepath, error))
92
+ return len(errors) == 0, errors
93
+
94
+ def preview_and_apply_changes(changes: Dict[Path, FileChange], workdir: Path) -> bool:
95
+ """Preview changes in temporary directory and apply if confirmed."""
96
+ console = Console()
97
+
98
+ if not changes:
99
+ console.print("\n[yellow]No changes were found to apply[/yellow]")
100
+ return False
101
+
102
+ with tempfile.TemporaryDirectory() as temp_dir:
103
+ preview_dir = Path(temp_dir)
104
+ if workdir.exists():
105
+ shutil.copytree(workdir, preview_dir, dirs_exist_ok=True)
106
+
107
+ for filepath, change in changes.items():
108
+ # Get original content
109
+ orig_path = workdir / filepath
110
+ original = orig_path.read_text() if orig_path.exists() else ""
111
+
112
+ # Prepare preview
113
+ preview_path = preview_dir / filepath
114
+ preview_path.parent.mkdir(parents=True, exist_ok=True)
115
+ preview_path.write_text(change['new_content'])
116
+
117
+ # Show changes
118
+ show_diff_changes(console, filepath, original, change['new_content'], change['description'])
119
+
120
+ # Apply changes if confirmed
121
+ if Confirm.ask("\nApply these changes?"):
122
+ for filepath, _ in changes.items():
123
+ preview_path = preview_dir / filepath
124
+ target_path = workdir / filepath
125
+ target_path.parent.mkdir(parents=True, exist_ok=True)
126
+ shutil.copy2(preview_path, target_path)
127
+ console.print(f"[green]✓[/green] Applied changes to {filepath}")
128
+ return True
129
+
130
+ return False
131
+
132
+ def apply_content_changes(content: str, request: str, workdir: Path) -> Tuple[bool, Path]:
133
+ """Regular flow: Parse content, save to history, and apply changes."""
134
+ console = Console()
135
+ changes = parse_block_changes(content)
136
+
137
+ if not changes:
138
+ console.print("\n[yellow]No file changes were found in the response[/yellow]")
139
+ return False, None
140
+
141
+ # Validate changes before proceeding
142
+ is_valid, errors = validate_changes(changes)
143
+ if not is_valid:
144
+ console = Console()
145
+ console.print("\n[red bold]⚠️ Cannot apply changes: Python syntax errors detected![/red bold]")
146
+ for filepath, error in errors:
147
+ console.print(f"\n[red]⚠️ {filepath}: {error}[/red]")
148
+ return False, None
149
+
150
+ history_file = save_changes_to_history(content, request, workdir)
151
+ success = preview_and_apply_changes(changes, workdir)
152
+ return success, history_file
153
+
154
+ def handle_changes_file(filepath: Path, workdir: Path) -> Tuple[bool, Path]:
155
+ """Replay flow: Load changes from file and apply them."""
156
+ content = filepath.read_text()
157
+ changes = parse_block_changes(content)
158
+
159
+ if not changes:
160
+ console = Console()
161
+ console.print("\n[yellow]No file changes were found in the file[/yellow]")
162
+ return False, None
163
+
164
+ success = preview_and_apply_changes(changes, workdir)
165
+ return success, filepath
janito/prompts.py ADDED
@@ -0,0 +1,97 @@
1
+ import re
2
+
3
+ # Core system prompt focused on role and purpose
4
+ SYSTEM_PROMPT = """You are Janito, an AI assistant for software development tasks. Be concise.
5
+ """
6
+
7
+
8
+ CHANGE_ANALISYS_PROMPT = """
9
+ Current files:
10
+ <files>
11
+ {files_content}
12
+ </files>
13
+
14
+ Considering the current files content, provide a table of options for the requested change.
15
+ Always provide options using a header label "=== **Option 1** : ...", "=== **Option 2**: ...", etc.
16
+ Provide the header with a short description followed by the file changes on the next line
17
+ What files should be modified and what should they contain? (one line description)
18
+ Do not provide the content of any of the file suggested to be created or modified.
19
+
20
+ Request:
21
+ {request}
22
+ """
23
+
24
+ SELECTED_OPTION_PROMPT = """
25
+ Original request: {request}
26
+
27
+ Please provide detailed implementation using the following guide:
28
+ {option_text}
29
+
30
+ Current files:
31
+ <files>
32
+ {files_content}
33
+ </files>
34
+
35
+ After checking the above files and the provided implementation, please provide the following:
36
+
37
+ ## <uuid4> filename begin "short description of the change" ##
38
+ <entire file content>
39
+ ## <uuid4> filename end ##
40
+
41
+ ALWAYS provide the entire file content, not just the changes.
42
+ If no changes are needed answer to any worksppace just reply <
43
+ """
44
+
45
+ def build_selected_option_prompt(option_number: int, request: str, initial_response: str, files_content: str = "") -> str:
46
+ """Build prompt for selected option details"""
47
+ options = parse_options(initial_response)
48
+ if option_number not in options:
49
+ raise ValueError(f"Option {option_number} not found in response")
50
+
51
+ return SELECTED_OPTION_PROMPT.format(
52
+ option_text=options[option_number],
53
+ request=request,
54
+ files_content=files_content
55
+ )
56
+
57
+ def parse_options(response: str) -> dict[int, str]:
58
+ """Parse options from the response text, including any list items after the option label"""
59
+ options = {}
60
+ pattern = r"===\s*\*\*Option (\d+)\*\*\s*:\s*(.+?)(?====\s*\*\*Option|\Z)"
61
+ matches = re.finditer(pattern, response, re.DOTALL)
62
+
63
+ for match in matches:
64
+ option_num = int(match.group(1))
65
+ option_text = match.group(2).strip()
66
+
67
+ # Split into description and list items
68
+ lines = option_text.splitlines()
69
+ description = lines[0]
70
+ list_items = []
71
+
72
+ # Collect list items that follow
73
+ for line in lines[1:]:
74
+ line = line.strip()
75
+ if line.startswith(('- ', '* ', '• ')):
76
+ list_items.append(line)
77
+ elif not line:
78
+ continue
79
+ else:
80
+ break
81
+
82
+ # Combine description with list items if any exist
83
+ if list_items:
84
+ option_text = description + '\n' + '\n'.join(list_items)
85
+
86
+ options[option_num] = option_text
87
+
88
+ return options
89
+
90
+
91
+ def build_request_analisys_prompt(files_content: str, request: str) -> str:
92
+ """Build prompt for information requests"""
93
+
94
+ return CHANGE_ANALISYS_PROMPT.format(
95
+ files_content=files_content,
96
+ request=request
97
+ )
janito/qa.py ADDED
@@ -0,0 +1,32 @@
1
+ from rich.console import Console
2
+ from rich.markdown import Markdown
3
+ from janito.claude import ClaudeAPIAgent
4
+
5
+ QA_PROMPT = """Please provide a clear and concise answer to the following question about the codebase:
6
+
7
+ Question: {question}
8
+
9
+ Current files:
10
+ <files>
11
+ {files_content}
12
+ </files>
13
+
14
+ Focus on providing factual information and explanations. Do not suggest code changes.
15
+ """
16
+
17
+ def ask_question(question: str, files_content: str, claude: ClaudeAPIAgent) -> str:
18
+ """Process a question about the codebase and return the answer"""
19
+ prompt = QA_PROMPT.format(
20
+ question=question,
21
+ files_content=files_content
22
+ )
23
+ return claude.send_message(prompt)
24
+
25
+ def display_answer(answer: str, raw: bool = False) -> None:
26
+ """Display the answer in markdown or raw format"""
27
+ console = Console()
28
+ if raw:
29
+ console.print(answer)
30
+ else:
31
+ md = Markdown(answer)
32
+ console.print(md)
janito/scan.py ADDED
@@ -0,0 +1,122 @@
1
+ from pathlib import Path
2
+ from typing import List, Tuple
3
+ from rich.console import Console
4
+ from rich.columns import Columns
5
+ from janito.config import config
6
+ from pathspec import PathSpec
7
+ from pathspec.patterns import GitWildMatchPattern
8
+
9
+
10
+ SPECIAL_FILES = ["README.md", "__init__.py", "__main__.py"]
11
+
12
+ def _scan_paths(paths: List[Path], workdir: Path = None) -> Tuple[List[str], List[str]]:
13
+ """Common scanning logic used by both preview and content collection"""
14
+ content_parts = []
15
+ file_items = []
16
+ skipped_files = []
17
+ console = Console()
18
+
19
+ # Load gitignore if it exists
20
+ gitignore_path = workdir / '.gitignore' if workdir else None
21
+ gitignore_spec = None
22
+ if gitignore_path and gitignore_path.exists():
23
+ with open(gitignore_path) as f:
24
+ gitignore = f.read()
25
+ gitignore_spec = PathSpec.from_lines(GitWildMatchPattern, gitignore.splitlines())
26
+
27
+
28
+ def scan_path(path: Path, level: int) -> None:
29
+ """
30
+ Scan a path and add it to the content_parts list
31
+ level 0 means we are scanning the root directory
32
+ level 1 we provide both directory directory name and file content
33
+ level > 1 we just return
34
+ """
35
+ if level > 1:
36
+ return
37
+
38
+ relative_base = workdir
39
+ if path.is_dir():
40
+ relative_path = path.relative_to(relative_base)
41
+ content_parts.append(f'<directory><path>{relative_path}</path>not sent</directory>')
42
+ file_items.append(f"[blue]•[/blue] {relative_path}/")
43
+ # Check for special files
44
+ special_found = []
45
+ for special_file in SPECIAL_FILES:
46
+ if (path / special_file).exists():
47
+ special_found.append(special_file)
48
+ if special_found:
49
+ file_items[-1] = f"[blue]•[/blue] {relative_path}/ [cyan]({', '.join(special_found)})[/cyan]"
50
+ for special_file in special_found:
51
+ special_path = path / special_file
52
+ try:
53
+ relative_path = special_path.relative_to(relative_base)
54
+ file_content = special_path.read_text(encoding='utf-8')
55
+ content_parts.append(f"<file>\n<path>{relative_path}</path>\n<content>\n{file_content}\n</content>\n</file>")
56
+ except UnicodeDecodeError:
57
+ skipped_files.append(str(relative_path))
58
+ console.print(f"[yellow]Warning: Skipping file due to encoding issues: {relative_path}[/yellow]")
59
+
60
+ for item in path.iterdir():
61
+ # Skip if matches gitignore patterns
62
+ if gitignore_spec:
63
+ rel_path = str(item.relative_to(workdir))
64
+ if gitignore_spec.match_file(rel_path):
65
+ continue
66
+ scan_path(item, level+1)
67
+
68
+ else:
69
+ relative_path = path.relative_to(relative_base)
70
+ # check if file is binary
71
+ try:
72
+ if path.is_file() and path.read_bytes().find(b'\x00') != -1:
73
+ console.print(f"[red]Skipped binary file found: {relative_path}[/red]")
74
+ return
75
+ file_content = path.read_text(encoding='utf-8')
76
+ content_parts.append(f"<file>\n<path>{relative_path}</path>\n<content>\n{file_content}\n</content>\n</file>")
77
+ file_items.append(f"[cyan]•[/cyan] {relative_path}")
78
+ except UnicodeDecodeError:
79
+ skipped_files.append(str(relative_path))
80
+ console.print(f"[yellow]Warning: Skipping file due to encoding issues: {relative_path}[/yellow]")
81
+
82
+ for path in paths:
83
+ scan_path(path, 0)
84
+
85
+ if skipped_files and config.verbose:
86
+ console.print("\n[yellow]Files skipped due to encoding issues:[/yellow]")
87
+ for file in skipped_files:
88
+ console.print(f" • {file}")
89
+
90
+ return content_parts, file_items
91
+
92
+ def collect_files_content(paths: List[Path], workdir: Path = None) -> str:
93
+ """Collect content from all files in XML format"""
94
+ console = Console()
95
+ content_parts, file_items = _scan_paths(paths, workdir)
96
+
97
+ if file_items and config.verbose:
98
+ console.print("\n[bold blue]Contents being analyzed:[/bold blue]")
99
+ console.print(Columns(file_items, padding=(0, 4), expand=True))
100
+
101
+ return "\n".join(content_parts)
102
+
103
+ def preview_scan(paths: List[Path], workdir: Path = None) -> None:
104
+ """Preview what files and directories would be scanned"""
105
+ console = Console()
106
+ _, file_items = _scan_paths(paths, workdir)
107
+
108
+ # Change message based on whether we're scanning included paths or workdir
109
+ if len(paths) == 1 and paths[0] == workdir:
110
+ console.print(f"\n[bold blue]Scanning working directory:[/bold blue] {workdir.absolute()}")
111
+ else:
112
+ console.print(f"\n[bold blue]Working directory:[/bold blue] {workdir.absolute()}")
113
+ console.print("\n[bold blue]Scanning included paths:[/bold blue]")
114
+ for path in paths:
115
+ console.print(f" • {path.absolute()}")
116
+
117
+ console.print("\n[bold blue]Files that would be analyzed:[/bold blue]")
118
+ console.print(Columns(file_items, padding=(0, 4), expand=True))
119
+
120
+ def is_dir_empty(path: Path) -> bool:
121
+ """Check if directory is empty, ignoring hidden files"""
122
+ return not any(item for item in path.iterdir() if not item.name.startswith('.'))
@@ -0,0 +1,138 @@
1
+ Metadata-Version: 2.3
2
+ Name: janito
3
+ Version: 0.3.0
4
+ Summary: A CLI tool for software development tasks powered by AI
5
+ Project-URL: Homepage, https://github.com/joaompinto/janito
6
+ Project-URL: Repository, https://github.com/joaompinto/janito.git
7
+ Author-email: João Pinto <lamego.pinto@gmail.com>
8
+ License: MIT
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3.8
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Topic :: Software Development
17
+ Requires-Python: >=3.8
18
+ Requires-Dist: anthropic
19
+ Requires-Dist: pathspec
20
+ Requires-Dist: rich
21
+ Requires-Dist: typer
22
+ Description-Content-Type: text/markdown
23
+
24
+ # 🤖 Janito CLI
25
+
26
+ A CLI tool for software development tasks powered by AI.
27
+
28
+ Janito is an AI-powered assistant that helps automate common software development tasks like refactoring, documentation updates, and code optimization.
29
+
30
+ ## 📥 Installation
31
+
32
+ ```bash
33
+ # Install from PyPI
34
+ pip install janito
35
+
36
+ # Install from source
37
+ git clone https://github.com/joaompinto/janito.git
38
+ cd janito
39
+ pip install -e .
40
+ ```
41
+
42
+ ## ⚡ Requirements
43
+
44
+ - Python 3.8+
45
+ - Anthropic API key
46
+ - Required packages (automatically installed):
47
+ - typer
48
+ - pathspec
49
+ - rich
50
+
51
+ ## ⚙️ Configuration
52
+
53
+ ### 🔑 API Key Setup
54
+ Janito requires an Anthropic API key to function. Set it as an environment variable:
55
+
56
+ ```bash
57
+ export ANTHROPIC_API_KEY='your-api-key-here'
58
+ ```
59
+
60
+ You can also add this to your shell profile (~/.bashrc, ~/.zshrc, etc.) for persistence.
61
+
62
+ ## 📖 Usage
63
+
64
+ Janito can be used in two modes: Command Line or Interactive Console.
65
+
66
+ ### 💻 Command Line Mode
67
+
68
+ ```bash
69
+ janito REQUEST [OPTIONS]
70
+ ```
71
+
72
+ #### Arguments
73
+ - `REQUEST`: The modification request
74
+
75
+ #### Options
76
+ - `-w, --workdir PATH`: Working directory (defaults to current directory)
77
+ - `--raw`: Print raw response instead of markdown format
78
+ - `--play PATH`: Replay a saved prompt file
79
+ - `-i, --include PATH`: Additional paths to include in analysis
80
+ - `--debug`: Show debug information
81
+ - `-v, --verbose`: Show verbose output
82
+ - `--ask`: Ask a question about the codebase
83
+ - `--scan`: Preview files that would be analyzed
84
+
85
+ ### 🖥️ Interactive Console Mode
86
+
87
+ Start the interactive console by running `janito` without arguments:
88
+
89
+ ```bash
90
+ janito
91
+ ```
92
+
93
+ In console mode, you can:
94
+ - Enter requests directly
95
+ - Navigate history with up/down arrows
96
+ - Use special commands starting with /
97
+
98
+ ### 📝 Examples
99
+
100
+ ```bash
101
+ # Command Line Mode Examples
102
+ janito "create docstrings for all functions"
103
+ janito "add error handling" -w ./myproject
104
+ janito "update tests" -i ./tests -i ./lib
105
+ janito --ask "explain the authentication flow"
106
+ janito --scan # Preview files to be analyzed
107
+
108
+ # Console Mode
109
+ janito # Starts interactive session
110
+ ```
111
+
112
+ ## ✨ Features
113
+
114
+ - 🤖 AI-powered code analysis and modifications
115
+ - 💻 Interactive console mode for continuous interaction
116
+ - 📁 Support for multiple file types
117
+ - ✅ Syntax validation for Python files
118
+ - 👀 Interactive change preview and confirmation
119
+ - 📜 History tracking of all changes
120
+ - 🐛 Debug and verbose output modes
121
+ - ❓ Question-answering about codebase
122
+ - 🔍 File scanning preview
123
+
124
+ ## 📚 History and Debugging
125
+
126
+ Changes are automatically saved in `.janito/history/` with timestamps:
127
+ - `*_analysis.txt`: Initial analysis
128
+ - `*_selected.txt`: Selected implementation
129
+ - `*_changes.txt`: Actual changes
130
+
131
+ Enable debug mode for detailed logging:
132
+ ```bash
133
+ janito "request" --debug
134
+ ```
135
+
136
+ ## 📄 License
137
+
138
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
@@ -0,0 +1,15 @@
1
+ janito/__init__.py,sha256=CLeVFqpY9Ki3R3MgLAiTjNsJjsj1BD3_9CzP2kgCj-k,52
2
+ janito/__main__.py,sha256=bGp3nWUZC5omneyE3hn78D_J5PkBfk19qKfMV1kIThI,9892
3
+ janito/changeviewer.py,sha256=C_CRdeD6dE4AIpOM_kryTn_HyD5XC-glaZO8n8zrQPE,2487
4
+ janito/claude.py,sha256=tj0lNNVE0CW0bBkbhVDFgBl0AFoMHicWaHnYuYOk3_E,2911
5
+ janito/config.py,sha256=YsS0bNVkjl7cIboP9nSDy0NXsJaVHYAIpPkc6bbErpo,967
6
+ janito/console.py,sha256=ieKZ7IRbvJO_KW8A3CU4FCoO4YFEjJQT8BN98YotAnM,2770
7
+ janito/contentchange.py,sha256=BxFmW8JtRjzX5lnfGfzo0JPRnGRw1RQEtqZCK1wVArw,6404
8
+ janito/prompts.py,sha256=XonVVbfIg3YY1Dpbkx9m0ZSRE4bgDP3MYHO3D-5FcIE,3080
9
+ janito/qa.py,sha256=F9bd18CBaZDpbJwwvwFL18gXPBA0cq8kRY0nA3_AKPY,916
10
+ janito/scan.py,sha256=5JV0crOepVUqCZ3LAUqCJL2yerLFvjZ6RYdOwIGHvX0,5450
11
+ janito-0.3.0.dist-info/METADATA,sha256=fq7zvCHx_PV8RND5SVfOMnM082BEptZQ2G2qWPOVisQ,3681
12
+ janito-0.3.0.dist-info/WHEEL,sha256=C2FUgwZgiLbznR-k0b_5k3Ai_1aASOXDss3lzCUsUug,87
13
+ janito-0.3.0.dist-info/entry_points.txt,sha256=wIo5zZxbmu4fC-ZMrsKD0T0vq7IqkOOLYhrqRGypkx4,48
14
+ janito-0.3.0.dist-info/licenses/LICENSE,sha256=xLIUXRPjtsgQml2zD1Pn4LpgiyZ49raw6jZDlO_gZdo,1062
15
+ janito-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.26.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ janito = janito.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 João Pinto
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.