@codedealer/pi-sync 1.0.0 → 1.4.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/src/cli.js CHANGED
@@ -24,6 +24,7 @@ const RESOURCE_RULES = {
24
24
  };
25
25
  const RESOURCE_TARGET_ROOTS = Object.values(RESOURCE_RULES).map((rule) => `${rule.targetRoot}/`);
26
26
  const BUNDLED_SKILL = fileURLToPath(new URL('../resources/skills/pi-sync', import.meta.url));
27
+ const BUNDLED_EXTENSION = fileURLToPath(new URL('../resources/extensions/pi-sync', import.meta.url));
27
28
 
28
29
  function fail(error, code = VALIDATION_ERROR) {
29
30
  const command = process.argv[2] ?? null;
@@ -157,7 +158,8 @@ const SECRET_PATTERNS = [
157
158
  ['GitHub token', /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\b/],
158
159
  ['AWS access key', /\bAKIA[0-9A-Z]{16}\b/],
159
160
  ['bearer token', /\bBearer\s+[A-Za-z0-9._~+/-]{20,}/i],
160
- ['credential assignment', /(?:api[_-]?key|access[_-]?token|client[_-]?secret|password)\s*[:=]\s*["']?(?!your[_ -]|example|changeme|placeholder)[A-Za-z0-9_./+=-]{12,}/i],
161
+ // Keep the __placeholder__ exclusion in sync with SAFE_PLACEHOLDER below.
162
+ ['credential assignment', /(?:api[_-]?key|access[_-]?token|client[_-]?secret|password)\s*[:=]\s*["']?(?!your[_ -]|example|changeme|placeholder|__placeholder__)[A-Za-z0-9_./+=-]{12,}/i],
161
163
  ];
162
164
 
163
165
  function findLikelySecretsInText(text, relative) {
@@ -203,14 +205,31 @@ async function findLikelySecrets(root, relative) {
203
205
  return findings;
204
206
  }
205
207
 
208
+ const ENV_REFERENCE = /^\$[A-Za-z_][A-Za-z0-9_]*$|^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/;
209
+ // Safe literal for credential-like keys of providers that require no real API key
210
+ // (Pi's documented way to satisfy the mandatory apiKey field for local endpoints).
211
+ const SAFE_PLACEHOLDER = '__PLACEHOLDER__';
212
+ // LLM usage/limit parameters that contain "token" but never carry credentials.
213
+ const NON_CREDENTIAL_TOKEN_KEYS = new Set(['tokens', 'maxtokens', 'mintokens', 'tokencount', 'tokenlimit', 'numtokens']);
214
+
215
+ function isCredentialLikeKey(normalized) {
216
+ if (['apikey', 'password', 'passwd', 'secret', 'credential', 'privatekey'].some((part) => normalized.includes(part))) return true;
217
+ if (normalized.includes('token')) return !NON_CREDENTIAL_TOKEN_KEYS.has(normalized);
218
+ if (normalized.includes('auth')) return !normalized.startsWith('author');
219
+ return false;
220
+ }
221
+
206
222
  function validatePortableSettingValue(value, label) {
207
223
  if (Array.isArray(value)) {
208
224
  value.forEach((item, index) => validatePortableSettingValue(item, `${label}[${index}]`));
209
225
  } else if (isRecord(value)) {
210
226
  for (const [key, nested] of Object.entries(value)) {
211
227
  const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, '');
212
- if (/(?:apikey|token|password|secret|credential|auth)/.test(normalized)) {
213
- throw new Error(`${label} contains a credential-like key (${key})`);
228
+ if (isCredentialLikeKey(normalized)) {
229
+ if (typeof nested !== 'string' || !(nested === SAFE_PLACEHOLDER || ENV_REFERENCE.test(nested))) {
230
+ throw new Error(`${label} contains a credential-like key (${key}); only a $NAME or ${'$'}{NAME} environment reference or the ${SAFE_PLACEHOLDER} placeholder may be managed`);
231
+ }
232
+ continue;
214
233
  }
215
234
  validatePortableSettingValue(nested, `${label}.${key}`);
216
235
  }
@@ -303,7 +322,7 @@ async function validateRepository(repo, { allowMissingPins = false } = {}) {
303
322
  const segments = decodePointer(field.path, `${fieldLabel}.path`);
304
323
  validatePortableSettingValue(field.value, `${fieldLabel}.value`);
305
324
  for (const segment of segments) {
306
- if (/(?:apikey|token|password|secret|credential|auth)/.test(segment.toLowerCase().replace(/[^a-z0-9]/g, ''))) {
325
+ if (isCredentialLikeKey(segment.toLowerCase().replace(/[^a-z0-9]/g, ''))) {
307
326
  throw new Error(`${fieldLabel}.path contains a credential-like key`);
308
327
  }
309
328
  }
@@ -888,7 +907,7 @@ function summarizeActions(actions) {
888
907
  }
889
908
 
890
909
  async function readSettingsFile(file) {
891
- try {
910
+ const readOnce = async () => {
892
911
  const info = await fs.lstat(file);
893
912
  if (info.isSymbolicLink() || !info.isFile()) throw new Error(`Managed settings target must be a regular file: ${file}`);
894
913
  const text = await fs.readFile(file, 'utf8');
@@ -896,10 +915,22 @@ async function readSettingsFile(file) {
896
915
  try { value = JSON.parse(text); } catch { throw new Error(`Managed settings file is not valid JSON: ${file}`); }
897
916
  if (!isRecord(value)) throw new Error(`Managed settings file must contain a JSON object: ${file}`);
898
917
  return { value, text, exists: true };
899
- } catch (error) {
900
- if (error.code === 'ENOENT') return { value: {}, text: null, exists: false };
901
- throw error;
918
+ };
919
+ let error;
920
+ for (let attempt = 1; attempt <= 2; attempt++) {
921
+ try {
922
+ return await readOnce();
923
+ } catch (readError) {
924
+ if (readError.code === 'ENOENT') return { value: {}, text: null, exists: false };
925
+ error = readError;
926
+ // Pi persists settings with an in-place write, so a concurrent read can
927
+ // catch a partially written file for a few milliseconds. Retry once so a
928
+ // transient torn read does not fail the whole operation; a file that is
929
+ // genuinely invalid still fails on the second attempt.
930
+ if (attempt === 1) await new Promise((resolve) => setTimeout(resolve, 50));
931
+ }
902
932
  }
933
+ throw error;
903
934
  }
904
935
 
905
936
  async function buildPlan(repo, agentDir, declarations, state) {
@@ -952,6 +983,105 @@ async function buildPlan(repo, agentDir, declarations, state) {
952
983
  return { resources, settings, stale };
953
984
  }
954
985
 
986
+ // Read-only sync classification shared by the `sync` preview/apply and the
987
+ // status/plan `sync` block, so every consumer sees identical content:
988
+ // syncable fields (local-only and portable, with from/to), conflicts (changed
989
+ // in both places or without a baseline), rejected (non-portable local
990
+ // values), and review (locally removed managed fields). No manifest write,
991
+ // no re-baseline, no lock.
992
+ function syncClassification(plan) {
993
+ const syncable = [];
994
+ const conflicts = [];
995
+ const rejected = [];
996
+ const review = [];
997
+ for (const item of plan.settings) {
998
+ for (const field of item.fields) {
999
+ if (field.action === 'local-divergence') {
1000
+ if (!field.local.exists) {
1001
+ review.push({ target: item.target, path: field.pointer, reason: 'managed setting was removed locally; update the setup manifest manually' });
1002
+ continue;
1003
+ }
1004
+ try {
1005
+ validatePortableSettingValue(field.local.value, `${item.target} ${field.pointer}`);
1006
+ syncable.push({ target: item.target, path: field.pointer, from: field.value, to: field.local.value });
1007
+ } catch (error) {
1008
+ rejected.push({ target: item.target, path: field.pointer, reason: error.message });
1009
+ }
1010
+ } else if (field.action === 'conflict' || field.action === 'collision') {
1011
+ conflicts.push({ target: item.target, path: field.pointer, action: field.action, ...(field.reason ? { reason: field.reason } : {}) });
1012
+ }
1013
+ }
1014
+ }
1015
+ return { syncable, conflicts, rejected, review };
1016
+ }
1017
+
1018
+ // Back-syncs local-only managed setting changes into the setup manifest.
1019
+ // A field is eligible only when the three-way comparison says the repository
1020
+ // is unchanged and the local installation diverged from the last applied
1021
+ // baseline. Conflicts, collisions (no baseline), and locally removed fields
1022
+ // are reported for manual review and never written. The manifest is the only
1023
+ // file this command modifies; it is rewritten with the same atomic
1024
+ // read-check-rename pattern as add/remove and re-validated after the write.
1025
+ async function syncSettings(repo, agentDir, declarations, state, applyChanges) {
1026
+ const plan = await buildPlan(repo, agentDir, declarations, state);
1027
+ const { syncable, conflicts, rejected, review } = syncClassification(plan);
1028
+
1029
+ if (!applyChanges) {
1030
+ return { apiVersion: 1, command: 'sync', ok: true, exitCode: 0, repository: repo, agentDirectory: agentDir, mode: 'preview',
1031
+ syncable, conflicts, rejected, review,
1032
+ note: syncable.length
1033
+ ? 'Preview only: run `pi-sync sync --apply` to write the local values into the setup manifest. Pushing the change still requires an explicit `pi-sync push`.'
1034
+ : 'Nothing to sync: every managed setting field matches the setup manifest locally.' };
1035
+ }
1036
+
1037
+ const output = { apiVersion: 1, command: 'sync', ok: true, exitCode: 0, repository: repo, agentDirectory: agentDir, mode: 'applied',
1038
+ synced: [], conflicts, rejected, review };
1039
+ if (!syncable.length) {
1040
+ output.note = conflicts.length || rejected.length || review.length
1041
+ ? 'No eligible local-only changes to write; the reported fields need manual review.'
1042
+ : 'Nothing to sync: every managed setting field matches the setup manifest locally.';
1043
+ if (conflicts.length || rejected.length || review.length) { output.ok = false; output.exitCode = OPERATION_ERROR; }
1044
+ return output;
1045
+ }
1046
+
1047
+ const manifestPath = path.join(repo, MANIFEST_FILE);
1048
+ const manifestText = await fs.readFile(manifestPath, 'utf8');
1049
+ const manifest = JSON.parse(manifestText);
1050
+ const nextManifest = structuredClone(manifest);
1051
+ for (const change of syncable) {
1052
+ const field = nextManifest.settings?.find((item) => item.target === change.target)?.fields?.find((item) => item.path === change.path);
1053
+ if (!field) throw new Error(`Manifest no longer declares ${change.target} ${change.path}; re-run sync`);
1054
+ field.value = change.to;
1055
+ }
1056
+ const findings = findLikelySecretsInText(`${JSON.stringify(nextManifest, null, 2)}\n`, MANIFEST_FILE);
1057
+ if (findings.length) {
1058
+ const error = new Error('Synced local setting values contain suspected credential material; nothing was written');
1059
+ error.errorCode = 'SYNC_BLOCKED';
1060
+ error.details = { reviewRequired: findings };
1061
+ throw error;
1062
+ }
1063
+ await writeJsonFileAtomic(manifestPath, nextManifest, manifestText);
1064
+ try {
1065
+ await validateRepository(repo);
1066
+ } catch (error) {
1067
+ await restoreFileAtomic(manifestPath, manifestText);
1068
+ throw error;
1069
+ }
1070
+ // Re-baseline the synced fields so a later local change is compared against
1071
+ // the value this sync just published, not against the pre-sync baseline
1072
+ // (which would misclassify the next local-only change as a both-sides conflict).
1073
+ const mutableState = structuredClone(state);
1074
+ for (const change of syncable) {
1075
+ mutableState.settings[change.target] ??= {};
1076
+ mutableState.settings[change.target][change.path] = { exists: true, value: change.to };
1077
+ }
1078
+ await writeState(agentDir, mutableState);
1079
+ output.synced = syncable;
1080
+ output.note = 'Local values were written to the setup manifest. The setup repository is not committed or pushed; publish with an explicit `pi-sync push`.';
1081
+ if (conflicts.length || rejected.length || review.length) { output.ok = false; output.exitCode = OPERATION_ERROR; }
1082
+ return output;
1083
+ }
1084
+
955
1085
  function outputAction(item, key) {
956
1086
  return { type: item.type ?? 'setting', [key]: item[key], action: item.action, ...(item.reason ? { reason: item.reason } : {}),
957
1087
  ...(item.files?.length ? { files: item.files.map(({ path: file, action, reason }) => ({ path: file, action, ...(reason ? { reason } : {}) })) } : {}) };
@@ -1348,31 +1478,42 @@ function bootstrapResponse(ok, errorCode, error, extra = {}) {
1348
1478
  return { apiVersion: 1, command: 'bootstrap', ok, errorCode, error, exitCode: ok ? 0 : OPERATION_ERROR, ...extra };
1349
1479
  }
1350
1480
 
1351
- async function installBundledSkill(repo) {
1481
+ async function installBundledResources(repo) {
1352
1482
  const manifestPath = path.join(repo, MANIFEST_FILE);
1353
1483
  const manifestText = await fs.readFile(manifestPath, 'utf8');
1354
1484
  const manifest = JSON.parse(manifestText);
1355
1485
  const declarations = await validateRepository(repo);
1356
- const source = 'resources/skills/pi-sync';
1357
- const target = 'skills/pi-sync';
1358
- const exact = declarations.resources.some((item) => item.source === source && item.target === target);
1359
- if (exact) return { ok: true, added: false };
1360
-
1361
- const collision = declarations.resources.find((item) => pathsOverlap(item.target, target) || pathsOverlap(item.source, source));
1362
- if (collision) return { ok: false, collision: { source: collision.source, target: collision.target } };
1363
- const sourcePath = path.join(repo, ...source.split('/'));
1364
- const existingSource = await fs.lstat(sourcePath).catch((error) => error.code === 'ENOENT' ? null : Promise.reject(error));
1365
- if (existingSource) return { ok: false, collision: { source, target, reason: 'setup repository source path already exists and is not declared as this resource' } };
1366
-
1367
- const bundled = await fs.lstat(BUNDLED_SKILL).catch((error) => { throw new Error(`Bundled pi-sync skill is unavailable: ${error.message}`); });
1368
- if (bundled.isSymbolicLink() || !bundled.isDirectory()) throw new Error('Bundled pi-sync skill is not a real directory');
1369
- await ensureRealDirectory(repo, 'resources/skills', true);
1370
- await fs.mkdir(sourcePath);
1486
+ const bundled = [
1487
+ { type: 'skill', source: 'resources/skills/pi-sync', target: 'skills/pi-sync', root: BUNDLED_SKILL },
1488
+ { type: 'extension', source: 'resources/extensions/pi-sync', target: 'extensions/pi-sync', root: BUNDLED_EXTENSION },
1489
+ ];
1490
+ // Fail before materializing anything: a collision on any bundled resource
1491
+ // must not leave partially declared repository content behind.
1492
+ const toAdd = [];
1493
+ for (const item of bundled) {
1494
+ if (declarations.resources.some((resource) => resource.source === item.source && resource.target === item.target)) continue;
1495
+ const collision = declarations.resources.find((resource) => pathsOverlap(resource.target, item.target) || pathsOverlap(resource.source, item.source));
1496
+ if (collision) return { ok: false, collision: { source: collision.source, target: collision.target, bundled: { type: item.type, source: item.source, target: item.target } } };
1497
+ const sourcePath = path.join(repo, ...item.source.split('/'));
1498
+ const existingSource = await fs.lstat(sourcePath).catch((error) => error.code === 'ENOENT' ? null : Promise.reject(error));
1499
+ if (existingSource) return { ok: false, collision: { source: item.source, target: item.target, bundled: { type: item.type, source: item.source, target: item.target }, reason: 'setup repository source path already exists and is not declared as this resource' } };
1500
+ const info = await fs.lstat(item.root).catch((error) => { throw new Error(`Bundled pi-sync ${item.type} is unavailable: ${error.message}`); });
1501
+ if (info.isSymbolicLink() || !info.isDirectory()) throw new Error(`Bundled pi-sync ${item.type} is not a real directory`);
1502
+ toAdd.push(item);
1503
+ }
1504
+ if (!toAdd.length) return { ok: true, added: false, addedResources: [] };
1505
+ const created = [];
1371
1506
  try {
1372
- for (const entry of await fs.readdir(BUNDLED_SKILL)) {
1373
- await fs.cp(path.join(BUNDLED_SKILL, entry), path.join(sourcePath, entry), { recursive: true, errorOnExist: true, force: false });
1507
+ for (const item of toAdd) {
1508
+ const sourcePath = path.join(repo, ...item.source.split('/'));
1509
+ await ensureRealDirectory(repo, path.posix.dirname(item.source), true);
1510
+ await fs.mkdir(sourcePath);
1511
+ created.push(sourcePath);
1512
+ for (const entry of await fs.readdir(item.root)) {
1513
+ await fs.cp(path.join(item.root, entry), path.join(sourcePath, entry), { recursive: true, errorOnExist: true, force: false });
1514
+ }
1374
1515
  }
1375
- const nextManifest = { ...manifest, resources: [...manifest.resources, { type: 'skill', source, target }] };
1516
+ const nextManifest = { ...manifest, resources: [...manifest.resources, ...toAdd.map(({ type, source, target }) => ({ type, source, target }))] };
1376
1517
  const temporary = `${manifestPath}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
1377
1518
  try {
1378
1519
  const info = await fs.stat(manifestPath);
@@ -1384,11 +1525,11 @@ async function installBundledSkill(repo) {
1384
1525
  throw error;
1385
1526
  }
1386
1527
  } catch (error) {
1387
- await fs.rm(sourcePath, { recursive: true, force: true }).catch(() => {});
1528
+ for (const directory of created) await fs.rm(directory, { recursive: true, force: true }).catch(() => {});
1388
1529
  throw error;
1389
1530
  }
1390
1531
  await validateRepository(repo);
1391
- return { ok: true, added: true };
1532
+ return { ok: true, added: true, addedResources: toAdd.map(({ type, source, target }) => ({ type, source, target })) };
1392
1533
  }
1393
1534
 
1394
1535
  async function runPiSync(command, repo, agentDir) {
@@ -1466,16 +1607,20 @@ async function bootstrap(repoUrl, repoDir, agentDir, binDir) {
1466
1607
  missingPrerequisites: prerequisites.tools.filter((tool) => tool.status === 'failed'),
1467
1608
  });
1468
1609
 
1469
- const declaration = await installBundledSkill(checkout);
1470
- if (!declaration.ok) return bootstrapResponse(false, 'RESOURCE_COLLISION', 'The bundled pi-sync skill conflicts with existing setup repository content; no installation was performed', {
1471
- repository: destination, collision: declaration.collision, skill: { source: 'resources/skills/pi-sync', target: 'skills/pi-sync' },
1610
+ const declaration = await installBundledResources(checkout);
1611
+ if (!declaration.ok) return bootstrapResponse(false, 'RESOURCE_COLLISION', 'A bundled pi-sync resource conflicts with existing setup repository content; no installation was performed', {
1612
+ repository: destination, collision: declaration.collision,
1472
1613
  });
1473
1614
 
1474
1615
  await validateRepository(checkout);
1475
1616
  const plan = await runPiSync('plan', checkout, path.resolve(agentDir));
1476
1617
  if (plan.status !== 0 || !plan.output.ok) {
1477
1618
  return bootstrapResponse(false, 'APPLY_CONFLICT', 'Bootstrap plan found a collision or conflict; existing local content was left untouched', {
1478
- repository: destination, skill: { source: 'resources/skills/pi-sync', target: 'skills/pi-sync' }, plan: plan.output,
1619
+ repository: destination,
1620
+ bundledResources: [
1621
+ { type: 'skill', source: 'resources/skills/pi-sync', target: 'skills/pi-sync' },
1622
+ { type: 'extension', source: 'resources/extensions/pi-sync', target: 'extensions/pi-sync' },
1623
+ ], plan: plan.output,
1479
1624
  });
1480
1625
  }
1481
1626
  if (await fs.lstat(destination).catch((error) => error.code === 'ENOENT' ? null : Promise.reject(error))) {
@@ -1486,7 +1631,10 @@ async function bootstrap(repoUrl, repoDir, agentDir, binDir) {
1486
1631
  return bootstrapResponse(applied.status === 0 && applied.output.ok, applied.status === 0 && applied.output.ok ? undefined : 'APPLY_CONFLICT',
1487
1632
  applied.status === 0 && applied.output.ok ? undefined : 'Bootstrap apply did not complete cleanly; inspect the structured apply result and resume after resolving it', {
1488
1633
  repository: destination, authenticationMethod, prerequisites: prerequisites.tools,
1489
- skill: { source: 'resources/skills/pi-sync', target: 'skills/pi-sync', action: declaration.added ? 'declared-and-applied' : 'applied' },
1634
+ bundledResources: [
1635
+ { type: 'skill', source: 'resources/skills/pi-sync', target: 'skills/pi-sync' },
1636
+ { type: 'extension', source: 'resources/extensions/pi-sync', target: 'extensions/pi-sync' },
1637
+ ].map((item) => ({ ...item, action: declaration.addedResources.some((added) => added.source === item.source) ? 'declared-and-applied' : 'applied' })),
1490
1638
  apply: applied.output,
1491
1639
  });
1492
1640
  } finally {
@@ -1517,6 +1665,51 @@ function managedGitPath(relative, resourceSources) {
1517
1665
  return relative === MANIFEST_FILE || relative === LOCK_FILE || resourceSources.some((source) => relative === source || relative.startsWith(`${source}/`));
1518
1666
  }
1519
1667
 
1668
+ // Read-only publish state of the setup repository relative to its upstream,
1669
+ // computed with the same git primitives the managed push uses: working-tree
1670
+ // dirt from `git status --porcelain --untracked-files=no` (untracked files do
1671
+ // not count: managed-only push never stages them, so they are not
1672
+ // publishable changes) and ahead/behind from
1673
+ // `git rev-list --left-right --count HEAD...@{upstream}`. Returns null when
1674
+ // the setup directory is not the root of a git working tree (status still
1675
+ // works in that case for manifest validation), and null ahead/behind when no
1676
+ // upstream can be resolved (no remote, no upstream configured, detached HEAD,
1677
+ // or an upstream ref that cannot be compared, such as an unfetched one).
1678
+ async function repositoryPublishState(repo) {
1679
+ const top = await gitResult(repo, ['rev-parse', '--show-toplevel']);
1680
+ if (top.status !== 0) return null;
1681
+ if (path.resolve(top.output) !== path.resolve(repo)) return null;
1682
+ const dirty = await gitResult(repo, ['status', '--porcelain', '--untracked-files=no']);
1683
+ if (dirty.status !== 0) throw new Error(`Could not inspect the setup repository working tree: ${dirty.errorText || `git exited ${dirty.status}`}`);
1684
+ let ahead = null;
1685
+ let behind = null;
1686
+ const upstream = await gitResult(repo, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}']);
1687
+ if (upstream.status === 0) {
1688
+ const counts = await gitResult(repo, ['rev-list', '--left-right', '--count', `HEAD...${upstream.output}`]);
1689
+ if (counts.status === 0) {
1690
+ const [left, right] = counts.output.split(/\s+/).map(Number);
1691
+ if (Number.isInteger(left) && Number.isInteger(right)) { ahead = left; behind = right; }
1692
+ }
1693
+ }
1694
+ return { dirty: dirty.output.length > 0, ahead, behind };
1695
+ }
1696
+
1697
+ // Derives the footer-ready summary from the reporting blocks. Counts follow
1698
+ // the two-stage pipeline with no double counting (a change always lives in
1699
+ // exactly one stage): syncable fields, working-tree dirt, and ahead commits
1700
+ // are local-pending; behind commits and unapplied install/update actions are
1701
+ // remote-pending; conflict/collision actions, rejected and locally removed
1702
+ // fields, and locally diverged resources need review. syncable counts fields
1703
+ // (the sync unit); review counts manifest items (actions).
1704
+ function deriveStatusSummary(publishState, sync, conflicts, actions) {
1705
+ const localPending = sync.syncable.length + (publishState ? (publishState.dirty ? 1 : 0) + (publishState.ahead ?? 0) : 0);
1706
+ const remotePending = (publishState?.behind ?? 0) + actions.filter((item) => item.action === 'install' || item.action === 'update').length;
1707
+ const needsReview = conflicts.length + sync.rejected.length + sync.review.length +
1708
+ actions.filter((item) => item.type !== 'setting' && item.action === 'local-divergence').length;
1709
+ const clean = localPending === 0 && remotePending === 0 && needsReview === 0;
1710
+ return { localPending, remotePending, needsReview, clean };
1711
+ }
1712
+
1520
1713
  // Returns the resource sources declared in the setup manifest at the given
1521
1714
  // commit, or an empty list when that commit predates the manifest. Malformed
1522
1715
  // manifests at ahead commits fail closed so they are reconciled manually.
@@ -1762,8 +1955,8 @@ async function managedPush(repo, declarations, publish = false) {
1762
1955
 
1763
1956
  async function main() {
1764
1957
  const command = process.argv[2];
1765
- if (!['status', 'plan', 'apply', 'add', 'remove', 'prune', 'bootstrap', 'update', 'push'].includes(command)) {
1766
- throw new Error('Usage: pi-sync <status|plan|apply|add|remove|prune|update|push|bootstrap> [--repo PATH] [--repo-url URL --repo-dir PATH] [--agent-dir PATH] [--type skill|extension|prompt|agent|role|dependency --target VALUE | --source VALUE] [--confirm] [--approve] [--apply] [--push] [--publish] [--uninstall]');
1958
+ if (!['status', 'plan', 'apply', 'add', 'remove', 'prune', 'bootstrap', 'update', 'push', 'sync'].includes(command)) {
1959
+ throw new Error('Usage: pi-sync <status|plan|apply|add|remove|prune|update|push|bootstrap|sync> [--repo PATH] [--repo-url URL --repo-dir PATH] [--agent-dir PATH] [--type skill|extension|prompt|agent|role|dependency --target VALUE | --source VALUE] [--confirm] [--approve] [--apply] [--push] [--publish] [--uninstall]');
1767
1960
  }
1768
1961
  const options = { repo: process.cwd(), repoDir: path.join(os.homedir(), '.pi-sync', 'setup'), agentDir: process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), '.pi', 'agent'), binDir: path.join(os.homedir(), '.local', 'bin'), confirm: false, approve: false, applyUpdate: false, pushUpdate: false, publish: false, uninstall: false };
1769
1962
  for (let i = 3; i < process.argv.length; i++) {
@@ -1789,7 +1982,7 @@ async function main() {
1789
1982
  }
1790
1983
  if (options.confirm && command !== 'prune') throw new Error('--confirm is only supported by prune');
1791
1984
  if (options.approve && !['apply', 'update'].includes(command)) throw new Error('--approve is only supported by apply or update');
1792
- if (options.applyUpdate && command !== 'update') throw new Error('--apply is only supported by update');
1985
+ if (options.applyUpdate && !['update', 'sync'].includes(command)) throw new Error('--apply is only supported by update or sync');
1793
1986
  if (options.pushUpdate && command !== 'update') throw new Error('--push is only supported by update');
1794
1987
  if (options.pushUpdate && !options.applyUpdate) throw new Error('update --push requires --apply so package updates are explicitly approved');
1795
1988
  if (options.publish && command !== 'push') throw new Error('--publish is only supported by push');
@@ -1904,6 +2097,21 @@ async function main() {
1904
2097
  } finally { await releasePackageLock(); }
1905
2098
  return;
1906
2099
  }
2100
+ if (command === 'sync') {
2101
+ if (!agentInfo) throw new Error(`Pi agent directory does not exist: ${agentDir}`);
2102
+ // --apply writes the manifest and re-baselines synced fields, so it takes
2103
+ // the same agent lock as apply to serialize state.json updates.
2104
+ const releaseSyncLock = options.applyUpdate ? await acquireLock(agentDir) : null;
2105
+ try {
2106
+ const state = await loadState(agentDir);
2107
+ const output = await syncSettings(repo, agentDir, declarations, state, options.applyUpdate);
2108
+ console.log(JSON.stringify(output, null, 2));
2109
+ if (!output.ok) process.exitCode = output.exitCode;
2110
+ } finally {
2111
+ if (releaseSyncLock) await releaseSyncLock();
2112
+ }
2113
+ return;
2114
+ }
1907
2115
  if (command === 'apply' && !agentInfo) {
1908
2116
  await fs.mkdir(agentDir, { recursive: true });
1909
2117
  agentInfo = await fs.lstat(agentDir);
@@ -1937,8 +2145,25 @@ async function main() {
1937
2145
  const conflicts = actions.filter((item) => ['collision', 'conflict'].includes(item.action) || item.fields?.some((field) => ['collision', 'conflict'].includes(field.action)));
1938
2146
  const staleResources = plan.stale.map(({ target, state: staleState, localSnapshot, overlaps }) => ({ target, state: staleState,
1939
2147
  installed: localSnapshot !== null, pruneAvailable: overlaps.length === 0, ...(overlaps.length ? { overlaps } : {}) }));
2148
+ // status and plan are read-only reporting commands: they gain the
2149
+ // repository publish-state block (omitted when the setup directory is not
2150
+ // a git working tree), the read-only sync classification (identical
2151
+ // content to a `sync` preview), and the derived summary. apply and prune
2152
+ // keep their existing contracts and take the agent lock as before; these
2153
+ // blocks never write and take no lock.
2154
+ const reporting = command === 'status' || command === 'plan';
2155
+ let publishState;
2156
+ let sync;
2157
+ let summary;
2158
+ if (reporting) {
2159
+ publishState = await repositoryPublishState(repo);
2160
+ sync = syncClassification(plan);
2161
+ summary = deriveStatusSummary(publishState, sync, conflicts, actions);
2162
+ }
1940
2163
  const output = { apiVersion: 1, command, ok: conflicts.length === 0, exitCode: conflicts.length ? OPERATION_ERROR : 0,
1941
- repository: repo, agentDirectory: agentDir, actions, conflicts, staleResources,
2164
+ ...(reporting ? (publishState ? { repository: publishState } : {}) : { repository: repo }),
2165
+ agentDirectory: agentDir, actions, conflicts, staleResources,
2166
+ ...(reporting ? { sync, summary } : {}),
1942
2167
  packages: declarations.lock.packages.map((item) => ({ package: packageIdentity(item), pin: packageRevision(item), action: command === 'apply' ? 'pinned-install' : 'locked' })),
1943
2168
  dependencyModel: 'exact top-level pins; Pi controls transitive dependencies, which Pi Sync does not lock or claim reproducible' };
1944
2169
 
@@ -2024,4 +2249,4 @@ async function main() {
2024
2249
  }
2025
2250
  }
2026
2251
 
2027
- main().catch((error) => fail(error, ['PACKAGE_INSTALL_FAILED', 'APPROVAL_REQUIRED', 'UPDATE_TRANSACTION_FAILED', 'PUSH_BLOCKED'].includes(error?.errorCode) || /update stopped closed|^(Another Pi Sync|A stale Pi Sync)/.test(error.message) ? OPERATION_ERROR : VALIDATION_ERROR));
2252
+ main().catch((error) => fail(error, ['PACKAGE_INSTALL_FAILED', 'APPROVAL_REQUIRED', 'UPDATE_TRANSACTION_FAILED', 'PUSH_BLOCKED', 'SYNC_BLOCKED'].includes(error?.errorCode) || /update stopped closed|^(Another Pi Sync|A stale Pi Sync)/.test(error.message) ? OPERATION_ERROR : VALIDATION_ERROR));