claude-dev-cli 0.13.0__py3-none-any.whl → 0.16.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.

Potentially problematic release.


This version of claude-dev-cli might be problematic. Click here for more details.

@@ -3,11 +3,50 @@
3
3
  import re
4
4
  import difflib
5
5
  from pathlib import Path
6
- from typing import List, Tuple, Optional, Literal
7
- from dataclasses import dataclass
6
+ from typing import List, Tuple, Optional, Literal, Dict, Any
7
+ from dataclasses import dataclass, field
8
8
  from rich.console import Console
9
9
  from rich.tree import Tree
10
10
  from rich.panel import Panel
11
+ from rich.syntax import Syntax
12
+ from io import StringIO
13
+
14
+ try:
15
+ from unidiff import PatchSet
16
+ UNIDIFF_AVAILABLE = True
17
+ except ImportError:
18
+ UNIDIFF_AVAILABLE = False
19
+
20
+
21
+ @dataclass
22
+ class HunkWrapper:
23
+ """Wrapper around unidiff.Hunk with approval state."""
24
+ hunk: Any # unidiff.Hunk
25
+ approved: bool = False
26
+
27
+ @property
28
+ def source_start(self) -> int:
29
+ """Get source start line number."""
30
+ return self.hunk.source_start if hasattr(self.hunk, 'source_start') else 0
31
+
32
+ @property
33
+ def source_length(self) -> int:
34
+ """Get source length."""
35
+ return self.hunk.source_length if hasattr(self.hunk, 'source_length') else 0
36
+
37
+ @property
38
+ def target_start(self) -> int:
39
+ """Get target start line number."""
40
+ return self.hunk.target_start if hasattr(self.hunk, 'target_start') else 0
41
+
42
+ @property
43
+ def target_length(self) -> int:
44
+ """Get target length."""
45
+ return self.hunk.target_length if hasattr(self.hunk, 'target_length') else 0
46
+
47
+ def __str__(self) -> str:
48
+ """Format hunk as unified diff text."""
49
+ return str(self.hunk)
11
50
 
12
51
 
13
52
  @dataclass
@@ -17,6 +56,7 @@ class FileChange:
17
56
  content: str
18
57
  change_type: Literal["create", "modify", "delete"]
19
58
  original_content: Optional[str] = None
59
+ hunks: List[HunkWrapper] = field(default_factory=list)
20
60
 
21
61
  @property
22
62
  def line_count(self) -> int:
@@ -36,11 +76,83 @@ class FileChange:
36
76
  original_lines,
37
77
  new_lines,
38
78
  fromfile=f"a/{self.path}",
39
- tofile=f"b/{self.path}",
40
- lineterm=''
79
+ tofile=f"b/{self.path}"
41
80
  ))
42
81
 
43
82
  return ''.join(diff_lines) if diff_lines else None
83
+
84
+ def parse_hunks(self) -> None:
85
+ """Parse diff into individual hunks using unidiff library."""
86
+ if self.change_type != "modify" or not self.diff:
87
+ return
88
+
89
+ if not UNIDIFF_AVAILABLE:
90
+ # Fallback: no hunk parsing available
91
+ self.hunks = []
92
+ return
93
+
94
+ self.hunks = []
95
+
96
+ try:
97
+ # Parse the diff with unidiff
98
+ patch = PatchSet(StringIO(self.diff))
99
+
100
+ # Extract all hunks from all patched files
101
+ for patched_file in patch:
102
+ for hunk in patched_file:
103
+ self.hunks.append(HunkWrapper(hunk=hunk, approved=False))
104
+
105
+ except Exception:
106
+ # If unidiff fails, fall back to empty hunks list
107
+ self.hunks = []
108
+
109
+ def apply_approved_hunks(self) -> str:
110
+ """Apply only approved hunks using unidiff's line access methods."""
111
+ if self.change_type != "modify" or not self.original_content:
112
+ return self.content
113
+
114
+ # If no hunks parsed, caller decides what to do
115
+ # In write_all, we check if hunks exist before calling this
116
+ if not self.hunks:
117
+ return self.content
118
+
119
+ # If no hunks approved, return original
120
+ if not any(h.approved for h in self.hunks):
121
+ return self.original_content
122
+
123
+ # If all hunks approved, return new content
124
+ if all(h.approved for h in self.hunks):
125
+ return self.content
126
+
127
+ # Apply only approved hunks
128
+ original_lines = self.original_content.splitlines(keepends=True)
129
+ result_lines = original_lines.copy()
130
+
131
+ # Sort hunks by position (reversed for bottom-up application)
132
+ sorted_hunks = sorted(
133
+ [h for h in self.hunks if h.approved],
134
+ key=lambda h: h.source_start,
135
+ reverse=True
136
+ )
137
+
138
+ for wrapper in sorted_hunks:
139
+ hunk = wrapper.hunk
140
+
141
+ # Calculate indices for replacement
142
+ start_idx = wrapper.source_start - 1
143
+ end_idx = start_idx + wrapper.source_length
144
+
145
+ # Extract new lines using unidiff's line iteration
146
+ new_lines = []
147
+ for line in hunk:
148
+ if line.is_added or line.is_context:
149
+ # Get line value (already includes newline)
150
+ new_lines.append(line.value)
151
+
152
+ # Replace section
153
+ result_lines[start_idx:end_idx] = new_lines
154
+
155
+ return ''.join(result_lines)
44
156
 
