pi-git-sync 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 daze
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.
package/README.md ADDED
@@ -0,0 +1,83 @@
1
+ # pi-git-sync
2
+
3
+ Pi package that adds `/sync` to sync `~/.pi/agent` through Git.
4
+
5
+ It keeps your Pi config portable across machines without committing auth, sessions, caches, package clones, or local sync state.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pi install npm:pi-git-sync
11
+ ```
12
+
13
+ Then run inside Pi:
14
+
15
+ ```text
16
+ /sync <git-repo-url>
17
+ ```
18
+
19
+ After the first setup, use:
20
+
21
+ ```text
22
+ /sync
23
+ ```
24
+
25
+ ## Commands
26
+
27
+ ```text
28
+ /sync <repo> # configure repo and sync
29
+ /sync # sync configured repo
30
+ /sync status # show git status
31
+ ```
32
+
33
+ ## What gets synced
34
+
35
+ - `settings.json`
36
+ - `web-search.json`
37
+ - `keybindings.json`
38
+ - `agents/`
39
+ - `extensions/`
40
+ - `skills/`
41
+ - `prompts/`
42
+ - `themes/`
43
+
44
+ ## What is ignored
45
+
46
+ - `auth.json`
47
+ - `models.json`
48
+ - `sync.json`
49
+ - `sessions/`, `cache/`, `logs/`, `tmp/`
50
+ - `npm/`, `git/`, `node_modules/`
51
+ - `.env`, tokens, credentials, keys, pem files
52
+
53
+ ## Conflict handling
54
+
55
+ If Git reports a conflict, `/sync` offers to:
56
+
57
+ - ask the agent to merge
58
+ - abort
59
+ - use local and force-push
60
+ - use remote and backup local
61
+
62
+ When the agent resolves a conflict, the next `/sync` commits it as:
63
+
64
+ ```text
65
+ sync: agent merged conflicts ...
66
+ ```
67
+
68
+ ## Safety
69
+
70
+ - Uses an allowlist instead of `git add .`.
71
+ - Refuses to commit staged files that look like secrets.
72
+ - Backs up local config before replacing it with remote config.
73
+ - Generates the config repo `README.md` automatically.
74
+
75
+ ## Development
76
+
77
+ ```bash
78
+ npm run smoke
79
+ ```
80
+
81
+ ## License
82
+
83
+ MIT
@@ -0,0 +1,328 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { execFile } from "node:child_process";
3
+ import { existsSync } from "node:fs";
4
+ import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+
8
+ const agentDir = join(homedir(), ".pi", "agent");
9
+ const syncFile = join(agentDir, "sync.json");
10
+ const backupDir = join(agentDir, "backups");
11
+ const agentMergeFlag = join(agentDir, ".git", "pi-git-sync-agent-merge");
12
+ const branch = "main";
13
+ const allow = [
14
+ ".gitignore",
15
+ "README.md",
16
+ "settings.json",
17
+ "web-search.json",
18
+ "keybindings.json",
19
+ "agents",
20
+ "extensions",
21
+ "skills",
22
+ "prompts",
23
+ "themes",
24
+ ];
25
+
26
+ const ignore = `# pi-git-sync local state
27
+ sync.json
28
+ models.json
29
+ backups/
30
+ extensions-disabled/
31
+ bin/
32
+
33
+ # secrets/auth
34
+ .env
35
+ .env.*
36
+ auth*
37
+ tokens*
38
+ secrets*
39
+ credentials*
40
+ *.key
41
+ *.pem
42
+
43
+ # runtime
44
+ sessions/
45
+ logs/
46
+ cache/
47
+ tmp/
48
+ temp/
49
+ trust.json
50
+
51
+ # installed packages / external code
52
+ npm/
53
+ git/
54
+ node_modules/
55
+ `;
56
+
57
+ type Config = { repo: string; branch: string };
58
+
59
+ function repoOwner(repo?: string) {
60
+ if (!repo) return "you";
61
+ const path = repo.replace(/^git@[^:]+:/, "").replace(/^[a-z]+:\/\/[^/]+\//, "");
62
+ return path.split("/").filter(Boolean)[0]?.replace(/\.git$/, "") || "you";
63
+ }
64
+
65
+ function readme(config?: Config) {
66
+ return `# Beautiful Pi Configuration by ${repoOwner(config?.repo)}
67
+
68
+ Synced pi-agent config managed by pi-git-sync.
69
+
70
+ ## Included
71
+
72
+ - settings.json
73
+ - web-search.json
74
+ - keybindings.json
75
+ - agents/
76
+ - extensions/
77
+ - skills/
78
+ - prompts/
79
+ - themes/
80
+
81
+ ## Excluded
82
+
83
+ Secrets, auth, sessions, cache, logs, installed packages, models.json, and local sync state.
84
+
85
+ Generated automatically by pi-git-sync.
86
+ `;
87
+ }
88
+
89
+ function sh(cmd: string, args: string[], cwd = agentDir) {
90
+ return new Promise<{ code: number; stdout: string; stderr: string }>((resolve) => {
91
+ execFile(cmd, args, { cwd }, (err, stdout, stderr) => {
92
+ resolve({ code: (err as any)?.code ?? 0, stdout: String(stdout), stderr: String(stderr) });
93
+ });
94
+ });
95
+ }
96
+
97
+ async function git(args: string[]) {
98
+ return sh("git", args);
99
+ }
100
+
101
+ async function mustGit(args: string[]) {
102
+ const result = await git(args);
103
+ if (result.code !== 0) throw new Error(result.stderr || result.stdout || `git ${args.join(" ")} failed`);
104
+ return result;
105
+ }
106
+
107
+ function isConfig(value: unknown): value is Config {
108
+ return !!value && typeof value === "object" && typeof (value as Config).repo === "string" && typeof (value as Config).branch === "string";
109
+ }
110
+
111
+ async function loadConfig(): Promise<Config | undefined> {
112
+ if (!existsSync(syncFile)) return;
113
+ try {
114
+ const config = JSON.parse(await readFile(syncFile, "utf8"));
115
+ if (isConfig(config)) return config;
116
+ } catch {}
117
+ throw new Error("Invalid ~/.pi/agent/sync.json, delete it or fix repo/branch");
118
+ }
119
+
120
+ async function saveConfig(config: Config) {
121
+ await mkdir(agentDir, { recursive: true });
122
+ await writeFile(syncFile, `${JSON.stringify(config, null, 2)}\n`);
123
+ }
124
+
125
+ async function ensureFiles(config?: Config) {
126
+ await mkdir(agentDir, { recursive: true });
127
+ const gitignorePath = join(agentDir, ".gitignore");
128
+ const current = existsSync(gitignorePath) ? await readFile(gitignorePath, "utf8") : "";
129
+ const normalized = current.replace("# pi-sync local state", "# pi-git-sync local state");
130
+ if (normalized !== current) await writeFile(gitignorePath, normalized);
131
+ else if (!current.includes("# pi-git-sync local state")) await writeFile(gitignorePath, `${current.trim()}\n\n${ignore}`.trimStart());
132
+ const readmePath = join(agentDir, "README.md");
133
+ // README is generated by design.
134
+ await writeFile(readmePath, readme(config));
135
+ }
136
+
137
+ async function backupSafe() {
138
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
139
+ const dest = join(backupDir, stamp);
140
+ await mkdir(dest, { recursive: true });
141
+ for (const p of allow) {
142
+ const from = join(agentDir, p);
143
+ if (existsSync(from)) await cp(from, join(dest, p), { recursive: true });
144
+ }
145
+ return dest;
146
+ }
147
+
148
+ async function removeSafe() {
149
+ for (const p of allow.filter((p) => !p.startsWith("."))) {
150
+ await rm(join(agentDir, p), { recursive: true, force: true });
151
+ }
152
+ }
153
+
154
+ async function addAllowed() {
155
+ const tracked = (await mustGit(["ls-files", "--", ...allow])).stdout.split("\n").filter(Boolean);
156
+ const existing = allow.filter((p) => existsSync(join(agentDir, p)));
157
+ const paths = [...new Set([...tracked, ...existing])];
158
+ if (paths.length) await mustGit(["add", "-A", "--", ...paths]);
159
+ }
160
+
161
+ async function stagedFiles() {
162
+ return (await mustGit(["diff", "--cached", "--name-only"])).stdout.split("\n").filter(Boolean);
163
+ }
164
+
165
+ async function scanSecrets() {
166
+ const files = await stagedFiles();
167
+ const hits: string[] = [];
168
+ const secretPattern = /(sk-[A-Za-z0-9_-]{20,}|(apiKey|access[_-]?token|refresh[_-]?token|password|secret)\s*[=:]\s*["'][^"']{8,})/i;
169
+ for (const file of files) {
170
+ const path = join(agentDir, file);
171
+ if (!existsSync(path)) continue;
172
+ const text = await readFile(path, "utf8").catch(() => "");
173
+ if (secretPattern.test(text)) hits.push(file);
174
+ }
175
+ if (hits.length) throw new Error(`Refusing to commit possible secrets in: ${hits.join(", ")}`);
176
+ }
177
+
178
+ async function commitIfNeeded(message: string) {
179
+ await addAllowed();
180
+ const diff = await git(["diff", "--cached", "--quiet"]);
181
+ if (diff.code === 0) return false;
182
+ if (diff.code !== 1) throw new Error(diff.stderr || diff.stdout || "git diff failed");
183
+ await scanSecrets();
184
+ await mustGit(["commit", "-m", message]);
185
+ return true;
186
+ }
187
+
188
+ async function ensureRemote(config: Config) {
189
+ const remote = await git(["remote", "get-url", "origin"]);
190
+ if (remote.code !== 0) await mustGit(["remote", "add", "origin", config.repo]);
191
+ else if (remote.stdout.trim() !== config.repo) await mustGit(["remote", "set-url", "origin", config.repo]);
192
+ }
193
+
194
+ async function initRepo(config: Config, ctx: ExtensionCommandContext) {
195
+ if (!existsSync(join(agentDir, ".git"))) await mustGit(["init"]);
196
+ await ensureRemote(config);
197
+ await mustGit(["checkout", "-B", config.branch]);
198
+
199
+ const remoteBranch = await git(["ls-remote", "--heads", "origin", config.branch]);
200
+ if (remoteBranch.code !== 0) throw new Error(remoteBranch.stderr || remoteBranch.stdout || "git ls-remote failed");
201
+ if (!remoteBranch.stdout.trim()) {
202
+ await ensureFiles(config);
203
+ await commitIfNeeded(`sync: initial pi config ${new Date().toISOString()}`);
204
+ await mustGit(["push", "-u", "origin", config.branch]);
205
+ ctx.ui.notify("Pi config uploaded to empty repo", "info");
206
+ return;
207
+ }
208
+
209
+ if (!ctx.hasUI) throw new Error("Remote config exists; interactive setup required");
210
+ const choice = await ctx.ui.select("Remote config already exists", ["Backup local and download", "Abort"]);
211
+ if (choice !== "Backup local and download") return;
212
+ const backup = await backupSafe();
213
+ await removeSafe();
214
+ await mustGit(["fetch", "origin", config.branch]);
215
+ await mustGit(["reset", "--hard", `origin/${config.branch}`]);
216
+ ctx.ui.notify(`Downloaded remote config. Backup: ${backup}`, "info");
217
+ }
218
+
219
+ function inMerge() {
220
+ return existsSync(join(agentDir, ".git", "MERGE_HEAD"));
221
+ }
222
+
223
+ async function continueMerge(config: Config, ctx: ExtensionCommandContext) {
224
+ await addAllowed();
225
+ const unresolved = await mustGit(["diff", "--name-only", "--diff-filter=U"]);
226
+ if (unresolved.stdout.trim()) throw new Error(`Still unresolved: ${unresolved.stdout.trim()}`);
227
+ await scanSecrets();
228
+ const byAgent = existsSync(agentMergeFlag);
229
+ const message = byAgent ? `sync: agent merged conflicts ${new Date().toISOString()}` : `sync: resolved conflicts ${new Date().toISOString()}`;
230
+ await mustGit(["commit", "-m", message]);
231
+ await rm(agentMergeFlag, { force: true });
232
+ await mustGit(["push", "origin", config.branch]);
233
+ ctx.ui.notify("Conflict resolution pushed", "info");
234
+ }
235
+
236
+ async function askAgentToMerge(pi: ExtensionAPI, config: Config) {
237
+ await writeFile(agentMergeFlag, new Date().toISOString());
238
+ pi.sendUserMessage(`Resolve pi-git-sync git conflicts in ${agentDir}.
239
+
240
+ Rules:
241
+ - Only edit tracked/synced pi config files.
242
+ - Do not touch auth.json, models.json, sync.json, sessions, cache, npm, git, or secrets.
243
+ - Preserve useful changes from both sides when possible.
244
+ - After resolving, run git status.
245
+ - Do not commit or push; tell me to run /sync again.
246
+
247
+ After I run /sync again, pi-git-sync will commit with message: sync: agent merged conflicts ...`, { deliverAs: "followUp" });
248
+ }
249
+
250
+ async function normalSync(pi: ExtensionAPI, config: Config, ctx: ExtensionCommandContext) {
251
+ await ensureFiles(config);
252
+ if (inMerge()) return continueMerge(config, ctx);
253
+
254
+ await commitIfNeeded(`sync: ${new Date().toISOString()}`);
255
+ const pulled = await git(["pull", "--no-rebase", "--no-edit", "origin", config.branch]);
256
+ if (pulled.code !== 0) {
257
+ if (!ctx.hasUI) throw new Error(pulled.stderr || "git pull failed");
258
+ const choice = await ctx.ui.select("Sync conflict", ["Ask agent to merge", "Abort", "Use local and force push", "Use remote and backup local"]);
259
+ if (choice === "Ask agent to merge") {
260
+ await askAgentToMerge(pi, config);
261
+ ctx.ui.notify("Queued agent conflict resolver", "info");
262
+ return;
263
+ }
264
+ if (choice === "Use local and force push") {
265
+ await mustGit(["merge", "--abort"]);
266
+ await mustGit(["push", "--force-with-lease", "origin", config.branch]);
267
+ ctx.ui.notify("Local config pushed", "info");
268
+ return;
269
+ }
270
+ if (choice === "Use remote and backup local") {
271
+ const backup = await backupSafe();
272
+ await mustGit(["merge", "--abort"]);
273
+ await mustGit(["fetch", "origin", config.branch]);
274
+ await mustGit(["reset", "--hard", `origin/${config.branch}`]);
275
+ ctx.ui.notify(`Remote config applied. Backup: ${backup}`, "info");
276
+ return;
277
+ }
278
+ await mustGit(["merge", "--abort"]);
279
+ return;
280
+ }
281
+
282
+ const ahead = await git(["status", "--porcelain", "--branch"]);
283
+ if (ahead.stdout.includes("ahead")) await mustGit(["push", "origin", config.branch]);
284
+ ctx.ui.notify("Pi config synced", "info");
285
+ }
286
+
287
+ async function showStatus(ctx: ExtensionCommandContext) {
288
+ const status = await git(["status", "--short", "--branch"]);
289
+ ctx.ui.notify(status.stdout.trim() || "No git status", "info");
290
+ }
291
+
292
+ function looksLikeRepo(arg: string) {
293
+ return /^(git@|https?:\/\/|ssh:\/\/|git:\/\/)/.test(arg) || /^[\w.-]+\/[\w./-]+(\.git)?$/.test(arg);
294
+ }
295
+
296
+ export default function piGitSync(pi: ExtensionAPI) {
297
+ pi.registerCommand("sync", {
298
+ description: "Sync ~/.pi/agent config through git",
299
+ getArgumentCompletions: (prefix) => ["status"].filter((x) => x.startsWith(prefix)).map((value) => ({ value, label: value })),
300
+ handler: async (args, ctx) => {
301
+ await ctx.waitForIdle();
302
+ const arg = args.trim();
303
+ let config = await loadConfig();
304
+
305
+ if (arg === "status") return showStatus(ctx);
306
+ if (arg && looksLikeRepo(arg)) {
307
+ config = { repo: arg, branch };
308
+ await saveConfig(config);
309
+ }
310
+
311
+ await ensureFiles(config);
312
+ if (!config) {
313
+ if (!ctx.hasUI) throw new Error("Run /sync <repo-url> once to set repo URL");
314
+ const repo = await ctx.ui.input("Pi git sync repo", "git@github.com:user/pi-config.git");
315
+ if (!repo) return;
316
+ config = { repo, branch };
317
+ await saveConfig(config);
318
+ await ensureFiles(config);
319
+ }
320
+
321
+ if (!existsSync(join(agentDir, ".git"))) await initRepo(config, ctx);
322
+ else {
323
+ await ensureRemote(config);
324
+ await normalSync(pi, config, ctx);
325
+ }
326
+ },
327
+ });
328
+ }
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "pi-git-sync",
3
+ "version": "0.1.0",
4
+ "description": "Sync Pi agent config through Git",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi",
8
+ "pi-agent",
9
+ "git",
10
+ "sync",
11
+ "config"
12
+ ],
13
+ "author": "daze",
14
+ "license": "MIT",
15
+ "type": "module",
16
+ "files": [
17
+ "README.md",
18
+ "LICENSE",
19
+ "extensions"
20
+ ],
21
+ "scripts": {
22
+ "smoke": "npx esbuild extensions/sync.ts --bundle --platform=node --format=esm --outfile=/tmp/pi-git-sync-smoke.mjs --external:@earendil-works/pi-coding-agent"
23
+ },
24
+ "peerDependencies": {
25
+ "@earendil-works/pi-coding-agent": "*"
26
+ },
27
+ "pi": {
28
+ "extensions": [
29
+ "./extensions"
30
+ ]
31
+ }
32
+ }