janito 0.1.0__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.
janito-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) [year] [fullname]
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 this 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.
janito-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.1
2
+ Name: janito
3
+ Version: 0.1.0
4
+ Summary: Language-Driven Software Development Assistant powered by Claude AI
5
+ Home-page: https://github.com/joaompinto/janito
6
+ Author: João M. Pinto
7
+ Author-email: "João M. Pinto" <lamego.pinto@gmail.com>
8
+ License: MIT
9
+ Project-URL: Homepage, https://github.com/joaompinto/janito
10
+ Project-URL: Documentation, https://github.com/joaompinto/janito#readme
11
+ Project-URL: Repository, https://github.com/joaompinto/janito.git
12
+ Keywords: ai,development,claude,assistant,code
13
+ Requires-Python: >=3.8
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: anthropic
17
+ Requires-Dist: prompt_toolkit
18
+ Requires-Dist: rich
19
+ Requires-Dist: typer
20
+ Requires-Dist: watchdog
21
+ Requires-Dist: pytest
22
+
23
+
24
+ Janito is an open source Language-Driven Software Development Assistant powered by Claude AI. It helps developers understand, modify, and improve their Python code through natural language interaction.
25
+
26
+ ## Features
27
+
28
+ - Natural language code interactions
29
+ - File system monitoring and auto-restart
30
+ - Interactive command-line interface
31
+ - Workspace management and visualization
32
+ - History and session management
33
+ - Debug mode for troubleshooting
34
+ - Syntax error detection and fixing
35
+ - Python file execution
36
+ - File editing with system editor
37
+
38
+ ## Installation
39
+
40
+ ```bash
41
+ # Install package
42
+ pip install janito
43
+ ```
44
+
45
+ ## Usage
46
+
47
+ Start Janito in your project directory:
48
+
49
+ ```bash
50
+ python -m janito
51
+ ```
52
+
53
+ Or launch with options:
54
+
55
+ ```bash
56
+ python -m janito --debug # Enable debug mode
57
+ python -m janito --no-watch # Disable file watching
58
+ ```
59
+
60
+ ### Commands
61
+
62
+ - `.help` - Show help information
63
+ - `.exit` - Exit the console
64
+ - `.clear` - Clear console output
65
+ - `.debug` - Toggle debug mode
66
+ - `.workspace` - Show workspace structure
67
+ - `.last` - Show last Claude response
68
+ - `.show <file>` - Show file content with syntax highlighting
69
+ - `.check` - Check workspace Python files for syntax errors
70
+ - `.p <file>` - Run a Python file
71
+ - `.python <file>` - Run a Python file (alias for .p)
72
+ - `.edit <file>` - Open file in system editor
73
+
74
+ ### Input Formats
75
+
76
+ - `!request` - Request file changes (e.g. '!add logging to utils.py')
77
+ - `request?` - Get information and analysis without changes
78
+ - `request` - General discussion and queries
79
+ - `$command` - Execute shell commands
80
+
81
+ ## Configuration
82
+
83
+ Requires an Anthropic API key set via environment variable:
84
+
85
+ ```bash
86
+ export ANTHROPIC_API_KEY='your_api_key_here'
87
+ ```
88
+
89
+ ## Development
90
+
91
+ The package consists of several modules:
92
+
93
+ - `janito.py` - Core functionality and CLI interface
94
+ - `change.py` - File modification and change tracking
95
+ - `claude.py` - Claude API interaction
96
+ - `console.py` - Interactive console and command handling
97
+ - `commands.py` - Command implementations
98
+ - `prompts.py` - Prompt templates and builders
99
+ - `watcher.py` - File system monitoring
100
+ - `workspace.py` - Workspace analysis and management
101
+ - `xmlchangeparser.py` - XML parser for file changes
102
+ - `watcher.py` - File system monitoring
103
+
104
+ ## License
105
+
106
+ MIT License
janito-0.1.0/README.md ADDED
@@ -0,0 +1,84 @@
1
+
2
+ Janito is an open source Language-Driven Software Development Assistant powered by Claude AI. It helps developers understand, modify, and improve their Python code through natural language interaction.
3
+
4
+ ## Features
5
+
6
+ - Natural language code interactions
7
+ - File system monitoring and auto-restart
8
+ - Interactive command-line interface
9
+ - Workspace management and visualization
10
+ - History and session management
11
+ - Debug mode for troubleshooting
12
+ - Syntax error detection and fixing
13
+ - Python file execution
14
+ - File editing with system editor
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ # Install package
20
+ pip install janito
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ Start Janito in your project directory:
26
+
27
+ ```bash
28
+ python -m janito
29
+ ```
30
+
31
+ Or launch with options:
32
+
33
+ ```bash
34
+ python -m janito --debug # Enable debug mode
35
+ python -m janito --no-watch # Disable file watching
36
+ ```
37
+
38
+ ### Commands
39
+
40
+ - `.help` - Show help information
41
+ - `.exit` - Exit the console
42
+ - `.clear` - Clear console output
43
+ - `.debug` - Toggle debug mode
44
+ - `.workspace` - Show workspace structure
45
+ - `.last` - Show last Claude response
46
+ - `.show <file>` - Show file content with syntax highlighting
47
+ - `.check` - Check workspace Python files for syntax errors
48
+ - `.p <file>` - Run a Python file
49
+ - `.python <file>` - Run a Python file (alias for .p)
50
+ - `.edit <file>` - Open file in system editor
51
+
52
+ ### Input Formats
53
+
54
+ - `!request` - Request file changes (e.g. '!add logging to utils.py')
55
+ - `request?` - Get information and analysis without changes
56
+ - `request` - General discussion and queries
57
+ - `$command` - Execute shell commands
58
+
59
+ ## Configuration
60
+
61
+ Requires an Anthropic API key set via environment variable:
62
+
63
+ ```bash
64
+ export ANTHROPIC_API_KEY='your_api_key_here'
65
+ ```
66
+
67
+ ## Development
68
+
69
+ The package consists of several modules:
70
+
71
+ - `janito.py` - Core functionality and CLI interface
72
+ - `change.py` - File modification and change tracking
73
+ - `claude.py` - Claude API interaction
74
+ - `console.py` - Interactive console and command handling
75
+ - `commands.py` - Command implementations
76
+ - `prompts.py` - Prompt templates and builders
77
+ - `watcher.py` - File system monitoring
78
+ - `workspace.py` - Workspace analysis and management
79
+ - `xmlchangeparser.py` - XML parser for file changes
80
+ - `watcher.py` - File system monitoring
81
+
82
+ ## License
83
+
84
+ MIT License
@@ -0,0 +1,6 @@
1
+
2
+ """
3
+ Janito - Language-Driven Software Development Assistant
4
+ """
5
+
6
+ __version__ = "0.1.0"
@@ -0,0 +1,9 @@
1
+ """
2
+ Entry point for Janito package.
3
+ Allows running the application using 'python -m janito'.
4
+ """
5
+
6
+ from janito.janito import run_cli
7
+
8
+ if __name__ == '__main__':
9
+ run_cli()
@@ -0,0 +1,382 @@
1
+ from typing import Optional, List, Set
2
+ import re
3
+ from pathlib import Path
4
+ import ast
5
+ import shutil
6
+ from rich.syntax import Syntax
7
+ from rich.console import Console
8
+ from rich.markdown import Markdown
9
+ import tempfile
10
+ import os # Add this import
11
+ from janito.workspace import Workspace
12
+ from janito.xmlchangeparser import XMLChangeParser, XMLChange
13
+
14
+ class FileChangeHandler:
15
+ def __init__(self, interactive=True):
16
+ self.preview_dir = Path(tempfile.mkdtemp(prefix='janito_preview_'))
17
+ self.console = Console()
18
+ self.workspace = Workspace()
19
+ self.xml_parser = XMLChangeParser()
20
+ self.interactive = interactive
21
+
22
+ # Remove generate_changes_prompt method as it's not being used
23
+
24
+ # Remove _parse_xml_response method as it's replaced by xml_parser
25
+
26
+ def test_parse_empty_block(self) -> bool:
27
+ """Test parsing of XML with empty content blocks"""
28
+ test_xml = '''<fileChanges>
29
+ <change path="hello.py" operation="create">
30
+ <block description="Create new file hello.py">
31
+ <oldContent></oldContent>
32
+ <newContent></newContent>
33
+ </block>
34
+ </change>
35
+ </fileChanges>'''
36
+
37
+ changes = self.xml_parser.parse_response(test_xml)
38
+ if not changes:
39
+ self.console.print("[red]Error: No changes parsed[/]")
40
+ return False
41
+
42
+ change = changes[0]
43
+ if (change.path.name != "hello.py" or
44
+ change.operation != "create" or
45
+ not change.blocks or
46
+ change.blocks[0].description != "Create new file hello.py"):
47
+ self.console.print("[red]Error: Parsed change does not match expected structure[/]")
48
+ return False
49
+
50
+ block = change.blocks[0]
51
+ if block.old_content != [] or block.new_content != []:
52
+ self.console.print("[red]Error: Content lists should be empty[/]")
53
+ return False
54
+
55
+ self.console.print("[green]Empty block parsing test passed[/]")
56
+ return True
57
+
58
+ def _validate_syntax(self, filepath: Path) -> tuple[Optional[SyntaxError], bool]:
59
+ """Validate file syntax
60
+ Returns (error, supported):
61
+ - (None, True) -> valid syntax
62
+ - (SyntaxError, True) -> invalid syntax
63
+ - (None, False) -> unsupported file type
64
+ """
65
+ # Add more file types as needed
66
+ SUPPORTED_TYPES = {
67
+ '.py': self._validate_python_syntax,
68
+ }
69
+
70
+ validator = SUPPORTED_TYPES.get(filepath.suffix)
71
+ if not validator:
72
+ return None, False
73
+
74
+ try:
75
+ error = validator(filepath)
76
+ return error, True
77
+ except Exception as e:
78
+ return SyntaxError(str(e)), True
79
+
80
+ def _validate_python_syntax(self, filepath: Path) -> Optional[SyntaxError]:
81
+ """Validate Python syntax"""
82
+ try:
83
+ with open(filepath) as f:
84
+ ast.parse(f.read())
85
+ return None
86
+ except SyntaxError as e:
87
+ return e
88
+
89
+ def _apply_indentation(self, new_content: List[str], base_indent: int, first_line_indent: Optional[int] = None) -> List[str]:
90
+ """Apply consistent indentation to new content
91
+ Args:
92
+ new_content: List of lines to indent
93
+ base_indent: Base indentation level to apply
94
+ first_line_indent: Optional indentation of first line in original block for relative indenting
95
+ Returns:
96
+ List of indented lines
97
+ """
98
+ if not new_content:
99
+ return []
100
+
101
+ indented_content = []
102
+ for i, line in enumerate(new_content):
103
+ if not line.strip():
104
+ indented_content.append('')
105
+ continue
106
+
107
+ # For first non-empty line, use base indentation
108
+ if not indented_content or all(not l.strip() for l in indented_content):
109
+ curr_indent = base_indent
110
+ else:
111
+ # Calculate relative indentation from first line
112
+ if first_line_indent is None:
113
+ first_line_indent = len(new_content[0]) - len(new_content[0].lstrip())
114
+ # Maintain relative indentation from first line
115
+ curr_indent = base_indent + (len(line) - len(line.lstrip()) - first_line_indent)
116
+ indented_content.append(' ' * max(0, curr_indent) + line.lstrip())
117
+
118
+ return indented_content
119
+
120
+ def _create_preview_files(self, changes: List[XMLChange]) -> dict[Path, Path]:
121
+ """Create preview files for all changes"""
122
+ preview_files = {}
123
+
124
+ for change in changes:
125
+ preview_path = self.preview_dir / change.path.name
126
+
127
+ if change.operation == 'create':
128
+ # For new files, use direct content or block content
129
+ content = change.content
130
+ if not content.strip() and change.blocks:
131
+ content = "\n".join(change.blocks[0].new_content)
132
+ preview_path.write_text(content)
133
+
134
+ elif change.operation == 'modify' and change.path.exists():
135
+ original_text = change.path.read_text()
136
+ original_content = original_text.splitlines()
137
+ modified_content = original_content.copy()
138
+
139
+ for block in change.blocks:
140
+ if not block.old_content or (len(block.old_content) == 1 and not block.old_content[0].strip()):
141
+ if block.new_content:
142
+ if modified_content and not original_text.endswith('\n'):
143
+ modified_content[-1] = modified_content[-1] + '\n'
144
+ # Get the last line's indentation for appends
145
+ base_indent = len(modified_content[-1]) - len(modified_content[-1].lstrip()) if modified_content else 0
146
+ indented_content = self._apply_indentation(block.new_content, base_indent)
147
+ modified_content.extend(indented_content)
148
+ else:
149
+ result = self._find_block_start(modified_content, block.old_content, preview_path)
150
+ if result is None:
151
+ continue
152
+ start_idx, base_indent = result
153
+
154
+ # For single-line deletions/replacements
155
+ if len(block.old_content) == 1:
156
+ if block.new_content:
157
+ # Replace single line
158
+ indented_content = self._apply_indentation(block.new_content, base_indent)
159
+ modified_content[start_idx:start_idx + 1] = indented_content
160
+ else:
161
+ # Delete single line
162
+ del modified_content[start_idx]
163
+ else:
164
+ # Multi-line block handling
165
+ end_idx = start_idx + len([l for l in block.old_content if l.strip()])
166
+ if block.new_content:
167
+ indented_content = self._apply_indentation(block.new_content, base_indent)
168
+ modified_content[start_idx:end_idx] = indented_content
169
+ else:
170
+ del modified_content[start_idx:end_idx]
171
+
172
+ preview_path.write_text('\n'.join(modified_content))
173
+
174
+ preview_files[change.path] = preview_path
175
+
176
+ return preview_files
177
+
178
+ def _preview_changes(self, changes: List[XMLChange], raw_response: str = None) -> tuple[bool, bool]:
179
+ """Show preview of all changes and ask for confirmation
180
+ Returns: (success, has_syntax_errors)"""
181
+ # Create preview files
182
+ preview_files = self._create_preview_files(changes)
183
+
184
+ # Validate syntax for all preview files
185
+ validation_status = {}
186
+ has_syntax_errors = False
187
+ for orig_path, preview_path in preview_files.items():
188
+ error, supported = self._validate_syntax(preview_path)
189
+ if not supported:
190
+ validation_status[orig_path] = "[yellow]? Syntax check not supported[/]"
191
+ elif error:
192
+ validation_status[orig_path] = f"[red]✗ {str(error)}[/]"
193
+ has_syntax_errors = True
194
+ else:
195
+ validation_status[orig_path] = "[green]✓ Valid syntax[/]"
196
+
197
+ self.console.print("\n[cyan]Preview of changes to be applied:[/]")
198
+ self.console.print("=" * 80)
199
+
200
+ for change in changes:
201
+ if change.operation == 'create':
202
+ preview_content = preview_files[change.path].read_text()
203
+ status = validation_status.get(change.path, '')
204
+ self.console.print(f"\n[green]CREATE NEW FILE: {change.path}[/] {status}")
205
+ syntax = Syntax(preview_content, "python", theme="monokai")
206
+ self.console.print(syntax)
207
+ continue
208
+
209
+ if not change.path.exists():
210
+ self.console.print(f"\n[red]SKIP: File not found - {change.path}[/]")
211
+ continue
212
+
213
+ status = validation_status.get(change.path, '')
214
+ self.console.print(f"\n[yellow]MODIFY FILE: {change.path}[/] {status}")
215
+ for block in change.blocks:
216
+ self.console.print(f"\n[cyan]{block.description}[/]")
217
+
218
+ if not block.old_content or (len(block.old_content) == 1 and not block.old_content[0].strip()):
219
+ if block.new_content:
220
+ # Get the last line's indentation
221
+ file_lines = change.path.read_text().splitlines() if change.path.exists() else []
222
+ base_indent = len(file_lines[-1]) - len(file_lines[-1].lstrip()) if file_lines else 0
223
+ indented_content = self._apply_indentation(block.new_content, base_indent)
224
+ self.console.print("[green]Append to end of file:[/]")
225
+ syntax = Syntax("\n".join(indented_content), "python", theme="monokai")
226
+ self.console.print(syntax)
227
+ else:
228
+ self.console.print("[black on red]Remove:[/]")
229
+ syntax = Syntax("\n".join(block.old_content), "python", theme="monokai")
230
+ self.console.print(syntax)
231
+ if block.new_content: # Only show replacement if there is new content
232
+ self.console.print("\n[black on green]Replace with:[/]")
233
+ syntax = Syntax("\n".join(block.new_content), "python", theme="monokai")
234
+ self.console.print(syntax)
235
+ else:
236
+ self.console.print("[black on yellow](Content will be deleted)[/]")
237
+
238
+ self.console.print("\n" + "=" * 80)
239
+
240
+ if has_syntax_errors:
241
+ self.console.print("\n[red]⚠️ Error: Cannot apply changes - syntax errors detected![/]")
242
+ return False, has_syntax_errors
243
+
244
+ # Only ask for confirmation if interactive and no syntax errors
245
+ if self.interactive:
246
+ try:
247
+ response = input("\nApply these changes? [y/N] ").lower().strip()
248
+ return response == 'y', has_syntax_errors
249
+ except EOFError:
250
+ self.console.print("\n[yellow]Changes cancelled (Ctrl-D)[/]")
251
+ return False, has_syntax_errors
252
+ return True, has_syntax_errors
253
+
254
+ def process_changes(self, response: str) -> bool:
255
+ try:
256
+ if not (match := re.search(r'<fileChanges>(.*?)</fileChanges>', response, re.DOTALL)):
257
+ self.console.print("[red]No file changes found in response[/]")
258
+ self.console.print("\nResponse content:")
259
+ self.console.print(response)
260
+ return False
261
+
262
+ xml_content = f"<fileChanges>{match.group(1)}</fileChanges>"
263
+ self.console.print("[cyan]Found change block, parsing...[/]")
264
+
265
+ changes = self.xml_parser.parse_response(xml_content)
266
+ if not changes:
267
+ self.console.print("[red]No valid changes found after parsing[/]")
268
+ return False
269
+
270
+ try:
271
+ # First phase: Create and validate all preview files
272
+ preview_result, has_syntax_errors = self._preview_changes(changes, raw_response=response)
273
+ if not preview_result:
274
+ if self.interactive and not has_syntax_errors:
275
+ self.console.print("[yellow]Changes cancelled by user[/]")
276
+ return False
277
+
278
+ # Second phase: Pre-validate all files can be written to
279
+ preview_files = self._create_preview_files(changes)
280
+ for change in changes:
281
+ preview_path = preview_files.get(change.path)
282
+ if not preview_path or not preview_path.exists():
283
+ self.console.print(f"[red]Preview file missing for {change.path}[/]")
284
+ return False
285
+
286
+ try:
287
+ # Validate write permissions and parent directory creation
288
+ change.path.parent.mkdir(parents=True, exist_ok=True)
289
+ # Test write permission without actually writing
290
+ if change.path.exists():
291
+ os.access(change.path, os.W_OK)
292
+ else:
293
+ change.path.parent.joinpath('test').touch()
294
+ change.path.parent.joinpath('test').unlink()
295
+ except (OSError, IOError) as e:
296
+ self.console.print(f"[red]Cannot write to {change.path}: {e}[/]")
297
+ return False
298
+
299
+ # Final phase: Apply all changes in a batch
300
+ self.console.print("\n[cyan]Applying changes...[/]")
301
+ try:
302
+ # Copy all files in a single transaction-like batch
303
+ for change in changes:
304
+ preview_path = preview_files[change.path]
305
+ shutil.copy2(preview_path, change.path)
306
+ self.console.print(f"[green]{'Created' if change.operation == 'create' else 'Updated'} file: {change.path}[/]")
307
+
308
+ self.console.print("\n[green]✓ All changes applied successfully[/]")
309
+ return True
310
+
311
+ except (OSError, IOError) as e:
312
+ self.console.print(f"[red]Error applying changes: {e}[/]")
313
+ return False
314
+
315
+ except KeyboardInterrupt:
316
+ self.console.print("[yellow]Changes cancelled by user (Ctrl-C)[/]")
317
+ return False
318
+
319
+ except EOFError:
320
+ self.console.print("\n[yellow]Changes cancelled (Ctrl-D)[/]")
321
+ return False
322
+ except KeyboardInterrupt:
323
+ self.console.print("\n[yellow]Changes cancelled (Ctrl-C)[/]")
324
+ return False
325
+ except Exception as e:
326
+ self.console.print(f"\n[red]Error applying changes: {e}[/]")
327
+ return False
328
+
329
+ def _find_block_start(self, content: List[str], old_content: List[str], filepath: Path = None) -> Optional[tuple[int, int]]:
330
+ """Find the start of the indentation block containing old_content"""
331
+ try:
332
+ if not old_content:
333
+ return None
334
+
335
+ # Convert string content to lines if needed
336
+ lines = content if isinstance(content, list) else content.split('\n')
337
+
338
+ # For single-line content, do exact string matching
339
+ if len(old_content) == 1:
340
+ for i, line in enumerate(lines):
341
+ if line.strip() == old_content[0].strip():
342
+ return (i, len(line) - len(line.lstrip()))
343
+ self.console.print(f"[yellow]Warning: Line not found in {filepath.name if filepath else 'unknown file'}: {old_content[0]}[/]")
344
+ return None
345
+
346
+ # For multi-line blocks, use existing block matching logic
347
+ first_line = next((line for line in old_content if line.strip()), '')
348
+ target_indent = len(first_line) - len(first_line.lstrip())
349
+
350
+ # Search for the block
351
+ for i in range(len(lines) - len(old_content) + 1):
352
+ # Check if block matches at this position
353
+ matches = True
354
+ for j, old_line in enumerate(old_content):
355
+ if not old_line.strip(): # Skip empty lines
356
+ continue
357
+ if i + j >= len(lines):
358
+ matches = False
359
+ break
360
+ if lines[i + j].lstrip() != old_line.lstrip():
361
+ matches = False
362
+ break
363
+ if matches:
364
+ return (i, target_indent)
365
+
366
+ self.console.print(f"[yellow]Warning: Block not found in {filepath.name if filepath else 'unknown file'}[/]")
367
+ return None
368
+
369
+ except Exception as e:
370
+ self.console.print(f"[yellow]Error finding block in {filepath.name if filepath else 'unknown file'}: {e}[/]")
371
+ return None
372
+
373
+ def cleanup(self):
374
+ """Clean up preview directory"""
375
+ try:
376
+ shutil.rmtree(self.preview_dir)
377
+ except (OSError, IOError) as e:
378
+ self.console.print(f"[yellow]Warning: Failed to clean up preview directory: {e}[/]")
379
+
380
+ def __del__(self):
381
+ """Ensure cleanup on destruction"""
382
+ self.cleanup()