@narumitw/pi-sync 0.24.0 → 0.28.0

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.
package/README.md CHANGED
@@ -8,19 +8,17 @@ It syncs automatically by default when Pi starts, then uses immutable snapshot b
8
8
 
9
9
  ## ✨ Features
10
10
 
11
- - Adds a `/pisync` command with `status`, `diff`, `push`, `pull`, `sync`, `history`, `rollback`, `doctor`, and `unlock` subcommands.
12
- - Syncs allowlisted Pi configuration from `~/.pi/agent`:
13
- - `settings.json`
14
- - `keybindings.json`
15
- - `models.json`
16
- - `AGENTS.md`
17
- - `APPEND_SYSTEM.md`
18
- - `skills/`, `prompts/`, `themes/`, and `extensions/`
11
+ - Opens every pi-sync action from the bare `/sync` menu and keeps direct `help`, `init`, `config`, `files`, `status`, `diff`, `doctor`, `push`, `pull`, `sync`, `history`, `rollback`, and `unlock` subcommands for automation and advanced flags.
12
+ - Uses `/sync files` to choose which top-level Pi files and recursive directory groups are managed by this machine.
13
+ - Syncs selected, allowlisted Pi configuration from `~/.pi/agent`:
14
+ - `settings.json`, `keybindings.json`, `models.json`, `AGENTS.md`, and `APPEND_SYSTEM.md`
15
+ - recursive `skills/`, `prompts/`, `themes/`, and `extensions/` groups
16
+ - safe top-level files selected through `extraFiles`
19
17
  - optionally denylist-filtered `sessions/**/*.jsonl` when `syncSessions` is enabled
20
18
  - Stores each remote version as an immutable gzip-compressed JSON snapshot bundle.
21
19
  - Updates remote state through `latest.json` after re-reading remote state to reject already-visible remote changes.
22
20
  - Creates local backups before `pull` and `rollback` under `~/.pi/agent/.pisync/backups/`.
23
- - Runs `/pisync sync` automatically on Pi startup when R2/S3 config is present.
21
+ - Runs `/sync sync` automatically on Pi startup when R2/S3 config is present.
24
22
  - Uses a local exclusive lock at `~/.pi/agent/.pisync/lock` for destructive sync operations and only treats locks as stale after checking process liveness.
25
23
  - Refuses to push common secret patterns and denylisted paths such as `.env`, `.env.local`, token/secret files, `.pisync`, `.git`, and `node_modules`.
26
24
  - Preflights snapshot apply operations before mutating local files, refuses symlink path escapes, and rejects writes over symlinks or directories.
@@ -48,7 +46,7 @@ pi -e ./extensions/pi-sync
48
46
  Run:
49
47
 
50
48
  ```text
51
- /pisync init
49
+ /sync init
52
50
  ```
53
51
 
54
52
  Then edit:
@@ -71,6 +69,17 @@ Example:
71
69
  "profile": "default",
72
70
  "prefix": "pi-sync",
73
71
  "autoSync": true,
72
+ "syncFiles": [
73
+ "settings.json",
74
+ "keybindings.json",
75
+ "models.json",
76
+ "AGENTS.md",
77
+ "APPEND_SYSTEM.md",
78
+ "skills",
79
+ "prompts",
80
+ "themes",
81
+ "extensions"
82
+ ],
74
83
  "syncSessions": false,
75
84
  "extraFiles": []
76
85
  }
@@ -91,7 +100,13 @@ export PI_SYNC_AUTO_SYNC="true"
91
100
  export PI_SYNC_SESSIONS="false" # opt in with true to sync Pi conversation JSONL files
