Remove Claude.ai session key handling and related artifact fetching functionality

This commit is contained in:
akadeepesh
2026-08-09 04:15:04 +05:30
parent d8faf3acf2
commit 64ff135102
14 changed files with 6 additions and 1929 deletions
+3
View File
@@ -38,3 +38,6 @@ desktop.ini
# contextzip workspace
.contextzip/
# contextzip workspace
exports/
-454
View File
@@ -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
+1 -40
View File
@@ -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`.
-280
View File
@@ -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
-150
View File
@@ -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/"
-73
View File
@@ -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
View File
@@ -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()
# ---------------------------------------------------------------------------
-55
View File
@@ -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}[/]")
-59
View File
@@ -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
-86
View File
@@ -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
-257
View File
@@ -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:]
-50
View File
@@ -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():
-131
View 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()]
-61
View File
@@ -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")