mirror of
https://github.com/akadeepesh/contextzip.git
synced 2026-10-20 08:36:20 +02:00
Remove Claude.ai session key handling and related artifact fetching functionality
This commit is contained in:
@@ -38,3 +38,6 @@ desktop.ini
|
||||
|
||||
# contextzip workspace
|
||||
.contextzip/
|
||||
|
||||
# contextzip workspace
|
||||
exports/
|
||||
|
||||
-454
@@ -1,454 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to contextzip are documented here.
|
||||
|
||||
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
This project uses [Semantic Versioning](https://semver.org/).
|
||||
|
||||
---
|
||||
|
||||
## [0.1.1] — 2026-05-15
|
||||
|
||||
### Fixed
|
||||
- LICENSE link in README now points to GitHub instead of a relative path that broke on PyPI
|
||||
- License metadata updated to SPDX string format (`"MIT"`) to comply with setuptools ≥ 77
|
||||
|
||||
---
|
||||
|
||||
## [0.1.2] — 2026-05-15
|
||||
|
||||
### Added
|
||||
- Smart framework and language detection (Next.js, Node.js, Python, Django, FastAPI, Rust, Go, Ruby)
|
||||
- Automatic exclusion rules per detected ecosystem
|
||||
- `.gitignore` patterns respected automatically (`--no-gitignore` to bypass)
|
||||
- `--include` flag for scoping to specific directories (exact path-boundary matching)
|
||||
- `--exclude` flag for additional gitignore-style exclusion patterns
|
||||
- `--dry-run` flag to preview without creating any files
|
||||
- `--verbose` flag showing every file with its size
|
||||
- `--output` flag for custom ZIP destination
|
||||
- `--no-clipboard` flag to skip clipboard/folder-open step
|
||||
- Tiered clipboard strategy: file-on-clipboard (macOS/Linux), Explorer with file selected (Windows), path fallback
|
||||
- Warnings for large files (≥ 1 MB), binary files, and skipped files (dangling symlinks, permission errors)
|
||||
- Rich terminal output with panels, progress bars, and colour-coded detection results
|
||||
- Compression stats (ratio, size before/after) shown after ZIP creation
|
||||
|
||||
---
|
||||
|
||||
## [0.2.0] — 2026-05-15
|
||||
|
||||
### Added
|
||||
- `--git-changes` mode for packaging only files reported as changed by git
|
||||
- Automatic detection of:
|
||||
- staged files
|
||||
- unstaged modifications
|
||||
- untracked files
|
||||
- Dedicated git-aware packaging pipeline optimised for AI/code review workflows
|
||||
- Rich git change summary panel with staged/unstaged/untracked breakdown
|
||||
- Verbose git mode output showing categorized changed files
|
||||
- Graceful handling for:
|
||||
- repositories with no changes
|
||||
- deleted files
|
||||
- submodules
|
||||
- repositories outside the selected project tree
|
||||
- missing git installations
|
||||
- non-git directories
|
||||
- Support for monorepo-aware repository root resolution
|
||||
- Binary and large-file checks now also apply in git-changes mode
|
||||
|
||||
### Improved
|
||||
- File resolution architecture refactored to support multiple packaging strategies cleanly
|
||||
- Internal file scanning pipeline separated from git-based file selection logic
|
||||
- CLI output consistency improved between standard scan mode and git-changes mode
|
||||
|
||||
### Developer Notes
|
||||
- Introduced new `contextzip.git` module for git integration and porcelain parsing
|
||||
- Added `resolve_files_from_git()` pipeline for pre-selected file resolution
|
||||
- Added structured `GitChanges` and `GitError` models for cleaner error handling and extensibility
|
||||
|
||||
---
|
||||
|
||||
## [0.2.1] — 2026-05-16
|
||||
|
||||
### Fixed
|
||||
- `--exclude` / `-e` patterns containing Windows-style paths (e.g. `.\CHANGELOG.md`) were silently ignored due to backslash and leading `.\` — patterns are now normalized to forward-slash posix paths before matching
|
||||
- Files passed to `-e` were not being excluded from the ZIP despite appearing in the exclusion list
|
||||
|
||||
### Improved
|
||||
- `-e` now accepts multiple space-separated patterns in a single flag invocation: `-e file1 file2 folder/` in addition to the existing repeatable form `-e file1 -e file2`
|
||||
- Folder exclusion via `-e folder/*` is now supported and normalized to `folder/` automatically
|
||||
|
||||
## [0.2.2] - 2026-05-16
|
||||
|
||||
### Added
|
||||
- `contextzip exclude PATTERN…` subcommand — exclude multiple files and folders
|
||||
in a single invocation without repeating `-e` (e.g. `contextzip exclude CHANGELOG.md LICENSE .github/`)
|
||||
- `contextzip include PATH…` subcommand — symmetric counterpart to `exclude`;
|
||||
packages only the specified paths (e.g. `contextzip include src/ app/`)
|
||||
- All modifier flags (`--dry-run`, `--verbose`, `--output`, `--no-clipboard`,
|
||||
`--git-changes`, `--no-gitignore`) work on both subcommands
|
||||
|
||||
### Changed
|
||||
- `-e` / `--exclude` flag on the main command restored and kept for full
|
||||
backwards compatibility alongside the new subcommand
|
||||
- Warning messages for large files now mention both `-e PATTERN` and
|
||||
`contextzip exclude PATTERN` as remediation options
|
||||
- `_normalize_pattern` promoted to a named module-level function (testable,
|
||||
documented); behaviour is unchanged
|
||||
|
||||
### Fixed
|
||||
- Removed the previous positional-argument approach to exclusions on the main
|
||||
command, which was ambiguous and undiscoverable
|
||||
|
||||
---
|
||||
|
||||
## [0.2.3] — 2026-05-19
|
||||
|
||||
### Added
|
||||
- Dedicated Ruby ecosystem exclusion rules via new `contextzip.rules.ruby` module
|
||||
- Automatic exclusion of common Ruby / Rails artifacts:
|
||||
- Bundler directories and lockfiles
|
||||
- Rails logs, temp files, assets, storage, and Spring cache
|
||||
- Built gems and compiled Ruby extensions
|
||||
- Coverage and generated documentation directories
|
||||
- Additional package discovery keywords for AI tooling and code packaging:
|
||||
- `claude`
|
||||
- `chatgpt`
|
||||
- `code-packaging`
|
||||
|
||||
### Changed
|
||||
- Ruby project detection now uses the dedicated Ruby rule module instead of the generic base rules
|
||||
- Git-based packaging now enforces base exclusion safety rules even for tracked files
|
||||
- Repository governance and metadata files are now excluded by default:
|
||||
- `CHANGELOG*`
|
||||
- `LICENSE*`
|
||||
- `CONTRIBUTING*`
|
||||
- `SECURITY.md`
|
||||
- `.github` issue / PR templates
|
||||
- `.gitignore` is no longer excluded automatically from packaged context archives
|
||||
|
||||
### Fixed
|
||||
- Prevented git-tracked sensitive files (e.g. `.env`, secrets, binaries) from being included in `--git-changes` mode
|
||||
- Improved ZIP packaging resilience when file stat operations fail during archive creation
|
||||
- Fixed progress tracking during ZIP generation to avoid repeated filesystem stat calls
|
||||
- Fixed Ruby framework detection routing so Ruby projects receive ecosystem-specific exclusion behaviour
|
||||
|
||||
---
|
||||
|
||||
## [0.2.4] — 2026-05-20
|
||||
|
||||
### Added
|
||||
|
||||
* Persistent `.contextzip/` workspace directory for generated context packages
|
||||
* Automatic output naming based on packaging mode:
|
||||
|
||||
* `codebase.zip` for standard packaging
|
||||
* `changes.zip` for `--git-changes`
|
||||
* Automatic `.contextzip/` exclusion from packaged archives and file scanning
|
||||
* Automatic `.gitignore` integration:
|
||||
|
||||
* `.contextzip/` is appended when a git repository is detected
|
||||
* missing entries are added only once
|
||||
* Repository-root-aware workspace placement:
|
||||
|
||||
* `.contextzip/` is created at the git repository root when `.git/` is detected
|
||||
* falls back to the current working directory outside git repositories
|
||||
|
||||
### Changed
|
||||
|
||||
* ZIP outputs are now written to the persistent `.contextzip/` workspace by default instead of temporary system directories
|
||||
* Generated archives now overwrite previous outputs with the same logical name instead of creating temporary randomized filenames
|
||||
* `--output` now bypasses all `.contextzip/` workspace logic and writes directly to the user-specified destination
|
||||
|
||||
### Improved
|
||||
|
||||
* Generated context packages are now easier to rediscover, reuse, and inspect across AI-assisted development workflows
|
||||
* Workspace output structure now provides a foundation for future features such as:
|
||||
|
||||
* timestamped package history
|
||||
* metadata indexing
|
||||
* incremental packaging
|
||||
* AI debug sessions
|
||||
* package caching
|
||||
|
||||
### Fixed
|
||||
|
||||
* Prevented `.contextzip/` contents from recursively packaging previous generated archives
|
||||
* Improved fallback behaviour when workspace creation fails due to filesystem permissions or read-only directories
|
||||
|
||||
## [0.3.0] — 2026-05-24
|
||||
|
||||
### Added
|
||||
|
||||
* `--prompt` / `-p` flag for AI-powered file selection — describe your task in
|
||||
plain English and contextzip automatically selects the minimum relevant files
|
||||
(e.g. `contextzip --prompt "Change toast color on failed login"`)
|
||||
* Gemini 2.5 Flash Lite integration via a thin `httpx` client — no Google SDK
|
||||
required; a single lightweight POST call to the Gemini API
|
||||
* First-run onboarding flow for users without an API key:
|
||||
* Displays a guided panel with a link to [Google AI Studio](https://aistudio.google.com/apikey) (free, no credit card)
|
||||
* Optionally opens the browser automatically
|
||||
* Saves the key persistently on confirmation
|
||||
* Persistent API key storage in the platform-appropriate config directory:
|
||||
* `~/.config/contextzip/config.json` on Linux / macOS
|
||||
* `%APPDATA%\contextzip\config.json` on Windows
|
||||
* `GEMINI_API_KEY` environment variable always takes precedence
|
||||
* `contextzip config` subcommand for key management:
|
||||
* `contextzip config` — show current key status and source
|
||||
* `contextzip config --reset-key` — clear stored key and re-run onboarding
|
||||
* `contextzip config --show-key-path` — print config file location
|
||||
* `prompt.txt` is written as the first entry in every AI-generated ZIP,
|
||||
containing the task description, detected framework, and selected file list —
|
||||
so any AI tool receiving the archive immediately knows the context
|
||||
* Keyword-based heuristic file scorer as a rate-limit fallback:
|
||||
* Activates only on confirmed HTTP 429 from Gemini
|
||||
* Scores files by token overlap with the prompt, directory semantics, and
|
||||
extension type
|
||||
* User is warned clearly that heuristic was used and Gemini results are
|
||||
preferred
|
||||
* `--prompt --dry-run` combination — preview AI-selected files without
|
||||
creating a ZIP
|
||||
* API key validation on load — keys not starting with `AIza` are treated as
|
||||
unconfigured and trigger onboarding rather than a cryptic API error
|
||||
* `diagnose_api_key()` surfaces a human-readable explanation when a key exists
|
||||
but fails validation, before any API call is attempted
|
||||
|
||||
### Changed
|
||||
|
||||
* `create_zip()` in `packager.py` now accepts an optional `prompt_txt`
|
||||
parameter; when provided, `prompt.txt` is written as the first ZIP entry
|
||||
* xdg-open on Linux now runs with stdout and stderr suppressed to prevent
|
||||
KDE / DBus / MIME warnings from appearing over the API key prompt
|
||||
|
||||
### Developer Notes
|
||||
|
||||
* Introduced `contextzip.config` module for cross-platform config path
|
||||
resolution and key read / write / delete operations
|
||||
* Introduced `contextzip.ai` package with three modules:
|
||||
* `gemini.py` — Gemini API client with typed exception hierarchy
|
||||
(`GeminiError`, `GeminiRateLimitError`, `GeminiUnavailable`)
|
||||
* `heuristic.py` — keyword + directory scoring fallback scorer
|
||||
* `selector.py` — project map builder, AI orchestration, `prompt.txt`
|
||||
generation; returns `(paths, prompt_txt, method)` so callers know
|
||||
which selection path was taken
|
||||
* Added `httpx` as a required runtime dependency
|
||||
|
||||
## [0.3.1] — 2026-05-25
|
||||
|
||||
### Added
|
||||
|
||||
* `contextzip watch -- <command>` subcommand — wraps any dev server or build
|
||||
process, buffers its output, detects errors automatically, and packages a
|
||||
debug-ready ZIP in a single keypress
|
||||
* Supports `npm run dev`, `python manage.py runserver`, and any process
|
||||
that writes errors to stdout/stderr
|
||||
* On error detection, renders an inline prompt beneath the error output:
|
||||
`[D] package debug context [S] skip`
|
||||
* Pressing `D` writes `.contextzip/debug-context.zip` immediately; the
|
||||
wrapped process continues running uninterrupted
|
||||
* On `Ctrl+C`, if no errors were packaged during the session, a final
|
||||
prompt offers to capture the full session output as a fallback
|
||||
* `debug-context.zip` — flat ZIP structure produced by `watch`:
|
||||
* `prompt.txt` — auto-generated from detected framework, error type, and
|
||||
referenced files; no `--prompt` flag required from the user
|
||||
* `terminal-error.txt` — cleaned, noise-stripped error block and stack trace
|
||||
* `source-files.zip` — inner ZIP containing source files extracted from
|
||||
stack frames, with relative paths preserved
|
||||
* `contextzip/error_parser.py` — full terminal output processing pipeline:
|
||||
* `strip_ansi()` — removes all CSI, OSC, and Fe escape sequences and bare
|
||||
carriage returns from raw process output
|
||||
* `detect_error_block()` — backward scan through the rolling buffer to find
|
||||
the most recent error; walks back from terminal exception lines to capture
|
||||
the full block including the `Traceback` header
|
||||
* `extract_paths()` — runs per-framework path regexes on the error block,
|
||||
resolves relative and bare filenames, and filters out stdlib, venv, and
|
||||
`node_modules` paths
|
||||
* `strip_noise()` — removes known noise lines (request logs, startup
|
||||
banners, HMR chatter) and collapses blank line runs
|
||||
* `build_prompt_txt()` — generates structured, AI-ready `prompt.txt` content
|
||||
* `process_buffer()` — convenience function running the full pipeline in a
|
||||
single call
|
||||
* `contextzip/watcher.py` — process management and packaging:
|
||||
* Spawns child via `subprocess.Popen` with piped stdout/stderr; two daemon
|
||||
threads drain pipes concurrently into a `deque(maxlen=2000)` rolling buffer
|
||||
* Detection thread polls the buffer every 300ms using `detect_error_block()`
|
||||
* Error deduplication: MD5 of the first three non-blank lines of an error
|
||||
block; same signature within a session suppresses repeat prompts
|
||||
* Single-keypress D/S prompt via `termios`/`tty` raw mode on Unix and
|
||||
`msvcrt` on Windows, with an `input()` fallback for unusual environments
|
||||
* Child terminated cleanly on `Ctrl+C`: SIGTERM → 3-second grace period →
|
||||
SIGKILL
|
||||
* `contextzip/rules/errors/python.py` — error detection pattern sets for
|
||||
Python, Django, and FastAPI:
|
||||
* 18 error start patterns covering tracebacks, syntax errors, Django/DRF
|
||||
exceptions, uvicorn/gunicorn ERROR logs, and pytest failures
|
||||
* 4 stack frame path patterns
|
||||
* 21 noise patterns for Django startup, request logs, pytest collection
|
||||
output, and pip install chatter
|
||||
* `contextzip/rules/errors/node.py` — error detection pattern sets for
|
||||
Node.js, Next.js, and React:
|
||||
* 33 error start patterns covering JS runtime errors, Next.js error symbols
|
||||
(`✗`, `×`, `⨯`), webpack/bundler errors, npm errors, and unhandled
|
||||
promise rejections
|
||||
* 7 stack frame and import path patterns
|
||||
* 32 noise patterns for Next.js/Vite/CRA startup, HMR chatter, and request
|
||||
logs
|
||||
|
||||
### Changed
|
||||
|
||||
* `cli.py` refactored from a single 1,100-line file into four focused modules
|
||||
with no behaviour changes:
|
||||
* `cli.py` — Click group, four subcommands, and `_run()`; routing only
|
||||
* `cli_display.py` — all Rich rendering helpers (`print_detection`,
|
||||
`print_scan_summary`, `print_file_warnings`, `print_package_result`, etc.)
|
||||
* `cli_ai.py` — AI selection orchestration and `normalize_pattern`
|
||||
* `cli_onboard.py` — Gemini API key onboarding flow and browser launch
|
||||
* `pyproject.toml` updated to include `contextzip.rules.errors` in package
|
||||
discovery
|
||||
|
||||
### Developer Notes
|
||||
|
||||
* `contextzip/rules/errors/` follows the same pattern as `contextzip/rules/`:
|
||||
one file per ecosystem, each exporting `ERROR_START_PATTERNS`,
|
||||
`PATH_PATTERNS`, and `NOISE_PATTERNS` as lists of compiled `re.Pattern`
|
||||
objects — adding support for a new framework means adding one file
|
||||
* `_load_patterns()` in `error_parser.py` maps ecosystem names from
|
||||
`DetectionResult.ecosystems` to rule modules; unrecognised ecosystems fall
|
||||
back to loading both Python and Node patterns as a best-effort default
|
||||
* `cli_display.py` functions accept an optional `con: Console` parameter
|
||||
(defaulting to the module-level console) making every display function
|
||||
independently testable without monkey-patching
|
||||
* No new runtime dependencies introduced — `watch` uses only stdlib
|
||||
(`subprocess`, `threading`, `queue`, `termios`/`msvcrt`, `zipfile`)
|
||||
|
||||
## [0.3.2] — 2026-05-25
|
||||
|
||||
### Added
|
||||
|
||||
* **Python API** — contextzip is now usable as a library, not just a CLI tool:
|
||||
* `get_git_changes(path?)` — returns a `FileCollection` of git-modified,
|
||||
added, and untracked files; applies base safety rules (no secrets, no
|
||||
binaries) automatically
|
||||
* `get_files(path?, include?, exclude?, use_gitignore?)` — returns a
|
||||
`FileCollection` of all project files after applying ecosystem rules,
|
||||
`.gitignore`, and any extra exclusion patterns
|
||||
* `create_zip(collection, output?)` — writes a `FileCollection` to a ZIP
|
||||
archive and returns a `PackageResult`; output path is caller-controlled
|
||||
* `detect_ecosystem(path?)` — returns a `DetectionResult` with ecosystem
|
||||
names, display string, and confidence level
|
||||
* `FileCollection` — unified return type for `get_git_changes` and
|
||||
`get_files`; supports iteration, `len()`, and `bool()` directly; `.files`
|
||||
is always a plain `list[pathlib.Path]` usable without zipping
|
||||
* `PackageResult` re-exported from the top-level package
|
||||
* Exception hierarchy: `ContextzipError` (base), `NotARepositoryError`,
|
||||
`GitNotFoundError`, `GitCommandError`, `NoFilesError`
|
||||
* All API functions default `path` to `Path.cwd()`, matching CLI behaviour
|
||||
|
||||
* `contextzip/api.py` — new module implementing the public API; no Click, no
|
||||
Rich output, no `SystemExit`; raises typed exceptions on failure
|
||||
* `contextzip/packager.py` — added `create_zip_silent()` and
|
||||
`_workspace_output_path_silent()`: same ZIP logic as the CLI path but
|
||||
without a `Console` dependency, used internally by the API
|
||||
|
||||
### Changed
|
||||
|
||||
* `contextzip/__init__.py` — now exports the full public API
|
||||
(`get_git_changes`, `get_files`, `create_zip`, `detect_ecosystem`,
|
||||
`FileCollection`, `PackageResult`, and all exception types) so
|
||||
`from contextzip import ...` works without knowing internal module paths
|
||||
|
||||
### Fixed
|
||||
|
||||
* `filters.py` — symlink guard inner `try` block repeated the identical
|
||||
`relative_to` call that already failed in the outer block; the fallback now
|
||||
correctly uses the pre-resolve (`original_path`) to compute the relative
|
||||
path for symlinks that point outside the project tree
|
||||
* `watcher.py` — `_drain_output_queue` silently consumed sentinel values
|
||||
(`raw=None`) with `continue`, making them invisible to `_check_reader_done`;
|
||||
sentinels are now put back into the queue so done flags are set correctly
|
||||
* `watcher.py` — `_read_key_windows` had no timeout and would spin forever in
|
||||
non-interactive terminals; now uses a 60-second `monotonic` deadline
|
||||
matching the existing Unix `select` timeout
|
||||
* `packager.py` — removed unused `_safe_name` helper
|
||||
* `ai/gemini.py` — corrected stale docstring (`gemini-2.0-flash-lite` →
|
||||
`gemini-2.5-flash-lite`) to match the actual `DEFAULT_MODEL` constant
|
||||
|
||||
## [0.3.3] — 2026-05-29
|
||||
|
||||
### Fixed
|
||||
|
||||
* `contextzip.ai` and all subpackages were missing from the published wheel
|
||||
due to an incomplete `include` allowlist in `pyproject.toml` — replaced
|
||||
with auto-discovery so all current and future subpackages are captured
|
||||
automatically
|
||||
|
||||
## [0.3.4] — 2026-06-21
|
||||
|
||||
### Added
|
||||
|
||||
* `contextzip eod` — builds a paste-ready end-of-day prompt from today's
|
||||
Claude/ChatGPT conversation plus whatever code changed, copied straight
|
||||
to the clipboard. Does no summarizing itself — that's left to whichever
|
||||
AI tool the prompt gets pasted into
|
||||
* `contextzip handoff` — same pipeline, aimed at continuing a project in a
|
||||
fresh chat after hitting a usage limit, rather than at an end-of-day report
|
||||
* Per-branch commit checkpoints (`.contextzip/markers/eod.json`,
|
||||
`.contextzip/markers/handoff.json`) so every run only reports what's new
|
||||
since *that command* last ran on *that branch* — no manual bookkeeping,
|
||||
no date-based guessing
|
||||
* Three-case code-change resolution per file, checked in priority order:
|
||||
1. **Not pushed** — diff against the upstream branch (covers committed-
|
||||
but-unpushed commits and uncommitted edits in one comparison) + the
|
||||
complete current file
|
||||
2. **Diverged from Claude** — diff against the version Claude last
|
||||
produced, matched by filename against a local artifacts folder + the
|
||||
complete current codebase file
|
||||
3. **Since last run** — diff against the stored marker commit, or the
|
||||
branch's merge-base with the default branch on a branch never tracked
|
||||
before + the complete current file
|
||||
|
||||
Brand-new untracked files skip the diff (no baseline exists) and are
|
||||
included as full content only
|
||||
* Best-effort fetching of Claude's artifact files for case 2, using the
|
||||
user's own Claude.ai session key (`contextzip config --set-session-key`).
|
||||
Explicitly degrades gracefully — a missing key, an expired cookie, or the
|
||||
endpoint changing shape just skips case 2 with a warning, never fails the
|
||||
whole `eod`/`handoff` run
|
||||
* `contextzip config --set-session-key` / `--reset-session-key` — manage
|
||||
the Claude session key alongside the existing Gemini key; default
|
||||
`contextzip config` now shows both keys' status
|
||||
* Text-clipboard support (`clipboard.handle_text()`) — `eod`/`handoff`
|
||||
produce a prompt to paste, not a file to upload, so the existing
|
||||
file-clipboard tiers gained a text-mode sibling (`pbcopy` / `xclip`+`xsel`
|
||||
/ `clip.exe`)
|
||||
* `exports/` added to the base exclusion ruleset (next to `.contextzip/`) —
|
||||
conversation exports and fetched Claude artifacts are contextzip's own
|
||||
working files, never code to package or report on
|
||||
|
||||
### Changed
|
||||
|
||||
* `packager._ensure_gitignore()` generalised to accept an arbitrary entry
|
||||
instead of being hardcoded to `.contextzip/`, so `exports/` can register
|
||||
itself in `.gitignore` the same way on first use
|
||||
|
||||
### Developer Notes
|
||||
|
||||
* New modules: `markers.py` (per-branch checkpoint storage), `code_changes.py`
|
||||
(the three-case decision tree), `claude_export.py` (locating the latest
|
||||
export, inline-vs-attach sizing), `claude_artifacts.py` (best-effort
|
||||
artifact fetching, rewritten on `httpx` for consistency with `ai/gemini.py`
|
||||
rather than introducing a second HTTP stack), `brief.py` (orchestrates
|
||||
`eod`/`handoff` end to end)
|
||||
* `git.py` gained branch-aware primitives: `get_current_branch()`,
|
||||
`get_default_branch()` (origin/HEAD detection with fallback chain),
|
||||
`get_merge_base()`, `is_ancestor()`, `get_upstream_branch()`,
|
||||
`diff_against()` (working-tree-aware — captures committed-since-baseline
|
||||
and uncommitted changes in one diff), `changed_files_against()`
|
||||
* A stored marker that's no longer an ancestor of `HEAD` (e.g. after a
|
||||
rebase) is detected via `is_ancestor()` and silently recomputed from the
|
||||
branch's merge-base, with a warning surfaced to the user rather than
|
||||
producing a nonsense diff
|
||||
* `claude_artifacts.py` matches Claude's flat artifact filenames against the
|
||||
codebase by basename only, since artifacts carry no knowledge of the
|
||||
repo's directory structure; ambiguous matches (same filename in multiple
|
||||
directories) pick the most recently modified candidate and surface a
|
||||
warning rather than guessing silently
|
||||
@@ -23,7 +23,6 @@ contextzip eliminates that entirely. Run it from your project root — it detect
|
||||
- **Git-aware packaging** — use `--git-changes` to package only modified, staged, and untracked files; perfect for incremental debugging and PR review sessions
|
||||
- **AI-powered file selection** — describe your task in plain English with `--prompt` and Gemini selects the minimum relevant files automatically, no manual hunting required
|
||||
- **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
|
||||
- **End-of-day / handoff prompts** — `contextzip eod` and `contextzip handoff` turn a Claude/ChatGPT conversation export plus today's code changes into a paste-ready prompt, copied straight to your clipboard
|
||||
- **Persistent workspace** — all generated ZIPs land in `.contextzip/` at your project root, discoverable, reusable, and git-ignored automatically
|
||||
- **Warns before it's a problem** — flags large (≥ 1 MB) and binary files that AI tools can't read, before you waste an upload
|
||||
- **Handles edge cases** — dangling symlinks, unreadable files, and paths outside the project tree are caught and reported, never silently dropped
|
||||
@@ -89,7 +88,7 @@ contextzip [OPTIONS]
|
||||
| `--no-clipboard` | Skip the clipboard / folder-open step |
|
||||
| `--no-gitignore` | Ignore the project's `.gitignore` |
|
||||
|
||||
**Subcommands:** `exclude`, `include`, `watch`, `config`, `eod`, `handoff` — run `contextzip --help` for full details.
|
||||
**Subcommands:** `exclude`, `include`, `watch`, `config` — run `contextzip --help` for full details.
|
||||
|
||||
---
|
||||
|
||||
@@ -215,44 +214,6 @@ Press **D** and contextzip immediately writes `.contextzip/debug-context.zip`. Y
|
||||
|
||||
---
|
||||
|
||||
## End-of-day reports and chat handoffs
|
||||
|
||||
If you work through a problem in a Claude or ChatGPT conversation and need to either (a) summarize what you did for an end-of-day report, or (b) continue the same work in a fresh chat after hitting a usage limit, `eod` and `handoff` build the prompt for you — contextzip does no summarizing itself; that's left to whichever AI tool you paste the result into.
|
||||
|
||||
```bash
|
||||
contextzip eod
|
||||
contextzip handoff
|
||||
```
|
||||
|
||||
**Setup:** export your conversation (any markdown export works) and drop the `.md` file into `exports/` at your project root. Both commands pick the most recently modified file there automatically.
|
||||
|
||||
**What gets built:**
|
||||
|
||||
- The conversation itself — pasted directly into the prompt if it's short, or referenced as an attachment if it's long enough that inlining it would burn through the next chat's context budget
|
||||
- Whatever code changed, resolved per file in priority order:
|
||||
1. **Not pushed** — diffed against your upstream branch, plus the complete current file
|
||||
2. **Diverged from Claude** — diffed against the version Claude last produced (if you've set up a session key — see below), plus the complete current codebase file
|
||||
3. **Since last run** — diffed against a per-branch checkpoint that `eod`/`handoff` remember automatically, plus the complete current file
|
||||
- New, untracked files are included as full content (there's nothing to diff them against)
|
||||
- `eod` ends the prompt with a flat instruction to produce a work-log table; `handoff` frames it as continuing the project in a new chat
|
||||
|
||||
The result is copied straight to your clipboard, and also saved to `.contextzip/` if you want to look it over first.
|
||||
|
||||
**Optional — diffing against Claude's own version (case 2):** if you give contextzip your Claude.ai session key, `eod`/`handoff` will fetch the files Claude actually produced and compare them against your codebase, catching drift even after everything's pushed and in sync.
|
||||
|
||||
```bash
|
||||
contextzip config --set-session-key
|
||||
```
|
||||
|
||||
This is best-effort by design — it depends on an undocumented Claude.ai endpoint, so a missing key, an expired cookie, or the endpoint changing shape just skips this check with a warning rather than failing the whole command.
|
||||
|
||||
```bash
|
||||
contextzip eod --dry-run # preview without advancing the checkpoint
|
||||
contextzip handoff --no-fetch # skip the Claude-artifact fetch for this run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What gets excluded
|
||||
|
||||
contextzip stacks exclusion rules based on your detected stack, on top of your `.gitignore`.
|
||||
|
||||
@@ -1,280 +0,0 @@
|
||||
"""
|
||||
brief.py — Orchestrates `contextzip eod` and `contextzip handoff`.
|
||||
|
||||
Both commands share one pipeline:
|
||||
1. Locate the latest conversation export
|
||||
2. Best-effort fetch Claude's artifact files if missing (for case 2)
|
||||
3. Resolve code changes (the three-case decision tree in code_changes.py)
|
||||
4. Render the prompt — conversation inlined or attached depending on size,
|
||||
code changes attached as a zip when there are any
|
||||
5. Write the prompt text (+ zip) to .contextzip/, advance the marker
|
||||
|
||||
`eod` and `handoff` differ only in the fixed instruction text and which
|
||||
marker they read/write — everything else is identical, so this module
|
||||
takes a `kind` parameter rather than having two near-duplicate pipelines.
|
||||
|
||||
contextzip does no AI calls here. This produces a prompt for a human to
|
||||
paste into Claude or ChatGPT; the actual summarizing/continuing is the
|
||||
receiving chat's job, not contextzip's.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import zipfile
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from contextzip.claude_artifacts import fetch_artifacts
|
||||
from contextzip.claude_export import (
|
||||
DEFAULT_EXPORTS_DIR,
|
||||
ConversationExport,
|
||||
NoExportFoundError,
|
||||
artifacts_dir_for,
|
||||
find_latest_export,
|
||||
should_inline,
|
||||
)
|
||||
from contextzip.code_changes import CodeChangesResult, resolve_code_changes
|
||||
from contextzip.config import get_session_key
|
||||
from contextzip.git import GitError
|
||||
from contextzip.markers import save_marker
|
||||
from contextzip.packager import _ensure_gitignore, _workspace_dir
|
||||
|
||||
EOD_INSTRUCTION = "Create the work log table."
|
||||
|
||||
HANDOFF_INTRO = (
|
||||
"I've hit my usage limit in a previous chat and need to continue this "
|
||||
"project in a new one."
|
||||
)
|
||||
HANDOFF_REVIEW_INLINE = "Here's the conversation so far — please review it and continue from where we left off."
|
||||
HANDOFF_REVIEW_ATTACHED = (
|
||||
"I've attached the prior conversation export ({filename}) — please "
|
||||
"review it and continue from where we left off."
|
||||
)
|
||||
|
||||
|
||||
class BriefError(Exception):
|
||||
"""Raised for hard failures — no export found, etc."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class BriefResult:
|
||||
kind: str # "eod" | "handoff"
|
||||
prompt_text: str
|
||||
prompt_path: Path
|
||||
changes_zip_path: Path | None
|
||||
export: ConversationExport
|
||||
conversation_inlined: bool
|
||||
code_changes: CodeChangesResult | None
|
||||
fetch_warnings: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def run_brief(
|
||||
*,
|
||||
kind: str,
|
||||
project_dir: Path,
|
||||
exports_dir: Path | None = None,
|
||||
auto_fetch_artifacts: bool = True,
|
||||
dry_run: bool = False,
|
||||
) -> BriefResult:
|
||||
if exports_dir is None:
|
||||
exports_dir = project_dir / DEFAULT_EXPORTS_DIR
|
||||
|
||||
_ensure_exports_ignored(exports_dir, project_dir)
|
||||
|
||||
try:
|
||||
export = find_latest_export(exports_dir)
|
||||
except NoExportFoundError as exc:
|
||||
raise BriefError(str(exc))
|
||||
|
||||
fetch_warnings: list[str] = []
|
||||
claude_dir = artifacts_dir_for(export, exports_dir)
|
||||
|
||||
if auto_fetch_artifacts and not _has_any_file(claude_dir):
|
||||
session_key = get_session_key()
|
||||
if not session_key:
|
||||
fetch_warnings.append(
|
||||
"No Claude session key configured — skipping the diverged-from-Claude "
|
||||
"check (case 2). Run `contextzip config --set-session-key` to enable it."
|
||||
)
|
||||
else:
|
||||
_saved, errors = fetch_artifacts(export.text, claude_dir, session_key)
|
||||
fetch_warnings.extend(errors)
|
||||
|
||||
code_changes = resolve_code_changes(
|
||||
project_dir=project_dir,
|
||||
kind=kind,
|
||||
claude_artifacts_dir=claude_dir if _has_any_file(claude_dir) else None,
|
||||
)
|
||||
if isinstance(code_changes, GitError):
|
||||
fetch_warnings.append(
|
||||
f"Code changes skipped — {code_changes.message.splitlines()[0]}"
|
||||
)
|
||||
code_changes = None
|
||||
elif code_changes.warnings:
|
||||
fetch_warnings.extend(code_changes.warnings)
|
||||
|
||||
inline = should_inline(export)
|
||||
|
||||
changes_zip_path: Path | None = None
|
||||
if code_changes and not code_changes.is_empty:
|
||||
changes_zip_path = _build_changes_zip(code_changes, project_dir, kind)
|
||||
|
||||
prompt_text = _render_prompt(
|
||||
kind=kind,
|
||||
export=export,
|
||||
inline=inline,
|
||||
changes_zip_path=changes_zip_path,
|
||||
)
|
||||
|
||||
prompt_path = _write_prompt(prompt_text, project_dir, kind)
|
||||
|
||||
if not dry_run:
|
||||
from contextzip.git import get_commit_hash, get_current_branch
|
||||
|
||||
branch = get_current_branch(project_dir) or "HEAD"
|
||||
head = get_commit_hash("HEAD", project_dir)
|
||||
if head:
|
||||
save_marker(kind, branch, head, project_dir)
|
||||
|
||||
return BriefResult(
|
||||
kind=kind,
|
||||
prompt_text=prompt_text,
|
||||
prompt_path=prompt_path,
|
||||
changes_zip_path=changes_zip_path,
|
||||
export=export,
|
||||
conversation_inlined=inline,
|
||||
code_changes=code_changes,
|
||||
fetch_warnings=fetch_warnings,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prompt rendering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _render_prompt(
|
||||
*,
|
||||
kind: str,
|
||||
export: ConversationExport,
|
||||
inline: bool,
|
||||
changes_zip_path: Path | None,
|
||||
) -> str:
|
||||
if kind == "eod":
|
||||
return _render_eod(export, inline, changes_zip_path)
|
||||
return _render_handoff(export, inline, changes_zip_path)
|
||||
|
||||
|
||||
def _render_eod(
|
||||
export: ConversationExport, inline: bool, changes_zip_path: Path | None
|
||||
) -> str:
|
||||
parts: list[str] = []
|
||||
|
||||
if inline:
|
||||
parts.append(f"Conversation:\n{export.text}")
|
||||
else:
|
||||
parts.append(f"Conversation attached: {export.path.name}")
|
||||
|
||||
if changes_zip_path is not None:
|
||||
parts.append(f"Code changes attached: {changes_zip_path.name}")
|
||||
|
||||
parts.append(EOD_INSTRUCTION)
|
||||
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def _render_handoff(
|
||||
export: ConversationExport, inline: bool, changes_zip_path: Path | None
|
||||
) -> str:
|
||||
parts: list[str] = [HANDOFF_INTRO]
|
||||
|
||||
if inline:
|
||||
parts.append(f"{HANDOFF_REVIEW_INLINE}\n\n{export.text}")
|
||||
else:
|
||||
parts.append(HANDOFF_REVIEW_ATTACHED.format(filename=export.path.name))
|
||||
|
||||
if changes_zip_path is not None:
|
||||
parts.append(
|
||||
f"I've also attached the code changes since our last session "
|
||||
f"({changes_zip_path.name}) so you have the current state without "
|
||||
f"needing the full codebase."
|
||||
)
|
||||
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output writing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _write_prompt(prompt_text: str, project_dir: Path, kind: str) -> Path:
|
||||
workspace, is_git_repo = _workspace_dir(project_dir)
|
||||
workspace.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if is_git_repo:
|
||||
try:
|
||||
_ensure_gitignore(workspace.parent)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# eod is once-a-day (overwritable, like codebase.zip); handoff can fire
|
||||
# multiple times in a day (you can hit the limit more than once), so it
|
||||
# gets a timestamp instead of just a date.
|
||||
if kind == "eod":
|
||||
stamp = datetime.now().strftime("%Y-%m-%d")
|
||||
else:
|
||||
stamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
|
||||
|
||||
path = workspace / f"{kind}-{stamp}.txt"
|
||||
path.write_text(prompt_text, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def _build_changes_zip(
|
||||
code_changes: CodeChangesResult, project_dir: Path, kind: str
|
||||
) -> Path:
|
||||
workspace, _ = _workspace_dir(project_dir)
|
||||
workspace.mkdir(parents=True, exist_ok=True)
|
||||
zip_path = workspace / f"{kind}-changes.zip"
|
||||
|
||||
with zipfile.ZipFile(
|
||||
zip_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6
|
||||
) as zf:
|
||||
for f in code_changes.files:
|
||||
if f.content is not None:
|
||||
zf.writestr(f"full/{f.rel_path}", f.content.encode("utf-8"))
|
||||
if f.diff is not None:
|
||||
zf.writestr(f"diffs/{f.rel_path}.patch", f.diff.encode("utf-8"))
|
||||
|
||||
return zip_path
|
||||
|
||||
|
||||
def _has_any_file(directory: Path) -> bool:
|
||||
return directory.is_dir() and any(p.is_file() for p in directory.rglob("*"))
|
||||
|
||||
|
||||
def _ensure_exports_ignored(exports_dir: Path, project_dir: Path) -> None:
|
||||
"""
|
||||
Register exports/ in .gitignore the first time it's used, same treatment
|
||||
as .contextzip/. Conversation exports and fetched Claude artifacts are
|
||||
personal working files, not something to accidentally commit — though
|
||||
a team that *wants* to share them can simply remove the line.
|
||||
"""
|
||||
from contextzip.packager import _find_git_root
|
||||
|
||||
git_root = _find_git_root(project_dir)
|
||||
if git_root is None:
|
||||
return
|
||||
|
||||
try:
|
||||
rel = exports_dir.resolve().relative_to(git_root)
|
||||
except ValueError:
|
||||
return # exports_dir lives outside the repo — nothing to register
|
||||
|
||||
entry = rel.as_posix().rstrip("/") + "/"
|
||||
try:
|
||||
_ensure_gitignore(git_root, entry)
|
||||
except OSError:
|
||||
pass
|
||||
@@ -1,150 +0,0 @@
|
||||
"""
|
||||
claude_artifacts.py — Fetches Claude.ai artifact files referenced in a
|
||||
conversation export.
|
||||
|
||||
This talks to an undocumented Claude.ai endpoint using the user's own
|
||||
session cookie (contextzip.config.get_session_key()). It is explicitly
|
||||
best-effort: a missing key, an expired cookie, or the endpoint changing
|
||||
shape should degrade gracefully — code_changes.py simply skips case 2
|
||||
(diverged-from-Claude) for affected files — rather than aborting the whole
|
||||
eod/handoff run. Nothing here should ever raise out to the caller for a
|
||||
single failed download; only total inability to attempt the fetch at all
|
||||
(httpx missing) is surfaced as an immediate empty result with an error.
|
||||
|
||||
Known fragility, by design, not oversight: this is reverse-engineered from
|
||||
the claude.ai web app's own network calls, not a published API. It may stop
|
||||
working without notice, and depending on Cloudflare's bot-management state
|
||||
it may also simply fail unpredictably from a non-browser client regardless
|
||||
of how correct the request looks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import httpx
|
||||
|
||||
_HTTPX_AVAILABLE = True
|
||||
except ImportError:
|
||||
_HTTPX_AVAILABLE = False
|
||||
|
||||
|
||||
DOWNLOAD_LINK_RE = re.compile(
|
||||
r"\[([^\]]+)\]\((https://claude\.ai/api/organizations/[^)\s]+"
|
||||
r"/wiggle/download-file\?path=[^)\s]+)\)"
|
||||
)
|
||||
|
||||
_BROWSER_HEADERS = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
|
||||
),
|
||||
"Accept": "*/*",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArtifactLink:
|
||||
label: str
|
||||
url: str
|
||||
filename: str
|
||||
|
||||
|
||||
def extract_links(export_text: str) -> list[ArtifactLink]:
|
||||
"""Pull every unique download-file link out of an export's raw text."""
|
||||
seen: set[str] = set()
|
||||
links: list[ArtifactLink] = []
|
||||
|
||||
for label, url in DOWNLOAD_LINK_RE.findall(export_text):
|
||||
if url in seen:
|
||||
continue
|
||||
seen.add(url)
|
||||
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
query = urllib.parse.parse_qs(parsed.query)
|
||||
raw_path = query.get("path", [""])[0]
|
||||
decoded = urllib.parse.unquote(raw_path)
|
||||
filename = Path(decoded).name or Path(label).name or "artifact"
|
||||
|
||||
links.append(ArtifactLink(label=label, url=url, filename=filename))
|
||||
|
||||
return links
|
||||
|
||||
|
||||
def fetch_artifacts(
|
||||
export_text: str,
|
||||
dest_dir: Path,
|
||||
session_key: str,
|
||||
) -> tuple[list[Path], list[str]]:
|
||||
"""
|
||||
Download every artifact link found in *export_text* into *dest_dir*.
|
||||
|
||||
Returns (saved_paths, errors). Never raises for an individual download
|
||||
failure — each failure becomes a string in *errors* so callers (brief.py)
|
||||
can surface them as warnings and continue with whatever did succeed.
|
||||
"""
|
||||
if not _HTTPX_AVAILABLE:
|
||||
return [], ["httpx is not installed — cannot fetch Claude artifacts."]
|
||||
|
||||
links = extract_links(export_text)
|
||||
if not links:
|
||||
return [], []
|
||||
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
saved: list[Path] = []
|
||||
errors: list[str] = []
|
||||
|
||||
session_key = session_key.strip().strip('"').strip("'")
|
||||
headers_base = dict(_BROWSER_HEADERS)
|
||||
headers_base["Cookie"] = f"sessionKey={session_key}"
|
||||
|
||||
with httpx.Client(timeout=30, follow_redirects=True) as client:
|
||||
for link in links:
|
||||
headers = dict(headers_base)
|
||||
headers["Referer"] = _conversation_referer(link.url)
|
||||
headers["Origin"] = "https://claude.ai"
|
||||
|
||||
try:
|
||||
resp = client.get(link.url, headers=headers)
|
||||
except httpx.RequestError as exc:
|
||||
errors.append(f"{link.filename}: network error ({exc})")
|
||||
continue
|
||||
|
||||
if resp.status_code in (401, 403):
|
||||
errors.append(
|
||||
f"{link.filename}: auth failed ({resp.status_code}) — session key "
|
||||
"may be missing/expired, or this request was blocked before it "
|
||||
"even reached Claude's app (Cloudflare bot protection)."
|
||||
)
|
||||
continue
|
||||
if resp.status_code == 404:
|
||||
errors.append(
|
||||
f"{link.filename}: not found (404) — the file or conversation "
|
||||
"session may have expired."
|
||||
)
|
||||
continue
|
||||
if resp.status_code != 200:
|
||||
errors.append(f"{link.filename}: HTTP {resp.status_code}")
|
||||
continue
|
||||
|
||||
dest = dest_dir / link.filename
|
||||
try:
|
||||
dest.write_bytes(resp.content)
|
||||
saved.append(dest)
|
||||
except OSError as exc:
|
||||
errors.append(f"{link.filename}: could not write file ({exc})")
|
||||
|
||||
return saved, errors
|
||||
|
||||
|
||||
def _conversation_referer(url: str) -> str:
|
||||
match = re.search(r"/conversations/([0-9a-fA-F-]+)/wiggle", url)
|
||||
if match:
|
||||
return f"https://claude.ai/chat/{match.group(1)}"
|
||||
return "https://claude.ai/"
|
||||
@@ -1,73 +0,0 @@
|
||||
"""
|
||||
claude_export.py — Locates and prepares a Claude.ai conversation export for
|
||||
use in `eod` / `handoff` prompts.
|
||||
|
||||
Pure text handling — no AI calls, no network. Finding "the latest export"
|
||||
and deciding whether it's short enough to paste inline are the only two
|
||||
responsibilities here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_EXPORTS_DIR = "exports"
|
||||
DEFAULT_ARTIFACTS_SUBDIR = "artifacts"
|
||||
|
||||
# Conversations at or under this length get pasted directly into the
|
||||
# generated prompt; longer ones are referenced as an attachment instead,
|
||||
# since a full day's chat could otherwise burn through the next chat's
|
||||
# context budget before any actual work happens.
|
||||
INLINE_CHAR_THRESHOLD = 3500
|
||||
|
||||
|
||||
class NoExportFoundError(Exception):
|
||||
"""Raised when no .md export exists in the exports directory."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversationExport:
|
||||
path: Path
|
||||
text: str
|
||||
conversation_name: str # filename stem — used for the artifacts subfolder
|
||||
|
||||
|
||||
def find_latest_export(exports_dir: Path) -> ConversationExport:
|
||||
"""
|
||||
Return the most recently modified .md file in *exports_dir*.
|
||||
|
||||
"Most recently modified" rather than "most recently created" — if you
|
||||
re-export the same conversation later in the day, the newer copy wins
|
||||
without needing a naming convention.
|
||||
"""
|
||||
if not exports_dir.is_dir():
|
||||
raise NoExportFoundError(
|
||||
f"Exports folder not found: {exports_dir}. "
|
||||
f"Export a conversation and drop the .md file there first."
|
||||
)
|
||||
|
||||
candidates = list(exports_dir.glob("*.md"))
|
||||
if not candidates:
|
||||
raise NoExportFoundError(
|
||||
f"No .md export files found in {exports_dir}. "
|
||||
f"Export a conversation and drop the .md file there first."
|
||||
)
|
||||
|
||||
candidates.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
path = candidates[0]
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
return ConversationExport(path=path, text=text, conversation_name=path.stem)
|
||||
|
||||
|
||||
def artifacts_dir_for(export: ConversationExport, exports_dir: Path) -> Path:
|
||||
"""Where this export's Claude-provided artifact files live (or will be fetched to)."""
|
||||
return exports_dir / DEFAULT_ARTIFACTS_SUBDIR / export.conversation_name
|
||||
|
||||
|
||||
def should_inline(
|
||||
export: ConversationExport, threshold: int = INLINE_CHAR_THRESHOLD
|
||||
) -> bool:
|
||||
"""True if the export is short enough to paste directly into the prompt."""
|
||||
return len(export.text) <= threshold
|
||||
+2
-233
@@ -15,8 +15,6 @@ from contextzip import __version__
|
||||
from contextzip.config import (
|
||||
get_api_key,
|
||||
delete_api_key,
|
||||
get_session_key,
|
||||
delete_session_key,
|
||||
config_path,
|
||||
diagnose_api_key,
|
||||
)
|
||||
@@ -24,10 +22,7 @@ from contextzip.detector import detect
|
||||
from contextzip.filters import build_spec, resolve_files, resolve_files_from_git
|
||||
from contextzip.git import get_changed_files, GitError
|
||||
from contextzip.packager import create_zip
|
||||
from contextzip.clipboard import (
|
||||
handle as clipboard_handle,
|
||||
handle_text as clipboard_handle_text,
|
||||
)
|
||||
from contextzip.clipboard import handle as clipboard_handle
|
||||
|
||||
from contextzip.cli_display import (
|
||||
print_detection,
|
||||
@@ -37,14 +32,13 @@ from contextzip.cli_display import (
|
||||
print_package_result,
|
||||
print_zip_write_warnings,
|
||||
print_clipboard_result,
|
||||
print_brief_result,
|
||||
)
|
||||
from contextzip.cli_ai import (
|
||||
normalize_pattern,
|
||||
run_ai_selection,
|
||||
run_ai_selection_preview,
|
||||
)
|
||||
from contextzip.cli_onboard import onboard_api_key, onboard_session_key
|
||||
from contextzip.cli_onboard import onboard_api_key
|
||||
|
||||
console = Console()
|
||||
|
||||
@@ -377,166 +371,6 @@ def cmd_watch(command: tuple[str, ...]) -> None:
|
||||
raise SystemExit(exit_code)
|
||||
|
||||
|
||||
@main.command("eod")
|
||||
@click.option(
|
||||
"--exports-dir",
|
||||
default=None,
|
||||
metavar="DIR",
|
||||
help="Folder containing conversation exports (default: ./exports).",
|
||||
)
|
||||
@click.option(
|
||||
"--no-clipboard",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Skip copying the prompt to the clipboard.",
|
||||
)
|
||||
@click.option(
|
||||
"--no-fetch",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Don't auto-fetch Claude's artifact files even if a session key is configured.",
|
||||
)
|
||||
@click.option(
|
||||
"--dry-run",
|
||||
"-n",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Build the prompt without advancing the eod marker.",
|
||||
)
|
||||
def cmd_eod(
|
||||
exports_dir: str | None, no_clipboard: bool, no_fetch: bool, dry_run: bool
|
||||
) -> None:
|
||||
"""
|
||||
Build a paste-ready end-of-day prompt from today's conversation + code changes.
|
||||
|
||||
\b
|
||||
EXAMPLES
|
||||
contextzip eod
|
||||
contextzip eod --dry-run
|
||||
contextzip eod --exports-dir ~/claude-exports
|
||||
|
||||
Picks the most recently modified .md file in exports/, packages whatever
|
||||
code changed since the last time eod ran (diffs for files that already
|
||||
existed, full content for new ones), and copies a ready-to-paste prompt
|
||||
to your clipboard. contextzip does no summarizing itself — that's the
|
||||
job of whatever AI tool you paste the prompt into.
|
||||
"""
|
||||
_run_brief("eod", exports_dir, no_clipboard, no_fetch, dry_run)
|
||||
|
||||
|
||||
@main.command("handoff")
|
||||
@click.option(
|
||||
"--exports-dir",
|
||||
default=None,
|
||||
metavar="DIR",
|
||||
help="Folder containing conversation exports (default: ./exports).",
|
||||
)
|
||||
@click.option(
|
||||
"--no-clipboard",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Skip copying the prompt to the clipboard.",
|
||||
)
|
||||
@click.option(
|
||||
"--no-fetch",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Don't auto-fetch Claude's artifact files even if a session key is configured.",
|
||||
)
|
||||
@click.option(
|
||||
"--dry-run",
|
||||
"-n",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Build the prompt without advancing the handoff marker.",
|
||||
)
|
||||
def cmd_handoff(
|
||||
exports_dir: str | None, no_clipboard: bool, no_fetch: bool, dry_run: bool
|
||||
) -> None:
|
||||
"""
|
||||
Build a paste-ready prompt to continue this project in a fresh chat.
|
||||
|
||||
\b
|
||||
EXAMPLES
|
||||
contextzip handoff
|
||||
contextzip handoff --dry-run
|
||||
|
||||
Use this when you've hit a usage limit and need to start a new chat
|
||||
without losing context: picks the latest conversation export, packages
|
||||
the code changed since the last handoff (or eod), and copies a prompt
|
||||
ready to paste into the new chat.
|
||||
"""
|
||||
_run_brief("handoff", exports_dir, no_clipboard, no_fetch, dry_run)
|
||||
|
||||
|
||||
def _run_brief(
|
||||
kind: str,
|
||||
exports_dir: str | None,
|
||||
no_clipboard: bool,
|
||||
no_fetch: bool,
|
||||
dry_run: bool,
|
||||
) -> None:
|
||||
"""Shared implementation for cmd_eod and cmd_handoff — see brief.py for the actual logic."""
|
||||
from contextzip.brief import run_brief, BriefError
|
||||
|
||||
project_dir = Path(os.getcwd()).resolve()
|
||||
|
||||
console.print()
|
||||
console.print(
|
||||
Panel.fit(
|
||||
f"[bold cyan]contextzip[/] [dim]{kind}[/]\n"
|
||||
f"[dim]Project:[/] [white]{project_dir}[/]",
|
||||
border_style="cyan",
|
||||
padding=(0, 2),
|
||||
)
|
||||
)
|
||||
console.print()
|
||||
|
||||
exports_path = Path(exports_dir).resolve() if exports_dir else None
|
||||
|
||||
try:
|
||||
with console.status(f"[cyan]Building {kind} prompt…[/]", spinner="dots"):
|
||||
result = run_brief(
|
||||
kind=kind,
|
||||
project_dir=project_dir,
|
||||
exports_dir=exports_path,
|
||||
auto_fetch_artifacts=not no_fetch,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
except BriefError as exc:
|
||||
console.print(
|
||||
Panel.fit(
|
||||
f"[red]{exc}[/]",
|
||||
border_style="red",
|
||||
padding=(0, 2),
|
||||
)
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
print_brief_result(result)
|
||||
|
||||
if dry_run:
|
||||
console.print()
|
||||
console.print(
|
||||
Panel.fit(
|
||||
"[yellow]Dry run:[/] the prompt and any changes zip above were "
|
||||
"still written so you can review them, but the checkpoint was "
|
||||
"[bold]not[/] advanced.\n"
|
||||
"[dim]Run again without --dry-run once you're happy with this — "
|
||||
"it'll pick up the same files.[/]",
|
||||
border_style="yellow",
|
||||
padding=(0, 2),
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
if not no_clipboard:
|
||||
console.print()
|
||||
with console.status("[cyan]Preparing clipboard…[/]", spinner="dots"):
|
||||
cb = clipboard_handle_text(result.prompt_text, result.prompt_path)
|
||||
print_clipboard_result(cb)
|
||||
|
||||
|
||||
@main.command("config")
|
||||
@click.option(
|
||||
"--reset-key",
|
||||
@@ -544,18 +378,6 @@ def _run_brief(
|
||||
default=False,
|
||||
help="Clear the stored Gemini API key and re-run the setup prompt.",
|
||||
)
|
||||
@click.option(
|
||||
"--set-session-key",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Set up the Claude.ai session key used by eod/handoff's case-2 fetching.",
|
||||
)
|
||||
@click.option(
|
||||
"--reset-session-key",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Clear the stored Claude session key and re-run its setup prompt.",
|
||||
)
|
||||
@click.option(
|
||||
"--show-key-path",
|
||||
is_flag=True,
|
||||
@@ -564,8 +386,6 @@ def _run_brief(
|
||||
)
|
||||
def cmd_config(
|
||||
reset_key: bool,
|
||||
set_session_key: bool,
|
||||
reset_session_key: bool,
|
||||
show_key_path: bool,
|
||||
) -> None:
|
||||
"""
|
||||
@@ -574,26 +394,12 @@ def cmd_config(
|
||||
\b
|
||||
EXAMPLES
|
||||
contextzip config --reset-key # clear Gemini key, re-onboard
|
||||
contextzip config --set-session-key # set up the Claude session key
|
||||
contextzip config --reset-session-key # clear it and re-run setup
|
||||
contextzip config --show-key-path # print config file location
|
||||
"""
|
||||
if show_key_path:
|
||||
console.print(f"\n [dim]Config file:[/] [cyan]{config_path()}[/]\n")
|
||||
return
|
||||
|
||||
if set_session_key or reset_session_key:
|
||||
if reset_session_key:
|
||||
removed = delete_session_key()
|
||||
if removed:
|
||||
console.print(
|
||||
f"\n [green]✓[/] Session key removed from [dim]{config_path()}[/]"
|
||||
)
|
||||
else:
|
||||
console.print("\n [dim]No session key was stored.[/]")
|
||||
onboard_session_key()
|
||||
return
|
||||
|
||||
if reset_key:
|
||||
removed = delete_api_key()
|
||||
if removed:
|
||||
@@ -645,43 +451,6 @@ def cmd_config(
|
||||
)
|
||||
|
||||
console.print()
|
||||
session_key = get_session_key()
|
||||
session_from_env = bool(os.environ.get("CLAUDE_SESSION_KEY", "").strip())
|
||||
|
||||
if session_key:
|
||||
masked = (
|
||||
session_key[:6] + "…" + session_key[-4:]
|
||||
if len(session_key) > 10
|
||||
else "****"
|
||||
)
|
||||
source = (
|
||||
"[dim](from environment variable)[/]"
|
||||
if session_from_env
|
||||
else f"[dim]({config_path()})[/]"
|
||||
)
|
||||
console.print(
|
||||
Panel(
|
||||
f" [dim]Claude session key:[/] [green]{masked}[/] {source}\n\n"
|
||||
" [dim]Used by [cyan]eod[/]/[cyan]handoff[/] for case-2 fetching. "
|
||||
"Run [cyan]contextzip config --reset-session-key[/] to change it.[/]",
|
||||
title="[bold]contextzip config[/]",
|
||||
border_style="cyan",
|
||||
padding=(0, 2),
|
||||
)
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
Panel(
|
||||
" No Claude session key configured.\n\n"
|
||||
" [dim]Optional — only needed for eod/handoff's diverged-from-Claude "
|
||||
"check.[/]\n"
|
||||
" Run [cyan]contextzip config --set-session-key[/] to set one up.",
|
||||
title="[bold]contextzip config[/]",
|
||||
border_style="yellow",
|
||||
padding=(0, 2),
|
||||
)
|
||||
)
|
||||
console.print()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -329,58 +329,3 @@ def human_size(n: int) -> str:
|
||||
return f"{n:.0f} {unit}"
|
||||
n /= 1024
|
||||
return f"{n:.1f} TB"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# eod / handoff result
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def print_brief_result(result, *, con: Console = console) -> None:
|
||||
"""Render the eod/handoff summary panel: export used, per-case file counts,
|
||||
where the conversation went (inline vs attached), and any warnings."""
|
||||
from contextzip.code_changes import ChangeCase
|
||||
|
||||
table = Table(box=box.ROUNDED, show_header=False, padding=(0, 2))
|
||||
table.add_column(style="dim", min_width=18)
|
||||
table.add_column()
|
||||
|
||||
table.add_row("Export used", f"[cyan]{result.export.path.name}[/]")
|
||||
table.add_row(
|
||||
"Conversation",
|
||||
"[green]inlined in prompt[/]"
|
||||
if result.conversation_inlined
|
||||
else "[yellow]attached (too long to inline)[/]",
|
||||
)
|
||||
|
||||
if result.code_changes and not result.code_changes.is_empty:
|
||||
case_labels = {
|
||||
ChangeCase.UNPUSHED: "not pushed",
|
||||
ChangeCase.DIVERGED_FROM_CLAUDE: "diverged from Claude",
|
||||
ChangeCase.SINCE_MARKER: "since last run",
|
||||
ChangeCase.NEW_FILE: "new file",
|
||||
}
|
||||
counts: dict[str, int] = {}
|
||||
for f in result.code_changes.files:
|
||||
label = case_labels[f.case]
|
||||
counts[label] = counts.get(label, 0) + 1
|
||||
breakdown = " ".join(f"{n} {label}" for label, n in counts.items())
|
||||
table.add_row("Code changes", f"[green]{breakdown}[/]")
|
||||
else:
|
||||
table.add_row("Code changes", "[dim]none found[/]")
|
||||
|
||||
table.add_row("Prompt saved to", f"[cyan]{result.prompt_path}[/]")
|
||||
|
||||
con.print(
|
||||
Panel(
|
||||
table,
|
||||
title=f"[bold green]✓ {result.kind} ready[/]",
|
||||
border_style="green",
|
||||
padding=(0, 1),
|
||||
)
|
||||
)
|
||||
|
||||
if result.fetch_warnings:
|
||||
con.print()
|
||||
for w in result.fetch_warnings:
|
||||
con.print(f" [yellow]⚠[/] [dim]{w}[/]")
|
||||
|
||||
@@ -122,62 +122,3 @@ def onboard_api_key(*, con: Console = console) -> str | None:
|
||||
|
||||
con.print()
|
||||
return key
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Claude session key onboarding — used by eod/handoff's optional case-2
|
||||
# (diverged-from-Claude) artifact fetching.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def onboard_session_key(*, con: Console = console) -> str | None:
|
||||
from contextzip.config import save_session_key
|
||||
|
||||
con.print()
|
||||
con.print(
|
||||
Panel(
|
||||
" [bold]eod[/] / [bold]handoff[/] can diff your codebase against the "
|
||||
"files Claude\n last gave you, if you give it your Claude.ai session cookie.\n\n"
|
||||
" 1. Open [bold cyan]claude.ai[/] and make sure you're logged in.\n"
|
||||
" 2. Open DevTools (F12) → Application/Storage → Cookies → "
|
||||
"https://claude.ai\n"
|
||||
" 3. Copy the value of the cookie named [cyan]sessionKey[/]\n\n"
|
||||
" [dim]This is equivalent to your account password while it's valid —\n"
|
||||
" it's stored locally on this machine and only ever sent to claude.ai.[/]",
|
||||
title="[bold yellow]Claude Session Key (optional)[/]",
|
||||
border_style="yellow",
|
||||
padding=(0, 2),
|
||||
)
|
||||
)
|
||||
con.print()
|
||||
|
||||
key = click.prompt(
|
||||
" Paste your sessionKey cookie value (or press Enter to skip)",
|
||||
default="",
|
||||
show_default=False,
|
||||
hide_input=True,
|
||||
prompt_suffix=" › ",
|
||||
).strip()
|
||||
|
||||
if not key:
|
||||
con.print(
|
||||
"\n [dim]Skipped — eod/handoff will still work, just without the "
|
||||
"diverged-from-Claude check.[/]"
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
save_session_key(key)
|
||||
con.print(
|
||||
f"\n [green]✓[/] Key saved to [dim]{config_path()}[/]\n"
|
||||
f" [dim]Run [cyan]contextzip config --reset-session-key[/] to change it.[/]"
|
||||
)
|
||||
except OSError as exc:
|
||||
con.print(
|
||||
f"\n [yellow]⚠[/] Could not save key to disk ([dim]{exc}[/]).\n"
|
||||
f" [dim]Set [cyan]CLAUDE_SESSION_KEY[/] as an environment variable "
|
||||
f"to avoid this prompt next time.[/]"
|
||||
)
|
||||
|
||||
con.print()
|
||||
return key
|
||||
|
||||
@@ -221,89 +221,3 @@ def _tier3(zip_path: Path) -> ClipboardResult:
|
||||
tier=Tier.PATH_ONLY,
|
||||
message=(f"📄 Copy this path and open it manually:\n [cyan]{zip_path}[/]"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Text clipboard — used by eod/handoff, which produce a prompt to paste
|
||||
# rather than a file to upload. Same tiered philosophy: try a real text
|
||||
# clipboard, fall back to "here's the file, open it yourself".
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def handle_text(text: str, fallback_path: Path) -> ClipboardResult:
|
||||
system = platform.system()
|
||||
|
||||
if system == "Darwin":
|
||||
result = _text_macos(text)
|
||||
if result:
|
||||
return result
|
||||
elif system == "Linux":
|
||||
result = _text_linux(text)
|
||||
if result:
|
||||
return result
|
||||
elif system == "Windows":
|
||||
result = _text_windows(text)
|
||||
if result:
|
||||
return result
|
||||
|
||||
return ClipboardResult(
|
||||
tier=Tier.PATH_ONLY,
|
||||
message=(
|
||||
f"📄 Couldn't reach the system clipboard — open and copy from:\n"
|
||||
f" [cyan]{fallback_path}[/]"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _text_macos(text: str) -> ClipboardResult | None:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["pbcopy"], input=text.encode("utf-8"), capture_output=True, timeout=8
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
return ClipboardResult(
|
||||
tier=Tier.FILE_ON_CLIPBOARD,
|
||||
message="📋 Prompt copied to clipboard — just paste it!",
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _text_linux(text: str) -> ClipboardResult | None:
|
||||
for tool, args in (
|
||||
("xclip", ["xclip", "-selection", "clipboard"]),
|
||||
("xsel", ["xsel", "--clipboard", "--input"]),
|
||||
):
|
||||
if not shutil.which(tool):
|
||||
continue
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
args, input=text.encode("utf-8"), capture_output=True, timeout=8
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
return ClipboardResult(
|
||||
tier=Tier.FILE_ON_CLIPBOARD,
|
||||
message="📋 Prompt copied to clipboard — just paste it!",
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _text_windows(text: str) -> ClipboardResult | None:
|
||||
# clip.exe is built into every Windows install and reads stdin verbatim.
|
||||
# Non-ASCII content may render oddly in legacy console codepages — a
|
||||
# known, minor limitation, not worth a heavier dependency to fully solve.
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["clip"], input=text.encode("utf-8", errors="replace"), timeout=8
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
return ClipboardResult(
|
||||
tier=Tier.FILE_ON_CLIPBOARD,
|
||||
message="📋 Prompt copied to clipboard — just paste it!",
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
||||
pass
|
||||
return None
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
"""
|
||||
code_changes.py — Resolves "what code changed" for `eod` / `handoff` using
|
||||
three baselines, checked in priority order per file:
|
||||
|
||||
1. UNPUSHED — file differs from the pushed/upstream branch
|
||||
(covers both committed-but-unpushed commits and
|
||||
uncommitted edits, in one comparison)
|
||||
2. DIVERGED_FROM_CLAUDE — file is pushed/in sync, but differs from the
|
||||
version Claude last produced (matched by filename
|
||||
against a local folder of fetched/saved artifacts)
|
||||
3. SINCE_MARKER — neither of the above; diff against the eod/handoff
|
||||
marker commit (or the branch's merge-base with the
|
||||
default branch, on a branch never tracked before)
|
||||
|
||||
Every file in cases 1-3 gets BOTH a diff (against whichever baseline applied)
|
||||
and the complete current file content. Brand-new untracked files have no
|
||||
baseline to diff against at all, so they get content only.
|
||||
|
||||
contextzip's base safety rules (no .env, no secrets, no binaries-as-text)
|
||||
are applied here too — being "relevant to today's report" never overrides
|
||||
"this shouldn't leave the machine".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
from contextzip.filters import build_spec
|
||||
from contextzip.git import (
|
||||
GitError,
|
||||
changed_files_against,
|
||||
diff_against,
|
||||
get_changed_files,
|
||||
get_commit_hash,
|
||||
get_current_branch,
|
||||
get_default_branch,
|
||||
get_merge_base,
|
||||
get_upstream_branch,
|
||||
is_ancestor,
|
||||
)
|
||||
from contextzip.markers import load_marker
|
||||
|
||||
_BINARY_PEEK = 512
|
||||
|
||||
|
||||
class ChangeCase(Enum):
|
||||
UNPUSHED = "unpushed"
|
||||
DIVERGED_FROM_CLAUDE = "diverged_from_claude"
|
||||
SINCE_MARKER = "since_marker"
|
||||
NEW_FILE = "new_file"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChangedFile:
|
||||
path: Path # absolute path in the working tree
|
||||
rel_path: str
|
||||
case: ChangeCase
|
||||
diff: str | None # unified diff text against the case's baseline; None for NEW_FILE
|
||||
content: str | None # complete current content; None if unreadable/binary
|
||||
is_binary: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class CodeChangesResult:
|
||||
files: list[ChangedFile] = field(default_factory=list)
|
||||
branch: str = ""
|
||||
baseline_commit: str | None = None # the case-3 baseline actually used
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
return not self.files
|
||||
|
||||
|
||||
def resolve_code_changes(
|
||||
*,
|
||||
project_dir: Path,
|
||||
kind: str, # "eod" or "handoff" — which marker to read
|
||||
claude_artifacts_dir: Path | None = None,
|
||||
default_branch_override: str | None = None,
|
||||
) -> CodeChangesResult | GitError:
|
||||
"""
|
||||
Build a :class:`CodeChangesResult` for the current working tree.
|
||||
|
||||
Returns a :class:`GitError` (not raised) if the project isn't a usable
|
||||
git repository at all — callers decide whether that's fatal or just
|
||||
means "skip the code-changes section of the prompt".
|
||||
"""
|
||||
git_changes = get_changed_files(project_dir)
|
||||
if isinstance(git_changes, GitError):
|
||||
return git_changes
|
||||
|
||||
result = CodeChangesResult()
|
||||
|
||||
branch = get_current_branch(project_dir) or "HEAD"
|
||||
result.branch = branch
|
||||
|
||||
# ── Resolve the case-3 baseline (marker, or merge-base on first run) ────
|
||||
marker = load_marker(kind, branch, project_dir)
|
||||
if marker and is_ancestor(marker, "HEAD", project_dir):
|
||||
baseline_commit = marker
|
||||
else:
|
||||
if marker:
|
||||
result.warnings.append(
|
||||
f"Stored {kind} marker for '{branch}' is no longer reachable from "
|
||||
"HEAD (likely a rebase) — recomputed from the branch's divergence point."
|
||||
)
|
||||
default_branch = get_default_branch(project_dir, default_branch_override)
|
||||
baseline_commit = get_merge_base(branch, default_branch, project_dir)
|
||||
if baseline_commit is None:
|
||||
# Unrelated histories, or default_branch couldn't be resolved at all —
|
||||
# last resort: HEAD itself, which collapses to "just today's
|
||||
# uncommitted changes", the same safe behaviour as a brand-new repo.
|
||||
baseline_commit = get_commit_hash("HEAD", project_dir)
|
||||
|
||||
result.baseline_commit = baseline_commit
|
||||
|
||||
# ── Resolve case-1 baseline (upstream) once, if one exists ─────────────
|
||||
upstream = get_upstream_branch(project_dir)
|
||||
unpushed_files: set[str] = set()
|
||||
if upstream:
|
||||
unpushed_files = set(changed_files_against(upstream, project_dir) or [])
|
||||
|
||||
# ── Universe of tracked files in scope ──────────────────────────────────
|
||||
since_baseline = set(changed_files_against(baseline_commit, project_dir) or [])
|
||||
dirty_now = set(git_changes.staged) | set(git_changes.unstaged)
|
||||
tracked_in_scope = since_baseline | dirty_now
|
||||
|
||||
base_spec = build_spec(rule_modules=["base"])
|
||||
|
||||
ambiguous_reference_warned: set[str] = set()
|
||||
|
||||
for rel_path in sorted(tracked_in_scope):
|
||||
if base_spec.match_file(rel_path):
|
||||
continue # .env, secrets, etc. — never included regardless of relevance
|
||||
|
||||
abs_path = project_dir / rel_path
|
||||
if not abs_path.is_file():
|
||||
continue # deleted since — nothing to diff or attach
|
||||
|
||||
content, is_binary = _read_text(abs_path)
|
||||
|
||||
if rel_path in unpushed_files:
|
||||
diff = diff_against(upstream, project_dir, paths=[rel_path])
|
||||
case = ChangeCase.UNPUSHED
|
||||
elif claude_artifacts_dir is not None:
|
||||
ref_path, extras = _find_claude_reference(rel_path, claude_artifacts_dir)
|
||||
if extras and rel_path not in ambiguous_reference_warned:
|
||||
ambiguous_reference_warned.add(rel_path)
|
||||
result.warnings.append(
|
||||
f"Multiple Claude-provided files named '{Path(rel_path).name}' found — "
|
||||
f"used the most recent ({ref_path}); ignored {len(extras)} other match(es)."
|
||||
)
|
||||
if ref_path is not None:
|
||||
claude_content, claude_is_binary = _read_text(ref_path)
|
||||
if not claude_is_binary and not is_binary and claude_content != content:
|
||||
diff = _text_diff(
|
||||
claude_content, content or "", f"claude/{rel_path}", rel_path
|
||||
)
|
||||
case = ChangeCase.DIVERGED_FROM_CLAUDE
|
||||
else:
|
||||
diff = diff_against(baseline_commit, project_dir, paths=[rel_path])
|
||||
case = ChangeCase.SINCE_MARKER
|
||||
else:
|
||||
diff = diff_against(baseline_commit, project_dir, paths=[rel_path])
|
||||
case = ChangeCase.SINCE_MARKER
|
||||
else:
|
||||
diff = diff_against(baseline_commit, project_dir, paths=[rel_path])
|
||||
case = ChangeCase.SINCE_MARKER
|
||||
|
||||
result.files.append(
|
||||
ChangedFile(
|
||||
path=abs_path,
|
||||
rel_path=rel_path,
|
||||
case=case,
|
||||
diff=diff,
|
||||
content=content,
|
||||
is_binary=is_binary,
|
||||
)
|
||||
)
|
||||
|
||||
# ── Untracked files — no baseline exists, content only ─────────────────
|
||||
for rel_path in sorted(git_changes.untracked):
|
||||
if base_spec.match_file(rel_path):
|
||||
continue
|
||||
|
||||
abs_path = project_dir / rel_path
|
||||
if not abs_path.is_file():
|
||||
continue
|
||||
|
||||
content, is_binary = _read_text(abs_path)
|
||||
result.files.append(
|
||||
ChangedFile(
|
||||
path=abs_path,
|
||||
rel_path=rel_path,
|
||||
case=ChangeCase.NEW_FILE,
|
||||
diff=None,
|
||||
content=content,
|
||||
is_binary=is_binary,
|
||||
)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _read_text(path: Path) -> tuple[str | None, bool]:
|
||||
"""Return (content, is_binary). content is None when binary or unreadable."""
|
||||
try:
|
||||
with path.open("rb") as fh:
|
||||
chunk = fh.read(_BINARY_PEEK)
|
||||
if b"\x00" in chunk:
|
||||
return None, True
|
||||
return path.read_text(encoding="utf-8", errors="replace"), False
|
||||
except OSError:
|
||||
return None, False
|
||||
|
||||
|
||||
def _text_diff(a_text: str, b_text: str, a_label: str, b_label: str) -> str:
|
||||
a_lines = a_text.splitlines(keepends=True)
|
||||
b_lines = b_text.splitlines(keepends=True)
|
||||
return "".join(
|
||||
difflib.unified_diff(a_lines, b_lines, fromfile=a_label, tofile=b_label)
|
||||
)
|
||||
|
||||
|
||||
def _find_claude_reference(
|
||||
rel_path: str, claude_artifacts_dir: Path
|
||||
) -> tuple[Path | None, list[Path]]:
|
||||
"""
|
||||
Find a file named like *rel_path*'s basename inside claude_artifacts_dir.
|
||||
|
||||
Claude's artifacts come out flat (no knowledge of the repo's directory
|
||||
structure), so matching can only be done by filename. Returns
|
||||
(best_match, other_matches) — best_match is the most recently modified
|
||||
candidate when more than one file shares the name.
|
||||
"""
|
||||
if not claude_artifacts_dir.is_dir():
|
||||
return None, []
|
||||
|
||||
target_name = Path(rel_path).name
|
||||
matches = [p for p in claude_artifacts_dir.rglob(target_name) if p.is_file()]
|
||||
|
||||
if not matches:
|
||||
return None, []
|
||||
if len(matches) == 1:
|
||||
return matches[0], []
|
||||
|
||||
matches.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
return matches[0], matches[1:]
|
||||
@@ -146,56 +146,6 @@ def config_path() -> Path:
|
||||
return _CONFIG_FILE
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Claude session key — used by eod/handoff to fetch Claude's artifact files
|
||||
# for the diverged-from-Claude comparison (code_changes.py case 2).
|
||||
#
|
||||
# Unlike the Gemini key, there's no documented prefix to validate against —
|
||||
# Anthropic doesn't publish a format for this cookie, so we only check that
|
||||
# something non-empty was provided. Getting this wrong silently is the
|
||||
# right failure mode here: a bad key just means case 2 gets skipped with a
|
||||
# warning, not a broken eod/handoff run.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_session_key() -> str | None:
|
||||
env_key = os.environ.get("CLAUDE_SESSION_KEY", "").strip()
|
||||
if env_key:
|
||||
return env_key
|
||||
|
||||
try:
|
||||
data = _read_config()
|
||||
key = data.get("claude_session_key", "").strip()
|
||||
return key or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def save_session_key(key: str) -> None:
|
||||
_CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
data = _read_config()
|
||||
data["claude_session_key"] = key.strip()
|
||||
_CONFIG_FILE.write_text(
|
||||
json.dumps(data, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def delete_session_key() -> bool:
|
||||
try:
|
||||
data = _read_config()
|
||||
if "claude_session_key" not in data:
|
||||
return False
|
||||
del data["claude_session_key"]
|
||||
_CONFIG_FILE.write_text(
|
||||
json.dumps(data, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _read_config() -> dict:
|
||||
"""Read and parse the config file. Returns {} if missing or malformed."""
|
||||
if not _CONFIG_FILE.is_file():
|
||||
|
||||
@@ -249,134 +249,3 @@ def _is_submodule(path: Path) -> bool:
|
||||
return False
|
||||
git_marker = path / ".git"
|
||||
return git_marker.exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Branch-aware primitives — used by markers.py and code_changes.py for the
|
||||
# eod/handoff three-case diff logic. Each function returns None on any
|
||||
# failure (git not found, command error, condition not met) rather than
|
||||
# raising, since callers always have a sensible fallback for "couldn't
|
||||
# determine this" — see code_changes.py's case-resolution cascade.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run_git(
|
||||
args: list[str], project_dir: Path, timeout: int = 15
|
||||
) -> subprocess.CompletedProcess[str] | None:
|
||||
"""Run a git command, swallowing the kinds of failures callers treat as None."""
|
||||
try:
|
||||
return subprocess.run(
|
||||
["git", *args],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def get_current_branch(project_dir: Path) -> str | None:
|
||||
"""Return the current branch name, or None if detached HEAD / on failure."""
|
||||
proc = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], project_dir)
|
||||
if proc is None or proc.returncode != 0:
|
||||
return None
|
||||
branch = proc.stdout.strip()
|
||||
return None if branch == "HEAD" else branch
|
||||
|
||||
|
||||
def get_commit_hash(ref: str, project_dir: Path) -> str | None:
|
||||
"""Resolve *ref* (branch name, HEAD, short hash, etc.) to a full commit hash."""
|
||||
proc = _run_git(["rev-parse", ref], project_dir)
|
||||
if proc is None or proc.returncode != 0:
|
||||
return None
|
||||
return proc.stdout.strip()
|
||||
|
||||
|
||||
def get_upstream_branch(project_dir: Path) -> str | None:
|
||||
"""
|
||||
Return the upstream tracking ref for the current branch (e.g. 'origin/main'),
|
||||
or None if the branch has no upstream configured (never pushed).
|
||||
"""
|
||||
proc = _run_git(
|
||||
["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], project_dir
|
||||
)
|
||||
if proc is None or proc.returncode != 0:
|
||||
return None
|
||||
return proc.stdout.strip() or None
|
||||
|
||||
|
||||
def get_default_branch(project_dir: Path, configured: str | None = None) -> str:
|
||||
"""
|
||||
Best-effort detection of the repo's default branch, for use as a
|
||||
divergence point when a feature branch has never been tracked before.
|
||||
|
||||
Resolution order: explicit *configured* override → origin/HEAD symbolic
|
||||
ref → first of origin/main, origin/master, local main, local master that
|
||||
actually exists → current branch as a last resort (degenerate: diffing
|
||||
a branch against itself is a no-op, which is the same safe fallback
|
||||
used when there's no history at all to diverge from).
|
||||
"""
|
||||
if configured:
|
||||
return configured
|
||||
|
||||
proc = _run_git(["symbolic-ref", "refs/remotes/origin/HEAD"], project_dir)
|
||||
if proc is not None and proc.returncode == 0:
|
||||
ref = proc.stdout.strip()
|
||||
if ref.startswith("refs/remotes/origin/"):
|
||||
return ref[len("refs/remotes/origin/") :]
|
||||
|
||||
for candidate in ("origin/main", "origin/master", "main", "master"):
|
||||
proc = _run_git(
|
||||
[
|
||||
"show-ref",
|
||||
"--verify",
|
||||
"--quiet",
|
||||
f"refs/{'remotes/' if '/' in candidate else 'heads/'}{candidate}",
|
||||
],
|
||||
project_dir,
|
||||
)
|
||||
if proc is not None and proc.returncode == 0:
|
||||
return candidate
|
||||
|
||||
return get_current_branch(project_dir) or "HEAD"
|
||||
|
||||
|
||||
def get_merge_base(ref_a: str, ref_b: str, project_dir: Path) -> str | None:
|
||||
"""Return the merge-base commit of *ref_a* and *ref_b*, or None if unrelated/missing."""
|
||||
proc = _run_git(["merge-base", ref_a, ref_b], project_dir)
|
||||
if proc is None or proc.returncode != 0:
|
||||
return None
|
||||
return proc.stdout.strip() or None
|
||||
|
||||
|
||||
def is_ancestor(commit: str, ref: str, project_dir: Path) -> bool:
|
||||
"""True if *commit* is an ancestor of (reachable from) *ref*."""
|
||||
proc = _run_git(["merge-base", "--is-ancestor", commit, ref], project_dir)
|
||||
return proc is not None and proc.returncode == 0
|
||||
|
||||
|
||||
def diff_against(
|
||||
ref: str, project_dir: Path, paths: list[str] | None = None
|
||||
) -> str | None:
|
||||
"""
|
||||
Unified diff between *ref* and the current working tree (staged +
|
||||
unstaged), optionally scoped to *paths*. Using the working tree as the
|
||||
right-hand side (rather than HEAD) means committed-since-ref and
|
||||
currently-uncommitted changes both show up in one diff.
|
||||
"""
|
||||
args = ["diff", ref]
|
||||
if paths:
|
||||
args.extend(["--", *paths])
|
||||
proc = _run_git(args, project_dir, timeout=30)
|
||||
if proc is None or proc.returncode != 0:
|
||||
return None
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def changed_files_against(ref: str, project_dir: Path) -> list[str] | None:
|
||||
"""Relative paths of tracked files differing between *ref* and the working tree."""
|
||||
proc = _run_git(["diff", "--name-only", ref], project_dir, timeout=30)
|
||||
if proc is None or proc.returncode != 0:
|
||||
return None
|
||||
return [line for line in proc.stdout.splitlines() if line.strip()]
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
"""
|
||||
markers.py — Per-branch commit checkpoints for `eod` and `handoff`.
|
||||
|
||||
Each command (eod / handoff) tracks, independently and per git branch, the
|
||||
commit hash it last ran against. This is what lets every report contain
|
||||
only what's new since *that specific command* last ran on *that specific
|
||||
branch*, with no manual bookkeeping:
|
||||
|
||||
- First time running a command on a given branch → no marker yet →
|
||||
code_changes.py falls back to the branch's merge-base with the default
|
||||
branch, which correctly captures everything done on the branch so far
|
||||
(whether that's one commit or several).
|
||||
- Every subsequent run on the same branch → diff against the stored
|
||||
commit, i.e. "everything since last time I ran this".
|
||||
|
||||
Storage: .contextzip/markers/<kind>.json at the project's git root (or the
|
||||
project directory itself outside a git repo), shaped as:
|
||||
|
||||
{"main": "a1b2c3...", "feature-x": "d4e5f6..."}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from contextzip.packager import _workspace_dir
|
||||
|
||||
|
||||
def _markers_path(project_dir: Path, kind: str) -> Path:
|
||||
workspace, _ = _workspace_dir(project_dir)
|
||||
return workspace / "markers" / f"{kind}.json"
|
||||
|
||||
|
||||
def load_marker(kind: str, branch: str, project_dir: Path) -> str | None:
|
||||
"""Return the stored commit hash for *branch* under *kind*, or None if unset."""
|
||||
path = _markers_path(project_dir, kind)
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
value = data.get(branch)
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
|
||||
def save_marker(kind: str, branch: str, commit: str, project_dir: Path) -> None:
|
||||
"""Persist *commit* as the new checkpoint for *branch* under *kind*."""
|
||||
path = _markers_path(project_dir, kind)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
data: dict[str, str] = {}
|
||||
if path.is_file():
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
data = {}
|
||||
|
||||
data[branch] = commit
|
||||
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
||||
Reference in New Issue
Block a user