Skip to content

Commit 0b827d1

Browse files
authored
feat: add profile sync script for pi configuration management (#9)
* feat: add profile sync script for pi configuration management * docs: update changelog
1 parent 284aa54 commit 0b827d1

2 files changed

Lines changed: 254 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,13 @@ All notable changes to agent-stuff are documented here.
1414

1515

1616

17-
## feat/profile-badge-display
17+
18+
19+
## feat/profile-sync-script
20+
21+
Added a new profile synchronization utility script for Pi configuration management (#9). The `sync-profiles.ts` script enables users to merge configuration from a source profile to a destination profile, supporting deep-merge semantics for `settings.json` and `keybindings.json` (with source values winning on conflicts), while wholesale-copying extension JSON files. The tool operates in dry-run mode by default and provides detailed diff output showing additions, changes, removals, and unchanged entries before writing changes with the `--apply` flag, making it safe for managing multiple Pi profiles without accidental overwrites.
22+
23+
## [1.0.0](https://github.com/kostyay/agent-stuff/pull/8) - 2026-03-02
1824

1925
Added a color-coded profile badge to the status bar that displays the current profile name and authentication method (#8). The badge uses a deterministically hashed background color derived from the profile name for easy visual distinction across multiple profiles, appearing only when `PI_CODING_AGENT_DIR` points to a non-default directory. Supporting utilities including `hashString`, `hslToRgb`, and `buildProfileBadge` were extracted as reusable functions, along with comprehensive unit tests covering color conversion, token formatting, and profile detection logic. The extension now provides richer visual feedback about the active authentication context (OAuth vs. API key) at a glance.
2026

scripts/sync-profiles.ts

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
/**
2+
* Sync Pi Profiles
3+
*
4+
* Syncs configuration files from a source pi profile to a destination profile.
5+
* Handles settings.json and keybindings.json via deep merge (source wins on conflict),
6+
* and copies extensions/*.json files wholesale.
7+
*
8+
* Usage:
9+
* node --experimental-strip-types scripts/sync-profiles.ts <src> <dst> [--apply]
10+
*
11+
* Arguments:
12+
* src Source profile name (e.g. "agent-personal") or full path
13+
* dst Destination profile name (e.g. "agent-work") or full path
14+
* --apply Actually write changes (default is dry-run)
15+
*
16+
* Examples:
17+
* node --experimental-strip-types scripts/sync-profiles.ts agent-personal agent-work
18+
* node --experimental-strip-types scripts/sync-profiles.ts agent-personal agent-work --apply
19+
*/
20+
21+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
22+
import { homedir } from "node:os";
23+
import { join, resolve } from "node:path";
24+
25+
// --- Types ---
26+
27+
interface DiffEntry {
28+
key: string;
29+
type: "added" | "changed" | "unchanged" | "removed";
30+
srcValue?: unknown;
31+
dstValue?: unknown;
32+
}
33+
34+
interface SyncResult {
35+
file: string;
36+
diffs: DiffEntry[];
37+
written: boolean;
38+
}
39+
40+
// --- Core Functions ---
41+
42+
/** Resolves a profile name or path to an absolute directory path. */
43+
function resolveProfilePath(nameOrPath: string): string {
44+
const asAbsolute = resolve(nameOrPath);
45+
if (existsSync(asAbsolute)) {
46+
return asAbsolute;
47+
}
48+
const piDir = join(homedir(), ".pi", nameOrPath);
49+
if (existsSync(piDir)) {
50+
return piDir;
51+
}
52+
throw new Error(`Profile not found: "${nameOrPath}" (tried ${asAbsolute} and ${piDir})`);
53+
}
54+
55+
/** Deep merges src into dst. Source values win on conflict. Arrays are replaced, not concatenated. */
56+
function deepMerge(src: Record<string, unknown>, dst: Record<string, unknown>): Record<string, unknown> {
57+
const result: Record<string, unknown> = { ...dst };
58+
for (const key of Object.keys(src)) {
59+
const srcVal = src[key];
60+
const dstVal = result[key];
61+
if (isPlainObject(srcVal) && isPlainObject(dstVal)) {
62+
result[key] = deepMerge(srcVal as Record<string, unknown>, dstVal as Record<string, unknown>);
63+
} else {
64+
result[key] = srcVal;
65+
}
66+
}
67+
return result;
68+
}
69+
70+
/** Returns true if value is a non-null, non-array plain object. */
71+
function isPlainObject(value: unknown): value is Record<string, unknown> {
72+
return typeof value === "object" && value !== null && !Array.isArray(value);
73+
}
74+
75+
/** Computes a flat diff between the before and after states of a JSON object. */
76+
function diffObjects(before: Record<string, unknown>, after: Record<string, unknown>): DiffEntry[] {
77+
const allKeys = new Set([...Object.keys(before), ...Object.keys(after)]);
78+
const entries: DiffEntry[] = [];
79+
80+
for (const key of [...allKeys].sort()) {
81+
const inBefore = key in before;
82+
const inAfter = key in after;
83+
84+
if (!inBefore && inAfter) {
85+
entries.push({ key, type: "added", srcValue: after[key] });
86+
} else if (inBefore && !inAfter) {
87+
entries.push({ key, type: "removed", dstValue: before[key] });
88+
} else if (JSON.stringify(before[key]) === JSON.stringify(after[key])) {
89+
entries.push({ key, type: "unchanged", srcValue: after[key] });
90+
} else {
91+
entries.push({ key, type: "changed", srcValue: after[key], dstValue: before[key] });
92+
}
93+
}
94+
95+
return entries;
96+
}
97+
98+
/** Reads a JSON file, returning an empty object if it doesn't exist. */
99+
function readJsonFile(filePath: string): Record<string, unknown> {
100+
if (!existsSync(filePath)) {
101+
return {};
102+
}
103+
const content = readFileSync(filePath, "utf-8");
104+
return JSON.parse(content) as Record<string, unknown>;
105+
}
106+
107+
/** Syncs a single JSON file from src to dst using deep merge. */
108+
function syncJsonFile(srcPath: string, dstPath: string, fileName: string, apply: boolean): SyncResult {
109+
const srcData = readJsonFile(srcPath);
110+
const dstData = readJsonFile(dstPath);
111+
112+
if (Object.keys(srcData).length === 0) {
113+
return { file: fileName, diffs: [], written: false };
114+
}
115+
116+
const merged = deepMerge(srcData, dstData);
117+
const diffs = diffObjects(dstData, merged);
118+
let written = false;
119+
120+
if (apply && diffs.some((d) => d.type !== "unchanged")) {
121+
writeFileSync(dstPath, JSON.stringify(merged, null, 2) + "\n", "utf-8");
122+
written = true;
123+
}
124+
125+
return { file: fileName, diffs, written };
126+
}
127+
128+
/** Syncs the extensions/ directory: copies each JSON file from src to dst. */
129+
function syncExtensionsDir(srcDir: string, dstDir: string, apply: boolean): SyncResult[] {
130+
if (!existsSync(srcDir)) {
131+
return [];
132+
}
133+
134+
const files = readdirSync(srcDir).filter((f) => f.endsWith(".json"));
135+
const results: SyncResult[] = [];
136+
137+
for (const file of files) {
138+
const srcPath = join(srcDir, file);
139+
const dstPath = join(dstDir, file);
140+
const srcData = readJsonFile(srcPath);
141+
const dstData = readJsonFile(dstPath);
142+
const isNew = !existsSync(dstPath);
143+
const diffs = diffObjects(dstData, srcData);
144+
let written = false;
145+
146+
if (apply && (isNew || diffs.some((d) => d.type !== "unchanged"))) {
147+
mkdirSync(dstDir, { recursive: true });
148+
writeFileSync(dstPath, JSON.stringify(srcData, null, 2) + "\n", "utf-8");
149+
written = true;
150+
}
151+
152+
results.push({
153+
file: `extensions/${file}${isNew ? " [new]" : ""}`,
154+
diffs,
155+
written,
156+
});
157+
}
158+
159+
return results;
160+
}
161+
162+
// --- Display ---
163+
164+
/** Formats a value for display, truncating long arrays/objects. */
165+
function formatValue(value: unknown): string {
166+
const str = JSON.stringify(value);
167+
if (str.length > 80) {
168+
return str.slice(0, 77) + "...";
169+
}
170+
return str;
171+
}
172+
173+
/** Prints the diff results for a single synced file. */
174+
function printResult(result: SyncResult): void {
175+
const hasChanges = result.diffs.some((d) => d.type !== "unchanged");
176+
if (!hasChanges && result.diffs.length > 0) {
177+
console.log(`\n${result.file}: (no changes)`);
178+
return;
179+
}
180+
if (result.diffs.length === 0) {
181+
console.log(`\n${result.file}: (source file missing, skipped)`);
182+
return;
183+
}
184+
185+
console.log(`\n${result.file}:${result.written ? " ✓ written" : ""}`);
186+
for (const diff of result.diffs) {
187+
switch (diff.type) {
188+
case "added":
189+
console.log(` + ${diff.key}: ${formatValue(diff.srcValue)}`);
190+
break;
191+
case "changed":
192+
console.log(` ~ ${diff.key}: ${formatValue(diff.dstValue)}${formatValue(diff.srcValue)}`);
193+
break;
194+
case "removed":
195+
console.log(` - ${diff.key}: ${formatValue(diff.dstValue)}`);
196+
break;
197+
case "unchanged":
198+
console.log(` = ${diff.key}: ${formatValue(diff.srcValue)}`);
199+
break;
200+
}
201+
}
202+
}
203+
204+
// --- Main ---
205+
206+
function main(): void {
207+
const args = process.argv.slice(2);
208+
const apply = args.includes("--apply");
209+
const positional = args.filter((a) => a !== "--apply");
210+
211+
if (positional.length !== 2) {
212+
console.error("Usage: sync-profiles.ts <src> <dst> [--apply]");
213+
console.error("Example: sync-profiles.ts agent-personal agent-work --apply");
214+
process.exit(1);
215+
}
216+
217+
const srcDir = resolveProfilePath(positional[0]);
218+
const dstDir = resolveProfilePath(positional[1]);
219+
220+
console.log(`Source: ${srcDir}`);
221+
console.log(`Destination: ${dstDir}`);
222+
console.log(`Mode: ${apply ? "APPLY" : "DRY RUN"}`);
223+
224+
const results: SyncResult[] = [
225+
syncJsonFile(join(srcDir, "settings.json"), join(dstDir, "settings.json"), "settings.json", apply),
226+
syncJsonFile(join(srcDir, "keybindings.json"), join(dstDir, "keybindings.json"), "keybindings.json", apply),
227+
...syncExtensionsDir(join(srcDir, "extensions"), join(dstDir, "extensions"), apply),
228+
];
229+
230+
for (const result of results) {
231+
printResult(result);
232+
}
233+
234+
const changeCount = results.reduce((sum, r) => sum + r.diffs.filter((d) => d.type !== "unchanged").length, 0);
235+
236+
console.log(`\n--- ${changeCount} change(s) across ${results.length} file(s) ---`);
237+
if (!apply && changeCount > 0) {
238+
console.log("Run with --apply to write changes.");
239+
}
240+
}
241+
242+
try {
243+
main();
244+
} catch (error) {
245+
console.error(`Error: ${error instanceof Error ? error.message : error}`);
246+
process.exit(1);
247+
}

0 commit comments

Comments
 (0)