contextzip 0.3.6__tar.gz → 0.3.7__tar.gz

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.
Files changed (45) hide show
  1. {contextzip-0.3.6 → contextzip-0.3.7}/PKG-INFO +34 -2
  2. {contextzip-0.3.6 → contextzip-0.3.7}/README.md +33 -1
  3. {contextzip-0.3.6 → contextzip-0.3.7}/contextzip/__init__.py +1 -1
  4. {contextzip-0.3.6 → contextzip-0.3.7}/contextzip/cli.py +95 -1
  5. {contextzip-0.3.6 → contextzip-0.3.7}/contextzip/config.py +30 -0
  6. {contextzip-0.3.6 → contextzip-0.3.7}/contextzip/filters.py +145 -2
  7. contextzip-0.3.7/contextzip/webui/__init__.py +9 -0
  8. contextzip-0.3.7/contextzip/webui/assets.py +1012 -0
  9. contextzip-0.3.7/contextzip/webui/persist.py +59 -0
  10. contextzip-0.3.7/contextzip/webui/server.py +426 -0
  11. contextzip-0.3.7/contextzip/webui/suggestions.py +136 -0
  12. {contextzip-0.3.6 → contextzip-0.3.7}/contextzip.egg-info/PKG-INFO +34 -2
  13. {contextzip-0.3.6 → contextzip-0.3.7}/contextzip.egg-info/SOURCES.txt +6 -1
  14. {contextzip-0.3.6 → contextzip-0.3.7}/pyproject.toml +1 -1
  15. {contextzip-0.3.6 → contextzip-0.3.7}/LICENSE +0 -0
  16. {contextzip-0.3.6 → contextzip-0.3.7}/contextzip/ai/__init__.py +0 -0
  17. {contextzip-0.3.6 → contextzip-0.3.7}/contextzip/ai/gemini.py +0 -0
  18. {contextzip-0.3.6 → contextzip-0.3.7}/contextzip/ai/heuristic.py +0 -0
  19. {contextzip-0.3.6 → contextzip-0.3.7}/contextzip/ai/selector.py +0 -0
  20. {contextzip-0.3.6 → contextzip-0.3.7}/contextzip/api.py +0 -0
  21. {contextzip-0.3.6 → contextzip-0.3.7}/contextzip/cli_ai.py +0 -0
  22. {contextzip-0.3.6 → contextzip-0.3.7}/contextzip/cli_display.py +0 -0
  23. {contextzip-0.3.6 → contextzip-0.3.7}/contextzip/cli_onboard.py +0 -0
  24. {contextzip-0.3.6 → contextzip-0.3.7}/contextzip/clipboard.py +0 -0
  25. {contextzip-0.3.6 → contextzip-0.3.7}/contextzip/detector.py +0 -0
  26. {contextzip-0.3.6 → contextzip-0.3.7}/contextzip/error_parser.py +0 -0
  27. {contextzip-0.3.6 → contextzip-0.3.7}/contextzip/git.py +0 -0
  28. {contextzip-0.3.6 → contextzip-0.3.7}/contextzip/packager.py +0 -0
  29. {contextzip-0.3.6 → contextzip-0.3.7}/contextzip/project_config.py +0 -0
  30. {contextzip-0.3.6 → contextzip-0.3.7}/contextzip/rules/__init__.py +0 -0
  31. {contextzip-0.3.6 → contextzip-0.3.7}/contextzip/rules/base.py +0 -0
  32. {contextzip-0.3.6 → contextzip-0.3.7}/contextzip/rules/errors/__init__.py +0 -0
  33. {contextzip-0.3.6 → contextzip-0.3.7}/contextzip/rules/errors/node.py +0 -0
  34. {contextzip-0.3.6 → contextzip-0.3.7}/contextzip/rules/errors/python.py +0 -0
  35. {contextzip-0.3.6 → contextzip-0.3.7}/contextzip/rules/go.py +0 -0
  36. {contextzip-0.3.6 → contextzip-0.3.7}/contextzip/rules/node.py +0 -0
  37. {contextzip-0.3.6 → contextzip-0.3.7}/contextzip/rules/python.py +0 -0
  38. {contextzip-0.3.6 → contextzip-0.3.7}/contextzip/rules/ruby.py +0 -0
  39. {contextzip-0.3.6 → contextzip-0.3.7}/contextzip/rules/rust.py +0 -0
  40. {contextzip-0.3.6 → contextzip-0.3.7}/contextzip/watcher.py +0 -0
  41. {contextzip-0.3.6 → contextzip-0.3.7}/contextzip.egg-info/dependency_links.txt +0 -0
  42. {contextzip-0.3.6 → contextzip-0.3.7}/contextzip.egg-info/entry_points.txt +0 -0
  43. {contextzip-0.3.6 → contextzip-0.3.7}/contextzip.egg-info/requires.txt +0 -0
  44. {contextzip-0.3.6 → contextzip-0.3.7}/contextzip.egg-info/top_level.txt +0 -0
  45. {contextzip-0.3.6 → contextzip-0.3.7}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: contextzip
