onepaste 1.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.
@@ -0,0 +1,10 @@
1
+ """CodeCollector - Collect project code files for AI assistants."""
2
+
3
+ __version__ = "1.3.0"
4
+ __author__ = "thawn"
5
+ __license__ = "MIT"
6
+
7
+ from codecollector.collector import FileCollector
8
+ from codecollector.config import CollectorConfig
9
+
10
+ __all__ = ["FileCollector", "CollectorConfig", "__version__"]
@@ -0,0 +1,6 @@
1
+ """Support for python -m codecollector"""
2
+
3
+ from codecollector.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
codecollector/cli.py ADDED
@@ -0,0 +1,568 @@
1
+ """Command-line interface for CodeCollector."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import List, Optional
6
+
7
+ from rich.console import Console
8
+ from rich.panel import Panel
9
+ from rich.table import Table
10
+ from rich.theme import Theme
11
+
12
+ from codecollector import __version__
13
+ from codecollector.collector import FileCollector
14
+ from codecollector.config import CONFIG_FILE, CollectorConfig, ensure_config_dir
15
+ from codecollector.formatter import OutputFormatter
16
+ from codecollector.output import output_files_exist, write_manifest
17
+ from codecollector.selector import InteractiveSelector
18
+ from codecollector.splitter import write_collection_output
19
+ from codecollector.tokens import count_tokens, method_label
20
+ from codecollector.uninstall import uninstall_package
21
+
22
+ custom_theme = Theme({
23
+ "info": "cyan",
24
+ "warning": "yellow",
25
+ "error": "red bold",
26
+ "success": "green bold",
27
+ "path": "blue",
28
+ "highlight": "magenta bold",
29
+ "title": "bold cyan",
30
+ "subtitle": "dim white",
31
+ })
32
+
33
+ console = Console(theme=custom_theme)
34
+ # Diagnostics and errors always go to stderr so piped stdout stays clean.
35
+ err_console = Console(file=sys.stderr, theme=custom_theme)
36
+
37
+
38
+ class CodeCollectorApp:
39
+ """Main application class for CodeCollector."""
40
+
41
+ def __init__(self):
42
+ self.config: Optional[CollectorConfig] = None
43
+ self.collector: Optional[FileCollector] = None
44
+ self.dry_run: bool = False
45
+ self.force: bool = False
46
+ self.stdout_mode: bool = False
47
+ # In --stdout mode all human-readable output goes to stderr so the
48
+ # piped stream stays clean.
49
+ self.console: Console = console
50
+
51
+ def run_interactive(self, filter_mode: Optional[bool] = None) -> None:
52
+ """Run in interactive mode: select directory, then collect immediately."""
53
+ self._print_banner()
54
+
55
+ selected_path = InteractiveSelector.select_directory()
56
+
57
+ if selected_path is None:
58
+ console.print("\n[yellow]Exited[/yellow]")
59
+ return
60
+
61
+ console.print(f"\n[success]Collecting:[/success] [path]{selected_path}[/path]")
62
+
63
+ overrides: dict = {"recursive": True}
64
+ if filter_mode is not None:
65
+ overrides["respect_gitignore"] = filter_mode
66
+
67
+ self.config = CollectorConfig.from_sources(
68
+ root_path=selected_path,
69
+ overrides=overrides,
70
+ )
71
+
72
+ self._execute_collection()
73
+
74
+ def run_with_path(
75
+ self,
76
+ path_str: str,
77
+ recursive: bool = True,
78
+ output_file: str = "code_collection.md",
79
+ max_output_size_mb: Optional[float] = None,
80
+ output_dir: Optional[str] = None,
81
+ config_file: Optional[str] = None,
82
+ exclude_dirs: Optional[List[str]] = None,
83
+ respect_gitignore: Optional[bool] = None,
84
+ include_patterns: Optional[List[str]] = None,
85
+ exclude_patterns: Optional[List[str]] = None,
86
+ dry_run: bool = False,
87
+ force: bool = False,
88
+ stdout_mode: bool = False,
89
+ ) -> None:
90
+ """Run with a specific directory path."""
91
+ target_path = Path(path_str).resolve()
92
+
93
+ if not target_path.exists():
94
+ err_console.print(f"[error]Directory not found: {target_path}[/error]")
95
+ sys.exit(1)
96
+
97
+ if not target_path.is_dir():
98
+ err_console.print(f"[error]Not a directory: {target_path}[/error]")
99
+ sys.exit(1)
100
+
101
+ overrides: dict = {
102
+ "recursive": recursive,
103
+ "output_file": output_file,
104
+ }
105
+ # None means "auto": from_sources defaults .gitignore filtering on
106
+ # inside git work trees.
107
+ if respect_gitignore is not None:
108
+ overrides["respect_gitignore"] = respect_gitignore
109
+ if max_output_size_mb is not None:
110
+ overrides["max_output_size_mb"] = max_output_size_mb
111
+ if output_dir is not None:
112
+ overrides["output_dir"] = Path(output_dir)
113
+ if exclude_dirs:
114
+ overrides["extra_exclude_dirs"] = set(exclude_dirs)
115
+ if include_patterns:
116
+ overrides["include_patterns"] = list(include_patterns)
117
+ if exclude_patterns:
118
+ overrides["exclude_patterns"] = list(exclude_patterns)
119
+
120
+ self.config = CollectorConfig.from_sources(
121
+ root_path=target_path,
122
+ config_file=config_file,
123
+ overrides=overrides,
124
+ )
125
+ self.dry_run = dry_run
126
+ self.force = force
127
+ self.stdout_mode = stdout_mode
128
+
129
+ if stdout_mode:
130
+ self.console = Console(
131
+ file=sys.stderr,
132
+ theme=custom_theme,
133
+ )
134
+
135
+ self._execute_collection()
136
+
137
+ def _execute_collection(self) -> None:
138
+ """Execute the code collection process."""
139
+ c = self.console
140
+ filter_state = "on" if self.config.respect_gitignore else "off"
141
+ c.print("\n[info]Collecting code files...[/info]")
142
+ c.print(f"[dim].gitignore filter: {filter_state}[/dim]")
143
+
144
+ self.collector = FileCollector(self.config)
145
+
146
+ with c.status("[cyan]Scanning...[/cyan]"):
147
+ try:
148
+ collected_files = self.collector.collect_files()
149
+ except Exception as e:
150
+ c.print(f"\n[error]Collection error: {e}[/error]")
151
+ return
152
+
153
+ if not collected_files:
154
+ c.print("\n[warning]No matching code files found[/warning]")
155
+ return
156
+
157
+ if self.dry_run:
158
+ self._print_dry_run(collected_files)
159
+ return
160
+
161
+ formatter = OutputFormatter()
162
+
163
+ file_contents = [
164
+ formatter.format_file_content(file_path, self.config.root_path)
165
+ for file_path in collected_files
166
+ ]
167
+ summary = formatter.format_summary(
168
+ collected_files, self.collector.skipped_files, self.config
169
+ )
170
+
171
+ if self.stdout_mode:
172
+ self._write_stdout(summary, file_contents, collected_files)
173
+ return
174
+
175
+ output_dir = self.config.output_dir or Path.cwd()
176
+ output_dir.mkdir(parents=True, exist_ok=True)
177
+
178
+ if (
179
+ self.config.auto_increment_output
180
+ and not self.force
181
+ and output_files_exist(output_dir, self.config.output_file)
182
+ ):
183
+ c.print(
184
+ f"\n[warning]Output exists:[/warning] [path]{self.config.output_file}[/path] "
185
+ "[dim](will auto-increment)[/dim]"
186
+ )
187
+
188
+ output_paths, resolved_name = write_collection_output(
189
+ output_dir,
190
+ self.config.output_file,
191
+ summary,
192
+ collected_files,
193
+ file_contents,
194
+ self.config,
195
+ force=self.force,
196
+ )
197
+
198
+ if resolved_name != self.config.output_file:
199
+ c.print(
200
+ f"\n[info]Output renamed to avoid overwrite:[/info] "
201
+ f"[path]{resolved_name}[/path]"
202
+ )
203
+
204
+ if len(output_paths) == 1:
205
+ c.print(
206
+ f"\n[info]Done:[/info] [path]{output_paths[0]}[/path]"
207
+ )
208
+ else:
209
+ c.print(
210
+ f"\n[info]Done ({len(output_paths)} parts):[/info]"
211
+ )
212
+ for p in output_paths:
213
+ c.print(f" [path]{p}[/path]")
214
+
215
+ manifest_path = None
216
+ if self.config.write_manifest and len(output_paths) > 1:
217
+ manifest_path = write_manifest(
218
+ output_dir,
219
+ resolved_name,
220
+ output_paths,
221
+ self.config.root_path,
222
+ len(collected_files),
223
+ )
224
+ c.print(f" [dim]Manifest: {manifest_path.name}[/dim]")
225
+
226
+ self._print_results(output_paths, collected_files, manifest_path)
227
+
228
+ def _write_stdout(
229
+ self,
230
+ summary: str,
231
+ file_contents: List[str],
232
+ collected_files: list,
233
+ ) -> None:
234
+ """Write collection output to stdout (pipe-friendly).
235
+
236
+ Progress and stats go to stderr; splitting is disabled.
237
+ """
238
+ c = self.console
239
+
240
+ max_bytes = int(self.config.max_output_size_mb * 1024 * 1024)
241
+ total_bytes = len(summary.encode("utf-8")) + sum(
242
+ len(part.encode("utf-8")) for part in file_contents
243
+ )
244
+ if max_bytes > 0 and total_bytes > max_bytes:
245
+ c.print(
246
+ f"[warning]Output is {total_bytes / (1024 * 1024):.1f} MB "
247
+ f"(> {self.config.max_output_size_mb:g} MB limit); "
248
+ "--stdout does not split[/warning]"
249
+ )
250
+
251
+ sys.stdout.write(summary + "".join(file_contents))
252
+ sys.stdout.flush()
253
+
254
+ total_tokens = count_tokens(summary) + sum(
255
+ count_tokens(part) for part in file_contents
256
+ )
257
+ c.print(
258
+ f"\n[success]Done:[/success] {len(collected_files)} files, "
259
+ f"{total_tokens:,} tokens ({method_label()}) [dim]-> stdout[/dim]"
260
+ )
261
+
262
+ def _print_dry_run(self, collected_files: list) -> None:
263
+ """Print dry-run preview without writing output."""
264
+ c = self.console
265
+ c.print("\n[highlight]Dry Run[/highlight] [dim](no files written)[/dim]\n")
266
+
267
+ table = Table(title="[bold cyan]Collection Preview[/bold cyan]", show_header=False)
268
+ table.add_column(style="bold cyan", width=20)
269
+ table.add_column(style="white")
270
+
271
+ table.add_row("Root", str(self.config.root_path))
272
+ table.add_row("Files to collect", f"[success]{len(collected_files)}[/success]")
273
+
274
+ if self.collector.skipped_files:
275
+ table.add_row("Files skipped", f"[warning]{len(self.collector.skipped_files)}[/warning]")
276
+
277
+ total_size = sum(fp.stat().st_size for fp in collected_files if fp.exists())
278
+ table.add_row("Estimated size", f"{total_size / 1024:.1f} KB")
279
+ table.add_row("Filter mode", "on" if self.config.respect_gitignore else "off")
280
+ if self.config.include_patterns:
281
+ shown = ", ".join(self.config.include_patterns[:3])
282
+ more = f" (+{len(self.config.include_patterns) - 3})" if len(self.config.include_patterns) > 3 else ""
283
+ table.add_row("Include patterns", f"{shown}{more}")
284
+ if self.config.exclude_patterns:
285
+ shown = ", ".join(self.config.exclude_patterns[:3])
286
+ more = f" (+{len(self.config.exclude_patterns) - 3})" if len(self.config.exclude_patterns) > 3 else ""
287
+ table.add_row("Exclude patterns", f"{shown}{more}")
288
+ if self.stdout_mode:
289
+ table.add_row("Output", "[path]stdout[/path]")
290
+ else:
291
+ output_dir = self.config.output_dir or Path.cwd()
292
+ table.add_row("Output dir", str(output_dir))
293
+ table.add_row("Output file", self.config.output_file)
294
+
295
+ c.print(table)
296
+
297
+ if self.collector.skipped_files and self.config.show_skipped:
298
+ c.print("\n[warning]Skipped files (first 10):[/warning]")
299
+ for fp, reason in self.collector.skipped_files[:10]:
300
+ try:
301
+ rel = fp.relative_to(self.config.root_path)
302
+ except ValueError:
303
+ rel = fp
304
+ c.print(f" [dim]• {rel}: {reason}[/dim]")
305
+ if len(self.collector.skipped_files) > 10:
306
+ c.print(f" [dim]... and {len(self.collector.skipped_files) - 10} more[/dim]")
307
+
308
+ def _print_results(
309
+ self,
310
+ output_paths: list,
311
+ collected_files: list,
312
+ manifest_path: Optional[Path] = None,
313
+ ) -> None:
314
+ """Print collection results."""
315
+ c = self.console
316
+ c.print()
317
+
318
+ table = Table(title="[bold cyan]Collection Results[/bold cyan]", show_header=False)
319
+ table.add_column(style="bold cyan", width=20)
320
+ table.add_column(style="white")
321
+
322
+ table.add_row("Files collected", f"[success]{len(collected_files)}[/success]")
323
+
324
+ if self.collector.skipped_files:
325
+ table.add_row("Files skipped", f"[warning]{len(self.collector.skipped_files)}[/warning]")
326
+
327
+ total_lines = 0
328
+ total_size = 0
329
+ total_tokens = 0
330
+ file_types = set()
331
+
332
+ for fp in collected_files:
333
+ try:
334
+ with open(fp, "r", encoding="utf-8") as f:
335
+ content = f.read()
336
+ total_lines += content.count("\n") + 1
337
+ total_tokens += count_tokens(content)
338
+ total_size += fp.stat().st_size
339
+ file_types.add(fp.suffix or "no_ext")
340
+ except Exception:
341
+ pass
342
+
343
+ table.add_row("Total lines", f"{total_lines:,}")
344
+ table.add_row("Total size", f"{total_size / 1024:.1f} KB")
345
+ table.add_row("Total tokens", f"{total_tokens:,} [dim]({method_label()})[/dim]")
346
+ table.add_row("File types", str(len(file_types)))
347
+
348
+ if len(output_paths) == 1:
349
+ table.add_row("Output file", f"[path]{output_paths[0]}[/path]")
350
+ else:
351
+ table.add_row("Output files", f"[success]{len(output_paths)} parts[/success]")
352
+ for p in output_paths:
353
+ table.add_row("", f"[path]{p.name}[/path]")
354
+ if manifest_path:
355
+ table.add_row("Manifest", f"[path]{manifest_path.name}[/path]")
356
+
357
+ c.print(table)
358
+
359
+ if len(output_paths) == 1:
360
+ tip = (
361
+ "[info]Copy the file content and paste it into your LLM[/info]\n"
362
+ f"[dim]{output_paths[0].absolute()}[/dim]"
363
+ )
364
+ else:
365
+ paths_text = "\n".join(f"[dim] {p.absolute()}[/dim]" for p in output_paths)
366
+ tip = (
367
+ f"[info]Paste each part into your LLM in order "
368
+ f"(Part 1 → Part {len(output_paths)})[/info]\n"
369
+ f"{paths_text}"
370
+ )
371
+
372
+ c.print(Panel(tip, border_style="green"))
373
+
374
+ @staticmethod
375
+ def _print_banner() -> None:
376
+ """Print application banner."""
377
+ console.print()
378
+ console.print(
379
+ Panel(
380
+ "[title]CodeCollector[/title]\n"
381
+ "[subtitle]Select a directory → Enter → ready for LLM[/subtitle]\n"
382
+ f"[dim]v{__version__}[/dim]",
383
+ border_style="cyan",
384
+ padding=(1, 2),
385
+ )
386
+ )
387
+
388
+
389
+ def main() -> None:
390
+ """Main entry point."""
391
+ import argparse
392
+
393
+ parser = argparse.ArgumentParser(
394
+ description="CodeCollector - Collect project code for AI assistants",
395
+ formatter_class=argparse.RawDescriptionHelpFormatter,
396
+ epilog=f"""
397
+ Examples:
398
+ collect Select directory interactively, then collect
399
+ collect . Collect current directory (.gitignore on inside git repos)
400
+ collect . --no-gitignore Include gitignored files
401
+ collect /path/to/project Collect specific directory
402
+ collect . -n Non-recursive (current directory only)
403
+ collect . -o output.md Custom output filename
404
+ collect . --max-output-size 5 Split output when exceeding 5MB per file
405
+ collect . --stdout | llm "..." Pipe collection straight into another tool
406
+ collect . --dry-run Preview without writing output
407
+ collect . --force Overwrite existing output files
408
+ collect . -d ./output Write output to specific directory
409
+ collect . --exclude vendor Extra directory to exclude
410
+ collect . --include "src/**" Only include files matching glob (repeatable)
411
+ collect . --exclude-pattern "*_test.go" Skip files matching glob (repeatable)
412
+ collect --init-config Initialize default configuration
413
+ collect --uninstall Uninstall codecollector (pipx/pip)
414
+ collect --uninstall --purge-config Uninstall and remove ~/.config/codecollector
415
+
416
+ Version: {__version__}
417
+ """,
418
+ )
419
+
420
+ parser.add_argument("path", nargs="?", help="Directory path to collect code from")
421
+ parser.add_argument(
422
+ "-i",
423
+ action="store_true",
424
+ help="Force-enable .gitignore filtering "
425
+ "(deprecated: enabled by default inside git repos)",
426
+ )
427
+ parser.add_argument(
428
+ "--no-gitignore",
429
+ action="store_true",
430
+ help="Disable .gitignore filtering",
431
+ )
432
+ parser.add_argument("-n", "--non-recursive", action="store_true", help="Non-recursive mode")
433
+ parser.add_argument("-o", "--output", type=str, help="Output filename (default: code_collection.md)")
434
+ parser.add_argument("-d", "--output-dir", type=str, help="Output directory (default: cwd)")
435
+ parser.add_argument(
436
+ "--max-output-size",
437
+ type=float,
438
+ metavar="MB",
439
+ help="Max output file size in MB; auto-split when exceeded (default: 2, 0=disable)",
440
+ )
441
+ parser.add_argument(
442
+ "--exclude",
443
+ action="append",
444
+ metavar="DIR",
445
+ help="Extra directory to exclude (repeatable)",
446
+ )
447
+ parser.add_argument(
448
+ "--include",
449
+ action="append",
450
+ metavar="GLOB",
451
+ help="Only include files matching glob, e.g. 'src/**/*.ts' "
452
+ "(repeatable; overrides extension whitelist)",
453
+ )
454
+ parser.add_argument(
455
+ "--exclude-pattern",
456
+ dest="exclude_patterns",
457
+ action="append",
458
+ metavar="GLOB",
459
+ help="Exclude files matching glob, e.g. '*.test.ts' (repeatable)",
460
+ )
461
+ parser.add_argument(
462
+ "--stdout",
463
+ action="store_true",
464
+ help="Write the collection to stdout instead of a file (progress goes to stderr)",
465
+ )
466
+ parser.add_argument(
467
+ "--dry-run",
468
+ action="store_true",
469
+ help="Preview collection without writing output",
470
+ )
471
+ parser.add_argument(
472
+ "--force",
473
+ action="store_true",
474
+ help="Overwrite existing output files instead of auto-incrementing",
475
+ )
476
+ parser.add_argument("--config", type=str, help="Configuration file path")
477
+ parser.add_argument("--init-config", action="store_true", help="Initialize default config")
478
+ parser.add_argument(
479
+ "--uninstall",
480
+ action="store_true",
481
+ help="Uninstall codecollector (via pipx and/or pip)",
482
+ )
483
+ parser.add_argument(
484
+ "--purge-config",
485
+ action="store_true",
486
+ help="Also remove ~/.config/codecollector (use with --uninstall)",
487
+ )
488
+ parser.add_argument("-v", "--version", action="version", version=f"CodeCollector v{__version__}")
489
+
490
+ args = parser.parse_args()
491
+
492
+ if args.uninstall:
493
+ ok, messages = uninstall_package(purge_config=args.purge_config)
494
+ for msg in messages:
495
+ console.print(msg)
496
+ sys.exit(0 if ok else 1)
497
+
498
+ if args.purge_config and not args.uninstall:
499
+ err_console.print("[error]--purge-config must be used with --uninstall[/error]")
500
+ sys.exit(2)
501
+
502
+ if args.i and args.no_gitignore:
503
+ err_console.print("[error]-i and --no-gitignore are mutually exclusive[/error]")
504
+ sys.exit(2)
505
+
506
+ if args.stdout:
507
+ conflicts = [
508
+ flag
509
+ for flag, given in (
510
+ ("-o/--output", args.output),
511
+ ("-d/--output-dir", args.output_dir),
512
+ ("--max-output-size", args.max_output_size is not None),
513
+ ("--force", args.force),
514
+ )
515
+ if given
516
+ ]
517
+ if conflicts:
518
+ err_console.print(
519
+ f"[error]--stdout cannot be combined with: {', '.join(conflicts)}[/error]"
520
+ )
521
+ sys.exit(2)
522
+ if not args.path:
523
+ err_console.print(
524
+ "[error]--stdout requires an explicit PATH "
525
+ "(interactive picker would pollute stdout)[/error]"
526
+ )
527
+ sys.exit(2)
528
+
529
+ ensure_config_dir()
530
+
531
+ if args.init_config:
532
+ config = CollectorConfig(root_path=Path.cwd())
533
+ config.save_to_file(str(CONFIG_FILE))
534
+ console.print(f"[success]Config created:[/success] [path]{CONFIG_FILE}[/path]")
535
+ return
536
+
537
+ try:
538
+ app = CodeCollectorApp()
539
+
540
+ if not args.path and not args.config:
541
+ respect = True if args.i else (False if args.no_gitignore else None)
542
+ app.run_interactive(filter_mode=respect)
543
+ else:
544
+ app.run_with_path(
545
+ path_str=args.path or ".",
546
+ recursive=not args.non_recursive,
547
+ output_file=args.output or "code_collection.md",
548
+ max_output_size_mb=args.max_output_size,
549
+ output_dir=args.output_dir,
550
+ config_file=args.config,
551
+ exclude_dirs=args.exclude,
552
+ respect_gitignore=True if args.i else (False if args.no_gitignore else None),
553
+ include_patterns=args.include,
554
+ exclude_patterns=args.exclude_patterns,
555
+ dry_run=args.dry_run,
556
+ force=args.force,
557
+ stdout_mode=args.stdout,
558
+ )
559
+
560
+ except KeyboardInterrupt:
561
+ err_console.print("\n\n[yellow]Cancelled[/yellow]")
562
+ except Exception as e:
563
+ err_console.print(f"\n[error]Error: {e}[/error]")
564
+ sys.exit(1)
565
+
566
+
567
+ if __name__ == "__main__":
568
+ main()