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 +3 -0
- contextzip/cli.py +351 -0
- contextzip/clipboard.py +228 -0
- contextzip/detector.py +218 -0
- contextzip/filters.py +225 -0
- contextzip/packager.py +152 -0
- contextzip/rules/__init__.py +0 -0
- contextzip/rules/base.py +67 -0
- contextzip/rules/go.py +10 -0
- contextzip/rules/node.py +29 -0
- contextzip/rules/python.py +36 -0
- contextzip/rules/rust.py +10 -0
- contextzip-0.1.0.dist-info/METADATA +331 -0
- contextzip-0.1.0.dist-info/RECORD +18 -0
- contextzip-0.1.0.dist-info/WHEEL +5 -0
- contextzip-0.1.0.dist-info/entry_points.txt +2 -0
- contextzip-0.1.0.dist-info/licenses/LICENSE +21 -0
- contextzip-0.1.0.dist-info/top_level.txt +1 -0
contextzip/__init__.py
ADDED
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"
|
contextzip/clipboard.py
ADDED
|
@@ -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
|
+
)
|