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.
Files changed (2) hide show
  1. package/bin/tokrepo.js +369 -408
  2. 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.2.0';
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': 'tokrepo-cli/2.0.0',
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
- // Use lowercase singular names to match backend tag slugs
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
- info('Get your API token from https://tokrepo.com/en/workflows/submit');
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 ask('API Token:');
395
- if (!token) error('Token is required');
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 ?? 1));
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/create', {
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
- success(`Pushed!`);
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: 1,
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-or-uuid>');
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 query = process.argv.slice(3).join(' ');
598
- if (!query) error('Usage: tokrepo search <keyword>');
724
+ const rawQuery = process.argv.slice(3).join(' ');
725
+ if (!rawQuery) error('Usage: tokrepo search <keyword>');
599
726
 
600
- log(`\n${C.bold}tokrepo search${C.reset} "${query}"\n`);
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
- log(`\n ${C.dim}Try different keywords or browse: https://tokrepo.com/en/featured${C.reset}\n`);
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
- info(`Searching for "${target}"...`);
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(target);
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 exact title match, use it directly
672
- const exact = searchData.list.find(w => w.title.toLowerCase().includes(target.toLowerCase()));
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 cmdTags() {
917
- log(`\n${C.bold}tokrepo tags${C.reset}\n`);
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
- try {
923
- const data = await apiRequest('GET', '/api/v1/tokenboard/tags/list', null, null, apiBase);
924
- log(` Available tags:\n`);
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
- 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 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
- return assets;
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
- // Non-project directory: each subdirectory = one asset; loose files = one asset per file
979
- let entries;
980
- try { entries = fs.readdirSync(dirPath, { withFileTypes: true }); } catch { return assets; }
981
-
982
- const looseFiles = [];
983
-
984
- for (const entry of entries) {
985
- if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name) || SKIP_FILES.has(entry.name)) continue;
986
-
987
- const fullPath = path.join(dirPath, entry.name);
988
-
989
- if (entry.isDirectory()) {
990
- // Subdirectory = one asset (e.g., ~/.claude/skills/my-skill/)
991
- const files = collectAssetFiles(fullPath);
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
- // Each loose file = one asset
1015
- for (const f of looseFiles) {
1016
- let content;
1017
- try { content = fs.readFileSync(f.path, 'utf8'); } catch { continue; }
1018
- if (!content.trim()) continue;
1121
+ if (allItems.length === 0) {
1122
+ info(`@${username} has no public assets.`);
1123
+ return;
1124
+ }
1019
1125
 
1020
- const files = [{ name: f.name, content, type: detectFileType(f.name) }];
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
- assets.push({ title, files, hash, tags: tag ? [tag] : [], sourcePath: f.path });
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
- return assets;
1030
- }
1031
-
1032
- function collectAssetFiles(dirPath) {
1033
- const files = [];
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
- function walk(dir, relBase) {
1037
- let entries;
1038
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
1039
- for (const entry of entries) {
1040
- if (entry.name.startsWith('.') || SKIP.has(entry.name)) continue;
1041
- const fullPath = path.join(dir, entry.name);
1042
- const relPath = relBase ? `${relBase}/${entry.name}` : entry.name;
1043
- if (entry.isDirectory()) {
1044
- walk(fullPath, relPath);
1045
- } else if (entry.isFile()) {
1046
- const ext = path.extname(entry.name).toLowerCase();
1047
- const validExts = ['.md', '.sh', '.py', '.js', '.mjs', '.ts', '.json', '.yaml', '.yml', '.toml', '.prompt', '.rb', '.go', '.rs'];
1048
- if (validExts.includes(ext) || entry.name === '.cursorrules') {
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
- walk(dirPath, '');
1059
- return files;
1162
+ log('');
1163
+ success(`Cloned ${downloaded}/${allItems.length} assets to ./${username}/`);
1060
1164
  }
1061
1165
 
1062
- function guessAssetTitle(files, fallbackName) {
1063
- // Try to find a heading in the first .md file
1064
- for (const f of files) {
1065
- if (f.name.toLowerCase().endsWith('.md')) {
1066
- const match = f.content.match(/^#\s+(.+)$/m);
1067
- if (match) return match[1].trim();
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 cmdSync() {
1077
- const args = parseArgs(process.argv);
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 targetDir = args.positional[0]
1082
- ? path.resolve(args.positional[0])
1083
- : path.join(require('os').homedir(), '.claude', 'skills');
1190
+ const projectConfig = readProjectConfig();
1191
+ const baseDir = process.cwd();
1084
1192
 
1085
- if (!fs.existsSync(targetDir)) {
1086
- error(`Directory not found: ${targetDir}`);
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
- const visibility = args.flags.public ? 1 : 0; // default private for sync
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
- log(` Found ${C.bold}${assets.length}${C.reset} assets\n`);
1101
-
1102
- // Load local sync state for fast local-only diff
1103
- const syncState = readSyncState();
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
- // Show status
1142
- let newCount = 0, updatedCount = 0, unchangedCount = 0;
1143
-
1144
- for (const asset of assets) {
1145
- const st = localStatus[asset.title] || { status: 'new' };
1146
- if (st.status === 'new') {
1147
- log(` ${C.green}+ new${C.reset} ${asset.title} ${C.dim}(${asset.files.length} files)${C.reset}`);
1148
- newCount++;
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
- log('');
1159
- log(` ${C.green}${newCount} new${C.reset} ${C.yellow}${updatedCount} updated${C.reset} ${C.dim}${unchangedCount} unchanged${C.reset}`);
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
- log('');
1168
-
1169
- // Confirm unless -y
1170
- if (!args.flags.yes) {
1171
- const confirm = await ask(`Push ${newCount + updatedCount} assets? (y/N):`);
1172
- if (confirm.toLowerCase() !== 'y') { log('Aborted.'); return; }
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
- log(`\n${C.bold}tokrepo status${C.reset}\n`);
1238
- info(`Scanning ${targetDir}...`);
1237
+ assets.push({ title: titleBase, content_hash: localHash });
1239
1238
 
1240
- const assets = scanDirectory(targetDir);
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
- // Check local sync state first, remote for unknowns
1247
- const syncState = readSyncState();
1248
- const localStatus = {};
1249
- const needsRemoteCheck = [];
1250
-
1251
- for (const asset of assets) {
1252
- const cached = syncState[asset.sourcePath];
1253
- if (cached && cached.hash === asset.hash) {
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
- if (needsRemoteCheck.length > 0) {
1263
- info('Comparing with remote...');
1264
- const diffPayload = needsRemoteCheck.map(a => ({ title: a.title, content_hash: a.hash }));
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
- log('');
1276
- let newCount = 0, updatedCount = 0, unchangedCount = 0;
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
- log(` ${C.dim} unchanged ${asset.title}${uuidShort}${C.reset}`);
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 --public .${C.reset} # share your own assets
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> Download raw asset files
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 & SYNC${C.reset}
1323
- ${C.cyan}push${C.reset} [files...] Push files/directory (creates new asset)
1324
- ${C.cyan}sync${C.reset} [dir] Sync directory to TokRepo (smart upsert)
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 by UUID
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}--public${C.reset} Make asset publicly visible (default)
1339
- ${C.cyan}--private${C.reset} Make asset private
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 --public . # Push current directory
1365
- tokrepo push --public --title "My MCP" . # Push with custom title
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/workflows/submit
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.2.0",
4
- "description": "AI assets for humans and agents — sync, search, install, push. Like git push for your AI skills.",
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/henu-wang/tokrepo-cli.git"
14
+ "url": "git+https://github.com/tokrepo/cli.git"
15
15
  },
16
16
  "homepage": "https://tokrepo.com",
17
17
  "keywords": [