45
157
 
46
158
  class MultiFileResponse:
@@ -254,13 +366,25 @@ class MultiFileResponse:
254
366
  base_path.mkdir(parents=True, exist_ok=True)
255
367
 
256
368
  for file_change in self.files:
369
+ # Skip files marked for skipping
370
+ if hasattr(file_change, 'change_type') and file_change.change_type == 'skip':
371
+ continue
372
+
373
+ # Skip empty content for create (marked as rejected)
374
+ if file_change.change_type == 'create' and not file_change.content:
375
+ continue
376
+
257
377
  full_path = base_path / file_change.path
258
378
 
259
379
  if dry_run:
260
380
  if file_change.change_type == 'create':
261
381
  console.print(f"[dim]Would create: {file_change.path}[/dim]")
262
382
  elif file_change.change_type == 'modify':
263
- console.print(f"[dim]Would modify: {file_change.path}[/dim]")
383
+ if file_change.hunks and any(h.approved for h in file_change.hunks):
384
+ approved_count = sum(1 for h in file_change.hunks if h.approved)
385
+ console.print(f"[dim]Would modify: {file_change.path} ({approved_count}/{len(file_change.hunks)} hunks)[/dim]")
386
+ else:
387
+ console.print(f"[dim]Would modify: {file_change.path}[/dim]")
264
388
  elif file_change.change_type == 'delete':
265
389
  console.print(f"[dim]Would delete: {file_change.path}[/dim]")
266
390
  continue
@@ -274,29 +398,60 @@ class MultiFileResponse:
274
398
  # Create parent directories
275
399
  full_path.parent.mkdir(parents=True, exist_ok=True)
276
400
 
277
- # Write file
278
- full_path.write_text(file_change.content)
279
-
280
- if file_change.change_type == 'create':
281
- console.print(f"[green]✓[/green] Created: {file_change.path}")
282
- elif file_change.change_type == 'modify':
283
- console.print(f"[yellow]✓[/yellow] Modified: {file_change.path}")
401
+ # For modify with hunks, apply only approved hunks
402
+ if file_change.change_type == 'modify' and file_change.hunks:
403
+ # Only write if at least one hunk is approved
404
+ if not any(h.approved for h in file_change.hunks):
405
+ console.print(f"[dim]Skipped: {file_change.path} (no hunks approved)[/dim]")
406
+ continue
407
+
408
+ content_to_write = file_change.apply_approved_hunks()
409
+ full_path.write_text(content_to_write)
410
+ approved_count = sum(1 for h in file_change.hunks if h.approved)
411
+ console.print(f"[yellow]✓[/yellow] Modified: {file_change.path} ({approved_count}/{len(file_change.hunks)} hunks)")
412
+ else:
413
+ # Write file normally (no hunks or create operation)
414
+ full_path.write_text(file_change.content)
415
+
416
+ if file_change.change_type == 'create':
417
+ console.print(f"[green]✓[/green] Created: {file_change.path}")
418
+ elif file_change.change_type == 'modify':
419
+ console.print(f"[yellow]✓[/yellow] Modified: {file_change.path}")
284
420
 
