pi-sync-cli 0.4.0__tar.gz → 0.5.1__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.
@@ -0,0 +1,125 @@
1
+ # AGENTS.md
2
+
3
+ Working notes for AI agents (and humans) changing this repo. User-facing
4
+ documentation lives in [README.md](README.md); layout, tooling and the release
5
+ process live in [DEVELOPMENT.md](DEVELOPMENT.md).
6
+
7
+ ## What this is
8
+
9
+ pi-sync: a single click module that rsyncs pi agent config between hosts.
10
+ `src/pi_sync/cli.py` is the whole program and `tests/test_cli.py` the whole
11
+ suite, so a change is one file plus its tests.
12
+
13
+ ## Commands
14
+
15
+ ```bash
16
+ uv sync # create/refresh .venv and uv.lock
17
+ uv run pytest # the suite (fast, offline)
18
+ uv run pi-sync --help
19
+ ```
20
+
21
+ ## Invariants — please don't "fix" these
22
+
23
+ - **Entry point is `pi_sync.cli:app`**, a click group with a default-command
24
+ fallback: `DefaultGroup.parse_args` prepends `sync` when the first token is not
25
+ a subcommand, and `SyncCommand.format_usage` keeps help reading
26
+ `pi-sync [OPTIONS] ...`. The sync function stays named `main` because the tests
27
+ invoke `cli.main` directly.
28
+ - **No `__version__`.** The version comes from distribution metadata; adding one
29
+ back reintroduces the drift that removal fixed.
30
+ - **Tests never spawn anything.** `FakeRun` records argv and replays output;
31
+ extend it instead of calling `subprocess`. That guarantee is deliberate.
32
+ - **Host probes are POSIX sh piped to `ssh host sh -s`**, because some hosts run
33
+ fish and `for …; do … done` is a syntax error there.
34
+ - **Never trust pi's installer exit status.** Its "do nothing" menu choice exits
35
+ 0, so `install_pi` re-probes the host. Keep new install/uninstall paths
36
+ verification-based.
37
+ - **`update` resolves the distribution from its own environment**; do not
38
+ hardcode `pi-sync-cli`, since an older install can coexist under `pi-sync` and
39
+ a hardcoded name would upgrade the wrong virtualenv.
40
+ - **`--dry-run` must not mutate anything**, including installing or uninstalling
41
+ pi on a host.
42
+
43
+ ## Conventions
44
+
45
+ - Commit messages: all lowercase, conventional prefix (`fix:`, `feat:`, `chore:`,
46
+ `ci:`, `docs:`).
47
+ - Run `uv run pytest` and `lens_diagnostics mode=all` before declaring work done;
48
+ fix blockers rather than reporting around them.
49
+ - pi-lens reformats files on write and sometimes *after* a commit. Check
50
+ `git status` before finishing and commit formatter churn separately.
51
+ - `.pi/tasks/` is gitignored local task output — never commit it.
52
+ - Never commit secrets. `auth.json` is host-local and only travels with an
53
+ explicit `--auth`.
54
+
55
+ ## Working against real hosts
56
+
57
+ - This Mac drives everything; `tinfoil` and the Raspberry Pi (`pi`) are real
58
+ hosts and can be offline.
59
+ - Prefer `--dry-run` when demonstrating. A real push overwrites host config — a
60
+ `.backup` is kept, but don't be cavalier about it.
61
+ - `~/.ssh/config` is protected: read it through code that extracts host aliases,
62
+ and don't dump its contents into output or a commit.
63
+
64
+ Kept out of the README: these describe *using* pi-sync rather than changing
65
+ it, and are collected here so the README stays a short front page.
66
+
67
+ ## Hosts without pi
68
+
69
+ Each host is checked before syncing. If pi is missing, pi-sync offers to install
70
+ it — interactively, pi's installer takes over your terminal, and the sync
71
+ continues when it exits. `--install` does that unattended.
72
+
73
+ A host that still has no pi afterwards is skipped, and the run exits non-zero.
74
+
75
+ ## Updating
76
+
77
+ - `pi-sync update` upgrades pi-sync itself, through whichever installer owns it
78
+ (`--check` reports without changing anything).
79
+ - `pi-sync --update-pi <hosts>` updates pi on the hosts before syncing.
80
+
81
+ ## Uninstalling pi from a host
82
+
83
+ `pi-sync --uninstall <hosts>` removes pi and leaves your config in place. If it
84
+ fails, run `curl -fsSL https://pi.dev/install.sh | sh` on the host and choose
85
+ `u` instead.
86
+
87
+ ## Shell completions
88
+
89
+ Host arguments complete from `~/.ssh/config`. zsh and fish also show where each
90
+ alias points:
91
+
92
+ ```console
93
+ $ pi-sync t<TAB>
94
+ tinfoil tinfoil@tinfoil.sayan.page
95
+ tinfoil-proxy notdebian@100.98.241.11
96
+ ```
97
+
98
+ ```bash
99
+ # bash
100
+ _PI_SYNC_COMPLETE=bash_source pi-sync > ~/.pi-sync-complete.bash
101
+ echo 'source ~/.pi-sync-complete.bash' >> ~/.bashrc
102
+
103
+ # zsh
104
+ _PI_SYNC_COMPLETE=zsh_source pi-sync > ~/.pi-sync-complete.zsh
105
+ echo 'source ~/.pi-sync-complete.zsh' >> ~/.zshrc
106
+
107
+ # fish (config.fish)
108
+ _PI_SYNC_COMPLETE=fish_source pi-sync | source
109
+ ```
110
+
111
+ PowerShell uses `powershell_source` the same way.
112
+
113
+ ## Caveats
114
+
115
+ - `settings.json` is written by pi itself (`lastChangelogVersion` bumps, UI
116
+ toggles), so pushing it overwrites the host's local preferences — the previous
117
+ copy is kept as `settings.json.backup`. Sync it when you change `packages`.
118
+ - Extensions that keep runtime files in their own directory (logs, checkpoints)
119
+ get those files synced too, and whichever side pushes last wins. Exclude them
120
+ with `-x '*/logs/*'`.
121
+ - Extension versions are whatever each host has installed; pin them in
122
+ `settings.json` (`npm:pi-lens@1.2.3`) if you need hosts identical.
123
+ - `--auth` copies API keys in the clear. Prefer `OPENCODE_API_KEY` (and friends)
124
+ in the environment where you can.
125
+ - `--delete` disables backups for the mirrored directory.
@@ -0,0 +1,166 @@
1
+ # Developing pi-sync
2
+
3
+ ## Layout
4
+
5
+ ```text
6
+ src/pi_sync/cli.py the whole CLI: probing, rsync, completions, self-update
7
+ tests/test_cli.py unit tests (no ssh or rsync process is ever executed)
8
+ pyproject.toml click dependency, console script, uv dev group
9
+ pyrightconfig.json points pyright at .venv (see "Tooling" below)
10
+ ```
11
+
12
+ Three design anchors worth understanding before editing:
13
+
14
+ - **The console script points at `pi_sync.cli:app`**, a click *group*.
15
+ `DefaultGroup.parse_args` prepends `sync` when the first argument is not a
16
+ subcommand, which is what lets `pi-sync tinfoil`, `pi-sync --config tinfoil`
17
+ and `pi-sync update` all work. `SyncCommand.format_usage` exists so help reads
18
+ `pi-sync [OPTIONS] [USER@]HOST...` instead of `pi-sync sync [OPTIONS] ...`.
19
+ `main` is the sync subcommand and keeps that name because the tests drive it
20
+ directly.
21
+ - **The version comes from distribution metadata** (`@click.version_option`), so
22
+ there is no `__version__` to fall out of step with the release.
23
+ - **`update` resolves the distribution from its own environment**
24
+ (`importlib.metadata.packages_distributions()`) rather than hardcoding a name.
25
+ An older install can coexist under the pre-rename name `pi-sync`, and a
26
+ hardcoded name would upgrade the wrong virtualenv — or nothing.
27
+
28
+ ## How it works
29
+
30
+ **One probe per host.** `probe_host` pipes a POSIX sh script through
31
+ `ssh host sh -s`, so reachability and pi's location cost a single round trip. It
32
+ checks `command -v pi` and then the usual install locations (`~/.local/bin`,
33
+ `~/.pi/bin`, `~/.pi/agent/bin`, linuxbrew, homebrew, `/usr/local/bin`) because a
34
+ non-interactive ssh session does not source the host's shell init — on a
35
+ linuxbrew host `command -v pi` alone finds nothing.
36
+
37
+ **Installing pi.** Interactively the terminal is handed to pi's own installer and
38
+ the sync resumes when it exits. Unattended (`--install` with no tty) the output is
39
+ captured and stdin is closed, so a prompt fails fast instead of hanging. The
40
+ installer's exit status is not trusted — its "do nothing" menu choice exits 0 —
41
+ so `install_pi` re-probes and reports honestly. A host that still lacks pi is
42
+ skipped, which makes the run exit non-zero. After a successful install,
43
+ `mkdir -p` creates the agent directory, because rsync will not create
44
+ intermediate directories itself.
45
+
46
+ **Uninstalling.** The official installer can only uninstall through its
47
+ interactive menu (its unattended mode always installs or reinstalls), so pi-sync
48
+ issues the npm command that menu would have: `npm uninstall -g --prefix <prefix>
49
+ @earendil-works/pi-coding-agent`, with the prefix derived from where pi actually
50
+ lives. It re-probes afterwards, since npm can exit 0 having removed nothing. Only
51
+ the CLI goes; the agent directory is never touched.
52
+
53
+ **Self-update.** `update` classifies the running environment and delegates:
54
+
55
+ | Detected | Action |
56
+ | --- | --- |
57
+ | pipx | `pipx upgrade <dist>` |
58
+ | uv tool | `uv tool upgrade <dist>` |
59
+ | editable checkout | prints `git -C <path> pull` |
60
+ | uvx ephemeral | nothing to do; suggests a durable install |
61
+ | otherwise | `python -m pip install --upgrade <dist>` |
62
+
63
+ It resolves the distribution from its own environment instead of hardcoding a
64
+ name, and refuses to downgrade when the local version is ahead of PyPI.
65
+
66
+ **Completions.** Read from `~/.ssh/config` at completion time, following
67
+ `Include` and skipping wildcard entries; `CompletionItem.help` carries
68
+ `user@hostname` for the shells that render it (bash cannot).
69
+
70
+ **rsync invocation.** `-az -i`, plus `--backup --suffix=.backup` and
71
+ `--exclude=*.backup` so replaced files are kept but backups never travel, and
72
+ `--delete-during` only for `extensions/` when `--delete` is given — which also
73
+ suppresses backups for that directory.
74
+
75
+ ## Commands
76
+
77
+ ```bash
78
+ uv sync # create/refresh .venv and uv.lock
79
+ uv run pytest # the suite
80
+ uv run pi-sync --help
81
+ uv build # wheel + sdist into dist/
82
+ ```
83
+
84
+ ## Tests
85
+
86
+ `FakeRun` records every command and replays canned output, so the suite needs no
87
+ network, no hosts, and no real transfers. When you add a command, extend
88
+ `FakeRun` rather than reaching for `subprocess` — the guarantee that nothing is
89
+ executed is what keeps the suite fast and safe.
90
+
91
+ The one path that cannot be unit-tested is the interactive installer handover,
92
+ because it depends on a real terminal. It was verified out-of-band with a PTY
93
+ harness that attaches a pseudo-terminal, feeds a keystroke, and asserts the
94
+ installer's `/dev/tty` menu reached the terminal and that the keypress reached
95
+ the remote host.
96
+
97
+ ## Tooling
98
+
99
+ `pi-lens` runs ruff format/lint as files are written, and sometimes reformats
100
+ *after* a commit — check `git status` before finishing, and commit formatter
101
+ churn on its own.
102
+
103
+ `pyrightconfig.json` sets `venvPath`/`venv` because pyright does not pick up a
104
+ uv-created venv on its own here. Measured: without it, `Import "pytest" could
105
+ not be resolved`; with it, zero errors. A long-lived pyright language server
106
+ that started before the venv existed will keep reporting the stale result until
107
+ it is restarted.
108
+
109
+ ## Releasing
110
+
111
+ ```bash
112
+ uv version --bump minor # or patch; also updates uv.lock
113
+ git commit -am "chore: release X.Y.Z"
114
+ git push origin main
115
+ git tag -a vX.Y.Z -m vX.Y.Z
116
+ git push origin vX.Y.Z # publishing happens on the tag
117
+ ```
118
+
119
+ `.github/workflows/publish.yml` then builds, smoke-tests both artifacts *through
120
+ the console script*, and publishes with OIDC trusted publishing — no token is
121
+ stored anywhere. It refuses to publish when the tag does not match the version in
122
+ `pyproject.toml`.
123
+
124
+ Verify a release:
125
+
126
+ ```bash
127
+ gh run view <run-id> --log | grep "Uploading pi_"
128
+ curl -s https://pypi.org/pypi/pi-sync-cli/X.Y.Z/json # per-version: immediate
129
+ ```
130
+
131
+ Expect **CDN lag** on the aggregate endpoints. Measured on 0.4.0: the
132
+ per-version endpoint answered immediately, `/pypi/pi-sync-cli/json` took ~45s,
133
+ and the simple index took ~40s. Until the simple index flips,
134
+ `uvx --from pi-sync-cli==X.Y.Z` fails with "no version … requirements are
135
+ unsatisfiable", which looks like a broken release but is not. Re-tagging never
136
+ helps (PyPI rejects duplicate versions); `workflow_dispatch` re-runs the job
137
+ without a new tag.
138
+
139
+ ## PyPI notes
140
+
141
+ The distribution is `pi-sync-cli`; the command is `pi-sync`. Plain `pi-sync` is
142
+ permanently unavailable: PyPI compares names with punctuation stripped, and
143
+ `pisync` already exists (an unrelated rsync backup script).
144
+
145
+ Trusted publisher fields: project `pi-sync-cli`, owner `say4n`, repository
146
+ `pi-sync`, workflow `publish.yml`, environment `pypi`. A pending publisher
147
+ reserves nothing until first use — it is invalidated if someone else registers
148
+ the name in the meantime.
149
+
150
+ ## Platform gotchas found the hard way
151
+
152
+ - macOS ships **openrsync**, which rejects rsync 3 flags. Only flags verified
153
+ against it are used; `--ignore-missing-args`, `--human-readable` and
154
+ `--mkpath` are not available.
155
+ - `--backup` with `--delete` fails on openrsync acting as *receiver*
156
+ (`fchownat: Operation not permitted`, exit 23) but works fine with rsync 3.x,
157
+ so backups are suppressed whenever `--delete` is in play.
158
+ - rsync's default check compares size and mtime at 1-second granularity: two
159
+ files created in the same second with equal sizes look "already in sync". Use
160
+ distinct mtimes when testing backups, or the test proves nothing.
161
+ - Remote shells are **fish** on some hosts, so probes are POSIX sh piped over
162
+ stdin (`ssh host sh -s`), never a shell loop in the command string
163
+ (`for …; do … done` is a syntax error there).
164
+ - pi's installer reads `/dev/tty`, not stdin, so capturing its output makes its
165
+ prompts invisible while it waits for a keypress. Interactive installs must
166
+ stream; unattended ones must close stdin so a prompt fails fast.
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.5
2
+ Name: pi-sync-cli
3
+ Version: 0.5.1
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
+ pi-sync keeps your pi agent config the same across hosts. It copies the
21
+ declarative parts of `~/.pi/agent` to another machine over rsync, and it reads
22
+ your ssh config, so the aliases in `~/.ssh/config` work as host names.
23
+
24
+ ```bash
25
+ pi-sync tinfoil # copy config and extensions to tinfoil
26
+ pi-sync --config laptop # copy only models.json and settings.json
27
+ pi-sync --pull --all tinfoil # copy tinfoil's config back to this machine
28
+ pi-sync --dry-run --all a b # report what would change, without copying
29
+ pi-sync update # update pi-sync itself
30
+ ```
31
+
32
+ ## install
33
+
34
+ ```bash
35
+ pipx install pi-sync-cli
36
+ ```
37
+
38
+ The PyPI package is `pi-sync-cli`, and it installs the `pi-sync` command.
39
+ `uv tool install pi-sync-cli` works the same way.
40
+
41
+ To install from source, run:
42
+
43
+ ```bash
44
+ pipx install git+ssh://git@github.com/say4n/pi-sync
45
+ ```
46
+
47
+ pi-sync requires Python 3.10 or later.
48
+
49
+ ## what syncs
50
+
51
+ pi-sync copies the declarative parts of the agent directory, and nothing else.
52
+
53
+ | group | what it copies |
54
+ | --- | --- |
55
+ | `--config` | `models.json` and `settings.json` |
56
+ | `--extensions` | the `extensions/` directory |
57
+ | `--auth` | `auth.json`, which holds API keys. This group is opt-in, and pi-sync warns you before it copies secrets. |
58
+
59
+ `--all` copies `--config` and `--extensions`. It is the default when you pass no
60
+ group flag.
61
+
62
+ pi-sync leaves host-local state alone: `sessions/`, `npm/`, `models-store.json`,
63
+ `ayu/`, `bin/`, and `trust.json` stay where they are.
64
+
65
+ ## flags
66
+
67
+ | flag | effect |
68
+ | --- | --- |
69
+ | `--all`, `--config`, `--extensions`, `--auth` | set what pi-sync copies |
70
+ | `--pull` | copy from the host to this machine instead of the other way |
71
+ | `--delete` | make `extensions/` match the source exactly, including deletions |
72
+ | `--dry-run` | report changes without copying anything |
73
+ | `-x, --exclude PATTERN` | skip files that match the pattern. Repeat the flag to add more patterns. |
74
+ | `--install` | install pi on hosts that do not have it, without prompting |
75
+ | `--uninstall` | remove pi from the host instead of copying config. Your config stays. |
76
+ | `--update-pi` | update pi on each host before copying config |
77
+ | `--local-dir` | set the local agent directory. The default is `$PI_CODING_AGENT_DIR`, or `~/.pi/agent`. |
78
+ | `--remote-dir` | set the agent directory on the host. The default is `~/.pi/agent`. |
79
+ | `-v, --verbose` | print each rsync command and its output |
80
+
81
+ To copy to more than one host, pass more than one name: `pi-sync a b c`. The
82
+ command exits with a non-zero status when it cannot copy to a host. Each file
83
+ pi-sync overwrites on the destination is kept beside the new one as
84
+ `<name>.backup`.
85
+
86
+ ## developing
87
+
88
+ For the layout, the tests, and the release process, see
89
+ [DEVELOPMENT.md](DEVELOPMENT.md).
@@ -0,0 +1,72 @@
1
+ # pi-sync
2
+
3
+ pi-sync keeps your pi agent config the same across hosts. It copies the
4
+ declarative parts of `~/.pi/agent` to another machine over rsync, and it reads
5
+ your ssh config, so the aliases in `~/.ssh/config` work as host names.
6
+
7
+ ```bash
8
+ pi-sync tinfoil # copy config and extensions to tinfoil
9
+ pi-sync --config laptop # copy only models.json and settings.json
10
+ pi-sync --pull --all tinfoil # copy tinfoil's config back to this machine
11
+ pi-sync --dry-run --all a b # report what would change, without copying
12
+ pi-sync update # update pi-sync itself
13
+ ```
14
+
15
+ ## install
16
+
17
+ ```bash
18
+ pipx install pi-sync-cli
19
+ ```
20
+
21
+ The PyPI package is `pi-sync-cli`, and it installs the `pi-sync` command.
22
+ `uv tool install pi-sync-cli` works the same way.
23
+
24
+ To install from source, run:
25
+
26
+ ```bash
27
+ pipx install git+ssh://git@github.com/say4n/pi-sync
28
+ ```
29
+
30
+ pi-sync requires Python 3.10 or later.
31
+
32
+ ## what syncs
33
+
34
+ pi-sync copies the declarative parts of the agent directory, and nothing else.
35
+
36
+ | group | what it copies |
37
+ | --- | --- |
38
+ | `--config` | `models.json` and `settings.json` |
39
+ | `--extensions` | the `extensions/` directory |
40
+ | `--auth` | `auth.json`, which holds API keys. This group is opt-in, and pi-sync warns you before it copies secrets. |
41
+
42
+ `--all` copies `--config` and `--extensions`. It is the default when you pass no
43
+ group flag.
44
+
45
+ pi-sync leaves host-local state alone: `sessions/`, `npm/`, `models-store.json`,
46
+ `ayu/`, `bin/`, and `trust.json` stay where they are.
47
+
48
+ ## flags
49
+
50
+ | flag | effect |
51
+ | --- | --- |
52
+ | `--all`, `--config`, `--extensions`, `--auth` | set what pi-sync copies |
53
+ | `--pull` | copy from the host to this machine instead of the other way |
54
+ | `--delete` | make `extensions/` match the source exactly, including deletions |
55
+ | `--dry-run` | report changes without copying anything |
56
+ | `-x, --exclude PATTERN` | skip files that match the pattern. Repeat the flag to add more patterns. |
57
+ | `--install` | install pi on hosts that do not have it, without prompting |
58
+ | `--uninstall` | remove pi from the host instead of copying config. Your config stays. |
59
+ | `--update-pi` | update pi on each host before copying config |
60
+ | `--local-dir` | set the local agent directory. The default is `$PI_CODING_AGENT_DIR`, or `~/.pi/agent`. |
61
+ | `--remote-dir` | set the agent directory on the host. The default is `~/.pi/agent`. |
62
+ | `-v, --verbose` | print each rsync command and its output |
63
+
64
+ To copy to more than one host, pass more than one name: `pi-sync a b c`. The
65
+ command exits with a non-zero status when it cannot copy to a host. Each file
66
+ pi-sync overwrites on the destination is kept beside the new one as
67
+ `<name>.backup`.
68
+
69
+ ## developing
70
+
71
+ For the layout, the tests, and the release process, see
72
+ [DEVELOPMENT.md](DEVELOPMENT.md).
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pi-sync-cli"
3
- version = "0.4.0"
3
+ version = "0.5.1"
4
4
  description = "Sync pi agent config and extensions between hosts over rsync"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -20,7 +20,7 @@ Repository = "https://github.com/say4n/pi-sync"
20
20
  Issues = "https://github.com/say4n/pi-sync/issues"
21
21
 
22
22
  [project.scripts]
23
- pi-sync = "pi_sync.cli:main"
23
+ pi-sync = "pi_sync.cli:app"
24
24
 
25
25
  [dependency-groups]
26
26
  dev = ["pytest>=8"]
@@ -0,0 +1,5 @@
1
+ """Sync pi agent config between hosts over rsync.
2
+
3
+ The version lives in the distribution metadata (see `pi-sync --version`), so
4
+ there is deliberately no `__version__` here to fall out of step with it.
5
+ """