tokrepo 3.2.0 → 3.3.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 +319 -390
  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
 
@@ -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) {
@@ -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,6 +663,54 @@ async function cmdPull() {
591
663
  }
592
664
  }
593
665
 
666
+ // Resolve various input formats to a UUID:
667
+ // - UUID directly: "ca000374-f5d8-..."
668
+ // - Full URL: "https://tokrepo.com/en/workflows/ca000374-f5d8-..."
669
+ // - @username/asset-name: search by author + keyword
670
+ // - Plain name: search by keyword
671
+ async function resolveAssetId(input, config, apiBase) {
672
+ // Already a UUID
673
+ if (/^[a-f0-9-]{36}$/.test(input)) return input;
674
+
675
+ // URL containing UUID
676
+ const urlMatch = input.match(/workflows\/([a-f0-9-]{36})/);
677
+ if (urlMatch) return urlMatch[1];
678
+
679
+ // @username/asset-name format
680
+ const atMatch = input.match(/^@([^/]+)\/(.+)$/);
681
+ if (atMatch) {
682
+ const [, username, assetName] = atMatch;
683
+ info(`Searching for "${assetName}" by @${username}...`);
684
+ // Search by keyword, then filter by author nickname
685
+ const encoded = encodeURIComponent(assetName);
686
+ try {
687
+ const data = await apiRequest('GET', `/api/v1/tokenboard/workflows/list?keyword=${encoded}&page=1&page_size=20&sort_by=views`, null, config?.token, apiBase);
688
+ const items = data.list || data.items || [];
689
+ const match = items.find(w => {
690
+ const authorName = (w.author?.nickname || w.nickname || '').toLowerCase();
691
+ return authorName === username.toLowerCase();
692
+ });
693
+ if (match) return match.uuid;
694
+ // Fallback: return first result
695
+ if (items.length > 0) {
696
+ warn(`No exact match for @${username}, using best match: "${items[0].title}"`);
697
+ return items[0].uuid;
698
+ }
699
+ } catch { /* fall through */ }
700
+ error(`Asset not found: ${input}`);
701
+ }
702
+
703
+ // Plain name: search by keyword
704
+ info(`Searching for "${input}"...`);
705
+ const encoded = encodeURIComponent(input);
706
+ try {
707
+ const data = await apiRequest('GET', `/api/v1/tokenboard/workflows/list?keyword=${encoded}&page=1&page_size=5&sort_by=views`, null, config?.token, apiBase);
708
+ const items = data.list || data.items || [];
709
+ if (items.length > 0) return items[0].uuid;
710
+ } catch { /* fall through */ }
711
+ error(`Asset not found: ${input}`);
712
+ }
713
+
594
714
  // ─── Search ───
595
715
 
596
716
  async function cmdSearch() {
@@ -752,9 +872,6 @@ Examples:
752
872
  }
753
873
  case 'mcp':
754
874
  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
875
  // Save as mcp config, hint about manual merge
759
876
  if (!fileName.endsWith('.json')) fileName = fileName.replace(/\.md$/, '.json');
760
877
  break;
@@ -785,10 +902,14 @@ Examples:
785
902
  }
786
903
  }
787
904
 
788
- // Sanitize fileName to prevent path traversal from API response
789
- fileName = path.basename(fileName);
790
905
  const destPath = path.join(destDir, fileName);
791
906
 
907
+ // Path traversal guard: ensure resolved path stays inside destDir
908
+ if (!path.resolve(destPath).startsWith(path.resolve(destDir) + path.sep) && path.resolve(destPath) !== path.resolve(destDir)) {
909
+ warn(`Skipping "${fileName}" — path traversal detected`);
910
+ continue;
911
+ }
912
+
792
913
  // Don't overwrite without warning
793
914
  if (fs.existsSync(destPath)) {
794
915
  warn(`File exists: ${path.relative(process.cwd(), destPath)} (overwriting)`);
@@ -913,393 +1034,207 @@ async function cmdDelete() {
913
1034
  }
914
1035
  }
