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
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,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.
|