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.
- {contextzip-0.3.3 → contextzip-0.3.5}/PKG-INFO +37 -5
- {contextzip-0.3.3 → contextzip-0.3.5}/README.md +36 -4
- {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/__init__.py +1 -1
- {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/cli.py +75 -33
- {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/cli_display.py +8 -1
- {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/clipboard.py +6 -16
- {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/config.py +51 -1
- {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/detector.py +122 -12
- {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/git.py +11 -11
- {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/packager.py +91 -28
- contextzip-0.3.5/contextzip/project_config.py +93 -0
- {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/rules/base.py +1 -1
- {contextzip-0.3.3 → contextzip-0.3.5}/contextzip.egg-info/PKG-INFO +37 -5
- {contextzip-0.3.3 → contextzip-0.3.5}/contextzip.egg-info/SOURCES.txt +1 -0
- {contextzip-0.3.3 → contextzip-0.3.5}/pyproject.toml +1 -1
- {contextzip-0.3.3 → contextzip-0.3.5}/LICENSE +0 -0
- {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/ai/__init__.py +0 -0
- {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/ai/gemini.py +0 -0
- {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/ai/heuristic.py +0 -0
- {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/ai/selector.py +0 -0
- {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/api.py +0 -0
- {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/cli_ai.py +0 -0
- {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/cli_onboard.py +0 -0
- {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/error_parser.py +0 -0
- {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/filters.py +0 -0
- {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/rules/__init__.py +0 -0
- {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/rules/errors/__init__.py +0 -0
- {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/rules/errors/node.py +0 -0
- {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/rules/errors/python.py +0 -0
- {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/rules/go.py +0 -0
- {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/rules/node.py +0 -0
- {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/rules/python.py +0 -0
- {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/rules/ruby.py +0 -0
- {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/rules/rust.py +0 -0
- {contextzip-0.3.3 → contextzip-0.3.5}/contextzip/watcher.py +0 -0
- {contextzip-0.3.3 → contextzip-0.3.5}/contextzip.egg-info/dependency_links.txt +0 -0
- {contextzip-0.3.3 → contextzip-0.3.5}/contextzip.egg-info/entry_points.txt +0 -0
- {contextzip-0.3.3 → contextzip-0.3.5}/contextzip.egg-info/requires.txt +0 -0
- {contextzip-0.3.3 → contextzip-0.3.5}/contextzip.egg-info/top_level.txt +0 -0
- {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
|
+
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
|
-
- **
|
|
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`),
|
|
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
|
-
- **
|
|
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`),
|
|
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,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(
|
|
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
|
|
423
|
-
contextzip config --
|
|
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 = [
|
|
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
|
|
39
|
-
FOLDER_OPENED = 2
|
|
40
|
-
PATH_ONLY = 3
|
|
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
|
|
47
|
-
success: bool = True
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
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)
|
|
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)
|
|
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]
|
|
187
|
-
rel_path = raw_line[3:].strip()
|
|
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]
|
|
194
|
-
y = xy[1]
|
|
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
|
|
270
|
+
Ensure *entry* is listed in <git_root>/.gitignore.
|
|
271
271
|
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|
283
|
-
return
|
|
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{
|
|
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{
|
|
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
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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 =
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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 =
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: contextzip
|
|
3
|
-
Version: 0.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
|
-
- **
|
|
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`),
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|