285
- def confirm(self, console: Console) -> bool:
421
+ def confirm(self, console: Console, base_path: Optional[Path] = None) -> bool:
286
422
  """Interactive confirmation prompt.
287
423
 
424
+ Args:
425
+ console: Rich console for output
426
+ base_path: Base directory for file operations (needed for edit/save)
427
+
288
428
  Returns True if user confirms, False otherwise.
289
429
  """
290
430
  if not self.files:
291
431
  return False
292
432
 
293
433
  while True:
294
- response = console.input("\n[cyan]Continue?[/cyan] [dim](Y/n/preview/help)[/dim] ").strip().lower()
434
+ response = console.input("\n[cyan]Continue?[/cyan] [dim](Y/n/preview/patch/edit/save/help)[/dim] ").strip().lower()
295
435
 
296
436
  if response in ('y', 'yes', ''):
297
437
  return True
298
438
  elif response in ('n', 'no'):
299
439
  return False
440
+ elif response == 'patch':
441
+ # Use hunk-by-hunk confirmation
442
+ return self.confirm_with_hunks(console)
443
+ elif response == 'edit':
444
+ # Open files in editor for manual editing
445
+ if base_path:
446
+ self._edit_files(console, base_path)
447
+ else:
448
+ console.print("[red]Edit not available (no base path provided)[/red]")
449
+ elif response == 'save':
450
+ # Save to custom location
451
+ if base_path:
452
+ return self._save_to_location(console, base_path)
453
+ else:
454
+ console.print("[red]Save not available (no base path provided)[/red]")
300
455
  elif response == 'preview':
301
456
  # Show individual file contents
302
457
  for i, file_change in enumerate(self.files, 1):
@@ -315,13 +470,242 @@ class MultiFileResponse:
315
470
  elif response == 'help':
316
471
  console.print("""
317
472
  [bold]Options:[/bold]
318
- y, yes - Proceed with changes
319
- n, no - Cancel
473
+ y, yes - Proceed with all changes
474
+ n, no - Cancel all changes
475
+ patch - Review changes hunk-by-hunk (like git add -p)
476
+ edit - Open files in $EDITOR before applying
477
+ save - Save to custom location/filename
320
478
  preview - Show file contents/diffs
321
479
  help - Show this help
322
480
  """)
323
481
  else:
324
482
  console.print("[red]Invalid response. Type 'help' for options.[/red]")
