tokrepo 3.1.2 → 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 +320 -367
  2. package/package.json +2 -2
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.1.1';
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,369 +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 ───
1046
+ // Extract username from @username format
1047
+ let username = target;
1048
+ if (username.startsWith('@')) username = username.slice(1);
935
1049
 
936
- function computeHash(files) {
937
- const h = crypto.createHash('sha256');
938
- for (const f of files) {
939
- h.update(f.name);
940
- h.update('\0');
941
- h.update(f.content);
942
- h.update('\0');
943
- }
944
- return h.digest('hex');
945
- }
946
-
947
- function scanDirectory(dirPath) {
948
- const assets = [];
949
- const SKIP_DIRS = new Set(['node_modules', '.git', '__pycache__', '.venv', 'dist', 'build']);
950
- const SKIP_FILES = new Set(['.DS_Store', 'Thumbs.db', 'package-lock.json', 'yarn.lock']);
951
-
952
- // Each subdirectory = one asset; loose files = one asset per file
953
- let entries;
954
- try { entries = fs.readdirSync(dirPath, { withFileTypes: true }); } catch { return assets; }
955
-
956
- const looseFiles = [];
957
-
958
- for (const entry of entries) {
959
- if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name) || SKIP_FILES.has(entry.name)) continue;
960
-
961
- const fullPath = path.join(dirPath, entry.name);
962
-
963
- if (entry.isDirectory()) {
964
- // Subdirectory = one asset (e.g., ~/.claude/skills/my-skill/)
965
- const files = collectAssetFiles(fullPath);
966
- if (files.length === 0) continue;
967
-
968
- // Use directory name as title (matches what `push` uses for remote title)
969
- // NOT the markdown heading, which can be completely different
970
- const title = entry.name;
971
- const hash = computeHash(files);
972
- const detectedTags = new Set();
973
- for (const f of files) {
974
- const ft = detectFileType(f.name);
975
- const tag = guessTag(ft);
976
- if (tag) detectedTags.add(tag);
977
- }
978
-
979
- assets.push({ title, files, hash, tags: Array.from(detectedTags), sourcePath: fullPath });
980
- } else if (entry.isFile()) {
981
- // Loose file
982
- const ext = path.extname(entry.name).toLowerCase();
983
- const validExts = ['.md', '.sh', '.py', '.js', '.mjs', '.ts', '.json', '.yaml', '.yml', '.toml', '.prompt'];
984
- if (validExts.includes(ext) || entry.name === '.cursorrules' || entry.name === '.windsurfrules') {
985
- looseFiles.push({ path: fullPath, name: entry.name });
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;
986
1061
  }
987
1062
  }
988
- }
989
-
990
- // Each loose file = one asset
991
- for (const f of looseFiles) {
992
- let content;
993
- try { content = fs.readFileSync(f.path, 'utf8'); } catch { continue; }
994
- if (!content.trim()) continue;
995
-
996
- const files = [{ name: f.name, content, type: detectFileType(f.name) }];
997
- const title = guessAssetTitle(files, path.basename(f.name, path.extname(f.name)));
998
- const hash = computeHash(files);
999
- const ft = detectFileType(f.name);
1000
- const tag = guessTag(ft);
1001
-
1002
- assets.push({ title, files, hash, tags: tag ? [tag] : [], sourcePath: f.path });
1003
- }
1004
-
1005
- return assets;
1006
- }
1007
-
1008
- function collectAssetFiles(dirPath) {
1009
- const files = [];
1010
- const SKIP = new Set(['.DS_Store', 'node_modules', '.git', '__pycache__']);
1011
-
1012
- function walk(dir, relBase) {
1013
- let entries;
1014
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
1015
- for (const entry of entries) {
1016
- if (entry.name.startsWith('.') || SKIP.has(entry.name)) continue;
1017
- const fullPath = path.join(dir, entry.name);
1018
- const relPath = relBase ? `${relBase}/${entry.name}` : entry.name;
1019
- if (entry.isDirectory()) {
1020
- walk(fullPath, relPath);
1021
- } else if (entry.isFile()) {
1022
- const ext = path.extname(entry.name).toLowerCase();
1023
- const validExts = ['.md', '.sh', '.py', '.js', '.mjs', '.ts', '.json', '.yaml', '.yml', '.toml', '.prompt', '.rb', '.go', '.rs'];
1024
- if (validExts.includes(ext) || entry.name === '.cursorrules') {
1025
- let content;
1026
- try { content = fs.readFileSync(fullPath, 'utf8'); } catch { continue; }
1027
- if (!content.trim()) continue;
1028
- files.push({ name: relPath, content, type: detectFileType(relPath) });
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;
1029
1072
  }
1030
- }
1073
+ } catch { /* fall through */ }
1031
1074
  }
1075
+ if (!authorUuid) error(`User @${username} not found. Make sure they have published assets.`);
1032
1076
  }
