tokrepo 3.2.0 → 3.3.1
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 +369 -408
- package/package.json +3 -3
package/bin/tokrepo.js
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
|
-
const crypto = require('crypto');
|
|
6
5
|
const https = require('https');
|
|
7
6
|
const http = require('http');
|
|
8
7
|
const readline = require('readline');
|
|
@@ -22,10 +21,9 @@ const C = {
|
|
|
22
21
|
|
|
23
22
|
const CONFIG_DIR = path.join(require('os').homedir(), '.tokrepo');
|
|
24
23
|
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
25
|
-
const SYNC_STATE_FILE = path.join(CONFIG_DIR, 'sync-state.json');
|
|
26
24
|
const PROJECT_CONFIG = '.tokrepo.json';
|
|
27
25
|
const DEFAULT_API = 'https://api.tokrepo.com';
|
|
28
|
-
const CLI_VERSION = '3.
|
|
26
|
+
const CLI_VERSION = '3.3.0';
|
|
29
27
|
const VERSION_CHECK_FILE = path.join(require('os').homedir(), '.tokrepo', '.version-check');
|
|
30
28
|
|
|
31
29
|
// ─── Helpers ───
|
|
@@ -37,6 +35,11 @@ function warn(msg) { log(`${C.yellow}!${C.reset} ${msg}`); }
|
|
|
37
35
|
function info(msg) { log(`${C.cyan}→${C.reset} ${msg}`); }
|
|
38
36
|
|
|
39
37
|
function readConfig() {
|
|
38
|
+
// P0: TOKREPO_TOKEN env var takes priority (enables Agent automation)
|
|
39
|
+
const envToken = process.env.TOKREPO_TOKEN;
|
|
40
|
+
if (envToken) {
|
|
41
|
+
return { token: envToken, api: process.env.TOKREPO_API || DEFAULT_API };
|
|
42
|
+
}
|
|
40
43
|
try {
|
|
41
44
|
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
42
45
|
} catch {
|
|
@@ -106,15 +109,6 @@ function compareVersions(a, b) {
|
|
|
106
109
|
return 0;
|
|
107
110
|
}
|
|
108
111
|
|
|
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 });
|
|
116
|
-
}
|
|
117
|
-
|
|
118
112
|
function readProjectConfig(baseDir = process.cwd()) {
|
|
119
113
|
const configPath = path.join(baseDir, PROJECT_CONFIG);
|
|
120
114
|
try {
|
|
@@ -151,7 +145,7 @@ function apiRequest(method, urlPath, body, token, apiBase) {
|
|
|
151
145
|
|
|
152
146
|
const headers = {
|
|
153
147
|
'Content-Type': 'application/json',
|
|
154
|
-
'User-Agent':
|
|
148
|
+
'User-Agent': `tokrepo-cli/${CLI_VERSION}`,
|
|
155
149
|
};
|
|
156
150
|
if (token) {
|
|
157
151
|
headers['Authorization'] = `Bearer ${token}`;
|
|
@@ -216,8 +210,7 @@ function detectFileType(filename) {
|
|
|
216
210
|
|
|
217
211
|
// Guess tag from file type
|
|
218
212
|
function guessTag(fileType) {
|
|
219
|
-
|
|
220
|
-
const map = { skill: 'skill', prompt: 'prompt', script: 'script', config: 'config', mcp: 'mcp' };
|
|
213
|
+
const map = { skill: 'Skills', prompt: 'Prompts', script: 'Scripts', config: 'Configs' };
|
|
221
214
|
return map[fileType] || null;
|
|
222
215
|
}
|
|
223
216
|
|
|
@@ -326,11 +319,6 @@ function collectFiles(paths, baseDir) {
|
|
|
326
319
|
|
|
327
320
|
for (const p of paths) {
|
|
328
321
|
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
|
-
}
|
|
334
322
|
if (!fs.existsSync(resolved)) {
|
|
335
323
|
warn(`Not found: ${p}`);
|
|
336
324
|
continue;
|
|
@@ -388,15 +376,33 @@ function guessTitle(files, baseDir) {
|
|
|
388
376
|
|
|
389
377
|
async function cmdLogin() {
|
|
390
378
|
log(`\n${C.bold}tokrepo login${C.reset}\n`);
|
|
391
|
-
|
|
379
|
+
|
|
380
|
+
// Check for --token flag for manual token entry
|
|
381
|
+
const args = process.argv.slice(2);
|
|
382
|
+
const useToken = args.includes('--token') || args.includes('-t');
|
|
383
|
+
|
|
384
|
+
if (useToken) {
|
|
385
|
+
// Manual token flow
|
|
386
|
+
info('Paste your API token (from https://tokrepo.com/en/my/settings)');
|
|
387
|
+
log('');
|
|
388
|
+
const token = await ask('API Token:');
|
|
389
|
+
if (!token) error('Token is required');
|
|
390
|
+
return await saveAndVerifyToken(token);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Browser OAuth flow (default)
|
|
394
|
+
info('Opening browser for authentication...');
|
|
395
|
+
log(` ${C.dim}(Use ${C.cyan}tokrepo login --token${C.dim} to paste a token manually)${C.reset}`);
|
|
392
396
|
log('');
|
|
393
397
|
|
|
394
|
-
const token = await
|
|
395
|
-
if (!token) error('
|
|
398
|
+
const token = await browserAuthFlow();
|
|
399
|
+
if (!token) error('Authentication failed or was cancelled');
|
|
400
|
+
return await saveAndVerifyToken(token);
|
|
401
|
+
}
|
|
396
402
|
|
|
403
|
+
async function saveAndVerifyToken(token) {
|
|
397
404
|
writeConfig({ token, api: DEFAULT_API });
|
|
398
405
|
success(`Config saved to ${CONFIG_FILE}`);
|
|
399
|
-
|
|
400
406
|
try {
|
|
401
407
|
const config = readConfig();
|
|
402
408
|
const data = await fetchCurrentUser(config);
|
|
@@ -406,6 +412,71 @@ async function cmdLogin() {
|
|
|
406
412
|
}
|
|
407
413
|
}
|
|
408
414
|
|
|
415
|
+
function browserAuthFlow() {
|
|
416
|
+
return new Promise((resolve) => {
|
|
417
|
+
const server = http.createServer((req, res) => {
|
|
418
|
+
// CORS headers for browser fetch
|
|
419
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
420
|
+
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
|
421
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
422
|
+
|
|
423
|
+
if (req.method === 'OPTIONS') {
|
|
424
|
+
res.writeHead(204);
|
|
425
|
+
res.end();
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (req.method === 'POST' && req.url === '/callback') {
|
|
430
|
+
let body = '';
|
|
431
|
+
req.on('data', (chunk) => { body += chunk; });
|
|
432
|
+
req.on('end', () => {
|
|
433
|
+
try {
|
|
434
|
+
const data = JSON.parse(body);
|
|
435
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
436
|
+
res.end(JSON.stringify({ ok: true }));
|
|
437
|
+
server.close();
|
|
438
|
+
clearTimeout(timeout);
|
|
439
|
+
resolve(data.token);
|
|
440
|
+
} catch {
|
|
441
|
+
res.writeHead(400);
|
|
442
|
+
res.end('Invalid request');
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
res.writeHead(404);
|
|
449
|
+
res.end('Not found');
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
// Listen on random port
|
|
453
|
+
server.listen(0, '127.0.0.1', () => {
|
|
454
|
+
const port = server.address().port;
|
|
455
|
+
const authUrl = `https://tokrepo.com/en/cli-auth?port=${port}`;
|
|
456
|
+
|
|
457
|
+
info(`Listening on http://127.0.0.1:${port}`);
|
|
458
|
+
log(` ${C.dim}If browser doesn't open, visit:${C.reset}`);
|
|
459
|
+
log(` ${C.cyan}${authUrl}${C.reset}`);
|
|
460
|
+
log('');
|
|
461
|
+
info('Waiting for authorization...');
|
|
462
|
+
|
|
463
|
+
// Open browser
|
|
464
|
+
const opener = process.platform === 'darwin' ? 'open'
|
|
465
|
+
: process.platform === 'win32' ? 'start'
|
|
466
|
+
: 'xdg-open';
|
|
467
|
+
require('child_process').exec(`${opener} "${authUrl}"`);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
// Timeout after 5 minutes
|
|
471
|
+
const timeout = setTimeout(() => {
|
|
472
|
+
server.close();
|
|
473
|
+
log('');
|
|
474
|
+
warn('Authorization timed out (5 minutes). Try again or use --token flag.');
|
|
475
|
+
resolve(null);
|
|
476
|
+
}, 5 * 60 * 1000);
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
409
480
|
async function cmdPush() {
|
|
410
481
|
const args = parseArgs(process.argv);
|
|
411
482
|
|
|
@@ -446,7 +517,7 @@ async function cmdPush() {
|
|
|
446
517
|
// Flags override config
|
|
447
518
|
title = args.flags.title || title || guessTitle(filesToPush, baseDir);
|
|
448
519
|
description = args.flags.desc || description || '';
|
|
449
|
-
visibility = args.flags.public ? 1 : (args.flags.private ? 0 : (projectConfig?.visibility ??
|
|
520
|
+
visibility = args.flags.public ? 1 : (args.flags.private ? 0 : (projectConfig?.visibility ?? 0));
|
|
450
521
|
tags = args.flags.tags || tags || [];
|
|
451
522
|
|
|
452
523
|
// Read files and detect types
|
|
@@ -482,8 +553,8 @@ async function cmdPush() {
|
|
|
482
553
|
// Show summary
|
|
483
554
|
log(`\n${C.bold}tokrepo push${C.reset}\n`);
|
|
484
555
|
log(` ${C.bold}Title:${C.reset} ${title}`);
|
|
485
|
-
log(` ${C.bold}Visibility:${C.reset} ${visibility === 1 ? `${C.green}public${C.reset}` : `${C.yellow}private${C.reset}`}`);
|
|
486
|
-
log(` ${C.bold}Files:${C.reset} ${pushFiles.length}`);
|
|
556
|
+
log(` ${C.bold}Visibility:${C.reset} ${visibility === 1 ? `${C.green}public${C.reset} (visible to everyone)` : `${C.yellow}private${C.reset} (only you can see)`}`);
|
|
557
|
+
log(` ${C.bold}Files:${C.reset} ${pushFiles.length} (only these files will be uploaded)`);
|
|
487
558
|
if (detectedTags.size > 0) {
|
|
488
559
|
log(` ${C.bold}Tags:${C.reset} ${Array.from(detectedTags).join(', ')}`);
|
|
489
560
|
}
|
|
@@ -501,7 +572,7 @@ async function cmdPush() {
|
|
|
501
572
|
info('Pushing...');
|
|
502
573
|
|
|
503
574
|
try {
|
|
504
|
-
const data = await apiRequest('POST', '/api/v1/tokenboard/push/
|
|
575
|
+
const data = await apiRequest('POST', '/api/v1/tokenboard/push/upsert', {
|
|
505
576
|
title,
|
|
506
577
|
description,
|
|
507
578
|
files: pushFiles,
|
|
@@ -511,7 +582,10 @@ async function cmdPush() {
|
|
|
511
582
|
}, config.token, config.api);
|
|
512
583
|
|
|
513
584
|
log('');
|
|
514
|
-
|
|
585
|
+
const actionLabel = data.action === 'created' ? 'Created'
|
|
586
|
+
: data.action === 'updated' ? 'Updated'
|
|
587
|
+
: 'Unchanged (no diff)';
|
|
588
|
+
success(`${actionLabel}!`);
|
|
515
589
|
log(`\n ${C.bold}URL:${C.reset} ${C.cyan}${data.url}${C.reset}`);
|
|
516
590
|
log(` ${C.bold}UUID:${C.reset} ${data.uuid}\n`);
|
|
517
591
|
} catch (e) {
|
|
@@ -540,7 +614,7 @@ async function cmdInit() {
|
|
|
540
614
|
title: title || dirName,
|
|
541
615
|
description: description || '',
|
|
542
616
|
files: ['*.md', '*.sh', '*.py', '*.js', '*.mjs', '*.ts', '*.json', '*.yaml'],
|
|
543
|
-
visibility:
|
|
617
|
+
visibility: 0,
|
|
544
618
|
tags: [],
|
|
545
619
|
};
|
|
546
620
|
|
|
@@ -555,17 +629,15 @@ async function cmdInit() {
|
|
|
555
629
|
|
|
556
630
|
async function cmdPull() {
|
|
557
631
|
const urlOrUuid = process.argv[3];
|
|
558
|
-
if (!urlOrUuid) error('Usage: tokrepo pull <url
|
|
632
|
+
if (!urlOrUuid) error('Usage: tokrepo pull <url|uuid|@user/name>');
|
|
559
633
|
|
|
560
634
|
log(`\n${C.bold}tokrepo pull${C.reset}\n`);
|
|
561
635
|
|
|
562
|
-
let uuid = urlOrUuid;
|
|
563
|
-
const urlMatch = urlOrUuid.match(/workflows\/([a-f0-9-]+)/);
|
|
564
|
-
if (urlMatch) uuid = urlMatch[1];
|
|
565
|
-
|
|
566
636
|
const config = readConfig();
|
|
567
637
|
const apiBase = config?.api || DEFAULT_API;
|
|
568
638
|
|
|
639
|
+
let uuid = await resolveAssetId(urlOrUuid, config, apiBase);
|
|
640
|
+
|
|
569
641
|
info(`Fetching ${uuid}...`);
|
|
570
642
|
|
|
571
643
|
try {
|
|
@@ -591,13 +663,70 @@ async function cmdPull() {
|
|
|
591
663
|
}
|
|
592
664
|
}
|
|
593
665
|
|
|
666
|
+
// Normalize query: replace hyphens/underscores/dots with spaces for better matching
|
|
667
|
+
function normalizeQuery(q) {
|
|
668
|
+
return q.replace(/[-_.]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Resolve various input formats to a UUID:
|
|
672
|
+
// - UUID directly: "ca000374-f5d8-..."
|
|
673
|
+
// - Full URL: "https://tokrepo.com/en/workflows/ca000374-f5d8-..."
|
|
674
|
+
// - @username/asset-name: search by author + keyword
|
|
675
|
+
// - Plain name: search by keyword
|
|
676
|
+
async function resolveAssetId(input, config, apiBase) {
|
|
677
|
+
// Already a UUID
|
|
678
|
+
if (/^[a-f0-9-]{36}$/.test(input)) return input;
|
|
679
|
+
|
|
680
|
+
// URL containing UUID
|
|
681
|
+
const urlMatch = input.match(/workflows\/([a-f0-9-]{36})/);
|
|
682
|
+
if (urlMatch) return urlMatch[1];
|
|
683
|
+
|
|
684
|
+
// @username/asset-name format
|
|
685
|
+
const atMatch = input.match(/^@([^/]+)\/(.+)$/);
|
|
686
|
+
if (atMatch) {
|
|
687
|
+
const [, username, assetName] = atMatch;
|
|
688
|
+
const normalizedName = normalizeQuery(assetName);
|
|
689
|
+
info(`Searching for "${normalizedName}" by @${username}...`);
|
|
690
|
+
// Search by keyword, then filter by author nickname
|
|
691
|
+
const encoded = encodeURIComponent(normalizedName);
|
|
692
|
+
try {
|
|
693
|
+
const data = await apiRequest('GET', `/api/v1/tokenboard/workflows/list?keyword=${encoded}&page=1&page_size=20&sort_by=views`, null, config?.token, apiBase);
|
|
694
|
+
const items = data.list || data.items || [];
|
|
695
|
+
const match = items.find(w => {
|
|
696
|
+
const authorName = (w.author?.nickname || w.nickname || '').toLowerCase();
|
|
697
|
+
return authorName === username.toLowerCase();
|
|
698
|
+
});
|
|
699
|
+
if (match) return match.uuid;
|
|
700
|
+
// Fallback: return first result
|
|
701
|
+
if (items.length > 0) {
|
|
702
|
+
warn(`No exact match for @${username}, using best match: "${items[0].title}"`);
|
|
703
|
+
return items[0].uuid;
|
|
704
|
+
}
|
|
705
|
+
} catch { /* fall through */ }
|
|
706
|
+
error(`Asset not found: ${input}`);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Plain name: search by keyword (normalize separators)
|
|
710
|
+
const normalizedInput = normalizeQuery(input);
|
|
711
|
+
info(`Searching for "${normalizedInput}"...`);
|
|
712
|
+
const encoded = encodeURIComponent(normalizedInput);
|
|
713
|
+
try {
|
|
714
|
+
const data = await apiRequest('GET', `/api/v1/tokenboard/workflows/list?keyword=${encoded}&page=1&page_size=5&sort_by=views`, null, config?.token, apiBase);
|
|
715
|
+
const items = data.list || data.items || [];
|
|
716
|
+
if (items.length > 0) return items[0].uuid;
|
|
717
|
+
} catch { /* fall through */ }
|
|
718
|
+
error(`Asset not found: ${input}`);
|
|
719
|
+
}
|
|
720
|
+
|
|
594
721
|
// ─── Search ───
|
|
595
722
|
|
|
596
723
|
async function cmdSearch() {
|
|
597
|
-
const
|
|
598
|
-
if (!
|
|
724
|
+
const rawQuery = process.argv.slice(3).join(' ');
|
|
725
|
+
if (!rawQuery) error('Usage: tokrepo search <keyword>');
|
|
599
726
|
|
|
600
|
-
|
|
727
|
+
const query = normalizeQuery(rawQuery);
|
|
728
|
+
const displayQuery = query !== rawQuery ? `"${rawQuery}" → "${query}"` : `"${query}"`;
|
|
729
|
+
log(`\n${C.bold}tokrepo search${C.reset} ${displayQuery}\n`);
|
|
601
730
|
|
|
602
731
|
const config = readConfig();
|
|
603
732
|
const apiBase = config?.api || DEFAULT_API;
|
|
@@ -608,7 +737,14 @@ async function cmdSearch() {
|
|
|
608
737
|
|
|
609
738
|
if (!data.list || data.list.length === 0) {
|
|
610
739
|
info('No assets found.');
|
|
611
|
-
|
|
740
|
+
// Suggest broader search terms
|
|
741
|
+
const words = query.split(' ');
|
|
742
|
+
if (words.length > 1) {
|
|
743
|
+
log(`\n ${C.dim}Try fewer keywords:${C.reset}`);
|
|
744
|
+
log(` ${C.cyan}tokrepo search ${words[0]}${C.reset}`);
|
|
745
|
+
log(` ${C.cyan}tokrepo search ${words.slice(0, 2).join(' ')}${C.reset}`);
|
|
746
|
+
}
|
|
747
|
+
log(`\n ${C.dim}Browse all: https://tokrepo.com/en/featured${C.reset}\n`);
|
|
612
748
|
return;
|
|
613
749
|
}
|
|
614
750
|
|
|
@@ -619,8 +755,13 @@ async function cmdSearch() {
|
|
|
619
755
|
const tags = (wf.tags || []).map(t => t.name).join(', ');
|
|
620
756
|
const views = wf.view_count || 0;
|
|
621
757
|
const votes = wf.vote_count || 0;
|
|
758
|
+
// Truncate long descriptions for readability
|
|
759
|
+
const desc = (wf.description || '').length > 80
|
|
760
|
+
? wf.description.substring(0, 77) + '...'
|
|
761
|
+
: (wf.description || '');
|
|
622
762
|
|
|
623
763
|
log(` ${C.dim}${String(i + 1).padStart(2)}.${C.reset} ${C.bold}${wf.title}${C.reset}`);
|
|
764
|
+
if (desc) log(` ${desc}`);
|
|
624
765
|
if (tags) log(` ${C.cyan}${tags}${C.reset} ${C.dim}★${votes} 👁${views}${C.reset}`);
|
|
625
766
|
log(` ${C.dim}tokrepo install ${wf.uuid}${C.reset}`);
|
|
626
767
|
log('');
|
|
@@ -658,18 +799,23 @@ Examples:
|
|
|
658
799
|
}
|
|
659
800
|
// UUID format check
|
|
660
801
|
else if (!/^[a-f0-9-]{36}$/.test(target)) {
|
|
661
|
-
// Search by name
|
|
662
|
-
|
|
802
|
+
// Search by name (normalize separators for better matching)
|
|
803
|
+
const normalizedTarget = normalizeQuery(target);
|
|
804
|
+
info(`Searching for "${normalizedTarget}"...`);
|
|
663
805
|
try {
|
|
664
|
-
const encoded = encodeURIComponent(
|
|
806
|
+
const encoded = encodeURIComponent(normalizedTarget);
|
|
665
807
|
const searchData = await apiRequest('GET', `/api/v1/tokenboard/workflows/list?keyword=${encoded}&page=1&page_size=5&sort_by=views`, null, config?.token, apiBase);
|
|
666
808
|
|
|
667
809
|
if (!searchData.list || searchData.list.length === 0) {
|
|
668
810
|
error(`No asset found matching "${target}". Try: tokrepo search ${target}`);
|
|
669
811
|
}
|
|
670
812
|
|
|
671
|
-
// If
|
|
672
|
-
const
|
|
813
|
+
// If title contains all query words, prefer it
|
|
814
|
+
const queryWords = normalizedTarget.toLowerCase().split(' ');
|
|
815
|
+
const exact = searchData.list.find(w => {
|
|
816
|
+
const title = w.title.toLowerCase();
|
|
817
|
+
return queryWords.every(word => title.includes(word));
|
|
818
|
+
});
|
|
673
819
|
const chosen = exact || searchData.list[0];
|
|
674
820
|
|
|
675
821
|
uuid = chosen.uuid;
|
|
@@ -752,9 +898,6 @@ Examples:
|
|
|
752
898
|
}
|
|
753
899
|
case 'mcp':
|
|
754
900
|
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.');
|
|
758
901
|
// Save as mcp config, hint about manual merge
|
|
759
902
|
if (!fileName.endsWith('.json')) fileName = fileName.replace(/\.md$/, '.json');
|
|
760
903
|
break;
|
|
@@ -785,10 +928,14 @@ Examples:
|
|
|
785
928
|
}
|
|
786
929
|
}
|
|
787
930
|
|
|
788
|
-
// Sanitize fileName to prevent path traversal from API response
|
|
789
|
-
fileName = path.basename(fileName);
|
|
790
931
|
const destPath = path.join(destDir, fileName);
|
|
791
932
|
|
|
933
|
+
// Path traversal guard: ensure resolved path stays inside destDir
|
|
934
|
+
if (!path.resolve(destPath).startsWith(path.resolve(destDir) + path.sep) && path.resolve(destPath) !== path.resolve(destDir)) {
|
|
935
|
+
warn(`Skipping "${fileName}" — path traversal detected`);
|
|
936
|
+
continue;
|
|
937
|
+
}
|
|
938
|
+
|
|
792
939
|
// Don't overwrite without warning
|
|
793
940
|
if (fs.existsSync(destPath)) {
|
|
794
941
|
warn(`File exists: ${path.relative(process.cwd(), destPath)} (overwriting)`);
|
|
@@ -913,403 +1060,221 @@ async function cmdDelete() {
|
|
|
913
1060
|
}
|
|
914
1061
|
}
|
|
915
1062
|
|
|
916
|
-
async function
|
|
917
|
-
|
|
1063
|
+
async function cmdClone() {
|
|
1064
|
+
const target = process.argv[3];
|
|
1065
|
+
if (!target) error('Usage: tokrepo clone @username');
|
|
1066
|
+
|
|
1067
|
+
log(`\n${C.bold}tokrepo clone${C.reset}\n`);
|
|
918
1068
|
|
|
919
1069
|
const config = readConfig();
|
|
920
1070
|
const apiBase = config?.api || DEFAULT_API;
|
|
921
1071
|
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
for (const tag of data.tags) {
|
|
926
|
-
log(` ${C.cyan}${tag.name}${C.reset}${tag.count ? ` ${C.dim}(${tag.count} assets)${C.reset}` : ''}`);
|
|
927
|
-
}
|
|
928
|
-
log('');
|
|
929
|
-
} catch (e) {
|
|
930
|
-
error(`Failed: ${e.message}`);
|
|
931
|
-
}
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
// ─── Sync: scan directory, diff with remote, upsert changes ───
|
|
1072
|
+
// Extract username from @username format
|
|
1073
|
+
let username = target;
|
|
1074
|
+
if (username.startsWith('@')) username = username.slice(1);
|
|
935
1075
|
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
function isProjectDirectory(dirPath) {
|
|
948
|
-
const PROJECT_MARKERS = [
|
|
949
|
-
'package.json', '.tokrepo.json', 'go.mod', 'Cargo.toml',
|
|
950
|
-
'pyproject.toml', 'setup.py', 'Gemfile', 'pom.xml',
|
|
951
|
-
'build.gradle', 'Makefile', 'CMakeLists.txt', 'deno.json',
|
|
952
|
-
];
|
|
953
|
-
return PROJECT_MARKERS.some(m => fs.existsSync(path.join(dirPath, m)));
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
function scanDirectory(dirPath) {
|
|
957
|
-
const assets = [];
|
|
958
|
-
const SKIP_DIRS = new Set(['node_modules', '.git', '__pycache__', '.venv', 'dist', 'build']);
|
|
959
|
-
const SKIP_FILES = new Set(['.DS_Store', 'Thumbs.db', 'package-lock.json', 'yarn.lock']);
|
|
960
|
-
|
|
961
|
-
// If the directory itself is a project, treat the whole thing as one asset
|
|
962
|
-
if (isProjectDirectory(dirPath)) {
|
|
963
|
-
const files = collectAssetFiles(dirPath);
|
|
964
|
-
if (files.length > 0) {
|
|
965
|
-
const title = guessAssetTitle(files, path.basename(dirPath));
|
|
966
|
-
const hash = computeHash(files);
|
|
967
|
-
const detectedTags = new Set();
|
|
968
|
-
for (const f of files) {
|
|
969
|
-
const ft = detectFileType(f.name);
|
|
970
|
-
const tag = guessTag(ft);
|
|
971
|
-
if (tag) detectedTags.add(tag);
|
|
1076
|
+
// Step 1: Find user's UUID by searching for their workflows
|
|
1077
|
+
info(`Finding user @${username}...`);
|
|
1078
|
+
let authorUuid = '';
|
|
1079
|
+
try {
|
|
1080
|
+
const searchData = await apiRequest('GET', `/api/v1/tokenboard/workflows/list?keyword=${encodeURIComponent(username)}&page=1&page_size=5&sort_by=latest`, null, config?.token, apiBase);
|
|
1081
|
+
const items = searchData.list || searchData.items || [];
|
|
1082
|
+
for (const item of items) {
|
|
1083
|
+
const authorName = (item.author?.nickname || item.nickname || '').toLowerCase();
|
|
1084
|
+
if (authorName === username.toLowerCase()) {
|
|
1085
|
+
authorUuid = item.author?.uuid || item.author_uuid || '';
|
|
1086
|
+
break;
|
|
972
1087
|
}
|
|
973
|
-
assets.push({ title, files, hash, tags: Array.from(detectedTags), sourcePath: dirPath });
|
|
974
1088
|
}
|
|
975
|
-
|
|
1089
|
+
} catch { /* fall through */ }
|
|
1090
|
+
|
|
1091
|
+
if (!authorUuid) {
|
|
1092
|
+
// Try fetching user's own workflows if logged in and cloning self
|
|
1093
|
+
if (config?.token) {
|
|
1094
|
+
try {
|
|
1095
|
+
const me = await apiRequest('GET', '/api/v1/tokenboard/auth/me', null, config.token, apiBase);
|
|
1096
|
+
if (me.nickname?.toLowerCase() === username.toLowerCase()) {
|
|
1097
|
+
authorUuid = me.uuid;
|
|
1098
|
+
}
|
|
1099
|
+
} catch { /* fall through */ }
|
|
1100
|
+
}
|
|
1101
|
+
if (!authorUuid) error(`User @${username} not found. Make sure they have published assets.`);
|
|
976
1102
|
}
|
|
977
1103
|
|
|
978
|
-
//
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
if (files.length === 0) continue;
|
|
993
|
-
|
|
994
|
-
const title = entry.name;
|
|
995
|
-
const hash = computeHash(files);
|
|
996
|
-
const detectedTags = new Set();
|
|
997
|
-
for (const f of files) {
|
|
998
|
-
const ft = detectFileType(f.name);
|
|
999
|
-
const tag = guessTag(ft);
|
|
1000
|
-
if (tag) detectedTags.add(tag);
|
|
1001
|
-
}
|
|
1002
|
-
|
|
1003
|
-
assets.push({ title, files, hash, tags: Array.from(detectedTags), sourcePath: fullPath });
|
|
1004
|
-
} else if (entry.isFile()) {
|
|
1005
|
-
// Loose file
|
|
1006
|
-
const ext = path.extname(entry.name).toLowerCase();
|
|
1007
|
-
const validExts = ['.md', '.sh', '.py', '.js', '.mjs', '.ts', '.json', '.yaml', '.yml', '.toml', '.prompt'];
|
|
1008
|
-
if (validExts.includes(ext) || entry.name === '.cursorrules' || entry.name === '.windsurfrules') {
|
|
1009
|
-
looseFiles.push({ path: fullPath, name: entry.name });
|
|
1010
|
-
}
|
|
1104
|
+
// Step 2: List all public workflows by this author
|
|
1105
|
+
info(`Fetching all assets by @${username}...`);
|
|
1106
|
+
let allItems = [];
|
|
1107
|
+
let page = 1;
|
|
1108
|
+
while (true) {
|
|
1109
|
+
try {
|
|
1110
|
+
const data = await apiRequest('GET', `/api/v1/tokenboard/workflows/list?author_uuid=${authorUuid}&page=${page}&page_size=50&sort_by=latest`, null, config?.token, apiBase);
|
|
1111
|
+
const items = data.list || data.items || [];
|
|
1112
|
+
if (items.length === 0) break;
|
|
1113
|
+
allItems = allItems.concat(items);
|
|
1114
|
+
if (items.length < 50) break;
|
|
1115
|
+
page++;
|
|
1116
|
+
} catch (e) {
|
|
1117
|
+
error(`Failed to list assets: ${e.message}`);
|
|
1011
1118
|
}
|
|
1012
1119
|
}
|
|
1013
1120
|
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
if (!content.trim()) continue;
|
|
1121
|
+
if (allItems.length === 0) {
|
|
1122
|
+
info(`@${username} has no public assets.`);
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1019
1125
|
|
|
1020
|
-
|
|
1021
|
-
const title = guessAssetTitle(files, path.basename(f.name, path.extname(f.name)));
|
|
1022
|
-
const hash = computeHash(files);
|
|
1023
|
-
const ft = detectFileType(f.name);
|
|
1024
|
-
const tag = guessTag(ft);
|
|
1126
|
+
log(` Found ${C.bold}${allItems.length}${C.reset} assets\n`);
|
|
1025
1127
|
|
|
1026
|
-
|
|
1128
|
+
// Step 3: Create directory and pull each asset
|
|
1129
|
+
const outDir = path.join(process.cwd(), username);
|
|
1130
|
+
if (!fs.existsSync(outDir)) {
|
|
1131
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
1027
1132
|
}
|
|
1028
1133
|
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
const SKIP = new Set(['.DS_Store', 'node_modules', '.git', '__pycache__']);
|
|
1134
|
+
let downloaded = 0;
|
|
1135
|
+
for (const item of allItems) {
|
|
1136
|
+
const title = item.title || item.uuid;
|
|
1137
|
+
const safeDirName = title.replace(/[/\\?%*:|"<>]/g, '-').substring(0, 80);
|
|
1138
|
+
const assetDir = path.join(outDir, safeDirName);
|
|
1035
1139
|
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
if (
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
let content;
|
|
1050
|
-
try { content = fs.readFileSync(fullPath, 'utf8'); } catch { continue; }
|
|
1051
|
-
if (!content.trim()) continue;
|
|
1052
|
-
files.push({ name: relPath, content, type: detectFileType(relPath) });
|
|
1140
|
+
try {
|
|
1141
|
+
const detail = await apiRequest('GET', `/api/v1/tokenboard/workflows/detail?uuid=${item.uuid}`, null, config?.token, apiBase);
|
|
1142
|
+
const workflow = detail.workflow;
|
|
1143
|
+
|
|
1144
|
+
if (workflow.steps && workflow.steps.length > 0) {
|
|
1145
|
+
if (!fs.existsSync(assetDir)) fs.mkdirSync(assetDir, { recursive: true });
|
|
1146
|
+
for (const step of workflow.steps) {
|
|
1147
|
+
const content = step.prompt_template || step.promptTemplate;
|
|
1148
|
+
if (content) {
|
|
1149
|
+
const fileName = `${step.title || 'step-' + step.step_order}`;
|
|
1150
|
+
const safeName = fileName.replace(/[/\\?%*:|"<>]/g, '-');
|
|
1151
|
+
fs.writeFileSync(path.join(assetDir, safeName), content);
|
|
1152
|
+
}
|
|
1053
1153
|
}
|
|
1154
|
+
downloaded++;
|
|
1155
|
+
log(` ${C.green}✓${C.reset} ${safeDirName} ${C.dim}(${workflow.steps.length} files)${C.reset}`);
|
|
1054
1156
|
}
|
|
1157
|
+
} catch (e) {
|
|
1158
|
+
log(` ${C.yellow}!${C.reset} ${safeDirName} ${C.dim}(skipped: ${e.message})${C.reset}`);
|
|
1055
1159
|
}
|
|
1056
1160
|
}
|
|
1057
1161
|
|
|
1058
|
-
|
|
1059
|
-
|
|
1162
|
+
log('');
|
|
1163
|
+
success(`Cloned ${downloaded}/${allItems.length} assets to ./${username}/`);
|
|
1060
1164
|
}
|
|
1061
1165
|
|
|
1062
|
-
function
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1166
|
+
async function cmdTags() {
|
|
1167
|
+
log(`\n${C.bold}tokrepo tags${C.reset}\n`);
|
|
1168
|
+
|
|
1169
|
+
const config = readConfig();
|
|
1170
|
+
const apiBase = config?.api || DEFAULT_API;
|
|
1171
|
+
|
|
1172
|
+
try {
|
|
1173
|
+
const data = await apiRequest('GET', '/api/v1/tokenboard/tags/list', null, null, apiBase);
|
|
1174
|
+
log(` Available tags:\n`);
|
|
1175
|
+
for (const tag of data.tags) {
|
|
1176
|
+
log(` ${C.cyan}${tag.name}${C.reset}${tag.count ? ` ${C.dim}(${tag.count} assets)${C.reset}` : ''}`);
|
|
1068
1177
|
}
|
|
1178
|
+
log('');
|
|
1179
|
+
} catch (e) {
|
|
1180
|
+
error(`Failed: ${e.message}`);
|
|
1069
1181
|
}
|
|
1070
|
-
// Clean up fallback name
|
|
1071
|
-
return fallbackName
|
|
1072
|
-
.replace(/[-_]/g, ' ')
|
|
1073
|
-
.replace(/\b\w/g, c => c.toUpperCase());
|
|
1074
1182
|
}
|
|
1075
1183
|
|
|
1076
|
-
async function
|
|
1077
|
-
|
|
1184
|
+
async function cmdStatus() {
|
|
1185
|
+
log(`\n${C.bold}tokrepo status${C.reset}\n`);
|
|
1186
|
+
|
|
1078
1187
|
const config = readConfig();
|
|
1079
1188
|
if (!config || !config.token) error(`Not logged in. Run: ${C.cyan}tokrepo login${C.reset}`);
|
|
1080
1189
|
|
|
1081
|
-
const
|
|
1082
|
-
|
|
1083
|
-
: path.join(require('os').homedir(), '.claude', 'skills');
|
|
1190
|
+
const projectConfig = readProjectConfig();
|
|
1191
|
+
const baseDir = process.cwd();
|
|
1084
1192
|
|
|
1085
|
-
|
|
1086
|
-
|
|
1193
|
+
// Collect local files
|
|
1194
|
+
let filesToCheck;
|
|
1195
|
+
if (projectConfig) {
|
|
1196
|
+
const patterns = projectConfig.files || ['*.md'];
|
|
1197
|
+
filesToCheck = findFiles(patterns, baseDir);
|
|
1198
|
+
} else {
|
|
1199
|
+
filesToCheck = collectFiles(['.'], baseDir);
|
|
1087
1200
|
}
|
|
1088
1201
|
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
log(`\n${C.bold}tokrepo sync${C.reset}\n`);
|
|
1092
|
-
info(`Scanning ${targetDir}...`);
|
|
1093
|
-
|
|
1094
|
-
const assets = scanDirectory(targetDir);
|
|
1095
|
-
if (assets.length === 0) {
|
|
1096
|
-
info('No assets found in directory.');
|
|
1202
|
+
if (filesToCheck.length === 0) {
|
|
1203
|
+
info('No pushable files found in current directory.');
|
|
1097
1204
|
return;
|
|
1098
1205
|
}
|
|
1099
1206
|
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
const
|
|
1104
|
-
|
|
1105
|
-
// Classify each asset: check local state first, then remote diff for unknowns
|
|
1106
|
-
const needsRemoteCheck = [];
|
|
1107
|
-
const localStatus = {};
|
|
1108
|
-
|
|
1109
|
-
for (const asset of assets) {
|
|
1110
|
-
const key = asset.sourcePath;
|
|
1111
|
-
const cached = syncState[key];
|
|
1112
|
-
if (cached && cached.hash === asset.hash) {
|
|
1113
|
-
// Local hash matches last sync — unchanged
|
|
1114
|
-
localStatus[asset.title] = { status: 'unchanged', uuid: cached.uuid };
|
|
1115
|
-
} else if (cached && cached.hash !== asset.hash) {
|
|
1116
|
-
// Local hash differs from last sync — updated
|
|
1117
|
-
localStatus[asset.title] = { status: 'updated', uuid: cached.uuid };
|
|
1118
|
-
} else {
|
|
1119
|
-
// Not in local state — check remote
|
|
1120
|
-
needsRemoteCheck.push(asset);
|
|
1121
|
-
}
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
// Remote diff for assets not in local state
|
|
1125
|
-
if (needsRemoteCheck.length > 0) {
|
|
1126
|
-
info('Comparing with remote...');
|
|
1127
|
-
const diffPayload = needsRemoteCheck.map(a => ({ title: a.title, content_hash: a.hash }));
|
|
1128
|
-
try {
|
|
1129
|
-
const data = await apiRequest('POST', '/api/v1/tokenboard/push/diff', { assets: diffPayload }, config.token, config.api);
|
|
1130
|
-
for (const r of data.results) {
|
|
1131
|
-
localStatus[r.title] = { status: r.status, uuid: r.remote_uuid || '' };
|
|
1132
|
-
}
|
|
1133
|
-
} catch (e) {
|
|
1134
|
-
warn(`Diff API: ${e.message} — treating unknowns as new`);
|
|
1135
|
-
for (const a of needsRemoteCheck) {
|
|
1136
|
-
localStatus[a.title] = { status: 'new', uuid: '' };
|
|
1137
|
-
}
|
|
1138
|
-
}
|
|
1139
|
-
}
|
|
1207
|
+
// Build assets with content hashes for diff
|
|
1208
|
+
const crypto = require('crypto');
|
|
1209
|
+
const assets = [];
|
|
1210
|
+
const titleBase = projectConfig?.title || guessTitle(filesToCheck, baseDir);
|
|
1140
1211
|
|
|
1141
|
-
//
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
for (const
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
} else if (st.status === 'updated') {
|
|
1150
|
-
log(` ${C.yellow}~ updated${C.reset} ${asset.title}`);
|
|
1151
|
-
updatedCount++;
|
|
1152
|
-
} else {
|
|
1153
|
-
log(` ${C.dim}= unchanged ${asset.title}${C.reset}`);
|
|
1154
|
-
unchangedCount++;
|
|
1155
|
-
}
|
|
1212
|
+
// Each file becomes an asset for diff comparison
|
|
1213
|
+
// But the primary asset is the whole push unit
|
|
1214
|
+
const pushFiles = [];
|
|
1215
|
+
for (const f of filesToCheck) {
|
|
1216
|
+
let content;
|
|
1217
|
+
try { content = fs.readFileSync(f.path, 'utf8'); } catch { continue; }
|
|
1218
|
+
if (!content.trim()) continue;
|
|
1219
|
+
pushFiles.push({ name: f.relPath, content });
|
|
1156
1220
|
}
|
|
1157
1221
|
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
if (newCount === 0 && updatedCount === 0) {
|
|
1162
|
-
log('');
|
|
1163
|
-
success('Everything is up to date!');
|
|
1222
|
+
if (pushFiles.length === 0) {
|
|
1223
|
+
info('No readable text files found.');
|
|
1164
1224
|
return;
|
|
1165
1225
|
}
|
|
1166
1226
|
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
// Upsert each changed asset + save state
|
|
1176
|
-
let successCount = 0;
|
|
1177
|
-
let failCount = 0;
|
|
1178
|
-
|
|
1179
|
-
for (const asset of assets) {
|
|
1180
|
-
const st = localStatus[asset.title] || { status: 'new' };
|
|
1181
|
-
if (st.status === 'unchanged') continue;
|
|
1182
|
-
|
|
1183
|
-
const totalChars = asset.files.reduce((sum, f) => sum + f.content.length, 0);
|
|
1184
|
-
|
|
1185
|
-
try {
|
|
1186
|
-
const data = await apiRequest('POST', '/api/v1/tokenboard/push/upsert', {
|
|
1187
|
-
title: asset.title,
|
|
1188
|
-
files: asset.files,
|
|
1189
|
-
tags: asset.tags,
|
|
1190
|
-
token_cost: String(Math.round(totalChars / 4)),
|
|
1191
|
-
visibility,
|
|
1192
|
-
}, config.token, config.api);
|
|
1193
|
-
|
|
1194
|
-
const action = data.action === 'created' ? C.green + '+ created' : C.yellow + '~ updated';
|
|
1195
|
-
log(` ${action}${C.reset} ${asset.title} ${C.dim}${data.url}${C.reset}`);
|
|
1196
|
-
|
|
1197
|
-
// Save to local sync state
|
|
1198
|
-
syncState[asset.sourcePath] = {
|
|
1199
|
-
uuid: data.uuid,
|
|
1200
|
-
hash: asset.hash,
|
|
1201
|
-
title: asset.title,
|
|
1202
|
-
url: data.url,
|
|
1203
|
-
lastSync: new Date().toISOString(),
|
|
1204
|
-
};
|
|
1205
|
-
successCount++;
|
|
1206
|
-
} catch (e) {
|
|
1207
|
-
log(` ${C.red}✗ failed${C.reset} ${asset.title}: ${e.message}`);
|
|
1208
|
-
failCount++;
|
|
1209
|
-
}
|
|
1210
|
-
}
|
|
1211
|
-
|
|
1212
|
-
// Persist sync state
|
|
1213
|
-
writeSyncState(syncState);
|
|
1214
|
-
|
|
1215
|
-
log('');
|
|
1216
|
-
if (failCount === 0) {
|
|
1217
|
-
success(`Synced ${successCount} assets!`);
|
|
1218
|
-
} else {
|
|
1219
|
-
warn(`${successCount} synced, ${failCount} failed`);
|
|
1220
|
-
}
|
|
1221
|
-
log('');
|
|
1222
|
-
}
|
|
1223
|
-
|
|
1224
|
-
async function cmdStatus() {
|
|
1225
|
-
const args = parseArgs(process.argv);
|
|
1226
|
-
const config = readConfig();
|
|
1227
|
-
if (!config || !config.token) error(`Not logged in. Run: ${C.cyan}tokrepo login${C.reset}`);
|
|
1228
|
-
|
|
1229
|
-
const targetDir = args.positional[0]
|
|
1230
|
-
? path.resolve(args.positional[0])
|
|
1231
|
-
: path.join(require('os').homedir(), '.claude', 'skills');
|
|
1232
|
-
|
|
1233
|
-
if (!fs.existsSync(targetDir)) {
|
|
1234
|
-
error(`Directory not found: ${targetDir}`);
|
|
1227
|
+
// Compute content hash matching backend's computeContentHash format
|
|
1228
|
+
const h = crypto.createHash('sha256');
|
|
1229
|
+
for (const f of pushFiles) {
|
|
1230
|
+
h.update(f.name);
|
|
1231
|
+
h.update('\0');
|
|
1232
|
+
h.update(f.content);
|
|
1233
|
+
h.update('\0');
|
|
1235
1234
|
}
|
|
1235
|
+
const localHash = h.digest('hex');
|
|
1236
1236
|
|
|
1237
|
-
|
|
1238
|
-
info(`Scanning ${targetDir}...`);
|
|
1237
|
+
assets.push({ title: titleBase, content_hash: localHash });
|
|
1239
1238
|
|
|
1240
|
-
|
|
1241
|
-
if (assets.length === 0) {
|
|
1242
|
-
info('No assets found in directory.');
|
|
1243
|
-
return;
|
|
1244
|
-
}
|
|
1239
|
+
info('Comparing local files with remote...');
|
|
1245
1240
|
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
localStatus[asset.title] = { status: 'unchanged', uuid: cached.uuid };
|
|
1255
|
-
} else if (cached && cached.hash !== asset.hash) {
|
|
1256
|
-
localStatus[asset.title] = { status: 'updated', uuid: cached.uuid };
|
|
1257
|
-
} else {
|
|
1258
|
-
needsRemoteCheck.push(asset);
|
|
1241
|
+
try {
|
|
1242
|
+
const data = await apiRequest('POST', '/api/v1/tokenboard/push/diff', { assets }, config.token, config.api);
|
|
1243
|
+
log('');
|
|
1244
|
+
for (const r of data.results) {
|
|
1245
|
+
const icon = r.status === 'new' ? `${C.green}+ new${C.reset}`
|
|
1246
|
+
: r.status === 'updated' ? `${C.yellow}~ modified${C.reset}`
|
|
1247
|
+
: `${C.dim}= unchanged${C.reset}`;
|
|
1248
|
+
log(` ${icon} ${r.title}${r.remote_uuid ? ` ${C.dim}(${r.remote_uuid.substring(0, 8)}...)${C.reset}` : ''}`);
|
|
1259
1249
|
}
|
|
1260
|
-
|
|
1250
|
+
log('');
|
|
1261
1251
|
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
const
|
|
1265
|
-
try {
|
|
1266
|
-
const data = await apiRequest('POST', '/api/v1/tokenboard/push/diff', { assets: diffPayload }, config.token, config.api);
|
|
1267
|
-
for (const r of data.results) {
|
|
1268
|
-
localStatus[r.title] = { status: r.status, uuid: r.remote_uuid || '' };
|
|
1269
|
-
}
|
|
1270
|
-
} catch (e) {
|
|
1271
|
-
error(`Diff API error: ${e.message}`);
|
|
1272
|
-
}
|
|
1273
|
-
}
|
|
1252
|
+
const newCount = data.results.filter(r => r.status === 'new').length;
|
|
1253
|
+
const updatedCount = data.results.filter(r => r.status === 'updated').length;
|
|
1254
|
+
const unchangedCount = data.results.filter(r => r.status === 'unchanged').length;
|
|
1274
1255
|
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
for (const asset of assets) {
|
|
1279
|
-
const st = localStatus[asset.title] || { status: 'new', uuid: '' };
|
|
1280
|
-
const status = st.status;
|
|
1281
|
-
const uuid = st.uuid || '';
|
|
1282
|
-
const uuidShort = uuid ? ` ${C.dim}(${uuid.substring(0, 8)})${C.reset}` : '';
|
|
1283
|
-
|
|
1284
|
-
if (status === 'new') {
|
|
1285
|
-
log(` ${C.green}+ new${C.reset} ${asset.title} ${C.dim}(${asset.files.length} files)${C.reset}`);
|
|
1286
|
-
newCount++;
|
|
1287
|
-
} else if (status === 'updated') {
|
|
1288
|
-
log(` ${C.yellow}~ modified${C.reset} ${asset.title}${uuidShort}`);
|
|
1289
|
-
updatedCount++;
|
|
1256
|
+
if (newCount || updatedCount) {
|
|
1257
|
+
info(`${newCount ? newCount + ' new' : ''}${newCount && updatedCount ? ', ' : ''}${updatedCount ? updatedCount + ' modified' : ''}. Run ${C.cyan}tokrepo push${C.reset} to sync.`);
|
|
1290
1258
|
} else {
|
|
1291
|
-
|
|
1292
|
-
unchangedCount++;
|
|
1259
|
+
success('Everything up to date.');
|
|
1293
1260
|
}
|
|
1261
|
+
} catch (e) {
|
|
1262
|
+
error(`Status check failed: ${e.message}`);
|
|
1294
1263
|
}
|
|
1295
|
-
|
|
1296
|
-
log('');
|
|
1297
|
-
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}`);
|
|
1298
|
-
|
|
1299
|
-
if (newCount > 0 || updatedCount > 0) {
|
|
1300
|
-
log(`\n Run ${C.cyan}tokrepo sync ${args.positional[0] || ''}${C.reset} to push changes`);
|
|
1301
|
-
}
|
|
1302
|
-
log('');
|
|
1303
1264
|
}
|
|
1304
1265
|
|
|
1305
1266
|
function showHelp() {
|
|
1306
1267
|
log(`
|
|
1307
1268
|
${C.bold}tokrepo${C.reset} — AI assets for humans and agents. Like GitHub, for AI experience.
|
|
1308
1269
|
|
|
1270
|
+
${C.dim}You control what gets pushed. Each push uploads only the files you specify.
|
|
1271
|
+
Nothing is shared without your explicit action. Private by default.${C.reset}
|
|
1272
|
+
|
|
1309
1273
|
${C.bold}QUICK START${C.reset}
|
|
1310
1274
|
${C.cyan}tokrepo search cursor rules${C.reset} # find assets
|
|
1311
1275
|
${C.cyan}tokrepo install awesome-cursor-rules${C.reset} # install to your project
|
|
1312
|
-
${C.cyan}tokrepo push --
|
|
1276
|
+
${C.cyan}tokrepo push --private my-skill.md${C.reset} # save privately (only you can see)
|
|
1277
|
+
${C.cyan}tokrepo push --public my-skill.md${C.reset} # share publicly
|
|
1313
1278
|
|
|
1314
1279
|
${C.bold}USAGE${C.reset}
|
|
1315
1280
|
tokrepo <command> [args] [options]
|
|
@@ -1317,26 +1282,26 @@ ${C.bold}USAGE${C.reset}
|
|
|
1317
1282
|
${C.bold}DISCOVER & INSTALL${C.reset}
|
|
1318
1283
|
${C.cyan}search${C.reset} <query> Search assets by keyword
|
|
1319
1284
|
${C.cyan}install${C.reset} <name|uuid> Smart install (auto-detects type & placement)
|
|
1320
|
-
${C.cyan}pull${C.reset} <url|uuid>
|
|
1285
|
+
${C.cyan}pull${C.reset} <url|uuid|@u/n> Download raw asset files
|
|
1286
|
+
${C.cyan}clone${C.reset} @username Clone all assets from a user
|
|
1321
1287
|
|
|
1322
|
-
${C.bold}PUBLISH
|
|
1323
|
-
${C.cyan}push${C.reset} [files...] Push files/directory (
|
|
1324
|
-
${C.cyan}
|
|
1325
|
-
${C.cyan}status${C.reset} [dir] Show local vs remote diff
|
|
1288
|
+
${C.bold}PUBLISH${C.reset}
|
|
1289
|
+
${C.cyan}push${C.reset} [files...] Push files/directory (idempotent upsert)
|
|
1290
|
+
${C.cyan}status${C.reset} Compare local vs remote (like git status)
|
|
1326
1291
|
${C.cyan}init${C.reset} Create .tokrepo.json project config
|
|
1327
|
-
${C.cyan}update${C.reset} <uuid> [f] Update existing asset
|
|
1292
|
+
${C.cyan}update${C.reset} <uuid> [f] Update existing asset
|
|
1328
1293
|
${C.cyan}delete${C.reset} <uuid> Delete an asset
|
|
1329
1294
|
|
|
1330
1295
|
${C.bold}ACCOUNT${C.reset}
|
|
1331
|
-
${C.cyan}login${C.reset} Save API token
|
|
1296
|
+
${C.cyan}login${C.reset} Save API token (or set TOKREPO_TOKEN env var)
|
|
1332
1297
|
${C.cyan}list${C.reset} List your published assets
|
|
1333
1298
|
${C.cyan}tags${C.reset} List available tags
|
|
1334
1299
|
${C.cyan}whoami${C.reset} Show current user
|
|
1335
1300
|
${C.cyan}help${C.reset} Show this help
|
|
1336
1301
|
|
|
1337
1302
|
${C.bold}PUSH OPTIONS${C.reset}
|
|
1338
|
-
${C.cyan}--
|
|
1339
|
-
${C.cyan}--
|
|
1303
|
+
${C.cyan}--private${C.reset} Keep asset private — only you can see it (recommended for personal assets)
|
|
1304
|
+
${C.cyan}--public${C.reset} Share asset publicly with the community
|
|
1340
1305
|
${C.cyan}--title${C.reset} "..." Set title (auto-detected from README or dir name)
|
|
1341
1306
|
${C.cyan}--desc${C.reset} "..." Set description
|
|
1342
1307
|
${C.cyan}--tag${C.reset} Skills Add tag (repeatable)
|
|
@@ -1348,21 +1313,13 @@ ${C.bold}INSTALL BEHAVIOR${C.reset}
|
|
|
1348
1313
|
MCP → current dir (.json)
|
|
1349
1314
|
Prompts → current dir (.md)
|
|
1350
1315
|
|
|
1351
|
-
${C.bold}SYNC (the killer feature)${C.reset}
|
|
1352
|
-
${C.cyan}tokrepo sync ~/.claude/skills/${C.reset} # Sync all skills (default: private)
|
|
1353
|
-
${C.cyan}tokrepo sync ~/.claude/skills/ --public${C.reset} # Sync as public assets
|
|
1354
|
-
${C.cyan}tokrepo sync . -y${C.reset} # Sync current dir, skip confirm
|
|
1355
|
-
${C.cyan}tokrepo status${C.reset} # Show what would change
|
|
1356
|
-
|
|
1357
|
-
Sync scans a directory, detects new/modified assets, and pushes only
|
|
1358
|
-
what changed. Each subdirectory becomes one asset. Loose files become
|
|
1359
|
-
individual assets. Like ${C.bold}git push${C.reset} for your AI assets.
|
|
1360
|
-
|
|
1361
1316
|
${C.bold}EXAMPLES${C.reset}
|
|
1362
1317
|
tokrepo search "mcp server" # Find MCP configs
|
|
1363
1318
|
tokrepo install ca000374-f5d8-... # Install by UUID
|
|
1364
|
-
tokrepo push --
|
|
1365
|
-
tokrepo push --public
|
|
1319
|
+
tokrepo push --private my-rules.md # Save one file privately
|
|
1320
|
+
tokrepo push --public skill.md # Share one file publicly
|
|
1321
|
+
tokrepo push --private . # Push current dir as private
|
|
1322
|
+
tokrepo push --public --title "My MCP" . # Push dir publicly with title
|
|
1366
1323
|
|
|
1367
1324
|
${C.bold}FILE TYPE AUTO-DETECTION${C.reset}
|
|
1368
1325
|
.sh .py .js .ts .mjs .go .rs → script
|
|
@@ -1371,8 +1328,12 @@ ${C.bold}FILE TYPE AUTO-DETECTION${C.reset}
|
|
|
1371
1328
|
.prompt .prompt.md → prompt
|
|
1372
1329
|
.md (other) → other
|
|
1373
1330
|
|
|
1331
|
+
${C.bold}AGENT / CI SETUP${C.reset}
|
|
1332
|
+
export TOKREPO_TOKEN=tk_xxx # skip login, agents use this
|
|
1333
|
+
export TOKREPO_API=https://... # optional custom API endpoint
|
|
1334
|
+
|
|
1374
1335
|
${C.bold}GET YOUR TOKEN${C.reset}
|
|
1375
|
-
https://tokrepo.com/en/
|
|
1336
|
+
https://tokrepo.com/en/my/settings
|
|
1376
1337
|
`);
|
|
1377
1338
|
}
|
|
1378
1339
|
|
|
@@ -1385,15 +1346,15 @@ async function main() {
|
|
|
1385
1346
|
case 'login': await cmdLogin(); break;
|
|
1386
1347
|
case 'init': await cmdInit(); break;
|
|
1387
1348
|
case 'push': await cmdPush(); break;
|
|
1388
|
-
case 'sync': await cmdSync(); break;
|
|
1389
|
-
case 'status': case 'st': await cmdStatus(); break;
|
|
1390
1349
|
case 'pull': await cmdPull(); break;
|
|
1391
1350
|
case 'search': case 'find': await cmdSearch(); break;
|
|
1392
1351
|
case 'install': case 'i': await cmdInstall(); break;
|
|
1393
1352
|
case 'list': await cmdList(); break;
|
|
1394
1353
|
case 'update': await cmdUpdate(); break;
|
|
1395
1354
|
case 'delete': await cmdDelete(); break;
|
|
1355
|
+
case 'clone': await cmdClone(); break;
|
|
1396
1356
|
case 'tags': await cmdTags(); break;
|
|
1357
|
+
case 'status': case 'diff': await cmdStatus(); break;
|
|
1397
1358
|
case 'whoami': await cmdWhoami(); break;
|
|
1398
1359
|
case '--version': case '-v': case 'version':
|
|
1399
1360
|
log(`tokrepo ${CLI_VERSION}`); break;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokrepo",
|
|
3
|
-
"version": "3.
|
|
4
|
-
"description": "AI assets for humans and agents —
|
|
3
|
+
"version": "3.3.1",
|
|
4
|
+
"description": "AI assets for humans and agents — search, install, push. Like GitHub, for AI experience.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"tokrepo": "bin/tokrepo.js"
|
|
7
7
|
},
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"repository": {
|
|
13
13
|
"type": "git",
|
|
14
|
-
"url": "git+https://github.com/
|
|
14
|
+
"url": "git+https://github.com/tokrepo/cli.git"
|
|
15
15
|
},
|
|
16
16
|
"homepage": "https://tokrepo.com",
|
|
17
17
|
"keywords": [
|