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.
- codecollector/__init__.py +10 -0
- codecollector/__main__.py +6 -0
- codecollector/cli.py +568 -0
- codecollector/collector.py +235 -0
- codecollector/config.py +151 -0
- codecollector/formatter.py +197 -0
- codecollector/gitignore.py +160 -0
- codecollector/output.py +61 -0
- codecollector/selector.py +115 -0
- codecollector/splitter.py +142 -0
- codecollector/tokens.py +56 -0
- codecollector/uninstall.py +118 -0
- onepaste-1.3.0.dist-info/METADATA +241 -0
- onepaste-1.3.0.dist-info/RECORD +17 -0
- onepaste-1.3.0.dist-info/WHEEL +4 -0
- onepaste-1.3.0.dist-info/entry_points.txt +2 -0
- onepaste-1.3.0.dist-info/licenses/LICENSE +22 -0
|
@@ -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__"]
|
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()
|