Skip to content

Commit 0666ebf

Browse files
committed
Rename PyPI distribution to handoff-agent; add (c) copy on resume hint
The short name "handoff" is already taken on PyPI, so the distribution is published as handoff-agent while the CLI command, Python package, and entry point stay as "handoff". Also prompts the user to press (c) to copy the resume command after a transfer, and falls back gracefully when no clipboard tool is available.
1 parent edd731a commit 0666ebf

9 files changed

Lines changed: 152 additions & 36 deletions

File tree

.github/workflows/release.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ on:
77

88
# Use PyPI trusted publishing — no long-lived token required.
99
# Configure at https://pypi.org/manage/account/publishing/
10-
# Project: handoff, Owner: <your-org>, Repo: handoff, Workflow: release.yml, Environment: pypi
10+
# Project: handoff-agent, Owner: HacksonClark, Repo: handoff, Workflow: release.yml, Environment: pypi
1111
permissions:
1212
contents: read
1313

@@ -41,7 +41,7 @@ jobs:
4141
runs-on: ubuntu-latest
4242
environment:
4343
name: pypi
44-
url: https://pypi.org/p/handoff
44+
url: https://pypi.org/p/handoff-agent
4545
permissions:
4646
id-token: write # trusted publishing
4747
steps:

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,15 @@ When you switch between AI coding agents mid-project — rate limits, a second o
1717

1818
## Install
1919

20+
The CLI is named `handoff`; the PyPI distribution is `handoff-agent` (the
21+
short name was already taken):
22+
2023
```bash
2124
# uv (recommended)
22-
uv tool install handoff
25+
uv tool install handoff-agent
2326

2427
# pip
25-
pip install handoff
28+
pip install handoff-agent
2629
```
2730

2831
## Usage

packaging/README.md

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,20 @@ Releases publish automatically when you push a `v*` tag. Trusted publishing
66
is configured via GitHub Actions (`.github/workflows/release.yml`). Set up
77
the project on PyPI first:
88

9-
1. Create the project on https://pypi.org by uploading a manual release once.
10-
2. Add a trusted publisher: repo `HacksonClark/handoff`, workflow
11-
`release.yml`, environment `pypi`.
12-
3. Tag a release: `git tag v0.1.0 && git push --tags`.
9+
1. In PyPI, add a trusted publisher for project `handoff-agent`. The name
10+
`handoff` was already taken, so the distribution is published as
11+
`handoff-agent` while the CLI command stays `handoff`.
12+
2. Use repo `HacksonClark/handoff`, workflow `release.yml`, environment `pypi`.
13+
3. If the project does not exist on PyPI yet, configure it as a pending
14+
publisher so the first publish creates the project.
15+
4. Tag a release: `git tag v0.1.0 && git push origin v0.1.0`.
16+
17+
Once the workflow publishes `handoff-agent` to PyPI, users can install it
18+
with:
19+
20+
```bash
21+
uv tool install handoff-agent
22+
```
1323

1424
## Homebrew
1525

@@ -25,7 +35,8 @@ Ship via a personal tap first; if there's uptake, submit to `homebrew-core`.
2535

2636
## uv tool
2737

28-
Nothing to do — `uv tool install handoff` works once the package is on PyPI.
38+
Nothing separate to do. `uv tool install handoff-agent` installs from PyPI
39+
by default, so publishing to PyPI is what makes uv installs work.
2940

3041
## Shell completion
3142

packaging/handoff.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ class Handoff < Formula
1212

1313
desc "Seamlessly switch between AI coding agents without losing context"
1414
homepage "https://github.com/HacksonClark/handoff"
15-
url "https://files.pythonhosted.org/packages/source/h/handoff/handoff-0.1.0.tar.gz"
15+
url "https://files.pythonhosted.org/packages/source/h/handoff-agent/handoff_agent-0.1.0.tar.gz"
1616
sha256 "REPLACE_WITH_SHA256_OF_SDIST"
1717
license "MIT"
1818

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[project]
2-
name = "handoff"
2+
name = "handoff-agent"
33
version = "0.1.0"
44
description = "Seamlessly switch between AI coding agents without losing context."
55
readme = "README.md"

