code-towel 1.0.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.
towel/cli.py ADDED
@@ -0,0 +1,813 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Command-line interface for Towel.
4
+
5
+ This module provides CLI entry points for the Towel code refactoring tool.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+ import argparse
12
+ from pathlib import Path
13
+ from typing import List, Tuple, Optional
14
+
15
+
16
+ def main() -> None:
17
+ """Main entry point with subcommands."""
18
+ parser = argparse.ArgumentParser(
19
+ prog="code-towel",
20
+ description="A Python tool that DRYs your code - finds and refactors repeated code",
21
+ formatter_class=argparse.RawDescriptionHelpFormatter,
22
+ epilog="""
23
+ Examples:
24
+ code-towel preview src/ # Preview duplicates (read-only)
25
+ code-towel dry src/ cleaned/ # Apply refactorings to new directory
26
+ code-towel dry src/ src/ # Apply refactorings in-place
27
+ code-towel rename-helpers # Rename extracted functions with LLM assistance
28
+
29
+ For more help on a specific command:
30
+ code-towel <command> --help
31
+ """,
32
+ )
33
+
34
+ parser.add_argument(
35
+ "--version",
36
+ action="version",
37
+ version="towel 1.0.0",
38
+ )
39
+
40
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
41
+
42
+ # Add subcommand parsers
43
+ _add_dry_parser(subparsers)
44
+ _add_preview_parser(subparsers)
45
+ _add_rename_helpers_parser(subparsers)
46
+
47
+ args = parser.parse_args()
48
+
49
+ if not args.command:
50
+ parser.print_help()
51
+ sys.exit(0)
52
+
53
+ # Dispatch to appropriate handler
54
+ if args.command == "dry":
55
+ _run_dry(args)
56
+ elif args.command == "preview":
57
+ _run_preview(args)
58
+ elif args.command == "rename-helpers":
59
+ _run_rename_helpers(args)
60
+
61
+
62
+ def _add_dry_parser(subparsers) -> None:
63
+ """Add 'dry' subcommand parser."""
64
+ parser = subparsers.add_parser(
65
+ "dry",
66
+ help="Apply refactorings to remove duplicate code",
67
+ formatter_class=argparse.RawTextHelpFormatter,
68
+ description="Analyze and apply unification-based refactorings to file(s) or directories.",
69
+ epilog="""
70
+ Examples:
71
+ code-towel dry src/ cleaned/ # Refactor directory until fixed point
72
+ code-towel dry file.py file_out.py --non-interactive # Non-interactive single file
73
+ code-towel dry src/ out/ --max-iterations 50 # Cap at 50 applied refactorings
74
+ code-towel dry src/ out/ --progress detail # Verbose per-phase output
75
+ """,
76
+ )
77
+
78
+ parser.add_argument("input", help="Input file or directory")
79
+ parser.add_argument("output", help="Output file or directory (can be same as input)")
80
+ parser.add_argument(
81
+ "--non-interactive",
82
+ action="store_true",
83
+ help="Run without interactive confirmation (skip prompt)",
84
+ )
85
+
86
+ pref_group = parser.add_mutually_exclusive_group()
87
+ pref_group.add_argument(
88
+ "--prefer-absolute-imports",
89
+ dest="prefer_absolute_imports",
90
+ action="store_true",
91
+ help="Prefer absolute imports for cross-file extractions when possible",
92
+ )
93
+ pref_group.add_argument(
94
+ "--no-prefer-absolute-imports",
95
+ dest="prefer_absolute_imports",
96
+ action="store_false",
97
+ help="Prefer local/same-dir imports when possible",
98
+ )
99
+ parser.set_defaults(prefer_absolute_imports=None)
100
+
101
+ pep_group = parser.add_mutually_exclusive_group()
102
+ pep_group.add_argument(
103
+ "--pep420",
104
+ dest="pep420",
105
+ action="store_true",
106
+ help="Treat directories as namespace packages (PEP 420) when deriving module paths",
107
+ )
108
+ pep_group.add_argument(
109
+ "--no-pep420",
110
+ dest="pep420",
111
+ action="store_false",
112
+ help="Require __init__.py for packages when deriving module paths",
113
+ )
114
+ parser.set_defaults(pep420=None)
115
+
116
+ parser.add_argument(
117
+ "--max-iterations",
118
+ type=int,
119
+ default=0,
120
+ help="Maximum refactorings to apply (0 = unlimited until fixed point, default: 0)",
121
+ )
122
+
123
+ parser.add_argument(
124
+ "--progress",
125
+ choices=["auto", "tqdm", "none", "detail"],
126
+ default="tqdm",
127
+ help="Progress display mode: 'tqdm' shows bars, 'auto' falls back if tqdm unavailable, "
128
+ "'none' disables output, 'detail' prints per-phase summaries.",
129
+ )
130
+
131
+
132
+ def _add_preview_parser(subparsers) -> None:
133
+ """Add 'preview' subcommand parser."""
134
+ parser = subparsers.add_parser(
135
+ "preview",
136
+ help="Preview duplicate code detection (read-only)",
137
+ description="Preview unification-based refactoring opportunities without modifying files.",
138
+ )
139
+
140
+ parser.add_argument("target", help="File or directory to analyze")
141
+
142
+ pref_group = parser.add_mutually_exclusive_group()
143
+ pref_group.add_argument(
144
+ "--prefer-absolute-imports",
145
+ dest="prefer_absolute_imports",
146
+ action="store_true",
147
+ help="Prefer absolute imports for cross-file extractions when possible",
148
+ )
149
+ pref_group.add_argument(
150
+ "--no-prefer-absolute-imports",
151
+ dest="prefer_absolute_imports",
152
+ action="store_false",
153
+ help="Prefer local/same-dir imports when possible",
154
+ )
155
+ parser.set_defaults(prefer_absolute_imports=None)
156
+
157
+ pep_group = parser.add_mutually_exclusive_group()
158
+ pep_group.add_argument(
159
+ "--pep420",
160
+ dest="pep420",
161
+ action="store_true",
162
+ help="Treat directories as namespace packages (PEP 420) when deriving module paths",
163
+ )
164
+ pep_group.add_argument(
165
+ "--no-pep420",
166
+ dest="pep420",
167
+ action="store_false",
168
+ help="Require __init__.py for packages when deriving module paths",
169
+ )
170
+ parser.set_defaults(pep420=None)
171
+
172
+
173
+ def _add_rename_helpers_parser(subparsers) -> None:
174
+ """Add 'rename-helpers' subcommand parser."""
175
+ parser = subparsers.add_parser(
176
+ "rename-helpers",
177
+ help="Rename extracted functions using LLM assistance",
178
+ formatter_class=argparse.RawDescriptionHelpFormatter,
179
+ description="""
180
+ Rename auto-generated __extracted_func_* functions into meaningful names.
181
+
182
+ This tool analyzes the extracted functions and uses an LLM to suggest better names
183
+ based on the code's purpose and context. You can use Claude Code, ChatGPT, or any
184
+ other AI coding assistant to generate the names.
185
+
186
+ The tool works in two modes:
187
+
188
+ 1. INTERACTIVE MODE (default):
189
+ Generates a prompt for an LLM to review your code and suggest new names.
190
+ You paste the LLM's response back, and the tool applies the renamings.
191
+
192
+ 2. FILE MODE (--rename-file):
193
+ Provide a JSON file with old_name -> new_name mappings.
194
+ The tool applies these renamings across your entire codebase.
195
+ """,
196
+ epilog="""
197
+ Examples:
198
+ # Interactive mode: generate LLM prompt and apply suggestions
199
+ code-towel rename-helpers src/
200
+
201
+ # List all extracted helpers
202
+ code-towel rename-helpers src/ --list
203
+
204
+ # Apply renamings from a JSON file
205
+ code-towel rename-helpers src/ --rename-file renames.json
206
+
207
+ # Dry run (preview only)
208
+ code-towel rename-helpers src/ --dry-run
209
+
210
+ # Specify specific files or functions
211
+ code-towel rename-helpers src/ --file mymodule.py
212
+ code-towel rename-helpers src/ --function __extracted_func_7
213
+ """,
214
+ )
215
+
216
+ parser.add_argument(
217
+ "target",
218
+ help="Directory containing code with __extracted_func_* functions",
219
+ )
220
+
221
+ parser.add_argument(
222
+ "--list",
223
+ action="store_true",
224
+ help="List all extracted helper functions found",
225
+ )
226
+
227
+ parser.add_argument(
228
+ "--dry-run",
229
+ action="store_true",
230
+ help="Preview changes without writing files",
231
+ )
232
+
233
+ parser.add_argument(
234
+ "--rename-file",
235
+ type=Path,
236
+ help="JSON file with old_name -> new_name mappings to apply",
237
+ )
238
+
239
+ parser.add_argument(
240
+ "--file",
241
+ action="append",
242
+ dest="files",
243
+ help="Limit processing to specific file(s) (relative path)",
244
+ )
245
+
246
+ parser.add_argument(
247
+ "--function",
248
+ action="append",
249
+ dest="functions",
250
+ help="Limit processing to specific function(s) by name",
251
+ )
252
+
253
+ parser.add_argument(
254
+ "--llm",
255
+ choices=["claude", "gpt", "copilot", "generic"],
256
+ default="generic",
257
+ help="Format the LLM prompt for a specific AI assistant (default: generic)",
258
+ )
259
+
260
+
261
+ def _run_dry(args: argparse.Namespace) -> None:
262
+ """Run the dry command."""
263
+ # Import here to avoid loading heavy modules if not needed
264
+ import os
265
+ import shutil
266
+ from towel.unification.refactor_engine import UnificationRefactorEngine
267
+
268
+ input_path = args.input
269
+ output_path = args.output
270
+
271
+ # Check if input exists
272
+ if not os.path.exists(input_path):
273
+ print(f"Error: '{input_path}' does not exist")
274
+ sys.exit(1)
275
+
276
+ # Determine if it's a file or directory
277
+ is_file = os.path.isfile(input_path)
278
+ is_dir = os.path.isdir(input_path)
279
+
280
+ if not is_file and not is_dir:
281
+ print(f"Error: '{input_path}' is neither a file nor a directory")
282
+ sys.exit(1)
283
+
284
+ # Check input file extension for single files
285
+ if is_file and not input_path.endswith(".py"):
286
+ print(f"Warning: '{input_path}' is not a Python file (.py)")
287
+ response = input("Analyze anyway? (y/N): ").strip().lower()
288
+ if response != "y":
289
+ return
290
+
291
+ # Copy input to output
292
+ abs_input = os.path.abspath(input_path)
293
+ abs_output = os.path.abspath(output_path)
294
+
295
+ if abs_input == abs_output:
296
+ print(f"Modifying in place: {input_path}")
297
+ else:
298
+ print(f"Copying {input_path} -> {output_path}")
299
+ if is_file:
300
+ output_dir = os.path.dirname(output_path)
301
+ if output_dir:
302
+ os.makedirs(output_dir, exist_ok=True)
303
+ shutil.copy2(input_path, output_path)
304
+ else:
305
+ if os.path.exists(output_path):
306
+ shutil.rmtree(output_path)
307
+ shutil.copytree(input_path, output_path)
308
+ print()
309
+
310
+ # Create engine
311
+ engine = UnificationRefactorEngine(
312
+ max_parameters=5,
313
+ min_lines=3,
314
+ parameterize_constants=True,
315
+ prefer_absolute_imports=args.prefer_absolute_imports,
316
+ pep420_namespace_packages=args.pep420,
317
+ )
318
+
319
+ # Use fixed-point iteration
320
+ print("=" * 70)
321
+ print("APPLYING REFACTORINGS (FIXED-POINT ITERATION)")
322
+ print("=" * 70)
323
+ print()
324
+ print("This will apply refactorings one at a time until no more are found.")
325
+ print("Extracted functions will be placed at the end of files.")
326
+ print()
327
+
328
+ if not args.non_interactive:
329
+ response = input("Proceed? (y/N): ").strip().lower()
330
+ if response != "y":
331
+ print("Aborted.")
332
+ return
333
+
334
+ print()
335
+
336
+ # Apply refactorings to fixed point
337
+ if is_file:
338
+ print(f"Refactoring file: {output_path}")
339
+ final_code, num_applied, descriptions = engine.refactor_to_fixed_point(
340
+ output_path,
341
+ max_iterations=args.max_iterations,
342
+ )
343
+
344
+ with open(output_path, "w") as f:
345
+ f.write(final_code)
346
+
347
+ if num_applied > 0:
348
+ print(f"\n✓ Applied {num_applied} refactoring(s):")
349
+ for i, desc in enumerate(descriptions, 1):
350
+ print(f" {i}. {desc}")
351
+ else:
352
+ print("\nNo refactorings found!")
353
+ else:
354
+ print(f"Refactoring directory: {output_path}")
355
+ results, termination_reason = engine.refactor_directory_to_fixed_point(
356
+ output_path,
357
+ output_path,
358
+ max_iterations=args.max_iterations,
359
+ progress=args.progress,
360
+ )
361
+
362
+ if results:
363
+ total_refactorings = sum(count for count, _ in results.values())
364
+ print(f"\n✓ Applied {total_refactorings} refactoring(s) across {len(results)} file(s)")
365
+ print(f" Termination: {termination_reason}")
366
+ for file_path, (count, descriptions) in sorted(results.items()):
367
+ print(f"\n {file_path}: {count} refactoring(s)")
368
+ for desc in descriptions[:3]:
369
+ print(f" - {desc}")
370
+ if len(descriptions) > 3:
371
+ print(f" ... and {len(descriptions) - 3} more")
372
+ else:
373
+ print("\nNo refactorings found! Termination: fixed_point")
374
+
375
+
376
+ def _run_preview(args: argparse.Namespace) -> None:
377
+ """Run the preview command."""
378
+ import os
379
+ import ast
380
+ from towel.unification.refactor_engine import (
381
+ UnificationRefactorEngine,
382
+ filter_overlapping_proposals,
383
+ )
384
+
385
+ target = args.target
386
+
387
+ # Check if target exists
388
+ if not os.path.exists(target):
389
+ print(f"Error: '{target}' does not exist")
390
+ sys.exit(1)
391
+
392
+ is_file = os.path.isfile(target)
393
+ is_dir = os.path.isdir(target)
394
+
395
+ if not is_file and not is_dir:
396
+ print(f"Error: '{target}' is neither a file nor a directory")
397
+ sys.exit(1)
398
+
399
+ # Create engine
400
+ engine = UnificationRefactorEngine(
401
+ max_parameters=5,
402
+ min_lines=3,
403
+ parameterize_constants=True,
404
+ prefer_absolute_imports=args.prefer_absolute_imports,
405
+ pep420_namespace_packages=args.pep420,
406
+ )
407
+
408
+ # Analyze
409
+ if is_file:
410
+ if not target.endswith(".py"):
411
+ print(f"Note: '{target}' is not a Python file (.py)")
412
+ print()
413
+
414
+ print(f"Analyzing file: {target}")
415
+ all_proposals = engine.analyze_file(target)
416
+ else:
417
+ print(f"Analyzing directory: {target}")
418
+ all_proposals = engine.analyze_directory(
419
+ target, recursive=True, verbose=True, progress="tqdm"
420
+ )
421
+
422
+ print(f"\nFound {len(all_proposals)} refactoring opportunities")
423
+
424
+ if not all_proposals:
425
+ print("No duplicates found!")
426
+ return
427
+
428
+ # Filter overlapping proposals
429
+ proposals = filter_overlapping_proposals(all_proposals)
430
+
431
+ if len(proposals) < len(all_proposals):
432
+ print(f"Filtered to {len(proposals)} non-overlapping proposals")
433
+ print(f"(Removed {len(all_proposals) - len(proposals)} overlapping proposals)")
434
+
435
+ print("\n" + "=" * 70)
436
+ print("REFACTORING OPPORTUNITIES")
437
+ print("=" * 70)
438
+
439
+ for i, prop in enumerate(proposals[:10], 1):
440
+ print(f"\n{i}. {prop.description}")
441
+ print(f" Parameters: {prop.parameters_count}")
442
+
443
+ # Show which files are affected
444
+ files_affected = set()
445
+ for item in prop.replacements:
446
+ if len(item) == 3:
447
+ files_affected.add(item[2])
448
+ else:
449
+ files_affected.add(prop.file_path)
450
+
451
+ if len(files_affected) > 1:
452
+ print(f" Type: Cross-file ({len(files_affected)} files)")
453
+ for f in sorted(files_affected):
454
+ print(f" - {f}")
455
+ else:
456
+ print(f" Type: Same file ({prop.file_path})")
457
+
458
+ # Show extracted function preview
459
+ print("\n Extracted function preview:")
460
+ try:
461
+ func_code = ast.unparse(prop.extracted_function)
462
+ lines = func_code.split("\n")
463
+ for line in lines[:8]:
464
+ print(f" {line}")
465
+ if len(lines) > 8:
466
+ print(f" ... ({len(lines) - 8} more lines)")
467
+ except ValueError as e:
468
+ print(f" (Preview unavailable: {e})")
469
+ print(f" Function name: {prop.extracted_function.name}")
470
+
471
+ if len(proposals) > 10:
472
+ print(f"\n... and {len(proposals) - 10} more proposals")
473
+
474
+ print("\n" + "=" * 70)
475
+ print("\nTo apply these refactorings, run:")
476
+ if is_file:
477
+ print(f" code-towel dry {target} <output>")
478
+ else:
479
+ print(f" code-towel dry {target} <output_dir>")
480
+ print()
481
+
482
+
483
+ def _run_rename_helpers(args: argparse.Namespace) -> None:
484
+ """Run the rename-helpers command."""
485
+ target = Path(args.target)
486
+
487
+ if not target.exists():
488
+ print(f"Error: '{target}' does not exist")
489
+ sys.exit(1)
490
+
491
+ if not target.is_dir():
492
+ print(f"Error: '{target}' must be a directory")
493
+ sys.exit(1)
494
+
495
+ # Find all extracted helper functions
496
+ helpers = _find_extracted_helpers(target, args.files, args.functions)
497
+
498
+ if not helpers:
499
+ print(f"No __extracted_func_* functions found in {target}")
500
+ return
501
+
502
+ # List mode
503
+ if args.list:
504
+ print(f"\nFound {len(helpers)} extracted helper function(s):\n")
505
+ for file_path, func_name, lineno, source_preview in helpers:
506
+ rel_path = file_path.relative_to(target)
507
+ print(f" {rel_path}:{lineno}")
508
+ print(f" Function: {func_name}")
509
+ if source_preview:
510
+ lines = source_preview.split("\n")[:3]
511
+ for line in lines:
512
+ print(f" {line}")
513
+ if len(source_preview.split("\n")) > 3:
514
+ print(" ...")
515
+ print()
516
+ return
517
+
518
+ # Apply renamings from file
519
+ if args.rename_file:
520
+ _apply_rename_file(target, helpers, args.rename_file, args.dry_run)
521
+ return
522
+
523
+ # Interactive LLM mode
524
+ _run_interactive_llm_mode(target, helpers, args.llm, args.dry_run)
525
+
526
+
527
+ def _find_extracted_helpers(
528
+ target: Path,
529
+ file_filters: Optional[List[str]],
530
+ function_filters: Optional[List[str]],
531
+ ) -> List[Tuple[Path, str, int, str]]:
532
+ """Find all __extracted_func_* functions in target directory."""
533
+ import ast
534
+ import re
535
+
536
+ helper_pattern = re.compile(r"^__extracted_func(?:_\d+)?$")
537
+ helpers = []
538
+
539
+ for py_file in target.rglob("*.py"):
540
+ # Apply file filters
541
+ if file_filters:
542
+ rel_path = str(py_file.relative_to(target))
543
+ if not any(f in rel_path for f in file_filters):
544
+ continue
545
+
546
+ try:
547
+ source = py_file.read_text()
548
+ tree = ast.parse(source)
549
+ except (SyntaxError, UnicodeDecodeError):
550
+ continue
551
+
552
+ for node in ast.walk(tree):
553
+ if isinstance(node, ast.FunctionDef):
554
+ if helper_pattern.match(node.name):
555
+ # Apply function filters
556
+ if function_filters and node.name not in function_filters:
557
+ continue
558
+
559
+ # Get source preview
560
+ lines = source.split("\n")
561
+ if hasattr(node, "lineno") and node.lineno <= len(lines):
562
+ start = node.lineno - 1
563
+ end = min(start + 5, len(lines))
564
+ preview = "\n".join(lines[start:end])
565
+ else:
566
+ preview = ""
567
+
568
+ helpers.append((py_file, node.name, node.lineno, preview))
569
+
570
+ return sorted(helpers, key=lambda x: (str(x[0]), x[2]))
571
+
572
+
573
+ def _apply_rename_file(
574
+ target: Path,
575
+ helpers: List[Tuple[Path, str, int, str]],
576
+ rename_file: Path,
577
+ dry_run: bool,
578
+ ) -> None:
579
+ """Apply renamings from a JSON file."""
580
+ import json
581
+ import re
582
+
583
+ # Load rename mappings
584
+ try:
585
+ with open(rename_file) as f:
586
+ renames = json.load(f)
587
+ except (json.JSONDecodeError, FileNotFoundError) as e:
588
+ print(f"Error reading rename file: {e}")
589
+ sys.exit(1)
590
+
591
+ if not isinstance(renames, dict):
592
+ print("Error: Rename file must contain a JSON object (dict)")
593
+ sys.exit(1)
594
+
595
+ # Apply renamings
596
+ total_changes = 0
597
+
598
+ for old_name, new_name in renames.items():
599
+ if not isinstance(new_name, str) or not new_name:
600
+ print(f"Warning: Skipping invalid mapping {old_name} -> {new_name}")
601
+ continue
602
+
603
+ # Validate new name
604
+ if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", new_name):
605
+ print(f"Warning: Skipping invalid Python identifier: {new_name}")
606
+ continue
607
+
608
+ # Find and replace in all Python files
609
+ count = _rename_function_in_directory(target, old_name, new_name, dry_run)
610
+ if count > 0:
611
+ total_changes += count
612
+ print(f" {old_name} -> {new_name} ({count} replacement(s))")
613
+
614
+ if dry_run:
615
+ print(f"\n[DRY RUN] Would make {total_changes} change(s)")
616
+ else:
617
+ print(f"\n✓ Applied {total_changes} change(s)")
618
+
619
+
620
+ def _rename_function_in_directory(
621
+ target: Path,
622
+ old_name: str,
623
+ new_name: str,
624
+ dry_run: bool,
625
+ ) -> int:
626
+ """Rename a function throughout all Python files in directory."""
627
+ import re
628
+
629
+ # Pattern to match function definitions and calls
630
+ # This is a simple regex - could be improved with AST rewriting for accuracy
631
+ patterns = [
632
+ (rf"\bdef {re.escape(old_name)}\b", f"def {new_name}"),
633
+ (rf"\b{re.escape(old_name)}\(", f"{new_name}("),
634
+ ]
635
+
636
+ total_replacements = 0
637
+
638
+ for py_file in target.rglob("*.py"):
639
+ try:
640
+ content = py_file.read_text()
641
+ new_content = content
642
+
643
+ for pattern, replacement in patterns:
644
+ new_content = re.sub(pattern, replacement, new_content)
645
+
646
+ if new_content != content:
647
+ replacements = sum(len(re.findall(pattern, content)) for pattern, _ in patterns)
648
+ total_replacements += replacements
649
+
650
+ if not dry_run:
651
+ py_file.write_text(new_content)
652
+
653
+ except (UnicodeDecodeError, PermissionError):
654
+ continue
655
+
656
+ return total_replacements
657
+
658
+
659
+ def _run_interactive_llm_mode(
660
+ target: Path,
661
+ helpers: List[Tuple[Path, str, int, str]],
662
+ llm_type: str,
663
+ dry_run: bool,
664
+ ) -> None:
665
+ """Run interactive mode: generate LLM prompt and apply suggestions."""
666
+ print(f"\nFound {len(helpers)} extracted helper function(s) to rename.\n") # noqa: F541
667
+ print("=" * 70)
668
+ print("STEP 1: LLM PROMPT GENERATION")
669
+ print("=" * 70)
670
+ print()
671
+
672
+ # Generate LLM prompt
673
+ prompt = _generate_llm_prompt(target, helpers, llm_type)
674
+
675
+ print("Copy the following prompt and paste it into your LLM assistant:")
676
+ print("\n" + "=" * 70)
677
+ print(prompt)
678
+ print("=" * 70 + "\n")
679
+
680
+ print("After the LLM provides suggestions, paste the JSON response below.")
681
+ print("The response should be a JSON object mapping old names to new names:")
682
+ print(
683
+ 'Example: {"__extracted_func_1": "calculate_total", "__extracted_func_2": "validate_input"}'
684
+ )
685
+ print()
686
+ print("Paste the JSON response (press Ctrl+D or Ctrl+Z when done):")
687
+ print()
688
+
689
+ # Read LLM response from stdin
690
+ try:
691
+ llm_response = sys.stdin.read().strip()
692
+ except KeyboardInterrupt:
693
+ print("\nAborted.")
694
+ return
695
+
696
+ if not llm_response:
697
+ print("No response provided. Aborted.")
698
+ return
699
+
700
+ # Extract JSON from response (handle markdown code blocks)
701
+ import json
702
+ import re
703
+
704
+ # Try to extract JSON from markdown code block
705
+ json_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", llm_response, re.DOTALL)
706
+ if json_match:
707
+ json_str = json_match.group(1)
708
+ else:
709
+ # Try to find JSON object directly
710
+ json_match = re.search(r"\{.*\}", llm_response, re.DOTALL)
711
+ if json_match:
712
+ json_str = json_match.group(0)
713
+ else:
714
+ json_str = llm_response
715
+
716
+ # Parse JSON
717
+ try:
718
+ renames = json.loads(json_str)
719
+ except json.JSONDecodeError as e:
720
+ print(f"\nError parsing JSON response: {e}")
721
+ print("Please ensure the response is valid JSON.")
722
+ return
723
+
724
+ if not isinstance(renames, dict):
725
+ print("Error: Response must be a JSON object (dict)")
726
+ return
727
+
728
+ print("\n" + "=" * 70)
729
+ print("STEP 2: APPLYING RENAMINGS")
730
+ print("=" * 70)
731
+ print()
732
+
733
+ # Apply renamings
734
+ total_changes = 0
735
+ for old_name, new_name in renames.items():
736
+ if not isinstance(new_name, str) or not new_name:
737
+ print(f"Warning: Skipping invalid mapping {old_name} -> {new_name}")
738
+ continue
739
+
740
+ count = _rename_function_in_directory(target, old_name, new_name, dry_run)
741
+ if count > 0:
742
+ total_changes += count
743
+ print(f" ✓ {old_name} -> {new_name} ({count} replacement(s))")
744
+
745
+ if dry_run:
746
+ print(f"\n[DRY RUN] Would make {total_changes} change(s)")
747
+ else:
748
+ print(f"\n✓ Successfully applied {total_changes} change(s)")
749
+
750
+
751
+ def _generate_llm_prompt(
752
+ target: Path,
753
+ helpers: List[Tuple[Path, str, int, str]],
754
+ llm_type: str,
755
+ ) -> str:
756
+ """Generate a prompt for an LLM to suggest better function names."""
757
+
758
+ intro = """I have extracted duplicate code into helper functions, but they have generic names like __extracted_func_1, __extracted_func_2, etc.
759
+
760
+ Please review each function and suggest a better, more descriptive name based on what the function does. The new names should:
761
+ - Be descriptive and indicate the function's purpose
762
+ - Follow Python naming conventions (lowercase with underscores)
763
+ - Be concise but meaningful
764
+ - Avoid generic names like "helper" or "utility"
765
+
766
+ Here are the extracted functions:
767
+
768
+ """
769
+
770
+ functions_section = ""
771
+ for i, (file_path, func_name, lineno, source_preview) in enumerate(helpers, 1):
772
+ rel_path = file_path.relative_to(target)
773
+ functions_section += f"\n{i}. {func_name} ({rel_path}:{lineno})\n"
774
+ if source_preview:
775
+ functions_section += "```python\n"
776
+ functions_section += source_preview + "\n"
777
+ functions_section += "```\n"
778
+
779
+ output_format = """
780
+
781
+ Please provide your suggestions as a JSON object mapping old names to new names. For example:
782
+
783
+ ```json
784
+ {
785
+ "__extracted_func_1": "calculate_total_price",
786
+ "__extracted_func_2": "validate_email_format",
787
+ "__extracted_func_3": "format_date_string"
788
+ }
789
+ ```
790
+
791
+ Only include the JSON object in your response.
792
+ """
793
+
794
+ return intro + functions_section + output_format
795
+
796
+
797
+ # Legacy entry points for backwards compatibility
798
+ def dry_main() -> None:
799
+ """Direct entry point for 'towel-dry' command (legacy)."""
800
+ # Emulate 'towel dry' by injecting 'dry' as first argument
801
+ sys.argv.insert(1, "dry")
802
+ main()
803
+
804
+
805
+ def preview_main() -> None:
806
+ """Direct entry point for 'towel-preview' command (legacy)."""
807
+ # Emulate 'towel preview' by injecting 'preview' as first argument
808
+ sys.argv.insert(1, "preview")
809
+ main()
810
+
811
+
812
+ if __name__ == "__main__":
813
+ main()