92
101
  ```
93
102
 
94
- Set `extraFiles` to additional top-level file names to include beyond the default allowlist. Machines without a matching `extraFiles` entry preserve those remote files but do not apply or delete them locally.
103
+ Run `/sync files` in TUI mode to edit `syncFiles`, `syncSessions`, and `extraFiles` with a searchable selector. Enter or Space toggles an item and Escape closes the screen. Each successful change is saved immediately to `pi-sync.local.json`, preserving unknown fields through an atomic replacement with `0600` permissions on POSIX. No network sync starts until the next manual `push`, `pull`, or `sync`, or the existing automatic sync lifecycle.
104
+
105
+ `syncFiles` accepts the built-in names shown above and has no environment-variable override. Directory names select the complete recursive group. Omitting `syncFiles` preserves the legacy default of selecting every built-in item; an empty array is valid and means this machine manages none of them. Unknown values make configuration validation fail instead of silently narrowing the selection.
106
+
107
+ The selector always shows built-in items, even when they are absent locally, so another machine can provide them on pull. It also shows safe top-level regular files found locally and configured `extraFiles` that may only exist remotely. It excludes directories, symlinks, reserved names, and denylisted names. You can still edit `extraFiles` manually to select additional safe top-level file names.
108
+
109
+ An unselected item is unmanaged by this machine: pi-sync does not collect, compare, pull, overwrite, or delete its local content, and pushes preserve its existing remote snapshot content for other machines. This applies to built-ins, directory groups, sessions, and extra files. Selecting nothing therefore does not delete unmanaged local or remote files.
95
110
 
96
111
  `PI_SYNC_ACCESS_KEY_ID`, `PI_SYNC_SECRET_ACCESS_KEY`, and `PI_SYNC_SESSION_TOKEN` are local-only credentials. Do not put them in files that pi-sync syncs. `PI_SYNC_SESSION_TOKEN` is optional and only needed for temporary credentials such as AWS STS, AWS SSO, assumed roles, or S3-compatible providers that issue short-lived credentials.
97
112
 
@@ -103,7 +118,7 @@ pi-sync also reads `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TO
103
118
 
104
119
  `syncSessions` defaults to `false`. Set it to `true`, or set `PI_SYNC_SESSIONS=true`, to include Pi's configured session JSONL files in snapshots. pi-sync uses `PI_CODING_AGENT_SESSION_DIR`, Pi's `sessionDir` setting, or the default `${PI_CODING_AGENT_DIR:-~/.pi/agent}/sessions/**/*.jsonl` storage. Empty or misspelled `PI_SYNC_SESSIONS` values stay disabled. Only JSONL session files are included; other session-directory files and denylisted paths such as `.env*`, `.pisync`, `node_modules`, and names containing `token` or `secret` are ignored.
105
120
 
106
- Session sync is snapshot-based, not live collaboration. If the same session changes on two machines, `/pisync sync` uses the same conflict rules as settings sync and skips when both local and remote changed. Run `/pisync diff`, then choose `/pisync pull --force` or `/pisync push --force` if needed.
121
+ Session sync is snapshot-based, not live collaboration. If the same session changes on two machines, `/sync sync` uses the same conflict rules as settings sync and skips when both local and remote changed. Run `/sync diff`, then choose `/sync pull --force` or `/sync push --force` if needed.
107
122
 
108
123
  When both `autoSync` and `syncSessions` are enabled, pi-sync syncs on startup and attempts a quiet session push on shutdown when local files changed. Startup session pulls happen after Pi has already selected the current session, so restart Pi or resume a pulled session to use newly synced conversations. If the remote changed first, the shutdown push is skipped with a warning instead of overwriting it.
109
124
 
@@ -111,32 +126,45 @@ Session files can contain prompts, model output, tool results, file paths, image
111
126
 
112
127
  ## 🚀 Usage
113
128
 
129
+ Run `/sync` without arguments to open the interactive menu containing every pi-sync action. The **files — Choose synced files** action opens the persistent selector. Choosing rollback asks for the snapshot id before showing the existing confirmation. Cancelling either action leaves unmodified state unchanged; file-selection changes that were already saved are not rolled back when the screen closes.
130
+
114
131
  ```text
115
- /pisync config
116
- /pisync doctor
117
- /pisync status
118
- /pisync diff
119
- /pisync push
120
- /pisync pull
121
- /pisync sync
122
- /pisync history
123
- /pisync rollback <snapshot-id>
124
- /pisync unlock --stale
132
+ /sync
125
133
  ```
126
134
 
135
+ The same actions remain available as direct routes for automation, non-interactive use, and advanced flags:
136
+
137
+ ```text
138
+ /sync help
139
+ /sync init
140
+ /sync config
141
+ /sync files
142
+ /sync status
143
+ /sync diff
144
+ /sync doctor
145
+ /sync push
146
+ /sync pull
147
+ /sync sync
148
+ /sync history
149
+ /sync rollback <snapshot-id>
150
+ /sync unlock --stale
151
+ ```
152
+
153
+ Bare `/sync` reports command usage when an interactive UI is unavailable; use a direct route for the desired operation. Outside TUI mode, `/sync files` does not open a custom component and instead reports the effective selection, config path, and manual editing fields. When `PI_SYNC_SESSIONS` is set, the sessions row is read-only and identifies the environment override.
154
+
127
155
  Useful flags:
