pi-sync-cli 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Sync pi agent config and extensions between hosts over rsync."""
2
+
3
+ __version__ = "0.1.0"
pi_sync/cli.py ADDED
@@ -0,0 +1,268 @@
1
+ """pi-sync — push or pull pi agent config between hosts over rsync.
2
+
3
+ Only the declarative parts of the agent dir are syncable:
4
+
5
+ config models.json, settings.json (hand-written, portable)
6
+ extensions extensions/ (your extension code)
7
+ auth auth.json (secrets, opt-in)
8
+
9
+ Host-local state (sessions/, npm/, models-store.json, ayu/, bin/, trust.json)
10
+ is deliberately never touched.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import subprocess
17
+ from collections.abc import Sequence
18
+ from pathlib import Path
19
+
20
+ import click
21
+
22
+ DEFAULT_AGENT_DIR = "~/.pi/agent"
23
+ DIRECTORY_ITEMS = frozenset({"extensions"})
24
+ GROUPS: dict[str, tuple[str, ...]] = {
25
+ "config": ("models.json", "settings.json"),
26
+ "extensions": ("extensions",),
27
+ "auth": ("auth.json",),
28
+ }
29
+ # Order groups are synced in, so output is stable.
30
+ GROUP_ORDER = ("config", "extensions", "auth")
31
+
32
+
33
+ def local_agent_dir(override: str | None = None) -> Path:
34
+ """Local agent dir: explicit flag, else $PI_CODING_AGENT_DIR, else ~/.pi/agent."""
35
+ raw = override or os.environ.get("PI_CODING_AGENT_DIR") or DEFAULT_AGENT_DIR
36
+ return Path(raw).expanduser()
37
+
38
+
39
+ def select_items(groups: set[str]) -> list[str]:
40
+ return [item for group in GROUP_ORDER if group in groups for item in GROUPS[group]]
41
+
42
+
43
+ def rsync_argv(
44
+ rel: str,
45
+ target: str,
46
+ local_dir: Path,
47
+ remote_dir: str,
48
+ *,
49
+ pull: bool = False,
50
+ delete: bool = False,
51
+ dry_run: bool = False,
52
+ excludes: Sequence[str] = (),
53
+ ) -> list[str]:
54
+ """One rsync invocation for one item, in the requested direction."""
55
+ remote = f"{target}:{remote_dir.rstrip('/')}/{rel}"
56
+ local = str(local_dir / rel)
57
+ if rel in DIRECTORY_ITEMS:
58
+ remote += "/" # copy contents, not the directory itself
59
+ local += "/"
60
+ argv = ["rsync", "-az", "-i"]
61
+ argv += [f"--exclude={pattern}" for pattern in excludes]
62
+ if delete and rel in DIRECTORY_ITEMS:
63
+ argv.append("--delete-during")
64
+ if dry_run:
65
+ argv.append("-n")
66
+ argv += [remote, local] if pull else [local, remote]
67
+ return argv
68
+
69
+
70
+ def run_cmd(argv: list[str]) -> subprocess.CompletedProcess[str]:
71
+ return subprocess.run(argv, capture_output=True, text=True)
72
+
73
+
74
+ def summarize(output: str) -> tuple[int, int]:
75
+ """Count transferred files and deletions from rsync --itemize-changes output."""
76
+ transferred = deleted = 0
77
+ for line in output.splitlines():
78
+ if line.startswith("*deleting"):
79
+ deleted += 1
80
+ elif line[:1] in ("<", ">"):
81
+ transferred += 1
82
+ return transferred, deleted
83
+
84
+
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"
92
+
93
+
94
+ def sync_host(
95
+ target: str,
96
+ items: list[str],
97
+ local_dir: Path,
98
+ remote_dir: str,
99
+ *,
100
+ pull: bool,
101
+ delete: bool,
102
+ dry_run: bool,
103
+ verbose: bool,
104
+ excludes: Sequence[str] = (),
105
+ ) -> bool:
106
+ click.secho(f"→ {target}", bold=True)
107
+ ok = True
108
+ for rel in items:
109
+ if not pull and not (local_dir / rel).exists():
110
+ click.secho(f" {rel:<14} skipped (not found locally)", fg="yellow")
111
+ continue
112
+ argv = rsync_argv(
113
+ rel,
114
+ target,
115
+ local_dir,
116
+ remote_dir,
117
+ pull=pull,
118
+ delete=delete,
119
+ dry_run=dry_run,
120
+ excludes=excludes,
121
+ )
122
+ if verbose:
123
+ click.echo(f" $ {' '.join(argv)}")
124
+ proc = run_cmd(argv)
125
+ if verbose and proc.stdout:
126
+ click.echo(
127
+ "".join(f" {line}\n" for line in proc.stdout.splitlines()), nl=False
128
+ )
129
+ if proc.returncode != 0:
130
+ ok = False
131
+ click.secho(f" {rel:<14} FAILED", fg="red")
132
+ for line in (proc.stderr or proc.stdout).strip().splitlines()[:5]:
133
+ click.echo(f" {line}")
134
+ continue
135
+ transferred, deleted = summarize(proc.stdout)
136
+ if not transferred and not deleted:
137
+ click.secho(f" {rel:<14} already in sync")
138
+ continue
139
+ parts = []
140
+ if transferred:
141
+ parts.append(f"{transferred} file{'s' if transferred != 1 else ''}")
142
+ if deleted:
143
+ parts.append(f"{deleted} deleted")
144
+ click.secho(
145
+ f" {rel:<14} {'would copy' if dry_run else 'copied'} {', '.join(parts)}"
146
+ )
147
+ return ok
148
+
149
+
150
+ @click.command(context_settings={"help_option_names": ["-h", "--help"]})
151
+ @click.argument("targets", nargs=-1, required=True, metavar="[USER@]HOST...")
152
+ @click.option(
153
+ "--all", "all_", is_flag=True, help="Sync config and extensions (the default)."
154
+ )
155
+ @click.option(
156
+ "--config", "config_", is_flag=True, help="Sync models.json and settings.json."
157
+ )
158
+ @click.option(
159
+ "--extensions", "extensions_", is_flag=True, help="Sync the extensions/ directory."
160
+ )
161
+ @click.option(
162
+ "--auth", "auth_", is_flag=True, help="Sync auth.json (contains API keys)."
163
+ )
164
+ @click.option("--pull", is_flag=True, help="Copy host → local instead of local → host.")
165
+ @click.option(
166
+ "--delete",
167
+ "delete_",
168
+ is_flag=True,
169
+ help="Mirror extensions/ exactly, deleting files absent from the source.",
170
+ )
171
+ @click.option(
172
+ "--dry-run", is_flag=True, help="Report changes without copying anything."
173
+ )
174
+ @click.option(
175
+ "-x",
176
+ "--exclude",
177
+ "excludes",
178
+ multiple=True,
179
+ metavar="PATTERN",
180
+ help="rsync exclude pattern, e.g. '*/logs/*' (repeatable).",
181
+ )
182
+ @click.option(
183
+ "--local-dir",
184
+ default=None,
185
+ help="Local agent dir (default: $PI_CODING_AGENT_DIR or ~/.pi/agent).",
186
+ )
187
+ @click.option(
188
+ "--remote-dir",
189
+ default=DEFAULT_AGENT_DIR,
190
+ show_default=True,
191
+ help="Agent dir on the host.",
192
+ )
193
+ @click.option(
194
+ "-v", "--verbose", is_flag=True, help="Print each rsync command and its output."
195
+ )
196
+ def main(
197
+ targets: tuple[str, ...],
198
+ all_: bool,
199
+ config_: bool,
200
+ extensions_: bool,
201
+ auth_: bool,
202
+ pull: bool,
203
+ delete_: bool,
204
+ dry_run: bool,
205
+ excludes: tuple[str, ...],
206
+ local_dir: str | None,
207
+ remote_dir: str,
208
+ verbose: bool,
209
+ ) -> None:
210
+ """Sync pi agent config to one or more hosts, using your ssh config for routing.
211
+
212
+ \b
213
+ pi-sync tinfoil # push config + extensions
214
+ pi-sync --config laptop # just models.json and settings.json
215
+ pi-sync --pull --all tinfoil # fetch the host's config back
216
+ """
217
+ groups = {
218
+ group
219
+ for group, enabled in (
220
+ ("config", config_ or all_),
221
+ ("extensions", extensions_ or all_),
222
+ ("auth", auth_),
223
+ )
224
+ if enabled
225
+ }
226
+ if not groups:
227
+ groups = {"config", "extensions"}
228
+ items = select_items(groups)
229
+
230
+ if auth_ and not pull:
231
+ click.secho(
232
+ "! auth.json contains API keys and will be copied to the host", fg="yellow"
233
+ )
234
+
235
+ agent_dir = local_agent_dir(local_dir)
236
+ click.echo(
237
+ f"{'pulling' if pull else 'pushing'} {', '.join(items)} "
238
+ f"{'from' if pull else 'to'} {len(targets)} host(s)\n"
239
+ f"local: {agent_dir}\nremote: {remote_dir}\n"
240
+ )
241
+
242
+ failed = False
243
+ for raw_target in targets:
244
+ target = raw_target.rstrip(":")
245
+ error = check_ssh(target)
246
+ if error:
247
+ failed = True
248
+ click.secho(f"→ {target}\n unreachable: {error}", fg="red")
249
+ continue
250
+ if not sync_host(
251
+ target,
252
+ items,
253
+ agent_dir,
254
+ remote_dir,
255
+ pull=pull,
256
+ delete=delete_,
257
+ dry_run=dry_run,
258
+ verbose=verbose,
259
+ excludes=excludes,
260
+ ):
261
+ failed = True
262
+
263
+ if failed:
264
+ raise SystemExit(1)
265
+
266
+
267
+ if __name__ == "__main__":
268
+ main()
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.5
2
+ Name: pi-sync-cli
3
+ Version: 0.1.0
4
+ Summary: Sync pi agent config and extensions between hosts over rsync
5
+ Project-URL: Repository, https://github.com/say4n/pi-sync
6
+ Project-URL: Issues, https://github.com/say4n/pi-sync/issues
7
+ Author: Sayan Goswami
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: agent-config,dotfiles,pi,rsync,sync
11
+ Classifier: Environment :: Console
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: System :: Archiving :: Mirroring
14
+ Requires-Python: >=3.10
15
+ Requires-Dist: click>=8.1
16
+ Description-Content-Type: text/markdown
17
+
18
+ # pi-sync
19
+
20
+ Sync pi agent config between hosts over rsync, using your existing ssh config
21
+ for routing (so `~/.ssh/config` aliases just work).
22
+
23
+ ```bash
24
+ pi-sync tinfoil # push config + extensions
25
+ pi-sync --config laptop # only models.json and settings.json
26
+ pi-sync --pull --all tinfoil # fetch the host's config back
27
+ pi-sync --dry-run --all a b # preview against two hosts
28
+ ```
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ # the PyPI package is pi-sync-cli; it installs the `pi-sync` command
34
+ pipx install pi-sync-cli
35
+ pipx install git+ssh://git@github.com/say4n/pi-sync # from source (needs access)
36
+ ```
37
+
38
+ Requires Python 3.10+. `uv tool install` works in place of `pipx install`.
39
+
40
+ ## What syncs
41
+
42
+ Only the declarative parts of the agent dir:
43
+
44
+ | Group | Files |
45
+ | --- | --- |
46
+ | `--config` | `models.json`, `settings.json` |
47
+ | `--extensions` | `extensions/` |
48
+ | `--auth` | `auth.json` — secrets, opt-in, warns on push |
49
+
50
+ `--all` is `--config` + `--extensions` (also the default when no flag is given).
51
+
52
+ Host-local state is deliberately never touched: `sessions/`, `npm/`,
53
+ `models-store.json` (regenerated from the pi.dev catalog), `ayu/`, `bin/`,
54
+ `trust.json`.
55
+
56
+ ## Flags
57
+
58
+ | Flag | Effect |
59
+ | --- | --- |
60
+ | `--all` / `--config` / `--extensions` / `--auth` | what to sync |
61
+ | `--pull` | host → local instead of local → host |
62
+ | `--delete` | mirror `extensions/` exactly (deletes extras on the destination) |
63
+ | `--dry-run` | report changes, copy nothing |
64
+ | `-x, --exclude PATTERN` | skip matching files (repeatable) |
65
+ | `--local-dir` | default `$PI_CODING_AGENT_DIR` or `~/.pi/agent` |
66
+ | `--remote-dir` | default `~/.pi/agent` |
67
+ | `-v` / `--verbose` | print each rsync command and its output |
68
+
69
+ Multiple hosts are accepted: `pi-sync a b c`. Exits non-zero if any host is
70
+ unreachable or any transfer fails.
71
+
72
+ ## Caveats
73
+
74
+ - `settings.json` is machine-written by pi (`lastChangelogVersion` bumps, UI
75
+ toggles), so two hosts pushing it will overwrite each other's local
76
+ preferences. Sync it when you change `packages`, not reflexively.
77
+ - Extensions that write runtime files inside their own directory (logs,
78
+ checkpoints) get those files synced too, and each host's copy is overwritten by
79
+ whichever side pushed last — exclude them with `-x '*/logs/*'`.
80
+ - Extension versions are whatever each host has installed; pin them in
81
+ `settings.json` (`npm:pi-lens@1.2.3`) if you need hosts identical.
82
+ - `--auth` copies API keys in the clear. Prefer `OPENCODE_API_KEY` (and friends)
83
+ in the environment where you can.
84
+ - Remote paths go through the host's shell, so `~` expands there as usual.
85
+
86
+ ## Development
87
+
88
+ ```bash
89
+ uv sync
90
+ uv run pytest
91
+ uv run pi-sync --help
92
+ ```
@@ -0,0 +1,7 @@
1
+ pi_sync/__init__.py,sha256=7Jcg-AZQBM3SmEwNjvKLy26mOgSU6OXlqsRc6TOdnSU,91
2
+ pi_sync/cli.py,sha256=ISvrOfR8i2PKyt62pAdx7kMsni8RxOuqY7bt4bQ6pE4,8015
3
+ pi_sync_cli-0.1.0.dist-info/METADATA,sha256=HqcaLtliqqdJiH2Pdg3x35ZGnUU-1xOvZ3zBTVELEWY,3255
4
+ pi_sync_cli-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
5
+ pi_sync_cli-0.1.0.dist-info/entry_points.txt,sha256=Z63BicxQy3E69ctpwTBX_n2jGlnoU4dQ_bT379vvQS8,45
6
+ pi_sync_cli-0.1.0.dist-info/licenses/LICENSE,sha256=kLP2fsRn_aHoOfox5-5oZDMFZFSRhH-R04k4_FG8Q7w,1070
7
+ pi_sync_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pi-sync = pi_sync.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sayan Goswami
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.