tokrepo 1.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/bin/tokrepo.js +649 -28
  2. package/package.json +2 -2
package/bin/tokrepo.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const crypto = require('crypto');
5
6
  const https = require('https');
6
7
  const http = require('http');
7
8
  const readline = require('readline');
@@ -23,6 +24,8 @@ const CONFIG_DIR = path.join(require('os').homedir(), '.tokrepo');
23
24
  const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
24
25
  const PROJECT_CONFIG = '.tokrepo.json';
25
26
  const DEFAULT_API = 'https://api.tokrepo.com';
27
+ const CLI_VERSION = '3.0.0';
28
+ const VERSION_CHECK_FILE = path.join(require('os').homedir(), '.tokrepo', '.version-check');
26
29
 
27
30
  // ─── Helpers ───
28
31
 
@@ -42,9 +45,64 @@ function readConfig() {
42
45
 
43
46
  function writeConfig(config) {
44
47
  if (!fs.existsSync(CONFIG_DIR)) {
45
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
48
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
46
49
  }
47
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
50
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
51
+ }
52
+
53
+ // Check npm registry for newer version (non-blocking, max once per day)
54
+ async function checkForUpdate() {
55
+ try {
56
+ // Only check once per 24 hours
57
+ if (fs.existsSync(VERSION_CHECK_FILE)) {
58
+ const stat = fs.statSync(VERSION_CHECK_FILE);
59
+ const hoursSinceCheck = (Date.now() - stat.mtimeMs) / 3600000;
60
+ if (hoursSinceCheck < 24) return;
61
+ }
62
+ // Touch the file to mark check time
63
+ if (!fs.existsSync(path.dirname(VERSION_CHECK_FILE))) {
64
+ fs.mkdirSync(path.dirname(VERSION_CHECK_FILE), { recursive: true, mode: 0o700 });
65
+ }
66
+ fs.writeFileSync(VERSION_CHECK_FILE, '', { mode: 0o600 });
67
+
68
+ const data = await new Promise((resolve, reject) => {
69
+ const req = https.get('https://registry.npmjs.org/tokrepo/latest', {
70
+ headers: { 'Accept': 'application/json' },
71
+ timeout: 3000,
72
+ }, (res) => {
73
+ let body = '';
74
+ res.on('data', (chunk) => { body += chunk; });
75
+ res.on('end', () => {
76
+ try { resolve(JSON.parse(body)); } catch { reject(); }
77
+ });
78
+ });
79
+ req.on('error', reject);
80
+ req.on('timeout', () => { req.destroy(); reject(); });
81
+ });
82
+
83
+ const latest = data.version;
84
+ if (latest && latest !== CLI_VERSION) {
85
+ const cmp = compareVersions(latest, CLI_VERSION);
86
+ if (cmp > 0) {
87
+ log('');
88
+ log(`${C.yellow}!${C.reset} Update available: ${C.dim}${CLI_VERSION}${C.reset} → ${C.green}${latest}${C.reset}`);
89
+ log(` Run: ${C.cyan}npm install -g tokrepo${C.reset}`);
90
+ log('');
91
+ }
92
+ }
93
+ } catch {
94
+ // Silent fail — update check is best-effort
95
+ }
96
+ }
97
+
98
+ function compareVersions(a, b) {
99
+ const pa = a.split('.').map(Number);
100
+ const pb = b.split('.').map(Number);
101
+ for (let i = 0; i < 3; i++) {
102
+ if ((pa[i] || 0) > (pb[i] || 0)) return 1;
103
+ if ((pa[i] || 0) < (pb[i] || 0)) return -1;
104
+ }
105
+ return 0;
48
106
  }
49
107
 
50
108
  function readProjectConfig(baseDir = process.cwd()) {
@@ -74,12 +132,16 @@ function apiRequest(method, urlPath, body, token, apiBase) {
74
132
  return new Promise((resolve, reject) => {
75
133
  const base = apiBase || DEFAULT_API;
76
134
  const url = new URL(urlPath, base);
135
+ // Force HTTPS to prevent token transmission over plain HTTP
136
+ if (url.protocol === 'http:' && !url.hostname.match(/^(localhost|127\.0\.0\.1)$/)) {
137
+ url.protocol = 'https:';
138
+ }
77
139
  const isHttps = url.protocol === 'https:';
78
140
  const mod = isHttps ? https : http;
79
141
 
80
142
  const headers = {
81
143
  'Content-Type': 'application/json',
82
- 'User-Agent': 'tokrepo-cli/1.1.0',
144
+ 'User-Agent': 'tokrepo-cli/2.0.0',
83
145
  };
84
146
  if (token) {
85
147
  headers['Authorization'] = `Bearer ${token}`;
@@ -144,7 +206,8 @@ function detectFileType(filename) {
144
206
 
145
207
  // Guess tag from file type
146
208
  function guessTag(fileType) {
147
- const map = { skill: 'Skills', prompt: 'Prompts', script: 'Scripts', config: 'Configs' };
209
+ // Use lowercase singular names to match backend tag slugs
210
+ const map = { skill: 'skill', prompt: 'prompt', script: 'script', config: 'config', mcp: 'mcp' };
148
211
  return map[fileType] || null;
149
212
  }
150
213
 
@@ -253,6 +316,11 @@ function collectFiles(paths, baseDir) {
253
316
 
254
317
  for (const p of paths) {
255
318
  const resolved = path.resolve(baseDir, p);
319
+ // Prevent path traversal — resolved must be within baseDir
320
+ if (!resolved.startsWith(path.resolve(baseDir) + path.sep) && resolved !== path.resolve(baseDir)) {
321
+ warn(`Skipped (outside project): ${p}`);
322
+ continue;
323
+ }
256
324
  if (!fs.existsSync(resolved)) {
257
325
  warn(`Not found: ${p}`);
258
326
  continue;
@@ -513,6 +581,226 @@ async function cmdPull() {
513
581
  }
514
582
  }
515
583
 
584
+ // ─── Search ───
585
+
586
+ async function cmdSearch() {
587
+ const query = process.argv.slice(3).join(' ');
588
+ if (!query) error('Usage: tokrepo search <keyword>');
589
+
590
+ log(`\n${C.bold}tokrepo search${C.reset} "${query}"\n`);
591
+
592
+ const config = readConfig();
593
+ const apiBase = config?.api || DEFAULT_API;
594
+
595
+ try {
596
+ const encoded = encodeURIComponent(query);
597
+ const data = await apiRequest('GET', `/api/v1/tokenboard/workflows/list?keyword=${encoded}&page=1&page_size=20&sort_by=views`, null, config?.token, apiBase);
598
+
599
+ if (!data.list || data.list.length === 0) {
600
+ info('No assets found.');
601
+ log(`\n ${C.dim}Try different keywords or browse: https://tokrepo.com/en/featured${C.reset}\n`);
602
+ return;
603
+ }
604
+
605
+ log(` ${C.bold}${data.total}${C.reset} results:\n`);
606
+
607
+ for (let i = 0; i < data.list.length; i++) {
608
+ const wf = data.list[i];
609
+ const tags = (wf.tags || []).map(t => t.name).join(', ');
610
+ const views = wf.view_count || 0;
611
+ const votes = wf.vote_count || 0;
612
+
613
+ log(` ${C.dim}${String(i + 1).padStart(2)}.${C.reset} ${C.bold}${wf.title}${C.reset}`);
614
+ if (tags) log(` ${C.cyan}${tags}${C.reset} ${C.dim}★${votes} 👁${views}${C.reset}`);
615
+ log(` ${C.dim}tokrepo install ${wf.uuid}${C.reset}`);
616
+ log('');
617
+ }
618
+ } catch (e) {
619
+ error(`Search failed: ${e.message}`);
620
+ }
621
+ }
622
+
623
+ // ─── Install (smart pull with correct placement) ───
624
+
625
+ async function cmdInstall() {
626
+ const target = process.argv[3];
627
+ if (!target) {
628
+ error(`Usage: tokrepo install <name-or-uuid>
629
+
630
+ Examples:
631
+ tokrepo install awesome-cursor-rules
632
+ tokrepo install ca000374-f5d8-4d75-a30c-460fda0b6b0e
633
+ tokrepo install https://tokrepo.com/en/workflows/ca000374-...`);
634
+ }
635
+
636
+ log(`\n${C.bold}tokrepo install${C.reset}\n`);
637
+
638
+ const config = readConfig();
639
+ const apiBase = config?.api || DEFAULT_API;
640
+
641
+ // Resolve target to UUID
642
+ let uuid = target;
643
+
644
+ // URL format
645
+ const urlMatch = target.match(/workflows\/([a-f0-9-]+)/);
646
+ if (urlMatch) {
647
+ uuid = urlMatch[1];
648
+ }
649
+ // UUID format check
650
+ else if (!/^[a-f0-9-]{36}$/.test(target)) {
651
+ // Search by name
652
+ info(`Searching for "${target}"...`);
653
+ try {
654
+ const encoded = encodeURIComponent(target);
655
+ const searchData = await apiRequest('GET', `/api/v1/tokenboard/workflows/list?keyword=${encoded}&page=1&page_size=5&sort_by=views`, null, config?.token, apiBase);
656
+
657
+ if (!searchData.list || searchData.list.length === 0) {
658
+ error(`No asset found matching "${target}". Try: tokrepo search ${target}`);
659
+ }
660
+
661
+ // If exact title match, use it directly
662
+ const exact = searchData.list.find(w => w.title.toLowerCase().includes(target.toLowerCase()));
663
+ const chosen = exact || searchData.list[0];
664
+
665
+ uuid = chosen.uuid;
666
+ info(`Found: ${C.bold}${chosen.title}${C.reset}`);
667
+ } catch (e) {
668
+ error(`Search failed: ${e.message}`);
669
+ }
670
+ }
671
+
672
+ // Fetch the asset
673
+ info(`Fetching ${uuid.substring(0, 8)}...`);
674
+
675
+ let workflow, files;
676
+ try {
677
+ const data = await apiRequest('GET', `/api/v1/tokenboard/workflows/detail?uuid=${uuid}`, null, config?.token, apiBase);
678
+ workflow = data.workflow;
679
+ files = data.workflow.files || [];
680
+ } catch (e) {
681
+ error(`Fetch failed: ${e.message}`);
682
+ }
683
+
684
+ log(`\n ${C.bold}${workflow.title}${C.reset}`);
685
+ if (workflow.description) log(` ${C.dim}${workflow.description.substring(0, 100)}${C.reset}`);
686
+
687
+ // Determine asset type from tags
688
+ let assetType = 'other';
689
+ if (workflow.tags && workflow.tags.length > 0) {
690
+ assetType = (workflow.tags[0].slug || workflow.tags[0].name || '').toLowerCase();
691
+ }
692
+
693
+ // Get content — prefer files, fallback to steps
694
+ const contents = [];
695
+
696
+ if (files.length > 0) {
697
+ for (const f of files) {
698
+ if (f.content && !f.content.startsWith('PK')) {
699
+ contents.push({ name: f.name, content: f.content, type: f.file_type || f.fileType || 'other' });
700
+ }
701
+ }
702
+ }
703
+
704
+ if (contents.length === 0 && workflow.steps) {
705
+ for (const step of workflow.steps) {
706
+ const content = step.prompt_template || step.promptTemplate;
707
+ if (content && !content.startsWith('PK')) {
708
+ const name = (step.title || `step-${step.step_order}`).replace(/[/\\?%*:|"<>]/g, '-');
709
+ contents.push({ name, content, type: assetType });
710
+ }
711
+ }
712
+ }
713
+
714
+ if (contents.length === 0) {
715
+ error('No installable content found in this asset.');
716
+ }
717
+
718
+ log('');
719
+
720
+ // Smart install based on asset type
721
+ let installed = 0;
722
+
723
+ for (const item of contents) {
724
+ let destDir = process.cwd();
725
+ let fileName = item.name;
726
+
727
+ // Ensure file has extension
728
+ if (!path.extname(fileName)) fileName += '.md';
729
+
730
+ switch (assetType) {
731
+ case 'skills':
732
+ case 'skill': {
733
+ // Install to .claude/skills/ if it exists, otherwise current dir
734
+ const claudeSkillsDir = path.join(process.cwd(), '.claude', 'skills');
735
+ if (fs.existsSync(path.join(process.cwd(), '.claude'))) {
736
+ if (!fs.existsSync(claudeSkillsDir)) {
737
+ fs.mkdirSync(claudeSkillsDir, { recursive: true });
738
+ }
739
+ destDir = claudeSkillsDir;
740
+ }
741
+ break;
742
+ }
743
+ case 'mcp':
744
+ case 'mcp configs': {
745
+ // Security warning for MCP configs
746
+ warn('MCP server config detected. Review the configuration carefully before adding to your project.');
747
+ warn('MCP servers can execute arbitrary code. Only install from trusted sources.');
748
+ // Save as mcp config, hint about manual merge
749
+ if (!fileName.endsWith('.json')) fileName = fileName.replace(/\.md$/, '.json');
750
+ break;
751
+ }
752
+ case 'configs':
753
+ case 'config': {
754
+ // Save to project root
755
+ break;
756
+ }
757
+ case 'scripts':
758
+ case 'script': {
759
+ // Save and make executable
760
+ if (!path.extname(fileName) || fileName.endsWith('.md')) {
761
+ // Detect language from content
762
+ if (item.content.startsWith('#!/usr/bin/env python') || item.content.includes('import ')) {
763
+ fileName = fileName.replace(/\.md$/, '.py');
764
+ } else if (item.content.startsWith('#!/bin/bash') || item.content.startsWith('#!/bin/sh')) {
765
+ fileName = fileName.replace(/\.md$/, '.sh');
766
+ }
767
+ }
768
+ break;
769
+ }
770
+ case 'prompts':
771
+ case 'prompt': {
772
+ // Save as markdown
773
+ if (!fileName.endsWith('.md') && !fileName.endsWith('.prompt')) fileName += '.md';
774
+ break;
775
+ }
776
+ }
777
+
778
+ // Sanitize fileName to prevent path traversal from API response
779
+ fileName = path.basename(fileName);
780
+ const destPath = path.join(destDir, fileName);
781
+
782
+ // Don't overwrite without warning
783
+ if (fs.existsSync(destPath)) {
784
+ warn(`File exists: ${path.relative(process.cwd(), destPath)} (overwriting)`);
785
+ }
786
+
787
+ fs.writeFileSync(destPath, item.content);
788
+
789
+ // Make scripts executable
790
+ if (assetType === 'script' || assetType === 'scripts') {
791
+ try { fs.chmodSync(destPath, 0o755); } catch {}
792
+ }
793
+
794
+ const relPath = path.relative(process.cwd(), destPath);
795
+ success(`Installed: ${relPath}`);
796
+ installed++;
797
+ }
798
+
799
+ log('');
800
+ success(`${installed} file(s) installed from ${C.bold}${workflow.title}${C.reset}`);
801
+ log(` ${C.dim}Source: https://tokrepo.com/en/workflows/${uuid}${C.reset}\n`);
802
+ }
803
+
516
804
  async function cmdWhoami() {
517
805
  const config = readConfig();
518
806
  if (!config || !config.token) error('Not logged in. Run: tokrepo login');
@@ -633,43 +921,367 @@ async function cmdTags() {
633
921
  }
634
922
  }
635
923
 
924
+ // ─── Sync: scan directory, diff with remote, upsert changes ───
925
+
926
+ function computeHash(files) {
927
+ const h = crypto.createHash('sha256');
928
+ for (const f of files) {
929
+ h.update(f.name);
930
+ h.update('\0');
931
+ h.update(f.content);
932
+ h.update('\0');
933
+ }
934
+ return h.digest('hex');
935
+ }
936
+
937
+ function scanDirectory(dirPath) {
938
+ const assets = [];
939
+ const SKIP_DIRS = new Set(['node_modules', '.git', '__pycache__', '.venv', 'dist', 'build']);
940
+ const SKIP_FILES = new Set(['.DS_Store', 'Thumbs.db', 'package-lock.json', 'yarn.lock']);
941
+
942
+ // Each subdirectory = one asset; loose files = one asset per file
943
+ let entries;
944
+ try { entries = fs.readdirSync(dirPath, { withFileTypes: true }); } catch { return assets; }
945
+
946
+ const looseFiles = [];
947
+
948
+ for (const entry of entries) {
949
+ if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name) || SKIP_FILES.has(entry.name)) continue;
950
+
951
+ const fullPath = path.join(dirPath, entry.name);
952
+
953
+ if (entry.isDirectory()) {
954
+ // Subdirectory = one asset (e.g., ~/.claude/skills/my-skill/)
955
+ const files = collectAssetFiles(fullPath);
956
+ if (files.length === 0) continue;
957
+
958
+ const title = guessAssetTitle(files, entry.name);
959
+ const hash = computeHash(files);
960
+ const detectedTags = new Set();
961
+ for (const f of files) {
962
+ const ft = detectFileType(f.name);
963
+ const tag = guessTag(ft);
964
+ if (tag) detectedTags.add(tag);
965
+ }
966
+
967
+ assets.push({ title, files, hash, tags: Array.from(detectedTags), sourcePath: fullPath });
968
+ } else if (entry.isFile()) {
969
+ // Loose file
970
+ const ext = path.extname(entry.name).toLowerCase();
971
+ const validExts = ['.md', '.sh', '.py', '.js', '.mjs', '.ts', '.json', '.yaml', '.yml', '.toml', '.prompt'];
972
+ if (validExts.includes(ext) || entry.name === '.cursorrules' || entry.name === '.windsurfrules') {
973
+ looseFiles.push({ path: fullPath, name: entry.name });
974
+ }
975
+ }
976
+ }
977
+
978
+ // Each loose file = one asset
979
+ for (const f of looseFiles) {
980
+ let content;
981
+ try { content = fs.readFileSync(f.path, 'utf8'); } catch { continue; }
982
+ if (!content.trim()) continue;
983
+
984
+ const files = [{ name: f.name, content, type: detectFileType(f.name) }];
985
+ const title = guessAssetTitle(files, path.basename(f.name, path.extname(f.name)));
986
+ const hash = computeHash(files);
987
+ const ft = detectFileType(f.name);
988
+ const tag = guessTag(ft);
989
+
990
+ assets.push({ title, files, hash, tags: tag ? [tag] : [], sourcePath: f.path });
991
+ }
992
+
993
+ return assets;
994
+ }
995
+
996
+ function collectAssetFiles(dirPath) {
997
+ const files = [];
998
+ const SKIP = new Set(['.DS_Store', 'node_modules', '.git', '__pycache__']);
999
+
1000
+ function walk(dir, relBase) {
1001
+ let entries;
1002
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
1003
+ for (const entry of entries) {
1004
+ if (entry.name.startsWith('.') || SKIP.has(entry.name)) continue;
1005
+ const fullPath = path.join(dir, entry.name);
1006
+ const relPath = relBase ? `${relBase}/${entry.name}` : entry.name;
1007
+ if (entry.isDirectory()) {
1008
+ walk(fullPath, relPath);
1009
+ } else if (entry.isFile()) {
1010
+ const ext = path.extname(entry.name).toLowerCase();
1011
+ const validExts = ['.md', '.sh', '.py', '.js', '.mjs', '.ts', '.json', '.yaml', '.yml', '.toml', '.prompt', '.rb', '.go', '.rs'];
1012
+ if (validExts.includes(ext) || entry.name === '.cursorrules') {
1013
+ let content;
1014
+ try { content = fs.readFileSync(fullPath, 'utf8'); } catch { continue; }
1015
+ if (!content.trim()) continue;
1016
+ files.push({ name: relPath, content, type: detectFileType(relPath) });
1017
+ }
1018
+ }
1019
+ }
1020
+ }
1021
+
1022
+ walk(dirPath, '');
1023
+ return files;
1024
+ }
1025
+
1026
+ function guessAssetTitle(files, fallbackName) {
1027
+ // Try to find a heading in the first .md file
1028
+ for (const f of files) {
1029
+ if (f.name.toLowerCase().endsWith('.md')) {
1030
+ const match = f.content.match(/^#\s+(.+)$/m);
1031
+ if (match) return match[1].trim();
1032
+ }
1033
+ }
1034
+ // Clean up fallback name
1035
+ return fallbackName
1036
+ .replace(/[-_]/g, ' ')
1037
+ .replace(/\b\w/g, c => c.toUpperCase());
1038
+ }
1039
+
1040
+ async function cmdSync() {
1041
+ const args = parseArgs(process.argv);
1042
+ const config = readConfig();
1043
+ if (!config || !config.token) error(`Not logged in. Run: ${C.cyan}tokrepo login${C.reset}`);
1044
+
1045
+ const targetDir = args.positional[0]
1046
+ ? path.resolve(args.positional[0])
1047
+ : path.join(require('os').homedir(), '.claude', 'skills');
1048
+
1049
+ if (!fs.existsSync(targetDir)) {
1050
+ error(`Directory not found: ${targetDir}`);
1051
+ }
1052
+
1053
+ const visibility = args.flags.public ? 1 : 0; // default private for sync
1054
+
1055
+ log(`\n${C.bold}tokrepo sync${C.reset}\n`);
1056
+ info(`Scanning ${targetDir}...`);
1057
+
1058
+ const assets = scanDirectory(targetDir);
1059
+ if (assets.length === 0) {
1060
+ info('No assets found in directory.');
1061
+ return;
1062
+ }
1063
+
1064
+ log(` Found ${C.bold}${assets.length}${C.reset} assets\n`);
1065
+
1066
+ // Step 1: Diff with remote
1067
+ info('Comparing with remote...');
1068
+ const diffPayload = assets.map(a => ({ title: a.title, content_hash: a.hash }));
1069
+
1070
+ let diffResults;
1071
+ try {
1072
+ const data = await apiRequest('POST', '/api/v1/tokenboard/push/diff', { assets: diffPayload }, config.token, config.api);
1073
+ diffResults = data.results;
1074
+ } catch (e) {
1075
+ // Fallback: if diff endpoint not available, treat all as new
1076
+ warn(`Diff API not available (${e.message}), treating all as new`);
1077
+ diffResults = assets.map(a => ({ title: a.title, status: 'new' }));
1078
+ }
1079
+
1080
+ // Build status map
1081
+ const statusMap = {};
1082
+ for (const r of diffResults) {
1083
+ statusMap[r.title] = r;
1084
+ }
1085
+
1086
+ // Show status
1087
+ let newCount = 0, updatedCount = 0, unchangedCount = 0;
1088
+
1089
+ for (const asset of assets) {
1090
+ const status = statusMap[asset.title]?.status || 'new';
1091
+ if (status === 'new') {
1092
+ log(` ${C.green}+ new${C.reset} ${asset.title} ${C.dim}(${asset.files.length} files)${C.reset}`);
1093
+ newCount++;
1094
+ } else if (status === 'updated') {
1095
+ log(` ${C.yellow}~ updated${C.reset} ${asset.title}`);
1096
+ updatedCount++;
1097
+ } else {
1098
+ log(` ${C.dim}= unchanged ${asset.title}${C.reset}`);
1099
+ unchangedCount++;
1100
+ }
1101
+ }
1102
+
1103
+ log('');
1104
+ log(` ${C.green}${newCount} new${C.reset} ${C.yellow}${updatedCount} updated${C.reset} ${C.dim}${unchangedCount} unchanged${C.reset}`);
1105
+
1106
+ if (newCount === 0 && updatedCount === 0) {
1107
+ log('');
1108
+ success('Everything is up to date!');
1109
+ return;
1110
+ }
1111
+
1112
+ log('');
1113
+
1114
+ // Confirm unless -y
1115
+ if (!args.flags.yes) {
1116
+ const confirm = await ask(`Push ${newCount + updatedCount} assets? (y/N):`);
1117
+ if (confirm.toLowerCase() !== 'y') { log('Aborted.'); return; }
1118
+ }
1119
+
1120
+ // Step 2: Upsert each changed asset
1121
+ let successCount = 0;
1122
+ let failCount = 0;
1123
+
1124
+ for (const asset of assets) {
1125
+ const status = statusMap[asset.title]?.status || 'new';
1126
+ if (status === 'unchanged') continue;
1127
+
1128
+ const totalChars = asset.files.reduce((sum, f) => sum + f.content.length, 0);
1129
+
1130
+ try {
1131
+ const data = await apiRequest('POST', '/api/v1/tokenboard/push/upsert', {
1132
+ title: asset.title,
1133
+ files: asset.files,
1134
+ tags: asset.tags,
1135
+ token_cost: String(Math.round(totalChars / 4)),
1136
+ visibility,
1137
+ }, config.token, config.api);
1138
+
1139
+ const action = data.action === 'created' ? C.green + '+ created' : C.yellow + '~ updated';
1140
+ log(` ${action}${C.reset} ${asset.title} ${C.dim}${data.url}${C.reset}`);
1141
+ successCount++;
1142
+ } catch (e) {
1143
+ log(` ${C.red}✗ failed${C.reset} ${asset.title}: ${e.message}`);
1144
+ failCount++;
1145
+ }
1146
+ }
1147
+
1148
+ log('');
1149
+ if (failCount === 0) {
1150
+ success(`Synced ${successCount} assets!`);
1151
+ } else {
1152
+ warn(`${successCount} synced, ${failCount} failed`);
1153
+ }
1154
+ log('');
1155
+ }
1156
+
1157
+ async function cmdStatus() {
1158
+ const args = parseArgs(process.argv);
1159
+ const config = readConfig();
1160
+ if (!config || !config.token) error(`Not logged in. Run: ${C.cyan}tokrepo login${C.reset}`);
1161
+
1162
+ const targetDir = args.positional[0]
1163
+ ? path.resolve(args.positional[0])
1164
+ : path.join(require('os').homedir(), '.claude', 'skills');
1165
+
1166
+ if (!fs.existsSync(targetDir)) {
1167
+ error(`Directory not found: ${targetDir}`);
1168
+ }
1169
+
1170
+ log(`\n${C.bold}tokrepo status${C.reset}\n`);
1171
+ info(`Scanning ${targetDir}...`);
1172
+
1173
+ const assets = scanDirectory(targetDir);
1174
+ if (assets.length === 0) {
1175
+ info('No assets found in directory.');
1176
+ return;
1177
+ }
1178
+
1179
+ // Diff with remote
1180
+ info('Comparing with remote...\n');
1181
+ const diffPayload = assets.map(a => ({ title: a.title, content_hash: a.hash }));
1182
+
1183
+ let diffResults;
1184
+ try {
1185
+ const data = await apiRequest('POST', '/api/v1/tokenboard/push/diff', { assets: diffPayload }, config.token, config.api);
1186
+ diffResults = data.results;
1187
+ } catch (e) {
1188
+ error(`Diff API error: ${e.message}`);
1189
+ }
1190
+
1191
+ const statusMap = {};
1192
+ for (const r of diffResults) {
1193
+ statusMap[r.title] = r;
1194
+ }
1195
+
1196
+ let newCount = 0, updatedCount = 0, unchangedCount = 0;
1197
+
1198
+ for (const asset of assets) {
1199
+ const status = statusMap[asset.title]?.status || 'new';
1200
+ const uuid = statusMap[asset.title]?.remote_uuid || '';
1201
+ const uuidShort = uuid ? ` ${C.dim}(${uuid.substring(0, 8)})${C.reset}` : '';
1202
+
1203
+ if (status === 'new') {
1204
+ log(` ${C.green}+ new${C.reset} ${asset.title} ${C.dim}(${asset.files.length} files)${C.reset}`);
1205
+ newCount++;
1206
+ } else if (status === 'updated') {
1207
+ log(` ${C.yellow}~ modified${C.reset} ${asset.title}${uuidShort}`);
1208
+ updatedCount++;
1209
+ } else {
1210
+ log(` ${C.dim} unchanged ${asset.title}${uuidShort}${C.reset}`);
1211
+ unchangedCount++;
1212
+ }
1213
+ }
1214
+
1215
+ log('');
1216
+ 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}`);
1217
+
1218
+ if (newCount > 0 || updatedCount > 0) {
1219
+ log(`\n Run ${C.cyan}tokrepo sync ${args.positional[0] || ''}${C.reset} to push changes`);
1220
+ }
1221
+ log('');
1222
+ }
1223
+
636
1224
  function showHelp() {
637
1225
  log(`
638
- ${C.bold}tokrepo${C.reset} — Push AI assets to tokrepo.com
1226
+ ${C.bold}tokrepo${C.reset} — AI assets for humans and agents. Like GitHub, for AI experience.
639
1227
 
640
1228
  ${C.bold}QUICK START${C.reset}
641
- ${C.cyan}tokrepo login${C.reset} # one-time: paste your token
642
- ${C.cyan}tokrepo push --public .${C.reset} # push current directory
643
- ${C.cyan}tokrepo push --public README.md script.py${C.reset} # push specific files
1229
+ ${C.cyan}tokrepo search cursor rules${C.reset} # find assets
1230
+ ${C.cyan}tokrepo install awesome-cursor-rules${C.reset} # install to your project
1231
+ ${C.cyan}tokrepo push --public .${C.reset} # share your own assets
644
1232
 
645
1233
  ${C.bold}USAGE${C.reset}
646
- tokrepo push [files/dirs...] [options]
647
-
648
- ${C.bold}OPTIONS${C.reset}
1234
+ tokrepo <command> [args] [options]
1235
+
1236
+ ${C.bold}DISCOVER & INSTALL${C.reset}
1237
+ ${C.cyan}search${C.reset} <query> Search assets by keyword
1238
+ ${C.cyan}install${C.reset} <name|uuid> Smart install (auto-detects type & placement)
1239
+ ${C.cyan}pull${C.reset} <url|uuid> Download raw asset files
1240
+
1241
+ ${C.bold}PUBLISH & SYNC${C.reset}
1242
+ ${C.cyan}push${C.reset} [files...] Push files/directory (creates new asset)
1243
+ ${C.cyan}sync${C.reset} [dir] Sync directory to TokRepo (smart upsert)
1244
+ ${C.cyan}status${C.reset} [dir] Show local vs remote diff
1245
+ ${C.cyan}init${C.reset} Create .tokrepo.json project config
1246
+ ${C.cyan}update${C.reset} <uuid> [f] Update existing asset by UUID
1247
+ ${C.cyan}delete${C.reset} <uuid> Delete an asset
1248
+
1249
+ ${C.bold}ACCOUNT${C.reset}
1250
+ ${C.cyan}login${C.reset} Save API token
1251
+ ${C.cyan}list${C.reset} List your published assets
1252
+ ${C.cyan}tags${C.reset} List available tags
1253
+ ${C.cyan}whoami${C.reset} Show current user
1254
+ ${C.cyan}help${C.reset} Show this help
1255
+
1256
+ ${C.bold}PUSH OPTIONS${C.reset}
649
1257
  ${C.cyan}--public${C.reset} Make asset publicly visible (default)
650
1258
  ${C.cyan}--private${C.reset} Make asset private
651
1259
  ${C.cyan}--title${C.reset} "..." Set title (auto-detected from README or dir name)
652
1260
  ${C.cyan}--desc${C.reset} "..." Set description
653
- ${C.cyan}--tag${C.reset} Skills Add tag (repeatable: --tag Skills --tag MCP)
654
- ${C.cyan}-y, --yes${C.reset} Skip confirmation prompts
655
-
656
- ${C.bold}COMMANDS${C.reset}
657
- ${C.cyan}login${C.reset} Save API token
658
- ${C.cyan}push${C.reset} [files...] Push files/directory (default: current dir)
659
- ${C.cyan}init${C.reset} Create .tokrepo.json project config
660
- ${C.cyan}pull${C.reset} <url> Download asset to local files
661
- ${C.cyan}list${C.reset} List your assets
662
- ${C.cyan}update${C.reset} <uuid> [f] Update existing asset
663
- ${C.cyan}delete${C.reset} <uuid> Delete an asset
664
- ${C.cyan}tags${C.reset} List available tags
665
- ${C.cyan}whoami${C.reset} Show current user
666
- ${C.cyan}help${C.reset} Show this help
1261
+ ${C.cyan}--tag${C.reset} Skills Add tag (repeatable)
1262
+
1263
+ ${C.bold}INSTALL BEHAVIOR${C.reset}
1264
+ Skills → .claude/skills/ (if .claude/ exists)
1265
+ Scripts → current dir (chmod +x)
1266
+ Configs → project root
1267
+ MCP → current dir (.json)
1268
+ Prompts → current dir (.md)
1269
+
1270
+ ${C.bold}SYNC (the killer feature)${C.reset}
1271
+ ${C.cyan}tokrepo sync ~/.claude/skills/${C.reset} # Sync all skills (default: private)
1272
+ ${C.cyan}tokrepo sync ~/.claude/skills/ --public${C.reset} # Sync as public assets
1273
+ ${C.cyan}tokrepo sync . -y${C.reset} # Sync current dir, skip confirm
1274
+ ${C.cyan}tokrepo status${C.reset} # Show what would change
1275
+
1276
+ Sync scans a directory, detects new/modified assets, and pushes only
1277
+ what changed. Each subdirectory becomes one asset. Loose files become
1278
+ individual assets. Like ${C.bold}git push${C.reset} for your AI assets.
667
1279
 
668
1280
  ${C.bold}EXAMPLES${C.reset}
669
- tokrepo push --public . # Push all files in current dir
1281
+ tokrepo search "mcp server" # Find MCP configs
1282
+ tokrepo install ca000374-f5d8-... # Install by UUID
1283
+ tokrepo push --public . # Push current directory
670
1284
  tokrepo push --public --title "My MCP" . # Push with custom title
671
- tokrepo push --public src/ README.md # Push specific paths
672
- tokrepo push # Uses .tokrepo.json if exists
673
1285
 
674
1286
  ${C.bold}FILE TYPE AUTO-DETECTION${C.reset}
675
1287
  .sh .py .js .ts .mjs .go .rs → script
@@ -692,17 +1304,26 @@ async function main() {
692
1304
  case 'login': await cmdLogin(); break;
693
1305
  case 'init': await cmdInit(); break;
694
1306
  case 'push': await cmdPush(); break;
1307
+ case 'sync': await cmdSync(); break;
1308
+ case 'status': case 'st': await cmdStatus(); break;
695
1309
  case 'pull': await cmdPull(); break;
1310
+ case 'search': case 'find': await cmdSearch(); break;
1311
+ case 'install': case 'i': await cmdInstall(); break;
696
1312
  case 'list': await cmdList(); break;
697
1313
  case 'update': await cmdUpdate(); break;
698
1314
  case 'delete': await cmdDelete(); break;
699
1315
  case 'tags': await cmdTags(); break;
700
1316
  case 'whoami': await cmdWhoami(); break;
1317
+ case '--version': case '-v': case 'version':
1318
+ log(`tokrepo ${CLI_VERSION}`); break;
701
1319
  case 'help': case '--help': case '-h': case undefined:
702
1320
  showHelp(); break;
703
1321
  default:
704
1322
  error(`Unknown command: ${command}. Run: tokrepo help`);
705
1323
  }
1324
+
1325
+ // Non-blocking update check after command completes
1326
+ checkForUpdate();
706
1327
  }
707
1328
 
708
1329
  main().catch((e) => { error(e.message); });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "tokrepo",
3
- "version": "1.1.0",
4
- "description": "Push AI assets to tokrepo.comSkills, Prompts, MCP Configs, Scripts",
3
+ "version": "3.0.0",
4
+ "description": "AI assets for humans and agents sync, search, install, push. Like git push for your AI skills.",
5
5
  "bin": {
6
6
  "tokrepo": "bin/tokrepo.js"
7
7
  },