contextzip 0.3.3__tar.gz → 0.3.5__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 (40) hide show
  1. {contextzip-0.3.3 → contextzip-0.3.5}/PKG-INFO +37 -5
  2. {contextzip-0.3.3 → contextzip-0.3.5}/README.md +36 -4
  3. {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/__init__.py +1 -1
  4. {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/cli.py +75 -33
  5. {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/cli_display.py +8 -1
  6. {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/clipboard.py +6 -16
  7. {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/config.py +51 -1
  8. {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/detector.py +122 -12
  9. {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/git.py +11 -11
  10. {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/packager.py +91 -28
  11. contextzip-0.3.5/contextzip/project_config.py +93 -0
  12. {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/rules/base.py +1 -1
  13. {contextzip-0.3.3 → contextzip-0.3.5}/contextzip.egg-info/PKG-INFO +37 -5
  14. {contextzip-0.3.3 → contextzip-0.3.5}/contextzip.egg-info/SOURCES.txt +1 -0
  15. {contextzip-0.3.3 → contextzip-0.3.5}/pyproject.toml +1 -1
  16. {contextzip-0.3.3 → contextzip-0.3.5}/LICENSE +0 -0
  17. {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/ai/__init__.py +0 -0
  18. {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/ai/gemini.py +0 -0
  19. {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/ai/heuristic.py +0 -0
  20. {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/ai/selector.py +0 -0
  21. {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/api.py +0 -0
  22. {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/cli_ai.py +0 -0
  23. {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/cli_onboard.py +0 -0
  24. {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/error_parser.py +0 -0
  25. {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/filters.py +0 -0
  26. {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/rules/__init__.py +0 -0
  27. {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/rules/errors/__init__.py +0 -0
  28. {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/rules/errors/node.py +0 -0
  29. {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/rules/errors/python.py +0 -0
  30. {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/rules/go.py +0 -0
  31. {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/rules/node.py +0 -0
  32. {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/rules/python.py +0 -0
  33. {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/rules/ruby.py +0 -0
  34. {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/rules/rust.py +0 -0
  35. {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/watcher.py +0 -0
  36. {contextzip-0.3.3 → contextzip-0.3.5}/contextzip.egg-info/dependency_links.txt +0 -0
  37. {contextzip-0.3.3 → contextzip-0.3.5}/contextzip.egg-info/entry_points.txt +0 -0
  38. {contextzip-0.3.3 → contextzip-0.3.5}/contextzip.egg-info/requires.txt +0 -0
  39. {contextzip-0.3.3 → contextzip-0.3.5}/contextzip.egg-info/top_level.txt +0 -0
  40. {contextzip-0.3.3 → contextzip-0.3.5}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: contextzip
3
- Version: 0.3.3
3
+ Version: 0.3.5
4
4
  Summary: Intelligently package your codebase for AI tools
5
5
  Author-email: Deepesh <akadeepesh@gmail.com>
6
6
  License-Expression: MIT
@@ -47,12 +47,13 @@ contextzip eliminates that entirely. Run it from your project root — it detect
47
47
 
48
48
  ## Features
49
49
 
50
- - **Smart framework detection** — automatically identifies Node.js, Next.js, Python, Django, FastAPI, Rust, Go, and Ruby, applying the right exclusion rules for each
50
+ - **Smart framework detection** — automatically identifies Node.js, Next.js, Python, Django, FastAPI, Rust, Go, and Ruby, applying the right exclusion rules for each. Detection isn't limited to the project root: a shallow, bounded scan of subdirectories means a monorepo (`frontend/` + `backend/`, etc.) gets every ecosystem it contains detected and excluded correctly, not just whatever sits at the top level
51
51
  - **Respects `.gitignore`** — your existing ignore patterns are honoured automatically
52
52
  - **Git-aware packaging** — use `--git-changes` to package only modified, staged, and untracked files; perfect for incremental debugging and PR review sessions
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
- - **Persistent workspace** — all generated ZIPs land in `.contextzip/` at your project root, discoverable, reusable, and git-ignored automatically
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.json`
56
+ - **Persistent workspace** — all generated ZIPs land in `.contextzip/`, discoverable, reusable, and git-ignored automatically
56
57
  - **Warns before it's a problem** — flags large (≥ 1 MB) and binary files that AI tools can't read, before you waste an upload
57
58
  - **Handles edge cases** — dangling symlinks, unreadable files, and paths outside the project tree are caught and reported, never silently dropped
58
59
  - **Full CLI control** — `--include`, `--exclude`, `--dry-run`, `--output`, all composable
@@ -247,7 +248,7 @@ Press **D** and contextzip immediately writes `.contextzip/debug-context.zip`. Y
247
248
 
248
249
  contextzip stacks exclusion rules based on your detected stack, on top of your `.gitignore`.
249
250
 
250
- **Always excluded:** `.git/`, `.env` files, logs, caches, editor config (`.vscode/`, `.idea/`), OS files (`.DS_Store`, `Thumbs.db`), and common binary formats.
251
+ **Always excluded:** `.git/`, `.env` files, logs, caches, editor config (`.vscode/`, `.idea/`), OS files (`.DS_Store`, `Thumbs.db`), common binary formats, and contextzip's own `.contextzip/` working folder.
251
252
 
252
253
  **By framework:**
253
254
 
@@ -258,7 +259,38 @@ contextzip stacks exclusion rules based on your detected stack, on top of your `
258
259
  | Rust | `target/`, `Cargo.lock`, `*.rlib` |
259
260
  | Go | `vendor/`, `go.sum`, `bin/` |
260
261
 
261
- Detection is additive — a monorepo with both `package.json` and `pyproject.toml` gets both rule sets applied.
262
+ Detection is additive — a monorepo with both `package.json` and `pyproject.toml` gets both rule sets applied. Marker files don't have to sit at the project root: contextzip also does a shallow, bounded scan of subdirectories (2 levels deep, skipping `node_modules/`, `.venv/`, `.git/`, and other dependency/build dirs it would never want to look inside anyway), so a layout like
263
+
264
+ ```
265
+ root/
266
+ frontend/package.json
267
+ backend/requirements.txt
268
+ ```
269
+
270
+ detects both Node.js and Python from `root/`, without either marker existing at the top level. Run with default output (not `--dry-run --verbose`) and you'll see which subdirectory each ecosystem came from, e.g. `Next.js (frontend/) + FastAPI (backend/)`.
271
+
272
+ ---
273
+
274
+ ## Workspace location
275
+
276
+ By default, `.contextzip/` is created at your git root — that's `_find_git_root()` walking up from the current directory until it finds a `.git` folder; outside a repo, it falls back to the current directory. This can be overridden two ways, in order of priority:
277
+
278
+ 1. **`CONTEXTZIP_WORKSPACE_LOCATION` environment variable** — for a one-off override on a single run
279
+ 2. **A committed `.contextzip.json` at the project root** — team-shared, applies to everyone who clones the repo:
280
+ ```json
281
+ { "workspace_location": "git-root" }
282
+ ```
283
+ 3. **Your personal config** — a per-machine default that doesn't get committed:
284
+ ```bash
285
+ contextzip config --set-workspace cwd # always use ./.contextzip wherever you run it
286
+ contextzip config --set-workspace git-root # back to the default
287
+ contextzip config --set-workspace ~/zips # a fixed custom location, anywhere
288
+ contextzip config --reset-workspace # clear your personal override
289
+ ```
290
+
291
+ Accepted values are `"git-root"`, `"cwd"`, or any path (absolute, or relative to the git root). Run `contextzip config` with no flags to see which value is currently active and where it came from.
292
+
293
+ This matters most for monorepos where you sometimes run contextzip from a subdirectory (`cd frontend && contextzip`) — with the default `git-root` setting, the workspace still lands at the repo root no matter where you ran it from, so you don't end up with scattered `.contextzip/` folders across `frontend/`, `backend/`, etc. Set `workspace_location: "cwd"` in a project's `.contextzip.json` instead if you'd rather each subproject keep its own.
262
294
 
263
295
  ---
264
296
 
@@ -18,12 +18,13 @@ contextzip eliminates that entirely. Run it from your project root — it detect
18
18
 
19
19
  ## Features
20
20
 
21
- - **Smart framework detection** — automatically identifies Node.js, Next.js, Python, Django, FastAPI, Rust, Go, and Ruby, applying the right exclusion rules for each
21
+ - **Smart framework detection** — automatically identifies Node.js, Next.js, Python, Django, FastAPI, Rust, Go, and Ruby, applying the right exclusion rules for each. Detection isn't limited to the project root: a shallow, bounded scan of subdirectories means a monorepo (`frontend/` + `backend/`, etc.) gets every ecosystem it contains detected and excluded correctly, not just whatever sits at the top level
22
22
  - **Respects `.gitignore`** — your existing ignore patterns are honoured automatically
23
23
  - **Git-aware packaging** — use `--git-changes` to package only modified, staged, and untracked files; perfect for incremental debugging and PR review sessions
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
- - **Persistent workspace** — all generated ZIPs land in `.contextzip/` at your project root, discoverable, reusable, and git-ignored automatically
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.json`
27
+ - **Persistent workspace** — all generated ZIPs land in `.contextzip/`, discoverable, reusable, and git-ignored automatically
27
28
  - **Warns before it's a problem** — flags large (≥ 1 MB) and binary files that AI tools can't read, before you waste an upload
28
29
  - **Handles edge cases** — dangling symlinks, unreadable files, and paths outside the project tree are caught and reported, never silently dropped
29
30
  - **Full CLI control** — `--include`, `--exclude`, `--dry-run`, `--output`, all composable
@@ -218,7 +219,7 @@ Press **D** and contextzip immediately writes `.contextzip/debug-context.zip`. Y
218
219
 
219
220
  contextzip stacks exclusion rules based on your detected stack, on top of your `.gitignore`.
220
221
 
221
- **Always excluded:** `.git/`, `.env` files, logs, caches, editor config (`.vscode/`, `.idea/`), OS files (`.DS_Store`, `Thumbs.db`), and common binary formats.
222
+ **Always excluded:** `.git/`, `.env` files, logs, caches, editor config (`.vscode/`, `.idea/`), OS files (`.DS_Store`, `Thumbs.db`), common binary formats, and contextzip's own `.contextzip/` working folder.
222
223
 
223
224
  **By framework:**
224
225
 
@@ -229,7 +230,38 @@ contextzip stacks exclusion rules based on your detected stack, on top of your `
229
230
  | Rust | `target/`, `Cargo.lock`, `*.rlib` |
230
231
  | Go | `vendor/`, `go.sum`, `bin/` |
231
232
 
232
- Detection is additive — a monorepo with both `package.json` and `pyproject.toml` gets both rule sets applied.
233
+ Detection is additive — a monorepo with both `package.json` and `pyproject.toml` gets both rule sets applied. Marker files don't have to sit at the project root: contextzip also does a shallow, bounded scan of subdirectories (2 levels deep, skipping `node_modules/`, `.venv/`, `.git/`, and other dependency/build dirs it would never want to look inside anyway), so a layout like
234
+
235
+ ```
236
+ root/
237
+ frontend/package.json
238
+ backend/requirements.txt
239
+ ```
240
+
241
+ detects both Node.js and Python from `root/`, without either marker existing at the top level. Run with default output (not `--dry-run --verbose`) and you'll see which subdirectory each ecosystem came from, e.g. `Next.js (frontend/) + FastAPI (backend/)`.
242
+
243
+ ---
244
+
245
+ ## Workspace location
246
+
247
+ By default, `.contextzip/` is created at your git root — that's `_find_git_root()` walking up from the current directory until it finds a `.git` folder; outside a repo, it falls back to the current directory. This can be overridden two ways, in order of priority:
248
+
249
+ 1. **`CONTEXTZIP_WORKSPACE_LOCATION` environment variable** — for a one-off override on a single run
250
+ 2. **A committed `.contextzip.json` at the project root** — team-shared, applies to everyone who clones the repo:
251
+ ```json
252
+ { "workspace_location": "git-root" }
253
+ ```
254
+ 3. **Your personal config** — a per-machine default that doesn't get committed:
255
+ ```bash
256
+ contextzip config --set-workspace cwd # always use ./.contextzip wherever you run it
257
+ contextzip config --set-workspace git-root # back to the default
258
+ contextzip config --set-workspace ~/zips # a fixed custom location, anywhere
259
+ contextzip config --reset-workspace # clear your personal override
260
+ ```
261
+
262
+ Accepted values are `"git-root"`, `"cwd"`, or any path (absolute, or relative to the git root). Run `contextzip config` with no flags to see which value is currently active and where it came from.
263
+
264
+ This matters most for monorepos where you sometimes run contextzip from a subdirectory (`cd frontend && contextzip`) — with the default `git-root` setting, the workspace still lands at the repo root no matter where you ran it from, so you don't end up with scattered `.contextzip/` folders across `frontend/`, `backend/`, etc. Set `workspace_location: "cwd"` in a project's `.contextzip.json` instead if you'd rather each subproject keep its own.
233
265
 
234
266
  ---
235
267
 
@@ -1,6 +1,6 @@
1
1
  """contextzip — intelligent codebase packager for AI tools."""
2
2
 
3
- __version__ = "0.3.3"
3
+ __version__ = "0.3.5"
4
4
 
5
5
  from contextzip.api import (
6
6
  FileCollection,
@@ -1,29 +1,5 @@
1
1
  """
2
2
  cli.py — contextzip entry point and command routing.
3
-
4
- Command surface
5
- ───────────────
6
- contextzip [OPTIONS]
7
- -i / --include PATH only include files under these paths (repeatable)
8
- -e / --exclude PATTERN extra exclusion patterns (repeatable)
9
- -n / --dry-run preview without creating ZIP
10
- -o / --output FILE custom output path
11
- --no-clipboard skip clipboard / folder-open step
12
- --no-gitignore ignore project .gitignore
13
- --git-changes only package files changed in git
14
- -v / --verbose show every file decision
15
-
16
- Subcommands:
17
- contextzip exclude PATTERN… [OPTIONS]
18
- contextzip include PATH… [OPTIONS]
19
- contextzip watch -- COMMAND [ARGS]…
20
- contextzip config [--reset-key] [--show-key-path]
21
-
22
- Business logic lives in the modules below — this file is routing only:
23
- cli_display.py — all Rich rendering (_print_* helpers)
24
- cli_ai.py — AI file selection orchestration + pattern normalisation
25
- cli_onboard.py — Gemini API key onboarding flow
26
- watcher.py — watch-mode process spawning and error detection
27
3
  """
28
4
 
29
5
  from __future__ import annotations
@@ -41,6 +17,8 @@ from contextzip.config import (
41
17
  delete_api_key,
42
18
  config_path,
43
19
  diagnose_api_key,
20
+ save_workspace_location,
21
+ delete_workspace_location,
44
22
  )
45
23
  from contextzip.detector import detect
46
24
  from contextzip.filters import build_spec, resolve_files, resolve_files_from_git
@@ -395,11 +373,6 @@ def cmd_watch(command: tuple[str, ...]) -> None:
395
373
  raise SystemExit(exit_code)
396
374
 
397
375
 
398
- # ---------------------------------------------------------------------------
399
- # Subcommand: config
400
- # ---------------------------------------------------------------------------
401
-
402
-
403
376
  @main.command("config")
404
377
  @click.option(
405
378
  "--reset-key",
@@ -407,25 +380,77 @@ def cmd_watch(command: tuple[str, ...]) -> None:
407
380
  default=False,
408
381
  help="Clear the stored Gemini API key and re-run the setup prompt.",
409
382
  )
383
+ @click.option(
384
+ "--set-workspace",
385
+ default=None,
386
+ metavar="LOCATION",
387
+ help='Set your personal default for where .contextzip/ lives: "git-root", '
388
+ '"cwd", or a path.',
389
+ )
390
+ @click.option(
391
+ "--reset-workspace",
392
+ is_flag=True,
393
+ default=False,
394
+ help="Clear your personal workspace location override.",
395
+ )
410
396
  @click.option(
411
397
  "--show-key-path",
412
398
  is_flag=True,
413
399
  default=False,
414
400
  help="Print the path to the config file and exit.",
415
401
  )
416
- def cmd_config(reset_key: bool, show_key_path: bool) -> None:
402
+ def cmd_config(
403
+ reset_key: bool,
404
+ set_workspace: str | None,
405
+ reset_workspace: bool,
406
+ show_key_path: bool,
407
+ ) -> None:
417
408
  """
418
409
  Manage contextzip configuration.
419
410
 
420
411
  \b
421
412
  EXAMPLES
422
- contextzip config --reset-key # clear stored API key, re-onboard
423
- contextzip config --show-key-path # print config file location
413
+ contextzip config --reset-key # clear Gemini key, re-onboard
414
+ contextzip config --set-workspace cwd # always use ./.contextzip here
415
+ contextzip config --set-workspace git-root # back to the default
416
+ contextzip config --set-workspace ~/zips # a fixed custom location
417
+ contextzip config --reset-workspace # clear the personal override
418
+ contextzip config --show-key-path # print config file location
419
+
420
+ \b
421
+ A workspace location can also be pinned for the whole team by committing
422
+ a .contextzip.json file at the project root:
423
+ {"workspace_location": "git-root"}
424
+ Project config (if present) takes priority over this personal setting —
425
+ see the README for the full precedence order.
424
426
  """
425
427
  if show_key_path:
426
428
  console.print(f"\n [dim]Config file:[/] [cyan]{config_path()}[/]\n")
427
429
  return
428
430
 
431
+ if set_workspace is not None or reset_workspace:
432
+ if reset_workspace:
433
+ removed = delete_workspace_location()
434
+ if removed:
435
+ console.print(
436
+ f"\n [green]✓[/] Workspace override removed from "
437
+ f"[dim]{config_path()}[/]"
438
+ )
439
+ else:
440
+ console.print("\n [dim]No personal workspace override was set.[/]")
441
+ if set_workspace is None:
442
+ return
443
+
444
+ if set_workspace is not None:
445
+ save_workspace_location(set_workspace)
446
+ console.print(
447
+ f"\n [green]✓[/] Personal workspace location set to "
448
+ f"[cyan]{set_workspace}[/] in [dim]{config_path()}[/]\n"
449
+ f" [dim]A project-level .contextzip.json, if present, still "
450
+ f"takes priority over this.[/]"
451
+ )
452
+ return
453
+
429
454
  if reset_key:
430
455
  removed = delete_api_key()
431
456
  if removed:
@@ -475,10 +500,27 @@ def cmd_config(reset_key: bool, show_key_path: bool) -> None:
475
500
  padding=(0, 2),
476
501
  )
477
502
  )
503
+
504
+ console.print()
505
+
506
+ from contextzip.packager import _resolve_workspace_location
507
+
508
+ ws_location, ws_source = _resolve_workspace_location(Path(os.getcwd()).resolve())
509
+ console.print(
510
+ Panel(
511
+ f" [dim]Workspace location:[/] [green]{ws_location}[/] [dim]({ws_source})[/]\n\n"
512
+ " [dim]Run [cyan]contextzip config --set-workspace <location>[/] to set "
513
+ "a personal default,\n"
514
+ " or commit a [cyan].contextzip.json[/] at the project root to share "
515
+ "one with your team.[/]",
516
+ title="[bold]contextzip config[/]",
517
+ border_style="cyan",
518
+ padding=(0, 2),
519
+ )
520
+ )
478
521
  console.print()
479
522
 
480
523
 
481
- # ---------------------------------------------------------------------------
482
524
  # Core execution logic (shared by main command + all subcommands)
483
525
  # ---------------------------------------------------------------------------
484
526
 
@@ -43,7 +43,14 @@ def print_detection(detection, *, con: Console = console) -> None:
43
43
  "Go": "cyan",
44
44
  "Ruby": "red",
45
45
  }
46
- parts = [f"[{colours.get(n, 'white')}]{n}[/]" for n in detection.ecosystems]
46
+ parts = []
47
+ for n in detection.ecosystems:
48
+ colour = colours.get(n, "white")
49
+ src = detection.sources.get(n)
50
+ if src and src != ".":
51
+ parts.append(f"[{colour}]{n}[/] [dim]({src}/)[/]")
52
+ else:
53
+ parts.append(f"[{colour}]{n}[/]")
47
54
  ecosystem_line = " [dim]+[/] ".join(parts)
48
55
 
49
56
  conf_colour = {"high": "green", "medium": "yellow", "low": "dim"}.get(
@@ -35,21 +35,16 @@ from pathlib import Path
35
35
 
36
36
 
37
37
  class Tier(Enum):
38
- FILE_ON_CLIPBOARD = 1 # actual file object on clipboard — paste into browser
39
- FOLDER_OPENED = 2 # folder opened / file highlighted in file manager
40
- PATH_ONLY = 3 # nothing automatic; path printed for manual copy
38
+ FILE_ON_CLIPBOARD = 1
39
+ FOLDER_OPENED = 2
40
+ PATH_ONLY = 3
41
41
 
42
42
 
43
43
  @dataclass
44
44
  class ClipboardResult:
45
45
  tier: Tier
46
- message: str # human-readable outcome line
47
- success: bool = True # False only on unexpected errors worth surfacing
48
-
49
-
50
- # ---------------------------------------------------------------------------
51
- # Public API
52
- # ---------------------------------------------------------------------------
46
+ message: str
47
+ success: bool = True
53
48
 
54
49
 
55
50
  def handle(zip_path: Path) -> ClipboardResult:
@@ -202,7 +197,7 @@ def _tier2_windows(zip_path: Path) -> ClipboardResult | None:
202
197
  try:
203
198
  # Use the Windows path format with backslashes
204
199
  win_path = str(zip_path).replace("/", "\\")
205
- proc = subprocess.run(
200
+ subprocess.run(
206
201
  ["explorer", f"/select,{win_path}"],
207
202
  # explorer always exits 1 even on success — don't check returncode
208
203
  capture_output=True,
@@ -221,11 +216,6 @@ def _tier2_windows(zip_path: Path) -> ClipboardResult | None:
221
216
  return None
222
217
 
223
218
 
224
- # ---------------------------------------------------------------------------
225
- # Tier 3 — path only (always succeeds)
226
- # ---------------------------------------------------------------------------
227
-
228
-
229
219
  def _tier3(zip_path: Path) -> ClipboardResult:
230
220
  return ClipboardResult(
231
221
  tier=Tier.PATH_ONLY,
@@ -147,10 +147,60 @@ def config_path() -> Path:
147
147
 
148
148
 
149
149
  # ---------------------------------------------------------------------------
150
- # Internal helpers
150
+ # Personal workspace location preference
151
+ #
152
+ # Where the .contextzip/ workspace (output zips + eod/handoff-era markers,
153
+ # now just output zips) gets created, when nothing more specific overrides
154
+ # it. This is a *personal*, per-machine preference — for a setting the whole
155
+ # team should share, use the project-level config instead (project_config.py,
156
+ # a .contextzip.json file meant to be committed to the repo).
157
+ #
158
+ # Resolution order (highest wins), enforced by packager.py, not here:
159
+ # CLI flag > CONTEXTZIP_WORKSPACE_LOCATION env var > project config
160
+ # > this personal config > built-in default ("git-root")
161
+ #
162
+ # Accepted values: "git-root", "cwd", or an explicit path (absolute, or
163
+ # relative to the git root / project dir).
151
164
  # ---------------------------------------------------------------------------
152
165
 
153
166
 
167
+ def get_workspace_location() -> str | None:
168
+ """Return the personally configured workspace location, or None if unset."""
169
+ try:
170
+ data = _read_config()
171
+ value = data.get("workspace_location", "").strip()
172
+ return value or None
173
+ except Exception:
174
+ return None
175
+
176
+
177
+ def save_workspace_location(value: str) -> None:
178
+ """Persist *value* ("git-root" | "cwd" | a path) as the personal default."""
179
+ _CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
180
+ data = _read_config()
181
+ data["workspace_location"] = value.strip()
182
+ _CONFIG_FILE.write_text(
183
+ json.dumps(data, indent=2) + "\n",
184
+ encoding="utf-8",
185
+ )
186
+
187
+
188
+ def delete_workspace_location() -> bool:
189
+ """Remove the personal workspace location override, reverting to default/project config."""
190
+ try:
191
+ data = _read_config()
192
+ if "workspace_location" not in data:
193
+ return False
194
+ del data["workspace_location"]
195
+ _CONFIG_FILE.write_text(
196
+ json.dumps(data, indent=2) + "\n",
197
+ encoding="utf-8",
198
+ )
199
+ return True
200
+ except Exception:
201
+ return False
202
+
203
+
154
204
  def _read_config() -> dict:
155
205
  """Read and parse the config file. Returns {} if missing or malformed."""
156
206
  if not _CONFIG_FILE.is_file():
@@ -3,6 +3,28 @@ detector.py — Analyses a project directory and identifies the ecosystem(s) pre
3
3
 
4
4
  Detection is additive: base rules always apply, and each detected framework
5
5
  stacks its own rules on top. A monorepo can match multiple ecosystems at once.
6
+
7
+ Detection isn't limited to the project root. Marker files (package.json,
8
+ requirements.txt, etc.) are looked for at the root AND in a shallow scan of
9
+ subdirectories, so a layout like:
10
+
11
+ root/
12
+ frontend/package.json
13
+ backend/requirements.txt
14
+
15
+ correctly detects BOTH Node.js and Python and stacks both rule sets — rather
16
+ than finding nothing at root and only applying the universal base rules,
17
+ which would leave node_modules/ and .venv/ unexcluded except by whatever
18
+ .gitignore happens to catch.
19
+
20
+ The scan is unconditional (not "only if root finds nothing") because a
21
+ root-level marker (e.g. shared tooling's package.json) doesn't imply there's
22
+ nothing else to find in sibling directories — it only tells you about root.
23
+
24
+ The scan is bounded and cheap: max depth of 2, and it never descends into
25
+ directories that are themselves dependency/build output (node_modules,
26
+ .venv, .git, etc.) — the same names those directories' own rule modules
27
+ would exclude from the zip, so we skip them before we even look inside.
6
28
  """
7
29
 
8
30
  from __future__ import annotations
@@ -25,6 +47,10 @@ class DetectionResult:
25
47
  ecosystems: list[str] = field(default_factory=list) # e.g. ["Node.js", "Next.js"]
26
48
  rule_modules: list[str] = field(default_factory=list) # e.g. ["base", "node"]
27
49
  confidence: str = "low" # "low" | "medium" | "high"
50
+ # Maps ecosystem name -> relative dir it was found in ("." for root),
51
+ # e.g. {"Node.js": "frontend", "Python": "backend"}. Purely informational,
52
+ # shown in CLI output so a monorepo detection doesn't feel like magic.
53
+ sources: dict[str, str] = field(default_factory=dict)
28
54
 
29
55
  @property
30
56
  def display_name(self) -> str:
@@ -123,15 +149,91 @@ _RULES: list[_Rule] = [
123
149
  ]
124
150
 
125
151
 
152
+ # ---------------------------------------------------------------------------
153
+ # Subdirectory scan — bounded, cheap, skips dependency/build dirs
154
+ # ---------------------------------------------------------------------------
155
+
156
+ # Directories we never descend into while scanning for markers: they're
157
+ # either dependency trees (huge, and full of nested package.json/pyproject
158
+ # files that would produce false positives) or build/VCS output. This
159
+ # mirrors the directory names the rule modules themselves exclude.
160
+ _SKIP_DIR_NAMES = {
161
+ "node_modules",
162
+ ".git",
163
+ ".svn",
164
+ ".hg",
165
+ ".venv",
166
+ "venv",
167
+ "env",
168
+ "ENV",
169
+ "__pycache__",
170
+ ".pytest_cache",
171
+ ".mypy_cache",
172
+ ".ruff_cache",
173
+ ".tox",
174
+ "vendor",
175
+ "target",
176
+ "dist",
177
+ "build",
178
+ "out",
179
+ ".next",
180
+ ".nuxt",
181
+ ".turbo",
182
+ ".vercel",
183
+ ".netlify",
184
+ "coverage",
185
+ ".nyc_output",
186
+ "site-packages",
187
+ ".idea",
188
+ ".vscode",
189
+ ".cache",
190
+ "tmp",
191
+ "temp",
192
+ }
193
+
194
+ _MAX_SCAN_DEPTH = 2
195
+
196
+
197
+ def _scan_dirs(project_dir: Path, max_depth: int = _MAX_SCAN_DEPTH) -> list[Path]:
198
+ """
199
+ Return [project_dir] plus a bounded, shallow list of subdirectories worth
200
+ checking for ecosystem markers. Never descends into _SKIP_DIR_NAMES.
201
+ """
202
+ dirs = [project_dir]
203
+ if max_depth <= 0:
204
+ return dirs
205
+
206
+ frontier = [project_dir]
207
+ for _ in range(max_depth):
208
+ next_frontier: list[Path] = []
209
+ for d in frontier:
210
+ try:
211
+ children = [c for c in d.iterdir() if c.is_dir()]
212
+ except OSError:
213
+ continue
214
+ for child in children:
215
+ if child.name in _SKIP_DIR_NAMES or child.name.startswith("."):
216
+ continue
217
+ dirs.append(child)
218
+ next_frontier.append(child)
219
+ frontier = next_frontier
220
+
221
+ return dirs
222
+
223
+
126
224
  # ---------------------------------------------------------------------------
127
225
  # Public API
128
226
  # ---------------------------------------------------------------------------
129
227
 
130
228
 
131
- def detect(project_dir: Path) -> DetectionResult:
229
+ def detect(project_dir: Path, max_scan_depth: int = _MAX_SCAN_DEPTH) -> DetectionResult:
132
230
  """
133
231
  Analyse *project_dir* and return a :class:`DetectionResult`.
134
232
 
233
+ Checks project_dir itself plus a shallow, bounded scan of subdirectories
234
+ (see module docstring), so multi-framework monorepos are detected even
235
+ when no marker file sits at the root.
236
+
135
237
  Always includes "base" in rule_modules.
136
238
  """
137
239
  result = DetectionResult(rule_modules=["base"])
@@ -140,20 +242,28 @@ def detect(project_dir: Path) -> DetectionResult:
140
242
  seen_names: set[str] = set()
141
243
  total_weight = 0
142
244
 
143
- for rule in _RULES:
144
- try:
145
- matched = rule.check(project_dir)
146
- except Exception:
147
- matched = False
245
+ for candidate_dir in _scan_dirs(project_dir, max_scan_depth):
246
+ for rule in _RULES:
247
+ if rule.name in seen_names:
248
+ continue # already found this ecosystem elsewhere; don't re-check
249
+ try:
250
+ matched = rule.check(candidate_dir)
251
+ except Exception:
252
+ matched = False
148
253
 
149
- if matched:
150
- if rule.name not in seen_names:
254
+ if matched:
151
255
  result.ecosystems.append(rule.name)
152
256
  seen_names.add(rule.name)
153
- if rule.module not in seen_modules:
154
- result.rule_modules.append(rule.module)
155
- seen_modules.add(rule.module)
156
- total_weight += rule.weight
257
+ try:
258
+ rel = candidate_dir.relative_to(project_dir)
259
+ result.sources[rule.name] = str(rel) if str(rel) != "." else "."
260
+ except ValueError:
261
+ result.sources[rule.name] = "."
262
+
263
+ if rule.module not in seen_modules:
264
+ result.rule_modules.append(rule.module)
265
+ seen_modules.add(rule.module)
266
+ total_weight += rule.weight
157
267
 
158
268
  # Confidence based on cumulative signal weight
159
269
  if total_weight >= 4:
@@ -57,19 +57,19 @@ class GitErrorKind(Enum):
57
57
  @dataclass
58
58
  class GitError:
59
59
  kind: GitErrorKind
60
- message: str # human-readable, ready to print
60
+ message: str
61
61
 
62
62
 
63
63
  @dataclass
64
64
  class GitChanges:
65
65
  """Successful result from :func:`get_changed_files`."""
66
66
 
67
- files: list[Path] = field(default_factory=list) # absolute paths
68
- staged: list[str] = field(default_factory=list) # rel paths (display)
69
- unstaged: list[str] = field(default_factory=list) # rel paths (display)
70
- untracked: list[str] = field(default_factory=list) # rel paths (display)
71
- deleted: list[str] = field(default_factory=list) # rel paths (skipped)
72
- submodules: list[str] = field(default_factory=list) # rel paths (skipped)
67
+ files: list[Path] = field(default_factory=list)
68
+ staged: list[str] = field(default_factory=list)
69
+ unstaged: list[str] = field(default_factory=list)
70
+ untracked: list[str] = field(default_factory=list)
71
+ deleted: list[str] = field(default_factory=list)
72
+ submodules: list[str] = field(default_factory=list)
73
73
 
74
74
  @property
75
75
  def is_empty(self) -> bool:
@@ -183,15 +183,15 @@ def _parse_porcelain(
183
183
  if not raw_line or len(raw_line) < 4:
184
184
  continue
185
185
 
186
- xy = raw_line[:2] # e.g. "M ", " M", "??", "A "
187
- rel_path = raw_line[3:].strip() # path relative to repo root
186
+ xy = raw_line[:2]
187
+ rel_path = raw_line[3:].strip()
188
188
 
189
189
  # Strip surrounding quotes git adds for paths with spaces/special chars
190
190
  if rel_path.startswith('"') and rel_path.endswith('"'):
191
191
  rel_path = rel_path[1:-1]
192
192
 
193
- x = xy[0] # staged status
194
- y = xy[1] # unstaged status
193
+ x = xy[0]
194
+ y = xy[1]
195
195
 
196
196
  # Skip ignored files
197
197
  if x == "!" and y == "!":
@@ -265,13 +265,12 @@ def _find_git_root(start: Path) -> Path | None:
265
265
  current = parent
266
266
 
267
267
 
268
- def _ensure_gitignore(git_root: Path) -> None:
268
+ def _ensure_gitignore(git_root: Path, entry: str = _GITIGNORE_ENTRY) -> None:
269
269
  """
270
- Ensure .contextzip/ is listed in <git_root>/.gitignore.
270
+ Ensure *entry* is listed in <git_root>/.gitignore.
271
271
 
272
- - If .gitignore exists and already contains the entry: do nothing.
273
- - If .gitignore exists but lacks the entry: append it.
274
- - If .gitignore does not exist: create it with just the entry.
272
+ Generalised beyond just .contextzip/ so other workspace-style folders
273
+ (e.g. exports/) can register themselves the same way.
275
274
  """
276
275
  gitignore_path = git_root / ".gitignore"
277
276
 
@@ -279,33 +278,93 @@ def _ensure_gitignore(git_root: Path) -> None:
279
278
  content = gitignore_path.read_text(encoding="utf-8", errors="replace")
280
279
  # Check for the entry on its own line (with or without trailing slash variants)
281
280
  lines = [line.strip() for line in content.splitlines()]
282
- if _GITIGNORE_ENTRY in lines or _GITIGNORE_ENTRY.rstrip("/") in lines:
283
- return # Already present — nothing to do
284
- # Append, ensuring there's a trailing newline before our entry
281
+ if entry in lines or entry.rstrip("/") in lines:
282
+ return
285
283
  separator = "\n" if content and not content.endswith("\n") else ""
286
284
  with gitignore_path.open("a", encoding="utf-8") as f:
287
- f.write(f"{separator}\n# contextzip workspace\n{_GITIGNORE_ENTRY}\n")
285
+ f.write(f"{separator}\n# contextzip workspace\n{entry}\n")
288
286
  else:
289
287
  # .gitignore doesn't exist — create a minimal one
290
288
  gitignore_path.write_text(
291
- f"# contextzip workspace\n{_GITIGNORE_ENTRY}\n",
289
+ f"# contextzip workspace\n{entry}\n",
292
290
  encoding="utf-8",
293
291
  )
294
292
 
295
293
 
294
+ def _is_relative_to(path: Path, other: Path) -> bool:
295
+ """
296
+ True if *path* is inside *other*, after resolving both. Path.is_relative_to
297
+ exists from 3.9 onward (our floor), but resolving first avoids false
298
+ negatives from relative components or symlinks.
299
+ """
300
+ try:
301
+ path.resolve().relative_to(other.resolve())
302
+ return True
303
+ except ValueError:
304
+ return False
305
+
306
+
307
+ def _resolve_workspace_location(project_dir: Path) -> tuple[str, str]:
308
+ """
309
+ Determine where the .contextzip/ workspace should live, and where that
310
+ decision came from (for diagnostics — see `contextzip config`).
311
+
312
+ Precedence, highest wins:
313
+ 1. CONTEXTZIP_WORKSPACE_LOCATION env var
314
+ 2. Project config (.contextzip.json at git root — team-shared, committed)
315
+ 3. Personal config (~/.config/contextzip/config.json — per-machine)
316
+ 4. Built-in default: "git-root"
317
+
318
+ Returns (location, source) where location is "git-root", "cwd", or an
319
+ explicit path string, and source is a human-readable label for where it
320
+ came from.
321
+ """
322
+ import os
323
+
324
+ env_val = os.environ.get("CONTEXTZIP_WORKSPACE_LOCATION", "").strip()
325
+ if env_val:
326
+ return env_val, "CONTEXTZIP_WORKSPACE_LOCATION env var"
327
+
328
+ from contextzip.project_config import load_project_config
329
+
330
+ project_cfg = load_project_config(project_dir)
331
+ if project_cfg.workspace_location:
332
+ return project_cfg.workspace_location, "project config (.contextzip.json)"
333
+
334
+ from contextzip.config import get_workspace_location
335
+
336
+ personal_val = get_workspace_location()
337
+ if personal_val:
338
+ return personal_val, "personal config"
339
+
340
+ return "git-root", "default"
341
+
342
+
296
343
  def _workspace_dir(project_dir: Path) -> tuple[Path, bool]:
297
344
  """
298
345
  Resolve the .contextzip/ workspace directory.
299
346
 
300
347
  Returns ``(workspace_path, is_git_repo)`` where:
301
- - workspace_path is <git_root>/.contextzip/ when inside a git repo
302
- - workspace_path is <project_dir>/.contextzip/ as a fallback
303
- - is_git_repo indicates whether a git root was found
348
+ - workspace_path depends on the resolved workspace location (see
349
+ _resolve_workspace_location): under the git root by default, under
350
+ project_dir for "cwd", or an explicit path for anything else.
351
+ - is_git_repo indicates whether a git root was found (used to decide
352
+ whether .gitignore registration applies)
304
353
  """
305
354
  git_root = _find_git_root(project_dir)
306
- if git_root is not None:
307
- return git_root / ".contextzip", True
308
- return project_dir / ".contextzip", False
355
+ location, _source = _resolve_workspace_location(project_dir)
356
+ anchor = git_root if git_root is not None else project_dir
357
+
358
+ if location == "git-root":
359
+ workspace = anchor / ".contextzip"
360
+ elif location == "cwd":
361
+ workspace = project_dir / ".contextzip"
362
+ else:
363
+ custom = Path(location).expanduser()
364
+ base = custom if custom.is_absolute() else (anchor / custom)
365
+ workspace = base / ".contextzip"
366
+
367
+ return workspace, git_root is not None
309
368
 
310
369
 
311
370
  def _workspace_output_path_silent(
@@ -327,11 +386,12 @@ def _workspace_output_path_silent(
327
386
  return Path(tempfile.gettempdir()) / filename
328
387
 
329
388
  if is_git_repo:
330
- git_root = workspace.parent
331
- try:
332
- _ensure_gitignore(git_root)
333
- except OSError:
334
- pass
389
+ git_root = _find_git_root(project_dir)
390
+ if git_root is not None and _is_relative_to(workspace, git_root):
391
+ try:
392
+ _ensure_gitignore(git_root)
393
+ except OSError:
394
+ pass
335
395
 
336
396
  return workspace / filename
337
397
 
@@ -364,13 +424,16 @@ def _workspace_output_path(
364
424
  )
365
425
  return Path(tempfile.gettempdir()) / filename
366
426
 
367
- # Handle .gitignore only when we're inside a git repo
427
+ # Handle .gitignore only when we're inside a git repo and the workspace
428
+ # actually lives inside it (a custom absolute path outside the repo has
429
+ # nothing for .gitignore to protect)
368
430
  if is_git_repo:
369
- git_root = workspace.parent # workspace is <git_root>/.contextzip
370
- try:
371
- _ensure_gitignore(git_root)
372
- except OSError:
373
- # Non-fatal: gitignore update failed, carry on silently
374
- pass
431
+ git_root = _find_git_root(project_dir)
432
+ if git_root is not None and _is_relative_to(workspace, git_root):
433
+ try:
434
+ _ensure_gitignore(git_root)
435
+ except OSError:
436
+ # Non-fatal: gitignore update failed, carry on silently
437
+ pass
375
438
 
376
439
  return workspace / filename
@@ -0,0 +1,93 @@
1
+ """
2
+ project_config.py — Project-level, team-shareable contextzip settings.
3
+
4
+ Loaded from a `.contextzip.json` file at the project's git root (or the
5
+ project directory itself outside a git repo) — the same location `.contextzip/`
6
+ itself resolves to by default. Unlike config.py's personal, per-machine store
7
+ (~/.config/contextzip/config.json, holds API keys, never committed), this file
8
+ is meant to be committed to the repo so every contributor gets the same
9
+ project defaults automatically — no per-machine setup required.
10
+
11
+ Currently supports:
12
+
13
+ {
14
+ "workspace_location": "git-root" | "cwd" | "<path>",
15
+ "scan_depth": 2
16
+ }
17
+
18
+ Both keys are optional. Missing or malformed values fall through to the next
19
+ tier in the resolution order (see config.py's workspace_location docstring
20
+ and packager.py's _resolve_workspace_location).
21
+
22
+ Example — a monorepo that wants its workspace at the repo root explicitly
23
+ (the default anyway, but pinned so it's obvious to contributors), committed
24
+ alongside the code:
25
+
26
+ # .contextzip.json (git-tracked)
27
+ { "workspace_location": "git-root" }
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ from dataclasses import dataclass
34
+ from pathlib import Path
35
+
36
+ _PROJECT_CONFIG_FILENAME = ".contextzip.json"
37
+
38
+
39
+ @dataclass
40
+ class ProjectConfig:
41
+ workspace_location: str | None = None
42
+ scan_depth: int | None = None
43
+
44
+
45
+ def project_config_path(project_dir: Path) -> Path:
46
+ """
47
+ Return where a project-level config file would live for *project_dir* —
48
+ the git root if inside a repo, otherwise project_dir itself. Does not
49
+ imply the file exists.
50
+ """
51
+ from contextzip.packager import _find_git_root
52
+
53
+ git_root = _find_git_root(project_dir)
54
+ base = git_root if git_root is not None else project_dir
55
+ return base / _PROJECT_CONFIG_FILENAME
56
+
57
+
58
+ def load_project_config(project_dir: Path) -> ProjectConfig:
59
+ """
60
+ Load `.contextzip.json` for *project_dir*, if present.
61
+
62
+ Never raises — a missing, malformed, or unreadable file just yields an
63
+ empty ProjectConfig, so callers can fall through to the next tier
64
+ without special-casing errors.
65
+ """
66
+ path = project_config_path(project_dir)
67
+ if not path.is_file():
68
+ return ProjectConfig()
69
+
70
+ try:
71
+ data = json.loads(path.read_text(encoding="utf-8"))
72
+ except (json.JSONDecodeError, OSError):
73
+ return ProjectConfig()
74
+
75
+ if not isinstance(data, dict):
76
+ return ProjectConfig()
77
+
78
+ workspace_location = data.get("workspace_location")
79
+ if not isinstance(workspace_location, str) or not workspace_location.strip():
80
+ workspace_location = None
81
+
82
+ scan_depth = data.get("scan_depth")
83
+ if (
84
+ not isinstance(scan_depth, int)
85
+ or isinstance(scan_depth, bool)
86
+ or scan_depth < 0
87
+ ):
88
+ scan_depth = None
89
+
90
+ return ProjectConfig(
91
+ workspace_location=workspace_location,
92
+ scan_depth=scan_depth,
93
+ )
@@ -56,7 +56,7 @@ PATTERNS = [
56
56
  "*.rar",
57
57
  # contextzip workspace directory — always excluded regardless of .gitignore state
58
58
  ".contextzip/",
59
- # GitHub / repo governance (not useful as AI context)
59
+ "exports/",
60
60
  "CHANGELOG.md",
61
61
  "CHANGELOG",
62
62
  "CONTRIBUTING.md",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: contextzip
3
- Version: 0.3.3
3
+ Version: 0.3.5
4
4
  Summary: Intelligently package your codebase for AI tools
5
5
  Author-email: Deepesh <akadeepesh@gmail.com>
6
6
  License-Expression: MIT
@@ -47,12 +47,13 @@ contextzip eliminates that entirely. Run it from your project root — it detect
47
47
 
48
48
  ## Features
49
49
 
50
- - **Smart framework detection** — automatically identifies Node.js, Next.js, Python, Django, FastAPI, Rust, Go, and Ruby, applying the right exclusion rules for each
50
+ - **Smart framework detection** — automatically identifies Node.js, Next.js, Python, Django, FastAPI, Rust, Go, and Ruby, applying the right exclusion rules for each. Detection isn't limited to the project root: a shallow, bounded scan of subdirectories means a monorepo (`frontend/` + `backend/`, etc.) gets every ecosystem it contains detected and excluded correctly, not just whatever sits at the top level
51
51
  - **Respects `.gitignore`** — your existing ignore patterns are honoured automatically
52
52
  - **Git-aware packaging** — use `--git-changes` to package only modified, staged, and untracked files; perfect for incremental debugging and PR review sessions
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
- - **Persistent workspace** — all generated ZIPs land in `.contextzip/` at your project root, discoverable, reusable, and git-ignored automatically
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.json`
56
+ - **Persistent workspace** — all generated ZIPs land in `.contextzip/`, discoverable, reusable, and git-ignored automatically
56
57
  - **Warns before it's a problem** — flags large (≥ 1 MB) and binary files that AI tools can't read, before you waste an upload
57
58
  - **Handles edge cases** — dangling symlinks, unreadable files, and paths outside the project tree are caught and reported, never silently dropped
58
59
  - **Full CLI control** — `--include`, `--exclude`, `--dry-run`, `--output`, all composable
@@ -247,7 +248,7 @@ Press **D** and contextzip immediately writes `.contextzip/debug-context.zip`. Y
247
248
 
248
249
  contextzip stacks exclusion rules based on your detected stack, on top of your `.gitignore`.
249
250
 
250
- **Always excluded:** `.git/`, `.env` files, logs, caches, editor config (`.vscode/`, `.idea/`), OS files (`.DS_Store`, `Thumbs.db`), and common binary formats.
251
+ **Always excluded:** `.git/`, `.env` files, logs, caches, editor config (`.vscode/`, `.idea/`), OS files (`.DS_Store`, `Thumbs.db`), common binary formats, and contextzip's own `.contextzip/` working folder.
251
252
 
252
253
  **By framework:**
253
254
 
@@ -258,7 +259,38 @@ contextzip stacks exclusion rules based on your detected stack, on top of your `
258
259
  | Rust | `target/`, `Cargo.lock`, `*.rlib` |
259
260
  | Go | `vendor/`, `go.sum`, `bin/` |
260
261
 
261
- Detection is additive — a monorepo with both `package.json` and `pyproject.toml` gets both rule sets applied.
262
+ Detection is additive — a monorepo with both `package.json` and `pyproject.toml` gets both rule sets applied. Marker files don't have to sit at the project root: contextzip also does a shallow, bounded scan of subdirectories (2 levels deep, skipping `node_modules/`, `.venv/`, `.git/`, and other dependency/build dirs it would never want to look inside anyway), so a layout like
263
+
264
+ ```
265
+ root/
266
+ frontend/package.json
267
+ backend/requirements.txt
268
+ ```
269
+
270
+ detects both Node.js and Python from `root/`, without either marker existing at the top level. Run with default output (not `--dry-run --verbose`) and you'll see which subdirectory each ecosystem came from, e.g. `Next.js (frontend/) + FastAPI (backend/)`.
271
+
272
+ ---
273
+
274
+ ## Workspace location
275
+
276
+ By default, `.contextzip/` is created at your git root — that's `_find_git_root()` walking up from the current directory until it finds a `.git` folder; outside a repo, it falls back to the current directory. This can be overridden two ways, in order of priority:
277
+
278
+ 1. **`CONTEXTZIP_WORKSPACE_LOCATION` environment variable** — for a one-off override on a single run
279
+ 2. **A committed `.contextzip.json` at the project root** — team-shared, applies to everyone who clones the repo:
280
+ ```json
281
+ { "workspace_location": "git-root" }
282
+ ```
283
+ 3. **Your personal config** — a per-machine default that doesn't get committed:
284
+ ```bash
285
+ contextzip config --set-workspace cwd # always use ./.contextzip wherever you run it
286
+ contextzip config --set-workspace git-root # back to the default
287
+ contextzip config --set-workspace ~/zips # a fixed custom location, anywhere
288
+ contextzip config --reset-workspace # clear your personal override
289
+ ```
290
+
291
+ Accepted values are `"git-root"`, `"cwd"`, or any path (absolute, or relative to the git root). Run `contextzip config` with no flags to see which value is currently active and where it came from.
292
+
293
+ This matters most for monorepos where you sometimes run contextzip from a subdirectory (`cd frontend && contextzip`) — with the default `git-root` setting, the workspace still lands at the repo root no matter where you ran it from, so you don't end up with scattered `.contextzip/` folders across `frontend/`, `backend/`, etc. Set `workspace_location: "cwd"` in a project's `.contextzip.json` instead if you'd rather each subproject keep its own.
262
294
 
263
295
  ---
264
296
 
@@ -14,6 +14,7 @@ contextzip/error_parser.py
14
14
  contextzip/filters.py
15
15
  contextzip/git.py
16
16
  contextzip/packager.py
17
+ contextzip/project_config.py
17
18
  contextzip/watcher.py
18
19
  contextzip.egg-info/PKG-INFO
19
20
  contextzip.egg-info/SOURCES.txt
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "contextzip"
7
- version = "0.3.3"
7
+ version = "0.3.5"
8
8
  description = "Intelligently package your codebase for AI tools"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
File without changes
File without changes
File without changes