483
+
484
+ def confirm_with_hunks(self, console: Console) -> bool:
485
+ """Interactive hunk-by-hunk confirmation (like git add -p).
486
+
487
+ Returns True if at least some changes approved, False if all cancelled.
488
+ """
489
+ if not self.files:
490
+ return False
491
+
492
+ # Parse hunks for all modify operations
493
+ for file_change in self.files:
494
+ if file_change.change_type == 'modify':
495
+ file_change.parse_hunks()
496
+
497
+ has_any_approval = False
498
+
499
+ for file_change in self.files:
500
+ console.print(f"\n[bold cyan]File:[/bold cyan] {file_change.path}")
501
+
502
+ if file_change.change_type == 'create':
503
+ console.print(f"[green]Create new file ({file_change.line_count} lines)[/green]")
504
+ response = self._ask_file_action(console, "create")
505
+ if response == 'y':
506
+ # Mark as approved (keep as-is)
507
+ has_any_approval = True
508
+ elif response == 'n':
509
+ # Remove from files list
510
+ file_change.content = '' # Mark for skip
511
+ elif response == 'q':
512
+ return has_any_approval
513
+
514
+ elif file_change.change_type == 'delete':
515
+ console.print("[red]Delete file[/red]")
516
+ response = self._ask_file_action(console, "delete")
517
+ if response == 'y':
518
+ has_any_approval = True
519
+ elif response == 'n':
520
+ file_change.change_type = 'skip' # Mark for skip
521
+ elif response == 'q':
522
+ return has_any_approval
523
+
524
+ elif file_change.change_type == 'modify':
525
+ if not file_change.hunks:
526
+ console.print("[yellow]No hunks to review[/yellow]")
527
+ continue
528
+
529
+ console.print(f"[yellow]Modify file ({len(file_change.hunks)} hunk(s))[/yellow]")
530
+
531
+ for hunk_idx, hunk in enumerate(file_change.hunks, 1):
532
+ console.print(f"\n[bold]Hunk {hunk_idx}/{len(file_change.hunks)}:[/bold]")
533
+
534
+ # Show hunk with syntax highlighting
535
+ hunk_text = str(hunk)
536
+ console.print(Panel(
537
+ Syntax(hunk_text, "diff", theme="monokai", line_numbers=False),
538
+ border_style="yellow",
539
+ title=f"[bold]{file_change.path}[/bold]"
540
+ ))
541
+
542
+ while True:
543
+ response = console.input(
544
+ "[cyan]Apply this hunk?[/cyan] [dim](y/n/s=skip file/q=quit/help)[/dim] "
545
+ ).strip().lower()
546
+
547
+ if response in ('y', 'yes', ''):
548
+ hunk.approved = True
549
+ has_any_approval = True
550
+ break
551
+ elif response in ('n', 'no'):
552
+ hunk.approved = False
553
+ break
554
+ elif response in ('s', 'skip'):
555
+ # Skip remaining hunks in this file
556
+ break
557
+ elif response in ('q', 'quit'):
558
+ return has_any_approval
559
+ elif response == 'help':
560
+ console.print("""
561
+ [bold]Hunk Options:[/bold]
562
+ y, yes - Apply this hunk
563
+ n, no - Skip this hunk
564
+ s, skip - Skip remaining hunks in this file
565
+ q, quit - Quit and apply approved hunks so far
566
+ help - Show this help
567
+ """)
568
+ else:
569
+ console.print("[red]Invalid response. Type 'help' for options.[/red]")
570
+
571
+ if response in ('s', 'skip'):
572
+ break
573
+
574
+ return has_any_approval
575
+
576
+ def _ask_file_action(self, console: Console, action: str) -> str:
577
+ """Ask for confirmation on file-level action.
578
+
579
+ Returns: 'y' (yes), 'n' (no), 's' (skip), 'q' (quit)
580
+ """
581
+ while True:
582
+ response = console.input(
583
+ f"[cyan]{action.capitalize()} this file?[/cyan] [dim](y/n/s=skip/q=quit)[/dim] "
584
+ ).strip().lower()
585
+
586
+ if response in ('y', 'yes', 'n', 'no', 's', 'skip', 'q', 'quit', ''):
587
+ if response == '':
588
+ return 'y'
589
+ if response in ('skip',):
590
+ return 's'
591
+ if response in ('quit',):
592
+ return 'q'
593
+ return response[0] # Return first character
594
+ else:
595
+ console.print("[red]Invalid response. Use y/n/s/q[/red]")
596
+
597
+ def _edit_files(self, console: Console, base_path: Path) -> None:
598
+ """Open files in editor for manual editing before applying.
599
+
600
+ Args:
601
+ console: Rich console for output
602
+ base_path: Base directory for file operations
603
+ """
604
+ import os
605
+ import subprocess
606
+ import tempfile
607
+
608
+ editor = os.environ.get('EDITOR', 'vi')
609
+
610
+ console.print(f"\n[cyan]Opening files in {editor}...[/cyan]")
611
+ console.print("[dim]Save and close editor to continue[/dim]\n")
612
+
613
+ for file_change in self.files:
614
+ if file_change.change_type == 'delete':
615
+ console.print(f"[dim]Skipping delete operation: {file_change.path}[/dim]")
616
+ continue
617
+
618
+ # Write content to temp file
619
+ with tempfile.NamedTemporaryFile(mode='w', suffix=f'_{Path(file_change.path).name}', delete=False) as tf:
620
+ tf.write(file_change.content)
621
+ temp_path = tf.name
622
+
623
+ try:
624
+ console.print(f"[bold]Editing:[/bold] {file_change.path}")
625
+
626
+ # Open in editor
627
+ result = subprocess.run([editor, temp_path])
628
+
629
+ if result.returncode == 0:
630
+ # Read edited content
631
+ with open(temp_path, 'r') as f:
632
+ edited_content = f.read()
633
+
634
+ # Update file change with edited content
635
+ file_change.content = edited_content
636
+ console.print(f"[green]✓[/green] Updated: {file_change.path}")
637
+ else:
638
+ console.print(f"[yellow]Editor exited with error, keeping original content[/yellow]")
639
+ finally:
640
+ # Clean up temp file
641
+ try:
642
+ os.unlink(temp_path)
643
+ except:
644
+ pass
645
+
646
+ console.print("\n[green]Editing complete![/green]")
647
+
648
+ def _save_to_location(self, console: Console, base_path: Path) -> bool:
649
+ """Save files to custom location.
650
+
651
+ Args:
652
+ console: Rich console for output
653
+ base_path: Base directory for file operations
654
+
655
+ Returns: True if saved successfully, False otherwise
656
+ """
657
+ console.print("\n[cyan]Save options:[/cyan]")
658
+
659
+ if len(self.files) == 1:
660
+ # Single file - ask for filename
661
+ console.print("[dim]Enter filename (or path) to save to:[/dim]")
662
+ filename = console.input("[cyan]Filename:[/cyan] ").strip()
663
+
664
+ if not filename:
665
+ console.print("[yellow]Cancelled[/yellow]")
666
+ return False
667
+
668
+ save_path = Path(filename)
669
+ if not save_path.is_absolute():
670
+ save_path = base_path / save_path
671
+
672
+ # Create parent directories
673
+ save_path.parent.mkdir(parents=True, exist_ok=True)
674
+
675
+ # Write file
676
+ file_change = self.files[0]
677
+ save_path.write_text(file_change.content)
678
+ console.print(f"[green]✓[/green] Saved to: {save_path}")
679
+ return False # Don't continue with original write
680
+ else:
681
+ # Multiple files - ask for directory
682
+ console.print(f"[dim]Enter directory to save {len(self.files)} file(s) to:[/dim]")
683
+ dirname = console.input("[cyan]Directory:[/cyan] ").strip()
684
+
685
+ if not dirname:
686
+ console.print("[yellow]Cancelled[/yellow]")
687
+ return False
688
+
689
+ save_dir = Path(dirname)
690
+ if not save_dir.is_absolute():
691
+ save_dir = base_path / save_dir
692
+
693
+ # Create directory
694
+ save_dir.mkdir(parents=True, exist_ok=True)
695
+
696
+ # Write all files
697
+ for file_change in self.files:
698
+ if file_change.change_type == 'delete':
699
+ console.print(f"[dim]Skipping delete operation: {file_change.path}[/dim]")
700
+ continue
701
+
702
+ file_path = save_dir / file_change.path
703
+ file_path.parent.mkdir(parents=True, exist_ok=True)
704
+ file_path.write_text(file_change.content)
705
+ console.print(f"[green]✓[/green] Saved: {file_path}")
706
+
707
+ console.print(f"\n[green]All files saved to: {save_dir}[/green]")
708
+ return False # Don't continue with original write
325
709
 
326
710
 
327
711
  def extract_code_blocks(text: str) -> List[Tuple[str, str, str]]:
@@ -0,0 +1,28 @@
1
+ """AI Provider abstraction layer for claude-dev-cli.
2
+
3
+ This package provides a unified interface for multiple AI providers:
4
+ - Anthropic (Claude)
5
+ - OpenAI (GPT-4, GPT-3.5) - coming in v0.15.0
6
+ - Ollama (Local models) - coming in v0.16.0
7
+ - LM Studio (Local models) - coming in v0.16.0
8
+ """
9
+
10
+ from claude_dev_cli.providers.base import (
11
+ AIProvider,
12
+ ModelInfo,
13
+ UsageInfo,
14
+ ProviderError,
15
+ InsufficientCreditsError,
16
+ ProviderConnectionError,
17
+ ModelNotFoundError,
18
+ )
19
+
20
+ __all__ = [
21
+ "AIProvider",
22
+ "ModelInfo",
23
+ "UsageInfo",
24
+ "ProviderError",
25
+ "InsufficientCreditsError",
26
+ "ProviderConnectionError",
27
+ "ModelNotFoundError",
28
+ ]