915
1036
 
916
- async function cmdTags() {
917
- log(`\n${C.bold}tokrepo tags${C.reset}\n`);
1037
+ async function cmdClone() {
1038
+ const target = process.argv[3];
1039
+ if (!target) error('Usage: tokrepo clone @username');
1040
+
1041
+ log(`\n${C.bold}tokrepo clone${C.reset}\n`);
918
1042
 
919
1043
  const config = readConfig();
920
1044
  const apiBase = config?.api || DEFAULT_API;
921
1045
 
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 ───
935
-
936
- function computeHash(files) {
937
- const h = crypto.createHash('sha256');
938
- for (const f of files) {
939
- h.update(f.name);
940
- h.update('\0');
941
- h.update(f.content);
942
- h.update('\0');
943
- }
944
- return h.digest('hex');
945
- }
1046
+ // Extract username from @username format
1047
+ let username = target;
1048
+ if (username.startsWith('@')) username = username.slice(1);
946
1049
 
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);
1050
+ // Step 1: Find user's UUID by searching for their workflows
1051
+ info(`Finding user @${username}...`);
1052
+ let authorUuid = '';
1053
+ try {
1054
+ 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);
1055
+ const items = searchData.list || searchData.items || [];
1056
+ for (const item of items) {
1057
+ const authorName = (item.author?.nickname || item.nickname || '').toLowerCase();
1058
+ if (authorName === username.toLowerCase()) {
1059
+ authorUuid = item.author?.uuid || item.author_uuid || '';
1060
+ break;
972
1061
  }
973
- assets.push({ title, files, hash, tags: Array.from(detectedTags), sourcePath: dirPath });
974
1062
  }
975
- return assets;
1063
+ } catch { /* fall through */ }
1064
+
1065
+ if (!authorUuid) {
1066
+ // Try fetching user's own workflows if logged in and cloning self
1067
+ if (config?.token) {
1068
+ try {
1069
+ const me = await apiRequest('GET', '/api/v1/tokenboard/auth/me', null, config.token, apiBase);
1070
+ if (me.nickname?.toLowerCase() === username.toLowerCase()) {
1071
+ authorUuid = me.uuid;
1072
+ }
1073
+ } catch { /* fall through */ }
1074
+ }
1075
+ if (!authorUuid) error(`User @${username} not found. Make sure they have published assets.`);
976
1076
  }
977
1077
 
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
- }
1078
+ // Step 2: List all public workflows by this author
1079
+ info(`Fetching all assets by @${username}...`);
1080
+ let allItems = [];
1081
+ let page = 1;
1082
+ while (true) {
1083
+ try {
1084
+ 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);
1085
+ const items = data.list || data.items || [];
1086
+ if (items.length === 0) break;
1087
+ allItems = allItems.concat(items);
1088
+ if (items.length < 50) break;
1089
+ page++;
1090
+ } catch (e) {
1091
+ error(`Failed to list assets: ${e.message}`);
1011
1092
  }
1012
1093
  }
1013
1094
 
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;
1095
+ if (allItems.length === 0) {
1096
+ info(`@${username} has no public assets.`);
1097
+ return;
1098
+ }
1019
1099
 
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);
1100
+ log(` Found ${C.bold}${allItems.length}${C.reset} assets\n`);
1025
1101
 
1026
- assets.push({ title, files, hash, tags: tag ? [tag] : [], sourcePath: f.path });
1102
+ // Step 3: Create directory and pull each asset
1103
+ const outDir = path.join(process.cwd(), username);
1104
+ if (!fs.existsSync(outDir)) {
1105
+ fs.mkdirSync(outDir, { recursive: true });
1027
1106
  }
1028
1107
 
