handover 0.2.0__py3-none-any.whl

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.
handover/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """
2
+ handover — Universal AI Chat to Local Agent Handover Tool.
3
+
4
+ Design in chat. Build in terminal. Zero context lost.
5
+ """
6
+
7
+ __version__ = "0.2.0"
handover/cli.py ADDED
@@ -0,0 +1,309 @@
1
+ """
2
+ handover/cli.py
3
+
4
+ Click CLI entry point for the handover tool.
5
+ See PRD Section 11 — CLI Interface.
6
+
7
+ Subcommands:
8
+ handover (main) — parse export, generate CLAUDE.md + PLAN.md
9
+ handover list — enumerate conversations in a bulk JSONL export
10
+ handover init — scaffold custom templates to ~/.handover/templates/
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import shutil
16
+ import subprocess
17
+ from pathlib import Path
18
+
19
+ import click
20
+
21
+ from handover import __version__
22
+
23
+
24
+ @click.group(invoke_without_command=True)
25
+ @click.pass_context
26
+ @click.option(
27
+ "--input",
28
+ "-i",
29
+ "input_file",
30
+ type=click.Path(exists=True),
31
+ required=False,
32
+ help="Path to the chat export file (.json, .jsonl, .md)",
33
+ )
34
+ @click.option(
35
+ "--output",
36
+ "-o",
37
+ "output_dir",
38
+ type=click.Path(),
39
+ required=False,
40
+ help="Directory to write CLAUDE.md and PLAN.md",
41
+ )
42
+ @click.option(
43
+ "--source",
44
+ type=click.Choice(["claude", "chatgpt", "gemini", "perplexity"]),
45
+ default=None,
46
+ help="Force a specific parser adapter (default: auto-detect)",
47
+ )
48
+ @click.option(
49
+ "--title",
50
+ default=None,
51
+ help="Select conversation by title from a bulk JSONL export",
52
+ )
53
+ @click.option(
54
+ "--id",
55
+ "conversation_id",
56
+ default=None,
57
+ help="Select conversation by ID from a bulk JSONL export",
58
+ )
59
+ @click.option(
60
+ "--dry-run",
61
+ is_flag=True,
62
+ default=False,
63
+ help="Print what would be written without writing files",
64
+ )
65
+ @click.option(
66
+ "--no-llm",
67
+ is_flag=True,
68
+ default=False,
69
+ help="Use rule-based extraction only (no API key required)",
70
+ )
71
+ @click.option(
72
+ "--launch",
73
+ is_flag=True,
74
+ default=False,
75
+ help="Run `claude` in the output directory after writing files",
76
+ )
77
+ @click.option(
78
+ "--template",
79
+ type=click.Path(),
80
+ default=None,
81
+ help="Path to custom Jinja2 templates directory",
82
+ )
83
+ @click.version_option(version=__version__, prog_name="handover")
84
+ def main(
85
+ ctx: click.Context,
86
+ input_file: str | None,
87
+ output_dir: str | None,
88
+ source: str | None,
89
+ title: str | None,
90
+ conversation_id: str | None,
91
+ dry_run: bool,
92
+ no_llm: bool,
93
+ launch: bool,
94
+ template: str | None,
95
+ ) -> None:
96
+ """
97
+ handover — Universal AI Chat to Local Agent Handover Tool.
98
+
99
+ Design in chat. Build in terminal. Zero context lost.
100
+
101
+ Parse a Claude chat export and generate CLAUDE.md + PLAN.md for
102
+ immediate use with Claude Code or another terminal agent.
103
+
104
+ Examples:
105
+
106
+ handover --input chat.json --output ./my-project/
107
+
108
+ handover --input export.jsonl --title "API Design" --output ./my-project/
109
+
110
+ handover --input chat.json --dry-run
111
+ """
112
+ if ctx.invoked_subcommand is not None:
113
+ return
114
+
115
+ # Validate required flags
116
+ if not input_file:
117
+ raise click.UsageError("--input is required.")
118
+ if not output_dir:
119
+ raise click.UsageError("--output is required.")
120
+
121
+ from handover.models import HandoverAPIError
122
+ from handover.parsers import detect_source, get_parser
123
+
124
+ input_path = Path(input_file)
125
+ output_path = Path(output_dir)
126
+
127
+ # Auto-detect source adapter
128
+ if source is None:
129
+ try:
130
+ source = detect_source(str(input_path))
131
+ except ValueError as e:
132
+ raise click.ClickException(str(e)) from e
133
+
134
+ parser = get_parser(source)
135
+
136
+ # Parse the file — generalized conversation filter via BaseParser.parse_by_id()
137
+ messages = None
138
+ selected_conv: dict[str, str] | None = None
139
+ try:
140
+ if title or conversation_id:
141
+ conversations = parser.list_conversations(input_path)
142
+ selected_conv = next(
143
+ (
144
+ c
145
+ for c in conversations
146
+ if (title and title.strip().lower() in c["title"].strip().lower())
147
+ or (conversation_id and c["id"] == conversation_id)
148
+ ),
149
+ None,
150
+ )
151
+ if selected_conv is None:
152
+ hint = f"title={title!r} (substring match)" if title else f"id={conversation_id!r}"
153
+ raise click.ClickException(
154
+ f"No conversation found with {hint}. "
155
+ "Run `handover list <export_file>` to see available conversations."
156
+ )
157
+ messages = parser.parse_by_id(input_path, selected_conv["id"])
158
+ else:
159
+ messages = parser.parse(input_path)
160
+ except click.ClickException:
161
+ raise
162
+ except (FileNotFoundError, ValueError) as e:
163
+ raise click.ClickException(str(e)) from e
164
+
165
+ if not messages:
166
+ raise click.ClickException("No messages found in the export file.")
167
+
168
+ # Summarize
169
+ from handover import summarizer
170
+
171
+ try:
172
+ context = summarizer.summarize(messages, use_llm=not no_llm)
173
+ except HandoverAPIError as e:
174
+ raise click.ClickException(str(e)) from e
175
+
176
+ # Populate metadata not set by summarizer
177
+ context.source = source
178
+ fmt_version = parser.detect_format_version(input_path)
179
+ context.source_version = fmt_version
180
+
181
+ # Populate conversation title/id from file metadata if not set by summarizer
182
+ if not context.conversation_title:
183
+ if selected_conv:
184
+ # We already have the target conversation's metadata
185
+ context.conversation_title = selected_conv.get("title", "")
186
+ context.conversation_id = selected_conv.get("id")
187
+ elif source == "claude":
188
+ try:
189
+ import json as _json
190
+
191
+ if input_path.suffix.lower() == ".json":
192
+ data = _json.loads(input_path.read_text(encoding="utf-8"))
193
+ # JSON array — use first conversation
194
+ if isinstance(data, list) and data:
195
+ context.conversation_title = data[0].get("name", "")
196
+ context.conversation_id = data[0].get("uuid")
197
+ elif isinstance(data, dict):
198
+ context.conversation_title = data.get("name", "")
199
+ context.conversation_id = data.get("uuid")
200
+ except Exception:
201
+ pass
202
+
203
+ # Generate artifacts
204
+ from handover.generator import Generator
205
+
206
+ template_dir = Path(template) if template else None
207
+ gen = Generator(template_dir=template_dir)
208
+
209
+ if dry_run:
210
+ result = gen.generate(context, output_path, dry_run=True)
211
+ click.echo(f"\nParsing: {context.conversation_title or input_path.name!r}")
212
+ click.echo(f" Source : {source} ({fmt_version})")
213
+ click.echo(f" Messages: {len(messages)}")
214
+ click.echo("\nExtracted:")
215
+ click.echo(f" Goal : {context.goal or '(none detected)'}")
216
+ click.echo(f" Tech Stack : {', '.join(context.tech_stack.values()) or '(none detected)'}")
217
+ click.echo(f" Decisions : {len(context.decisions)}")
218
+ click.echo(f" Tasks : {len(context.tasks)}")
219
+ click.echo(f" Constraints: {len(context.constraints)}")
220
+ click.echo(f" Questions : {len(context.open_questions)}")
221
+ click.echo(f"\nWould write to {output_path}/:")
222
+ for filename, content in result.items():
223
+ size_kb = len(content.encode()) / 1024
224
+ click.echo(f" -> {filename} ({size_kb:.1f} KB)")
225
+ click.echo("\nRun without --dry-run to write files.")
226
+ else:
227
+ gen.generate(context, output_path, dry_run=False)
228
+ click.echo(f"Wrote CLAUDE.md and PLAN.md to {output_path}/")
229
+
230
+ # --launch: open claude in output directory
231
+ if launch and not dry_run:
232
+ try:
233
+ subprocess.run(["claude"], cwd=str(output_path), check=False)
234
+ except FileNotFoundError:
235
+ click.echo(
236
+ "Warning: `claude` command not found. Install Claude Code: https://claude.ai/code",
237
+ err=True,
238
+ )
239
+
240
+
241
+ @main.command("list")
242
+ @click.argument("export_file", type=click.Path(exists=True))
243
+ @click.option(
244
+ "--source",
245
+ type=click.Choice(["claude", "chatgpt", "gemini", "perplexity"]),
246
+ default=None,
247
+ help="Force a specific parser adapter (default: auto-detect)",
248
+ )
249
+ def list_conversations(export_file: str, source: str | None) -> None:
250
+ """
251
+ List all conversations in a multi-conversation export file.
252
+
253
+ EXPORT_FILE: Path to the export file (e.g. bulk .jsonl, conversations.json).
254
+
255
+ Example:
256
+
257
+ handover list export.jsonl
258
+ handover list conversations.json
259
+ """
260
+ from handover.parsers import detect_source, get_parser
261
+
262
+ export_path = Path(export_file)
263
+ if source is None:
264
+ try:
265
+ source = detect_source(str(export_path))
266
+ except ValueError as e:
267
+ raise click.ClickException(str(e)) from e
268
+
269
+ parser = get_parser(source)
270
+ try:
271
+ conversations = parser.list_conversations(export_path)
272
+ except ValueError as e:
273
+ raise click.ClickException(str(e)) from e
274
+
275
+ if not conversations:
276
+ click.echo("No conversations found.")
277
+ return
278
+
279
+ # Table header
280
+ click.echo(f"\n{'ID':<38} {'DATE':<12} TITLE")
281
+ click.echo("-" * 100)
282
+ for conv in conversations:
283
+ date = conv["date"][:10] if conv["date"] else "unknown "
284
+ click.echo(f"{conv['id']:<38} {date:<12} {conv['title']}")
285
+ click.echo(f"\n{len(conversations)} conversation(s) found.")
286
+
287
+
288
+ @main.command("init")
289
+ def init_templates() -> None:
290
+ """
291
+ Scaffold customizable Jinja2 templates to ~/.handover/templates/.
292
+
293
+ After running, edit:
294
+ ~/.handover/templates/claude_md.j2
295
+ ~/.handover/templates/plan_md.j2
296
+
297
+ Then use --template ~/.handover/templates/ on any handover run.
298
+ """
299
+ template_src = Path(__file__).parent / "templates"
300
+ template_dst = Path.home() / ".handover" / "templates"
301
+ template_dst.mkdir(parents=True, exist_ok=True)
302
+
303
+ for template_file in sorted(template_src.glob("*.j2")):
304
+ dst = template_dst / template_file.name
305
+ shutil.copy2(template_file, dst)
306
+ click.echo(f" Copied {template_file.name} -> {dst}")
307
+
308
+ click.echo(f"\nTemplates scaffolded to {template_dst}")
309
+ click.echo("Edit them, then use: handover --template ~/.handover/templates/ ...")
handover/generator.py ADDED
@@ -0,0 +1,98 @@
1
+ """
2
+ handover/generator.py
3
+
4
+ Generator component: produces CLAUDE.md and PLAN.md from a HandoverContext.
5
+ See PRD Section 6 — Architecture (Generator component).
6
+ See PRD Section 10 — Output Artifacts.
7
+
8
+ Uses Jinja2 templates. Default templates are in handover/templates/.
9
+ Custom templates can be provided via --template flag or handover init.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+
16
+ from jinja2 import Environment, FileSystemLoader, select_autoescape
17
+
18
+ from handover import __version__
19
+ from handover.models import HandoverContext
20
+
21
+ DEFAULT_TEMPLATE_DIR = Path(__file__).parent / "templates"
22
+
23
+
24
+ class Generator:
25
+ """
26
+ Generate CLAUDE.md and PLAN.md from a HandoverContext using Jinja2 templates.
27
+ """
28
+
29
+ def __init__(self, template_dir: Path | None = None) -> None:
30
+ """
31
+ Initialize the Generator.
32
+
33
+ Args:
34
+ template_dir: Path to a directory containing claude_md.j2 and plan_md.j2.
35
+ Defaults to handover/templates/ (bundled defaults).
36
+ """
37
+ self.template_dir = template_dir or DEFAULT_TEMPLATE_DIR
38
+ self._env = Environment(
39
+ loader=FileSystemLoader(str(self.template_dir)),
40
+ autoescape=select_autoescape([]), # Markdown output — no HTML escaping
41
+ trim_blocks=True,
42
+ lstrip_blocks=True,
43
+ )
44
+
45
+ def generate(
46
+ self,
47
+ context: HandoverContext,
48
+ output_dir: Path,
49
+ dry_run: bool = False,
50
+ ) -> dict[str, str]:
51
+ """
52
+ Generate CLAUDE.md and PLAN.md from the given HandoverContext.
53
+
54
+ Args:
55
+ context: Populated HandoverContext from the Summarizer.
56
+ output_dir: Directory to write output files.
57
+ dry_run: If True, return rendered content without writing files.
58
+
59
+ Returns:
60
+ Dict mapping filename to rendered content:
61
+ {"CLAUDE.md": "...", "PLAN.md": "..."}
62
+ """
63
+ claude_md = self.render_claude_md(context)
64
+ plan_md = self.render_plan_md(context)
65
+ result = {"CLAUDE.md": claude_md, "PLAN.md": plan_md}
66
+
67
+ if not dry_run:
68
+ output_dir.mkdir(parents=True, exist_ok=True)
69
+ (output_dir / "CLAUDE.md").write_text(claude_md, encoding="utf-8")
70
+ (output_dir / "PLAN.md").write_text(plan_md, encoding="utf-8")
71
+
72
+ return result
73
+
74
+ def render_claude_md(self, context: HandoverContext) -> str:
75
+ """
76
+ Render the CLAUDE.md artifact.
77
+
78
+ Args:
79
+ context: Populated HandoverContext.
80
+
81
+ Returns:
82
+ Rendered CLAUDE.md as a string.
83
+ """
84
+ template = self._env.get_template("claude_md.j2")
85
+ return str(template.render(context=context, version=__version__))
86
+
87
+ def render_plan_md(self, context: HandoverContext) -> str:
88
+ """
89
+ Render the PLAN.md artifact.
90
+
91
+ Args:
92
+ context: Populated HandoverContext.
93
+
94
+ Returns:
95
+ Rendered PLAN.md as a string.
96
+ """
97
+ template = self._env.get_template("plan_md.j2")
98
+ return str(template.render(context=context, version=__version__))