pi-sync-cli 0.2.0__tar.gz → 0.3.0__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.
- {pi_sync_cli-0.2.0 → pi_sync_cli-0.3.0}/.gitignore +3 -0
- {pi_sync_cli-0.2.0 → pi_sync_cli-0.3.0}/PKG-INFO +17 -1
- {pi_sync_cli-0.2.0 → pi_sync_cli-0.3.0}/README.md +16 -0
- {pi_sync_cli-0.2.0 → pi_sync_cli-0.3.0}/pyproject.toml +1 -1
- {pi_sync_cli-0.2.0 → pi_sync_cli-0.3.0}/src/pi_sync/cli.py +54 -0
- {pi_sync_cli-0.2.0 → pi_sync_cli-0.3.0}/tests/test_cli.py +80 -0
- {pi_sync_cli-0.2.0 → pi_sync_cli-0.3.0}/uv.lock +1 -1
- {pi_sync_cli-0.2.0 → pi_sync_cli-0.3.0}/.github/workflows/publish.yml +0 -0
- {pi_sync_cli-0.2.0 → pi_sync_cli-0.3.0}/LICENSE +0 -0
- {pi_sync_cli-0.2.0 → pi_sync_cli-0.3.0}/pyrightconfig.json +0 -0
- {pi_sync_cli-0.2.0 → pi_sync_cli-0.3.0}/src/pi_sync/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pi-sync-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Sync pi agent config and extensions between hosts over rsync
|
|
5
5
|
Project-URL: Repository, https://github.com/say4n/pi-sync
|
|
6
6
|
Project-URL: Issues, https://github.com/say4n/pi-sync/issues
|
|
@@ -63,6 +63,7 @@ Host-local state is deliberately never touched: `sessions/`, `npm/`,
|
|
|
63
63
|
| `--dry-run` | report changes, copy nothing |
|
|
64
64
|
| `-x, --exclude PATTERN` | skip matching files (repeatable) |
|
|
65
65
|
| `--install` | install pi on hosts that lack it, without prompting |
|
|
66
|
+
| `--uninstall` | remove pi from the host instead of syncing (config is kept) |
|
|
66
67
|
| `--local-dir` | default `$PI_CODING_AGENT_DIR` or `~/.pi/agent` |
|
|
67
68
|
| `--remote-dir` | default `~/.pi/agent` |
|
|
68
69
|
| `-v` / `--verbose` | print each rsync command and its output |
|
|
@@ -98,6 +99,21 @@ The probe checks `command -v pi` plus the usual install locations
|
|
|
98
99
|
`/usr/local/bin`), because a non-interactive ssh session does not source the
|
|
99
100
|
host's shell init — on a linuxbrew host `command -v pi` alone misses it.
|
|
100
101
|
|
|
102
|
+
## Uninstalling
|
|
103
|
+
|
|
104
|
+
`--uninstall` removes pi from the host instead of syncing. It runs
|
|
105
|
+
`npm uninstall -g --prefix <prefix> @earendil-works/pi-coding-agent`, deriving the
|
|
106
|
+
prefix from where pi actually lives (as the official installer does), then
|
|
107
|
+
re-probes to confirm the binary is really gone — npm can exit 0 having removed
|
|
108
|
+
nothing. `~/.pi/agent` is deliberately left untouched, since that is your config
|
|
109
|
+
rather than the CLI.
|
|
110
|
+
|
|
111
|
+
This exists because the official installer can only uninstall through its
|
|
112
|
+
interactive menu: its unattended mode always installs or reinstalls. If the
|
|
113
|
+
uninstall fails, that is usually a managed install
|
|
114
|
+
(`PI_EXPERIMENTAL=1`, under `<agent dir>/install`) — run
|
|
115
|
+
`curl -fsSL https://pi.dev/install.sh | sh` on the host and choose `u`.
|
|
116
|
+
|
|
101
117
|
## Shell completions
|
|
102
118
|
|
|
103
119
|
Host arguments complete from `~/.ssh/config`, following `Include` directives and
|
|
@@ -46,6 +46,7 @@ Host-local state is deliberately never touched: `sessions/`, `npm/`,
|
|
|
46
46
|
| `--dry-run` | report changes, copy nothing |
|
|
47
47
|
| `-x, --exclude PATTERN` | skip matching files (repeatable) |
|
|
48
48
|
| `--install` | install pi on hosts that lack it, without prompting |
|
|
49
|
+
| `--uninstall` | remove pi from the host instead of syncing (config is kept) |
|
|
49
50
|
| `--local-dir` | default `$PI_CODING_AGENT_DIR` or `~/.pi/agent` |
|
|
50
51
|
| `--remote-dir` | default `~/.pi/agent` |
|
|
51
52
|
| `-v` / `--verbose` | print each rsync command and its output |
|
|
@@ -81,6 +82,21 @@ The probe checks `command -v pi` plus the usual install locations
|
|
|
81
82
|
`/usr/local/bin`), because a non-interactive ssh session does not source the
|
|
82
83
|
host's shell init — on a linuxbrew host `command -v pi` alone misses it.
|
|
83
84
|
|
|
85
|
+
## Uninstalling
|
|
86
|
+
|
|
87
|
+
`--uninstall` removes pi from the host instead of syncing. It runs
|
|
88
|
+
`npm uninstall -g --prefix <prefix> @earendil-works/pi-coding-agent`, deriving the
|
|
89
|
+
prefix from where pi actually lives (as the official installer does), then
|
|
90
|
+
re-probes to confirm the binary is really gone — npm can exit 0 having removed
|
|
91
|
+
nothing. `~/.pi/agent` is deliberately left untouched, since that is your config
|
|
92
|
+
rather than the CLI.
|
|
93
|
+
|
|
94
|
+
This exists because the official installer can only uninstall through its
|
|
95
|
+
interactive menu: its unattended mode always installs or reinstalls. If the
|
|
96
|
+
uninstall fails, that is usually a managed install
|
|
97
|
+
(`PI_EXPERIMENTAL=1`, under `<agent dir>/install`) — run
|
|
98
|
+
`curl -fsSL https://pi.dev/install.sh | sh` on the host and choose `u`.
|
|
99
|
+
|
|
84
100
|
## Shell completions
|
|
85
101
|
|
|
86
102
|
Host arguments complete from `~/.ssh/config`, following `Include` directives and
|
|
@@ -24,6 +24,7 @@ from click.shell_completion import CompletionItem
|
|
|
24
24
|
DEFAULT_AGENT_DIR = "~/.pi/agent"
|
|
25
25
|
SSH_CONFIG = "~/.ssh/config"
|
|
26
26
|
PI_INSTALL_CMD = "curl -fsSL https://pi.dev/install.sh | sh"
|
|
27
|
+
PI_PACKAGE = "@earendil-works/pi-coding-agent"
|
|
27
28
|
# One ssh round trip answers both "is it reachable" and "where is pi".
|
|
28
29
|
PI_PROBE = """\
|
|
29
30
|
found="$(command -v pi 2>/dev/null)"
|
|
@@ -195,6 +196,30 @@ def install_pi(target: str) -> str | None:
|
|
|
195
196
|
return None
|
|
196
197
|
|
|
197
198
|
|
|
199
|
+
def uninstall_pi(target: str, pi_path: str) -> str | None:
|
|
200
|
+
"""Remove pi's npm install. Returns an error message, or None on success.
|
|
201
|
+
|
|
202
|
+
Mirrors the installer's own npm path — the prefix comes from where pi
|
|
203
|
+
actually lives — because its unattended mode can only install or reinstall;
|
|
204
|
+
uninstall is reachable only through its interactive menu. Like the
|
|
205
|
+
installer, the result is verified rather than trusted, and only the CLI is
|
|
206
|
+
removed: the agent directory is left alone.
|
|
207
|
+
"""
|
|
208
|
+
prefix = Path(pi_path).parent.parent
|
|
209
|
+
cmd = (
|
|
210
|
+
f"npm uninstall -g --prefix {prefix} --no-fund --no-audit "
|
|
211
|
+
f"--loglevel=error --progress=false {PI_PACKAGE}"
|
|
212
|
+
)
|
|
213
|
+
proc = run_cmd(["ssh", target, cmd])
|
|
214
|
+
if proc.returncode:
|
|
215
|
+
detail = ((proc.stderr or proc.stdout) or "").strip().splitlines()
|
|
216
|
+
return detail[-1] if detail else f"npm uninstall exited {proc.returncode}"
|
|
217
|
+
_, remaining = probe_host(target)
|
|
218
|
+
if remaining:
|
|
219
|
+
return f"npm uninstall finished, but pi is still present at {remaining}"
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
|
|
198
223
|
def ensure_pi(target: str, assume_yes: bool, remote_dir: str) -> bool:
|
|
199
224
|
"""Make sure the host has pi. Returns False when the host must be skipped.
|
|
200
225
|
|
|
@@ -327,6 +352,12 @@ def sync_host(
|
|
|
327
352
|
is_flag=True,
|
|
328
353
|
help="Install pi on hosts that lack it, without prompting.",
|
|
329
354
|
)
|
|
355
|
+
@click.option(
|
|
356
|
+
"--uninstall",
|
|
357
|
+
"uninstall_",
|
|
358
|
+
is_flag=True,
|
|
359
|
+
help="Uninstall pi from the host instead of syncing (keeps ~/.pi/agent).",
|
|
360
|
+
)
|
|
330
361
|
@click.option(
|
|
331
362
|
"--local-dir",
|
|
332
363
|
default=None,
|
|
@@ -352,6 +383,7 @@ def main(
|
|
|
352
383
|
dry_run: bool,
|
|
353
384
|
excludes: tuple[str, ...],
|
|
354
385
|
install_: bool,
|
|
386
|
+
uninstall_: bool,
|
|
355
387
|
local_dir: str | None,
|
|
356
388
|
remote_dir: str,
|
|
357
389
|
verbose: bool,
|
|
@@ -397,6 +429,28 @@ def main(
|
|
|
397
429
|
click.secho(f"→ {target}\n unreachable: {error}", fg="red")
|
|
398
430
|
continue
|
|
399
431
|
click.secho(f"→ {target}", bold=True)
|
|
432
|
+
if uninstall_:
|
|
433
|
+
if pi_path is None:
|
|
434
|
+
click.secho(" pi is not installed — nothing to uninstall")
|
|
435
|
+
continue
|
|
436
|
+
prefix = Path(pi_path).parent.parent
|
|
437
|
+
if dry_run:
|
|
438
|
+
click.secho(
|
|
439
|
+
f" would run: npm uninstall -g --prefix {prefix} {PI_PACKAGE}"
|
|
440
|
+
)
|
|
441
|
+
continue
|
|
442
|
+
click.secho(f" uninstalling pi from {pi_path}...")
|
|
443
|
+
error = uninstall_pi(target, pi_path)
|
|
444
|
+
if error:
|
|
445
|
+
failed = True
|
|
446
|
+
click.secho(f" {error}", fg="red")
|
|
447
|
+
click.secho(
|
|
448
|
+
" for a managed install, run the installer and choose 'u'",
|
|
449
|
+
fg="yellow",
|
|
450
|
+
)
|
|
451
|
+
else:
|
|
452
|
+
click.secho(" pi uninstalled — ~/.pi/agent was left alone", fg="green")
|
|
453
|
+
continue
|
|
400
454
|
if pi_path is None:
|
|
401
455
|
if dry_run:
|
|
402
456
|
click.secho(" pi not installed — nothing would be synced", fg="yellow")
|
|
@@ -29,6 +29,8 @@ class FakeRun:
|
|
|
29
29
|
pi_path: str | None = "/usr/bin/pi",
|
|
30
30
|
install_rc: int = 0,
|
|
31
31
|
install_installs: bool = True,
|
|
32
|
+
uninstall_rc: int = 0,
|
|
33
|
+
uninstall_removes: bool = True,
|
|
32
34
|
):
|
|
33
35
|
self.calls: list[list[str]] = []
|
|
34
36
|
self.inputs: list[str | None] = []
|
|
@@ -39,6 +41,8 @@ class FakeRun:
|
|
|
39
41
|
self.pi_path = pi_path
|
|
40
42
|
self.install_rc = install_rc
|
|
41
43
|
self.install_installs = install_installs
|
|
44
|
+
self.uninstall_rc = uninstall_rc
|
|
45
|
+
self.uninstall_removes = uninstall_removes
|
|
42
46
|
self.installed = False
|
|
43
47
|
|
|
44
48
|
def __call__(
|
|
@@ -51,6 +55,13 @@ class FakeRun:
|
|
|
51
55
|
return subprocess.CompletedProcess(
|
|
52
56
|
argv, self.rsync_rc, self.rsync_stdout, ""
|
|
53
57
|
)
|
|
58
|
+
if any("npm uninstall" in arg for arg in argv):
|
|
59
|
+
if self.uninstall_rc == 0 and self.uninstall_removes:
|
|
60
|
+
self.pi_path = None
|
|
61
|
+
stderr = "" if self.uninstall_rc == 0 else "npm ERR! code E404"
|
|
62
|
+
return subprocess.CompletedProcess(
|
|
63
|
+
argv, self.uninstall_rc, "removed\n", stderr
|
|
64
|
+
)
|
|
54
65
|
if any("install.sh" in arg for arg in argv):
|
|
55
66
|
if self.install_rc == 0:
|
|
56
67
|
# a real installer exits 0 even when the user picks "do nothing"
|
|
@@ -550,3 +561,72 @@ class TestInstallOffering:
|
|
|
550
561
|
)
|
|
551
562
|
assert result.exit_code == 1
|
|
552
563
|
assert install_calls(fake) == []
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
class TestUninstall:
|
|
567
|
+
def uninstall_calls(self, fake: FakeRun) -> list[list[str]]:
|
|
568
|
+
return [c for c in fake.calls if any("npm uninstall" in a for a in c)]
|
|
569
|
+
|
|
570
|
+
def test_uninstalls_with_the_prefix_pi_lives_in(
|
|
571
|
+
self, monkeypatch: pytest.MonkeyPatch, agent_dir: Path
|
|
572
|
+
) -> None:
|
|
573
|
+
fake = FakeRun(pi_path="/home/linuxbrew/.linuxbrew/bin/pi")
|
|
574
|
+
monkeypatch.setattr(cli, "run_cmd", fake)
|
|
575
|
+
result = CliRunner().invoke(cli.main, ["--uninstall", "host"])
|
|
576
|
+
assert result.exit_code == 0, result.output
|
|
577
|
+
(call,) = self.uninstall_calls(fake)
|
|
578
|
+
cmd = call[-1]
|
|
579
|
+
assert "npm uninstall -g --prefix /home/linuxbrew/.linuxbrew" in cmd
|
|
580
|
+
assert cli.PI_PACKAGE in cmd
|
|
581
|
+
assert "~/.pi/agent was left alone" in result.output
|
|
582
|
+
# uninstalling never syncs
|
|
583
|
+
assert not any(c[0] == "rsync" for c in fake.calls)
|
|
584
|
+
|
|
585
|
+
def test_absent_pi_is_a_noop(
|
|
586
|
+
self, monkeypatch: pytest.MonkeyPatch, agent_dir: Path
|
|
587
|
+
) -> None:
|
|
588
|
+
fake = FakeRun(pi_path=None)
|
|
589
|
+
monkeypatch.setattr(cli, "run_cmd", fake)
|
|
590
|
+
result = CliRunner().invoke(cli.main, ["--uninstall", "host"])
|
|
591
|
+
assert result.exit_code == 0
|
|
592
|
+
assert "nothing to uninstall" in result.output
|
|
593
|
+
assert self.uninstall_calls(fake) == []
|
|
594
|
+
|
|
595
|
+
def test_npm_failure_is_reported(
|
|
596
|
+
self, monkeypatch: pytest.MonkeyPatch, agent_dir: Path
|
|
597
|
+
) -> None:
|
|
598
|
+
fake = FakeRun(pi_path="/usr/bin/pi", uninstall_rc=1)
|
|
599
|
+
monkeypatch.setattr(cli, "run_cmd", fake)
|
|
600
|
+
result = CliRunner().invoke(cli.main, ["--uninstall", "host"])
|
|
601
|
+
assert result.exit_code == 1
|
|
602
|
+
assert "npm ERR!" in result.output
|
|
603
|
+
|
|
604
|
+
def test_still_present_after_uninstall_is_a_failure(
|
|
605
|
+
self, monkeypatch: pytest.MonkeyPatch, agent_dir: Path
|
|
606
|
+
) -> None:
|
|
607
|
+
"""npm can exit 0 having removed nothing; the re-probe has the last word."""
|
|
608
|
+
fake = FakeRun(pi_path="/usr/bin/pi", uninstall_removes=False)
|
|
609
|
+
monkeypatch.setattr(cli, "run_cmd", fake)
|
|
610
|
+
result = CliRunner().invoke(cli.main, ["--uninstall", "host"])
|
|
611
|
+
assert result.exit_code == 1
|
|
612
|
+
assert "still present at /usr/bin/pi" in result.output
|
|
613
|
+
|
|
614
|
+
def test_dry_run_uninstall_changes_nothing(
|
|
615
|
+
self, monkeypatch: pytest.MonkeyPatch, agent_dir: Path
|
|
616
|
+
) -> None:
|
|
617
|
+
fake = FakeRun(pi_path="/usr/bin/pi")
|
|
618
|
+
monkeypatch.setattr(cli, "run_cmd", fake)
|
|
619
|
+
result = CliRunner().invoke(cli.main, ["--uninstall", "--dry-run", "host"])
|
|
620
|
+
assert result.exit_code == 0
|
|
621
|
+
assert "would run: npm uninstall" in result.output
|
|
622
|
+
assert self.uninstall_calls(fake) == []
|
|
623
|
+
|
|
624
|
+
def test_unreachable_host_fails(
|
|
625
|
+
self, monkeypatch: pytest.MonkeyPatch, agent_dir: Path
|
|
626
|
+
) -> None:
|
|
627
|
+
fake = FakeRun(ssh_rc=255)
|
|
628
|
+
monkeypatch.setattr(cli, "run_cmd", fake)
|
|
629
|
+
result = CliRunner().invoke(cli.main, ["--uninstall", "host"])
|
|
630
|
+
assert result.exit_code == 1
|
|
631
|
+
assert "unreachable" in result.output
|
|
632
|
+
assert self.uninstall_calls(fake) == []
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|