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.
@@ -216,3 +216,6 @@ __marimo__/
216
216
 
217
217
  # Streamlit
218
218
  .streamlit/secrets.toml
219
+
220
+ # pi background task artifacts
221
+ .pi/tasks/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pi-sync-cli
3
- Version: 0.2.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pi-sync-cli"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Sync pi agent config and extensions between hosts over rsync"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -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) == []
@@ -52,7 +52,7 @@ wheels = [
52
52
 
53
53
  [[package]]
54
54
  name = "pi-sync-cli"
55
- version = "0.2.0"
55
+ version = "0.3.0"
56
56
  source = { editable = "." }
57
57
  dependencies = [
58
58
  { name = "click" },
File without changes