1033
1077
 
1034
- walk(dirPath, '');
1035
- return files;
1036
- }
1037
-
1038
- function guessAssetTitle(files, fallbackName) {
1039
- // Try to find a heading in the first .md file
1040
- for (const f of files) {
1041
- if (f.name.toLowerCase().endsWith('.md')) {
1042
- const match = f.content.match(/^#\s+(.+)$/m);
1043
- if (match) return match[1].trim();
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}`);
1044
1092
  }
1045
1093
  }
1046
- // Clean up fallback name
1047
- return fallbackName
1048
- .replace(/[-_]/g, ' ')
1049
- .replace(/\b\w/g, c => c.toUpperCase());
1050
- }
1051
-
1052
- async function cmdSync() {
1053
- const args = parseArgs(process.argv);
1054
- const config = readConfig();
1055
- if (!config || !config.token) error(`Not logged in. Run: ${C.cyan}tokrepo login${C.reset}`);
1056
-
1057
- const targetDir = args.positional[0]
1058
- ? path.resolve(args.positional[0])
1059
- : path.join(require('os').homedir(), '.claude', 'skills');
1060
1094
 
1061
- if (!fs.existsSync(targetDir)) {
1062
- error(`Directory not found: ${targetDir}`);
1063
- }
1064
-
1065
- const visibility = args.flags.public ? 1 : 0; // default private for sync
1066
-
1067
- log(`\n${C.bold}tokrepo sync${C.reset}\n`);
1068
- info(`Scanning ${targetDir}...`);
1069
-
1070
- const assets = scanDirectory(targetDir);
1071
- if (assets.length === 0) {
1072
- info('No assets found in directory.');
1095
+ if (allItems.length === 0) {
1096
+ info(`@${username} has no public assets.`);
1073
1097
  return;
1074
1098
  }
1075
1099
 
1076
- log(` Found ${C.bold}${assets.length}${C.reset} assets\n`);
1077
-
1078
- // Load local sync state for fast local-only diff
1079
- const syncState = readSyncState();
1080
-
1081
- // Classify each asset: check local state first, then remote diff for unknowns
1082
- const needsRemoteCheck = [];
1083
- const localStatus = {};
1100
+ log(` Found ${C.bold}${allItems.length}${C.reset} assets\n`);
1084
1101
 
1085
- for (const asset of assets) {
1086
- const key = asset.sourcePath;
1087
- const cached = syncState[key];
1088
- if (cached && cached.hash === asset.hash) {
1089
- // Local hash matches last sync — unchanged
1090
- localStatus[asset.title] = { status: 'unchanged', uuid: cached.uuid };
1091
- } else if (cached && cached.hash !== asset.hash) {
1092
- // Local hash differs from last sync — updated
1093
- localStatus[asset.title] = { status: 'updated', uuid: cached.uuid };
1094
- } else {
1095
- // Not in local state — check remote
1096
- needsRemoteCheck.push(asset);
1097
- }
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 });
1098
1106
  }
1099
1107
 
1100
- // Remote diff for assets not in local state
1101
- if (needsRemoteCheck.length > 0) {
1102
- info('Comparing with remote...');
1103
- const diffPayload = needsRemoteCheck.map(a => ({ title: a.title, content_hash: a.hash }));
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);
1113
+
1104
1114
  try {
1105
- const data = await apiRequest('POST', '/api/v1/tokenboard/push/diff', { assets: diffPayload }, config.token, config.api);
1106
- for (const r of data.results) {
1107
- localStatus[r.title] = { status: r.status, uuid: r.remote_uuid || '' };
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
+ }
1127
+ }
1128
+ downloaded++;
1129
+ log(` ${C.green}✓${C.reset} ${safeDirName} ${C.dim}(${workflow.steps.length} files)${C.reset}`);
1108
1130
  }
1109
1131
  } catch (e) {
1110
- warn(`Diff API: ${e.message} — treating unknowns as new`);
1111
- for (const a of needsRemoteCheck) {
1112
- localStatus[a.title] = { status: 'new', uuid: '' };
1113
- }
1132
+ log(` ${C.yellow}!${C.reset} ${safeDirName} ${C.dim}(skipped: ${e.message})${C.reset}`);
1114
1133
  }
1115
1134
  }
1116
1135
 
1117
- // Show status
1118
- let newCount = 0, updatedCount = 0, unchangedCount = 0;
1119
-
1120
- for (const asset of assets) {
1121
- const st = localStatus[asset.title] || { status: 'new' };
1122
- if (st.status === 'new') {
1123
- log(` ${C.green}+ new${C.reset} ${asset.title} ${C.dim}(${asset.files.length} files)${C.reset}`);
1124
- newCount++;
1125
- } else if (st.status === 'updated') {
1126
- log(` ${C.yellow}~ updated${C.reset} ${asset.title}`);
1127
- updatedCount++;
1128
- } else {
1129
- log(` ${C.dim}= unchanged ${asset.title}${C.reset}`);
1130
- unchangedCount++;
1131
- }
1132
- }
1133
-
1134
- log('');
1135
- log(` ${C.green}${newCount} new${C.reset} ${C.yellow}${updatedCount} updated${C.reset} ${C.dim}${unchangedCount} unchanged${C.reset}`);
1136
-
1137
- if (newCount === 0 && updatedCount === 0) {
1138
- log('');
1139
- success('Everything is up to date!');
1140
- return;
1141
- }
1142
-
1143
1136
  log('');
1137
+ success(`Cloned ${downloaded}/${allItems.length} assets to ./${username}/`);
1138
+ }
1144
1139
 
1145
- // Confirm unless -y
1146
- if (!args.flags.yes) {
1147
- const confirm = await ask(`Push ${newCount + updatedCount} assets? (y/N):`);
1148
- if (confirm.toLowerCase() !== 'y') { log('Aborted.'); return; }
1149
- }
1150
-
1151
- // Upsert each changed asset + save state
1152
- let successCount = 0;
1153
- let failCount = 0;
1154
-
1155
- for (const asset of assets) {
1156
- const st = localStatus[asset.title] || { status: 'new' };
1157
- if (st.status === 'unchanged') continue;
1140
+ async function cmdTags() {
1141
+ log(`\n${C.bold}tokrepo tags${C.reset}\n`);
1158
1142
 
1159
- const totalChars = asset.files.reduce((sum, f) => sum + f.content.length, 0);
1143
+ const config = readConfig();
1144
+ const apiBase = config?.api || DEFAULT_API;
1160
1145
 
1161
- try {
1162
- const data = await apiRequest('POST', '/api/v1/tokenboard/push/upsert', {
1163
- title: asset.title,
1164
- files: asset.files,
1165
- tags: asset.tags,
1166
- token_cost: String(Math.round(totalChars / 4)),
1167
- visibility,
1168
- }, config.token, config.api);
1169
-
1170
- const action = data.action === 'created' ? C.green + '+ created' : C.yellow + '~ updated';
1171
- log(` ${action}${C.reset} ${asset.title} ${C.dim}${data.url}${C.reset}`);
1172
-
1173
- // Save to local sync state
1174
- syncState[asset.sourcePath] = {
1175
- uuid: data.uuid,
1176
- hash: asset.hash,
1177
- title: asset.title,
1178
- url: data.url,
1179
- lastSync: new Date().toISOString(),
1180
- };
1181
- successCount++;
1182
- } catch (e) {
1183
- log(` ${C.red}✗ failed${C.reset} ${asset.title}: ${e.message}`);
1184
- failCount++;
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}` : ''}`);
1185
1151
  }
1152
+ log('');
1153
+ } catch (e) {
1154
+ error(`Failed: ${e.message}`);
1186
1155
  }
1187
-
1188
- // Persist sync state
1189
- writeSyncState(syncState);
1190
-
1191
- log('');
1192
- if (failCount === 0) {
1193
- success(`Synced ${successCount} assets!`);
1194
- } else {
1195
- warn(`${successCount} synced, ${failCount} failed`);
1196
- }
1197
- log('');
1198
1156
  }
1199
1157
 
1200
1158
  async function cmdStatus() {
1201
- const args = parseArgs(process.argv);
1159
+ log(`\n${C.bold}tokrepo status${C.reset}\n`);
1160
+
1202
1161
  const config = readConfig();
1203
1162
  if (!config || !config.token) error(`Not logged in. Run: ${C.cyan}tokrepo login${C.reset}`);
1204
1163
 
1205
- const targetDir = args.positional[0]
1206
- ? path.resolve(args.positional[0])
1207
- : path.join(require('os').homedir(), '.claude', 'skills');
1164
+ const projectConfig = readProjectConfig();
1165
+ const baseDir = process.cwd();
1208
1166
 
1209
- if (!fs.existsSync(targetDir)) {
1210
- 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);
1211
1174
  }
1212
1175
 
1213
- log(`\n${C.bold}tokrepo status${C.reset}\n`);
1214
- info(`Scanning ${targetDir}...`);
1215
-
1216
- const assets = scanDirectory(targetDir);
1217
- if (assets.length === 0) {
1218
- info('No assets found in directory.');
1176
+ if (filesToCheck.length === 0) {
1177
+ info('No pushable files found in current directory.');
1219
1178
  return;
1220
1179
  }
1221
1180
 
1222
- // Check local sync state first, remote for unknowns
1223
- const syncState = readSyncState();
1224
- const localStatus = {};
1225
- const needsRemoteCheck = [];
1226
-
1227
- for (const asset of assets) {
1228
- const cached = syncState[asset.sourcePath];
1229
- if (cached && cached.hash === asset.hash) {
1230
- localStatus[asset.title] = { status: 'unchanged', uuid: cached.uuid };
1231
- } else if (cached && cached.hash !== asset.hash) {
1232
- localStatus[asset.title] = { status: 'updated', uuid: cached.uuid };
1233
- } else {
1234
- needsRemoteCheck.push(asset);
1235
- }
1181
+ // Build assets with content hashes for diff
1182
+ const crypto = require('crypto');
1183
+ const assets = [];
1184
+ const titleBase = projectConfig?.title || guessTitle(filesToCheck, baseDir);
1185
+
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 });
1236
1194
  }
1237
1195
 
1238
- if (needsRemoteCheck.length > 0) {
1239
- info('Comparing with remote...');
1240
- const diffPayload = needsRemoteCheck.map(a => ({ title: a.title, content_hash: a.hash }));
1241
- try {
1242
- const data = await apiRequest('POST', '/api/v1/tokenboard/push/diff', { assets: diffPayload }, config.token, config.api);
1243
- for (const r of data.results) {
1244
- localStatus[r.title] = { status: r.status, uuid: r.remote_uuid || '' };
1245
- }
1246
- } catch (e) {
1247
- error(`Diff API error: ${e.message}`);
1248
- }
1196
+ if (pushFiles.length === 0) {
1197
+ info('No readable text files found.');
1198
+ return;
1249
1199
  }
1250
1200
 
1251
- log('');
1252
- let newCount = 0, updatedCount = 0, unchangedCount = 0;
1253
-
1254
- for (const asset of assets) {
1255
- const st = localStatus[asset.title] || { status: 'new', uuid: '' };
1256
- const status = st.status;
1257
- const uuid = st.uuid || '';
1258
- const uuidShort = uuid ? ` ${C.dim}(${uuid.substring(0, 8)})${C.reset}` : '';
1259
-
1260
- if (status === 'new') {
1261
- log(` ${C.green}+ new${C.reset} ${asset.title} ${C.dim}(${asset.files.length} files)${C.reset}`);
1262
- newCount++;
1263
- } else if (status === 'updated') {
1264
- log(` ${C.yellow}~ modified${C.reset} ${asset.title}${uuidShort}`);
1265
- updatedCount++;
1266
- } else {
1267
- log(` ${C.dim} unchanged ${asset.title}${uuidShort}${C.reset}`);
1268
- unchangedCount++;
1269
- }
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');
1270
1208
  }
1209
+ const localHash = h.digest('hex');
1271
1210
 
1272
- log('');
1273
- 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}`);
1211
+ assets.push({ title: titleBase, content_hash: localHash });
1212
+
1213
+ info('Comparing local files with remote...');
1274
1214
 
1275
- if (newCount > 0 || updatedCount > 0) {
1276
- log(`\n Run ${C.cyan}tokrepo sync ${args.positional[0] || ''}${C.reset} to push changes`);
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}` : ''}`);
1223
+ }
1224
+ log('');
1225
+
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;
1229
+
1230
+ if (newCount || updatedCount) {
1231
+ info(`${newCount ? newCount + ' new' : ''}${newCount && updatedCount ? ', ' : ''}${updatedCount ? updatedCount + ' modified' : ''}. Run ${C.cyan}tokrepo push${C.reset} to sync.`);
1232
+ } else {
1233
+ success('Everything up to date.');
1234
+ }
1235
+ } catch (e) {
1236
+ error(`Status check failed: ${e.message}`);
1277
1237
  }
