Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ That drops `cc_peer.py` and its [Claude Code skill](skills/cc-peer/SKILL.md) int
`~/.claude/skills/cc-peer/`, and links `~/.local/bin/cc-peer`. Nothing else is touched.
Remove it with `./install.sh --uninstall [--host ...]`.

`cc-peer update` refreshes this machine from the latest GitHub release. For another machine,
`./install.sh --host <host>` pushes this copy over SSH — deliberately, since a target with no
route to GitHub is one of the cases this tool exists for. `cc-peer update --host` reports what
that machine has rather than trying to make it fetch.

**Remote installs push the files over the SSH connection itself**, so the target needs
no internet access — which matters, since air-gapped hosts are one of the reasons this
exists. It needs `python3` and your SSH access, nothing more.
Expand Down Expand Up @@ -100,6 +105,12 @@ git log --oneline -5 | cc-peer send --host web-01 --to api-worker - # stdin

cc-peer send --host web-01 --to api-worker --dry-run "x" # resolve only
cc-peer list --host web-01 --json # machine-readable

# Versions. list --host reports what that machine has installed and flags a
# mismatch, since a host left behind by a release won't say so on its own.
cc-peer update --check # is there a newer release?
cc-peer update # replace this installation
cc-peer update --host web-01 # report what's over there
cc-peer send --host web-01 --ssh-opt=-p --ssh-opt=2222 --to api-worker "..." # note the '='

# Envelope. Sends carry who they're from and how to answer, both resolved from
Expand Down
137 changes: 136 additions & 1 deletion cc_peer.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,12 @@
import socket
import subprocess
import sys
import urllib.error
import urllib.request
from pathlib import Path

__version__ = "0.3.0"
GITHUB_REPO = "abruption/cc-peer"

# Claude Code refuses a same-machine message once its serialized form passes
# about a million characters, so fail here rather than at the far end.
Expand Down Expand Up @@ -481,14 +484,29 @@ def emit(as_json: bool, payload: dict, human: str) -> None:


def cmd_list(args: argparse.Namespace) -> int:
remote_version = None
if args.host:
result = run_remote(args.host, ["list"] + (["--all"] if args.all else []), args.ssh_opt)
sessions = result.get("sessions", [])
# Ask the machine what it has installed. result["version"] would only
# echo our own, since run_remote ships and runs this very file.
remote_version = remote_installed_version(args.host, args.ssh_opt)
else:
sessions = discover(include_unreachable=args.all)

where = args.host or "this machine"
emit(args.json, {"sessions": sessions}, render_sessions(sessions, where))
human = render_sessions(sessions, where)
if remote_version and remote_version != __version__:
human = (
f"{where} runs cc-peer {remote_version}; this machine has {__version__}."
f"\nUpdate it with: cc-peer update --host {args.host}\n\n{human}"
)
emit(
args.json,
{"sessions": sessions, "version": __version__,
**({"remoteVersion": remote_version} if remote_version else {})},
human,
)
return 0


Expand All @@ -503,6 +521,116 @@ def read_message(args: argparse.Namespace) -> str:
return args.message


def remote_installed_version(host: str, ssh_opts: list[str]) -> str | None:
"""Version of the copy *installed* on that machine.

Not the same thing as asking the remote command to report itself:
run_remote() ships our own source and runs that, so it would always echo
our version back. The installed file is what a session over there will
actually use, and it is what can fall behind.
"""
check_ssh_argument(host, "--host")
for opt in ssh_opts:
check_ssh_argument(opt, "--ssh-opt")
probe = 'python3 "$HOME/.claude/skills/cc-peer/cc_peer.py" --version 2>/dev/null'
try:
done = subprocess.run(
["ssh", *ssh_opts, host, probe],
capture_output=True, text=True, timeout=30,
)
except (OSError, subprocess.SubprocessError):
return None
out = done.stdout.strip()
return out.split()[-1] if out.startswith("cc-peer") else None


def parse_version(text: str) -> tuple[int, ...]:
"""(1, 2, 3) from "v1.2.3". Unparseable parts sort lowest."""
parts = text.strip().lstrip("vV").split(".")
return tuple(int(p) if p.isdigit() else 0 for p in parts[:3])


def latest_release() -> tuple[str, str]:
"""(tag, download URL) of the newest release on GitHub."""
url = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"
request = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json"})
try:
with urllib.request.urlopen(request, timeout=DETECT_TIMEOUT * 4) as response:
tag = json.load(response).get("tag_name", "")
except (urllib.error.URLError, OSError, ValueError) as exc:
raise CcPeerError(
f"could not reach GitHub to check for updates: {exc}. "
f"On a host with no route out, update it from a machine that has one: "
f"cc-peer update --host <this host>"
) from exc
if not tag:
raise CcPeerError("GitHub returned no release tag")
return tag, f"https://raw.githubusercontent.com/{GITHUB_REPO}/{tag}/cc_peer.py"


