first commit

This commit is contained in:
akadeepesh
2026-05-14 09:04:41 +05:30
commit d73dae5bcc
12 changed files with 846 additions and 0 deletions
+62
View File
@@ -0,0 +1,62 @@
# contextzip
> Intelligently package your codebase for AI tools — no noise, just signal.
## Install
```bash
pip install -e .
# or once published:
pip install contextzip
```
## Usage
```bash
# Run from your project root
contextzip
# Preview what would be included (no ZIP created)
contextzip --dry-run
# See every file decision
contextzip --dry-run --verbose
# Only include specific folders
contextzip --include src app routes
# Add extra exclusions on top of auto-rules
contextzip --exclude "*.log" "*.sqlite" temp.js
# Save ZIP to a specific path
contextzip --output ~/Desktop/myproject.zip
# Create ZIP without clipboard copy
contextzip --no-clipboard
```
## What gets auto-excluded
### Always (base rules)
`.git/`, `.env`, `*.log`, editor files, OS artifacts, binaries, media
### Node.js / Next.js
`node_modules/`, `.next/`, `dist/`, `package-lock.json`, `yarn.lock`
### Python
`__pycache__/`, `.venv/`, `.pytest_cache/`, `*.pyc`, `migrations/`
### Rust
`target/`, `Cargo.lock`
### Go
`vendor/`, `go.sum`
## Status
| Phase | Feature | Status |
|-------|---------|--------|
| 1+2 | Scaffold + Detection | ✅ Done |
| 3 | ZIP creation | 🔜 Next |
| 4 | Clipboard integration | 🔜 Planned |
| 5 | Rich output polish | 🔜 Planned |
+3
View File
@@ -0,0 +1,3 @@
"""contextzip — intelligent codebase packager for AI tools."""
__version__ = "0.1.0"
+248
View File
@@ -0,0 +1,248 @@
"""
cli.py — contextzip entry point.
Phase 1 + 2: scaffold, detection, dry-run, and rich output.
Packaging (Phase 3) and clipboard (Phase 4) are stubbed with clear TODOs.
"""
from __future__ import annotations
import os
from pathlib import Path
import click
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from rich import box
from rich.text import Text
from contextzip.detector import detect
from contextzip.filters import build_spec, resolve_files, summarise_exclusions
console = Console()
# ---------------------------------------------------------------------------
# CLI definition
# ---------------------------------------------------------------------------
@click.command(context_settings={"help_option_names": ["-h", "--help"]})
@click.option(
"--include", "-i",
multiple=True,
metavar="PATH",
help="Only include files under these paths (relative to project root). "
"Can be repeated: --include src --include app",
)
@click.option(
"--exclude", "-e",
multiple=True,
metavar="PATTERN",
help="Extra exclusion patterns on top of auto-rules (gitignore syntax). "
"Can be repeated: --exclude '*.log' --exclude temp.js",
)
@click.option(
"--dry-run", "-n",
is_flag=True,
default=False,
help="Show what would be included without creating the ZIP.",
)
@click.option(
"--output", "-o",
default=None,
metavar="FILE",
help="Path for the output ZIP file. "
"Defaults to <project_name>_context_<timestamp>.zip in the system temp dir.",
)
@click.option(
"--no-clipboard",
is_flag=True,
default=False,
help="Skip clipboard copy after creating the ZIP.",
)
@click.option(
"--verbose", "-v",
is_flag=True,
default=False,
help="Show every included and excluded file.",
)
@click.version_option(version="0.1.0", prog_name="contextzip")
def main(
include: tuple[str, ...],
exclude: tuple[str, ...],
dry_run: bool,
output: str | None,
no_clipboard: bool,
verbose: bool,
) -> None:
"""
\b
contextzip — package your codebase for AI tools.
Run from your project root to produce a smart, lightweight ZIP
ready to paste directly into Claude, ChatGPT, or any AI interface.
"""
project_dir = Path(os.getcwd()).resolve()
# ── Header ──────────────────────────────────────────────────────────────
console.print()
console.print(
Panel.fit(
f"[bold cyan]contextzip[/] [dim]v0.1.0[/]\n"
f"[dim]Project:[/] [white]{project_dir}[/]",
border_style="cyan",
padding=(0, 2),
)
)
console.print()
# ── Detection ───────────────────────────────────────────────────────────
with console.status("[cyan]Detecting project ecosystem…[/]", spinner="dots"):
detection = detect(project_dir)
_print_detection(detection)
# ── Build exclusion spec ────────────────────────────────────────────────
with console.status("[cyan]Building exclusion rules…[/]", spinner="dots"):
spec = build_spec(
rule_modules=detection.rule_modules,
extra_exclude=list(exclude) if exclude else None,
)
# ── Resolve files ────────────────────────────────────────────────────────
with console.status("[cyan]Scanning project files…[/]", spinner="dots"):
included, excluded = resolve_files(
project_dir=project_dir,
spec=spec,
include_only=list(include) if include else None,
)
# ── Results ─────────────────────────────────────────────────────────────
_print_results(included, excluded, project_dir, verbose)
if dry_run:
console.print()
console.print(
Panel.fit(
"[yellow]Dry run — no ZIP created.[/]\n"
"[dim]Remove --dry-run to produce the archive.[/]",
border_style="yellow",
padding=(0, 2),
)
)
return
if not included:
console.print(
"\n[red]Nothing to package.[/] All files were excluded — "
"try [cyan]--include[/] to override."
)
return
# ── TODO Phase 3: Create ZIP ─────────────────────────────────────────────
console.print()
console.print(
Panel.fit(
"[bold yellow]⚙ Phase 3 coming next[/]\n"
"[dim]ZIP creation will be wired in the next phase.[/]",
border_style="yellow",
padding=(0, 2),
)
)
# ── TODO Phase 4: Clipboard ──────────────────────────────────────────────
if not no_clipboard:
pass # clipboard logic arrives in Phase 4
# ---------------------------------------------------------------------------
# Display helpers
# ---------------------------------------------------------------------------
def _print_detection(detection) -> None:
"""Render a detection summary panel."""
if detection.is_unknown:
ecosystem_line = "[yellow]Unknown[/] — applying base rules only"
else:
colours = {
"Next.js": "bright_blue",
"Node.js": "green",
"Python": "yellow",
"Django": "green",
"FastAPI": "cyan",
"Rust": "red",
"Go": "cyan",
"Ruby": "red",
}
parts = []
for name in detection.ecosystems:
colour = colours.get(name, "white")
parts.append(f"[{colour}]{name}[/]")
ecosystem_line = " [dim]+[/] ".join(parts)
confidence_colour = {"high": "green", "medium": "yellow", "low": "dim"}.get(
detection.confidence, "dim"
)
rules_str = ", ".join(detection.rule_modules)
console.print(
Panel(
f" [dim]Ecosystem :[/] {ecosystem_line}\n"
f" [dim]Confidence:[/] [{confidence_colour}]{detection.confidence}[/]\n"
f" [dim]Rules :[/] [dim]{rules_str}[/]",
title="[bold]Detection[/]",
border_style="blue",
padding=(0, 1),
)
)
console.print()
def _print_results(
included: list[Path],
excluded: list[Path],
project_dir: Path,
verbose: bool,
) -> None:
"""Render file inclusion/exclusion summary."""
total = len(included) + len(excluded)
included_size = sum(p.stat().st_size for p in included if p.exists())
# Summary table
table = Table(box=box.ROUNDED, show_header=False, padding=(0, 2))
table.add_column(style="dim")
table.add_column()
table.add_row("Files scanned", str(total))
table.add_row(
"To be included",
f"[green]{len(included)}[/] [dim]({_human_size(included_size)})[/]",
)
table.add_row("Excluded", f"[red]{len(excluded)}[/]")
console.print(table)
# Verbose: show every included file
if verbose and included:
console.print()
console.print("[bold]Included files:[/]")
for p in included:
rel = p.relative_to(project_dir).as_posix()
console.print(f" [green]✓[/] {rel}")
# Always show top excluded buckets
if excluded:
console.print()
buckets = summarise_exclusions(excluded, project_dir)
console.print("[bold]Top excluded directories / files:[/]")
for label, count in list(buckets.items())[:8]:
console.print(f" [red]✗[/] [dim]{label}[/] [dim]({count} file{'s' if count != 1 else ''})[/]")
def _human_size(n: int) -> str:
for unit in ("B", "KB", "MB", "GB"):
if n < 1024:
return f"{n:.0f} {unit}"
n /= 1024
return f"{n:.1f} TB"
+218
View File
@@ -0,0 +1,218 @@
"""
detector.py — Analyses a project directory and identifies the ecosystem(s) present.
Detection is additive: base rules always apply, and each detected framework
stacks its own rules on top. A monorepo can match multiple ecosystems at once.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable
# ---------------------------------------------------------------------------
# Data model
# ---------------------------------------------------------------------------
@dataclass
class DetectionResult:
"""Everything the detector knows about a project directory."""
ecosystems: list[str] = field(default_factory=list) # e.g. ["Node.js", "Next.js"]
rule_modules: list[str] = field(default_factory=list) # e.g. ["base", "node"]
confidence: str = "low" # "low" | "medium" | "high"
@property
def display_name(self) -> str:
if not self.ecosystems:
return "Unknown"
return " + ".join(self.ecosystems)
@property
def is_unknown(self) -> bool:
return not self.ecosystems
# ---------------------------------------------------------------------------
# Detection rules — ordered from most specific to least specific
# ---------------------------------------------------------------------------
@dataclass
class _Rule:
"""A single detection rule."""
name: str # Human-readable ecosystem name e.g. "Next.js"
module: str # Rule module key e.g. "node"
check: Callable[[Path], bool]
weight: int = 1 # Higher = stronger signal
_RULES: list[_Rule] = [
# ── Next.js (must come before generic Node.js) ────────────────────────
_Rule(
name="Next.js",
module="node",
check=lambda p: (
_file_exists(p, "next.config.js")
or _file_exists(p, "next.config.ts")
or _file_exists(p, "next.config.mjs")
or _dep_present(p, "next")
),
weight=3,
),
# ── Node.js / generic JS+TS ───────────────────────────────────────────
_Rule(
name="Node.js",
module="node",
check=lambda p: _file_exists(p, "package.json"),
weight=2,
),
# ── Django (must come before generic Python) ──────────────────────────
_Rule(
name="Django",
module="python",
check=lambda p: _file_exists(p, "manage.py") or _dep_present_py(p, "django"),
weight=3,
),
# ── FastAPI ───────────────────────────────────────────────────────────
_Rule(
name="FastAPI",
module="python",
check=lambda p: _dep_present_py(p, "fastapi"),
weight=3,
),
# ── Generic Python ────────────────────────────────────────────────────
_Rule(
name="Python",
module="python",
check=lambda p: (
_file_exists(p, "requirements.txt")
or _file_exists(p, "pyproject.toml")
or _file_exists(p, "setup.py")
or _file_exists(p, "setup.cfg")
or _file_exists(p, "Pipfile")
),
weight=2,
),
# ── Rust ──────────────────────────────────────────────────────────────
_Rule(
name="Rust",
module="rust",
check=lambda p: _file_exists(p, "Cargo.toml"),
weight=3,
),
# ── Go ────────────────────────────────────────────────────────────────
_Rule(
name="Go",
module="go",
check=lambda p: _file_exists(p, "go.mod"),
weight=3,
),
# ── Ruby ─────────────────────────────────────────────────────────────
_Rule(
name="Ruby",
module="base", # no dedicated rule module yet; base covers it
check=lambda p: _file_exists(p, "Gemfile"),
weight=2,
),
]
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def detect(project_dir: Path) -> DetectionResult:
"""
Analyse *project_dir* and return a :class:`DetectionResult`.
Always includes "base" in rule_modules.
"""
result = DetectionResult(rule_modules=["base"])
seen_modules: set[str] = {"base"}
seen_names: set[str] = set()
total_weight = 0
for rule in _RULES:
try:
matched = rule.check(project_dir)
except Exception:
matched = False
if matched:
if rule.name not in seen_names:
result.ecosystems.append(rule.name)
seen_names.add(rule.name)
if rule.module not in seen_modules:
result.rule_modules.append(rule.module)
seen_modules.add(rule.module)
total_weight += rule.weight
# Confidence based on cumulative signal weight
if total_weight >= 4:
result.confidence = "high"
elif total_weight >= 2:
result.confidence = "medium"
elif total_weight >= 1:
result.confidence = "low"
return result
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _file_exists(project_dir: Path, filename: str) -> bool:
return (project_dir / filename).exists()
def _dep_present(project_dir: Path, dep: str) -> bool:
"""Check if *dep* appears in package.json dependencies."""
pkg = project_dir / "package.json"
if not pkg.exists():
return False
try:
import json
data = json.loads(pkg.read_text(encoding="utf-8", errors="ignore"))
all_deps = {
**data.get("dependencies", {}),
**data.get("devDependencies", {}),
**data.get("peerDependencies", {}),
}
return dep in all_deps
except Exception:
return False
def _dep_present_py(project_dir: Path, dep: str) -> bool:
"""Check if *dep* appears in requirements.txt or pyproject.toml."""
req = project_dir / "requirements.txt"
if req.exists():
try:
content = req.read_text(encoding="utf-8", errors="ignore").lower()
if dep.lower() in content:
return True
except Exception:
pass
ppt = project_dir / "pyproject.toml"
if ppt.exists():
try:
content = ppt.read_text(encoding="utf-8", errors="ignore").lower()
if dep.lower() in content:
return True
except Exception:
pass
return False
+141
View File
@@ -0,0 +1,141 @@
"""
filters.py — Loads exclusion patterns from rule modules and resolves
which files in a project directory should be included or excluded.
"""
from __future__ import annotations
import importlib
from pathlib import Path
import pathspec
# ---------------------------------------------------------------------------
# Rule registry — maps module key → importable module path
# ---------------------------------------------------------------------------
_RULE_REGISTRY: dict[str, str] = {
"base": "contextzip.rules.base",
"node": "contextzip.rules.node",
"python": "contextzip.rules.python",
"rust": "contextzip.rules.rust",
"go": "contextzip.rules.go",
}
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def build_spec(
rule_modules: list[str],
extra_exclude: list[str] | None = None,
) -> pathspec.PathSpec:
"""
Combine patterns from the given *rule_modules* (plus optional
*extra_exclude* patterns) into a single :class:`pathspec.PathSpec`.
The spec can then be used to test whether a path should be excluded.
"""
patterns: list[str] = []
for key in rule_modules:
module_path = _RULE_REGISTRY.get(key)
if module_path is None:
continue
try:
mod = importlib.import_module(module_path)
patterns.extend(getattr(mod, "PATTERNS", []))
except ImportError:
pass
if extra_exclude:
patterns.extend(extra_exclude)
return pathspec.PathSpec.from_lines("gitwildmatch", patterns)
def resolve_files(
project_dir: Path,
spec: pathspec.PathSpec,
include_only: list[str] | None = None,
) -> tuple[list[Path], list[Path]]:
"""
Walk *project_dir* and split every file into included / excluded lists.
Parameters
----------
project_dir:
Root of the project to scan.
spec:
The exclusion spec built by :func:`build_spec`.
include_only:
If provided, only files whose relative path starts with one of
these prefixes will be considered (after exclusions are applied).
Returns
-------
(included, excluded)
Both lists contain absolute :class:`Path` objects.
"""
included: list[Path] = []
excluded: list[Path] = []
for abs_path in sorted(project_dir.rglob("*")):
if not abs_path.is_file():
continue
rel = abs_path.relative_to(project_dir)
rel_str = rel.as_posix()
# Check directory-level exclusion — match any path component
if _any_parent_excluded(rel, spec):
excluded.append(abs_path)
continue
# Check file-level exclusion
if spec.match_file(rel_str):
excluded.append(abs_path)
continue
# Apply --include filter if set
if include_only:
if not any(rel_str.startswith(prefix.rstrip("/")) for prefix in include_only):
excluded.append(abs_path)
continue
included.append(abs_path)
return included, excluded
def summarise_exclusions(excluded: list[Path], project_dir: Path) -> dict[str, int]:
"""
Group excluded files by their top-level directory / filename for display.
Returns a dict of { label: count } sorted by count descending.
"""
buckets: dict[str, int] = {}
for p in excluded:
rel = p.relative_to(project_dir)
top = rel.parts[0] if rel.parts else str(rel)
buckets[top] = buckets.get(top, 0) + 1
return dict(sorted(buckets.items(), key=lambda x: x[1], reverse=True))
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _any_parent_excluded(rel: Path, spec: pathspec.PathSpec) -> bool:
"""
Return True if any *directory* component of *rel* is matched by *spec*.
This catches patterns like ``node_modules/`` that should exclude the
entire subtree, not just the top-level directory entry.
"""
parts = rel.parts[:-1] # all directory components, no filename
for i in range(len(parts)):
dir_path = "/".join(parts[: i + 1]) + "/"
if spec.match_file(dir_path):
return True
return False
View File
+67
View File
@@ -0,0 +1,67 @@
"""
Universal exclusion rules applied to every project regardless of language.
"""
PATTERNS = [
# Version control
".git/",
".gitignore",
".svn/",
".hg/",
# OS artifacts
".DS_Store",
"Thumbs.db",
"desktop.ini",
# Editor & IDE
".vscode/",
".idea/",
"*.swp",
"*.swo",
"*.swn",
".vim/",
# Logs
"*.log",
"logs/",
"log/",
# Temp & cache
".cache/",
"tmp/",
"temp/",
"*.tmp",
"*.bak",
"*.orig",
# Secrets & env
".env",
".env.*",
"*.pem",
"*.key",
"*.p12",
"*.pfx",
# Binaries & media (rarely useful for AI context)
"*.exe",
"*.dll",
"*.so",
"*.dylib",
"*.bin",
"*.png",
"*.jpg",
"*.jpeg",
"*.gif",
"*.svg",
"*.ico",
"*.mp4",
"*.mp3",
"*.zip",
"*.tar",
"*.gz",
"*.rar",
# The output zip itself (avoid recursion)
"*_context_*.zip",
]
+10
View File
@@ -0,0 +1,10 @@
"""
Go module exclusion rules.
"""
PATTERNS = [
"vendor/",
"*.test",
"go.sum",
"bin/",
]
+29
View File
@@ -0,0 +1,29 @@
"""
Node.js, Next.js, and general JS/TS framework exclusion rules.
"""
PATTERNS = [
"node_modules/",
".next/",
".nuxt/",
"dist/",
"build/",
"out/",
".turbo/",
".vercel/",
".netlify/",
"coverage/",
".nyc_output/",
"*.min.js",
"*.min.css",
"package-lock.json",
"yarn.lock",
"pnpm-lock.yaml",
".pnp.js",
".pnp.cjs",
".yarn/",
"storybook-static/",
".storybook/",
"*.d.ts", # generated type declarations
"tsconfig.tsbuildinfo",
]
+36
View File
@@ -0,0 +1,36 @@
"""
Python, Django, FastAPI, and general Python project exclusion rules.
"""
PATTERNS = [
"__pycache__/",
"*.pyc",
"*.pyo",
"*.pyd",
".Python",
".venv/",
"venv/",
"env/",
".env/",
"ENV/",
"*.egg-info/",
"*.egg",
"dist/",
"build/",
".pytest_cache/",
".mypy_cache/",
".ruff_cache/",
".hypothesis/",
"htmlcov/",
"coverage.xml",
".coverage",
"*.sqlite3",
"*.db",
"site/", # mkdocs build output
".tox/",
"poetry.lock",
"uv.lock",
"migrations/", # Django migrations (auto-generated)
"staticfiles/", # Django collected static
"media/", # Django uploaded media
]
+10
View File
@@ -0,0 +1,10 @@
"""
Rust (Cargo) exclusion rules.
"""
PATTERNS = [
"target/",
"Cargo.lock", # exclude for libraries; keep for binaries — we exclude for AI context
"*.rlib",
"*.rmeta",
]
+22
View File
@@ -0,0 +1,22 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "contextzip"
version = "0.1.0"
description = "Intelligently package your codebase for AI tools"
readme = "README.md"
requires-python = ">=3.9"
dependencies = [
"click>=8.1",
"rich>=13.0",
"pathspec>=0.12",
]
[project.scripts]
contextzip = "contextzip.cli:main"
[tool.setuptools.packages.find]
where = ["."]
include = ["contextzip*"]