1029
- return assets;
1030
- }
1031
-
1032
- function collectAssetFiles(dirPath) {
1033
- const files = [];
1034
- const SKIP = new Set(['.DS_Store', 'node_modules', '.git', '__pycache__']);
1108
+ let downloaded = 0;
1109
+ for (const item of allItems) {
1110
+ const title = item.title || item.uuid;
1111
+ const safeDirName = title.replace(/[/\\?%*:|"<>]/g, '-').substring(0, 80);
1112
+ const assetDir = path.join(outDir, safeDirName);
1035
1113
 
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) });
1114
+ try {
1115
+ const detail = await apiRequest('GET', `/api/v1/tokenboard/workflows/detail?uuid=${item.uuid}`, null, config?.token, apiBase);
1116
+ const workflow = detail.workflow;
1117
+
1118
+ if (workflow.steps && workflow.steps.length > 0) {
1119
+ if (!fs.existsSync(assetDir)) fs.mkdirSync(assetDir, { recursive: true });
1120
+ for (const step of workflow.steps) {
1121
+ const content = step.prompt_template || step.promptTemplate;
1122
+ if (content) {
1123
+ const fileName = `${step.title || 'step-' + step.step_order}`;
1124
+ const safeName = fileName.replace(/[/\\?%*:|"<>]/g, '-');
1125
+ fs.writeFileSync(path.join(assetDir, safeName), content);
1126
+ }
1053
1127
  }
1128
+ downloaded++;
1129
+ log(` ${C.green}✓${C.reset} ${safeDirName} ${C.dim}(${workflow.steps.length} files)${C.reset}`);
1054
1130
  }
1131
+ } catch (e) {
1132
+ log(` ${C.yellow}!${C.reset} ${safeDirName} ${C.dim}(skipped: ${e.message})${C.reset}`);
1055
1133
  }
1056
1134
  }
1057
1135
 
1058
- walk(dirPath, '');
1059
- return files;
1136
+ log('');
1137
+ success(`Cloned ${downloaded}/${allItems.length} assets to ./${username}/`);
1060
1138
  }
1061
1139
 
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();
1140
+ async function cmdTags() {
1141
+ log(`\n${C.bold}tokrepo tags${C.reset}\n`);
1142
+
1143
+ const config = readConfig();
1144
+ const apiBase = config?.api || DEFAULT_API;
1145
+
1146
+ try {
1147
+ const data = await apiRequest('GET', '/api/v1/tokenboard/tags/list', null, null, apiBase);
1148
+ log(` Available tags:\n`);
1149
+ for (const tag of data.tags) {
1150
+ log(` ${C.cyan}${tag.name}${C.reset}${tag.count ? ` ${C.dim}(${tag.count} assets)${C.reset}` : ''}`);
1068
1151
  }
1152
+ log('');
1153
+ } catch (e) {
1154
+ error(`Failed: ${e.message}`);
1069
1155
  }
1070
- // Clean up fallback name
1071
- return fallbackName
1072
- .replace(/[-_]/g, ' ')
1073
- .replace(/\b\w/g, c => c.toUpperCase());
1074
1156
  }
1075
1157
 
1076
- async function cmdSync() {
1077
- const args = parseArgs(process.argv);
1158
+ async function cmdStatus() {
1159
+ log(`\n${C.bold}tokrepo status${C.reset}\n`);
1160
+
1078
1161
  const config = readConfig();
1079
1162
  if (!config || !config.token) error(`Not logged in. Run: ${C.cyan}tokrepo login${C.reset}`);
1080
1163
 
1081
- const targetDir = args.positional[0]
1082
- ? path.resolve(args.positional[0])
1083
- : path.join(require('os').homedir(), '.claude', 'skills');
1164
+ const projectConfig = readProjectConfig();
1165
+ const baseDir = process.cwd();
1084
1166
 
1085
- if (!fs.existsSync(targetDir)) {
1086
- error(`Directory not found: ${targetDir}`);
1167
+ // Collect local files
1168
+ let filesToCheck;
1169
+ if (projectConfig) {
1170
+ const patterns = projectConfig.files || ['*.md'];
1171
+ filesToCheck = findFiles(patterns, baseDir);
1172
+ } else {
1173
+ filesToCheck = collectFiles(['.'], baseDir);
1087
1174
  }
1088
1175
 
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.');
1176
+ if (filesToCheck.length === 0) {
1177
+ info('No pushable files found in current directory.');
1097
1178
  return;
1098
1179
  }
1099
1180
 
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
- }
1181
+ // Build assets with content hashes for diff
1182
+ const crypto = require('crypto');
1183
+ const assets = [];
1184
+ const titleBase = projectConfig?.title || guessTitle(filesToCheck, baseDir);
1140
1185
 
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
- }
1186
+ // Each file becomes an asset for diff comparison
1187
+ // But the primary asset is the whole push unit
1188
+ const pushFiles = [];
1189
+ for (const f of filesToCheck) {
1190
+ let content;
1191
+ try { content = fs.readFileSync(f.path, 'utf8'); } catch { continue; }
1192
+ if (!content.trim()) continue;
1193
+ pushFiles.push({ name: f.relPath, content });
1156
1194
  }
1157
1195
 
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!');
1196
+ if (pushFiles.length === 0) {
1197
+ info('No readable text files found.');
1164
1198
  return;
1165
1199
  }
1166
1200
 
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}`);
1201
+ // Compute content hash matching backend's computeContentHash format
1202
+ const h = crypto.createHash('sha256');
1203
+ for (const f of pushFiles) {
1204
+ h.update(f.name);
1205
+ h.update('\0');
1206
+ h.update(f.content);
1207
+ h.update('\0');
1235
1208
  }
1209
+ const localHash = h.digest('hex');
1236
1210
 
1237
- log(`\n${C.bold}tokrepo status${C.reset}\n`);
1238
- info(`Scanning ${targetDir}...`);
1211
+ assets.push({ title: titleBase, content_hash: localHash });
1239
1212
 
1240
- const assets = scanDirectory(targetDir);
1241
- if (assets.length === 0) {
1242
- info('No assets found in directory.');
1243
- return;
1244
- }
1213
+ info('Comparing local files with remote...');
1245
1214
 
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);
1215
+ try {
1216
+ const data = await apiRequest('POST', '/api/v1/tokenboard/push/diff', { assets }, config.token, config.api);
1217
+ log('');
1218
+ for (const r of data.results) {
1219
+ const icon = r.status === 'new' ? `${C.green}+ new${C.reset}`
1220
+ : r.status === 'updated' ? `${C.yellow}~ modified${C.reset}`
1221
+ : `${C.dim}= unchanged${C.reset}`;
1222
+ log(` ${icon} ${r.title}${r.remote_uuid ? ` ${C.dim}(${r.remote_uuid.substring(0, 8)}...)${C.reset}` : ''}`);
1259
1223
  }
1260
- }
1224
+ log('');
1261
1225
 
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
- }
1226
+ const newCount = data.results.filter(r => r.status === 'new').length;
1227
+ const updatedCount = data.results.filter(r => r.status === 'updated').length;
1228
+ const unchangedCount = data.results.filter(r => r.status === 'unchanged').length;
1274
1229
 
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++;
1230
+ if (newCount || updatedCount) {
1231
+ info(`${newCount ? newCount + ' new' : ''}${newCount && updatedCount ? ', ' : ''}${updatedCount ? updatedCount + ' modified' : ''}. Run ${C.cyan}tokrepo push${C.reset} to sync.`);
1290
1232
  } else {
1291
- log(` ${C.dim} unchanged ${asset.title}${uuidShort}${C.reset}`);
1292
- unchangedCount++;
1233
+ success('Everything up to date.');
1293
1234
  }
1235
+ } catch (e) {
1236
+ error(`Status check failed: ${e.message}`);
1294
1237
  }
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
1238
  }
1304
1239
 
1305
1240
  function showHelp() {
@@ -1317,18 +1252,18 @@ ${C.bold}USAGE${C.reset}
1317
1252
  ${C.bold}DISCOVER & INSTALL${C.reset}
1318
1253
  ${C.cyan}search${C.reset} <query> Search assets by keyword
1319
1254
  ${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
1255
+ ${C.cyan}pull${C.reset} <url|uuid|@u/n> Download raw asset files
1256
+ ${C.cyan}clone${C.reset} @username Clone all assets from a user
1321
1257
 
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
1258
+ ${C.bold}PUBLISH${C.reset}
1259
+ ${C.cyan}push${C.reset} [files...] Push files/directory (idempotent upsert)
1260
+ ${C.cyan}status${C.reset} Compare local vs remote (like git status)
1326
1261
  ${C.cyan}init${C.reset} Create .tokrepo.json project config
1327
- ${C.cyan}update${C.reset} <uuid> [f] Update existing asset by UUID
1262
+ ${C.cyan}update${C.reset} <uuid> [f] Update existing asset
1328
1263
  ${C.cyan}delete${C.reset} <uuid> Delete an asset
1329
1264
 
1330
1265
  ${C.bold}ACCOUNT${C.reset}
1331
- ${C.cyan}login${C.reset} Save API token
1266
+ ${C.cyan}login${C.reset} Save API token (or set TOKREPO_TOKEN env var)
1332
1267
  ${C.cyan}list${C.reset} List your published assets
1333
1268
  ${C.cyan}tags${C.reset} List available tags
1334
1269
  ${C.cyan}whoami${C.reset} Show current user
@@ -1348,16 +1283,6 @@ ${C.bold}INSTALL BEHAVIOR${C.reset}
1348
1283
  MCP → current dir (.json)
1349
1284
  Prompts → current dir (.md)
1350
1285
 
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
1286
  ${C.bold}EXAMPLES${C.reset}
1362
1287
  tokrepo search "mcp server" # Find MCP configs
1363
1288
  tokrepo install ca000374-f5d8-... # Install by UUID
@@ -1371,8 +1296,12 @@ ${C.bold}FILE TYPE AUTO-DETECTION${C.reset}
1371
1296
  .prompt .prompt.md → prompt
1372
1297
  .md (other) → other
1373
1298
 
1299
+ ${C.bold}AGENT / CI SETUP${C.reset}
1300
+ export TOKREPO_TOKEN=tk_xxx # skip login, agents use this
1301
+ export TOKREPO_API=https://... # optional custom API endpoint
1302
+
1374
1303
  ${C.bold}GET YOUR TOKEN${C.reset}
1375
- https://tokrepo.com/en/workflows/submit
1304
+ https://tokrepo.com/en/my/settings
1376
1305
  `);
1377
1306
  }
1378
1307
 
@@ -1385,15 +1314,15 @@ async function main() {
1385
1314
  case 'login': await cmdLogin(); break;
1386
1315
  case 'init': await cmdInit(); break;
1387
1316
  case 'push': await cmdPush(); break;
1388
- case 'sync': await cmdSync(); break;
1389
- case 'status': case 'st': await cmdStatus(); break;
1390
1317
  case 'pull': await cmdPull(); break;
1391
1318
  case 'search': case 'find': await cmdSearch(); break;
1392
1319
  case 'install': case 'i': await cmdInstall(); break;
1393
1320
  case 'list': await cmdList(); break;
1394
1321
  case 'update': await cmdUpdate(); break;
1395
1322
  case 'delete': await cmdDelete(); break;
1323
+ case 'clone': await cmdClone(); break;
1396
1324
  case 'tags': await cmdTags(); break;
1325
+ case 'status': case 'diff': await cmdStatus(); break;
1397
1326
  case 'whoami': await cmdWhoami(); break;
1398
1327
  case '--version': case '-v': case 'version':
1399
1328
  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.0",
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": [