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.
- package/bin/tokrepo.js +649 -28
- 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/
|
|
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
|
-
|
|
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} —
|
|
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
|
|
642
|
-
${C.cyan}tokrepo
|
|
643
|
-
${C.cyan}tokrepo push --public
|
|
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
|
|
647
|
-
|
|
648
|
-
${C.bold}
|
|
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
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
${C.cyan}
|
|
664
|
-
${C.cyan}
|
|
665
|
-
${C.cyan}
|
|
666
|
-
${C.cyan}
|
|
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
|
|
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": "
|
|
4
|
-
"description": "
|
|
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
|
},
|