pi-sync-cli 0.1.0__tar.gz → 0.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pi-sync-cli
3
- Version: 0.1.0
3
+ Version: 0.2.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
@@ -62,6 +62,7 @@ Host-local state is deliberately never touched: `sessions/`, `npm/`,
62
62
  | `--delete` | mirror `extensions/` exactly (deletes extras on the destination) |
63
63
  | `--dry-run` | report changes, copy nothing |
64
64
  | `-x, --exclude PATTERN` | skip matching files (repeatable) |
65
+ | `--install` | install pi on hosts that lack it, without prompting |
65
66
  | `--local-dir` | default `$PI_CODING_AGENT_DIR` or `~/.pi/agent` |
66
67
  | `--remote-dir` | default `~/.pi/agent` |
67
68
  | `-v` / `--verbose` | print each rsync command and its output |
@@ -69,6 +70,59 @@ Host-local state is deliberately never touched: `sessions/`, `npm/`,
69
70
  Multiple hosts are accepted: `pi-sync a b c`. Exits non-zero if any host is
70
71
  unreachable or any transfer fails.
71
72
 
73
+ ## Host preflight
74
+
75
+ Each host gets one ssh probe that reports reachability and pi's location in the
76
+ same round trip.
77
+
78
+ If pi is missing, pi-sync hands the terminal to pi's own installer
79
+ (`curl -fsSL https://pi.dev/install.sh | sh`), which keeps full control: its
80
+ prompts (its install/uninstall/do-nothing menu, a Node.js install, a sudo
81
+ password) work normally, and the sync continues once it exits. pi-sync does not
82
+ add a confirmation of its own, because the installer already asks. Without a
83
+ terminal it runs unattended under `--install`, stays quiet otherwise, and
84
+ `--dry-run` never installs anything.
85
+
86
+ The installer's exit status is not treated as proof: its "do nothing" choice
87
+ exits 0, so the host is re-probed afterwards and reported honestly.
88
+
89
+ **A host without pi is skipped** — copying into a host that has never run pi is
90
+ not useful and usually fails anyway, since there is no agent directory to copy
91
+ into. That covers an installer run that installed nothing, a failed install, and
92
+ a non-interactive run without `--install`; a skipped host makes the run exit
93
+ non-zero. After a successful install pi-sync creates the agent directory,
94
+ because rsync will not create intermediate directories on its own.
95
+
96
+ The probe checks `command -v pi` plus the usual install locations
97
+ (`~/.local/bin`, `~/.pi/bin`, `~/.pi/agent/bin`, linuxbrew, homebrew,
98
+ `/usr/local/bin`), because a non-interactive ssh session does not source the
99
+ host's shell init — on a linuxbrew host `command -v pi` alone misses it.
100
+
101
+ ## Shell completions
102
+
103
+ Host arguments complete from `~/.ssh/config`, following `Include` directives and
104
+ skipping wildcard entries:
105
+
106
+ ```bash
107
+ # bash
108
+ _PI_SYNC_COMPLETE=bash_source pi-sync > ~/.pi-sync-complete.bash
109
+ echo 'source ~/.pi-sync-complete.bash' >> ~/.bashrc
110
+
111
+ # zsh
112
+ _PI_SYNC_COMPLETE=zsh_source pi-sync > ~/.pi-sync-complete.zsh
113
+ echo 'source ~/.pi-sync-complete.zsh' >> ~/.zshrc
114
+
115
+ # fish (config.fish)
116
+ _PI_SYNC_COMPLETE=fish_source pi-sync | source
117
+ ```
118
+
119
+ Writing the script out (rather than `eval "$(_PI_SYNC_COMPLETE=bash_source pi-sync)"`
120
+ on every shell start) keeps shell startup fast, since the eval form runs the
121
+ binary each time. PowerShell works the same way via `powershell_source`.
122
+
123
+ Completions are read from the config at completion time, so new hosts appear
124
+ without regenerating anything.
125
+
72
126
  ## Caveats
73
127
 
