contextzip 0.1.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.
contextzip/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """contextzip — intelligent codebase packager for AI tools."""
2
+
3
+ __version__ = "0.1.0"
contextzip/cli.py ADDED
@@ -0,0 +1,351 @@
1
+ """
2
+ cli.py — contextzip entry point. Phases 1–5 complete.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ from pathlib import Path
9
+
10
+ import click
11
+ from rich.console import Console
12
+ from rich.panel import Panel
13
+ from rich.table import Table
14
+ from rich import box
15
+
16
+ from contextzip import __version__
17
+ from contextzip.detector import detect
18
+ from contextzip.filters import build_spec, resolve_files, summarise_exclusions, LARGE_FILE_WARN_BYTES
19
+ from contextzip.packager import create_zip
20
+ from contextzip.clipboard import handle as clipboard_handle, Tier
21
+
22
+ console = Console()
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # CLI
27
+ # ---------------------------------------------------------------------------
28
+
29
+ @click.command(context_settings={"help_option_names": ["-h", "--help"]})
30
+ @click.option(
31
+ "--include", "-i",
32
+ multiple=True, metavar="PATH",
33
+ help="Only include files under these paths (relative to project root). "
34
+ "Repeatable: --include src --include app",
35
+ )
36
+ @click.option(
37
+ "--exclude", "-e",
38
+ multiple=True, metavar="PATTERN",
39
+ help="Extra exclusion patterns on top of auto-rules (gitignore syntax). "
40
+ "Repeatable: --exclude '*.log' --exclude temp.js",
41
+ )
42
+ @click.option(
43
+ "--dry-run", "-n",
44
+ is_flag=True, default=False,
45
+ help="Show what would be included without creating the ZIP.",
46
+ )
47
+ @click.option(
48
+ "--output", "-o",
49
+ default=None, metavar="FILE",
50
+ help="Output ZIP path. Defaults to <project>_context_<timestamp>.zip in temp dir.",
51
+ )
52
+ @click.option(
53
+ "--no-clipboard",
54
+ is_flag=True, default=False,
55
+ help="Skip clipboard / folder-open step after creating the ZIP.",
56
+ )
57
+ @click.option(
58
+ "--no-gitignore",
59
+ is_flag=True, default=False,
60
+ help="Ignore the project's .gitignore file (use only built-in rules).",
61
+ )
62
+ @click.option(
63
+ "--verbose", "-v",
64
+ is_flag=True, default=False,
65
+ help="Show every included and excluded file.",
66
+ )
67
+ @click.version_option(version=__version__, prog_name="contextzip")
68
+ def main(
69
+ include: tuple[str, ...],
70
+ exclude: tuple[str, ...],
71
+ dry_run: bool,
72
+ output: str | None,
73
+ no_clipboard: bool,
74
+ no_gitignore: bool,
75
+ verbose: bool,
76
+ ) -> None:
77
+ """
78
+ \b
79
+ contextzip — package your codebase for AI tools.
80
+
81
+ Run from your project root to produce a smart, lightweight ZIP
82
+ ready to paste directly into Claude, ChatGPT, or any AI interface.
83
+ """
84
+
85
+ project_dir = Path(os.getcwd()).resolve()
86
+
87
+ # ── Header ───────────────────────────────────────────────────────────────
88
+ console.print()
89
+ console.print(
90
+ Panel.fit(
91
+ f"[bold cyan]contextzip[/] [dim]v{__version__}[/]\n"
92
+ f"[dim]Project:[/] [white]{project_dir}[/]",
93
+ border_style="cyan", padding=(0, 2),
94
+ )
95
+ )
96
+ console.print()
97
+
98
+ # ── Detection ────────────────────────────────────────────────────────────
99
+ with console.status("[cyan]Detecting project ecosystem…[/]", spinner="dots"):
100
+ detection = detect(project_dir)
101
+
102
+ _print_detection(detection)
103
+
104
+ # ── Build exclusion spec ─────────────────────────────────────────────────
105
+ gitignore_path = None if no_gitignore else (project_dir / ".gitignore")
106
+ used_gitignore = (
107
+ not no_gitignore
108
+ and gitignore_path is not None
109
+ and gitignore_path.is_file()
110
+ )
111
+
112
+ with console.status("[cyan]Building exclusion rules…[/]", spinner="dots"):
113
+ spec = build_spec(
114
+ rule_modules=detection.rule_modules,
115
+ extra_exclude=list(exclude) if exclude else None,
116
+ gitignore_path=gitignore_path,
117
+ )
118
+
119
+ if used_gitignore:
120
+ console.print(f" [dim]↳ .gitignore patterns applied[/]")
121
+ console.print()
122
+
123
+ # ── Resolve files ─────────────────────────────────────────────────────────
124
+ with console.status("[cyan]Scanning project files…[/]", spinner="dots"):
125
+ resolved = resolve_files(
126
+ project_dir=project_dir,
127
+ spec=spec,
128
+ include_only=list(include) if include else None,
129
+ )
130
+
131
+ # ── File scan summary ────────────────────────────────────────────────────
132
+ _print_scan_summary(resolved, project_dir, verbose)
133
+
134
+ # ── Warnings: large files ────────────────────────────────────────────────
135
+ if resolved.large_files:
136
+ console.print()
137
+ console.print(
138
+ f" [yellow]⚠[/] [bold]{len(resolved.large_files)} large file"
139
+ f"{'s' if len(resolved.large_files) != 1 else ''}[/] "
140
+ f"[dim](≥ {_human_size(LARGE_FILE_WARN_BYTES)}) will be included:[/]"
141
+ )
142
+ for p, size in resolved.large_files[:5]:
143
+ rel = p.relative_to(project_dir).as_posix()
144
+ console.print(f" [yellow]·[/] [dim]{rel}[/] [yellow]{_human_size(size)}[/]")
145
+ if len(resolved.large_files) > 5:
146
+ console.print(f" [dim]… and {len(resolved.large_files) - 5} more[/]")
147
+ console.print(
148
+ f" [dim] Use --exclude to drop them if unneeded.[/]"
149
+ )
150
+
151
+ # ── Warnings: binary files ───────────────────────────────────────────────
152
+ if resolved.binary_files:
153
+ console.print()
154
+ console.print(
155
+ f" [yellow]⚠[/] [bold]{len(resolved.binary_files)} binary file"
156
+ f"{'s' if len(resolved.binary_files) != 1 else ''}[/] "
157
+ f"[dim]detected — AI tools may not read them:[/]"
158
+ )
159
+ for p in resolved.binary_files[:3]:
160
+ rel = p.relative_to(project_dir).as_posix()
161
+ console.print(f" [yellow]·[/] [dim]{rel}[/]")
162
+ if len(resolved.binary_files) > 3:
163
+ console.print(f" [dim]… and {len(resolved.binary_files) - 3} more[/]")
164
+
165
+ # ── Warnings: skipped files (symlinks, unreadable) ───────────────────────
166
+ if resolved.skipped:
167
+ console.print()
168
+ console.print(
169
+ f" [red]⚠[/] [bold]{len(resolved.skipped)} file"
170
+ f"{'s' if len(resolved.skipped) != 1 else ''}[/] "
171
+ f"[dim]skipped (unreadable or dangling symlink):[/]"
172
+ )
173
+ for p, reason in resolved.skipped[:3]:
174
+ console.print(f" [red]·[/] [dim]{p.name}[/] — {reason}")
175
+
176
+ # ── Dry run ──────────────────────────────────────────────────────────────
177
+ if dry_run:
178
+ console.print()
179
+ console.print(
180
+ Panel.fit(
181
+ "[yellow]Dry run — no ZIP created.[/]\n"
182
+ "[dim]Remove --dry-run to produce the archive.[/]",
183
+ border_style="yellow", padding=(0, 2),
184
+ )
185
+ )
186
+ return
187
+
188
+ if not resolved.included:
189
+ console.print(
190
+ "\n[red]Nothing to package.[/] All files were excluded — "
191
+ "try [cyan]--include[/] to override."
192
+ )
193
+ return
194
+
195
+ # ── Create ZIP ───────────────────────────────────────────────────────────
196
+ console.print()
197
+ output_path = Path(output).resolve() if output else None
198
+
199
+ try:
200
+ result = create_zip(
201
+ resolve_result=resolved,
202
+ project_dir=project_dir,
203
+ output_path=output_path,
204
+ console=console,
205
+ )
206
+ except Exception as exc:
207
+ console.print(f"\n[red]Failed to create ZIP:[/] {exc}")
208
+ raise SystemExit(1)
209
+
210
+ _print_package_result(result)
211
+
212
+ # ── Skipped during ZIP write ─────────────────────────────────────────────
213
+ if result.skipped_in_zip:
214
+ console.print()
215
+ console.print(
216
+ f" [red]⚠[/] [bold]{len(result.skipped_in_zip)} file"
217
+ f"{'s' if len(result.skipped_in_zip) != 1 else ''}[/] "
218
+ f"[dim]could not be written to ZIP:[/]"
219
+ )
220
+ for p, reason in result.skipped_in_zip[:3]:
221
+ console.print(f" [red]·[/] [dim]{p.name}[/] — {reason}")
222
+
223
+ # ── Clipboard ────────────────────────────────────────────────────────────
224
+ if not no_clipboard:
225
+ console.print()
226
+ with console.status("[cyan]Preparing clipboard…[/]", spinner="dots"):
227
+ cb = clipboard_handle(result.zip_path)
228
+ _print_clipboard_result(cb)
229
+
230
+
231
+ # ---------------------------------------------------------------------------
232
+ # Display helpers
233
+ # ---------------------------------------------------------------------------
234
+
235
+ def _print_detection(detection) -> None:
236
+ if detection.is_unknown:
237
+ ecosystem_line = "[yellow]Unknown[/] — applying base rules only"
238
+ else:
239
+ colours = {
240
+ "Next.js": "bright_blue", "Node.js": "green",
241
+ "Python": "yellow", "Django": "green",
242
+ "FastAPI": "cyan", "Rust": "red",
243
+ "Go": "cyan", "Ruby": "red",
244
+ }
245
+ parts = [
246
+ f"[{colours.get(n, 'white')}]{n}[/]"
247
+ for n in detection.ecosystems
248
+ ]
249
+ ecosystem_line = " [dim]+[/] ".join(parts)
250
+
251
+ conf_colour = {"high": "green", "medium": "yellow", "low": "dim"}.get(
252
+ detection.confidence, "dim"
253
+ )
254
+ console.print(
255
+ Panel(
256
+ f" [dim]Ecosystem :[/] {ecosystem_line}\n"
257
+ f" [dim]Confidence:[/] [{conf_colour}]{detection.confidence}[/]\n"
258
+ f" [dim]Rules :[/] [dim]{', '.join(detection.rule_modules)}[/]",
259
+ title="[bold]Detection[/]",
260
+ border_style="blue", padding=(0, 1),
261
+ )
262
+ )
263
+ console.print()
264
+
265
+
266
+ def _print_scan_summary(resolved, project_dir: Path, verbose: bool) -> None:
267
+ total = len(resolved.included) + len(resolved.excluded)
268
+ included_size = sum(p.stat().st_size for p in resolved.included if p.exists())
269
+
270
+ table = Table(box=box.ROUNDED, show_header=False, padding=(0, 2))
271
+ table.add_column(style="dim")
272
+ table.add_column()
273
+ table.add_row("Files scanned", str(total + len(resolved.skipped)))
274
+ table.add_row(
275
+ "To be included",
276
+ f"[green]{len(resolved.included)}[/] [dim]({_human_size(included_size)})[/]",
277
+ )
278
+ table.add_row("Excluded", f"[red]{len(resolved.excluded)}[/]")
279
+ if resolved.skipped:
280
+ table.add_row("Skipped", f"[yellow]{len(resolved.skipped)}[/]")
281
+ console.print(table)
282
+
283
+ if verbose and resolved.included:
284
+ console.print()
285
+ console.print("[bold]Included files:[/]")
286
+ for p in resolved.included:
287
+ size_str = _human_size(p.stat().st_size) if p.exists() else "?"
288
+ rel = p.relative_to(project_dir).as_posix()
289
+ console.print(f" [green]✓[/] {rel} [dim]{size_str}[/]")
290
+
291
+ if resolved.excluded:
292
+ console.print()
293
+ buckets = summarise_exclusions(resolved.excluded, project_dir)
294
+ console.print("[bold]Top excluded directories / files:[/]")
295
+ for label, count in list(buckets.items())[:8]:
296
+ console.print(
297
+ f" [red]✗[/] [dim]{label}[/] "
298
+ f"[dim]({count} file{'s' if count != 1 else ''})[/]"
299
+ )
300
+
301
+
302
+ def _print_package_result(result) -> None:
303
+ ratio_colour = "green" if result.compression_ratio >= 0.3 else "yellow"
304
+ size_detail = (
305
+ "[dim](ZIP overhead on tiny project)[/]"
306
+ if result.grew
307
+ else f"[{ratio_colour}](↓ {result.compression_pct} smaller)[/]"
308
+ )
309
+
310
+ table = Table(box=box.ROUNDED, show_header=False, padding=(0, 2))
311
+ table.add_column(style="dim", min_width=18)
312
+ table.add_column()
313
+ table.add_row("Files packed", f"[green]{result.file_count}[/]")
314
+ table.add_row("Original size", _human_size(result.uncompressed_bytes))
315
+ table.add_row(
316
+ "Compressed size",
317
+ f"[bold]{_human_size(result.compressed_bytes)}[/] {size_detail}",
318
+ )
319
+ table.add_row("Saved to", f"[cyan]{result.zip_path}[/]")
320
+
321
+ console.print(
322
+ Panel(
323
+ table,
324
+ title="[bold green]✓ ZIP created[/]",
325
+ border_style="green", padding=(0, 1),
326
+ )
327
+ )
328
+
329
+
330
+ def _print_clipboard_result(cb) -> None:
331
+ tier_style = {
332
+ Tier.FILE_ON_CLIPBOARD: ("green", "✓ Ready to paste"),
333
+ Tier.FOLDER_OPENED: ("yellow", "✓ Folder opened"),
334
+ Tier.PATH_ONLY: ("dim", "↳ Manual copy needed"),
335
+ }
336
+ border, title = tier_style.get(cb.tier, ("dim", "Clipboard"))
337
+ console.print(
338
+ Panel.fit(
339
+ cb.message,
340
+ title=f"[bold {border}]{title}[/]",
341
+ border_style=border, padding=(0, 2),
342
+ )
343
+ )
344
+
345
+
346
+ def _human_size(n: int) -> str:
347
+ for unit in ("B", "KB", "MB", "GB"):
348
+ if n < 1024:
349
+ return f"{n:.0f} {unit}"
350
+ n /= 1024
351
+ return f"{n:.1f} TB"
@@ -0,0 +1,228 @@
1
+ """
2
+ clipboard.py — Tiered clipboard strategy for copying a ZIP file.
3
+
4
+ Tier 1 — Copy the actual file object to clipboard (paste directly into
5
+ browser upload zones like Claude, ChatGPT, etc.)
6
+ macOS : osascript + Finder NSPasteboard trick
7
+ Linux : xclip (if installed)
8
+ Windows: not possible via CLI — skip to Tier 2
9
+
10
+ Tier 2 — Open the containing folder with the file highlighted/selected,
11
+ so the user can copy it themselves with one Ctrl+C.
12
+ macOS : open -R <file>
13
+ Linux : xdg-open <folder> (can't pre-select a file on Linux)
14
+ Windows: explorer /select,"<file>" ← selects the file in Explorer
15
+
16
+ Tier 3 — Print the path clearly and tell the user to copy it manually.
17
+ Always available, never fails.
18
+
19
+ The module returns a ClipboardResult describing which tier succeeded.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import platform
25
+ import shutil
26
+ import subprocess
27
+ from dataclasses import dataclass
28
+ from enum import Enum
29
+ from pathlib import Path
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Result model
34
+ # ---------------------------------------------------------------------------
35
+
36
+ class Tier(Enum):
37
+ FILE_ON_CLIPBOARD = 1 # actual file object on clipboard — paste into browser
38
+ FOLDER_OPENED = 2 # folder opened / file highlighted in file manager
39
+ PATH_ONLY = 3 # nothing automatic; path printed for manual copy
40
+
41
+
42
+ @dataclass
43
+ class ClipboardResult:
44
+ tier: Tier
45
+ message: str # human-readable outcome line
46
+ success: bool = True # False only on unexpected errors worth surfacing
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Public API
51
+ # ---------------------------------------------------------------------------
52
+
53
+ def handle(zip_path: Path) -> ClipboardResult:
54
+ """
55
+ Attempt to put *zip_path* on the clipboard using the best available tier.
56
+ Always returns a :class:`ClipboardResult` — never raises.
57
+ """
58
+ system = platform.system()
59
+
60
+ # ── Tier 1: real file-object on clipboard ───────────────────────────────
61
+ if system == "Darwin":
62
+ result = _tier1_macos(zip_path)
63
+ if result:
64
+ return result
65
+
66
+ elif system == "Linux":
67
+ result = _tier1_linux(zip_path)
68
+ if result:
69
+ return result
70
+
71
+ # Windows Tier 1: not possible — fall straight through to Tier 2
72
+
73
+ # ── Tier 2: open folder with file selected ──────────────────────────────
74
+ if system == "Darwin":
75
+ result = _tier2_macos(zip_path)
76
+ elif system == "Linux":
77
+ result = _tier2_linux(zip_path)
78
+ else:
79
+ result = _tier2_windows(zip_path)
80
+
81
+ if result:
82
+ return result
83
+
84
+ # ── Tier 3: path only ───────────────────────────────────────────────────
85
+ return _tier3(zip_path)
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Tier 1 — file object on clipboard
90
+ # ---------------------------------------------------------------------------
91
+
92
+ def _tier1_macos(zip_path: Path) -> ClipboardResult | None:
93
+ """
94
+ Use osascript to ask Finder to copy the file to the clipboard.
95
+ This puts a real file object on the NSPasteboard — identical to
96
+ selecting the file in Finder and pressing Cmd+C.
97
+ Browsers read this as a File object on paste.
98
+ """
99
+ script = (
100
+ f'tell application "Finder" to set the clipboard to '
101
+ f'(POSIX file "{zip_path.as_posix()}")'
102
+ )
103
+ try:
104
+ proc = subprocess.run(
105
+ ["osascript", "-e", script],
106
+ capture_output=True, text=True, timeout=8,
107
+ )
108
+ if proc.returncode == 0:
109
+ return ClipboardResult(
110
+ tier=Tier.FILE_ON_CLIPBOARD,
111
+ message="📋 ZIP copied to clipboard — just paste into Claude / ChatGPT!",
112
+ )
113
+ except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
114
+ pass
115
+ return None
116
+
117
+
118
+ def _tier1_linux(zip_path: Path) -> ClipboardResult | None:
119
+ """
120
+ Use xclip to write the file bytes with MIME type application/zip.
121
+ Works in most GTK/Qt browser upload dialogs when pasting.
122
+ Requires xclip to be installed.
123
+ """
124
+ if not shutil.which("xclip"):
125
+ return None
126
+ try:
127
+ with zip_path.open("rb") as fh:
128
+ proc = subprocess.run(
129
+ ["xclip", "-selection", "clipboard", "-t", "application/zip", "-i"],
130
+ stdin=fh,
131
+ capture_output=True,
132
+ timeout=10,
133
+ )
134
+ if proc.returncode == 0:
135
+ return ClipboardResult(
136
+ tier=Tier.FILE_ON_CLIPBOARD,
137
+ message="📋 ZIP copied to clipboard — paste into your AI tool!",
138
+ )
139
+ except (FileNotFoundError, subprocess.TimeoutExpired, OSError, PermissionError):
140
+ pass
141
+ return None
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # Tier 2 — open folder / highlight file
146
+ # ---------------------------------------------------------------------------
147
+
148
+ def _tier2_macos(zip_path: Path) -> ClipboardResult | None:
149
+ """open -R reveals and selects the file in Finder."""
150
+ try:
151
+ proc = subprocess.run(
152
+ ["open", "-R", str(zip_path)],
153
+ capture_output=True, timeout=6,
154
+ )
155
+ if proc.returncode == 0:
156
+ return ClipboardResult(
157
+ tier=Tier.FOLDER_OPENED,
158
+ message=(
159
+ "📂 Opened Finder with your ZIP selected.\n"
160
+ " Press [bold]Cmd+C[/] then paste into your AI tool."
161
+ ),
162
+ )
163
+ except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
164
+ pass
165
+ return None
166
+
167
+
168
+ def _tier2_linux(zip_path: Path) -> ClipboardResult | None:
169
+ """xdg-open opens the parent folder (can't pre-select on Linux)."""
170
+ if not shutil.which("xdg-open"):
171
+ return None
172
+ try:
173
+ subprocess.Popen(
174
+ ["xdg-open", str(zip_path.parent)],
175
+ stdout=subprocess.DEVNULL,
176
+ stderr=subprocess.DEVNULL,
177
+ )
178
+ return ClipboardResult(
179
+ tier=Tier.FOLDER_OPENED,
180
+ message=(
181
+ f"📂 Opened folder containing your ZIP.\n"
182
+ f" File: [cyan]{zip_path.name}[/]"
183
+ ),
184
+ )
185
+ except (FileNotFoundError, OSError):
186
+ pass
187
+ return None
188
+
189
+
190
+ def _tier2_windows(zip_path: Path) -> ClipboardResult | None:
191
+ """
192
+ explorer /select,"<path>" opens Explorer with the file highlighted.
193
+ The user can then press Ctrl+C and paste it directly into a browser.
194
+ """
195
+ try:
196
+ # Use the Windows path format with backslashes
197
+ win_path = str(zip_path).replace("/", "\\")
198
+ proc = subprocess.run(
199
+ ["explorer", f"/select,{win_path}"],
200
+ # explorer always exits 1 even on success — don't check returncode
201
+ capture_output=True,
202
+ timeout=8,
203
+ )
204
+ # explorer.exe opens asynchronously; a quick return (any code) means it launched
205
+ return ClipboardResult(
206
+ tier=Tier.FOLDER_OPENED,
207
+ message=(
208
+ "📂 Opened Explorer with your ZIP selected.\n"
209
+ " Press [bold]Ctrl+C[/] then paste into Claude / ChatGPT!"
210
+ ),
211
+ )
212
+ except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
213
+ pass
214
+ return None
215
+
216
+
217
+ # ---------------------------------------------------------------------------
218
+ # Tier 3 — path only (always succeeds)
219
+ # ---------------------------------------------------------------------------
220
+
221
+ def _tier3(zip_path: Path) -> ClipboardResult:
222
+ return ClipboardResult(
223
+ tier=Tier.PATH_ONLY,
224
+ message=(
225
+ f"📄 Copy this path and open it manually:\n"
226
+ f" [cyan]{zip_path}[/]"
227
+ ),
228
+ )