tokrepo 2.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/tokrepo.js +453 -6
- package/package.json +2 -2
package/bin/tokrepo.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
|
+
const crypto = require('crypto');
|
|
5
6
|
const https = require('https');
|
|
6
7
|
const http = require('http');
|
|
7
8
|
const readline = require('readline');
|
|
@@ -21,8 +22,11 @@ const C = {
|
|
|
21
22
|
|
|
22
23
|
const CONFIG_DIR = path.join(require('os').homedir(), '.tokrepo');
|
|
23
24
|
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
25
|
+
const SYNC_STATE_FILE = path.join(CONFIG_DIR, 'sync-state.json');
|
|
24
26
|
const PROJECT_CONFIG = '.tokrepo.json';
|
|
25
27
|
const DEFAULT_API = 'https://api.tokrepo.com';
|
|
28
|
+
const CLI_VERSION = '3.1.0';
|
|
29
|
+
const VERSION_CHECK_FILE = path.join(require('os').homedir(), '.tokrepo', '.version-check');
|
|
26
30
|
|
|
27
31
|
// ─── Helpers ───
|
|
28
32
|
|
|
@@ -42,9 +46,73 @@ function readConfig() {
|
|
|
42
46
|
|
|
43
47
|
function writeConfig(config) {
|
|
44
48
|
if (!fs.existsSync(CONFIG_DIR)) {
|
|
45
|
-
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
49
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
46
50
|
}
|
|
47
|
-
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
51
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Check npm registry for newer version (non-blocking, max once per day)
|
|
55
|
+
async function checkForUpdate() {
|
|
56
|
+
try {
|
|
57
|
+
// Only check once per 24 hours
|
|
58
|
+
if (fs.existsSync(VERSION_CHECK_FILE)) {
|
|
59
|
+
const stat = fs.statSync(VERSION_CHECK_FILE);
|
|
60
|
+
const hoursSinceCheck = (Date.now() - stat.mtimeMs) / 3600000;
|
|
61
|
+
if (hoursSinceCheck < 24) return;
|
|
62
|
+
}
|
|
63
|
+
// Touch the file to mark check time
|
|
64
|
+
if (!fs.existsSync(path.dirname(VERSION_CHECK_FILE))) {
|
|
65
|
+
fs.mkdirSync(path.dirname(VERSION_CHECK_FILE), { recursive: true, mode: 0o700 });
|
|
66
|
+
}
|
|
67
|
+
fs.writeFileSync(VERSION_CHECK_FILE, '', { mode: 0o600 });
|
|
68
|
+
|
|
69
|
+
const data = await new Promise((resolve, reject) => {
|
|
70
|
+
const req = https.get('https://registry.npmjs.org/tokrepo/latest', {
|
|
71
|
+
headers: { 'Accept': 'application/json' },
|
|
72
|
+
timeout: 3000,
|
|
73
|
+
}, (res) => {
|
|
74
|
+
let body = '';
|
|
75
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
76
|
+
res.on('end', () => {
|
|
77
|
+
try { resolve(JSON.parse(body)); } catch { reject(); }
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
req.on('error', reject);
|
|
81
|
+
req.on('timeout', () => { req.destroy(); reject(); });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const latest = data.version;
|
|
85
|
+
if (latest && latest !== CLI_VERSION) {
|
|
86
|
+
const cmp = compareVersions(latest, CLI_VERSION);
|
|
87
|
+
if (cmp > 0) {
|
|
88
|
+
log('');
|
|
89
|
+
log(`${C.yellow}!${C.reset} Update available: ${C.dim}${CLI_VERSION}${C.reset} → ${C.green}${latest}${C.reset}`);
|
|
90
|
+
log(` Run: ${C.cyan}npm install -g tokrepo${C.reset}`);
|
|
91
|
+
log('');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
// Silent fail — update check is best-effort
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function compareVersions(a, b) {
|
|
100
|
+
const pa = a.split('.').map(Number);
|
|
101
|
+
const pb = b.split('.').map(Number);
|
|
102
|
+
for (let i = 0; i < 3; i++) {
|
|
103
|
+
if ((pa[i] || 0) > (pb[i] || 0)) return 1;
|
|
104
|
+
if ((pa[i] || 0) < (pb[i] || 0)) return -1;
|
|
105
|
+
}
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Sync state: maps "dirPath:title" → { uuid, hash, lastSync }
|
|
110
|
+
function readSyncState() {
|
|
111
|
+
try { return JSON.parse(fs.readFileSync(SYNC_STATE_FILE, 'utf8')); } catch { return {}; }
|
|
112
|
+
}
|
|
113
|
+
function writeSyncState(state) {
|
|
114
|
+
if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
115
|
+
fs.writeFileSync(SYNC_STATE_FILE, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
48
116
|
}
|
|
49
117
|
|
|
50
118
|
function readProjectConfig(baseDir = process.cwd()) {
|
|
@@ -74,6 +142,10 @@ function apiRequest(method, urlPath, body, token, apiBase) {
|
|
|
74
142
|
return new Promise((resolve, reject) => {
|
|
75
143
|
const base = apiBase || DEFAULT_API;
|
|
76
144
|
const url = new URL(urlPath, base);
|
|
145
|
+
// Force HTTPS to prevent token transmission over plain HTTP
|
|
146
|
+
if (url.protocol === 'http:' && !url.hostname.match(/^(localhost|127\.0\.0\.1)$/)) {
|
|
147
|
+
url.protocol = 'https:';
|
|
148
|
+
}
|
|
77
149
|
const isHttps = url.protocol === 'https:';
|
|
78
150
|
const mod = isHttps ? https : http;
|
|
79
151
|
|
|
@@ -144,7 +216,8 @@ function detectFileType(filename) {
|
|
|
144
216
|
|
|
145
217
|
// Guess tag from file type
|
|
146
218
|
function guessTag(fileType) {
|
|
147
|
-
|
|
219
|
+
// Use lowercase singular names to match backend tag slugs
|
|
220
|
+
const map = { skill: 'skill', prompt: 'prompt', script: 'script', config: 'config', mcp: 'mcp' };
|
|
148
221
|
return map[fileType] || null;
|
|
149
222
|
}
|
|
150
223
|
|
|
@@ -253,6 +326,11 @@ function collectFiles(paths, baseDir) {
|
|
|
253
326
|
|
|
254
327
|
for (const p of paths) {
|
|
255
328
|
const resolved = path.resolve(baseDir, p);
|
|
329
|
+
// Prevent path traversal — resolved must be within baseDir
|
|
330
|
+
if (!resolved.startsWith(path.resolve(baseDir) + path.sep) && resolved !== path.resolve(baseDir)) {
|
|
331
|
+
warn(`Skipped (outside project): ${p}`);
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
256
334
|
if (!fs.existsSync(resolved)) {
|
|
257
335
|
warn(`Not found: ${p}`);
|
|
258
336
|
continue;
|
|
@@ -674,6 +752,9 @@ Examples:
|
|
|
674
752
|
}
|
|
675
753
|
case 'mcp':
|
|
676
754
|
case 'mcp configs': {
|
|
755
|
+
// Security warning for MCP configs
|
|
756
|
+
warn('MCP server config detected. Review the configuration carefully before adding to your project.');
|
|
757
|
+
warn('MCP servers can execute arbitrary code. Only install from trusted sources.');
|
|
677
758
|
// Save as mcp config, hint about manual merge
|
|
678
759
|
if (!fileName.endsWith('.json')) fileName = fileName.replace(/\.md$/, '.json');
|
|
679
760
|
break;
|
|
@@ -704,6 +785,8 @@ Examples:
|
|
|
704
785
|
}
|
|
705
786
|
}
|
|
706
787
|
|
|
788
|
+
// Sanitize fileName to prevent path traversal from API response
|
|
789
|
+
fileName = path.basename(fileName);
|
|
707
790
|
const destPath = path.join(destDir, fileName);
|
|
708
791
|
|
|
709
792
|
// Don't overwrite without warning
|
|
@@ -848,6 +931,351 @@ async function cmdTags() {
|
|
|
848
931
|
}
|
|
849
932
|
}
|
|
850
933
|
|
|
934
|
+
// ─── Sync: scan directory, diff with remote, upsert changes ───
|
|
935
|
+
|
|
936
|
+
function computeHash(files) {
|
|
937
|
+
const h = crypto.createHash('sha256');
|
|
938
|
+
for (const f of files) {
|
|
939
|
+
h.update(f.name);
|
|
940
|
+
h.update('\0');
|
|
941
|
+
h.update(f.content);
|
|
942
|
+
h.update('\0');
|
|
943
|
+
}
|
|
944
|
+
return h.digest('hex');
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function scanDirectory(dirPath) {
|
|
948
|
+
const assets = [];
|
|
949
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', '__pycache__', '.venv', 'dist', 'build']);
|
|
950
|
+
const SKIP_FILES = new Set(['.DS_Store', 'Thumbs.db', 'package-lock.json', 'yarn.lock']);
|
|
951
|
+
|
|
952
|
+
// Each subdirectory = one asset; loose files = one asset per file
|
|
953
|
+
let entries;
|
|
954
|
+
try { entries = fs.readdirSync(dirPath, { withFileTypes: true }); } catch { return assets; }
|
|
955
|
+
|
|
956
|
+
const looseFiles = [];
|
|
957
|
+
|
|
958
|
+
for (const entry of entries) {
|
|
959
|
+
if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name) || SKIP_FILES.has(entry.name)) continue;
|
|
960
|
+
|
|
961
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
962
|
+
|
|
963
|
+
if (entry.isDirectory()) {
|
|
964
|
+
// Subdirectory = one asset (e.g., ~/.claude/skills/my-skill/)
|
|
965
|
+
const files = collectAssetFiles(fullPath);
|
|
966
|
+
if (files.length === 0) continue;
|
|
967
|
+
|
|
968
|
+
const title = guessAssetTitle(files, entry.name);
|
|
969
|
+
const hash = computeHash(files);
|
|
970
|
+
const detectedTags = new Set();
|
|
971
|
+
for (const f of files) {
|
|
972
|
+
const ft = detectFileType(f.name);
|
|
973
|
+
const tag = guessTag(ft);
|
|
974
|
+
if (tag) detectedTags.add(tag);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
assets.push({ title, files, hash, tags: Array.from(detectedTags), sourcePath: fullPath });
|
|
978
|
+
} else if (entry.isFile()) {
|
|
979
|
+
// Loose file
|
|
980
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
981
|
+
const validExts = ['.md', '.sh', '.py', '.js', '.mjs', '.ts', '.json', '.yaml', '.yml', '.toml', '.prompt'];
|
|
982
|
+
if (validExts.includes(ext) || entry.name === '.cursorrules' || entry.name === '.windsurfrules') {
|
|
983
|
+
looseFiles.push({ path: fullPath, name: entry.name });
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// Each loose file = one asset
|
|
989
|
+
for (const f of looseFiles) {
|
|
990
|
+
let content;
|
|
991
|
+
try { content = fs.readFileSync(f.path, 'utf8'); } catch { continue; }
|
|
992
|
+
if (!content.trim()) continue;
|
|
993
|
+
|
|
994
|
+
const files = [{ name: f.name, content, type: detectFileType(f.name) }];
|
|
995
|
+
const title = guessAssetTitle(files, path.basename(f.name, path.extname(f.name)));
|
|
996
|
+
const hash = computeHash(files);
|
|
997
|
+
const ft = detectFileType(f.name);
|
|
998
|
+
const tag = guessTag(ft);
|
|
999
|
+
|
|
1000
|
+
assets.push({ title, files, hash, tags: tag ? [tag] : [], sourcePath: f.path });
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
return assets;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function collectAssetFiles(dirPath) {
|
|
1007
|
+
const files = [];
|
|
1008
|
+
const SKIP = new Set(['.DS_Store', 'node_modules', '.git', '__pycache__']);
|
|
1009
|
+
|
|
1010
|
+
function walk(dir, relBase) {
|
|
1011
|
+
let entries;
|
|
1012
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
1013
|
+
for (const entry of entries) {
|
|
1014
|
+
if (entry.name.startsWith('.') || SKIP.has(entry.name)) continue;
|
|
1015
|
+
const fullPath = path.join(dir, entry.name);
|
|
1016
|
+
const relPath = relBase ? `${relBase}/${entry.name}` : entry.name;
|
|
1017
|
+
if (entry.isDirectory()) {
|
|
1018
|
+
walk(fullPath, relPath);
|
|
1019
|
+
} else if (entry.isFile()) {
|
|
1020
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
1021
|
+
const validExts = ['.md', '.sh', '.py', '.js', '.mjs', '.ts', '.json', '.yaml', '.yml', '.toml', '.prompt', '.rb', '.go', '.rs'];
|
|
1022
|
+
if (validExts.includes(ext) || entry.name === '.cursorrules') {
|
|
1023
|
+
let content;
|
|
1024
|
+
try { content = fs.readFileSync(fullPath, 'utf8'); } catch { continue; }
|
|
1025
|
+
if (!content.trim()) continue;
|
|
1026
|
+
files.push({ name: relPath, content, type: detectFileType(relPath) });
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
walk(dirPath, '');
|
|
1033
|
+
return files;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
function guessAssetTitle(files, fallbackName) {
|
|
1037
|
+
// Try to find a heading in the first .md file
|
|
1038
|
+
for (const f of files) {
|
|
1039
|
+
if (f.name.toLowerCase().endsWith('.md')) {
|
|
1040
|
+
const match = f.content.match(/^#\s+(.+)$/m);
|
|
1041
|
+
if (match) return match[1].trim();
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
// Clean up fallback name
|
|
1045
|
+
return fallbackName
|
|
1046
|
+
.replace(/[-_]/g, ' ')
|
|
1047
|
+
.replace(/\b\w/g, c => c.toUpperCase());
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
async function cmdSync() {
|
|
1051
|
+
const args = parseArgs(process.argv);
|
|
1052
|
+
const config = readConfig();
|
|
1053
|
+
if (!config || !config.token) error(`Not logged in. Run: ${C.cyan}tokrepo login${C.reset}`);
|
|
1054
|
+
|
|
1055
|
+
const targetDir = args.positional[0]
|
|
1056
|
+
? path.resolve(args.positional[0])
|
|
1057
|
+
: path.join(require('os').homedir(), '.claude', 'skills');
|
|
1058
|
+
|
|
1059
|
+
if (!fs.existsSync(targetDir)) {
|
|
1060
|
+
error(`Directory not found: ${targetDir}`);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
const visibility = args.flags.public ? 1 : 0; // default private for sync
|
|
1064
|
+
|
|
1065
|
+
log(`\n${C.bold}tokrepo sync${C.reset}\n`);
|
|
1066
|
+
info(`Scanning ${targetDir}...`);
|
|
1067
|
+
|
|
1068
|
+
const assets = scanDirectory(targetDir);
|
|
1069
|
+
if (assets.length === 0) {
|
|
1070
|
+
info('No assets found in directory.');
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
log(` Found ${C.bold}${assets.length}${C.reset} assets\n`);
|
|
1075
|
+
|
|
1076
|
+
// Load local sync state for fast local-only diff
|
|
1077
|
+
const syncState = readSyncState();
|
|
1078
|
+
|
|
1079
|
+
// Classify each asset: check local state first, then remote diff for unknowns
|
|
1080
|
+
const needsRemoteCheck = [];
|
|
1081
|
+
const localStatus = {};
|
|
1082
|
+
|
|
1083
|
+
for (const asset of assets) {
|
|
1084
|
+
const key = asset.sourcePath;
|
|
1085
|
+
const cached = syncState[key];
|
|
1086
|
+
if (cached && cached.hash === asset.hash) {
|
|
1087
|
+
// Local hash matches last sync — unchanged
|
|
1088
|
+
localStatus[asset.title] = { status: 'unchanged', uuid: cached.uuid };
|
|
1089
|
+
} else if (cached && cached.hash !== asset.hash) {
|
|
1090
|
+
// Local hash differs from last sync — updated
|
|
1091
|
+
localStatus[asset.title] = { status: 'updated', uuid: cached.uuid };
|
|
1092
|
+
} else {
|
|
1093
|
+
// Not in local state — check remote
|
|
1094
|
+
needsRemoteCheck.push(asset);
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// Remote diff for assets not in local state
|
|
1099
|
+
if (needsRemoteCheck.length > 0) {
|
|
1100
|
+
info('Comparing with remote...');
|
|
1101
|
+
const diffPayload = needsRemoteCheck.map(a => ({ title: a.title, content_hash: a.hash }));
|
|
1102
|
+
try {
|
|
1103
|
+
const data = await apiRequest('POST', '/api/v1/tokenboard/push/diff', { assets: diffPayload }, config.token, config.api);
|
|
1104
|
+
for (const r of data.results) {
|
|
1105
|
+
localStatus[r.title] = { status: r.status, uuid: r.remote_uuid || '' };
|
|
1106
|
+
}
|
|
1107
|
+
} catch (e) {
|
|
1108
|
+
warn(`Diff API: ${e.message} — treating unknowns as new`);
|
|
1109
|
+
for (const a of needsRemoteCheck) {
|
|
1110
|
+
localStatus[a.title] = { status: 'new', uuid: '' };
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// Show status
|
|
1116
|
+
let newCount = 0, updatedCount = 0, unchangedCount = 0;
|
|
1117
|
+
|
|
1118
|
+
for (const asset of assets) {
|
|
1119
|
+
const st = localStatus[asset.title] || { status: 'new' };
|
|
1120
|
+
if (st.status === 'new') {
|
|
1121
|
+
log(` ${C.green}+ new${C.reset} ${asset.title} ${C.dim}(${asset.files.length} files)${C.reset}`);
|
|
1122
|
+
newCount++;
|
|
1123
|
+
} else if (st.status === 'updated') {
|
|
1124
|
+
log(` ${C.yellow}~ updated${C.reset} ${asset.title}`);
|
|
1125
|
+
updatedCount++;
|
|
1126
|
+
} else {
|
|
1127
|
+
log(` ${C.dim}= unchanged ${asset.title}${C.reset}`);
|
|
1128
|
+
unchangedCount++;
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
log('');
|
|
1133
|
+
log(` ${C.green}${newCount} new${C.reset} ${C.yellow}${updatedCount} updated${C.reset} ${C.dim}${unchangedCount} unchanged${C.reset}`);
|
|
1134
|
+
|
|
1135
|
+
if (newCount === 0 && updatedCount === 0) {
|
|
1136
|
+
log('');
|
|
1137
|
+
success('Everything is up to date!');
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
log('');
|
|
1142
|
+
|
|
1143
|
+
// Confirm unless -y
|
|
1144
|
+
if (!args.flags.yes) {
|
|
1145
|
+
const confirm = await ask(`Push ${newCount + updatedCount} assets? (y/N):`);
|
|
1146
|
+
if (confirm.toLowerCase() !== 'y') { log('Aborted.'); return; }
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
// Upsert each changed asset + save state
|
|
1150
|
+
let successCount = 0;
|
|
1151
|
+
let failCount = 0;
|
|
1152
|
+
|
|
1153
|
+
for (const asset of assets) {
|
|
1154
|
+
const st = localStatus[asset.title] || { status: 'new' };
|
|
1155
|
+
if (st.status === 'unchanged') continue;
|
|
1156
|
+
|
|
1157
|
+
const totalChars = asset.files.reduce((sum, f) => sum + f.content.length, 0);
|
|
1158
|
+
|
|
1159
|
+
try {
|
|
1160
|
+
const data = await apiRequest('POST', '/api/v1/tokenboard/push/upsert', {
|
|
1161
|
+
title: asset.title,
|
|
1162
|
+
files: asset.files,
|
|
1163
|
+
tags: asset.tags,
|
|
1164
|
+
token_cost: String(Math.round(totalChars / 4)),
|
|
1165
|
+
visibility,
|
|
1166
|
+
}, config.token, config.api);
|
|
1167
|
+
|
|
1168
|
+
const action = data.action === 'created' ? C.green + '+ created' : C.yellow + '~ updated';
|
|
1169
|
+
log(` ${action}${C.reset} ${asset.title} ${C.dim}${data.url}${C.reset}`);
|
|
1170
|
+
|
|
1171
|
+
// Save to local sync state
|
|
1172
|
+
syncState[asset.sourcePath] = {
|
|
1173
|
+
uuid: data.uuid,
|
|
1174
|
+
hash: asset.hash,
|
|
1175
|
+
title: asset.title,
|
|
1176
|
+
url: data.url,
|
|
1177
|
+
lastSync: new Date().toISOString(),
|
|
1178
|
+
};
|
|
1179
|
+
successCount++;
|
|
1180
|
+
} catch (e) {
|
|
1181
|
+
log(` ${C.red}✗ failed${C.reset} ${asset.title}: ${e.message}`);
|
|
1182
|
+
failCount++;
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// Persist sync state
|
|
1187
|
+
writeSyncState(syncState);
|
|
1188
|
+
|
|
1189
|
+
log('');
|
|
1190
|
+
if (failCount === 0) {
|
|
1191
|
+
success(`Synced ${successCount} assets!`);
|
|
1192
|
+
} else {
|
|
1193
|
+
warn(`${successCount} synced, ${failCount} failed`);
|
|
1194
|
+
}
|
|
1195
|
+
log('');
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
async function cmdStatus() {
|
|
1199
|
+
const args = parseArgs(process.argv);
|
|
1200
|
+
const config = readConfig();
|
|
1201
|
+
if (!config || !config.token) error(`Not logged in. Run: ${C.cyan}tokrepo login${C.reset}`);
|
|
1202
|
+
|
|
1203
|
+
const targetDir = args.positional[0]
|
|
1204
|
+
? path.resolve(args.positional[0])
|
|
1205
|
+
: path.join(require('os').homedir(), '.claude', 'skills');
|
|
1206
|
+
|
|
1207
|
+
if (!fs.existsSync(targetDir)) {
|
|
1208
|
+
error(`Directory not found: ${targetDir}`);
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
log(`\n${C.bold}tokrepo status${C.reset}\n`);
|
|
1212
|
+
info(`Scanning ${targetDir}...`);
|
|
1213
|
+
|
|
1214
|
+
const assets = scanDirectory(targetDir);
|
|
1215
|
+
if (assets.length === 0) {
|
|
1216
|
+
info('No assets found in directory.');
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// Check local sync state first, remote for unknowns
|
|
1221
|
+
const syncState = readSyncState();
|
|
1222
|
+
const localStatus = {};
|
|
1223
|
+
const needsRemoteCheck = [];
|
|
1224
|
+
|
|
1225
|
+
for (const asset of assets) {
|
|
1226
|
+
const cached = syncState[asset.sourcePath];
|
|
1227
|
+
if (cached && cached.hash === asset.hash) {
|
|
1228
|
+
localStatus[asset.title] = { status: 'unchanged', uuid: cached.uuid };
|
|
1229
|
+
} else if (cached && cached.hash !== asset.hash) {
|
|
1230
|
+
localStatus[asset.title] = { status: 'updated', uuid: cached.uuid };
|
|
1231
|
+
} else {
|
|
1232
|
+
needsRemoteCheck.push(asset);
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
if (needsRemoteCheck.length > 0) {
|
|
1237
|
+
info('Comparing with remote...');
|
|
1238
|
+
const diffPayload = needsRemoteCheck.map(a => ({ title: a.title, content_hash: a.hash }));
|
|
1239
|
+
try {
|
|
1240
|
+
const data = await apiRequest('POST', '/api/v1/tokenboard/push/diff', { assets: diffPayload }, config.token, config.api);
|
|
1241
|
+
for (const r of data.results) {
|
|
1242
|
+
localStatus[r.title] = { status: r.status, uuid: r.remote_uuid || '' };
|
|
1243
|
+
}
|
|
1244
|
+
} catch (e) {
|
|
1245
|
+
error(`Diff API error: ${e.message}`);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
log('');
|
|
1250
|
+
let newCount = 0, updatedCount = 0, unchangedCount = 0;
|
|
1251
|
+
|
|
1252
|
+
for (const asset of assets) {
|
|
1253
|
+
const st = localStatus[asset.title] || { status: 'new', uuid: '' };
|
|
1254
|
+
const status = st.status;
|
|
1255
|
+
const uuid = st.uuid || '';
|
|
1256
|
+
const uuidShort = uuid ? ` ${C.dim}(${uuid.substring(0, 8)})${C.reset}` : '';
|
|
1257
|
+
|
|
1258
|
+
if (status === 'new') {
|
|
1259
|
+
log(` ${C.green}+ new${C.reset} ${asset.title} ${C.dim}(${asset.files.length} files)${C.reset}`);
|
|
1260
|
+
newCount++;
|
|
1261
|
+
} else if (status === 'updated') {
|
|
1262
|
+
log(` ${C.yellow}~ modified${C.reset} ${asset.title}${uuidShort}`);
|
|
1263
|
+
updatedCount++;
|
|
1264
|
+
} else {
|
|
1265
|
+
log(` ${C.dim} unchanged ${asset.title}${uuidShort}${C.reset}`);
|
|
1266
|
+
unchangedCount++;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
log('');
|
|
1271
|
+
log(` ${C.bold}${assets.length}${C.reset} local assets: ${C.green}${newCount} new${C.reset} ${C.yellow}${updatedCount} modified${C.reset} ${C.dim}${unchangedCount} synced${C.reset}`);
|
|
1272
|
+
|
|
1273
|
+
if (newCount > 0 || updatedCount > 0) {
|
|
1274
|
+
log(`\n Run ${C.cyan}tokrepo sync ${args.positional[0] || ''}${C.reset} to push changes`);
|
|
1275
|
+
}
|
|
1276
|
+
log('');
|
|
1277
|
+
}
|
|
1278
|
+
|
|
851
1279
|
function showHelp() {
|
|
852
1280
|
log(`
|
|
853
1281
|
${C.bold}tokrepo${C.reset} — AI assets for humans and agents. Like GitHub, for AI experience.
|
|
@@ -865,10 +1293,12 @@ ${C.bold}DISCOVER & INSTALL${C.reset}
|
|
|
865
1293
|
${C.cyan}install${C.reset} <name|uuid> Smart install (auto-detects type & placement)
|
|
866
1294
|
${C.cyan}pull${C.reset} <url|uuid> Download raw asset files
|
|
867
1295
|
|
|
868
|
-
${C.bold}PUBLISH${C.reset}
|
|
869
|
-
${C.cyan}push${C.reset} [files...] Push files/directory
|
|
1296
|
+
${C.bold}PUBLISH & SYNC${C.reset}
|
|
1297
|
+
${C.cyan}push${C.reset} [files...] Push files/directory (creates new asset)
|
|
1298
|
+
${C.cyan}sync${C.reset} [dir] Sync directory to TokRepo (smart upsert)
|
|
1299
|
+
${C.cyan}status${C.reset} [dir] Show local vs remote diff
|
|
870
1300
|
${C.cyan}init${C.reset} Create .tokrepo.json project config
|
|
871
|
-
${C.cyan}update${C.reset} <uuid> [f] Update existing asset
|
|
1301
|
+
${C.cyan}update${C.reset} <uuid> [f] Update existing asset by UUID
|
|
872
1302
|
${C.cyan}delete${C.reset} <uuid> Delete an asset
|
|
873
1303
|
|
|
874
1304
|
${C.bold}ACCOUNT${C.reset}
|
|
@@ -892,6 +1322,16 @@ ${C.bold}INSTALL BEHAVIOR${C.reset}
|
|
|
892
1322
|
MCP → current dir (.json)
|
|
893
1323
|
Prompts → current dir (.md)
|
|
894
1324
|
|
|
1325
|
+
${C.bold}SYNC (the killer feature)${C.reset}
|
|
1326
|
+
${C.cyan}tokrepo sync ~/.claude/skills/${C.reset} # Sync all skills (default: private)
|
|
1327
|
+
${C.cyan}tokrepo sync ~/.claude/skills/ --public${C.reset} # Sync as public assets
|
|
1328
|
+
${C.cyan}tokrepo sync . -y${C.reset} # Sync current dir, skip confirm
|
|
1329
|
+
${C.cyan}tokrepo status${C.reset} # Show what would change
|
|
1330
|
+
|
|
1331
|
+
Sync scans a directory, detects new/modified assets, and pushes only
|
|
1332
|
+
what changed. Each subdirectory becomes one asset. Loose files become
|
|
1333
|
+
individual assets. Like ${C.bold}git push${C.reset} for your AI assets.
|
|
1334
|
+
|
|
895
1335
|
${C.bold}EXAMPLES${C.reset}
|
|
896
1336
|
tokrepo search "mcp server" # Find MCP configs
|
|
897
1337
|
tokrepo install ca000374-f5d8-... # Install by UUID
|
|
@@ -919,6 +1359,8 @@ async function main() {
|
|
|
919
1359
|
case 'login': await cmdLogin(); break;
|
|
920
1360
|
case 'init': await cmdInit(); break;
|
|
921
1361
|
case 'push': await cmdPush(); break;
|
|
1362
|
+
case 'sync': await cmdSync(); break;
|
|
1363
|
+
case 'status': case 'st': await cmdStatus(); break;
|
|
922
1364
|
case 'pull': await cmdPull(); break;
|
|
923
1365
|
case 'search': case 'find': await cmdSearch(); break;
|
|
924
1366
|
case 'install': case 'i': await cmdInstall(); break;
|
|
@@ -927,11 +1369,16 @@ async function main() {
|
|
|
927
1369
|
case 'delete': await cmdDelete(); break;
|
|
928
1370
|
case 'tags': await cmdTags(); break;
|
|
929
1371
|
case 'whoami': await cmdWhoami(); break;
|
|
1372
|
+
case '--version': case '-v': case 'version':
|
|
1373
|
+
log(`tokrepo ${CLI_VERSION}`); break;
|
|
930
1374
|
case 'help': case '--help': case '-h': case undefined:
|
|
931
1375
|
showHelp(); break;
|
|
932
1376
|
default:
|
|
933
1377
|
error(`Unknown command: ${command}. Run: tokrepo help`);
|
|
934
1378
|
}
|
|
1379
|
+
|
|
1380
|
+
// Non-blocking update check after command completes
|
|
1381
|
+
checkForUpdate();
|
|
935
1382
|
}
|
|
936
1383
|
|
|
937
1384
|
main().catch((e) => { error(e.message); });
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokrepo",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "AI assets for humans and agents — search, install, push. Like
|
|
3
|
+
"version": "3.1.0",
|
|
4
|
+
"description": "AI assets for humans and agents — sync, search, install, push. Like git push for your AI skills.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"tokrepo": "bin/tokrepo.js"
|
|
7
7
|
},
|