1278
- log('');
1279
1238
  }
1280
1239
 
1281
1240
  function showHelp() {
@@ -1293,18 +1252,18 @@ ${C.bold}USAGE${C.reset}
1293
1252
  ${C.bold}DISCOVER & INSTALL${C.reset}
1294
1253
  ${C.cyan}search${C.reset} <query> Search assets by keyword
1295
1254
  ${C.cyan}install${C.reset} <name|uuid> Smart install (auto-detects type & placement)
1296
- ${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
1297
1257
 
1298
- ${C.bold}PUBLISH & SYNC${C.reset}
1299
- ${C.cyan}push${C.reset} [files...] Push files/directory (creates new asset)
1300
- ${C.cyan}sync${C.reset} [dir] Sync directory to TokRepo (smart upsert)
1301
- ${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)
1302
1261
  ${C.cyan}init${C.reset} Create .tokrepo.json project config
1303
- ${C.cyan}update${C.reset} <uuid> [f] Update existing asset by UUID
1262
+ ${C.cyan}update${C.reset} <uuid> [f] Update existing asset
1304
1263
  ${C.cyan}delete${C.reset} <uuid> Delete an asset
1305
1264
 
1306
1265
  ${C.bold}ACCOUNT${C.reset}
1307
- ${C.cyan}login${C.reset} Save API token
1266
+ ${C.cyan}login${C.reset} Save API token (or set TOKREPO_TOKEN env var)
1308
1267
  ${C.cyan}list${C.reset} List your published assets
1309
1268
  ${C.cyan}tags${C.reset} List available tags
1310
1269
  ${C.cyan}whoami${C.reset} Show current user
@@ -1324,16 +1283,6 @@ ${C.bold}INSTALL BEHAVIOR${C.reset}
1324
1283
  MCP → current dir (.json)
1325
1284
  Prompts → current dir (.md)
1326
1285
 
1327
- ${C.bold}SYNC (the killer feature)${C.reset}
1328
- ${C.cyan}tokrepo sync ~/.claude/skills/${C.reset} # Sync all skills (default: private)
1329
- ${C.cyan}tokrepo sync ~/.claude/skills/ --public${C.reset} # Sync as public assets
1330
- ${C.cyan}tokrepo sync . -y${C.reset} # Sync current dir, skip confirm
1331
- ${C.cyan}tokrepo status${C.reset} # Show what would change
1332
-
1333
- Sync scans a directory, detects new/modified assets, and pushes only
1334
- what changed. Each subdirectory becomes one asset. Loose files become
1335
- individual assets. Like ${C.bold}git push${C.reset} for your AI assets.
1336
-
1337
1286
  ${C.bold}EXAMPLES${C.reset}
1338
1287
  tokrepo search "mcp server" # Find MCP configs
1339
1288
  tokrepo install ca000374-f5d8-... # Install by UUID
@@ -1347,8 +1296,12 @@ ${C.bold}FILE TYPE AUTO-DETECTION${C.reset}
1347
1296
  .prompt .prompt.md → prompt
1348
1297
  .md (other) → other
1349
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
+
1350
1303
  ${C.bold}GET YOUR TOKEN${C.reset}
1351
- https://tokrepo.com/en/workflows/submit
1304
+ https://tokrepo.com/en/my/settings
1352
1305
  `);
1353
1306
  }
1354
1307
 
@@ -1361,15 +1314,15 @@ async function main() {
1361
1314
  case 'login': await cmdLogin(); break;
1362
1315
  case 'init': await cmdInit(); break;
1363
1316
  case 'push': await cmdPush(); break;
1364
- case 'sync': await cmdSync(); break;
1365
- case 'status': case 'st': await cmdStatus(); break;
1366
1317
  case 'pull': await cmdPull(); break;
1367
1318
  case 'search': case 'find': await cmdSearch(); break;
1368
1319
  case 'install': case 'i': await cmdInstall(); break;
1369
1320
  case 'list': await cmdList(); break;
1370
1321
  case 'update': await cmdUpdate(); break;
1371
1322
  case 'delete': await cmdDelete(); break;
1323
+ case 'clone': await cmdClone(); break;
1372
1324
  case 'tags': await cmdTags(); break;
1325
+ case 'status': case 'diff': await cmdStatus(); break;
1373
1326
  case 'whoami': await cmdWhoami(); break;
1374
1327
  case '--version': case '-v': case 'version':
1375
1328
  log(`tokrepo ${CLI_VERSION}`); break;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "tokrepo",
3
- "version": "3.1.2",
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
  },