3
- Version: 0.3.6
3
+ Version: 0.3.7
4
4
  Summary: Intelligently package your codebase for AI tools
5
5
  Author-email: Deepesh <akadeepesh@gmail.com>
6
6
  License-Expression: MIT
@@ -53,6 +53,7 @@ contextzip eliminates that entirely. Run it from your project root — it detect
53
53
  - **AI-powered file selection** — describe your task in plain English with `--prompt` and Gemini selects the minimum relevant files automatically, no manual hunting required
54
54
  - **Terminal error watcher** — wrap any dev server with `contextzip watch` to auto-detect errors and package a ready-to-upload debug context in one keypress
55
55
  - **Configurable workspace location** — `.contextzip/` lives at the git root by default, but you can pin it elsewhere per-machine (`contextzip config --set-workspace`) or for the whole team via a committed `.contextzip/config.json`
56
+ - **Visual config UI** — `contextzip config --ui` opens a local browser tab to set include/exclude by clicking through your actual file tree, with live counts and one-click suggestions for PDFs, media, and other non-code files — nothing leaves your machine
56
57
  - **Persistent workspace** — all generated ZIPs land in `.contextzip/output/`, discoverable, reusable, and git-ignored automatically
57
58
  - **Warns before it's a problem** — flags large (≥ 1 MB) and binary files that AI tools can't read, before you waste an upload
58
59
  - **Handles edge cases** — dangling symlinks, unreadable files, and paths outside the project tree are caught and reported, never silently dropped
@@ -342,7 +343,7 @@ All keys are optional — start with just the ones you need.
342
343
 
343
344
  `always_include`/`always_exclude` are additive on top of `--include`/`--exclude` for that run — an explicit `contextzip include PATH` still has the final say over what's actually packaged. Persistent preferences belong in `config.json` rather than an ever-growing list of CLI flags; CLI flags stay for one-off, explicit actions (`--dry-run`, `--prompt "…"`, `--output FILE`, etc.).
344
345
 
345
- A **web-based configuration UI** that generates `config.json` for you is on the roadmap — the schema above is designed to grow additively, so future preferences (including ones set visually) won't require another migration.
346
+ You don't have to hand-write `config.json` — see [Visual config UI](#visual-config-ui) below for a point-and-click way to produce it.
346
347
 
347
348
  ### Deprecation: `.contextzip.json`
348
349
 
@@ -352,6 +353,37 @@ Earlier versions of contextzip read a `.contextzip.json` file at the project roo
352
353
 
353
354
  ---
354
355
 