128
156
 
129
157
  - `--yes` / `-y`: skip confirmation prompts.
130
158
  - `--force`: allow push or pull when both local and remote state changed.
131
- - `--stale`: remove a stale local lock with `/pisync unlock --stale`.
159
+ - `--stale`: remove a stale local lock with `/sync unlock --stale`.
132
160
 
133
161
  ## 🔄 Automatic sync
134
162
 
135
- `autoSync` defaults to `true`. When Pi starts, pi-sync runs the same conservative decision logic as `/pisync sync`:
163
+ `autoSync` defaults to `true`. When Pi starts, pi-sync runs the same conservative decision logic as `/sync sync`:
136
164
 
137
165
  - only local changed or remote is empty → push
138
166
  - first sync with existing local settings and an identical remote snapshot → initialize local sync state without rewriting files
139
- - first sync with existing local settings and a different remote snapshot → skip and show a warning so you manually choose `/pisync pull` or `/pisync push`
167
+ - first sync with existing local settings and a different remote snapshot → skip and show a warning so you manually choose `/sync pull` or `/sync push`
140
168
  - only remote changed after an established sync → pull with a backup
141
169
  - both local and remote changed after a previous sync → skip and show a warning
142
170
  - no config present → do nothing
@@ -171,13 +199,14 @@ pi-sync/
171
199
 
172
200
  Each snapshot contains the selected file tree and SHA-256 hashes. `latest.json` points to the active snapshot. Rollback applies an older snapshot locally and moves `latest.json` back to that snapshot.
173
201
 
174
- Before updating `latest.json`, pi-sync re-reads the current pointer and rejects the push if it already differs from the version seen at the start of the command. This prevents overwriting changes that are visible before the final write. It is not a true atomic cross-machine compare-and-swap on R2, so two machines that push at the same instant can still race; run `/pisync status` before important manual pushes if you use multiple machines heavily.
202
+ Before updating `latest.json`, pi-sync re-reads the current pointer and rejects the push if it already differs from the version seen at the start of the command. This prevents overwriting changes that are visible before the final write. It is not a true atomic cross-machine compare-and-swap on R2, so two machines that push at the same instant can still race; run `/sync status` before important manual pushes if you use multiple machines heavily.
175
203
 
176
204
  ## 🛡️ Safety notes
177
205
 
178
206
  - pi-sync auto-syncs on startup by default, but skips instead of overwriting when first-run local settings and a remote snapshot both exist, or when both local and remote changed after a previous sync.
207
+ - Unselected built-in files, directory groups, sessions, and extra files are unmanaged locally and preserved remotely; selecting nothing does not delete them.
179
208
  - With `syncSessions`/`PI_SYNC_SESSIONS` disabled, pi-sync does not collect or apply local Pi sessions, though settings-only pushes may preserve session files already present in remote snapshots. It never syncs OAuth state, npm caches, `.env`, `.env.local`, `node_modules`, or `.pisync` state.
180
- - If another Pi process is already syncing on the same machine, destructive commands stop at the local lock. `/pisync unlock --stale` is intended for locks whose process is gone or invalid.
209
+ - If another Pi process is already syncing on the same machine, destructive commands stop at the local lock. `/sync unlock --stale` is intended for locks whose process is gone or invalid.
181
210
  - If another machine's update is visible before this machine updates `latest.json`, push is rejected unless you explicitly use `--force`.
182
211
  - Pull and rollback create backups before writing local files, then preflight deletes and writes before mutating the local settings tree.
183
212
  - Pull and rollback refuse to follow symlinked parent paths during snapshot apply and refuse to overwrite a symlink or directory with file content.
@@ -187,7 +216,10 @@ Before updating `latest.json`, pi-sync re-reads the current pointer and rejects
187
216
  ```txt
188
217
  extensions/pi-sync/
189
218
  ├── src/
190
- │ ├── sync.ts # Pi entrypoint and command orchestration
219
+ │ ├── index.ts # Pi package entrypoint
220
+ │ ├── sync.ts # Extension registration and command orchestration
221
+ │ ├── file-selection.ts # Persistent SettingsList selector
222
+ │ ├── sync-policy.ts # Shared built-in catalog and path policy
191
223
  │ └── *.ts # Package-local config, snapshot, path, and S3 modules
192
224
  ├── README.md
193
225
  ├── LICENSE
@@ -195,12 +227,12 @@ extensions/pi-sync/
195
227
  └── package.json
196
228
  ```
