contextzip 0.3.3__tar.gz → 0.3.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {contextzip-0.3.3 → contextzip-0.3.4}/PKG-INFO +42 -3
- {contextzip-0.3.3 → contextzip-0.3.4}/README.md +41 -2
- {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/__init__.py +1 -1
- contextzip-0.3.4/contextzip/brief.py +280 -0
- contextzip-0.3.4/contextzip/claude_artifacts.py +150 -0
- contextzip-0.3.4/contextzip/claude_export.py +73 -0
- {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/cli.py +238 -32
- {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/cli_display.py +55 -0
- {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/cli_onboard.py +59 -0
- {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/clipboard.py +90 -14
- contextzip-0.3.4/contextzip/code_changes.py +257 -0
- {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/config.py +46 -1
- {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/git.py +142 -11
- contextzip-0.3.4/contextzip/markers.py +61 -0
- {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/packager.py +9 -11
- {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/rules/base.py +1 -1
- {contextzip-0.3.3 → contextzip-0.3.4}/contextzip.egg-info/PKG-INFO +42 -3
- {contextzip-0.3.3 → contextzip-0.3.4}/contextzip.egg-info/SOURCES.txt +5 -0
- {contextzip-0.3.3 → contextzip-0.3.4}/pyproject.toml +1 -1
- {contextzip-0.3.3 → contextzip-0.3.4}/LICENSE +0 -0
- {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/ai/__init__.py +0 -0
- {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/ai/gemini.py +0 -0
- {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/ai/heuristic.py +0 -0
- {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/ai/selector.py +0 -0
- {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/api.py +0 -0
- {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/cli_ai.py +0 -0
- {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/detector.py +0 -0
- {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/error_parser.py +0 -0
- {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/filters.py +0 -0
- {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/rules/__init__.py +0 -0
- {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/rules/errors/__init__.py +0 -0
- {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/rules/errors/node.py +0 -0
- {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/rules/errors/python.py +0 -0
- {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/rules/go.py +0 -0
- {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/rules/node.py +0 -0
- {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/rules/python.py +0 -0
- {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/rules/ruby.py +0 -0
- {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/rules/rust.py +0 -0
- {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/watcher.py +0 -0
- {contextzip-0.3.3 → contextzip-0.3.4}/contextzip.egg-info/dependency_links.txt +0 -0
- {contextzip-0.3.3 → contextzip-0.3.4}/contextzip.egg-info/entry_points.txt +0 -0
- {contextzip-0.3.3 → contextzip-0.3.4}/contextzip.egg-info/requires.txt +0 -0
- {contextzip-0.3.3 → contextzip-0.3.4}/contextzip.egg-info/top_level.txt +0 -0
- {contextzip-0.3.3 → contextzip-0.3.4}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: contextzip
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Summary: Intelligently package your codebase for AI tools
|
|
5
5
|
Author-email: Deepesh <akadeepesh@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -52,6 +52,7 @@ contextzip eliminates that entirely. Run it from your project root — it detect
|
|
|
52
52
|
- **Git-aware packaging** — use `--git-changes` to package only modified, staged, and untracked files; perfect for incremental debugging and PR review sessions
|
|
53
53
|
- **AI-powered file selection** — describe your task in plain English with `--prompt` and Gemini selects the minimum relevant files automatically, no manual hunting required
|
|
54
54
|
- **Terminal error watcher** — wrap any dev server with `contextzip watch` to auto-detect errors and package a ready-to-upload debug context in one keypress
|
|
55
|
+
- **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
|
|
55
56
|
- **Persistent workspace** — all generated ZIPs land in `.contextzip/` at your project root, discoverable, reusable, and git-ignored automatically
|
|
56
57
|
- **Warns before it's a problem** — flags large (≥ 1 MB) and binary files that AI tools can't read, before you waste an upload
|
|
57
58
|
- **Handles edge cases** — dangling symlinks, unreadable files, and paths outside the project tree are caught and reported, never silently dropped
|
|
@@ -117,7 +118,7 @@ contextzip [OPTIONS]
|
|
|
117
118
|
| `--no-clipboard` | Skip the clipboard / folder-open step |
|
|
118
119
|
| `--no-gitignore` | Ignore the project's `.gitignore` |
|
|
119
120
|
|
|
120
|
-
**Subcommands:** `exclude`, `include`, `watch`, `config` — run `contextzip --help` for full details.
|
|
121
|
+
**Subcommands:** `exclude`, `include`, `watch`, `config`, `eod`, `handoff` — run `contextzip --help` for full details.
|
|
121
122
|
|
|
122
123
|
---
|
|
123
124
|
|
|
@@ -243,11 +244,49 @@ Press **D** and contextzip immediately writes `.contextzip/debug-context.zip`. Y
|
|
|
243
244
|
|
|
244
245
|
---
|
|
245
246
|
|
|
247
|
+
## End-of-day reports and chat handoffs
|
|
248
|
+
|
|
249
|
+
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.
|
|
250
|
+
|
|
251
|
+
```bash
|
|
252
|
+
contextzip eod
|
|
253
|
+
contextzip handoff
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
**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.
|
|
257
|
+
|
|
258
|
+
**What gets built:**
|
|
259
|
+
|
|
260
|
+
- 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
|
|
261
|
+
- Whatever code changed, resolved per file in priority order:
|
|
262
|
+
1. **Not pushed** — diffed against your upstream branch, plus the complete current file
|
|
263
|
+
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
|
|
264
|
+
3. **Since last run** — diffed against a per-branch checkpoint that `eod`/`handoff` remember automatically, plus the complete current file
|
|
265
|
+
- New, untracked files are included as full content (there's nothing to diff them against)
|
|
266
|
+
- `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
|
|
267
|
+
|
|
268
|
+
The result is copied straight to your clipboard, and also saved to `.contextzip/` if you want to look it over first.
|
|
269
|
+
|
|
270
|
+
**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.
|
|
271
|
+
|
|
272
|
+
```bash
|
|
273
|
+
contextzip config --set-session-key
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
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.
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
contextzip eod --dry-run # preview without advancing the checkpoint
|
|
280
|
+
contextzip handoff --no-fetch # skip the Claude-artifact fetch for this run
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
246
285
|
## What gets excluded
|
|
247
286
|
|
|
248
287
|
contextzip stacks exclusion rules based on your detected stack, on top of your `.gitignore`.
|
|
249
288
|
|
|
250
|
-
**Always excluded:** `.git/`, `.env` files, logs, caches, editor config (`.vscode/`, `.idea/`), OS files (`.DS_Store`, `Thumbs.db`),
|
|
289
|
+
**Always excluded:** `.git/`, `.env` files, logs, caches, editor config (`.vscode/`, `.idea/`), OS files (`.DS_Store`, `Thumbs.db`), common binary formats, and contextzip's own `.contextzip/` and `exports/` working folders.
|
|
251
290
|
|
|
252
291
|
**By framework:**
|
|
253
292
|
|
|
@@ -23,6 +23,7 @@ contextzip eliminates that entirely. Run it from your project root — it detect
|
|
|
23
23
|
- **Git-aware packaging** — use `--git-changes` to package only modified, staged, and untracked files; perfect for incremental debugging and PR review sessions
|
|
24
24
|
- **AI-powered file selection** — describe your task in plain English with `--prompt` and Gemini selects the minimum relevant files automatically, no manual hunting required
|
|
25
25
|
- **Terminal error watcher** — wrap any dev server with `contextzip watch` to auto-detect errors and package a ready-to-upload debug context in one keypress
|
|
26
|
+
- **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
|
|
26
27
|
- **Persistent workspace** — all generated ZIPs land in `.contextzip/` at your project root, discoverable, reusable, and git-ignored automatically
|
|
27
28
|
- **Warns before it's a problem** — flags large (≥ 1 MB) and binary files that AI tools can't read, before you waste an upload
|
|
28
29
|
- **Handles edge cases** — dangling symlinks, unreadable files, and paths outside the project tree are caught and reported, never silently dropped
|
|
@@ -88,7 +89,7 @@ contextzip [OPTIONS]
|
|
|
88
89
|
| `--no-clipboard` | Skip the clipboard / folder-open step |
|
|
89
90
|
| `--no-gitignore` | Ignore the project's `.gitignore` |
|
|
90
91
|
|
|
91
|
-
**Subcommands:** `exclude`, `include`, `watch`, `config` — run `contextzip --help` for full details.
|
|
92
|
+
**Subcommands:** `exclude`, `include`, `watch`, `config`, `eod`, `handoff` — run `contextzip --help` for full details.
|
|
92
93
|
|
|
93
94
|
---
|
|
94
95
|
|
|
@@ -214,11 +215,49 @@ Press **D** and contextzip immediately writes `.contextzip/debug-context.zip`. Y
|
|
|
214
215
|
|
|
215
216
|
---
|
|
216
217
|
|
|
218
|
+
## End-of-day reports and chat handoffs
|
|
219
|
+
|
|
220
|
+
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.
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
contextzip eod
|
|
224
|
+
contextzip handoff
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
**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.
|
|
228
|
+
|
|
229
|
+
**What gets built:**
|
|
230
|
+
|
|
231
|
+
- 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
|
|
232
|
+
- Whatever code changed, resolved per file in priority order:
|
|
233
|
+
1. **Not pushed** — diffed against your upstream branch, plus the complete current file
|
|
234
|
+
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
|
|
235
|
+
3. **Since last run** — diffed against a per-branch checkpoint that `eod`/`handoff` remember automatically, plus the complete current file
|
|
236
|
+
- New, untracked files are included as full content (there's nothing to diff them against)
|
|
237
|
+
- `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
|
|
238
|
+
|
|
239
|
+
The result is copied straight to your clipboard, and also saved to `.contextzip/` if you want to look it over first.
|
|
240
|
+
|
|
241
|
+
**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.
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
contextzip config --set-session-key
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
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.
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
contextzip eod --dry-run # preview without advancing the checkpoint
|
|
251
|
+
contextzip handoff --no-fetch # skip the Claude-artifact fetch for this run
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
217
256
|
## What gets excluded
|
|
218
257
|
|
|
219
258
|
contextzip stacks exclusion rules based on your detected stack, on top of your `.gitignore`.
|
|
220
259
|
|
|
221
|
-
**Always excluded:** `.git/`, `.env` files, logs, caches, editor config (`.vscode/`, `.idea/`), OS files (`.DS_Store`, `Thumbs.db`),
|
|
260
|
+
**Always excluded:** `.git/`, `.env` files, logs, caches, editor config (`.vscode/`, `.idea/`), OS files (`.DS_Store`, `Thumbs.db`), common binary formats, and contextzip's own `.contextzip/` and `exports/` working folders.
|
|
222
261
|
|
|
223
262
|
**By framework:**
|
|
224
263
|
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""
|
|
2
|
+
brief.py — Orchestrates `contextzip eod` and `contextzip handoff`.
|
|
3
|
+
|
|
4
|
+
Both commands share one pipeline:
|
|
5
|
+
1. Locate the latest conversation export
|
|
6
|
+
2. Best-effort fetch Claude's artifact files if missing (for case 2)
|
|
7
|
+
3. Resolve code changes (the three-case decision tree in code_changes.py)
|
|
8
|
+
4. Render the prompt — conversation inlined or attached depending on size,
|
|
9
|
+
code changes attached as a zip when there are any
|
|
10
|
+
5. Write the prompt text (+ zip) to .contextzip/, advance the marker
|
|
11
|
+
|
|
12
|
+
`eod` and `handoff` differ only in the fixed instruction text and which
|
|
13
|
+
marker they read/write — everything else is identical, so this module
|
|
14
|
+
takes a `kind` parameter rather than having two near-duplicate pipelines.
|
|
15
|
+
|
|
16
|
+
contextzip does no AI calls here. This produces a prompt for a human to
|
|
17
|
+
paste into Claude or ChatGPT; the actual summarizing/continuing is the
|
|
18
|
+
receiving chat's job, not contextzip's.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import zipfile
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from datetime import datetime
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from contextzip.claude_artifacts import fetch_artifacts
|
|
29
|
+
from contextzip.claude_export import (
|
|
30
|
+
DEFAULT_EXPORTS_DIR,
|
|
31
|
+
ConversationExport,
|
|
32
|
+
NoExportFoundError,
|
|
33
|
+
artifacts_dir_for,
|
|
34
|
+
find_latest_export,
|
|
35
|
+
should_inline,
|
|
36
|
+
)
|
|
37
|
+
from contextzip.code_changes import CodeChangesResult, resolve_code_changes
|
|
38
|
+
from contextzip.config import get_session_key
|
|
39
|
+
from contextzip.git import GitError
|
|
40
|
+
from contextzip.markers import save_marker
|
|
41
|
+
from contextzip.packager import _ensure_gitignore, _workspace_dir
|
|
42
|
+
|
|
43
|
+
EOD_INSTRUCTION = "Create the work log table."
|
|
44
|
+
|
|
45
|
+
HANDOFF_INTRO = (
|
|
46
|
+
"I've hit my usage limit in a previous chat and need to continue this "
|
|
47
|
+
"project in a new one."
|
|
48
|
+
)
|
|
49
|
+
HANDOFF_REVIEW_INLINE = "Here's the conversation so far — please review it and continue from where we left off."
|
|
50
|
+
HANDOFF_REVIEW_ATTACHED = (
|
|
51
|
+
"I've attached the prior conversation export ({filename}) — please "
|
|
52
|
+
"review it and continue from where we left off."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class BriefError(Exception):
|
|
57
|
+
"""Raised for hard failures — no export found, etc."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class BriefResult:
|
|
62
|
+
kind: str # "eod" | "handoff"
|
|
63
|
+
prompt_text: str
|
|
64
|
+
prompt_path: Path
|
|
65
|
+
changes_zip_path: Path | None
|
|
66
|
+
export: ConversationExport
|
|
67
|
+
conversation_inlined: bool
|
|
68
|
+
code_changes: CodeChangesResult | None
|
|
69
|
+
fetch_warnings: list[str] = field(default_factory=list)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def run_brief(
|
|
73
|
+
*,
|
|
74
|
+
kind: str,
|
|
75
|
+
project_dir: Path,
|
|
76
|
+
exports_dir: Path | None = None,
|
|
77
|
+
auto_fetch_artifacts: bool = True,
|
|
78
|
+
dry_run: bool = False,
|
|
79
|
+
) -> BriefResult:
|
|
80
|
+
if exports_dir is None:
|
|
81
|
+
exports_dir = project_dir / DEFAULT_EXPORTS_DIR
|
|
82
|
+
|
|
83
|
+
_ensure_exports_ignored(exports_dir, project_dir)
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
export = find_latest_export(exports_dir)
|
|
87
|
+
except NoExportFoundError as exc:
|
|
88
|
+
raise BriefError(str(exc))
|
|
89
|
+
|
|
90
|
+
fetch_warnings: list[str] = []
|
|
91
|
+
claude_dir = artifacts_dir_for(export, exports_dir)
|
|
92
|
+
|
|
93
|
+
if auto_fetch_artifacts and not _has_any_file(claude_dir):
|
|
94
|
+
session_key = get_session_key()
|
|
95
|
+
if not session_key:
|
|
96
|
+
fetch_warnings.append(
|
|
97
|
+
"No Claude session key configured — skipping the diverged-from-Claude "
|
|
98
|
+
"check (case 2). Run `contextzip config --set-session-key` to enable it."
|
|
99
|
+
)
|
|
100
|
+
else:
|
|
101
|
+
_saved, errors = fetch_artifacts(export.text, claude_dir, session_key)
|
|
102
|
+
fetch_warnings.extend(errors)
|
|
103
|
+
|
|
104
|
+
code_changes = resolve_code_changes(
|
|
105
|
+
project_dir=project_dir,
|
|
106
|
+
kind=kind,
|
|
107
|
+
claude_artifacts_dir=claude_dir if _has_any_file(claude_dir) else None,
|
|
108
|
+
)
|
|
109
|
+
if isinstance(code_changes, GitError):
|
|
110
|
+
fetch_warnings.append(
|
|
111
|
+
f"Code changes skipped — {code_changes.message.splitlines()[0]}"
|
|
112
|
+
)
|
|
113
|
+
code_changes = None
|
|
114
|
+
elif code_changes.warnings:
|
|
115
|
+
fetch_warnings.extend(code_changes.warnings)
|
|
116
|
+
|
|
117
|
+
inline = should_inline(export)
|
|
118
|
+
|
|
119
|
+
changes_zip_path: Path | None = None
|
|
120
|
+
if code_changes and not code_changes.is_empty:
|
|
121
|
+
changes_zip_path = _build_changes_zip(code_changes, project_dir, kind)
|
|
122
|
+
|
|
123
|
+
prompt_text = _render_prompt(
|
|
124
|
+
kind=kind,
|
|
125
|
+
export=export,
|
|
126
|
+
inline=inline,
|
|
127
|
+
changes_zip_path=changes_zip_path,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
prompt_path = _write_prompt(prompt_text, project_dir, kind)
|
|
131
|
+
|
|
132
|
+
if not dry_run:
|
|
133
|
+
from contextzip.git import get_commit_hash, get_current_branch
|
|
134
|
+
|
|
135
|
+
branch = get_current_branch(project_dir) or "HEAD"
|
|
136
|
+
head = get_commit_hash("HEAD", project_dir)
|
|
137
|
+
if head:
|
|
138
|
+
save_marker(kind, branch, head, project_dir)
|
|
139
|
+
|
|
140
|
+
return BriefResult(
|
|
141
|
+
kind=kind,
|
|
142
|
+
prompt_text=prompt_text,
|
|
143
|
+
prompt_path=prompt_path,
|
|
144
|
+
changes_zip_path=changes_zip_path,
|
|
145
|
+
export=export,
|
|
146
|
+
conversation_inlined=inline,
|
|
147
|
+
code_changes=code_changes,
|
|
148
|
+
fetch_warnings=fetch_warnings,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
# Prompt rendering
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _render_prompt(
|
|
158
|
+
*,
|
|
159
|
+
kind: str,
|
|
160
|
+
export: ConversationExport,
|
|
161
|
+
inline: bool,
|
|
162
|
+
changes_zip_path: Path | None,
|
|
163
|
+
) -> str:
|
|
164
|
+
if kind == "eod":
|
|
165
|
+
return _render_eod(export, inline, changes_zip_path)
|
|
166
|
+
return _render_handoff(export, inline, changes_zip_path)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _render_eod(
|
|
170
|
+
export: ConversationExport, inline: bool, changes_zip_path: Path | None
|
|
171
|
+
) -> str:
|
|
172
|
+
parts: list[str] = []
|
|
173
|
+
|
|
174
|
+
if inline:
|
|
175
|
+
parts.append(f"Conversation:\n{export.text}")
|
|
176
|
+
else:
|
|
177
|
+
parts.append(f"Conversation attached: {export.path.name}")
|
|
178
|
+
|
|
179
|
+
if changes_zip_path is not None:
|
|
180
|
+
parts.append(f"Code changes attached: {changes_zip_path.name}")
|
|
181
|
+
|
|
182
|
+
parts.append(EOD_INSTRUCTION)
|
|
183
|
+
|
|
184
|
+
return "\n\n".join(parts)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _render_handoff(
|
|
188
|
+
export: ConversationExport, inline: bool, changes_zip_path: Path | None
|
|
189
|
+
) -> str:
|
|
190
|
+
parts: list[str] = [HANDOFF_INTRO]
|
|
191
|
+
|
|
192
|
+
if inline:
|
|
193
|
+
parts.append(f"{HANDOFF_REVIEW_INLINE}\n\n{export.text}")
|
|
194
|
+
else:
|
|
195
|
+
parts.append(HANDOFF_REVIEW_ATTACHED.format(filename=export.path.name))
|
|
196
|
+
|
|
197
|
+
if changes_zip_path is not None:
|
|
198
|
+
parts.append(
|
|
199
|
+
f"I've also attached the code changes since our last session "
|
|
200
|
+
f"({changes_zip_path.name}) so you have the current state without "
|
|
201
|
+
f"needing the full codebase."
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
return "\n\n".join(parts)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
# Output writing
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _write_prompt(prompt_text: str, project_dir: Path, kind: str) -> Path:
|
|
213
|
+
workspace, is_git_repo = _workspace_dir(project_dir)
|
|
214
|
+
workspace.mkdir(parents=True, exist_ok=True)
|
|
215
|
+
|
|
216
|
+
if is_git_repo:
|
|
217
|
+
try:
|
|
218
|
+
_ensure_gitignore(workspace.parent)
|
|
219
|
+
except OSError:
|
|
220
|
+
pass
|
|
221
|
+
|
|
222
|
+
# eod is once-a-day (overwritable, like codebase.zip); handoff can fire
|
|
223
|
+
# multiple times in a day (you can hit the limit more than once), so it
|
|
224
|
+
# gets a timestamp instead of just a date.
|
|
225
|
+
if kind == "eod":
|
|
226
|
+
stamp = datetime.now().strftime("%Y-%m-%d")
|
|
227
|
+
else:
|
|
228
|
+
stamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
|
|
229
|
+
|
|
230
|
+
path = workspace / f"{kind}-{stamp}.txt"
|
|
231
|
+
path.write_text(prompt_text, encoding="utf-8")
|
|
232
|
+
return path
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _build_changes_zip(
|
|
236
|
+
code_changes: CodeChangesResult, project_dir: Path, kind: str
|
|
237
|
+
) -> Path:
|
|
238
|
+
workspace, _ = _workspace_dir(project_dir)
|
|
239
|
+
workspace.mkdir(parents=True, exist_ok=True)
|
|
240
|
+
zip_path = workspace / f"{kind}-changes.zip"
|
|
241
|
+
|
|
242
|
+
with zipfile.ZipFile(
|
|
243
|
+
zip_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6
|
|
244
|
+
) as zf:
|
|
245
|
+
for f in code_changes.files:
|
|
246
|
+
if f.content is not None:
|
|
247
|
+
zf.writestr(f"full/{f.rel_path}", f.content.encode("utf-8"))
|
|
248
|
+
if f.diff is not None:
|
|
249
|
+
zf.writestr(f"diffs/{f.rel_path}.patch", f.diff.encode("utf-8"))
|
|
250
|
+
|
|
251
|
+
return zip_path
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _has_any_file(directory: Path) -> bool:
|
|
255
|
+
return directory.is_dir() and any(p.is_file() for p in directory.rglob("*"))
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _ensure_exports_ignored(exports_dir: Path, project_dir: Path) -> None:
|
|
259
|
+
"""
|
|
260
|
+
Register exports/ in .gitignore the first time it's used, same treatment
|
|
261
|
+
as .contextzip/. Conversation exports and fetched Claude artifacts are
|
|
262
|
+
personal working files, not something to accidentally commit — though
|
|
263
|
+
a team that *wants* to share them can simply remove the line.
|
|
264
|
+
"""
|
|
265
|
+
from contextzip.packager import _find_git_root
|
|
266
|
+
|
|
267
|
+
git_root = _find_git_root(project_dir)
|
|
268
|
+
if git_root is None:
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
rel = exports_dir.resolve().relative_to(git_root)
|
|
273
|
+
except ValueError:
|
|
274
|
+
return # exports_dir lives outside the repo — nothing to register
|
|
275
|
+
|
|
276
|
+
entry = rel.as_posix().rstrip("/") + "/"
|
|
277
|
+
try:
|
|
278
|
+
_ensure_gitignore(git_root, entry)
|
|
279
|
+
except OSError:
|
|
280
|
+
pass
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""
|
|
2
|
+
claude_artifacts.py — Fetches Claude.ai artifact files referenced in a
|
|
3
|
+
conversation export.
|
|
4
|
+
|
|
5
|
+
This talks to an undocumented Claude.ai endpoint using the user's own
|
|
6
|
+
session cookie (contextzip.config.get_session_key()). It is explicitly
|
|
7
|
+
best-effort: a missing key, an expired cookie, or the endpoint changing
|
|
8
|
+
shape should degrade gracefully — code_changes.py simply skips case 2
|
|
9
|
+
(diverged-from-Claude) for affected files — rather than aborting the whole
|
|
10
|
+
eod/handoff run. Nothing here should ever raise out to the caller for a
|
|
11
|
+
single failed download; only total inability to attempt the fetch at all
|
|
12
|
+
(httpx missing) is surfaced as an immediate empty result with an error.
|
|
13
|
+
|
|
14
|
+
Known fragility, by design, not oversight: this is reverse-engineered from
|
|
15
|
+
the claude.ai web app's own network calls, not a published API. It may stop
|
|
16
|
+
working without notice, and depending on Cloudflare's bot-management state
|
|
17
|
+
it may also simply fail unpredictably from a non-browser client regardless
|
|
18
|
+
of how correct the request looks.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
import urllib.parse
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
import httpx
|
|
30
|
+
|
|
31
|
+
_HTTPX_AVAILABLE = True
|
|
32
|
+
except ImportError:
|
|
33
|
+
_HTTPX_AVAILABLE = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
DOWNLOAD_LINK_RE = re.compile(
|
|
37
|
+
r"\[([^\]]+)\]\((https://claude\.ai/api/organizations/[^)\s]+"
|
|
38
|
+
r"/wiggle/download-file\?path=[^)\s]+)\)"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
_BROWSER_HEADERS = {
|
|
42
|
+
"User-Agent": (
|
|
43
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
44
|
+
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
|
|
45
|
+
),
|
|
46
|
+
"Accept": "*/*",
|
|
47
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class ArtifactLink:
|
|
53
|
+
label: str
|
|
54
|
+
url: str
|
|
55
|
+
filename: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def extract_links(export_text: str) -> list[ArtifactLink]:
|
|
59
|
+
"""Pull every unique download-file link out of an export's raw text."""
|
|
60
|
+
seen: set[str] = set()
|
|
61
|
+
links: list[ArtifactLink] = []
|
|
62
|
+
|
|
63
|
+
for label, url in DOWNLOAD_LINK_RE.findall(export_text):
|
|
64
|
+
if url in seen:
|
|
65
|
+
continue
|
|
66
|
+
seen.add(url)
|
|
67
|
+
|
|
68
|
+
parsed = urllib.parse.urlparse(url)
|
|
69
|
+
query = urllib.parse.parse_qs(parsed.query)
|
|
70
|
+
raw_path = query.get("path", [""])[0]
|
|
71
|
+
decoded = urllib.parse.unquote(raw_path)
|
|
72
|
+
filename = Path(decoded).name or Path(label).name or "artifact"
|
|
73
|
+
|
|
74
|
+
links.append(ArtifactLink(label=label, url=url, filename=filename))
|
|
75
|
+
|
|
76
|
+
return links
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def fetch_artifacts(
|
|
80
|
+
export_text: str,
|
|
81
|
+
dest_dir: Path,
|
|
82
|
+
session_key: str,
|
|
83
|
+
) -> tuple[list[Path], list[str]]:
|
|
84
|
+
"""
|
|
85
|
+
Download every artifact link found in *export_text* into *dest_dir*.
|
|
86
|
+
|
|
87
|
+
Returns (saved_paths, errors). Never raises for an individual download
|
|
88
|
+
failure — each failure becomes a string in *errors* so callers (brief.py)
|
|
89
|
+
can surface them as warnings and continue with whatever did succeed.
|
|
90
|
+
"""
|
|
91
|
+
if not _HTTPX_AVAILABLE:
|
|
92
|
+
return [], ["httpx is not installed — cannot fetch Claude artifacts."]
|
|
93
|
+
|
|
94
|
+
links = extract_links(export_text)
|
|
95
|
+
if not links:
|
|
96
|
+
return [], []
|
|
97
|
+
|
|
98
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
|
|
100
|
+
saved: list[Path] = []
|
|
101
|
+
errors: list[str] = []
|
|
102
|
+
|
|
103
|
+
session_key = session_key.strip().strip('"').strip("'")
|
|
104
|
+
headers_base = dict(_BROWSER_HEADERS)
|
|
105
|
+
headers_base["Cookie"] = f"sessionKey={session_key}"
|
|
106
|
+
|
|
107
|
+
with httpx.Client(timeout=30, follow_redirects=True) as client:
|
|
108
|
+
for link in links:
|
|
109
|
+
headers = dict(headers_base)
|
|
110
|
+
headers["Referer"] = _conversation_referer(link.url)
|
|
111
|
+
headers["Origin"] = "https://claude.ai"
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
resp = client.get(link.url, headers=headers)
|
|
115
|
+
except httpx.RequestError as exc:
|
|
116
|
+
errors.append(f"{link.filename}: network error ({exc})")
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
if resp.status_code in (401, 403):
|
|
120
|
+
errors.append(
|
|
121
|
+
f"{link.filename}: auth failed ({resp.status_code}) — session key "
|
|
122
|
+
"may be missing/expired, or this request was blocked before it "
|
|
123
|
+
"even reached Claude's app (Cloudflare bot protection)."
|
|
124
|
+
)
|
|
125
|
+
continue
|
|
126
|
+
if resp.status_code == 404:
|
|
127
|
+
errors.append(
|
|
128
|
+
f"{link.filename}: not found (404) — the file or conversation "
|
|
129
|
+
"session may have expired."
|
|
130
|
+
)
|
|
131
|
+
continue
|
|
132
|
+
if resp.status_code != 200:
|
|
133
|
+
errors.append(f"{link.filename}: HTTP {resp.status_code}")
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
dest = dest_dir / link.filename
|
|
137
|
+
try:
|
|
138
|
+
dest.write_bytes(resp.content)
|
|
139
|
+
saved.append(dest)
|
|
140
|
+
except OSError as exc:
|
|
141
|
+
errors.append(f"{link.filename}: could not write file ({exc})")
|
|
142
|
+
|
|
143
|
+
return saved, errors
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _conversation_referer(url: str) -> str:
|
|
147
|
+
match = re.search(r"/conversations/([0-9a-fA-F-]+)/wiggle", url)
|
|
148
|
+
if match:
|
|
149
|
+
return f"https://claude.ai/chat/{match.group(1)}"
|
|
150
|
+
return "https://claude.ai/"
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
claude_export.py — Locates and prepares a Claude.ai conversation export for
|
|
3
|
+
use in `eod` / `handoff` prompts.
|
|
4
|
+
|
|
5
|
+
Pure text handling — no AI calls, no network. Finding "the latest export"
|
|
6
|
+
and deciding whether it's short enough to paste inline are the only two
|
|
7
|
+
responsibilities here.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
DEFAULT_EXPORTS_DIR = "exports"
|
|
16
|
+
DEFAULT_ARTIFACTS_SUBDIR = "artifacts"
|
|
17
|
+
|
|
18
|
+
# Conversations at or under this length get pasted directly into the
|
|
19
|
+
# generated prompt; longer ones are referenced as an attachment instead,
|
|
20
|
+
# since a full day's chat could otherwise burn through the next chat's
|
|
21
|
+
# context budget before any actual work happens.
|
|
22
|
+
INLINE_CHAR_THRESHOLD = 3500
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class NoExportFoundError(Exception):
|
|
26
|
+
"""Raised when no .md export exists in the exports directory."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class ConversationExport:
|
|
31
|
+
path: Path
|
|
32
|
+
text: str
|
|
33
|
+
conversation_name: str # filename stem — used for the artifacts subfolder
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def find_latest_export(exports_dir: Path) -> ConversationExport:
|
|
37
|
+
"""
|
|
38
|
+
Return the most recently modified .md file in *exports_dir*.
|
|
39
|
+
|
|
40
|
+
"Most recently modified" rather than "most recently created" — if you
|
|
41
|
+
re-export the same conversation later in the day, the newer copy wins
|
|
42
|
+
without needing a naming convention.
|
|
43
|
+
"""
|
|
44
|
+
if not exports_dir.is_dir():
|
|
45
|
+
raise NoExportFoundError(
|
|
46
|
+
f"Exports folder not found: {exports_dir}. "
|
|
47
|
+
f"Export a conversation and drop the .md file there first."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
candidates = list(exports_dir.glob("*.md"))
|
|
51
|
+
if not candidates:
|
|
52
|
+
raise NoExportFoundError(
|
|
53
|
+
f"No .md export files found in {exports_dir}. "
|
|
54
|
+
f"Export a conversation and drop the .md file there first."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
candidates.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
|
58
|
+
path = candidates[0]
|
|
59
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
60
|
+
|
|
61
|
+
return ConversationExport(path=path, text=text, conversation_name=path.stem)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def artifacts_dir_for(export: ConversationExport, exports_dir: Path) -> Path:
|
|
65
|
+
"""Where this export's Claude-provided artifact files live (or will be fetched to)."""
|
|
66
|
+
return exports_dir / DEFAULT_ARTIFACTS_SUBDIR / export.conversation_name
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def should_inline(
|
|
70
|
+
export: ConversationExport, threshold: int = INLINE_CHAR_THRESHOLD
|
|
71
|
+
) -> bool:
|
|
72
|
+
"""True if the export is short enough to paste directly into the prompt."""
|
|
73
|
+
return len(export.text) <= threshold
|