356
+ ## Visual config UI
357
+
358
+ ```
359
+ contextzip config --ui
360
+ ```
361
+
362
+ Opens a local browser tab for setting `always_include`/`always_exclude` by clicking through your actual file tree instead of hand-writing patterns — live file counts and packed size update as you go, and one-click chips suggest excluding things like PDFs, office docs, fonts, media, or anything over 1MB that isn't already covered by contextzip's default rules.
363
+
364
+ It's also offered automatically the **first time** you run `contextzip` in a project that has no config at all:
365
+
366
+ ```
367
+ $ contextzip
368
+ ╭─────────────────────────╮
369
+ │ contextzip v0.3.5 │
370
+ ╰─────────────────────────╯
371
+ ...
372
+ ╭─ First run ─────────────────────────────────────╮
373
+ │ No project config found yet. │
374
+ │ contextzip can open a local browser tab... │
375
+ ╰───────────────────────────────────────────────────╯
376
+ Set up include/exclude visually now? [Y/n]:
377
+ ```
378
+
379
+ Decline once and it won't ask again (run `contextzip config --ui` any time you want it). It's also skipped automatically for non-interactive runs, when `CI` is set, or when `--prompt`/`--output` are passed — it only ever offers on a plain, interactive, first-ever run.
380
+
381
+ **Nothing about your project leaves your machine.** The server binds to `127.0.0.1` only (never `0.0.0.0`), every request needs a random per-session token embedded in the URL (the same approach Jupyter Notebook uses), and the page itself makes no calls anywhere except back to that local server — no CDN scripts, no web fonts, no analytics. Saving writes straight to `.contextzip/config.json` on disk and the server shuts itself down; if you close the tab without saving, it also shuts down after a short idle period so a forgotten session doesn't linger as an open port.
382
+
383
+ If you accept the first-run offer, the same `contextzip` invocation picks up whatever you saved and finishes packaging immediately — no need to run it again.
384
+
385
+ ---
386
+
355
387
  ## Contributing
356
388
 
357
389
  Contributions are welcome — especially new framework rule sets, edge case fixes, and platform-specific clipboard improvements.
@@ -24,6 +24,7 @@ contextzip eliminates that entirely. Run it from your project root — it detect
24
24
  - **AI-powered file selection** — describe your task in plain English with `--prompt` and Gemini selects the minimum relevant files automatically, no manual hunting required
25
25
  - **Terminal error watcher** — wrap any dev server with `contextzip watch` to auto-detect errors and package a ready-to-upload debug context in one keypress
26
26
  - **Configurable workspace location** — `.contextzip/` lives at the git root by default, but you can pin it elsewhere per-machine (`contextzip config --set-workspace`) or for the whole team via a committed `.contextzip/config.json`
27
+ - **Visual config UI** — `contextzip config --ui` opens a local browser tab to set include/exclude by clicking through your actual file tree, with live counts and one-click suggestions for PDFs, media, and other non-code files — nothing leaves your machine
27
28
  - **Persistent workspace** — all generated ZIPs land in `.contextzip/output/`, discoverable, reusable, and git-ignored automatically
28
29
  - **Warns before it's a problem** — flags large (≥ 1 MB) and binary files that AI tools can't read, before you waste an upload
29
30
  - **Handles edge cases** — dangling symlinks, unreadable files, and paths outside the project tree are caught and reported, never silently dropped
@@ -313,7 +314,7 @@ All keys are optional — start with just the ones you need.
313
314
 
314
315
  `always_include`/`always_exclude` are additive on top of `--include`/`--exclude` for that run — an explicit `contextzip include PATH` still has the final say over what's actually packaged. Persistent preferences belong in `config.json` rather than an ever-growing list of CLI flags; CLI flags stay for one-off, explicit actions (`--dry-run`, `--prompt "…"`, `--output FILE`, etc.).
315
316
 
316
- A **web-based configuration UI** that generates `config.json` for you is on the roadmap — the schema above is designed to grow additively, so future preferences (including ones set visually) won't require another migration.
317
+ You don't have to hand-write `config.json` — see [Visual config UI](#visual-config-ui) below for a point-and-click way to produce it.
317
318
 
318
319
  ### Deprecation: `.contextzip.json`
319
320
 
@@ -323,6 +324,37 @@ Earlier versions of contextzip read a `.contextzip.json` file at the project roo
323
324
 
324
325
  ---
325
326
 
