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.
Files changed (44) hide show
  1. {contextzip-0.3.3 → contextzip-0.3.4}/PKG-INFO +42 -3
  2. {contextzip-0.3.3 → contextzip-0.3.4}/README.md +41 -2
  3. {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/__init__.py +1 -1
  4. contextzip-0.3.4/contextzip/brief.py +280 -0
  5. contextzip-0.3.4/contextzip/claude_artifacts.py +150 -0
  6. contextzip-0.3.4/contextzip/claude_export.py +73 -0
  7. {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/cli.py +238 -32
  8. {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/cli_display.py +55 -0
  9. {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/cli_onboard.py +59 -0
  10. {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/clipboard.py +90 -14
  11. contextzip-0.3.4/contextzip/code_changes.py +257 -0
  12. {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/config.py +46 -1
  13. {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/git.py +142 -11
  14. contextzip-0.3.4/contextzip/markers.py +61 -0
  15. {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/packager.py +9 -11
  16. {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/rules/base.py +1 -1
  17. {contextzip-0.3.3 → contextzip-0.3.4}/contextzip.egg-info/PKG-INFO +42 -3
  18. {contextzip-0.3.3 → contextzip-0.3.4}/contextzip.egg-info/SOURCES.txt +5 -0
  19. {contextzip-0.3.3 → contextzip-0.3.4}/pyproject.toml +1 -1
  20. {contextzip-0.3.3 → contextzip-0.3.4}/LICENSE +0 -0
  21. {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/ai/__init__.py +0 -0
  22. {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/ai/gemini.py +0 -0
  23. {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/ai/heuristic.py +0 -0
  24. {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/ai/selector.py +0 -0
  25. {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/api.py +0 -0
  26. {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/cli_ai.py +0 -0
  27. {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/detector.py +0 -0
  28. {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/error_parser.py +0 -0
  29. {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/filters.py +0 -0
  30. {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/rules/__init__.py +0 -0
  31. {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/rules/errors/__init__.py +0 -0
  32. {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/rules/errors/node.py +0 -0
  33. {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/rules/errors/python.py +0 -0
  34. {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/rules/go.py +0 -0
  35. {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/rules/node.py +0 -0
  36. {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/rules/python.py +0 -0
  37. {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/rules/ruby.py +0 -0
  38. {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/rules/rust.py +0 -0
  39. {contextzip-0.3.3 → contextzip-0.3.4}/contextzip/watcher.py +0 -0
  40. {contextzip-0.3.3 → contextzip-0.3.4}/contextzip.egg-info/dependency_links.txt +0 -0
  41. {contextzip-0.3.3 → contextzip-0.3.4}/contextzip.egg-info/entry_points.txt +0 -0
  42. {contextzip-0.3.3 → contextzip-0.3.4}/contextzip.egg-info/requires.txt +0 -0
  43. {contextzip-0.3.3 → contextzip-0.3.4}/contextzip.egg-info/top_level.txt +0 -0
  44. {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
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`), and common binary formats.
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`), and common binary formats.
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
 
@@ -1,6 +1,6 @@
1
1
  """contextzip — intelligent codebase packager for AI tools."""
2
2
 
3
- __version__ = "0.3.3"
3
+ __version__ = "0.3.4"
4
4
 
5
5
  from contextzip.api import (
6
6
  FileCollection,
@@ -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