takomi 2.1.34 → 2.1.36

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
@@ -23,6 +23,7 @@ import {
23
23
  downloadDirectoryFromGitHub,
24
24
  } from './utils.js';
25
25
  import {
26
+ HARNESS_MAP,
26
27
  detectHarnesses,
27
28
  printHarnessStatus,
28
29
  syncToAllHarnesses,
@@ -393,6 +394,43 @@ async function promptSkillsInstallSelection() {
393
394
  return { mode: response.mode, selectedSkills };
394
395
  }
395
396
 
397
+ function normalizeHarnessSyncMode(value) {
398
+ return ['copy', 'symlink', 'auto'].includes(value) ? value : 'copy';
399
+ }
400
+
401
+ async function promptHarnessSyncMode(initial = 'auto') {
402
+ const normalizedInitial = normalizeHarnessSyncMode(initial);
403
+ const choices = [
404
+ { title: 'Auto (try symlink, copy if blocked)', value: 'auto', description: 'Best fallback when Windows permissions or filesystems block symlinks.' },
405
+ { title: 'Symlink / Junction (single source of truth)', value: 'symlink', description: 'Each managed skill/workflow points back to ~/.takomi; edits update one canonical copy.' },
406
+ { title: 'Copy (independent files)', value: 'copy', description: 'Most compatible; changes do not flow back to ~/.takomi.' },
407
+ ];
408
+ const response = await prompts({
409
+ type: 'select',
410
+ name: 'syncMode',
411
+ message: 'How should Takomi sync store resources into harness folders?',
412
+ choices,
413
+ initial: Math.max(0, choices.findIndex((choice) => choice.value === normalizedInitial)),
414
+ });
415
+ if (!response.syncMode) return null;
416
+ return normalizeHarnessSyncMode(response.syncMode);
417
+ }
418
+
419
+ function collectSyncFailures(syncSummary) {
420
+ return Object.entries(syncSummary || {})
421
+ .filter(([, result]) => result?.ok === false)
422
+ .map(([id, result]) => ({ id, errors: result.errors?.length ? result.errors : ['unknown sync error'] }));
423
+ }
424
+
425
+ function printSyncFailures(failures) {
426
+ if (!failures.length) return;
427
+ console.log(pc.yellow('\nSome harnesses did not fully sync:'));
428
+ for (const failure of failures) {
429
+ console.log(pc.dim(` - ${failure.id}: ${failure.errors.join('; ')}`));
430
+ }
431
+ process.exitCode = 1;
432
+ }
433
+
396
434
  async function installSkillsTarget() {
397
435
  console.log(pc.magenta('🧰 Takomi Skills Install\n'));
398
436
  try {
@@ -411,7 +449,7 @@ async function installSkillsTarget() {
411
449
  const result = await installBundledSkills(program.version(), selection);
412
450
  const validation = await validateSkillsInstall(selection.selectedSkills);
413
451
  printSkillsInstallSummary(result, validation);
414
- console.log(pc.dim('\nGlobal skills are ready for Pi or other supported harnesses.\n'));
452
+ console.log(pc.dim('\nShared skills target is ready. For per-harness global paths, run "takomi setup" or "takomi sync".\n'));
415
453
  } catch (error) {
416
454
  console.log(pc.red('\nSkills install failed.'));
417
455
  console.log(pc.dim(String(error?.message || error)));
@@ -622,7 +660,7 @@ async function install(target) {
622
660
 
623
661
  if (detected.length === 0) {
624
662
  console.log(pc.yellow(' No AI harnesses detected on this machine.'));
625
- console.log(pc.dim(' We support: Antigravity, KiloCode, Windsurf, Codex, Cursor, Gemini CLI'));
663
+ console.log(pc.dim(` We support: ${Object.values(HARNESS_MAP).map(h => h.name).join(', ')}`));
626
664
  console.log(pc.dim(' Run "takomi init" instead for per-project setup.\n'));
627
665
  return;
628
666
  }
@@ -715,6 +753,11 @@ async function install(target) {
715
753
 
716
754
  if (!contentResponse.content) return;
717
755
 
756
+ const syncMode = await promptHarnessSyncMode(
757
+ (hasExistingStoreSkills || hasExistingStoreWorkflows) ? existingStoreManifest.syncMode : 'auto'
758
+ );
759
+ if (!syncMode) return;
760
+
718
761
  try {
719
762
  // 4. Initialize global store
720
763
  console.log(pc.cyan(`\n📦 Creating global store (${STORE_PATH})...\n`));
@@ -802,11 +845,15 @@ async function install(target) {
802
845
  const syncSummary = await syncToAllHarnesses(selectedHarnesses, STORE_PATH, {
803
846
  useOwnership: true,
804
847
  owned: manifest.harnessOwned,
848
+ linkMode: syncMode,
805
849
  });
806
850
 
807
851
  // 7. Update manifest
852
+ const syncFailures = collectSyncFailures(syncSummary);
853
+ const successfulHarnesses = selectedHarnesses.filter(h => !syncFailures.some(failure => failure.id === h.id));
808
854
  manifest = await getManifest();
809
- manifest.linkedHarnesses = selectedHarnesses.map(h => h.id);
855
+ manifest.linkedHarnesses = successfulHarnesses.map(h => h.id);
856
+ manifest.syncMode = syncMode;
810
857
  manifest.installed.skills = await getStoreSkills();
811
858
  manifest.installed.workflows = await getStoreWorkflows();
812
859
  manifest.harnessOwned = manifest.harnessOwned || {};
@@ -814,11 +861,13 @@ async function install(target) {
814
861
  manifest.harnessOwned[harness.id] = syncSummary[harness.id]?.owned || manifest.harnessOwned[harness.id] || {};
815
862
  }
816
863
  await writeManifest(manifest);
864
+ printSyncFailures(syncFailures);
817
865
 
818
866
  // 8. Summary
819
- console.log(pc.magenta('\n✨ Your command center is live. ✨'));
867
+ console.log(pc.magenta(syncFailures.length ? '\n⚠ Your command center is partially synced. ⚠' : '\n✨ Your command center is live. ✨'));
820
868
  console.log(pc.white(`\n Store: ${STORE_PATH}`));
821
- console.log(pc.white(` Connected: ${selectedHarnesses.map(h => h.name).join(', ')}`));
869
+ console.log(pc.white(` Sync mode: ${syncMode}`));
870
+ console.log(pc.white(` Connected: ${successfulHarnesses.map(h => h.name).join(', ') || 'none'}`));
822
871
  console.log(pc.dim(`\n Add skills: takomi add <github-url>`));
823
872
  console.log(pc.dim(` Sync updates: takomi sync\n`));
824
873
 
@@ -882,23 +931,33 @@ async function sync(target) {
882
931
 
883
932
  const selectedHarnesses = detected.filter(h => response.harnesses.includes(h.id));
884
933
 
885
- console.log(pc.cyan('\n📡 Syncing from global store...\n'));
934
+ const envSyncMode = process.env.TAKOMI_HARNESS_SYNC_MODE;
935
+ const syncMode = normalizeHarnessSyncMode(envSyncMode || manifest.syncMode || 'copy');
936
+ console.log(pc.cyan(`\n📡 Syncing from global store (${syncMode})...\n`));
886
937
  const syncSummary = await syncToAllHarnesses(selectedHarnesses, STORE_PATH, {
887
938
  useOwnership: true,
888
939
  owned: manifest.harnessOwned,
940
+ linkMode: syncMode,
889
941
  });
890
942
 
891
943
  // Update manifest with current harnesses
892
- manifest.linkedHarnesses = [...new Set([...manifest.linkedHarnesses, ...response.harnesses])];
944
+ const syncFailures = collectSyncFailures(syncSummary);
945
+ const successfulHarnessIds = response.harnesses.filter(id => !syncFailures.some(failure => failure.id === id));
946
+ manifest.linkedHarnesses = [...new Set([...manifest.linkedHarnesses, ...successfulHarnessIds])];
947
+ if (!envSyncMode) manifest.syncMode = syncMode;
893
948
  manifest.harnessOwned = manifest.harnessOwned || {};
894
949
  for (const harness of selectedHarnesses) {
895
950
  manifest.harnessOwned[harness.id] = syncSummary[harness.id]?.owned || manifest.harnessOwned[harness.id] || {};
896
951
  }
897
952
  await writeManifest(manifest);
953
+ printSyncFailures(syncFailures);
898
954
 
899
955
  const skills = await getStoreSkills();
900
956
  const workflows = await getStoreWorkflows();
901
- console.log(pc.magenta(`\n✨ ${skills.length} skills and ${workflows.length} workflows synced to ${selectedHarnesses.length} IDE(s). Ready to build.\n`));
957
+ const syncedCount = successfulHarnessIds.length;
958
+ console.log(pc.magenta(syncFailures.length
959
+ ? `\n⚠ ${skills.length} skills and ${workflows.length} workflows partially synced to ${syncedCount}/${selectedHarnesses.length} IDE(s).\n`
960
+ : `\n✨ ${skills.length} skills and ${workflows.length} workflows synced to ${syncedCount} IDE(s). Ready to build.\n`));
902
961
  }
903
962
 
904
963
  // ─────────────────────────────────────────────────────────────────────────────
@@ -928,11 +987,14 @@ async function add(url) {
928
987
  console.log(pc.cyan('📡 Fetching from GitHub...\n'));
929
988
 
930
989
  // Try assets/.agent/skills/ first (Takomi structure)
990
+ const githubOptions = { repo: repoSlug };
991
+
931
992
  let skillCount = await downloadDirectoryFromGitHub(
932
993
  'assets/.agent/skills',
933
994
  skillsDest,
934
995
  null,
935
- 250
996
+ 250,
997
+ githubOptions
936
998
  );
937
999
 
938
1000
  // Fallback: try .agent/skills/
@@ -941,7 +1003,8 @@ async function add(url) {
941
1003
  '.agent/skills',
942
1004
  skillsDest,
943
1005
  null,
944
- 250
1006
+ 250,
1007
+ githubOptions
945
1008
  );
946
1009
  }
947
1010
 
@@ -950,7 +1013,8 @@ async function add(url) {
950
1013
  'assets/.agent/workflows',
951
1014
  workflowsDest,
952
1015
  (item) => item.name.endsWith('.md'),
953
- 250
1016
+ 250,
1017
+ githubOptions
954
1018
  );
955
1019
 
956
1020
  if (workflowCount === 0) {
@@ -958,7 +1022,8 @@ async function add(url) {
958
1022
  '.agent/workflows',
959
1023
  workflowsDest,
960
1024
  (item) => item.name.endsWith('.md'),
961
- 250
1025
+ 250,
1026
+ githubOptions
962
1027
  );
963
1028
  }
964
1029
 
@@ -988,8 +1053,21 @@ async function add(url) {
988
1053
  if (syncResponse.doSync) {
989
1054
  const detected = detectHarnesses();
990
1055
  const linked = detected.filter(h => manifest.linkedHarnesses.includes(h.id));
991
- await syncToAllHarnesses(linked, STORE_PATH);
992
- console.log(pc.magenta(`\n✨ Live on ${linked.length} IDE(s). You're armed and ready.\n`));
1056
+ const syncSummary = await syncToAllHarnesses(linked, STORE_PATH, {
1057
+ useOwnership: true,
1058
+ owned: manifest.harnessOwned,
1059
+ linkMode: normalizeHarnessSyncMode(process.env.TAKOMI_HARNESS_SYNC_MODE || manifest.syncMode || 'copy'),
1060
+ });
1061
+ const syncFailures = collectSyncFailures(syncSummary);
1062
+ manifest.harnessOwned = manifest.harnessOwned || {};
1063
+ for (const harness of linked) {
1064
+ manifest.harnessOwned[harness.id] = syncSummary[harness.id]?.owned || manifest.harnessOwned[harness.id] || {};
1065
+ }
1066
+ await writeManifest(manifest);
1067
+ printSyncFailures(syncFailures);
1068
+ console.log(pc.magenta(syncFailures.length
1069
+ ? `\n⚠ Live on ${linked.length - syncFailures.length}/${linked.length} IDE(s); some harnesses need attention.\n`
1070
+ : `\n✨ Live on ${linked.length} IDE(s). You're armed and ready.\n`));
993
1071
  }
994
1072
  } else {
995
1073
  console.log(pc.dim('\n Run "takomi sync" when you want to push these to your IDEs.\n'));
@@ -1016,6 +1094,7 @@ async function harnesses() {
1016
1094
  console.log(pc.white(` Location: ${STORE_PATH}`));
1017
1095
  console.log(pc.white(` Skills: ${skills.length} ready to deploy`));
1018
1096
  console.log(pc.white(` Workflows: ${workflows.length} available`));
1097
+ console.log(pc.white(` Sync mode: ${manifest.syncMode || 'copy'}`));
1019
1098
  console.log(pc.white(` Connected: ${manifest.linkedHarnesses.join(', ') || 'none'}`));
1020
1099
  console.log(pc.dim(` Updated: ${manifest.updatedAt}\n`));
1021
1100
  } else {
@@ -1050,19 +1129,30 @@ async function updateProjectResources() {
1050
1129
  if (!response.components || response.components.length === 0) return;
1051
1130
 
1052
1131
  const destRoot = process.cwd();
1132
+ let remoteDownloadCount = 0;
1133
+ let remoteUpdateRequested = false;
1134
+ const remoteFailures = [];
1135
+ const runRemoteUpdate = async (label, updater) => {
1136
+ remoteUpdateRequested = true;
1137
+ try {
1138
+ remoteDownloadCount += await updater();
1139
+ } catch (error) {
1140
+ remoteFailures.push(`${label}: ${error.message}`);
1141
+ }
1142
+ };
1053
1143
 
1054
1144
  if (response.components.includes('agent')) {
1055
1145
  const agentDest = path.join(destRoot, '.agent');
1056
- await updateWorkflows(path.join(agentDest, 'workflows'));
1057
- await updateSkills(path.join(agentDest, 'skills'));
1146
+ await runRemoteUpdate('workflows', () => updateWorkflows(path.join(agentDest, 'workflows')));
1147
+ await runRemoteUpdate('skills', () => updateSkills(path.join(agentDest, 'skills')));
1058
1148
  }
1059
1149
 
1060
1150
  if (response.components.includes('yamls')) {
1061
- await updateAgentYamls(path.join(destRoot, 'Takomi-Agents'));
1151
+ await runRemoteUpdate('agent YAMLs', () => updateAgentYamls(path.join(destRoot, 'Takomi-Agents')));
1062
1152
  }
1063
1153
 
1064
1154
  if (response.components.includes('legacy')) {
1065
- await updateLegacyManual(path.join(destRoot, 'Legacy-Protocols'));
1155
+ await runRemoteUpdate('legacy protocols', () => updateLegacyManual(path.join(destRoot, 'Legacy-Protocols')));
1066
1156
  }
1067
1157
 
1068
1158
  // Update global store if selected
@@ -1079,8 +1169,19 @@ async function updateProjectResources() {
1079
1169
  const detected = detectHarnesses();
1080
1170
  const linked = detected.filter(h => manifest.linkedHarnesses.includes(h.id));
1081
1171
  if (linked.length > 0) {
1082
- console.log(pc.cyan('\n📡 Auto-syncing to linked harnesses...\n'));
1083
- await syncToAllHarnesses(linked, STORE_PATH);
1172
+ const syncMode = normalizeHarnessSyncMode(process.env.TAKOMI_HARNESS_SYNC_MODE || manifest.syncMode || 'copy');
1173
+ console.log(pc.cyan(`\n📡 Auto-syncing to linked harnesses (${syncMode})...\n`));
1174
+ const syncSummary = await syncToAllHarnesses(linked, STORE_PATH, {
1175
+ useOwnership: true,
1176
+ owned: manifest.harnessOwned,
1177
+ linkMode: syncMode,
1178
+ });
1179
+ const syncFailures = collectSyncFailures(syncSummary);
1180
+ manifest.harnessOwned = manifest.harnessOwned || {};
1181
+ for (const harness of linked) {
1182
+ manifest.harnessOwned[harness.id] = syncSummary[harness.id]?.owned || manifest.harnessOwned[harness.id] || {};
1183
+ }
1184
+ printSyncFailures(syncFailures);
1084
1185
  }
1085
1186
  }
1086
1187
 
@@ -1089,6 +1190,19 @@ async function updateProjectResources() {
1089
1190
  await writeManifest(manifest);
1090
1191
  }
1091
1192
 
1193
+ if (remoteFailures.length) {
1194
+ console.log(pc.red('\nSome project resources failed to download from GitHub:'));
1195
+ for (const failure of remoteFailures) console.log(pc.dim(` - ${failure}`));
1196
+ process.exitCode = 1;
1197
+ return;
1198
+ }
1199
+
1200
+ if (remoteUpdateRequested && remoteDownloadCount === 0) {
1201
+ console.log(pc.red('\nNo project resources were downloaded from GitHub. Check the network or repository paths.'));
1202
+ process.exitCode = 1;
1203
+ return;
1204
+ }
1205
+
1092
1206
  console.log(pc.magenta('\n✨ Your toolkit is fresh and ready to ship.'));
1093
1207
  }
1094
1208
 
@@ -1116,7 +1230,7 @@ Examples:
1116
1230
  takomi refresh pi Refresh only Pi-related pieces
1117
1231
 
1118
1232
  Legacy aliases still work:
1119
- install -> setup, sync/upgrade -> refresh, init -> setup project,
1233
+ install -> setup, sync -> sync, upgrade -> refresh, init -> setup project,
1120
1234
  harnesses -> status, update -> refresh project
1121
1235
  `);
1122
1236
 
@@ -1171,8 +1285,8 @@ program
1171
1285
  // Re-sync (legacy alias)
1172
1286
  program
1173
1287
  .command('sync [target]', { hidden: true })
1174
- .description('Legacy alias: use "takomi refresh [target]"')
1175
- .action(refresh);
1288
+ .description('Legacy alias for global store sync')
1289
+ .action(sync);
1176
1290
 
1177
1291
  // Add remote skills (NEW)
1178
1292
  program
package/src/harness.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'path';
3
3
  import os from 'os';
4
- import crypto from 'crypto';
5
4
  import pc from 'picocolors';
5
+ import { hashPath, normalizeOwnedMap, materializeOwnedTree } from './owned-tree.js';
6
6
 
7
7
  // ─── Cross-Platform Home Directory ───────────────────────────────────────────
8
8
  const HOME = os.homedir();
@@ -34,13 +34,26 @@ function getAppData() {
34
34
  export const HARNESS_MAP = {
35
35
  antigravity: {
36
36
  name: 'Antigravity',
37
- rootPath: path.join(HOME, '.gemini', 'antigravity'),
37
+ rootPath: path.join(HOME, '.gemini', 'config'),
38
38
  detect() {
39
39
  return fs.existsSync(this.rootPath);
40
40
  },
41
41
  targets: {
42
- skills: path.join(HOME, '.gemini', 'antigravity', 'skills'),
43
- workflows: path.join(HOME, '.gemini', 'antigravity', 'global_workflows'),
42
+ skills: path.join(HOME, '.gemini', 'config', 'skills'),
43
+ workflows: path.join(HOME, '.gemini', 'config', 'global_workflows'),
44
+ yamls: null,
45
+ },
46
+ },
47
+
48
+ claude_code: {
49
+ name: 'Claude Code',
50
+ rootPath: path.join(HOME, '.claude'),
51
+ detect() {
52
+ return fs.existsSync(this.rootPath);
53
+ },
54
+ targets: {
55
+ skills: path.join(HOME, '.claude', 'skills'),
56
+ workflows: null,
44
57
  yamls: null,
45
58
  },
46
59
  },
@@ -78,7 +91,20 @@ export const HARNESS_MAP = {
78
91
  name: 'Codex',
79
92
  rootPath: path.join(HOME, '.codex'),
80
93
  detect() {
81
- return fs.existsSync(this.rootPath);
94
+ return fs.existsSync(this.rootPath) || fs.existsSync('/etc/codex');
95
+ },
96
+ targets: {
97
+ skills: path.join(HOME, '.codex', 'skills'),
98
+ workflows: null,
99
+ yamls: null,
100
+ },
101
+ },
102
+
103
+ pi: {
104
+ name: 'Pi / shared Agent Skills',
105
+ rootPath: path.join(HOME, '.pi'),
106
+ detect() {
107
+ return fs.existsSync(this.rootPath) || fs.existsSync(path.join(HOME, '.agents'));
82
108
  },
83
109
  targets: {
84
110
  skills: path.join(HOME, '.agents', 'skills'),
@@ -100,19 +126,7 @@ export const HARNESS_MAP = {
100
126
  },
101
127
  },
102
128
 
103
- gemini_cli: {
104
- name: 'Gemini CLI',
105
- rootPath: path.join(HOME, '.gemini'),
106
- detect() {
107
- // Only match bare Gemini CLI — not Antigravity (which also lives under .gemini)
108
- return fs.existsSync(this.rootPath) && !fs.existsSync(path.join(HOME, '.gemini', 'antigravity'));
109
- },
110
- targets: {
111
- skills: path.join(HOME, '.agents', 'skills'),
112
- workflows: null,
113
- yamls: null,
114
- },
115
- },
129
+
116
130
  };
117
131
 
118
132
  // ─── Detection ───────────────────────────────────────────────────────────────
@@ -163,55 +177,14 @@ export function printHarnessStatus(detected) {
163
177
 
164
178
  /**
165
179
  * Syncs a source directory to a harness target path.
166
- * Uses hard copy (fs.copy) no symlinks.
180
+ * Supports copy, symlink/junction, or auto-fallback materialization.
181
+ * When ownership is supplied, it also prunes previous Takomi-owned items
182
+ * that are no longer in the source while preserving manual or modified files.
167
183
  *
168
184
  * @param {string} sourcePath - Source directory (e.g., ~/.takomi/skills/)
169
- * @param {string} targetPath - Destination directory (e.g., ~/.gemini/antigravity/skills/)
185
+ * @param {string} targetPath - Destination directory (e.g., ~/.gemini/config/skills/)
170
186
  * @param {string} label - Human-readable label for logging
171
- * @returns {Promise<number>} - Number of items copied
172
- */
173
- function sha256(value) {
174
- return crypto.createHash('sha256').update(value).digest('hex');
175
- }
176
-
177
- async function hashPath(targetPath) {
178
- if (!await fs.pathExists(targetPath)) return null;
179
- const stat = await fs.stat(targetPath);
180
- if (stat.isFile()) return sha256(await fs.readFile(targetPath));
181
-
182
- const entries = [];
183
- async function walk(current, prefix = '') {
184
- const names = (await fs.readdir(current)).sort();
185
- for (const name of names) {
186
- const full = path.join(current, name);
187
- const rel = path.join(prefix, name).replace(/\\/g, '/');
188
- const st = await fs.stat(full);
189
- if (st.isDirectory()) {
190
- entries.push(`dir:${rel}`);
191
- await walk(full, rel);
192
- } else {
193
- entries.push(`file:${rel}:${sha256(await fs.readFile(full))}`);
194
- }
195
- }
196
- }
197
- await walk(targetPath);
198
- return sha256(entries.join('\n'));
199
- }
200
-
201
- function normalizeOwnedMap(value) {
202
- if (!value || typeof value !== 'object') return {};
203
- const normalized = {};
204
- for (const [name, entry] of Object.entries(value)) {
205
- if (typeof entry === 'string') normalized[name] = { hash: entry };
206
- else if (entry?.hash) normalized[name] = entry;
207
- }
208
- return normalized;
209
- }
210
-
211
- /**
212
- * Syncs a directory to a target path. When ownership is supplied, it also
213
- * prunes previous Takomi-owned items that are no longer in the source while
214
- * preserving manual or modified files.
187
+ * @returns {Promise<number|object>} - Number of items materialized, or details
215
188
  */
216
189
  export async function syncDirectory(sourcePath, targetPath, label = '', options = {}) {
217
190
  if (!targetPath) return options.returnDetails ? { copied: 0, pruned: 0, preservedManual: [], preservedModified: [], owned: {} } : 0;
@@ -225,7 +198,10 @@ export async function syncDirectory(sourcePath, targetPath, label = '', options
225
198
  const nextOwned = {};
226
199
  const preservedManual = [];
227
200
  const preservedModified = [];
201
+ const symlinkFallbacks = [];
202
+ const linkMode = options.linkMode || process.env.TAKOMI_HARNESS_SYNC_MODE || 'copy';
228
203
  let copied = 0;
204
+ let linked = 0;
229
205
  let pruned = 0;
230
206
 
231
207
  if (options.prune && Object.keys(previousOwned).length > 0) {
@@ -263,29 +239,35 @@ export async function syncDirectory(sourcePath, targetPath, label = '', options
263
239
  await fs.remove(dest);
264
240
  }
265
241
 
266
- await fs.copy(src, dest, { overwrite: true });
242
+ const materialized = await materializeOwnedTree(src, dest, { linkMode });
267
243
  nextOwned[item] = {
268
244
  hash: await hashPath(dest),
269
245
  targetPath: dest,
246
+ sourcePath: path.resolve(src),
247
+ syncMethod: materialized.method,
270
248
  syncedAt: new Date().toISOString(),
271
249
  };
272
- copied++;
250
+ if (materialized.method === 'symlink') linked++;
251
+ else copied++;
252
+ if (materialized.fallbackError) symlinkFallbacks.push(`${item}: ${materialized.fallbackError}`);
273
253
  }
274
254
 
275
255
  if (label) {
276
256
  const suffix = pruned ? `, removed ${pruned}` : '';
277
- console.log(pc.green(` ✔ ${label} (${copied} items${suffix})`));
257
+ const methodSummary = linked ? `${linked} linked, ${copied} copied` : `${copied} copied`;
258
+ console.log(pc.green(` ✔ ${label} (${methodSummary}${suffix})`));
278
259
  if (preservedManual.length) console.log(pc.yellow(` Preserved manual items: ${preservedManual.join(', ')}`));
279
260
  if (preservedModified.length) console.log(pc.yellow(` Preserved modified Takomi-owned items: ${preservedModified.join(', ')}`));
261
+ if (symlinkFallbacks.length) console.log(pc.yellow(` Symlink fallback copies: ${symlinkFallbacks.join('; ')}`));
280
262
  }
281
263
 
282
- const details = { copied, pruned, preservedManual, preservedModified, owned: Object.fromEntries(Object.entries(nextOwned).sort(([a], [b]) => a.localeCompare(b))) };
283
- return options.returnDetails ? details : copied;
264
+ const details = { copied: copied + linked, linked, copyCount: copied, pruned, preservedManual, preservedModified, symlinkFallbacks, owned: Object.fromEntries(Object.entries(nextOwned).sort(([a], [b]) => a.localeCompare(b))) };
265
+ return options.returnDetails ? details : copied + linked;
284
266
  } catch (error) {
285
267
  if (label) {
286
268
  console.log(pc.red(` ✗ ${label}: ${error.message}`));
287
269
  }
288
- return options.returnDetails ? { copied: 0, pruned: 0, preservedManual: [], preservedModified: [], owned: {} } : 0;
270
+ return options.returnDetails ? { copied: 0, linked: 0, copyCount: 0, pruned: 0, preservedManual: [], preservedModified: [], symlinkFallbacks: [], owned: normalizeOwnedMap(options.owned), ok: false, error: error.message } : 0;
289
271
  }
290
272
  }
291
273
 
@@ -329,8 +311,8 @@ export async function syncFile(sourceFile, targetPaths, label = '') {
329
311
  * @returns {Promise<{skills: number, workflows: number, yamls: number}>}
330
312
  */
331
313
  export async function syncToHarness(harness, storePath, options = {}) {
332
- const results = { skills: 0, workflows: 0, yamls: 0, owned: { skills: {}, workflows: {} } };
333
314
  const harnessOwned = options.owned?.[harness.id] || {};
315
+ const results = { skills: 0, workflows: 0, yamls: 0, owned: { skills: normalizeOwnedMap(harnessOwned.skills), workflows: normalizeOwnedMap(harnessOwned.workflows) }, ok: true, errors: [] };
334
316
  const useOwnership = Boolean(options.useOwnership);
335
317
 
336
318
  console.log(pc.cyan(`\n 📡 Syncing to ${harness.name}...`));
@@ -347,11 +329,16 @@ export async function syncToHarness(harness, storePath, options = {}) {
347
329
  preserveManual: useOwnership,
348
330
  prune: useOwnership,
349
331
  returnDetails: useOwnership,
332
+ linkMode: options.linkMode,
350
333
  },
351
334
  );
352
335
  if (useOwnership) {
353
336
  results.skills = skillResult.copied;
354
337
  results.owned.skills = skillResult.owned;
338
+ if (skillResult.ok === false) {
339
+ results.ok = false;
340
+ results.errors.push(skillResult.error);
341
+ }
355
342
  } else {
356
343
  results.skills = skillResult;
357
344
  }
@@ -369,11 +356,16 @@ export async function syncToHarness(harness, storePath, options = {}) {
369
356
  preserveManual: useOwnership,
370
357
  prune: useOwnership,
371
358
  returnDetails: useOwnership,
359
+ linkMode: options.linkMode,
372
360
  },
373
361
  );
374
362
  if (useOwnership) {
375
363
  results.workflows = workflowResult.copied;
376
364
  results.owned.workflows = workflowResult.owned;
365
+ if (workflowResult.ok === false) {
366
+ results.ok = false;
367
+ results.errors.push(workflowResult.error);
368
+ }
377
369
  } else {
378
370
  results.workflows = workflowResult;
379
371
  }
@@ -0,0 +1,114 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import crypto from 'crypto';
4
+ import { createReadStream } from 'fs';
5
+
6
+ export function sha256(value) {
7
+ return crypto.createHash('sha256').update(value).digest('hex');
8
+ }
9
+
10
+ async function hashFile(filePath) {
11
+ return new Promise((resolve, reject) => {
12
+ const hash = crypto.createHash('sha256');
13
+ const stream = createReadStream(filePath);
14
+ stream.on('data', (chunk) => hash.update(chunk));
15
+ stream.on('error', reject);
16
+ stream.on('end', () => resolve(hash.digest('hex')));
17
+ });
18
+ }
19
+
20
+ /**
21
+ * Hash a file tree without following symlinks.
22
+ *
23
+ * Symlinks are represented by their link target string instead of the linked
24
+ * file contents. This prevents store/harness reconciliation from recursively
25
+ * reading outside the intended tree or looping through symlink cycles.
26
+ */
27
+ export async function hashPath(targetPath) {
28
+ if (!await fs.pathExists(targetPath)) return null;
29
+
30
+ const rootStat = await fs.lstat(targetPath);
31
+ if (rootStat.isSymbolicLink()) return sha256(`symlink:${await fs.readlink(targetPath).catch(() => '')}`);
32
+ if (rootStat.isFile()) return hashFile(targetPath);
33
+ if (!rootStat.isDirectory()) return sha256(`other:${rootStat.mode}:${rootStat.size}`);
34
+
35
+ const entries = [];
36
+ async function walk(current, prefix = '') {
37
+ const names = (await fs.readdir(current)).sort();
38
+ for (const name of names) {
39
+ const full = path.join(current, name);
40
+ const rel = path.join(prefix, name).replace(/\\/g, '/');
41
+ const stat = await fs.lstat(full);
42
+ if (stat.isSymbolicLink()) {
43
+ const linkTarget = await fs.readlink(full).catch(() => '');
44
+ entries.push(`symlink:${rel}:${linkTarget}`);
45
+ } else if (stat.isDirectory()) {
46
+ entries.push(`dir:${rel}`);
47
+ await walk(full, rel);
48
+ } else if (stat.isFile()) {
49
+ entries.push(`file:${rel}:${await hashFile(full)}`);
50
+ } else {
51
+ entries.push(`other:${rel}:${stat.mode}:${stat.size}`);
52
+ }
53
+ }
54
+ }
55
+ await walk(targetPath);
56
+ return sha256(entries.join('\n'));
57
+ }
58
+
59
+ export function normalizeOwnedMap(value) {
60
+ if (!value || typeof value !== 'object') return {};
61
+ const normalized = {};
62
+ for (const [name, entry] of Object.entries(value)) {
63
+ if (typeof entry === 'string') normalized[name] = { hash: entry };
64
+ else if (entry?.hash) normalized[name] = entry;
65
+ }
66
+ return normalized;
67
+ }
68
+
69
+ export async function copyOwnedTree(src, dest, options = {}) {
70
+ const { allowSymlinks = false, filter, ...copyOptions } = options;
71
+ await fs.copy(src, dest, {
72
+ overwrite: true,
73
+ errorOnExist: false,
74
+ dereference: false,
75
+ ...copyOptions,
76
+ filter: async (candidate) => {
77
+ if (filter && !await filter(candidate)) return false;
78
+ if (allowSymlinks) return true;
79
+ const stat = await fs.lstat(candidate).catch(() => null);
80
+ if (stat?.isSymbolicLink()) {
81
+ throw new Error(`Refusing to copy symlink in managed tree: ${candidate}`);
82
+ }
83
+ return true;
84
+ },
85
+ });
86
+ }
87
+
88
+ export async function linkOwnedTree(src, dest) {
89
+ const resolvedSrc = path.resolve(src);
90
+ const stat = await fs.lstat(resolvedSrc);
91
+ const type = stat.isDirectory()
92
+ ? (process.platform === 'win32' ? 'junction' : 'dir')
93
+ : 'file';
94
+ await fs.ensureDir(path.dirname(dest));
95
+ await fs.symlink(resolvedSrc, dest, type);
96
+ }
97
+
98
+ export async function materializeOwnedTree(src, dest, options = {}) {
99
+ const linkMode = options.linkMode || 'copy';
100
+ if (linkMode === 'symlink' || linkMode === 'auto') {
101
+ try {
102
+ await linkOwnedTree(src, dest);
103
+ return { method: 'symlink' };
104
+ } catch (error) {
105
+ if (linkMode === 'symlink') throw error;
106
+ await fs.remove(dest).catch(() => {});
107
+ await copyOwnedTree(src, dest, options.copyOptions || {});
108
+ return { method: 'copy', fallbackError: error.message };
109
+ }
110
+ }
111
+
112
+ await copyOwnedTree(src, dest, options.copyOptions || {});
113
+ return { method: 'copy' };
114
+ }