def cmd_update(args: argparse.Namespace) -> int:
# Pushing from here beats asking the far side to fetch: the machines this
# tool exists for are the ones without a route to GitHub.
if args.host:
there = remote_installed_version(args.host, args.ssh_opt)
if there is None:
human = (f"{args.host} has no cc-peer installed (or it is too old to say).\n"
f"Install it with: ./install.sh --host {args.host}")
elif there == __version__:
human = f"{args.host} runs cc-peer {there} — same as this machine."
else:
human = (f"{args.host} runs cc-peer {there}; this machine has {__version__}.\n"
f"Push it with: ./install.sh --host {args.host}")
emit(args.json,
{"host": args.host, "remoteVersion": there, "current": __version__},
human)
return 0

tag, url = latest_release()
latest, current = parse_version(tag), parse_version(__version__)

if args.check:
state = "up to date" if current >= latest else f"{tag} available"
emit(
args.json,
{"current": __version__, "latest": tag, "outdated": current < latest},
f"cc-peer {__version__} — {state}",
)
return 0

if current >= latest:
emit(args.json, {"current": __version__, "latest": tag, "updated": False},
f"cc-peer {__version__} is already current ({tag}).")
return 0

target = Path(__file__).resolve()
try:
with urllib.request.urlopen(url, timeout=DETECT_TIMEOUT * 4) as response:
source = response.read()
except (urllib.error.URLError, OSError) as exc:
raise CcPeerError(f"could not download {tag}: {exc}") from exc
if b"__version__" not in source:
raise CcPeerError(f"what came back from {url} does not look like cc_peer.py")

# We are running from the file being replaced. Write beside it and rename,
# so a failed download can't leave a half-written script behind.
staged = target.with_suffix(".py.new")
try:
staged.write_bytes(source)
staged.chmod(target.stat().st_mode & 0o777)
staged.replace(target)
except OSError as exc:
staged.unlink(missing_ok=True)
raise CcPeerError(f"could not replace {target}: {exc}") from exc

emit(
args.json,
{"current": __version__, "latest": tag, "updated": True, "path": str(target)},
f"cc-peer {__version__} → {tag} ({target})",
)
return 0


def cmd_send(args: argparse.Namespace) -> int:
text = read_message(args)

Expand Down Expand Up @@ -609,6 +737,13 @@ def add_common(sub: argparse.ArgumentParser) -> None:
sending.add_argument("--dry-run", action="store_true", help="resolve the target, send nothing")
sending.set_defaults(func=cmd_send)

updating = subparsers.add_parser("update", help="update this installation")
add_common(updating)
updating.add_argument(
"--check", action="store_true", help="report the available version, change nothing"
)
updating.set_defaults(func=cmd_update)

return parser


Expand Down
44 changes: 44 additions & 0 deletions test_cc_peer.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,50 @@ def test_body_is_not_duplicated_or_reordered(self):
self.assertLess(out.index("line two"), out.index("Reply:"))


class VersionParsing(unittest.TestCase):
def test_orders_releases(self):
self.assertLess(cc_peer.parse_version("0.2.0"), cc_peer.parse_version("0.3.0"))
self.assertLess(cc_peer.parse_version("v0.9.0"), cc_peer.parse_version("v0.10.0"))
self.assertEqual(cc_peer.parse_version("v1.2.3"), cc_peer.parse_version("1.2.3"))

def test_survives_junk_without_raising(self):
# A malformed tag must not crash an update check; sorting lowest means
# "don't offer this as newer".
for text in ("", "not-a-version", "v..", "1.x.3"):
self.assertIsInstance(cc_peer.parse_version(text), tuple)
self.assertLess(cc_peer.parse_version("junk"), cc_peer.parse_version("0.0.1"))

def test_ignores_anything_past_patch(self):
self.assertEqual(cc_peer.parse_version("1.2.3.4"), (1, 2, 3))


class RemoteInstalledVersion(unittest.TestCase):
"""Why this exists at all: run_remote() ships and runs *our* source, so
asking the remote command to report itself would only echo our version.
The installed copy is the one that can fall behind."""

def run_with(self, stdout):
completed = mock.Mock(stdout=stdout, returncode=0)
with mock.patch.object(cc_peer.subprocess, "run", return_value=completed):
return cc_peer.remote_installed_version("web-01", [])

def test_reads_the_reported_version(self):
self.assertEqual(self.run_with("cc-peer 0.2.0\n"), "0.2.0")

def test_none_when_not_installed(self):
self.assertIsNone(self.run_with(""))
self.assertIsNone(self.run_with("python3: can't open file\n"))

def test_none_when_ssh_fails(self):
with mock.patch.object(cc_peer.subprocess, "run", side_effect=OSError("boom")):
self.assertIsNone(cc_peer.remote_installed_version("web-01", []))

def test_still_validates_ssh_arguments(self):
# This path builds its own ssh command, so it needs the same guard.
with self.assertRaises(cc_peer.CcPeerError):
cc_peer.remote_installed_version("-oProxyCommand=whoami", [])


class OwnSession(unittest.TestCase):
def test_reads_pid_from_the_exported_socket_path(self):
rows = [{"pid": 4011, "name": "worker", "reachable": True}]
Expand Down