197
229
 
198
- Only `sync.ts` is a Pi entrypoint; the other source modules are internal. The package exposes its Pi extension through `package.json`:
230
+ `index.ts` is the Pi entrypoint and forwards to `sync.ts`; the other source modules are internal. The package exposes its Pi extension through `package.json`:
199
231
 
200
232
  ```json
201
233
  {
202
234
  "pi": {
203
- "extensions": ["./src/sync.ts"]
235
+ "extensions": ["./src/index.ts"]
204
236
  }
205
237
  }
206
238
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-sync",
3
- "version": "0.24.0",
3
+ "version": "0.28.0",
4
4
  "description": "Pi extension that syncs Pi configuration through Cloudflare R2 or S3-compatible storage.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,7 +20,7 @@
20
20
  ],
21
21
  "pi": {
22
22
  "extensions": [
23
- "./src/sync.ts"
23
+ "./src/index.ts"
24
24
  ]
25
25
  },
26
26
  "scripts": {
@@ -28,11 +28,20 @@
28
28
  "format": "biome check --write .",
29
29
  "typecheck": "tsc --noEmit"
30
30
  },
31
+ "dependencies": {
32
+ "proper-lockfile": "^4.1.2"
33
+ },
31
34
  "devDependencies": {
32
35
  "@biomejs/biome": "2.5.3",
33
36
  "@earendil-works/pi-coding-agent": "0.80.10",
37
+ "@earendil-works/pi-tui": "0.80.10",
38
+ "@types/proper-lockfile": "^4.1.4",
34
39
  "typescript": "7.0.2"
35
40
  },
41
+ "peerDependencies": {
42
+ "@earendil-works/pi-coding-agent": "*",
43
+ "@earendil-works/pi-tui": "*"
44
+ },
36
45
  "repository": {
37
46
  "type": "git",
38
47
  "url": "https://github.com/narumiruna/pi-extensions",
package/src/command.ts CHANGED
@@ -1,23 +1,31 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
1
2
  import type { CommandArgumentCompletion, CommandOptions } from "./types.js";
2
3
 
3
4
  const YES_FLAG_COMPLETIONS: readonly CommandArgumentCompletion[] = [
4
5
  { value: "--yes", label: "--yes", description: "Skip confirmation prompts" },
5
6
  { value: "-y", label: "-y", description: "Skip confirmation prompts" },
6
7
  ];
7
- const SYNC_COMMAND_COMPLETIONS: readonly CommandArgumentCompletion[] = [
8
- { value: "help", label: "help", description: "Show command usage" },
9
- { value: "init", label: "init", description: "Create local config template" },
10
- { value: "config", label: "config", description: "Show resolved configuration" },
11
- { value: "status", label: "status", description: "Show sync status" },
12
- { value: "diff", label: "diff", description: "Show local/remote diff" },
13
- { value: "doctor", label: "doctor", description: "Check config, secrets, and lock state" },
14
- { value: "push", label: "push", description: "Upload local settings" },
15
- { value: "pull", label: "pull", description: "Apply remote settings" },
16
- { value: "sync", label: "sync", description: "Push or pull as needed" },
17
- { value: "history", label: "history", description: "Show recent remote snapshots" },
18
- { value: "rollback", label: "rollback", description: "Apply a previous snapshot" },
19
- { value: "unlock", label: "unlock", description: "Remove a stale local lock" },
20
- ];
8
+ export const SYNC_COMMANDS = [
9
+ { name: "help", description: "Show command usage" },
10
+ { name: "init", description: "Create local config template" },
11
+ { name: "config", description: "Show resolved configuration" },
12
+ { name: "files", description: "Choose synced files" },
13
+ { name: "status", description: "Show sync status" },
14
+ { name: "diff", description: "Show local/remote diff" },
15
+ { name: "doctor", description: "Check config, secrets, and lock state" },
16
+ { name: "push", description: "Upload local settings" },
17
+ { name: "pull", description: "Apply remote settings" },
18
+ { name: "sync", description: "Push or pull as needed" },
19
+ { name: "history", description: "Show recent remote snapshots" },
20
+ { name: "rollback", description: "Apply a previous snapshot", usageSuffix: " <snapshot>" },
21
+ { name: "unlock", description: "Remove a stale local lock", usageSuffix: " --stale" },
22
+ ] as const;
23
+
24
+ export type SyncCommandName = (typeof SYNC_COMMANDS)[number]["name"];
25
+
26
+ const SYNC_COMMAND_COMPLETIONS: readonly CommandArgumentCompletion[] = SYNC_COMMANDS.map(
27
+ ({ name, description }) => ({ value: name, label: name, description }),
28
+ );
21
29
  const SYNC_FLAG_COMPLETIONS: Record<string, readonly CommandArgumentCompletion[]> = {
22
30
  push: [
23
31
  ...YES_FLAG_COMPLETIONS,
@@ -36,7 +44,10 @@ const SYNC_FLAG_COMPLETIONS: Record<string, readonly CommandArgumentCompletion[]
36
44
  };
37
45
 
38
46
  export function splitArgs(input: string) {
39
- return input.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g)?.map((arg) => arg.replace(/^['"]|['"]$/g, "")) ?? [];
47
+ return (
48
+ input.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g)?.map((arg) => arg.replace(/^['"]|['"]$/g, "")) ??
49
+ []
50
+ );
40
51
  }
41
52
 
42
53
  export function parseOptions(args: string[]): CommandOptions {
@@ -88,10 +99,42 @@ export function completeSyncArguments(argumentPrefix: string): CommandArgumentCo
88
99
  : null;
89
100
  }
90
101
 
102
+ export function syncMenuOptions() {
103
+ return SYNC_COMMANDS.map(({ name, description }) => `${name} — ${description}`);
104
+ }
105
+
106
+ export function syncCommandFromMenuOption(option: string): SyncCommandName | undefined {
107
+ return SYNC_COMMANDS.find(({ name, description }) => option === `${name} — ${description}`)?.name;
108
+ }
109
+
110
+ export async function resolveSyncCommand(input: string, ctx: ExtensionCommandContext) {
111
+ const [subcommand, ...rest] = splitArgs(input);
112
+ if (subcommand) return { subcommand, rest };
113
+ if (!ctx.hasUI) {
114
+ ctx.ui.notify(usage(), "info");
115
+ return undefined;
116
+ }
117
+
118
+ const selectedOption = await ctx.ui.select("pi-sync", syncMenuOptions());
119
+ const selected = selectedOption ? syncCommandFromMenuOption(selectedOption) : undefined;
120
+ if (!selected) return undefined;
121
+ if (selected !== "rollback") return { subcommand: selected, rest: [] };
122
+
123
+ const target = (await ctx.ui.input("Rollback snapshot", "snapshot id"))?.trim();
124
+ if (!target) {
125
+ ctx.ui.notify("Rollback cancelled.", "info");
126
+ return undefined;
127
+ }
128
+ return { subcommand: selected, rest: [target] };
129
+ }
130
+
91
131
  export function usage() {
132
+ const commands = SYNC_COMMANDS.map(
133
+ (command) => `${command.name}${"usageSuffix" in command ? command.usageSuffix : ""}`,
134
+ ).join(", ");
92
135
  return [
93
- "Usage: /pisync <command>",
94
- "Commands: init, config, status, diff, doctor, push, pull, sync, history, rollback <snapshot>, unlock --stale",
95
- "Config: set PI_SYNC_ENDPOINT, PI_SYNC_BUCKET, PI_SYNC_ACCESS_KEY_ID, PI_SYNC_SECRET_ACCESS_KEY, optional PI_SYNC_SESSION_TOKEN, PI_SYNC_SESSIONS/syncSessions, region/profile/prefix, or edit ~/.pi/agent/pi-sync.local.json (or $PI_CODING_AGENT_DIR/pi-sync.local.json when PI_CODING_AGENT_DIR is set).",
136
+ "Usage: /sync <command>",
137
+ `Commands: ${commands}`,
138
+ "Config: set PI_SYNC_ENDPOINT, PI_SYNC_BUCKET, PI_SYNC_ACCESS_KEY_ID, PI_SYNC_SECRET_ACCESS_KEY, optional PI_SYNC_SESSION_TOKEN, PI_SYNC_SESSIONS/syncSessions, region/profile/prefix, or edit syncFiles/extraFiles in ~/.pi/agent/pi-sync.local.json (or $PI_CODING_AGENT_DIR/pi-sync.local.json when PI_CODING_AGENT_DIR is set).",
96
139
  ].join("\n");
97
140
  }