327
+ ## Visual config UI
328
+
329
+ ```
330
+ contextzip config --ui
331
+ ```
332
+
333
+ Opens a local browser tab for setting `always_include`/`always_exclude` by clicking through your actual file tree instead of hand-writing patterns — live file counts and packed size update as you go, and one-click chips suggest excluding things like PDFs, office docs, fonts, media, or anything over 1MB that isn't already covered by contextzip's default rules.
334
+
335
+ It's also offered automatically the **first time** you run `contextzip` in a project that has no config at all:
336
+
337
+ ```
338
+ $ contextzip
339
+ ╭─────────────────────────╮
340
+ │ contextzip v0.3.5 │
341
+ ╰─────────────────────────╯
342
+ ...
343
+ ╭─ First run ─────────────────────────────────────╮
344
+ │ No project config found yet. │
345
+ │ contextzip can open a local browser tab... │
346
+ ╰───────────────────────────────────────────────────╯
347
+ Set up include/exclude visually now? [Y/n]:
348
+ ```
349
+
350
+ Decline once and it won't ask again (run `contextzip config --ui` any time you want it). It's also skipped automatically for non-interactive runs, when `CI` is set, or when `--prompt`/`--output` are passed — it only ever offers on a plain, interactive, first-ever run.
351
+
352
+ **Nothing about your project leaves your machine.** The server binds to `127.0.0.1` only (never `0.0.0.0`), every request needs a random per-session token embedded in the URL (the same approach Jupyter Notebook uses), and the page itself makes no calls anywhere except back to that local server — no CDN scripts, no web fonts, no analytics. Saving writes straight to `.contextzip/config.json` on disk and the server shuts itself down; if you close the tab without saving, it also shuts down after a short idle period so a forgotten session doesn't linger as an open port.
353
+
354
+ If you accept the first-run offer, the same `contextzip` invocation picks up whatever you saved and finishes packaging immediately — no need to run it again.
355
+
356
+ ---
357
+
326
358
  ## Contributing
327
359
 
328
360
  Contributions are welcome — especially new framework rule sets, edge case fixes, and platform-specific clipboard improvements.
@@ -1,6 +1,6 @@
1
1
  """contextzip — intelligent codebase packager for AI tools."""
2
2
 
3
- __version__ = "0.3.6"
3
+ __version__ = "0.3.7"
4
4
 
5
5
  from contextzip.api import (
6
6
  FileCollection,
@@ -409,11 +409,20 @@ def cmd_watch(command: tuple[str, ...]) -> None:
409
409
  default=False,
410
410
  help="Print the path to the config file and exit.",
411
411
  )
412
+ @click.option(
413
+ "--ui",
414
+ "launch_ui",
415
+ is_flag=True,
416
+ default=False,
417
+ help="Open a local browser UI to set include/exclude preferences visually "
418
+ "and write .contextzip/config.json.",
419
+ )
412
420
  def cmd_config(
413
421
  reset_key: bool,
414
422
  set_workspace: str | None,
415
423
  reset_workspace: bool,
416
424
  show_key_path: bool,
425
+ launch_ui: bool,
417
426
  ) -> None:
418
427
  """
419
428
  Manage contextzip configuration.
@@ -426,6 +435,7 @@ def cmd_config(
426
435
  contextzip config --set-workspace ~/zips # a fixed custom location
427
436
  contextzip config --reset-workspace # clear the personal override
428
437
  contextzip config --show-key-path # print config file location
438
+ contextzip config --ui # visually set include/exclude
429
439
 
430
440
  \b
431
441
  A workspace location can also be pinned for the whole team by committing
@@ -434,6 +444,16 @@ def cmd_config(
434
444
  Project config (if present) takes priority over this personal setting —
435
445
  see the README for the full precedence order.
436
446
  """
447
+ if launch_ui:
448
+ from contextzip.detector import detect as _detect
449
+ from contextzip.webui.server import launch_config_ui
450
+
451
+ project_dir = Path(os.getcwd()).resolve()
452
+ with console.status("[cyan]Detecting project ecosystem…[/]", spinner="dots"):
453
+ detection = _detect(project_dir)
454
+ launch_config_ui(project_dir, detection, con=console)
455
+ return
456
+
437
457
  if show_key_path:
438
458
  console.print(f"\n [dim]Config file:[/] [cyan]{config_path()}[/]\n")
439
459
  return