src/handoff/agents/codex.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,11 @@ def inject(self, transcript: CanonicalTranscript) -> Path:
342342
ts_iso = ts.isoformat().replace("+00:00", "Z")
343343
lines: list[dict[str, Any]] = []
344344
first_user_message = next(
345-
(msg.content for msg in transcript.transcript if msg.author == "user" and msg.content.strip()),
345+
(
346+
msg.content
347+
for msg in transcript.transcript
348+
if msg.author == "user" and msg.content.strip()
349+
),
346350
"",
347351
)
348352
title = first_user_message or f"Handoff from {transcript.metadata.source_agent}"
@@ -387,8 +391,6 @@ def inject(self, transcript: CanonicalTranscript) -> Path:
387391
for msg in transcript.transcript:
388392
lines.extend(self._message_to_records(msg, ts_iso))
389393

390-
import os
391-
392394
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
393395
with os.fdopen(fd, "w", encoding="utf-8") as f:
394396
for rec in lines:
@@ -412,7 +414,9 @@ def inject(self, transcript: CanonicalTranscript) -> Path:
412414
reasoning_effort=None,
413415
)
414416
except Exception as exc:
415-
log.warning("handoff: Codex SQLite mirror failed (falling back to JSONL-only): %s", exc)
417+
log.warning(
418+
"handoff: Codex SQLite mirror failed (falling back to JSONL-only): %s", exc
419+
)
416420
return path
417421

418422
@staticmethod

src/handoff/cli.py

Lines changed: 99 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
from __future__ import annotations
44

55
import json
6+
import os
7+
import shutil
8+
import subprocess
69
import sys
10+
from dataclasses import dataclass
711
from datetime import datetime, timezone
812
from pathlib import Path
913