74
128
  - `settings.json` is machine-written by pi (`lastChangelogVersion` bumps, UI
@@ -45,6 +45,7 @@ Host-local state is deliberately never touched: `sessions/`, `npm/`,
45
45
  | `--delete` | mirror `extensions/` exactly (deletes extras on the destination) |
46
46
  | `--dry-run` | report changes, copy nothing |
47
47
  | `-x, --exclude PATTERN` | skip matching files (repeatable) |
48
+ | `--install` | install pi on hosts that lack it, without prompting |
48
49
  | `--local-dir` | default `$PI_CODING_AGENT_DIR` or `~/.pi/agent` |
49
50
  | `--remote-dir` | default `~/.pi/agent` |
50
51
  | `-v` / `--verbose` | print each rsync command and its output |
@@ -52,6 +53,59 @@ Host-local state is deliberately never touched: `sessions/`, `npm/`,
52
53
  Multiple hosts are accepted: `pi-sync a b c`. Exits non-zero if any host is
53
54
  unreachable or any transfer fails.
54
55
 
56
+ ## Host preflight
57
+
58
+ Each host gets one ssh probe that reports reachability and pi's location in the
59
+ same round trip.
60
+
61
+ If pi is missing, pi-sync hands the terminal to pi's own installer
62
+ (`curl -fsSL https://pi.dev/install.sh | sh`), which keeps full control: its
63
+ prompts (its install/uninstall/do-nothing menu, a Node.js install, a sudo
64
+ password) work normally, and the sync continues once it exits. pi-sync does not
65
+ add a confirmation of its own, because the installer already asks. Without a
66
+ terminal it runs unattended under `--install`, stays quiet otherwise, and
67
+ `--dry-run` never installs anything.
68
+
69
+ The installer's exit status is not treated as proof: its "do nothing" choice
70
+ exits 0, so the host is re-probed afterwards and reported honestly.
71
+
72
+ **A host without pi is skipped** — copying into a host that has never run pi is
73
+ not useful and usually fails anyway, since there is no agent directory to copy
74
+ into. That covers an installer run that installed nothing, a failed install, and
75
+ a non-interactive run without `--install`; a skipped host makes the run exit
76
+ non-zero. After a successful install pi-sync creates the agent directory,
77
+ because rsync will not create intermediate directories on its own.
78
+
79
+ The probe checks `command -v pi` plus the usual install locations
80
+ (`~/.local/bin`, `~/.pi/bin`, `~/.pi/agent/bin`, linuxbrew, homebrew,
81
+ `/usr/local/bin`), because a non-interactive ssh session does not source the
82
+ host's shell init — on a linuxbrew host `command -v pi` alone misses it.
83
+
84
+ ## Shell completions
85
+
86
+ Host arguments complete from `~/.ssh/config`, following `Include` directives and
87
+ skipping wildcard entries:
88
+
89
+ ```bash
90
+ # bash
91
+ _PI_SYNC_COMPLETE=bash_source pi-sync > ~/.pi-sync-complete.bash
92
+ echo 'source ~/.pi-sync-complete.bash' >> ~/.bashrc
93
+
94
+ # zsh
95
+ _PI_SYNC_COMPLETE=zsh_source pi-sync > ~/.pi-sync-complete.zsh
96
+ echo 'source ~/.pi-sync-complete.zsh' >> ~/.zshrc
97
+
98
+ # fish (config.fish)
99
+ _PI_SYNC_COMPLETE=fish_source pi-sync | source
100
+ ```
101
+
102
+ Writing the script out (rather than `eval "$(_PI_SYNC_COMPLETE=bash_source pi-sync)"`
103
+ on every shell start) keeps shell startup fast, since the eval form runs the
104
+ binary each time. PowerShell works the same way via `powershell_source`.
105
+
106
+ Completions are read from the config at completion time, so new hosts appear
107
+ without regenerating anything.
108
+
55
109
  ## Caveats
56
110
 
57
111
  - `settings.json` is machine-written by pi (`lastChangelogVersion` bumps, UI
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pi-sync-cli"
3
- version = "0.1.0"
3
+ version = "0.2.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"
@@ -0,0 +1,5 @@
1
+ {
2
+ "venvPath": ".",
3
+ "venv": ".venv",
4
+ "include": ["src", "tests"]
5
+ }
@@ -14,12 +14,29 @@ from __future__ import annotations
14
14
 
15
15
  import os
16
16
  import subprocess
17
+ import sys
17
18
  from collections.abc import Sequence
18
19
  from pathlib import Path
19
20
 
20
21
  import click
22
+ from click.shell_completion import CompletionItem
21
23
 
22
24
  DEFAULT_AGENT_DIR = "~/.pi/agent"
25
+ SSH_CONFIG = "~/.ssh/config"
26
+ PI_INSTALL_CMD = "curl -fsSL https://pi.dev/install.sh | sh"
27
+ # One ssh round trip answers both "is it reachable" and "where is pi".
28
+ PI_PROBE = """\
29
+ found="$(command -v pi 2>/dev/null)"
30
+ if [ -z "$found" ]; then
31
+ for candidate in "$HOME/.local/bin/pi" "$HOME/.pi/bin/pi" \\
32
+ "$HOME/.linuxbrew/bin/pi" /home/linuxbrew/.linuxbrew/bin/pi \\
33
+ "$HOME/.pi/agent/bin/pi" /opt/homebrew/bin/pi /usr/local/bin/pi; do
34
+ if [ -x "$candidate" ]; then found="$candidate"; break; fi
35
+ done
36
+ fi
37
+ if [ -n "$found" ]; then echo "PI:$found"; fi
38
+ exit 0
39
+ """
23
40
  DIRECTORY_ITEMS = frozenset({"extensions"})
24
41
  GROUPS: dict[str, tuple[str, ...]] = {
25
42
  "config": ("models.json", "settings.json"),
@@ -36,6 +53,56 @@ def local_agent_dir(override: str | None = None) -> Path:
36
53
  return Path(raw).expanduser()
37
54
 
38
55
 
56
+ def ssh_hosts(
57
+ config: str = SSH_CONFIG, _seen: frozenset[str] = frozenset()
58
+ ) -> list[str]:
59
+ """Host aliases from an ssh config, following Include and skipping wildcards."""
60
+ path = Path(config).expanduser()
61
+ if not path.is_file():
62
+ return []
63
+ resolved = str(path.resolve())
64
+ if resolved in _seen:
65
+ return []
66
+ seen = _seen | {resolved}
67
+ hosts: list[str] = []
68
+ for raw_line in path.read_text(errors="replace").splitlines():
69
+ line = raw_line.strip()
70
+ if not line or line.startswith("#"):
71
+ continue
72
+ parts = line.split(None, 1)
73
+ if len(parts) < 2:
74
+ continue
75
+ keyword, rest = parts[0].lower(), parts[1].strip()
76
+ if keyword == "host":
77
+ for token in rest.split():
78
+ wildcard = "*" in token or "?" in token or token.startswith("!")
79
+ if not wildcard and token not in hosts:
80
+ hosts.append(token)
81
+ elif keyword == "include":
82
+ for pattern in rest.split():
83
+ target = Path(pattern).expanduser()
84
+ if not target.is_absolute():
85
+ target = Path.home() / ".ssh" / target
86
+ for included in sorted(target.parent.glob(target.name)):
87
+ hosts += [
88
+ h for h in ssh_hosts(str(included), seen) if h not in hosts
89
+ ]
90
+ return hosts
91
+
92
+
93
+ def complete_target(
94
+ ctx: click.Context, param: click.Parameter, incomplete: str
95
+ ) -> list[CompletionItem]:
96
+ """Complete the host part of a target from the user's ssh config."""
97
+ prefix = incomplete.rpartition("@")[2]
98
+ lead = incomplete[: len(incomplete) - len(prefix)] if prefix else incomplete
99
+ return [
100
+ CompletionItem(f"{lead}{host}")
101
+ for host in ssh_hosts()
102
+ if host.startswith(prefix)
103
+ ]
104
+
105
+
39
106
  def select_items(groups: set[str]) -> list[str]:
40
107
  return [item for group in GROUP_ORDER if group in groups for item in GROUPS[group]]
41
108
 
@@ -67,8 +134,10 @@ def rsync_argv(
67
134
  return argv
68
135
 
69
136
 
70
- def run_cmd(argv: list[str]) -> subprocess.CompletedProcess[str]:
71
- return subprocess.run(argv, capture_output=True, text=True)
137
+ def run_cmd(
138
+ argv: list[str], input_text: str | None = None, capture: bool = True
139
+ ) -> subprocess.CompletedProcess[str]:
140
+ return subprocess.run(argv, capture_output=capture, text=True, input=input_text)
72
141
 
73
142
 
74
143
  def summarize(output: str) -> tuple[int, int]:
@@ -82,13 +151,81 @@ def summarize(output: str) -> tuple[int, int]:
82
151
  return transferred, deleted
83
152
 
84
153
 
85
- def check_ssh(target: str) -> str | None:
86
- """Return an error message if the host is unreachable, else None."""
87
- proc = run_cmd(["ssh", "-o", "ConnectTimeout=10", target, "true"])
88
- if proc.returncode == 0:
89
- return None
90
- detail = (proc.stderr or proc.stdout or "ssh failed").strip().splitlines()
91
- return detail[0] if detail else "ssh failed"
154
+ def probe_host(target: str) -> tuple[str | None, str | None]:
155
+ """Return (error, pi path): error is set when the host is unreachable."""
156
+ proc = run_cmd(["ssh", "-o", "ConnectTimeout=10", target, "sh -s"], PI_PROBE)
157
+ if proc.returncode != 0:
158
+ detail = (proc.stderr or proc.stdout or "ssh failed").strip().splitlines()
159
+ return (detail[0] if detail else "ssh failed"), None
160
+ for line in proc.stdout.splitlines():
161
+ if line.startswith("PI:"):
162
+ return None, line.removeprefix("PI:").strip()
163
+ return None, None
164
+
165
+
166
+ def install_pi(target: str) -> str | None:
167
+ """Run pi's installer on the host. Returns an error message, or None on success.
168
+
169
+ Attached to a terminal the installer streams straight through and gets a
170
+ remote tty, so its prompts are visible and answerable: capturing them would
171
+ leave the user staring at a silent, unanswerable hang. Unattended, output is
172
+ captured for the failure summary and stdin is closed so a prompt fails fast
173
+ instead of blocking forever.
174
+
175
+ The installer's exit status is not proof of anything: its "do nothing" menu
176
+ choice exits 0 without installing, so the host is re-probed afterwards.
177
+ """
178
+ interactive = sys.stdin.isatty()
179
+ argv = ["ssh", *(["-t"] if interactive else []), target, PI_INSTALL_CMD]
180
+ if interactive:
181
+ proc = run_cmd(argv, capture=False)
182
+ failure = f"installer exited {proc.returncode}" if proc.returncode else None
183
+ else:
184
+ proc = run_cmd(argv, input_text="")
185
+ if proc.returncode:
186
+ detail = ((proc.stderr or proc.stdout) or "").strip().splitlines()
187
+ failure = detail[-1] if detail else f"installer exited {proc.returncode}"
188
+ else:
189
+ failure = None
190
+ if failure:
191
+ return failure
192
+ _, pi_path = probe_host(target)
193
+ if pi_path is None:
194
+ return "installer exited without installing pi (its menu needs 'y')"
195
+ return None
196
+
197
+
198
+ def ensure_pi(target: str, assume_yes: bool, remote_dir: str) -> bool:
199
+ """Make sure the host has pi. Returns False when the host must be skipped.
200
+
201
+ Copying into a host without pi is not useful and usually fails outright,
202
+ since the agent directory does not exist there yet.
203
+
204
+ Interactively the installer owns the decision: it has its own
205
+ install/uninstall/do-nothing menu, so prompting a second time here would
206
+ only add a redundant question before the one that counts.
207
+ """
208
+ interactive = sys.stdin.isatty()
209
+ if not interactive and not assume_yes:
210
+ click.secho(
211
+ " pi not installed — skipping (pass --install to add it)", fg="yellow"
212
+ )
213
+ return False
214
+ if interactive:
215
+ click.secho(
216
+ " handing over to pi's installer — the sync continues when it exits"
217
+ )
218
+ else:
219
+ click.secho(" running pi's installer (unattended)")
220
+ error = install_pi(target)
221
+ if error:
222
+ click.secho(f" {error} — skipping host", fg="red")
223
+ return False
224
+ click.secho(" pi installed; continuing with the sync", fg="green")
225
+ # A fresh install has never run, so the agent dir may not exist yet and
226
+ # rsync will not create intermediate directories for us.
227
+ run_cmd(["ssh", target, f"mkdir -p {remote_dir}"])
228
+ return True
92
229
 
93
230
 
94
231
  def sync_host(
@@ -103,7 +240,6 @@ def sync_host(
103
240
  verbose: bool,
104
241
  excludes: Sequence[str] = (),
105
242
  ) -> bool:
106
- click.secho(f"→ {target}", bold=True)
107
243
  ok = True
108
244
  for rel in items:
109
245
  if not pull and not (local_dir / rel).exists():
@@ -148,7 +284,13 @@ def sync_host(
148
284
 
149
285
 
150
286
  @click.command(context_settings={"help_option_names": ["-h", "--help"]})
151
- @click.argument("targets", nargs=-1, required=True, metavar="[USER@]HOST...")
287
+ @click.argument(
288
+ "targets",
289
+ nargs=-1,
290
+ required=True,
291
+ metavar="[USER@]HOST...",
292
+ shell_complete=complete_target,
293
+ )
152
294
  @click.option(
153
295
  "--all", "all_", is_flag=True, help="Sync config and extensions (the default)."
154
296
  )
@@ -179,6 +321,12 @@ def sync_host(
179
321
  metavar="PATTERN",
180
322
  help="rsync exclude pattern, e.g. '*/logs/*' (repeatable).",
181
323
  )
324
+ @click.option(
325
+ "--install",
326
+ "install_",
327
+ is_flag=True,
328
+ help="Install pi on hosts that lack it, without prompting.",
329
+ )
182
330
  @click.option(
183
331
  "--local-dir",
184
332
  default=None,
@@ -203,6 +351,7 @@ def main(
203
351
  delete_: bool,
204
352
  dry_run: bool,
205
353
  excludes: tuple[str, ...],
354
+ install_: bool,
206
355
  local_dir: str | None,
207
356
  remote_dir: str,
208
357
  verbose: bool,
@@ -242,11 +391,20 @@ def main(
242
391
  failed = False
243
392
  for raw_target in targets:
244
393
  target = raw_target.rstrip(":")
245
- error = check_ssh(target)
394
+ error, pi_path = probe_host(target)
246
395
  if error:
247
396
  failed = True
248
397
  click.secho(f"→ {target}\n unreachable: {error}", fg="red")
249
398
  continue
399
+ click.secho(f"→ {target}", bold=True)
400
+ if pi_path is None:
401
+ if dry_run:
402
+ click.secho(" pi not installed — nothing would be synced", fg="yellow")
403
+ failed = True
404
+ continue
405
+ if not ensure_pi(target, install_, remote_dir):
406
+ failed = True
407
+ continue
250
408
  if not sync_host(
251
409
  target,
252
410
  items,
@@ -0,0 +1,552 @@
1
+ """Tests for pi-sync.
2
+
3
+ Covers flag→item selection, rsync argv construction, ssh-config host
4
+ completion, and the per-host pi probe/install path. No ssh or rsync process is
5
+ ever executed.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import subprocess
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ import click
15
+ import pytest
16
+ from click.testing import CliRunner
17
+
18
+ from pi_sync import cli
19
+
20
+
21
+ class FakeRun:
22
+ """Records every command; replays canned itemize output for rsync."""
23
+
24
+ def __init__(
25
+ self,
26
+ rsync_stdout: str = ">f+++++++ models.json\n",
27
+ rsync_rc: int = 0,
28
+ ssh_rc: int = 0,
29
+ pi_path: str | None = "/usr/bin/pi",
30
+ install_rc: int = 0,
31
+ install_installs: bool = True,
32
+ ):
33
+ self.calls: list[list[str]] = []
34
+ self.inputs: list[str | None] = []
35
+ self.captures: list[bool] = []
36
+ self.rsync_stdout = rsync_stdout
37
+ self.rsync_rc = rsync_rc
38
+ self.ssh_rc = ssh_rc
39
+ self.pi_path = pi_path
40
+ self.install_rc = install_rc
41
+ self.install_installs = install_installs
42
+ self.installed = False
43
+
44
+ def __call__(
45
+ self, argv: list[str], input_text: str | None = None, capture: bool = True
46
+ ) -> subprocess.CompletedProcess[str]:
47
+ self.calls.append(argv)
48
+ self.inputs.append(input_text)
49
+ self.captures.append(capture)
50
+ if argv[0] != "ssh":
51
+ return subprocess.CompletedProcess(
52
+ argv, self.rsync_rc, self.rsync_stdout, ""
53
+ )
54
+ if any("install.sh" in arg for arg in argv):
55
+ if self.install_rc == 0:
56
+ # a real installer exits 0 even when the user picks "do nothing"
57
+ self.installed = self.install_installs
58
+ stderr = "" if self.install_rc == 0 else "curl: (22) installer failed"
59
+ return subprocess.CompletedProcess(
60
+ argv, self.install_rc, "installed\n", stderr
61
+ )
62
+ if self.ssh_rc:
63
+ return subprocess.CompletedProcess(
64
+ argv, self.ssh_rc, "", "ssh: connect failed"
65
+ )
66
+ path = self.pi_path or ("/usr/local/bin/pi" if self.installed else None)
67
+ probe = f"PI:{path}\n" if path else ""
68
+ return subprocess.CompletedProcess(argv, 0, probe, "")
69
+
70
+
71
+ class _TtyStdin:
72
+ """Stand-in for an interactive terminal."""
73
+
74
+ def isatty(self) -> bool:
75
+ return True
76
+
77
+
78
+ @pytest.fixture
79
+ def fake(monkeypatch: pytest.MonkeyPatch) -> FakeRun:
80
+ runner = FakeRun()
81
+ monkeypatch.setattr(cli, "run_cmd", runner)
82
+ return runner
83
+
84
+
85
+ @pytest.fixture
86
+ def agent_dir(tmp_path: Path) -> Path:
87
+ """A fake local agent dir with every syncable item present."""
88
+ (tmp_path / "models.json").write_text("{}")
89
+ (tmp_path / "settings.json").write_text("{}")
90
+ (tmp_path / "auth.json").write_text("{}")
91
+ (tmp_path / "extensions").mkdir()
92
+ return tmp_path
93
+
94
+
95
+ def rsync_calls(fake: FakeRun) -> list[list[str]]:
96
+ return [call for call in fake.calls if call[0] == "rsync"]
97
+
98
+
99
+ def sources(fake: FakeRun) -> list[str]:
100
+ """The local-side path of each rsync call, for push invocations."""
101
+ return [Path(call[-2]).name for call in rsync_calls(fake)]
102
+
103
+
104
+ @pytest.mark.parametrize(
105
+ ("groups", "expected"),
106
+ [
107
+ ({"config"}, ["models.json", "settings.json"]),
108
+ ({"extensions"}, ["extensions"]),
109
+ ({"auth"}, ["auth.json"]),
110
+ ({"config", "extensions"}, ["models.json", "settings.json", "extensions"]),
111
+ ({"config", "auth"}, ["models.json", "settings.json", "auth.json"]),
112
+ (set(), []),
113
+ ],
114
+ )
115
+ def test_select_items(groups: set[str], expected: list[str]) -> None:
116
+ assert cli.select_items(groups) == expected
117
+
118
+
119
+ @pytest.mark.parametrize(
120
+ ("argv_kwargs", "expected"),
121
+ [
122
+ ({}, ["rsync", "-az", "-i", "/a/models.json", "host:~/.pi/agent/models.json"]),
123
+ (
124
+ {"pull": True},
125
+ ["rsync", "-az", "-i", "host:~/.pi/agent/models.json", "/a/models.json"],
126
+ ),
127
+ (
128
+ {"dry_run": True},
129
+ [
130
+ "rsync",
131
+ "-az",
132
+ "-i",
133
+ "-n",
134
+ "/a/models.json",
135
+ "host:~/.pi/agent/models.json",
136
+ ],
137
+ ),
138
+ (
139
+ {"delete": True},
140
+ ["rsync", "-az", "-i", "/a/models.json", "host:~/.pi/agent/models.json"],
141
+ ),
142
+ ],
143
+ )
144
+ def test_rsync_argv_files(argv_kwargs: dict, expected: list[str]) -> None:
145
+ assert (
146
+ cli.rsync_argv("models.json", "host", Path("/a"), "~/.pi/agent", **argv_kwargs)
147
+ == expected
148
+ )
149
+
150
+
151
+ def test_rsync_argv_directory_gets_trailing_slashes() -> None:
152
+ argv = cli.rsync_argv("extensions", "host", Path("/a"), "~/.pi/agent")
153
+ assert argv[-2:] == ["/a/extensions/", "host:~/.pi/agent/extensions/"]
154
+
155
+
156
+ def test_rsync_argv_delete_only_for_directories() -> None:
157
+ assert "--delete-during" in cli.rsync_argv(
158
+ "extensions", "host", Path("/a"), "~/.pi/agent", delete=True
159
+ )
160
+ assert "--delete-during" not in cli.rsync_argv(
161
+ "models.json", "host", Path("/a"), "~/.pi/agent", delete=True
162
+ )
163
+
164
+
165
+ def test_rsync_argv_excludes() -> None:
166
+ argv = cli.rsync_argv(
167
+ "extensions", "host", Path("/a"), "~/.pi/agent", excludes=("*/logs/*",)
168
+ )
169
+ assert "--exclude=*/logs/*" in argv
170
+
171
+
172
+ def test_exclude_flag_passes_through(fake: FakeRun, agent_dir: Path) -> None:
173
+ CliRunner().invoke(
174
+ cli.main,
175
+ ["--extensions", "-x", "*/logs/*", "--local-dir", str(agent_dir), "host"],
176
+ )
177
+ calls = rsync_calls(fake)
178
+ assert calls and all("--exclude=*/logs/*" in call for call in calls)
179
+
180
+
181
+ def test_remote_dir_trailing_slash_is_not_doubled() -> None:
182
+ argv = cli.rsync_argv("models.json", "host", Path("/a"), "~/.pi/agent/")
183
+ assert argv[-1] == "host:~/.pi/agent/models.json"
184
+
185
+
186
+ @pytest.mark.parametrize(
187
+ ("flags", "expected"),
188
+ [
189
+ ([], ["models.json", "settings.json", "extensions"]),
190
+ (["--all"], ["models.json", "settings.json", "extensions"]),
191
+ (["--config"], ["models.json", "settings.json"]),
192
+ (["--extensions"], ["extensions"]),
193
+ (["--config", "--auth"], ["models.json", "settings.json", "auth.json"]),
194
+ (
195
+ ["--all", "--auth"],
196
+ ["models.json", "settings.json", "extensions", "auth.json"],
197
+ ),
198
+ ],
199
+ )
200
+ def test_flags_select_items(
201
+ fake: FakeRun, agent_dir: Path, flags: list[str], expected: list[str]
202
+ ) -> None:
203
+ result = CliRunner().invoke(
204
+ cli.main, [*flags, "--local-dir", str(agent_dir), "host"]
205
+ )
206
+ assert result.exit_code == 0, result.output
207
+ assert sources(fake) == expected
208
+
209
+
210
+ def test_ssh_probe_runs_before_rsync(fake: FakeRun, agent_dir: Path) -> None:
211
+ CliRunner().invoke(cli.main, ["--local-dir", str(agent_dir), "host"])
212
+ assert fake.calls[0] == ["ssh", "-o", "ConnectTimeout=10", "host", "sh -s"]
213
+ assert fake.calls[1][0] == "rsync"
214
+ # the probe script is piped over stdin so it works under fish/csh too
215
+ assert "command -v pi" in (fake.inputs[0] or "")
216
+
217
+
218
+ def test_missing_local_item_is_skipped(fake: FakeRun, tmp_path: Path) -> None:
219
+ (tmp_path / "models.json").write_text("{}")
220
+ result = CliRunner().invoke(
221
+ cli.main, ["--all", "--local-dir", str(tmp_path), "host"]
222
+ )
223
+ assert result.exit_code == 0
224
+ assert sources(fake) == ["models.json"]
225
+ assert "skipped" in result.output
226
+
227
+
228
+ def test_pull_does_not_require_local_files(fake: FakeRun, tmp_path: Path) -> None:
229
+ result = CliRunner().invoke(
230
+ cli.main, ["--all", "--pull", "--local-dir", str(tmp_path), "host"]
231
+ )
232
+ assert result.exit_code == 0
233
+ assert sources(fake) == [
234
+ "models.json",
235
+ "settings.json",
236
+ "extensions",
237
+ ] # remote paths, pulled from
238
+
239
+
240
+ def test_default_remote_dir(fake: FakeRun, agent_dir: Path) -> None:
241
+ CliRunner().invoke(cli.main, ["--config", "--local-dir", str(agent_dir), "host"])
242
+ assert all("host:~/.pi/agent/" in call[-1] for call in rsync_calls(fake))
243
+
244
+
245
+ def test_remote_dir_override(fake: FakeRun, agent_dir: Path) -> None:
246
+ CliRunner().invoke(
247
+ cli.main,
248
+ ["--config", "--local-dir", str(agent_dir), "--remote-dir", "/srv/pi", "host"],
249
+ )
250
+ assert all("host:/srv/pi/" in call[-1] for call in rsync_calls(fake))
251
+
252
+
253
+ def test_trailing_colon_is_stripped(fake: FakeRun, agent_dir: Path) -> None:
254
+ CliRunner().invoke(
255
+ cli.main, ["--config", "--local-dir", str(agent_dir), "sayan@host:"]
256
+ )
257
+ assert all(call[-1].startswith("sayan@host:") for call in rsync_calls(fake))
258
+
259
+
260
+ def test_multiple_hosts(fake: FakeRun, agent_dir: Path) -> None:
261
+ CliRunner().invoke(cli.main, ["--config", "--local-dir", str(agent_dir), "a", "b"])
262
+ targets = {call[-1].split(":")[0] for call in rsync_calls(fake)}
263
+ assert targets == {"a", "b"}
264
+
265
+
266
+ def test_auth_warns_about_secrets(fake: FakeRun, agent_dir: Path) -> None:
267
+ result = CliRunner().invoke(
268
+ cli.main, ["--auth", "--local-dir", str(agent_dir), "host"]
269
+ )
270
+ assert "API keys" in result.output
271
+
272
+
273
+ def test_unreachable_host_exits_nonzero(
274
+ monkeypatch: pytest.MonkeyPatch, agent_dir: Path
275
+ ) -> None:
276
+ monkeypatch.setattr(cli, "run_cmd", FakeRun(ssh_rc=255))
277
+ result = CliRunner().invoke(
278
+ cli.main, ["--config", "--local-dir", str(agent_dir), "host"]
279
+ )
280
+ assert result.exit_code == 1
281
+ assert "unreachable" in result.output
282
+
283
+
284
+ def test_rsync_failure_exits_nonzero(
285
+ monkeypatch: pytest.MonkeyPatch, agent_dir: Path
286
+ ) -> None:
287
+ monkeypatch.setattr(cli, "run_cmd", FakeRun(rsync_rc=23))
288
+ result = CliRunner().invoke(
289
+ cli.main, ["--config", "--local-dir", str(agent_dir), "host"]
290
+ )
291
+ assert result.exit_code == 1
292
+ assert "FAILED" in result.output
293
+
294
+
295
+ @pytest.mark.parametrize(
296
+ ("output", "expected"),
297
+ [
298
+ (">f+++++++ a\n>f+++++++ b\n", (2, 0)),
299
+ ("*deleting stale.txt\n>f+++++++++ new.txt\n", (1, 1)),
300
+ (".d..t...... extensions/\n", (0, 0)),
301
+ ("", (0, 0)),
302
+ ],
303
+ )
304
+ def test_summarize(output: str, expected: tuple[int, int]) -> None:
305
+ assert cli.summarize(output) == expected
306
+
307
+
308
+ def test_local_dir_env_override(
309
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
310
+ ) -> None:
311
+ monkeypatch.setenv("PI_CODING_AGENT_DIR", str(tmp_path))
312
+ assert cli.local_agent_dir() == tmp_path
313
+ assert cli.local_agent_dir("/explicit") == Path("/explicit")
314
+
315
+
316
+ def ssh_probe_calls(fake: FakeRun) -> list[list[str]]:
317
+ """ssh calls that are the pi probe (not the installer)."""
318
+ return [
319
+ c for c in fake.calls if c[0] == "ssh" and not any("install.sh" in a for a in c)
320
+ ]
321
+
322
+
323
+ def install_calls(fake: FakeRun) -> list[list[str]]:
324
+ return [c for c in fake.calls if any("install.sh" in a for a in c)]
325
+
326
+
327
+ class TestSshHosts:
328
+ def test_single_host(self, tmp_path: Path) -> None:
329
+ cfg = tmp_path / "config"
330
+ cfg.write_text("Host tinfoil\n HostName tinfoil.example\n")
331
+ assert cli.ssh_hosts(str(cfg)) == ["tinfoil"]
332
+
333
+ def test_multiple_tokens_and_trailing_whitespace(self, tmp_path: Path) -> None:
334
+ cfg = tmp_path / "config"
335
+ cfg.write_text("Host svalbard \nHost a b\n")
336
+ assert cli.ssh_hosts(str(cfg)) == ["svalbard", "a", "b"]
337
+
338
+ def test_wildcards_and_negations_skipped(self, tmp_path: Path) -> None:
339
+ cfg = tmp_path / "config"
340
+ cfg.write_text(
341
+ "Host *.orb.local\nHost api.example.com !api.internal\nHost real\n"
342
+ )
343
+ assert cli.ssh_hosts(str(cfg)) == ["api.example.com", "real"]
344
+
345
+ def test_directives_are_case_insensitive(self, tmp_path: Path) -> None:
346
+ cfg = tmp_path / "config"
347
+ cfg.write_text("HOST upper\nhost lower\n")
348
+ assert cli.ssh_hosts(str(cfg)) == ["upper", "lower"]
349
+
350
+ def test_comments_and_blank_lines_ignored(self, tmp_path: Path) -> None:
351
+ cfg = tmp_path / "config"
352
+ cfg.write_text("# a comment\n\n # indented comment\nHost real\n")
353
+ assert cli.ssh_hosts(str(cfg)) == ["real"]
354
+
355
+ def test_non_host_directives_ignored(self, tmp_path: Path) -> None:
356
+ cfg = tmp_path / "config"
357
+ cfg.write_text("Host real\n HostName example.com\n User bob\n Port 2222\n")
358
+ assert cli.ssh_hosts(str(cfg)) == ["real"]
359
+
360
+ def test_include_is_followed_and_deduped(self, tmp_path: Path) -> None:
361
+ extra = tmp_path / "extra_config"
362
+ extra.write_text("Host orb\nHost tinfoil\n")
363
+ cfg = tmp_path / "config"
364
+ cfg.write_text(f"Host tinfoil\nInclude {extra}\n")
365
+ assert cli.ssh_hosts(str(cfg)) == ["tinfoil", "orb"]
366
+
367
+ def test_include_glob(self, tmp_path: Path) -> None:
368
+ (tmp_path / "conf.d").mkdir()
369
+ (tmp_path / "conf.d" / "a").write_text("Host alpha\n")
370
+ (tmp_path / "conf.d" / "b").write_text("Host beta\n")
371
+ cfg = tmp_path / "config"
372
+ cfg.write_text(f"Include {tmp_path}/conf.d/*\n")
373
+ assert cli.ssh_hosts(str(cfg)) == ["alpha", "beta"]
374
+
375
+ def test_include_cycle_terminates(self, tmp_path: Path) -> None:
376
+ cfg = tmp_path / "config"
377
+ other = tmp_path / "other"
378
+ cfg.write_text(f"Host one\nInclude {other}\n")
379
+ other.write_text(f"Host two\nInclude {cfg}\n")
380
+ assert cli.ssh_hosts(str(cfg)) == ["one", "two"]
381
+
382
+ def test_missing_config_is_empty(self, tmp_path: Path) -> None:
383
+ assert cli.ssh_hosts(str(tmp_path / "nope")) == []
384
+
385
+
386
+ class TestCompletion:
387
+ @staticmethod
388
+ def complete(incomplete: str) -> list[str]:
389
+ """Run the completion callback the way click would."""
390
+ ctx = click.Context(cli.main)
391
+ items = cli.complete_target(ctx, cli.main.params[0], incomplete)
392
+ return [item.value for item in items]
393
+
394
+ def test_completes_matching_hosts(self, monkeypatch: pytest.MonkeyPatch) -> None:
395
+ monkeypatch.setattr(
396
+ cli, "ssh_hosts", lambda *a, **k: ["tinfoil", "zero-frame", "phatboi"]
397
+ )
398
+ assert self.complete("t") == ["tinfoil"]
399
+
400
+ def test_empty_incomplete_lists_everything(
401
+ self, monkeypatch: pytest.MonkeyPatch
402
+ ) -> None:
403
+ monkeypatch.setattr(cli, "ssh_hosts", lambda *a, **k: ["a", "b"])
404
+ assert self.complete("") == ["a", "b"]
405
+
406
+ def test_user_at_prefix_is_preserved(self, monkeypatch: pytest.MonkeyPatch) -> None:
407
+ monkeypatch.setattr(cli, "ssh_hosts", lambda *a, **k: ["tinfoil", "zero-frame"])
408
+ assert self.complete("sayan@zer") == ["sayan@zero-frame"]
409
+
410
+
411
+ class TestProbe:
412
+ def test_reports_pi_path(self, monkeypatch: pytest.MonkeyPatch) -> None:
413
+ monkeypatch.setattr(
414
+ cli, "run_cmd", FakeRun(pi_path="/home/linuxbrew/.linuxbrew/bin/pi")
415
+ )
416
+ assert cli.probe_host("host") == (None, "/home/linuxbrew/.linuxbrew/bin/pi")
417
+
418
+ def test_reports_missing_pi(self, monkeypatch: pytest.MonkeyPatch) -> None:
419
+ monkeypatch.setattr(cli, "run_cmd", FakeRun(pi_path=None))
420
+ assert cli.probe_host("host") == (None, None)
421
+
422
+ def test_reports_unreachable(self, monkeypatch: pytest.MonkeyPatch) -> None:
423
+ monkeypatch.setattr(cli, "run_cmd", FakeRun(ssh_rc=255))
424
+ error, pi_path = cli.probe_host("host")
425
+ assert error == "ssh: connect failed"
426
+ assert pi_path is None
427
+
428
+
429
+ class TestInstallOffering:
430
+ def test_no_install_when_pi_present(
431
+ self, monkeypatch: pytest.MonkeyPatch, agent_dir: Path
432
+ ) -> None:
433
+ fake = FakeRun(pi_path="/usr/bin/pi")
434
+ monkeypatch.setattr(cli, "run_cmd", fake)
435
+ result = CliRunner().invoke(cli.main, ["--local-dir", str(agent_dir), "host"])
436
+ assert result.exit_code == 0
437
+ assert install_calls(fake) == []
438
+
439
+ def test_missing_pi_without_tty_skips_host(
440
+ self, monkeypatch: pytest.MonkeyPatch, agent_dir: Path
441
+ ) -> None:
442
+ fake = FakeRun(pi_path=None)
443
+ monkeypatch.setattr(cli, "run_cmd", fake)
444
+ result = CliRunner().invoke(cli.main, ["--local-dir", str(agent_dir), "host"])
445
+ assert result.exit_code == 1
446
+ assert install_calls(fake) == []
447
+ assert not any(c[0] == "rsync" for c in fake.calls)
448
+ assert "--install" in result.output
449
+
450
+ def test_interactive_install_needs_no_second_prompt(
451
+ self, monkeypatch: pytest.MonkeyPatch
452
+ ) -> None:
453
+ """The installer owns the decision; pi-sync must not pre-ask on top of it."""
454
+ fake = FakeRun(pi_path=None)
455
+ monkeypatch.setattr(cli, "run_cmd", fake)
456
+ monkeypatch.setattr(sys, "stdin", _TtyStdin())
457
+ asked: list[str] = []
458
+ monkeypatch.setattr(
459
+ click, "confirm", lambda *a, **k: asked.append("confirm") or False
460
+ )
461
+ assert cli.ensure_pi("host", assume_yes=False, remote_dir="~/.pi/agent") is True
462
+ assert asked == []
463
+ assert len(install_calls(fake)) == 1
464
+
465
+ def test_install_flag_installs_then_syncs(
466
+ self, monkeypatch: pytest.MonkeyPatch, agent_dir: Path
467
+ ) -> None:
468
+ fake = FakeRun(pi_path=None)
469
+ monkeypatch.setattr(cli, "run_cmd", fake)
470
+ result = CliRunner().invoke(
471
+ cli.main, ["--install", "--local-dir", str(agent_dir), "host"]
472
+ )
473
+ assert result.exit_code == 0
474
+ (call,) = install_calls(fake)
475
+ assert call[-1] == cli.PI_INSTALL_CMD
476
+ assert "pi installed" in result.output
477
+ # a fresh install has no agent dir yet, so it is created before rsync
478
+ assert any("mkdir -p" in arg for c in fake.calls for arg in c)
479
+ assert any(c[0] == "rsync" for c in fake.calls)
480
+
481
+ def test_install_failure_skips_host(
482
+ self, monkeypatch: pytest.MonkeyPatch, agent_dir: Path
483
+ ) -> None:
484
+ fake = FakeRun(pi_path=None, install_rc=22)
485
+ monkeypatch.setattr(cli, "run_cmd", fake)
486
+ result = CliRunner().invoke(
487
+ cli.main, ["--install", "--local-dir", str(agent_dir), "host"]
488
+ )
489
+ assert result.exit_code == 1
490
+ assert "skipping host" in result.output
491
+ assert not any(c[0] == "rsync" for c in fake.calls)
492
+
493
+ def test_installer_that_installs_nothing_skips_host(
494
+ self, monkeypatch: pytest.MonkeyPatch, agent_dir: Path
495
+ ) -> None:
496
+ """The installer exits 0 for its "do nothing" choice; that is not success."""
497
+ fake = FakeRun(pi_path=None, install_installs=False)
498
+ monkeypatch.setattr(cli, "run_cmd", fake)
499
+ result = CliRunner().invoke(
500
+ cli.main, ["--install", "--local-dir", str(agent_dir), "host"]
501
+ )
502
+ assert result.exit_code == 1
503
+ assert "without installing pi" in result.output
504
+ assert not any(c[0] == "rsync" for c in fake.calls)
505
+
506
+ def test_dry_run_never_installs_anything(
507
+ self, monkeypatch: pytest.MonkeyPatch, agent_dir: Path
508
+ ) -> None:
509
+ fake = FakeRun(pi_path=None)
510
+ monkeypatch.setattr(cli, "run_cmd", fake)
511
+ result = CliRunner().invoke(
512
+ cli.main,
513
+ ["--install", "--dry-run", "--local-dir", str(agent_dir), "host"],
514
+ )
515
+ assert result.exit_code == 1
516
+ assert install_calls(fake) == []
517
+ assert not any(c[0] == "rsync" for c in fake.calls)
518
+
519
+ def test_interactive_install_streams_and_gets_a_tty(
520
+ self, monkeypatch: pytest.MonkeyPatch
521
+ ) -> None:
522
+ """The installer prompts on /dev/tty, so its output must not be captured."""
523
+ fake = FakeRun()
524
+ monkeypatch.setattr(cli, "run_cmd", fake)
525
+ monkeypatch.setattr(sys, "stdin", _TtyStdin())
526
+ assert cli.install_pi("host") is None
527
+ (call,) = install_calls(fake)
528
+ assert "-t" in call
529
+ assert fake.captures[0] is False # install call streams; the re-probe captures
530
+
531
+ def test_unattended_install_captures_and_closes_stdin(
532
+ self, monkeypatch: pytest.MonkeyPatch
533
+ ) -> None:
534
+ """Without a terminal the installer gets EOF instead of blocking on a prompt."""
535
+ fake = FakeRun()
536
+ monkeypatch.setattr(cli, "run_cmd", fake)
537
+ assert cli.install_pi("host") is None
538
+ (call,) = install_calls(fake)
539
+ assert "-t" not in call
540
+ assert fake.inputs[0] == ""
541
+ assert fake.captures[0] is True
542
+
543
+ def test_no_install_attempt_when_unreachable(
544
+ self, monkeypatch: pytest.MonkeyPatch, agent_dir: Path
545
+ ) -> None:
546
+ fake = FakeRun(pi_path=None, ssh_rc=255)
547
+ monkeypatch.setattr(cli, "run_cmd", fake)
548
+ result = CliRunner().invoke(
549
+ cli.main, ["--install", "--local-dir", str(agent_dir), "host"]
550
+ )
551
+ assert result.exit_code == 1
552
+ assert install_calls(fake) == []
@@ -52,7 +52,7 @@ wheels = [
52
52
 
53
53
  [[package]]
54
54
  name = "pi-sync-cli"
55
- version = "0.1.0"
55
+ version = "0.2.0"
56
56
  source = { editable = "." }
57
57
  dependencies = [
58
58
  { name = "click" },
@@ -1,270 +0,0 @@
1
- """Tests for pi-sync. No ssh or rsync process is ever executed."""
2
-
3
- from __future__ import annotations
4
-
5
- import subprocess
6
- from pathlib import Path
7
-
8
- import pytest
9
- from click.testing import CliRunner
10
-
11
- from pi_sync import cli
12
-
13
-
14
- class FakeRun:
15
- """Records every command; replays canned itemize output for rsync."""
16
-
17
- def __init__(
18
- self,
19
- rsync_stdout: str = ">f+++++++ models.json\n",
20
- rsync_rc: int = 0,
21
- ssh_rc: int = 0,
22
- ):
23
- self.calls: list[list[str]] = []
24
- self.rsync_stdout = rsync_stdout
25
- self.rsync_rc = rsync_rc
26
- self.ssh_rc = ssh_rc
27
-
28
- def __call__(self, argv: list[str]) -> subprocess.CompletedProcess[str]:
29
- self.calls.append(argv)
30
- if argv[0] == "ssh":
31
- return subprocess.CompletedProcess(
32
- argv, self.ssh_rc, "", "" if self.ssh_rc == 0 else "ssh: connect failed"
33
- )
34
- return subprocess.CompletedProcess(argv, self.rsync_rc, self.rsync_stdout, "")
35
-
36
-
37
- @pytest.fixture
38
- def fake(monkeypatch: pytest.MonkeyPatch) -> FakeRun:
39
- runner = FakeRun()
40
- monkeypatch.setattr(cli, "run_cmd", runner)
41
- return runner
42
-
43
-
44
- @pytest.fixture
45
- def agent_dir(tmp_path: Path) -> Path:
46
- """A fake local agent dir with every syncable item present."""
47
- (tmp_path / "models.json").write_text("{}")
48
- (tmp_path / "settings.json").write_text("{}")
49
- (tmp_path / "auth.json").write_text("{}")
50
- (tmp_path / "extensions").mkdir()
51
- return tmp_path
52
-
53
-
54
- def rsync_calls(fake: FakeRun) -> list[list[str]]:
55
- return [call for call in fake.calls if call[0] == "rsync"]
56
-
57
-
58
- def sources(fake: FakeRun) -> list[str]:
59
- """The local-side path of each rsync call, for push invocations."""
60
- return [Path(call[-2]).name for call in rsync_calls(fake)]
61
-
62
-
63
- @pytest.mark.parametrize(
64
- ("groups", "expected"),
65
- [
66
- ({"config"}, ["models.json", "settings.json"]),
67
- ({"extensions"}, ["extensions"]),
68
- ({"auth"}, ["auth.json"]),
69
- ({"config", "extensions"}, ["models.json", "settings.json", "extensions"]),
70
- ({"config", "auth"}, ["models.json", "settings.json", "auth.json"]),
71
- (set(), []),
72
- ],
73
- )
74
- def test_select_items(groups: set[str], expected: list[str]) -> None:
75
- assert cli.select_items(groups) == expected
76
-
77
-
78
- @pytest.mark.parametrize(
79
- ("argv_kwargs", "expected"),
80
- [
81
- ({}, ["rsync", "-az", "-i", "/a/models.json", "host:~/.pi/agent/models.json"]),
82
- (
83
- {"pull": True},
84
- ["rsync", "-az", "-i", "host:~/.pi/agent/models.json", "/a/models.json"],
85
- ),
86
- (
87
- {"dry_run": True},
88
- [
89
- "rsync",
90
- "-az",
91
- "-i",
92
- "-n",
93
- "/a/models.json",
94
- "host:~/.pi/agent/models.json",
95
- ],
96
- ),
97
- (
98
- {"delete": True},
99
- ["rsync", "-az", "-i", "/a/models.json", "host:~/.pi/agent/models.json"],
100
- ),
101
- ],
102
- )
103
- def test_rsync_argv_files(argv_kwargs: dict, expected: list[str]) -> None:
104
- assert (
105
- cli.rsync_argv("models.json", "host", Path("/a"), "~/.pi/agent", **argv_kwargs)
106
- == expected
107
- )
108
-
109
-
110
- def test_rsync_argv_directory_gets_trailing_slashes() -> None:
111
- argv = cli.rsync_argv("extensions", "host", Path("/a"), "~/.pi/agent")
112
- assert argv[-2:] == ["/a/extensions/", "host:~/.pi/agent/extensions/"]
113
-
114
-
115
- def test_rsync_argv_delete_only_for_directories() -> None:
116
- assert "--delete-during" in cli.rsync_argv(
117
- "extensions", "host", Path("/a"), "~/.pi/agent", delete=True
118
- )
119
- assert "--delete-during" not in cli.rsync_argv(
120
- "models.json", "host", Path("/a"), "~/.pi/agent", delete=True
121
- )
122
-
123
-
124
- def test_rsync_argv_excludes() -> None:
125
- argv = cli.rsync_argv(
126
- "extensions", "host", Path("/a"), "~/.pi/agent", excludes=("*/logs/*",)
127
- )
128
- assert "--exclude=*/logs/*" in argv
129
-
130
-
131
- def test_exclude_flag_passes_through(fake: FakeRun, agent_dir: Path) -> None:
132
- CliRunner().invoke(
133
- cli.main,
134
- ["--extensions", "-x", "*/logs/*", "--local-dir", str(agent_dir), "host"],
135
- )
136
- calls = rsync_calls(fake)
137
- assert calls and all("--exclude=*/logs/*" in call for call in calls)
138
-
139
-
140
- def test_remote_dir_trailing_slash_is_not_doubled() -> None:
141
- argv = cli.rsync_argv("models.json", "host", Path("/a"), "~/.pi/agent/")
142
- assert argv[-1] == "host:~/.pi/agent/models.json"
143
-
144
-
145
- @pytest.mark.parametrize(
146
- ("flags", "expected"),
147
- [
148
- ([], ["models.json", "settings.json", "extensions"]),
149
- (["--all"], ["models.json", "settings.json", "extensions"]),
150
- (["--config"], ["models.json", "settings.json"]),
151
- (["--extensions"], ["extensions"]),
152
- (["--config", "--auth"], ["models.json", "settings.json", "auth.json"]),
153
- (
154
- ["--all", "--auth"],
155
- ["models.json", "settings.json", "extensions", "auth.json"],
156
- ),
157
- ],
158
- )
159
- def test_flags_select_items(
160
- fake: FakeRun, agent_dir: Path, flags: list[str], expected: list[str]
161
- ) -> None:
162
- result = CliRunner().invoke(
163
- cli.main, [*flags, "--local-dir", str(agent_dir), "host"]
164
- )
165
- assert result.exit_code == 0, result.output
166
- assert sources(fake) == expected
167
-
168
-
169
- def test_ssh_preflight_runs_before_rsync(fake: FakeRun, agent_dir: Path) -> None:
170
- CliRunner().invoke(cli.main, ["--local-dir", str(agent_dir), "host"])
171
- assert fake.calls[0] == ["ssh", "-o", "ConnectTimeout=10", "host", "true"]
172
- assert fake.calls[1][0] == "rsync"
173
-
174
-
175
- def test_missing_local_item_is_skipped(fake: FakeRun, tmp_path: Path) -> None:
176
- (tmp_path / "models.json").write_text("{}")
177
- result = CliRunner().invoke(
178
- cli.main, ["--all", "--local-dir", str(tmp_path), "host"]
179
- )
180
- assert result.exit_code == 0
181
- assert sources(fake) == ["models.json"]
182
- assert "skipped" in result.output
183
-
184
-
185
- def test_pull_does_not_require_local_files(fake: FakeRun, tmp_path: Path) -> None:
186
- result = CliRunner().invoke(
187
- cli.main, ["--all", "--pull", "--local-dir", str(tmp_path), "host"]
188
- )
189
- assert result.exit_code == 0
190
- assert sources(fake) == [
191
- "models.json",
192
- "settings.json",
193
- "extensions",
194
- ] # remote paths, pulled from
195
-
196
-
197
- def test_default_remote_dir(fake: FakeRun, agent_dir: Path) -> None:
198
- CliRunner().invoke(cli.main, ["--config", "--local-dir", str(agent_dir), "host"])
199
- assert all("host:~/.pi/agent/" in call[-1] for call in rsync_calls(fake))
200
-
201
-
202
- def test_remote_dir_override(fake: FakeRun, agent_dir: Path) -> None:
203
- CliRunner().invoke(
204
- cli.main,
205
- ["--config", "--local-dir", str(agent_dir), "--remote-dir", "/srv/pi", "host"],
206
- )
207
- assert all("host:/srv/pi/" in call[-1] for call in rsync_calls(fake))
208
-
209
-
210
- def test_trailing_colon_is_stripped(fake: FakeRun, agent_dir: Path) -> None:
211
- CliRunner().invoke(
212
- cli.main, ["--config", "--local-dir", str(agent_dir), "sayan@host:"]
213
- )
214
- assert all(call[-1].startswith("sayan@host:") for call in rsync_calls(fake))
215
-
216
-
217
- def test_multiple_hosts(fake: FakeRun, agent_dir: Path) -> None:
218
- CliRunner().invoke(cli.main, ["--config", "--local-dir", str(agent_dir), "a", "b"])
219
- targets = {call[-1].split(":")[0] for call in rsync_calls(fake)}
220
- assert targets == {"a", "b"}
221
-
222
-
223
- def test_auth_warns_about_secrets(fake: FakeRun, agent_dir: Path) -> None:
224
- result = CliRunner().invoke(
225
- cli.main, ["--auth", "--local-dir", str(agent_dir), "host"]
226
- )
227
- assert "API keys" in result.output
228
-
229
-
230
- def test_unreachable_host_exits_nonzero(
231
- monkeypatch: pytest.MonkeyPatch, agent_dir: Path
232
- ) -> None:
233
- monkeypatch.setattr(cli, "run_cmd", FakeRun(ssh_rc=255))
234
- result = CliRunner().invoke(
235
- cli.main, ["--config", "--local-dir", str(agent_dir), "host"]
236
- )
237
- assert result.exit_code == 1
238
- assert "unreachable" in result.output
239
-
240
-
241
- def test_rsync_failure_exits_nonzero(
242
- monkeypatch: pytest.MonkeyPatch, agent_dir: Path
243
- ) -> None:
244
- monkeypatch.setattr(cli, "run_cmd", FakeRun(rsync_rc=23))
245
- result = CliRunner().invoke(
246
- cli.main, ["--config", "--local-dir", str(agent_dir), "host"]
247
- )
248
- assert result.exit_code == 1
249
- assert "FAILED" in result.output
250
-
251
-
252
- @pytest.mark.parametrize(
253
- ("output", "expected"),
254
- [
255
- (">f+++++++ a\n>f+++++++ b\n", (2, 0)),
256
- ("*deleting stale.txt\n>f+++++++++ new.txt\n", (1, 1)),
257
- (".d..t...... extensions/\n", (0, 0)),
258
- ("", (0, 0)),
259
- ],
260
- )
261
- def test_summarize(output: str, expected: tuple[int, int]) -> None:
262
- assert cli.summarize(output) == expected
263
-
264
-
265
- def test_local_dir_env_override(
266
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
267
- ) -> None:
268
- monkeypatch.setenv("PI_CODING_AGENT_DIR", str(tmp_path))
269
- assert cli.local_agent_dir() == tmp_path
270
- assert cli.local_agent_dir("/explicit") == Path("/explicit")
File without changes
File without changes