@@ -528,7 +548,9 @@ def cmd_config(
528
548
  " [dim]Run [cyan]contextzip config --set-workspace <location>[/] to set "
529
549
  "a personal default,\n"
530
550
  " or commit a [cyan].contextzip/config.json[/] at the project root to "
531
- "share one with your team.[/]",
551
+ "share one with your team.\n"
552
+ " Run [cyan]contextzip config --ui[/] to set include/exclude "
553
+ "preferences visually.[/]",
532
554
  title="[bold]contextzip config[/]",
533
555
  border_style="cyan",
534
556
  padding=(0, 2),
@@ -605,6 +627,69 @@ def _enforce_ai_config(ai_cfg) -> None:
605
627
  raise SystemExit(1)
606
628
 
607
629
 
630
+ def _maybe_offer_config_ui(
631
+ project_dir: Path,
632
+ detection,
633
+ *,
634
+ prompt: str | None,
635
+ output: str | None,
636
+ ) -> bool:
637
+ """
638
+ On a project's very first run — no .contextzip/config.json and no
639
+ legacy .contextzip.json at all — offer to open the local config UI
640
+ instead of silently proceeding with bare defaults.
641
+
642
+ Deliberately conservative about when to ask:
643
+ - never if --prompt or --output were given (the user is mid-task,
644
+ not exploring — don't interrupt with a browser tab)
645
+ - never outside an interactive terminal, or with CI set (scripted/
646
+ automated runs must never block on a prompt)
647
+ - never again once declined once (persisted personally, not per
648
+ project — see config.get_config_ui_dismissed)
649
+
650
+ Returns True if the user saved a config during the offered session
651
+ (the caller should reload project config before continuing).
652
+ """
653
+ import sys
654
+
655
+ if prompt or output:
656
+ return False
657
+ if not (sys.stdin.isatty() and sys.stdout.isatty()):
658
+ return False
659
+ if os.environ.get("CI"):
660
+ return False
661
+
662
+ from contextzip.config import get_config_ui_dismissed, save_config_ui_dismissed
663
+
664
+ if get_config_ui_dismissed():
665
+ return False
666
+
667
+ console.print(
668
+ Panel.fit(
669
+ "[bold]No project config found yet.[/]\n\n"
670
+ "[dim]contextzip can open a local browser tab where you pick "
671
+ "exactly what\nto include/exclude — with live file counts and "
672
+ "one-click suggestions\nfor things like PDFs or other non-code "
673
+ "files. Nothing leaves your machine.[/]",
674
+ title="[cyan]First run[/]",
675
+ border_style="cyan",
676
+ padding=(0, 2),
677
+ )
678
+ )
679
+ console.print()
680
+
681
+ if not click.confirm(" Set up include/exclude visually now?", default=True):
682
+ save_config_ui_dismissed()
683
+ console.print(
684
+ " [dim]No problem — run [cyan]contextzip config --ui[/] anytime.[/]\n"
685
+ )
686
+ return False
687
+
688
+ from contextzip.webui.server import launch_config_ui
689
+
690
+ return launch_config_ui(project_dir, detection, con=console)
691
+
692
+
608
693
  def _run(
609
694
  *,
610
695
  extra_exclude: list[str] | None,
@@ -652,6 +737,15 @@ def _run(
652
737
 
653
738
  print_detection(detection)
654
739
 
740
+ # ── First-run offer: visual config UI ────────────────────────────────────
741
+ if project_cfg.source_path is None:
742
+ saved = _maybe_offer_config_ui(
743
+ project_dir, detection, prompt=prompt, output=output
744
+ )
745
+ if saved:
746
+ project_cfg = load_project_config(project_dir)
747
+ console.print()
748
+
655
749
  # ── Git-changes mode ─────────────────────────────────────────────────────
656
750
  if git_changes:
657
751
  with console.status("[cyan]Querying git for changed files…[/]", spinner="dots"):
@@ -201,6 +201,36 @@ def delete_workspace_location() -> bool:
201
201
  return False
202
202
 
203
203
 
204
+ # ---------------------------------------------------------------------------
205
+ # "Don't offer the local config UI again" preference
206
+ #
207
+ # Set the first time a user declines the first-run offer to launch
208
+ # `contextzip config --ui` (see cli.py's _maybe_offer_config_ui), so the
209
+ # prompt genuinely asks only once per machine rather than nagging on every
210
+ # run of every config-less project. Running `contextzip config --ui`
211
+ # explicitly is never affected by this — it's just the unsolicited offer.
212
+ # ---------------------------------------------------------------------------
213
+
214
+
215
+ def get_config_ui_dismissed() -> bool:
216
+ """True if the user has previously declined the first-run config UI offer."""
217
+ try:
218
+ return bool(_read_config().get("config_ui_prompt_dismissed", False))
219
+ except Exception:
220
+ return False
221
+
222
+
223
+ def save_config_ui_dismissed() -> None:
224
+ """Record that the user declined the first-run config UI offer."""
225
+ _CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
226
+ data = _read_config()
227
+ data["config_ui_prompt_dismissed"] = True
228
+ _CONFIG_FILE.write_text(
229
+ json.dumps(data, indent=2) + "\n",
230
+ encoding="utf-8",
231
+ )
232
+
233
+
204
234
  def _read_config() -> dict:
205
235
  """Read and parse the config file. Returns {} if missing or malformed."""
206
236
  if not _CONFIG_FILE.is_file():
@@ -26,6 +26,7 @@ Force-include (Phase 7):
26
26
  from __future__ import annotations
27
27
 
28
28
  import importlib
29
+ import os
29
30
  from pathlib import Path
30
31
  from dataclasses import dataclass, field
31
32
 
@@ -51,6 +52,40 @@ LARGE_FILE_WARN_BYTES = 1 * 1024 * 1024 # 1 MB
51
52
  # Peek this many bytes to detect binary files
52
53
  _BINARY_PEEK = 512
53
54
 
55
+ # Directory names scan_all_files() prunes during the walk itself, before
56
+ # even reading their contents. These are always dependency/build/VCS
57
+ # noise that no ecosystem's rule module would ever include, and — for
58
+ # .venv/venv especially — often contain symlinks to files well outside
59
+ # the project tree (e.g. bin/python -> the system interpreter), which is
60
+ # exactly the kind of thing that must never reach classify_scanned_files.
61
+ # Kept intentionally small and generic (not ecosystem-specific — that's
62
+ # what rules/*.py is for) since this list affects every project's scan.
63
+ _ALWAYS_PRUNED_DIRNAMES = frozenset(
64
+ {
65
+ ".git",
66
+ ".hg",
67
+ ".svn",
68
+ "node_modules",
69
+ ".venv",
70
+ "venv",
71
+ "env",
72
+ "__pycache__",
73
+ ".tox",
74
+ ".mypy_cache",
75
+ ".pytest_cache",
76
+ ".ruff_cache",
77
+ "dist",
78
+ "build",
79
+ ".next",
80
+ ".nuxt",
81
+ ".cache",
82
+ "target",
83
+ "vendor",
84
+ "site-packages",
85
+ ".contextzip",
86
+ }
87
+ )
88
+
54
89
 
55
90
  # ---------------------------------------------------------------------------
56
91
  # Result model
@@ -99,7 +134,9 @@ def build_spec(
99
134
  encoding="utf-8", errors="ignore"
100
135
  ).splitlines()
101
136
  # Strip comments and blank lines; pathspec handles the rest
102
- patterns.extend(l for l in lines if l.strip() and not l.startswith("#"))
137
+ patterns.extend(
138
+ line for line in lines if line.strip() and not line.startswith("#")
139
+ )
103
140
  except OSError:
104
141
  pass
105
142
 
@@ -122,6 +159,111 @@ def build_force_include_spec(patterns: list[str] | None) -> pathspec.PathSpec |
122
159
  return pathspec.PathSpec.from_lines("gitwildmatch", patterns)
123
160
 
124
161
 
162
+ def scan_all_files(project_dir: Path) -> list[tuple[Path, int]]:
163
+ """
164
+ Lightweight full-tree walk that returns every regular file under
165
+ *project_dir* once, as (abs_path, size_bytes) tuples, without
166
+ classifying anything.
167
+
168
+ Meant for tools that need to classify the same file list against
169
+ several different specs without re-touching the filesystem each time
170
+ (the local config UI — see webui/server.py — scans once at startup,
171
+ then reclassifies purely in memory on every checkbox toggle). Pair
172
+ with classify_scanned_files().
173
+
174
+ Two things this does that a plain rglob() wouldn't:
175
+
176
+ - Prunes a fixed list of known-huge, always-irrelevant directories
177
+ (.venv, node_modules, .git, __pycache__, build output, etc.) during
178
+ the walk itself, not just at classification time. These can easily
179
+ be tens of thousands of files (a .venv's site-packages, in
180
+ particular) that would never end up in a ZIP anyway — walking into
181
+ them at all makes the scan needlessly slow and floods the UI's tree
182
+ with noise nobody wants to look at.
183
+ - Rejects symlinks whose real target resolves outside project_dir.
184
+ A virtualenv's bin/python is a classic example (symlinks to the
185
+ system interpreter) — without this check, relative_to() calls
186
+ downstream (see webui/server.py's _rel()) raise ValueError and
187
+ crash the request entirely.
188
+
189
+ Deliberately simpler than resolve_files()'s walk (no skipped/binary
190
+ bookkeeping) since callers here only need "does this file exist, and
191
+ how big is it" — resolve_files() remains the source of truth for an
192
+ actual packaging run, and still sees everything within the pruned
193
+ dirs is excluded via the normal rules (rules/base.py etc.) whether or
194
+ not the config UI ever looked at them.
195
+ """
196
+ files: list[tuple[Path, int]] = []
197
+
198
+ for dirpath, dirnames, filenames in os.walk(project_dir, topdown=True):
199
+ dirnames[:] = [d for d in dirnames if d not in _ALWAYS_PRUNED_DIRNAMES]
200
+
201
+ for filename in filenames:
202
+ abs_path = Path(dirpath) / filename
203
+
204
+ if abs_path.is_symlink():
205
+ try:
206
+ real = abs_path.resolve(strict=True)
207
+ except (OSError, RuntimeError):
208
+ continue # dangling symlink — skip silently, this is best-effort
209
+ try:
210
+ real.relative_to(project_dir)
211
+ except ValueError:
212
+ continue # resolves outside the project tree — never include
213
+ if not real.is_file():
214
+ continue
215
+ abs_path = real
216
+ elif not abs_path.is_file():
217
+ continue
218
+
219
+ try:
220
+ size = abs_path.stat().st_size
221
+ except OSError:
222
+ continue
223
+
224
+ files.append((abs_path, size))
225
+
226
+ return files
227
+
228
+
229
+ def classify_scanned_files(
230
+ project_dir: Path,
231
+ files: list[tuple[Path, int]],
232
+ spec: pathspec.PathSpec,
233
+ force_include: pathspec.PathSpec | None = None,
234
+ ) -> dict[Path, bool]:
235
+ """
236
+ Classify each (abs_path, size) from scan_all_files() against *spec*
237
+ (and optional *force_include*) without touching the filesystem again.
238
+
239
+ Mirrors resolve_files()'s force_include-then-spec precedence exactly,
240
+ reusing the same matching primitives, but works off an already-scanned
241
+ file list. Returns {abs_path: is_included}.
242
+ """
243
+ result: dict[Path, bool] = {}
244
+ for abs_path, _size in files:
245
+ try:
246
+ rel = abs_path.relative_to(project_dir)
247
+ except ValueError:
248
+ continue
249
+
250
+ rel_str = rel.as_posix()
251
+
252
+ forced = force_include is not None and (
253
+ force_include.match_file(rel_str)
254
+ or _any_parent_excluded(rel, force_include)
255
+ )
256
+
257
+ if forced:
258
+ result[abs_path] = True
259
+ continue
260
+
261
+ excluded = _any_parent_excluded(rel, spec) or spec.match_file(rel_str)
262
+ result[abs_path] = not excluded
263
+
264
+ return result
265
+
266
+
125
267
  def resolve_files(
126
268
  project_dir: Path,
127
269
  spec: pathspec.PathSpec,
@@ -176,7 +318,8 @@ def resolve_files(
176
318
 
177
319
  # ── force_include short-circuits the exclusion checks below ─────────
178
320
  forced = force_include is not None and (
179
- force_include.match_file(rel_str) or _any_parent_excluded(rel, force_include)
321
+ force_include.match_file(rel_str)
322
+ or _any_parent_excluded(rel, force_include)
180
323
  )
181
324
 
182
325
  if not forced:
@@ -0,0 +1,9 @@
1
+ """
2
+ webui — Local-only browser UI for setting up .contextzip/config.json.
3
+
4
+ Entry point: `launch_config_ui()` in server.py, invoked by
5
+ `contextzip config --ui` and offered automatically on a project's first
6
+ run (see cli.py's _maybe_offer_config_ui). Everything here runs a plain
7
+ stdlib HTTP server bound to 127.0.0.1 — no new dependency, and no data
8
+ about the project ever leaves the machine.
9
+ """