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/.pi/extensions/takomi-runtime/context-panel.ts +37 -13
- package/.pi/extensions/takomi-runtime/index.ts +68 -1
- package/.pi/extensions/takomi-runtime/model-routing-defaults.ts +35 -5
- package/.pi/extensions/takomi-runtime/routing-policy.ts +35 -7
- package/.pi/extensions/takomi-runtime/takomi-stats.js +13 -3
- package/.pi/extensions/takomi-runtime/ui.ts +3 -3
- package/.pi/extensions/takomi-subagents/index.ts +10 -4
- package/.pi/extensions/takomi-subagents/native-render.ts +127 -7
- package/.pi/extensions/takomi-subagents/pi-subagents-engine.ts +32 -9
- package/.pi/extensions/takomi-subagents/tool-runner.ts +75 -17
- package/README.md +11 -7
- package/assets/.agent/skills/photo-book-builder/SKILL.md +51 -7
- package/package.json +18 -2
- package/src/cli.js +137 -23
- package/src/harness.js +63 -71
- package/src/owned-tree.js +114 -0
- package/src/pi-harness.js +53 -24
- package/src/pi-installer.js +12 -36
- package/src/store.js +5 -40
- package/src/takomi-stats.js +14 -3
- package/src/utils.js +81 -44
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('\
|
|
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(
|
|
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 =
|
|
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(`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1083
|
-
|
|
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
|
|
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
|
|
1175
|
-
.action(
|
|
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', '
|
|
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', '
|
|
43
|
-
workflows: path.join(HOME, '.gemini', '
|
|
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
|
-
|
|
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
|
-
*
|
|
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/
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
+
}
|