@@ -217,7 +221,10 @@ def transfer_cmd(
217221
_success(f"Created {to_a.title()} session: {new_path.stem}")
218222
_info(f" {new_path}")
219223
_info("")
220-
_info(_resume_help(to_a, new_path))
224+
hint = _resume_hint(to_a, new_path)
225+
_info(hint.text)
226+
if hint.command:
227+
_prompt_copy(hint.command)
221228

222229

223230
def _emit(transcript: CanonicalTranscript, fmt: str) -> None:
@@ -230,33 +237,109 @@ def _emit(transcript: CanonicalTranscript, fmt: str) -> None:
230237
click.echo(to_markdown(transcript))
231238

232239

233-
def _resume_help(agent: str, path: Path) -> str:
234-
"""Agent-specific instructions for opening the injected session."""
240+
@dataclass(frozen=True)
241+
class ResumeHint:
242+
"""Instructions for resuming an injected session, plus a copyable command.
243+
244+
``command`` is ``None`` when there is no single command to copy (e.g. the
245+
target agent only exposes a session picker UI).
246+
"""
247+
248+
text: str
249+
command: str | None = None
250+
251+
252+
def _resume_hint(agent: str, path: Path) -> ResumeHint:
235253
session_id = path.stem
236254
if agent == "claude":
237-
return (
238-
"To continue in Claude Code:\n"
239-
f" claude --resume {session_id}"
240-
)
255+
command = f"claude --resume {session_id}"
256+
return ResumeHint(text=f"To continue in Claude Code:\n {command}", command=command)
241257
if agent == "codex":
242258
try:
243259
first = path.read_text(encoding="utf-8").splitlines()[0]
244260
record = json.loads(first)
245261
session_id = record.get("payload", {}).get("id") or session_id
246262
except (OSError, json.JSONDecodeError, IndexError, AttributeError):
247263
pass
248-
return (
249-
"To continue in Codex:\n"
250-
f" codex resume {session_id}\n"
251-
"(or just run `codex` in this directory — the most recent rollout is picked up)"
264+
command = f"codex resume {session_id}"
265+
return ResumeHint(
266+
text=(
267+
"To continue in Codex:\n"
268+
f" {command}\n"
269+
"(or just run `codex` in this directory — the most recent rollout is picked up)"
270+
),
271+
command=command,
252272
)
253273
if agent == "opencode":
254-
return (
255-
"To continue in OpenCode:\n"
256-
" opencode # then open the 'Handoff from <agent>' session from the session list\n"
257-
f"(session id: {session_id})"
274+
# OpenCode has no single-command resume — user picks from a session list.
275+
return ResumeHint(
276+
text=(
277+
"To continue in OpenCode:\n"
278+
" opencode # then open the 'Handoff from <agent>' session from the session list\n"
279+
f"(session id: {session_id})"
280+
),
281+
command=None,
282+
)
283+
return ResumeHint(text="Ready to continue!", command=None)
284+
285+
286+
def _copy_to_clipboard(text: str) -> bool:
287+
"""Copy ``text`` to the system clipboard. Returns True on success."""
288+
candidates: list[list[str]] = []
289+
if sys.platform == "darwin":
290+
candidates = [["pbcopy"]]
291+
elif sys.platform == "win32":
292+
candidates = [["clip"]]
293+
else:
294+
if os.environ.get("WAYLAND_DISPLAY"):
295+
candidates.append(["wl-copy"])
296+
candidates.extend(
297+
[["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]]
258298
)
259-
return "Ready to continue!"
299+
300+
for cmd in candidates:
301+
if not shutil.which(cmd[0]):
302+
continue
303+
try:
304+
subprocess.run(cmd, input=text, text=True, check=True, timeout=2)
305+
return True
306+
except (subprocess.SubprocessError, OSError):
307+
continue
308+
return False
309+
310+
311+
def _prompt_copy(command: str) -> None:
312+
"""Offer to copy ``command`` to the clipboard on keypress.
313+
314+
Only runs in interactive terminals; a no-op when stdout/stdin aren't TTYs
315+
(e.g. piped output, CI, test harnesses).
316+
"""
317+
if not (sys.stdin.isatty() and sys.stdout.isatty()):
318+
return
319+
320+
click.echo("")
321+
prompt = click.style(
322+
"Press (c) to copy command, any other key to continue...", fg="cyan", dim=True
323+
)
324+
click.echo(prompt, nl=False)
325+
try:
326+
ch = click.getchar(echo=False)
327+
except (KeyboardInterrupt, EOFError):
328+
click.echo("")
329+
return
330+
331+
# Wipe the prompt line so the final output doesn't include the prompt text.
332+
click.echo("\r\033[2K", nl=False)
333+
if ch and ch.lower() == "c":
334+
if _copy_to_clipboard(command):
335+
click.echo(click.style("✓ Copied to clipboard.", fg="green"))
336+
else:
337+
click.echo(
338+
click.style(
339+
"✗ Could not copy (no clipboard tool found — install pbcopy/xclip/wl-copy).",
340+
fg="yellow",
341+
)
342+
)
260343

261344

262345
# ---------------------------------------------------------------------------

tests/test_cli.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from click.testing import CliRunner
66

7-
from handoff.cli import _resume_help, main
7+
from handoff.cli import _resume_hint, main
88

99

1010
def test_help() -> None:
@@ -41,12 +41,27 @@ def test_unknown_agent_errors(tmp_path: Path, monkeypatch) -> None:
4141
assert result.exit_code != 0
4242

4343

44-
def test_resume_help_uses_codex_session_uuid(tmp_path: Path) -> None:
44+
def test_resume_hint_uses_codex_session_uuid(tmp_path: Path) -> None:
4545
rollout = tmp_path / "rollout-2026-04-21T20-37-58-abc.jsonl"
4646
rollout.write_text(
4747
'{"timestamp":"2026-04-21T20:37:58Z","type":"session_meta","payload":{"id":"392bdabb-6109-4343-81e5-6b7ca056b09d"}}\n',
4848
encoding="utf-8",
4949
)
5050

51-
help_text = _resume_help("codex", rollout)
52-
assert "codex resume 392bdabb-6109-4343-81e5-6b7ca056b09d" in help_text
51+
hint = _resume_hint("codex", rollout)
52+
assert hint.command == "codex resume 392bdabb-6109-4343-81e5-6b7ca056b09d"
53+
assert "codex resume 392bdabb-6109-4343-81e5-6b7ca056b09d" in hint.text
54+
55+
56+
def test_resume_hint_claude_has_copyable_command(tmp_path: Path) -> None:
57+
session = tmp_path / "4c9b7967-90d7-4fab-8e1d-6a95f1b3c8e2.jsonl"
58+
session.write_text("", encoding="utf-8")
59+
hint = _resume_hint("claude", session)
60+
assert hint.command == "claude --resume 4c9b7967-90d7-4fab-8e1d-6a95f1b3c8e2"
61+
62+
63+
def test_resume_hint_opencode_has_no_copyable_command(tmp_path: Path) -> None:
64+
session = tmp_path / "abc.json"
65+
session.write_text("", encoding="utf-8")
66+
hint = _resume_hint("opencode", session)
67+
assert hint.command is None

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)