tokrepo 3.3.2 → 3.4.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 +1182 -158
  2. package/package.json +1 -1
package/bin/tokrepo.js CHANGED
@@ -5,6 +5,7 @@ const path = require('path');
5
5
  const crypto = require('crypto');
6
6
  const https = require('https');
7
7
  const http = require('http');
8
+ const os = require('os');
8
9
  const readline = require('readline');
9
10
 
10
11
  // ANSI colors
@@ -20,21 +21,41 @@ const C = {
20
21
  white: '\x1b[37m',
21
22
  };
22
23
 
23
- const CONFIG_DIR = path.join(require('os').homedir(), '.tokrepo');
24
+ const CONFIG_DIR = path.join(os.homedir(), '.tokrepo');
24
25
  const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
25
26
  const PROJECT_CONFIG = '.tokrepo.json';
26
27
  const DEFAULT_API = 'https://api.tokrepo.com';
27
- const CLI_VERSION = '3.3.2';
28
- const VERSION_CHECK_FILE = path.join(require('os').homedir(), '.tokrepo', '.version-check');
28
+ const CLI_VERSION = '3.4.0';
29
+ const VERSION_CHECK_FILE = path.join(os.homedir(), '.tokrepo', '.version-check');
30
+ const CODEX_DIR = path.join(os.homedir(), '.codex');
31
+ const CODEX_SKILLS_DIR = path.join(CODEX_DIR, 'skills');
32
+ const CODEX_TOKREPO_DIR = path.join(CODEX_DIR, 'tokrepo');
33
+ const CODEX_MANIFEST_FILE = path.join(CODEX_TOKREPO_DIR, 'install-manifest.json');
34
+ const SUPPORTED_INSTALL_TARGETS = ['gemini', 'codex'];
29
35
 
30
36
  // ─── Helpers ───
31
37
 
38
+ function wantsJson(argv = process.argv) {
39
+ return argv.includes('--json') || argv.some(arg => arg.startsWith('--json=') && arg !== '--json=false');
40
+ }
41
+
32
42
  function log(msg) { console.log(msg); }
33
43
  function success(msg) { log(`${C.green}✓${C.reset} ${msg}`); }
34
- function error(msg) { log(`${C.red}✗${C.reset} ${msg}`); process.exit(1); }
44
+ function error(msg) {
45
+ if (wantsJson()) {
46
+ console.error(JSON.stringify({ error: msg }, null, 2));
47
+ } else {
48
+ log(`${C.red}✗${C.reset} ${msg}`);
49
+ }
50
+ process.exit(1);
51
+ }
35
52
  function warn(msg) { log(`${C.yellow}!${C.reset} ${msg}`); }
36
53
  function info(msg) { log(`${C.cyan}→${C.reset} ${msg}`); }
37
54
 
55
+ function outputJson(data) {
56
+ console.log(JSON.stringify(data, null, 2));
57
+ }
58
+
38
59
  function readConfig() {
39
60
  // P0: TOKREPO_TOKEN env var takes priority (enables Agent automation)
40
61
  const envToken = process.env.TOKREPO_TOKEN;
@@ -272,32 +293,55 @@ function parseArgs(argv) {
272
293
  args.command = argv[i];
273
294
  i++;
274
295
  }
296
+
297
+ const valueFlags = new Set([
298
+ 'title', 'desc', 'tag', 'target', 'keyword', 'types',
299
+ 'page', 'page-size', 'page_size', 'sort-by', 'sort_by',
300
+ 'time-window', 'time_window',
301
+ ]);
302
+
303
+ const assignFlag = (rawName, value = true) => {
304
+ const name = rawName.replace(/^--?/, '');
305
+ const normalized = name.replace(/-/g, '_');
306
+ if (normalized === 'tag') {
307
+ if (!args.flags.tags) args.flags.tags = [];
308
+ args.flags.tags.push(value);
309
+ return;
310
+ }
311
+ if (normalized === 'page_size') {
312
+ args.flags.pageSize = value;
313
+ } else if (normalized === 'sort_by') {
314
+ args.flags.sortBy = value;
315
+ } else if (normalized === 'time_window') {
316
+ args.flags.timeWindow = value;
317
+ } else if (normalized === 'dry_run') {
318
+ args.flags.dryRun = value;
319
+ } else if (normalized === 'approve_mcp') {
320
+ args.flags.approveMcp = value;
321
+ }
322
+ args.flags[normalized] = value;
323
+ };
324
+
275
325
  while (i < argv.length) {
276
326
  const arg = argv[i];
277
- if (arg === '--public') {
278
- args.flags.public = true;
279
- } else if (arg === '--private') {
280
- args.flags.private = true;
281
- } else if (arg === '--title' && i + 1 < argv.length) {
282
- args.flags.title = argv[++i];
283
- } else if (arg.startsWith('--title=')) {
284
- args.flags.title = arg.split('=').slice(1).join('=');
285
- } else if (arg === '--desc' && i + 1 < argv.length) {
286
- args.flags.desc = argv[++i];
287
- } else if (arg.startsWith('--desc=')) {
288
- args.flags.desc = arg.split('=').slice(1).join('=');
289
- } else if (arg === '--tag' && i + 1 < argv.length) {
290
- if (!args.flags.tags) args.flags.tags = [];
291
- args.flags.tags.push(argv[++i]);
292
- } else if (arg.startsWith('--tag=')) {
293
- if (!args.flags.tags) args.flags.tags = [];
294
- args.flags.tags.push(arg.split('=').slice(1).join('='));
295
- } else if (arg === '--target' && i + 1 < argv.length) {
296
- args.flags.target = argv[++i];
297
- } else if (arg.startsWith('--target=')) {
298
- args.flags.target = arg.split('=').slice(1).join('=');
327
+ if (arg === '-h' || arg === '--help') {
328
+ args.flags.help = true;
299
329
  } else if (arg === '-y' || arg === '--yes') {
300
330
  args.flags.yes = true;
331
+ } else if (arg.startsWith('--')) {
332
+ const eqIndex = arg.indexOf('=');
333
+ if (eqIndex !== -1) {
334
+ const name = arg.slice(2, eqIndex);
335
+ const value = arg.slice(eqIndex + 1);
336
+ assignFlag(name, value);
337
+ } else {
338
+ const name = arg.slice(2);
339
+ if (valueFlags.has(name) && i + 1 < argv.length && !argv[i + 1].startsWith('-')) {
340
+ assignFlag(name, argv[++i]);
341
+ } else {
342
+ assignFlag(name, true);
343
+ }
344
+ }
301
345
  } else if (!arg.startsWith('-')) {
302
346
  args.positional.push(arg);
303
347
  }
@@ -388,10 +432,10 @@ async function cmdLogin() {
388
432
 
389
433
  if (useToken) {
390
434
  // Manual token flow
391
- info('Paste your API token (from https://tokrepo.com/en/my/settings)');
435
+ info('Paste your API key (from https://tokrepo.com/en/my/settings)');
392
436
  log('');
393
- const token = await ask('API Token:');
394
- if (!token) error('Token is required');
437
+ const token = await ask('API Key:');
438
+ if (!token) error('API key is required');
395
439
  return await saveAndVerifyToken(token);
396
440
  }
397
441
 
@@ -684,7 +728,10 @@ function normalizeQuery(q) {
684
728
  // - Full URL: "https://tokrepo.com/en/workflows/ca000374-f5d8-..."
685
729
  // - @username/asset-name: search by author + keyword
686
730
  // - Plain name: search by keyword
687
- async function resolveAssetId(input, config, apiBase) {
731
+ async function resolveAssetId(input, config, apiBase, opts = {}) {
732
+ const emitInfo = (msg) => { if (!opts.quiet) info(msg); };
733
+ const emitWarn = (msg) => { if (!opts.quiet) warn(msg); };
734
+
688
735
  // Already a UUID
689
736
  if (/^[a-f0-9-]{36}$/.test(input)) return input;
690
737
 
@@ -697,7 +744,7 @@ async function resolveAssetId(input, config, apiBase) {
697
744
  if (atMatch) {
698
745
  const [, username, assetName] = atMatch;
699
746
  const normalizedName = normalizeQuery(assetName);
700
- info(`Searching for "${normalizedName}" by @${username}...`);
747
+ emitInfo(`Searching for "${normalizedName}" by @${username}...`);
701
748
  // Search by keyword, then filter by author nickname
702
749
  const encoded = encodeURIComponent(normalizedName);
703
750
  try {
@@ -710,7 +757,7 @@ async function resolveAssetId(input, config, apiBase) {
710
757
  if (match) return match.uuid;
711
758
  // Fallback: return first result
712
759
  if (items.length > 0) {
713
- warn(`No exact match for @${username}, using best match: "${items[0].title}"`);
760
+ emitWarn(`No exact match for @${username}, using best match: "${items[0].title}"`);
714
761
  return items[0].uuid;
715
762
  }
716
763
  } catch { /* fall through */ }
@@ -719,7 +766,7 @@ async function resolveAssetId(input, config, apiBase) {
719
766
 
720
767
  // Plain name: search by keyword (normalize separators)
721
768
  const normalizedInput = normalizeQuery(input);
722
- info(`Searching for "${normalizedInput}"...`);
769
+ emitInfo(`Searching for "${normalizedInput}"...`);
723
770
  const encoded = encodeURIComponent(normalizedInput);
724
771
  try {
725
772
  const data = await apiRequest('GET', `/api/v1/tokenboard/workflows/list?keyword=${encoded}&page=1&page_size=5&sort_by=views`, null, config?.token, apiBase);
@@ -732,19 +779,43 @@ async function resolveAssetId(input, config, apiBase) {
732
779
  // ─── Search ───
733
780
 
734
781
  async function cmdSearch() {
735
- const rawQuery = process.argv.slice(3).join(' ');
736
- if (!rawQuery) error('Usage: tokrepo search <keyword>');
782
+ const args = parseArgs(process.argv);
783
+ const rawQuery = args.flags.keyword || args.positional.join(' ');
784
+ if (!rawQuery) {
785
+ showSearchHelp();
786
+ process.exit(1);
787
+ }
737
788
 
738
789
  const query = normalizeQuery(rawQuery);
739
790
  const displayQuery = query !== rawQuery ? `"${rawQuery}" → "${query}"` : `"${query}"`;
740
- log(`\n${C.bold}tokrepo search${C.reset} ${displayQuery}\n`);
791
+ if (!args.flags.json) log(`\n${C.bold}tokrepo search${C.reset} ${displayQuery}\n`);
741
792
 
742
793
  const config = readConfig();
743
794
  const apiBase = config?.api || DEFAULT_API;
744
795
 
745
796
  try {
746
797
  const encoded = encodeURIComponent(query);
747
- const data = await apiRequest('GET', `/api/v1/tokenboard/workflows/list?keyword=${encoded}&page=1&page_size=20&sort_by=views`, null, config?.token, apiBase);
798
+ const pageSize = Number(args.flags.pageSize || (args.flags.all ? 200 : 20)) || 20;
799
+ const sortBy = args.flags.sortBy || 'views';
800
+ let page = Number(args.flags.page || 1) || 1;
801
+ let data = await apiRequest('GET', `/api/v1/tokenboard/workflows/list?keyword=${encoded}&page=${page}&page_size=${pageSize}&sort_by=${encodeURIComponent(sortBy)}`, null, config?.token, apiBase);
802
+
803
+ if (args.flags.all) {
804
+ const list = [...(data.list || [])];
805
+ while (list.length < (data.total || 0)) {
806
+ page++;
807
+ const next = await apiRequest('GET', `/api/v1/tokenboard/workflows/list?keyword=${encoded}&page=${page}&page_size=${pageSize}&sort_by=${encodeURIComponent(sortBy)}`, null, config?.token, apiBase);
808
+ const items = next.list || [];
809
+ if (items.length === 0) break;
810
+ list.push(...items);
811
+ }
812
+ data = { ...data, list };
813
+ }
814
+
815
+ if (args.flags.json) {
816
+ outputJson({ query, total: data.total || 0, count: (data.list || []).length, list: data.list || [] });
817
+ return;
818
+ }
748
819
 
749
820
  if (!data.list || data.list.length === 0) {
750
821
  info('No assets found.');
@@ -782,6 +853,44 @@ async function cmdSearch() {
782
853
  }
783
854
  }
784
855
 
856
+ async function cmdDetail() {
857
+ const args = parseArgs(process.argv);
858
+ const target = args.positional[0];
859
+ if (!target) {
860
+ showDetailHelp();
861
+ process.exit(1);
862
+ }
863
+
864
+ const config = readConfig();
865
+ const apiBase = config?.api || DEFAULT_API;
866
+
867
+ try {
868
+ const uuid = await resolveAssetId(target, config, apiBase, { quiet: Boolean(args.flags.json) });
869
+ const data = await apiRequest('GET', `/api/v1/tokenboard/workflows/detail?uuid=${encodeURIComponent(uuid)}`, null, config?.token, apiBase);
870
+ if (args.flags.json) {
871
+ outputJson(data);
872
+ return;
873
+ }
874
+
875
+ const workflow = data.workflow;
876
+ log(`\n${C.bold}tokrepo detail${C.reset}\n`);
877
+ log(` ${C.bold}${workflow.title}${C.reset}`);
878
+ if (workflow.description) log(` ${C.dim}${workflow.description}${C.reset}`);
879
+ log(`\n ${C.bold}UUID:${C.reset} ${workflow.uuid}`);
880
+ log(` ${C.bold}URL:${C.reset} ${C.cyan}https://tokrepo.com/en/workflows/${workflow.uuid}${C.reset}`);
881
+ if (workflow.tags && workflow.tags.length) {
882
+ log(` ${C.bold}Tags:${C.reset} ${workflow.tags.map(t => t.name || t.slug).join(', ')}`);
883
+ }
884
+ const fileCount = (workflow.files || []).length;
885
+ const stepCount = (workflow.steps || []).length;
886
+ log(` ${C.bold}Files:${C.reset} ${fileCount}`);
887
+ log(` ${C.bold}Steps:${C.reset} ${stepCount}`);
888
+ log('');
889
+ } catch (e) {
890
+ error(`Detail failed: ${e.message}`);
891
+ }
892
+ }
893
+
785
894
  // ─── Install (smart pull with correct placement) ───
786
895
 
787
896
  function normalizeInstallTarget(target) {
@@ -790,6 +899,9 @@ function normalizeInstallTarget(target) {
790
899
  const aliases = {
791
900
  gemini: 'gemini',
792
901
  'gemini-cli': 'gemini',
902
+ codex: 'codex',
903
+ 'codex-cli': 'codex',
904
+ 'openai-codex': 'codex',
793
905
  };
794
906
  return aliases[normalized] || normalized;
795
907
  }
@@ -797,8 +909,8 @@ function normalizeInstallTarget(target) {
797
909
  function validateInstallTarget(target) {
798
910
  if (!target) return '';
799
911
  const normalized = normalizeInstallTarget(target);
800
- if (normalized !== 'gemini') {
801
- error(`Unsupported install target: ${target}. Supported targets: gemini`);
912
+ if (!SUPPORTED_INSTALL_TARGETS.includes(normalized)) {
913
+ error(`Unsupported install target: ${target}. Supported targets: ${SUPPORTED_INSTALL_TARGETS.join(', ')}`);
802
914
  }
803
915
  return normalized;
804
916
  }
@@ -838,27 +950,449 @@ function formatGeminiContent(workflow, contents) {
838
950
  return `${parts.join('\n\n').trim()}\n`;
839
951
  }
840
952
 
953
+ function getWorkflowAssetType(workflow) {
954
+ if (!workflow || !workflow.tags || workflow.tags.length === 0) return 'other';
955
+ return (workflow.tags[0].slug || workflow.tags[0].name || '').toLowerCase();
956
+ }
957
+
958
+ function extractInstallableContents(workflow, assetType) {
959
+ const contents = [];
960
+ const files = workflow.files || [];
961
+
962
+ if (files.length > 0) {
963
+ for (const f of files) {
964
+ if (f.content && !f.content.startsWith('PK')) {
965
+ contents.push({
966
+ name: f.name || 'SKILL.md',
967
+ content: f.content,
968
+ type: f.type || f.file_type || f.fileType || detectFileType(f.name || ''),
969
+ });
970
+ }
971
+ }
972
+ }
973
+
974
+ if (contents.length === 0 && workflow.steps) {
975
+ for (const step of workflow.steps) {
976
+ const content = step.prompt_template || step.promptTemplate;
977
+ if (content && !content.startsWith('PK')) {
978
+ const name = (step.title || `step-${step.step_order || contents.length + 1}`).replace(/[/\\?%*:|"<>]/g, '-');
979
+ contents.push({ name, content, type: assetType || 'other' });
980
+ }
981
+ }
982
+ }
983
+
984
+ return contents;
985
+ }
986
+
987
+ function sha256(content) {
988
+ return crypto.createHash('sha256').update(String(content || '')).digest('hex');
989
+ }
990
+
991
+ function slugify(input, fallback = 'asset') {
992
+ const raw = String(input || '')
993
+ .normalize('NFKD')
994
+ .replace(/[^\x00-\x7F]/g, '')
995
+ .toLowerCase()
996
+ .replace(/['"]/g, '')
997
+ .replace(/[^a-z0-9]+/g, '-')
998
+ .replace(/^-+|-+$/g, '')
999
+ .replace(/-{2,}/g, '-');
1000
+ return raw || fallback;
1001
+ }
1002
+
1003
+ function sanitizePathSegment(input, fallback = 'file') {
1004
+ const cleaned = String(input || '')
1005
+ .replace(/[/\\?%*:|"<>]/g, '-')
1006
+ .replace(/^\.+$/, '')
1007
+ .replace(/^\.+/, '')
1008
+ .trim();
1009
+ return cleaned || fallback;
1010
+ }
1011
+
1012
+ function sanitizeRelativePath(input, fallback = 'file.md') {
1013
+ const normalized = String(input || fallback).replace(/\\/g, '/');
1014
+ const parts = normalized
1015
+ .split('/')
1016
+ .filter(Boolean)
1017
+ .map((part, index) => sanitizePathSegment(part, index === 0 ? fallback : 'file'));
1018
+ let rel = parts.join('/');
1019
+ if (!rel) rel = fallback;
1020
+ if (!path.extname(rel)) rel += '.md';
1021
+ return rel;
1022
+ }
1023
+
1024
+ function ensureInside(baseDir, destPath) {
1025
+ const resolvedBase = path.resolve(baseDir);
1026
+ const resolvedDest = path.resolve(destPath);
1027
+ return resolvedDest === resolvedBase || resolvedDest.startsWith(resolvedBase + path.sep);
1028
+ }
1029
+
1030
+ function getFrontmatter(content) {
1031
+ const text = String(content || '').replace(/^\uFEFF/, '');
1032
+ const match = text.match(/^---\s*\n([\s\S]*?)\n---\s*\n?/);
1033
+ if (!match) return null;
1034
+ return { raw: match[0], body: match[1], rest: text.slice(match[0].length) };
1035
+ }
1036
+
1037
+ function getFrontmatterValue(content, key) {
1038
+ const fm = getFrontmatter(content);
1039
+ if (!fm) return '';
1040
+ const re = new RegExp(`^${key}\\s*:\\s*(.+)$`, 'im');
1041
+ const match = fm.body.match(re);
1042
+ if (!match) return '';
1043
+ return match[1].trim().replace(/^['"]|['"]$/g, '');
1044
+ }
1045
+
1046
+ function isCodexSkillDocument(item) {
1047
+ const content = item?.content || '';
1048
+ const name = getFrontmatterValue(content, 'name');
1049
+ const description = getFrontmatterValue(content, 'description');
1050
+ return Boolean(name && description) || /^skill\.md$/i.test(path.basename(item?.name || ''));
1051
+ }
1052
+
1053
+ function yamlQuoted(value) {
1054
+ return JSON.stringify(String(value || '').replace(/\s+/g, ' ').trim());
1055
+ }
1056
+
1057
+ function ensureCodexSkillFrontmatter(content, name, description) {
1058
+ const text = String(content || '').replace(/^\uFEFF/, '').trim();
1059
+ const fm = getFrontmatter(text);
1060
+ if (!fm) {
1061
+ return `---\nname: ${name}\ndescription: ${yamlQuoted(description)}\n---\n\n${text}\n`;
1062
+ }
1063
+
1064
+ const lines = fm.body.split('\n');
1065
+ const hasName = lines.some(line => /^name\s*:/i.test(line));
1066
+ const hasDescription = lines.some(line => /^description\s*:/i.test(line));
1067
+ const next = [];
1068
+ if (!hasName) next.push(`name: ${name}`);
1069
+ if (!hasDescription) next.push(`description: ${yamlQuoted(description)}`);
1070
+ next.push(...lines);
1071
+ return `---\n${next.join('\n')}\n---\n${fm.rest.trim() ? `\n${fm.rest.trim()}\n` : '\n'}`;
1072
+ }
1073
+
1074
+ function getCodexDescription(workflow, item) {
1075
+ const fromItem = getFrontmatterValue(item?.content || '', 'description');
1076
+ if (fromItem) return fromItem;
1077
+ const title = workflow?.title || item?.name || 'TokRepo asset';
1078
+ const desc = workflow?.description || '';
1079
+ return desc ? desc.substring(0, 240) : `Use ${title} from TokRepo.`;
1080
+ }
1081
+
1082
+ function codexSkillDirName(workflow, item, suffix = '') {
1083
+ const uuid8 = (workflow?.uuid || '').substring(0, 8) || 'asset';
1084
+ const fmName = getFrontmatterValue(item?.content || '', 'name');
1085
+ const base = fmName || item?.name || workflow?.slug || workflow?.title || uuid8;
1086
+ const baseSlug = slugify(base, uuid8).replace(/-md$/, '');
1087
+ const withSuffix = suffix ? `${baseSlug}-${slugify(suffix, 'part')}` : baseSlug;
1088
+ if (withSuffix.startsWith('tokrepo-') && withSuffix.endsWith(`-${uuid8}`)) return withSuffix;
1089
+ if (withSuffix.startsWith('tokrepo-')) return `${withSuffix}-${uuid8}`;
1090
+ if (withSuffix.endsWith(`-${uuid8}`)) return `tokrepo-${withSuffix}`;
1091
+ return `tokrepo-${withSuffix}-${uuid8}`;
1092
+ }
1093
+
1094
+ function explicitInstallMode(workflow) {
1095
+ const candidates = [
1096
+ workflow?.installMode,
1097
+ workflow?.install_mode,
1098
+ workflow?.metadata?.installMode,
1099
+ workflow?.metadata?.install_mode,
1100
+ ].filter(Boolean);
1101
+ const mode = String(candidates[0] || '').toLowerCase();
1102
+ return ['single', 'bundle', 'split'].includes(mode) ? mode : '';
1103
+ }
1104
+
1105
+ function inferCodexInstallMode(workflow, contents) {
1106
+ const explicit = explicitInstallMode(workflow);
1107
+ if (explicit) return explicit;
1108
+ if (contents.length <= 1) return 'single';
1109
+ const skillDocs = contents.filter(isCodexSkillDocument);
1110
+ if (skillDocs.length > 1 && skillDocs.length === contents.length) return 'split';
1111
+ return 'bundle';
1112
+ }
1113
+
1114
+ function analyzeInstallRisks(fileName, content, type) {
1115
+ const risks = new Set();
1116
+ const lowerName = String(fileName || '').toLowerCase();
1117
+ const text = String(content || '');
1118
+ if (type === 'script' || /\.(sh|py|js|mjs|ts|rb|go|rs|lua)$/.test(lowerName) || /^#!\//.test(text)) {
1119
+ risks.add('executable');
1120
+ }
1121
+ if (lowerName.endsWith('.mcp.json') || /"mcpServers"\s*:/.test(text) || /\bmcpServers\s*:/.test(text)) {
1122
+ risks.add('mcp');
1123
+ }
1124
+ if (/\b(PATH|HOME|TOKEN|API_KEY|SECRET|PASSWORD|OPENAI_API_KEY|ANTHROPIC_API_KEY)\b/.test(text)) {
1125
+ risks.add('env');
1126
+ }
1127
+ if (/(^|[\s"'=])\/(Users|opt|usr|var|etc|tmp)\//.test(text) || /[A-Za-z]:\\/.test(text)) {
1128
+ risks.add('absolute-path');
1129
+ }
1130
+ return Array.from(risks);
1131
+ }
1132
+
1133
+ function buildBundleEntrypoint(workflow, contents, skillName) {
1134
+ const title = workflow.title || 'TokRepo Asset';
1135
+ const sourceUrl = `https://tokrepo.com/en/workflows/${workflow.uuid}`;
1136
+ const fileList = contents
1137
+ .map((item, index) => `- ${sanitizeRelativePath(item.name || `file-${index + 1}.md`)}`)
1138
+ .join('\n');
1139
+ const body = `# ${title}\n\nThis Codex skill was installed from TokRepo as a bundle. Use the files in this directory as the source material for the skill.\n\n${fileList}\n\nSource: ${sourceUrl}\n`;
1140
+ return ensureCodexSkillFrontmatter(body, skillName, getCodexDescription(workflow));
1141
+ }
1142
+
1143
+ function addPlanFile(plan, destPath, content, sourceName, type) {
1144
+ const riskFlags = analyzeInstallRisks(sourceName || destPath, content, type);
1145
+ plan.files.push({
1146
+ path: destPath,
1147
+ sourceName: sourceName || path.basename(destPath),
1148
+ sha256: sha256(content),
1149
+ bytes: Buffer.byteLength(String(content || '')),
1150
+ riskFlags,
1151
+ content,
1152
+ });
1153
+ for (const risk of riskFlags) {
1154
+ if (!plan.risks.includes(risk)) plan.risks.push(risk);
1155
+ }
1156
+ }
1157
+
1158
+ function buildCodexInstallPlan(workflow, contents, opts = {}) {
1159
+ const installMode = opts.installMode || inferCodexInstallMode(workflow, contents);
1160
+ const plan = {
1161
+ uuid: workflow.uuid,
1162
+ title: workflow.title,
1163
+ sourceUrl: `https://tokrepo.com/en/workflows/${workflow.uuid}`,
1164
+ targetTool: 'codex',
1165
+ installMode,
1166
+ manifestPath: CODEX_MANIFEST_FILE,
1167
+ files: [],
1168
+ risks: [],
1169
+ };
1170
+
1171
+ if (installMode === 'split') {
1172
+ const usedDirs = new Set();
1173
+ contents.forEach((item, index) => {
1174
+ const skillName = slugify(getFrontmatterValue(item.content, 'name') || item.name || `${workflow.title}-${index + 1}`, `${workflow.uuid.substring(0, 8)}-${index + 1}`);
1175
+ const baseDirName = codexSkillDirName(workflow, item, contents.length > 1 && !getFrontmatterValue(item.content, 'name') ? String(index + 1) : '');
1176
+ let dirName = baseDirName;
1177
+ let duplicateIndex = 2;
1178
+ while (usedDirs.has(dirName)) {
1179
+ dirName = `${baseDirName}-${duplicateIndex}`;
1180
+ duplicateIndex++;
1181
+ }
1182
+ usedDirs.add(dirName);
1183
+ const destDir = path.join(CODEX_SKILLS_DIR, dirName);
1184
+ const destPath = path.join(destDir, 'SKILL.md');
1185
+ const content = ensureCodexSkillFrontmatter(item.content, skillName, getCodexDescription(workflow, item));
1186
+ addPlanFile(plan, destPath, content, item.name, item.type);
1187
+ });
1188
+ return plan;
1189
+ }
1190
+
1191
+ const primaryItem = contents.find(item => /^skill\.md$/i.test(path.basename(item.name || ''))) || contents[0];
1192
+ const skillName = slugify(getFrontmatterValue(primaryItem?.content || '', 'name') || workflow.slug || workflow.title, workflow.uuid.substring(0, 8));
1193
+ const dirItem = getFrontmatterValue(primaryItem?.content || '', 'name') ? primaryItem : null;
1194
+ const destDir = path.join(CODEX_SKILLS_DIR, codexSkillDirName(workflow, dirItem));
1195
+ plan.baseDir = destDir;
1196
+
1197
+ if (installMode === 'single' || contents.length === 1) {
1198
+ const item = primaryItem;
1199
+ const content = ensureCodexSkillFrontmatter(item.content, skillName, getCodexDescription(workflow, item));
1200
+ addPlanFile(plan, path.join(destDir, 'SKILL.md'), content, item.name, item.type);
1201
+ return plan;
1202
+ }
1203
+
1204
+ let hasEntrypoint = false;
1205
+ const usedRelNames = new Set();
1206
+ for (let i = 0; i < contents.length; i++) {
1207
+ const item = contents[i];
1208
+ const relName = sanitizeRelativePath(item.name || `file-${i + 1}.md`);
1209
+ let destName = /^skill\.md$/i.test(path.basename(relName)) ? 'SKILL.md' : relName;
1210
+ if (usedRelNames.has(destName)) {
1211
+ const ext = path.extname(destName);
1212
+ const base = destName.slice(0, destName.length - ext.length);
1213
+ let duplicateIndex = 2;
1214
+ let candidate = `${base}-${duplicateIndex}${ext}`;
1215
+ while (usedRelNames.has(candidate)) {
1216
+ duplicateIndex++;
1217
+ candidate = `${base}-${duplicateIndex}${ext}`;
1218
+ }
1219
+ destName = candidate;
1220
+ }
1221
+ usedRelNames.add(destName);
1222
+ const destPath = path.join(destDir, destName);
1223
+ const content = destName === 'SKILL.md'
1224
+ ? ensureCodexSkillFrontmatter(item.content, skillName, getCodexDescription(workflow, item))
1225
+ : `${String(item.content || '').trim()}\n`;
1226
+ if (destName === 'SKILL.md') hasEntrypoint = true;
1227
+ addPlanFile(plan, destPath, content, item.name, item.type);
1228
+ }
1229
+
1230
+ if (!hasEntrypoint) {
1231
+ addPlanFile(plan, path.join(destDir, 'SKILL.md'), buildBundleEntrypoint(workflow, contents, skillName), 'SKILL.md', 'skill');
1232
+ }
1233
+
1234
+ return plan;
1235
+ }
1236
+
1237
+ function publicInstallPlan(plan) {
1238
+ return {
1239
+ uuid: plan.uuid,
1240
+ title: plan.title,
1241
+ sourceUrl: plan.sourceUrl,
1242
+ targetTool: plan.targetTool,
1243
+ installMode: plan.installMode,
1244
+ manifestPath: plan.manifestPath,
1245
+ baseDir: plan.baseDir,
1246
+ risks: plan.risks,
1247
+ files: plan.files.map(file => ({
1248
+ path: file.path,
1249
+ sourceName: file.sourceName,
1250
+ sha256: file.sha256,
1251
+ bytes: file.bytes,
1252
+ riskFlags: file.riskFlags,
1253
+ exists: fs.existsSync(file.path),
1254
+ })),
1255
+ };
1256
+ }
1257
+
1258
+ function hasCodexInstallRisks(plan) {
1259
+ return (plan.risks || []).some(risk => ['mcp', 'executable', 'env', 'absolute-path'].includes(risk));
1260
+ }
1261
+
1262
+ function formatRiskLine(file) {
1263
+ if (!file.riskFlags || file.riskFlags.length === 0) return '';
1264
+ return `${file.sourceName || path.basename(file.path)}: ${file.riskFlags.join(', ')}`;
1265
+ }
1266
+
1267
+ async function confirmCodexInstallRisks(plan, opts = {}) {
1268
+ if (opts.dryRun || opts.stage || !hasCodexInstallRisks(plan)) return;
1269
+ if (opts.approveMcp || opts.approve_mcp || opts.yes) return;
1270
+
1271
+ if (opts.json || opts.throwOnError || process.env.TOKREPO_NONINTERACTIVE === '1') {
1272
+ throw new Error(`Install plan includes risky content (${plan.risks.join(', ')}). Re-run with --dry-run to inspect or --approve-mcp to approve writing the Codex skill bundle.`);
1273
+ }
1274
+
1275
+ warn(`This asset contains ${plan.risks.join(', ')} content.`);
1276
+ log(` ${C.dim}TokRepo will only write files under ${CODEX_SKILLS_DIR}; it will not merge MCP configs, modify PATH, or execute scripts.${C.reset}`);
1277
+ const riskyFiles = plan.files
1278
+ .map(formatRiskLine)
1279
+ .filter(Boolean)
1280
+ .slice(0, 8);
1281
+ for (const line of riskyFiles) {
1282
+ log(` ${C.yellow}!${C.reset} ${line}`);
1283
+ }
1284
+ if (plan.files.length > riskyFiles.length) {
1285
+ log(` ${C.dim}...and ${plan.files.length - riskyFiles.length} more file(s) in the plan${C.reset}`);
1286
+ }
1287
+
1288
+ const answer = await ask('Write this Codex skill bundle anyway? (y/N):');
1289
+ if (answer.toLowerCase() !== 'y') {
1290
+ throw new Error('Install aborted.');
1291
+ }
1292
+ }
1293
+
1294
+ function stageCodexInstallPlan(plan) {
1295
+ const stagedDir = path.join(CODEX_TOKREPO_DIR, 'staged');
1296
+ if (!fs.existsSync(stagedDir)) {
1297
+ fs.mkdirSync(stagedDir, { recursive: true, mode: 0o700 });
1298
+ }
1299
+ const stagePath = path.join(stagedDir, `${plan.uuid}.install-plan.json`);
1300
+ fs.writeFileSync(stagePath, `${JSON.stringify(publicInstallPlan(plan), null, 2)}\n`, { mode: 0o600 });
1301
+ return stagePath;
1302
+ }
1303
+
1304
+ function readCodexManifest() {
1305
+ try {
1306
+ const parsed = JSON.parse(fs.readFileSync(CODEX_MANIFEST_FILE, 'utf8'));
1307
+ if (Array.isArray(parsed.installs)) return parsed;
1308
+ } catch {}
1309
+ return { schemaVersion: 1, installs: [] };
1310
+ }
1311
+
1312
+ function writeCodexManifestRecord(plan, installedFiles) {
1313
+ if (!fs.existsSync(CODEX_TOKREPO_DIR)) {
1314
+ fs.mkdirSync(CODEX_TOKREPO_DIR, { recursive: true, mode: 0o700 });
1315
+ }
1316
+ const manifest = readCodexManifest();
1317
+ const installedAt = new Date().toISOString();
1318
+ const record = {
1319
+ uuid: plan.uuid,
1320
+ title: plan.title,
1321
+ sourceUrl: plan.sourceUrl,
1322
+ targetTool: 'codex',
1323
+ installMode: plan.installMode,
1324
+ installedAt,
1325
+ installedFiles: installedFiles.map(file => ({
1326
+ path: file.path,
1327
+ sourceName: file.sourceName,
1328
+ sha256: file.sha256,
1329
+ bytes: file.bytes,
1330
+ riskFlags: file.riskFlags,
1331
+ })),
1332
+ risks: plan.risks,
1333
+ };
1334
+ manifest.installs = manifest.installs.filter(item => !(item.uuid === plan.uuid && item.targetTool === 'codex'));
1335
+ manifest.installs.push(record);
1336
+ manifest.updatedAt = installedAt;
1337
+ fs.writeFileSync(CODEX_MANIFEST_FILE, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
1338
+ return record;
1339
+ }
1340
+
1341
+ function executeCodexInstallPlan(plan, opts = {}) {
1342
+ if (opts.dryRun) return { dryRun: true, plan: publicInstallPlan(plan), installedFiles: [] };
1343
+ if (opts.stage) {
1344
+ const stagePath = stageCodexInstallPlan(plan);
1345
+ return { dryRun: true, staged: true, stagePath, plan: publicInstallPlan(plan), installedFiles: [] };
1346
+ }
1347
+
1348
+ const installedFiles = [];
1349
+ for (const file of plan.files) {
1350
+ const destDir = path.dirname(file.path);
1351
+ if (!ensureInside(CODEX_SKILLS_DIR, file.path)) {
1352
+ throw new Error(`Install path escaped Codex skills directory: ${file.path}`);
1353
+ }
1354
+ if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
1355
+ fs.writeFileSync(file.path, file.content);
1356
+ installedFiles.push({
1357
+ path: file.path,
1358
+ sourceName: file.sourceName,
1359
+ sha256: sha256(file.content),
1360
+ bytes: Buffer.byteLength(String(file.content || '')),
1361
+ riskFlags: file.riskFlags,
1362
+ });
1363
+ }
1364
+
1365
+ const manifestRecord = writeCodexManifestRecord(plan, installedFiles);
1366
+ return { dryRun: false, plan: publicInstallPlan(plan), installedFiles, manifestRecord };
1367
+ }
1368
+
1369
+ async function installCodexAsset(workflow, contents, opts = {}) {
1370
+ const plan = buildCodexInstallPlan(workflow, contents, opts);
1371
+ await confirmCodexInstallRisks(plan, opts);
1372
+ return executeCodexInstallPlan(plan, opts);
1373
+ }
1374
+
841
1375
  async function cmdInstall() {
842
1376
  const args = parseArgs(process.argv);
843
1377
  const target = args.positional[0];
844
1378
  if (!target) {
845
- error(`Usage: tokrepo install <target> [--target gemini] [--yes]
846
-
847
- Examples:
848
- tokrepo install awesome-cursor-rules # by name
849
- tokrepo install ca000374-f5d8-4d75-a30c-460fda0b6b0e # by uuid
850
- tokrepo install https://tokrepo.com/en/workflows/ca000374-...
851
- tokrepo install pack/seo-geo # install whole theme pack
852
- tokrepo install c4b18aeb --target gemini # write .gemini/GEMINI.md`);
1379
+ showInstallHelp();
1380
+ process.exit(1);
853
1381
  }
854
1382
 
855
- log(`\n${C.bold}tokrepo install${C.reset}\n`);
1383
+ if (!args.flags.json) log(`\n${C.bold}tokrepo install${C.reset}\n`);
856
1384
 
857
1385
  const config = readConfig();
858
1386
  const apiBase = config?.api || DEFAULT_API;
859
1387
  const installOpts = {
860
1388
  targetTool: validateInstallTarget(args.flags.target),
861
1389
  yes: Boolean(args.flags.yes),
1390
+ update: Boolean(args.flags.update),
1391
+ dryRun: Boolean(args.flags.dryRun || args.flags.dry_run),
1392
+ stage: Boolean(args.flags.stage),
1393
+ approveMcp: Boolean(args.flags.approveMcp || args.flags.approve_mcp),
1394
+ json: Boolean(args.flags.json),
1395
+ manifest: Boolean(args.flags.manifest),
862
1396
  };
863
1397
 
864
1398
  // pack/<slug> dispatch — install entire theme pack
@@ -871,7 +1405,10 @@ Examples:
871
1405
  return;
872
1406
  }
873
1407
 
874
- await installOneAsset(target, config, apiBase, installOpts);
1408
+ const result = await installOneAsset(target, config, apiBase, installOpts);
1409
+ if (args.flags.json) {
1410
+ outputJson(result);
1411
+ }
875
1412
  }
876
1413
 
877
1414
  // Install all assets in a theme pack — sequentially, continue past per-item errors
@@ -916,6 +1453,7 @@ async function installPack(slug, config, apiBase, opts) {
916
1453
  async function installOneAsset(target, config, apiBase, opts) {
917
1454
  opts = opts || {};
918
1455
  const die = (msg) => { if (opts.throwOnError) throw new Error(msg); error(msg); };
1456
+ const emitInfo = (msg) => { if (!opts.json) info(msg); };
919
1457
 
920
1458
  // Resolve target to UUID
921
1459
  let uuid = target;
@@ -946,7 +1484,7 @@ async function installOneAsset(target, config, apiBase, opts) {
946
1484
  if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(uuid)) {
947
1485
  // Search by name (normalize separators for better matching)
948
1486
  const normalizedTarget = normalizeQuery(uuid);
949
- info(`Searching for "${normalizedTarget}"...`);
1487
+ emitInfo(`Searching for "${normalizedTarget}"...`);
950
1488
  try {
951
1489
  const encoded = encodeURIComponent(normalizedTarget);
952
1490
  const searchData = await apiRequest('GET', `/api/v1/tokenboard/workflows/list?keyword=${encoded}&page=1&page_size=5&sort_by=views`, null, config?.token, apiBase);
@@ -964,61 +1502,87 @@ async function installOneAsset(target, config, apiBase, opts) {
964
1502
  const chosen = exact || searchData.list[0];
965
1503
 
966
1504
  uuid = chosen.uuid;
967
- info(`Found: ${C.bold}${chosen.title}${C.reset}`);
1505
+ emitInfo(`Found: ${C.bold}${chosen.title}${C.reset}`);
968
1506
  } catch (e) {
969
1507
  die(`Search failed: ${e.message}`);
970
1508
  }
971
1509
  }
972
1510
 
973
1511
  // Fetch the asset
974
- info(`Fetching ${uuid.substring(0, 8)}...`);
1512
+ emitInfo(`Fetching ${uuid.substring(0, 8)}...`);
975
1513
 
976
- let workflow, files;
1514
+ let workflow;
977
1515
  try {
978
1516
  const data = await apiRequest('GET', `/api/v1/tokenboard/workflows/detail?uuid=${uuid}`, null, config?.token, apiBase);
979
1517
  workflow = data.workflow;
980
- files = data.workflow.files || [];
981
1518
  } catch (e) {
982
1519
  die(`Fetch failed: ${e.message}`);
983
1520
  }
984
1521
 
985
- log(`\n ${C.bold}${workflow.title}${C.reset}`);
986
- if (workflow.description) log(` ${C.dim}${workflow.description.substring(0, 100)}${C.reset}`);
1522
+ if (!opts.json) {
1523
+ log(`\n ${C.bold}${workflow.title}${C.reset}`);
1524
+ if (workflow.description) log(` ${C.dim}${workflow.description.substring(0, 100)}${C.reset}`);
1525
+ }
987
1526
 
988
1527
  // Determine asset type from tags
989
- let assetType = 'other';
990
- if (workflow.tags && workflow.tags.length > 0) {
991
- assetType = (workflow.tags[0].slug || workflow.tags[0].name || '').toLowerCase();
992
- }
1528
+ let assetType = getWorkflowAssetType(workflow);
993
1529
 
994
1530
  // Get content — prefer files, fallback to steps
995
- const contents = [];
1531
+ const contents = extractInstallableContents(workflow, assetType);
996
1532
 
997
- if (files.length > 0) {
998
- for (const f of files) {
999
- if (f.content && !f.content.startsWith('PK')) {
1000
- contents.push({ name: f.name, content: f.content, type: f.file_type || f.fileType || 'other' });
1001
- }
1002
- }
1533
+ if (contents.length === 0) {
1534
+ die('No installable content found in this asset.');
1003
1535
  }
1004
1536
 
1005
- if (contents.length === 0 && workflow.steps) {
1006
- for (const step of workflow.steps) {
1007
- const content = step.prompt_template || step.promptTemplate;
1008
- if (content && !content.startsWith('PK')) {
1009
- const name = (step.title || `step-${step.step_order}`).replace(/[/\\?%*:|"<>]/g, '-');
1010
- contents.push({ name, content, type: assetType });
1537
+ if (!opts.json) log('');
1538
+ const targetTool = normalizeInstallTarget(opts.targetTool);
1539
+
1540
+ if (targetTool === 'codex') {
1541
+ let result;
1542
+ try {
1543
+ result = await installCodexAsset(workflow, contents, opts);
1544
+ } catch (e) {
1545
+ die(e.message);
1546
+ }
1547
+
1548
+ if (!opts.json) {
1549
+ const plan = result.plan;
1550
+ if (opts.stage) {
1551
+ info(`Staged install plan: ${result.stagePath}`);
1552
+ info(`No Codex skill files were written. Re-run with --approve-mcp or --yes to install.`);
1553
+ } else if (opts.dryRun) {
1554
+ info(`Dry run: ${plan.files.length} file(s) would be installed to ${CODEX_SKILLS_DIR}`);
1555
+ for (const file of plan.files) {
1556
+ const rel = path.relative(os.homedir(), file.path);
1557
+ log(` ${C.dim}•${C.reset} ~/${rel}`);
1558
+ if (file.riskFlags.length) log(` ${C.yellow}${file.riskFlags.join(', ')}${C.reset}`);
1559
+ }
1560
+ } else {
1561
+ for (const file of result.installedFiles) {
1562
+ const relPath = path.relative(os.homedir(), file.path);
1563
+ success(`Installed: ~/${relPath}`);
1564
+ }
1565
+ log('');
1566
+ success(`${result.installedFiles.length} file(s) installed from ${C.bold}${workflow.title}${C.reset}`);
1567
+ log(` ${C.dim}Manifest: ${CODEX_MANIFEST_FILE}${C.reset}`);
1568
+ log(` ${C.dim}Source: https://tokrepo.com/en/workflows/${uuid}${C.reset}\n`);
1011
1569
  }
1012
1570
  }
1013
- }
1014
1571
 
1015
- if (contents.length === 0) {
1016
- die('No installable content found in this asset.');
1572
+ return {
1573
+ uuid,
1574
+ title: workflow.title,
1575
+ targetTool: 'codex',
1576
+ dryRun: Boolean(opts.dryRun || opts.stage),
1577
+ staged: Boolean(result.staged),
1578
+ stagePath: result.stagePath,
1579
+ installMode: result.plan.installMode,
1580
+ installedFiles: result.installedFiles || [],
1581
+ plan: result.plan,
1582
+ manifestPath: CODEX_MANIFEST_FILE,
1583
+ };
1017
1584
  }
1018
1585
 
1019
- log('');
1020
- const targetTool = normalizeInstallTarget(opts.targetTool);
1021
-
1022
1586
  if (targetTool === 'gemini') {
1023
1587
  const destDir = path.join(process.cwd(), '.gemini');
1024
1588
  if (!fs.existsSync(destDir)) {
@@ -1039,7 +1603,13 @@ async function installOneAsset(target, config, apiBase, opts) {
1039
1603
  log('');
1040
1604
  success(`1 file installed from ${C.bold}${workflow.title}${C.reset}`);
1041
1605
  log(` ${C.dim}Source: https://tokrepo.com/en/workflows/${uuid}${C.reset}\n`);
1042
- return;
1606
+ return {
1607
+ uuid,
1608
+ title: workflow.title,
1609
+ targetTool: 'gemini',
1610
+ installedFiles: [{ path: destPath }],
1611
+ sourceUrl: `https://tokrepo.com/en/workflows/${uuid}`,
1612
+ };
1043
1613
  }
1044
1614
 
1045
1615
  // Smart install based on asset type
@@ -1122,6 +1692,13 @@ async function installOneAsset(target, config, apiBase, opts) {
1122
1692
  log('');
1123
1693
  success(`${installed} file(s) installed from ${C.bold}${workflow.title}${C.reset}`);
1124
1694
  log(` ${C.dim}Source: https://tokrepo.com/en/workflows/${uuid}${C.reset}\n`);
1695
+ return {
1696
+ uuid,
1697
+ title: workflow.title,
1698
+ targetTool: targetTool || 'project',
1699
+ installed,
1700
+ sourceUrl: `https://tokrepo.com/en/workflows/${uuid}`,
1701
+ };
1125
1702
  }
1126
1703
 
1127
1704
  async function cmdWhoami() {
@@ -1141,13 +1718,33 @@ async function cmdWhoami() {
1141
1718
  }
1142
1719
 
1143
1720
  async function cmdList() {
1144
- log(`\n${C.bold}tokrepo list${C.reset}\n`);
1721
+ const args = parseArgs(process.argv);
1722
+ if (!args.flags.json) log(`\n${C.bold}tokrepo list${C.reset}\n`);
1145
1723
 
1146
1724
  const config = readConfig();
1147
1725
  if (!config || !config.token) error('Not logged in. Run: tokrepo login');
1148
1726
 
1149
1727
  try {
1150
- const data = await apiRequest('GET', '/api/v1/tokenboard/workflows/my?page=1&page_size=50', null, config.token, config.api);
1728
+ const pageSize = Number(args.flags.pageSize || (args.flags.all ? 200 : 50)) || 50;
1729
+ let page = Number(args.flags.page || 1) || 1;
1730
+ let data = await apiRequest('GET', `/api/v1/tokenboard/workflows/my?page=${page}&page_size=${pageSize}`, null, config.token, config.api);
1731
+
1732
+ if (args.flags.all) {
1733
+ const list = [...(data.list || [])];
1734
+ while (list.length < (data.total || 0)) {
1735
+ page++;
1736
+ const next = await apiRequest('GET', `/api/v1/tokenboard/workflows/my?page=${page}&page_size=${pageSize}`, null, config.token, config.api);
1737
+ const items = next.list || [];
1738
+ if (items.length === 0) break;
1739
+ list.push(...items);
1740
+ }
1741
+ data = { ...data, list };
1742
+ }
1743
+
1744
+ if (args.flags.json) {
1745
+ outputJson({ total: data.total || 0, count: (data.list || []).length, list: data.list || [] });
1746
+ return;
1747
+ }
1151
1748
 
1152
1749
  if (!data.list || data.list.length === 0) {
1153
1750
  info('No assets found. Run: tokrepo push');
@@ -1207,16 +1804,22 @@ async function cmdUpdate() {
1207
1804
  }
1208
1805
 
1209
1806
  async function cmdDelete() {
1210
- const uuid = process.argv[3];
1211
- if (!uuid) error('Usage: tokrepo delete <uuid>');
1807
+ const args = parseArgs(process.argv);
1808
+ const uuid = args.positional[0];
1809
+ if (!uuid) error('Usage: tokrepo delete <uuid> [--yes]');
1212
1810
 
1213
1811
  log(`\n${C.bold}tokrepo delete${C.reset}\n`);
1214
1812
 
1215
1813
  const config = readConfig();
1216
1814
  if (!config || !config.token) error('Not logged in. Run: tokrepo login');
1217
1815
 
1218
- const confirm = await ask(`Delete ${uuid.substring(0,8)}...? (y/N):`);
1219
- if (confirm.toLowerCase() !== 'y') { log('Aborted.'); return; }
1816
+ // --yes / -y / TOKREPO_NONINTERACTIVE 跳过交互式确认(脚本/CI 友好)。
1817
+ // 没有这两个的话仍然要 y/N 防误删。
1818
+ const skipConfirm = Boolean(args.flags.yes) || Boolean(args.flags.y) || process.env.TOKREPO_NONINTERACTIVE === '1';
1819
+ if (!skipConfirm) {
1820
+ const confirm = await ask(`Delete ${uuid.substring(0,8)}...? (y/N):`);
1821
+ if (confirm.toLowerCase() !== 'y') { log('Aborted.'); return; }
1822
+ }
1220
1823
 
1221
1824
  try {
1222
1825
  await apiRequest('DELETE', '/api/v1/tokenboard/workflows/delete', { uuid }, config.token, config.api);
@@ -1226,107 +1829,393 @@ async function cmdDelete() {
1226
1829
  }
1227
1830
  }
1228
1831
 
1832
+ function tagMatchesTypes(workflow, requestedTypes) {
1833
+ if (!requestedTypes || requestedTypes.length === 0) return true;
1834
+ const tags = (workflow.tags || []).flatMap(t => [t.slug, t.name]).filter(Boolean).map(t => String(t).toLowerCase());
1835
+ const assetType = getWorkflowAssetType(workflow);
1836
+ return requestedTypes.some(type => {
1837
+ const needle = String(type).trim().toLowerCase();
1838
+ if (!needle) return false;
1839
+ if (assetType === needle || assetType === `${needle}s`) return true;
1840
+ return tags.some(tag => tag === needle || tag === `${needle}s` || tag.includes(needle));
1841
+ });
1842
+ }
1843
+
1844
+ function itemMatchesKeyword(workflow, keyword) {
1845
+ if (!keyword) return true;
1846
+ const needle = normalizeQuery(keyword).toLowerCase();
1847
+ const fields = [
1848
+ workflow.title,
1849
+ workflow.slug,
1850
+ workflow.description,
1851
+ ...(workflow.tags || []).flatMap(t => [t.name, t.slug]),
1852
+ ].filter(Boolean).join(' ').toLowerCase();
1853
+ return needle.split(/\s+/).every(word => fields.includes(word));
1854
+ }
1855
+
1856
+ async function fetchCloneItems(username, config, apiBase, args) {
1857
+ const pageSize = Number(args.flags.pageSize || 200) || 200;
1858
+ const keyword = args.flags.keyword || '';
1859
+ const requestedTypes = String(args.flags.types || '')
1860
+ .split(',')
1861
+ .map(s => s.trim())
1862
+ .filter(Boolean);
1863
+
1864
+ let effectiveUsername = username.startsWith('@') ? username.slice(1) : username;
1865
+ const result = { username: effectiveUsername, source: 'public', list: [], total: 0 };
1866
+
1867
+ let cloneSelf = effectiveUsername === 'me';
1868
+ if (config?.token) {
1869
+ try {
1870
+ const me = await apiRequest('GET', '/api/v1/tokenboard/auth/me', null, config.token, apiBase);
1871
+ if (effectiveUsername === 'me' || me.nickname?.toLowerCase() === effectiveUsername.toLowerCase()) {
1872
+ cloneSelf = true;
1873
+ effectiveUsername = me.nickname || effectiveUsername;
1874
+ result.username = effectiveUsername;
1875
+ }
1876
+ } catch { /* anonymous/public clone still works */ }
1877
+ }
1878
+
1879
+ if (cloneSelf) {
1880
+ if (!config?.token) error('Cloning @me requires login or TOKREPO_TOKEN.');
1881
+ let page = 1;
1882
+ while (true) {
1883
+ const data = await apiRequest('GET', `/api/v1/tokenboard/workflows/my?page=${page}&page_size=${pageSize}`, null, config.token, apiBase);
1884
+ const items = data.list || [];
1885
+ result.total = data.total || result.total;
1886
+ result.list.push(...items);
1887
+ if (items.length < pageSize || result.list.length >= result.total) break;
1888
+ page++;
1889
+ }
1890
+ result.source = 'my';
1891
+ } else {
1892
+ let page = 1;
1893
+ while (true) {
1894
+ const params = [
1895
+ `author_name=${encodeURIComponent(effectiveUsername)}`,
1896
+ `page=${page}`,
1897
+ `page_size=${pageSize}`,
1898
+ 'sort_by=latest',
1899
+ ];
1900
+ if (keyword) params.push(`keyword=${encodeURIComponent(normalizeQuery(keyword))}`);
1901
+ const data = await apiRequest('GET', `/api/v1/tokenboard/workflows/list?${params.join('&')}`, null, config?.token, apiBase);
1902
+ const items = data.list || data.items || [];
1903
+ result.total = data.total || result.total;
1904
+ result.list.push(...items);
1905
+ if (items.length < pageSize || result.list.length >= result.total) break;
1906
+ page++;
1907
+ }
1908
+ }
1909
+
1910
+ result.list = result.list.filter(item => itemMatchesKeyword(item, keyword) && tagMatchesTypes(item, requestedTypes));
1911
+ result.count = result.list.length;
1912
+ result.keyword = keyword || undefined;
1913
+ result.types = requestedTypes;
1914
+ return result;
1915
+ }
1916
+
1229
1917
  async function cmdClone() {
1230
- const target = process.argv[3];
1231
- if (!target) error('Usage: tokrepo clone @username');
1918
+ const args = parseArgs(process.argv);
1919
+ const target = args.positional[0];
1920
+ if (!target) {
1921
+ showCloneHelp();
1922
+ process.exit(1);
1923
+ }
1232
1924
 
1233
- log(`\n${C.bold}tokrepo clone${C.reset}\n`);
1925
+ const json = Boolean(args.flags.json);
1926
+ if (!json) log(`\n${C.bold}tokrepo clone${C.reset}\n`);
1234
1927
 
1235
1928
  const config = readConfig();
1236
1929
  const apiBase = config?.api || DEFAULT_API;
1930
+ const targetTool = validateInstallTarget(args.flags.target);
1931
+ const dryRun = Boolean(args.flags.dryRun || args.flags.dry_run);
1237
1932
 
1238
- // Extract username from @username format
1239
- let username = target;
1240
- if (username.startsWith('@')) username = username.slice(1);
1241
-
1242
- // Step 1: Find user's UUID by searching for their workflows
1243
- info(`Finding user @${username}...`);
1244
- let authorUuid = '';
1245
1933
  try {
1246
- 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);
1247
- const items = searchData.list || searchData.items || [];
1248
- for (const item of items) {
1249
- const authorName = (item.author?.nickname || item.nickname || '').toLowerCase();
1250
- if (authorName === username.toLowerCase()) {
1251
- authorUuid = item.author?.uuid || item.author_uuid || '';
1252
- break;
1934
+ if (!json) info(`Fetching assets from ${target}...`);
1935
+ const cloneItems = await fetchCloneItems(target, config, apiBase, args);
1936
+
1937
+ if (cloneItems.list.length === 0) {
1938
+ if (json) {
1939
+ outputJson({ target, count: 0, list: [] });
1940
+ } else {
1941
+ info(`${target} has no matching assets.`);
1253
1942
  }
1943
+ return;
1254
1944
  }
1255
- } catch { /* fall through */ }
1256
1945
 
1257
- if (!authorUuid) {
1258
- // Try fetching user's own workflows if logged in and cloning self
1259
- if (config?.token) {
1946
+ if (!json) log(` Found ${C.bold}${cloneItems.list.length}${C.reset} matching asset(s)\n`);
1947
+
1948
+ if (targetTool === 'codex') {
1949
+ const results = [];
1950
+ let installedCount = 0;
1951
+ for (let i = 0; i < cloneItems.list.length; i++) {
1952
+ const item = cloneItems.list[i];
1953
+ if (!json) log(`${C.dim}[${i + 1}/${cloneItems.list.length}]${C.reset} ${item.title}`);
1954
+ try {
1955
+ const detail = await apiRequest('GET', `/api/v1/tokenboard/workflows/detail?uuid=${item.uuid}`, null, config?.token, apiBase);
1956
+ const workflow = detail.workflow;
1957
+ const assetType = getWorkflowAssetType(workflow);
1958
+ const contents = extractInstallableContents(workflow, assetType);
1959
+ if (contents.length === 0) throw new Error('No installable content found');
1960
+ const result = await installCodexAsset(workflow, contents, {
1961
+ ...args.flags,
1962
+ dryRun,
1963
+ stage: Boolean(args.flags.stage),
1964
+ approveMcp: Boolean(args.flags.approveMcp || args.flags.approve_mcp),
1965
+ json: true,
1966
+ throwOnError: true,
1967
+ });
1968
+ if (!dryRun) installedCount += result.installedFiles.length;
1969
+ results.push({
1970
+ uuid: workflow.uuid,
1971
+ title: workflow.title,
1972
+ dryRun: Boolean(dryRun || args.flags.stage),
1973
+ staged: Boolean(result.staged),
1974
+ stagePath: result.stagePath,
1975
+ installMode: result.plan.installMode,
1976
+ files: result.plan.files,
1977
+ installedFiles: result.installedFiles || [],
1978
+ risks: result.plan.risks,
1979
+ });
1980
+ if (!json) {
1981
+ const fileCount = (dryRun || args.flags.stage) ? result.plan.files.length : result.installedFiles.length;
1982
+ success(`${args.flags.stage ? 'Staged' : dryRun ? 'Planned' : 'Installed'} ${fileCount} file(s)`);
1983
+ }
1984
+ } catch (e) {
1985
+ results.push({ uuid: item.uuid, title: item.title, error: e.message });
1986
+ if (!json) warn(`Skipped "${item.title}": ${e.message}`);
1987
+ }
1988
+ }
1989
+
1990
+ const response = {
1991
+ target,
1992
+ username: cloneItems.username,
1993
+ targetTool: 'codex',
1994
+ dryRun,
1995
+ total: cloneItems.total,
1996
+ count: cloneItems.count,
1997
+ manifestPath: CODEX_MANIFEST_FILE,
1998
+ results,
1999
+ };
2000
+ if (json) {
2001
+ outputJson(response);
2002
+ } else {
2003
+ log('');
2004
+ if (args.flags.stage) {
2005
+ success(`Staged ${results.filter(r => !r.error).length}/${cloneItems.list.length} asset install plan(s)`);
2006
+ } else if (dryRun) {
2007
+ success(`Dry run complete: ${results.filter(r => !r.error).length}/${cloneItems.list.length} assets planned`);
2008
+ } else {
2009
+ success(`Installed ${installedCount} Codex file(s) from ${results.filter(r => !r.error).length}/${cloneItems.list.length} assets`);
2010
+ log(` ${C.dim}Manifest: ${CODEX_MANIFEST_FILE}${C.reset}\n`);
2011
+ }
2012
+ }
2013
+ return;
2014
+ }
2015
+
2016
+ if (targetTool && targetTool !== 'codex') {
2017
+ error(`clone --target ${targetTool} is not implemented yet. Supported clone target: codex`);
2018
+ }
2019
+
2020
+ if (json) {
2021
+ outputJson(cloneItems);
2022
+ return;
2023
+ }
2024
+
2025
+ // Legacy raw clone behavior for users who do not specify a target.
2026
+ const outDir = path.join(process.cwd(), cloneItems.username);
2027
+ if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
2028
+
2029
+ let downloaded = 0;
2030
+ for (const item of cloneItems.list) {
2031
+ const title = item.title || item.uuid;
2032
+ const safeDirName = title.replace(/[/\\?%*:|"<>]/g, '-').substring(0, 80);
2033
+ const assetDir = path.join(outDir, safeDirName);
2034
+
1260
2035
  try {
1261
- const me = await apiRequest('GET', '/api/v1/tokenboard/auth/me', null, config.token, apiBase);
1262
- if (me.nickname?.toLowerCase() === username.toLowerCase()) {
1263
- authorUuid = me.uuid;
2036
+ const detail = await apiRequest('GET', `/api/v1/tokenboard/workflows/detail?uuid=${item.uuid}`, null, config?.token, apiBase);
2037
+ const workflow = detail.workflow;
2038
+ const contents = extractInstallableContents(workflow, getWorkflowAssetType(workflow));
2039
+
2040
+ if (contents.length > 0) {
2041
+ if (!fs.existsSync(assetDir)) fs.mkdirSync(assetDir, { recursive: true });
2042
+ for (const contentItem of contents) {
2043
+ const safeName = sanitizeRelativePath(contentItem.name || 'content.md');
2044
+ fs.writeFileSync(path.join(assetDir, safeName), contentItem.content);
2045
+ }
2046
+ downloaded++;
2047
+ log(` ${C.green}✓${C.reset} ${safeDirName} ${C.dim}(${contents.length} files)${C.reset}`);
1264
2048
  }
1265
- } catch { /* fall through */ }
2049
+ } catch (e) {
2050
+ log(` ${C.yellow}!${C.reset} ${safeDirName} ${C.dim}(skipped: ${e.message})${C.reset}`);
2051
+ }
1266
2052
  }
1267
- if (!authorUuid) error(`User @${username} not found. Make sure they have published assets.`);
2053
+
2054
+ log('');
2055
+ success(`Cloned ${downloaded}/${cloneItems.list.length} assets to ./${cloneItems.username}/`);
2056
+ } catch (e) {
2057
+ error(`Clone failed: ${e.message}`);
1268
2058
  }
2059
+ }
1269
2060
 
1270
- // Step 2: List all public workflows by this author
1271
- info(`Fetching all assets by @${username}...`);
1272
- let allItems = [];
1273
- let page = 1;
1274
- while (true) {
1275
- try {
1276
- 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);
1277
- const items = data.list || data.items || [];
1278
- if (items.length === 0) break;
1279
- allItems = allItems.concat(items);
1280
- if (items.length < 50) break;
1281
- page++;
1282
- } catch (e) {
1283
- error(`Failed to list assets: ${e.message}`);
2061
+ function currentFileSha(filePath) {
2062
+ try {
2063
+ return sha256(fs.readFileSync(filePath, 'utf8'));
2064
+ } catch {
2065
+ return '';
2066
+ }
2067
+ }
2068
+
2069
+ function diffCodexPlanWithLocal(plan, manifestRecord = {}) {
2070
+ const reasons = [];
2071
+ const desired = new Map(plan.files.map(file => [file.path, file.sha256]));
2072
+ const recordedFiles = manifestRecord.installedFiles || manifestRecord.installed_files || [];
2073
+
2074
+ for (const file of plan.files) {
2075
+ if (!fs.existsSync(file.path)) {
2076
+ reasons.push({ type: 'missing', path: file.path });
2077
+ continue;
2078
+ }
2079
+ const actualSha = currentFileSha(file.path);
2080
+ if (actualSha !== file.sha256) {
2081
+ reasons.push({ type: 'changed', path: file.path, actualSha, expectedSha: file.sha256 });
1284
2082
  }
1285
2083
  }
1286
2084
 
1287
- if (allItems.length === 0) {
1288
- info(`@${username} has no public assets.`);
1289
- return;
2085
+ for (const file of recordedFiles) {
2086
+ if (file.path && !desired.has(file.path)) {
2087
+ reasons.push({ type: 'obsolete-manifest-path', path: file.path });
2088
+ }
1290
2089
  }
1291
2090
 
1292
- log(` Found ${C.bold}${allItems.length}${C.reset} assets\n`);
2091
+ return {
2092
+ needsUpdate: reasons.length > 0,
2093
+ reasons,
2094
+ };
2095
+ }
1293
2096
 
1294
- // Step 3: Create directory and pull each asset
1295
- const outDir = path.join(process.cwd(), username);
1296
- if (!fs.existsSync(outDir)) {
1297
- fs.mkdirSync(outDir, { recursive: true });
2097
+ async function fetchWorkflowForInstall(uuid, config, apiBase) {
2098
+ const data = await apiRequest('GET', `/api/v1/tokenboard/workflows/detail?uuid=${encodeURIComponent(uuid)}`, null, config?.token, apiBase);
2099
+ const workflow = data.workflow;
2100
+ const contents = extractInstallableContents(workflow, getWorkflowAssetType(workflow));
2101
+ if (contents.length === 0) {
2102
+ throw new Error('No installable content found');
1298
2103
  }
2104
+ return { workflow, contents };
2105
+ }
2106
+
2107
+ async function cmdSyncInstalled() {
2108
+ const args = parseArgs(process.argv);
2109
+ const targetTool = validateInstallTarget(args.flags.target || 'codex');
2110
+ if (targetTool !== 'codex') {
2111
+ error(`sync-installed currently supports --target codex only`);
2112
+ }
2113
+
2114
+ const json = Boolean(args.flags.json);
2115
+ if (!json) log(`\n${C.bold}tokrepo sync-installed${C.reset}\n`);
2116
+
2117
+ const manifest = readCodexManifest();
2118
+ const installed = (manifest.installs || []).filter(item => (item.targetTool || item.target_tool) === 'codex');
2119
+ if (installed.length === 0) {
2120
+ if (json) outputJson({ targetTool: 'codex', count: 0, results: [] });
2121
+ else info(`No Codex installs found in ${CODEX_MANIFEST_FILE}`);
2122
+ return;
2123
+ }
2124
+
2125
+ const config = readConfig();
2126
+ const apiBase = config?.api || DEFAULT_API;
2127
+ const dryRun = Boolean(args.flags.dryRun || args.flags.dry_run);
2128
+ const stage = Boolean(args.flags.stage);
2129
+ const force = Boolean(args.flags.update || args.flags.force);
2130
+ const results = [];
1299
2131
 
1300
- let downloaded = 0;
1301
- for (const item of allItems) {
1302
- const title = item.title || item.uuid;
1303
- const safeDirName = title.replace(/[/\\?%*:|"<>]/g, '-').substring(0, 80);
1304
- const assetDir = path.join(outDir, safeDirName);
2132
+ for (let i = 0; i < installed.length; i++) {
2133
+ const record = installed[i];
2134
+ const uuid = record.uuid;
2135
+ if (!uuid) continue;
2136
+
2137
+ if (!json) log(`${C.dim}[${i + 1}/${installed.length}]${C.reset} ${record.title || uuid}`);
1305
2138
 
1306
2139
  try {
1307
- const detail = await apiRequest('GET', `/api/v1/tokenboard/workflows/detail?uuid=${item.uuid}`, null, config?.token, apiBase);
1308
- const workflow = detail.workflow;
1309
-
1310
- if (workflow.steps && workflow.steps.length > 0) {
1311
- if (!fs.existsSync(assetDir)) fs.mkdirSync(assetDir, { recursive: true });
1312
- for (const step of workflow.steps) {
1313
- const content = step.prompt_template || step.promptTemplate;
1314
- if (content) {
1315
- const fileName = `${step.title || 'step-' + step.step_order}`;
1316
- const safeName = fileName.replace(/[/\\?%*:|"<>]/g, '-');
1317
- fs.writeFileSync(path.join(assetDir, safeName), content);
1318
- }
2140
+ const { workflow, contents } = await fetchWorkflowForInstall(uuid, config, apiBase);
2141
+ const plan = buildCodexInstallPlan(workflow, contents, { installMode: record.installMode || record.install_mode });
2142
+ const diff = diffCodexPlanWithLocal(plan, record);
2143
+ const shouldWrite = force || diff.needsUpdate;
2144
+
2145
+ if (dryRun) {
2146
+ results.push({
2147
+ uuid,
2148
+ title: workflow.title,
2149
+ status: shouldWrite ? 'would-update' : 'unchanged',
2150
+ needsUpdate: shouldWrite,
2151
+ reasons: diff.reasons,
2152
+ plan: publicInstallPlan(plan),
2153
+ });
2154
+ if (!json) {
2155
+ const label = shouldWrite ? `${C.yellow}would update${C.reset}` : `${C.green}unchanged${C.reset}`;
2156
+ log(` ${label}`);
1319
2157
  }
1320
- downloaded++;
1321
- log(` ${C.green}✓${C.reset} ${safeDirName} ${C.dim}(${workflow.steps.length} files)${C.reset}`);
2158
+ continue;
2159
+ }
2160
+
2161
+ if (!shouldWrite) {
2162
+ results.push({
2163
+ uuid,
2164
+ title: workflow.title,
2165
+ status: 'unchanged',
2166
+ needsUpdate: false,
2167
+ reasons: [],
2168
+ });
2169
+ if (!json) success('Unchanged');
2170
+ continue;
1322
2171
  }
2172
+
2173
+ const installResult = await installCodexAsset(workflow, contents, {
2174
+ ...args.flags,
2175
+ dryRun: false,
2176
+ stage,
2177
+ installMode: record.installMode || record.install_mode,
2178
+ approveMcp: Boolean(args.flags.approveMcp || args.flags.approve_mcp),
2179
+ json: true,
2180
+ throwOnError: true,
2181
+ });
2182
+
2183
+ results.push({
2184
+ uuid,
2185
+ title: workflow.title,
2186
+ status: stage ? 'staged' : 'updated',
2187
+ needsUpdate: true,
2188
+ reasons: diff.reasons,
2189
+ stagePath: installResult.stagePath,
2190
+ installedFiles: installResult.installedFiles || [],
2191
+ plan: installResult.plan,
2192
+ });
2193
+ if (!json) success(stage ? `Staged ${installResult.plan.files.length} file(s)` : `Updated ${(installResult.installedFiles || []).length} file(s)`);
1323
2194
  } catch (e) {
1324
- log(` ${C.yellow}!${C.reset} ${safeDirName} ${C.dim}(skipped: ${e.message})${C.reset}`);
2195
+ results.push({ uuid, title: record.title || uuid, status: 'failed', error: e.message });
2196
+ if (!json) warn(`Failed: ${e.message}`);
1325
2197
  }
1326
2198
  }
1327
2199
 
1328
- log('');
1329
- success(`Cloned ${downloaded}/${allItems.length} assets to ./${username}/`);
2200
+ const summary = {
2201
+ targetTool: 'codex',
2202
+ manifestPath: CODEX_MANIFEST_FILE,
2203
+ dryRun,
2204
+ stage,
2205
+ count: results.length,
2206
+ updated: results.filter(item => item.status === 'updated').length,
2207
+ staged: results.filter(item => item.status === 'staged').length,
2208
+ unchanged: results.filter(item => item.status === 'unchanged').length,
2209
+ failed: results.filter(item => item.status === 'failed').length,
2210
+ results,
2211
+ };
2212
+
2213
+ if (json) {
2214
+ outputJson(summary);
2215
+ } else {
2216
+ log('');
2217
+ success(`Sync complete: ${summary.updated} updated, ${summary.staged} staged, ${summary.unchanged} unchanged, ${summary.failed} failed`);
2218
+ }
1330
2219
  }
1331
2220
 
1332
2221
  async function cmdTags() {
@@ -1447,9 +2336,11 @@ ${C.bold}USAGE${C.reset}
1447
2336
 
1448
2337
  ${C.bold}DISCOVER & INSTALL${C.reset}
1449
2338
  ${C.cyan}search${C.reset} <query> Search assets by keyword
2339
+ ${C.cyan}detail${C.reset} <name|uuid> Show full asset metadata
1450
2340
  ${C.cyan}install${C.reset} <name|uuid> Smart install (auto-detects type & placement)
1451
2341
  ${C.cyan}pull${C.reset} <url|uuid|@u/n> Download raw asset files
1452
2342
  ${C.cyan}clone${C.reset} @username Clone all assets from a user
2343
+ ${C.cyan}sync-installed${C.reset} Update installed Codex assets from manifest
1453
2344
 
1454
2345
  ${C.bold}PUBLISH${C.reset}
1455
2346
  ${C.cyan}push${C.reset} [files...] Push files/directory (idempotent upsert)
@@ -1459,7 +2350,7 @@ ${C.bold}PUBLISH${C.reset}
1459
2350
  ${C.cyan}delete${C.reset} <uuid> Delete an asset
1460
2351
 
1461
2352
  ${C.bold}ACCOUNT${C.reset}
1462
- ${C.cyan}login${C.reset} Save API token (or set TOKREPO_TOKEN env var)
2353
+ ${C.cyan}login${C.reset} Save API key (or set TOKREPO_TOKEN env var)
1463
2354
  ${C.cyan}list${C.reset} List your published assets
1464
2355
  ${C.cyan}tags${C.reset} List available tags
1465
2356
  ${C.cyan}whoami${C.reset} Show current user
@@ -1475,6 +2366,7 @@ ${C.bold}PUSH OPTIONS${C.reset}
1475
2366
  ${C.bold}INSTALL BEHAVIOR${C.reset}
1476
2367
  Skills → .claude/skills/ (if .claude/ exists)
1477
2368
  Gemini → .gemini/GEMINI.md (with --target gemini)
2369
+ Codex → ~/.codex/skills/ (with --target codex)
1478
2370
  Scripts → current dir (chmod +x)
1479
2371
  Configs → project root
1480
2372
  MCP → current dir (.json)
@@ -1482,8 +2374,13 @@ ${C.bold}INSTALL BEHAVIOR${C.reset}
1482
2374
 
1483
2375
  ${C.bold}EXAMPLES${C.reset}
1484
2376
  tokrepo search "mcp server" # Find MCP configs
2377
+ tokrepo search video --json # Machine-readable search
2378
+ tokrepo detail ca000374-f5d8-... --json # Machine-readable detail
1485
2379
  tokrepo install ca000374-f5d8-... # Install by UUID
2380
+ tokrepo install ca000374-f5d8-... --target codex
1486
2381
  tokrepo install c4b18aeb --target gemini # Install for Gemini CLI
2382
+ tokrepo clone @henuwangkai --target codex --keyword video
2383
+ tokrepo sync-installed --target codex --dry-run
1487
2384
  tokrepo push --private my-rules.md # Save one file privately
1488
2385
  tokrepo push --public skill.md # Share one file publicly
1489
2386
  tokrepo push --private . # Push current dir as private
@@ -1505,10 +2402,133 @@ ${C.bold}GET YOUR TOKEN${C.reset}
1505
2402
  `);
1506
2403
  }
1507
2404
 
2405
+ function showSearchHelp() {
2406
+ log(`
2407
+ ${C.bold}tokrepo search${C.reset}
2408
+
2409
+ USAGE
2410
+ tokrepo search <query> [--json] [--all] [--page-size N] [--sort-by views|latest|stars|popular]
2411
+
2412
+ EXAMPLES
2413
+ tokrepo search video
2414
+ tokrepo search video --json
2415
+ tokrepo search "mcp server" --json --all
2416
+ `);
2417
+ }
2418
+
2419
+ function showDetailHelp() {
2420
+ log(`
2421
+ ${C.bold}tokrepo detail${C.reset}
2422
+
2423
+ USAGE
2424
+ tokrepo detail <uuid|url|name> [--json]
2425
+
2426
+ EXAMPLES
2427
+ tokrepo detail 91aeb22d-eff0-4310-abc6-811d2394b420 --json
2428
+ tokrepo detail https://tokrepo.com/en/workflows/91aeb22d-eff0-4310-abc6-811d2394b420
2429
+ `);
2430
+ }
2431
+
2432
+ function showInstallHelp() {
2433
+ log(`
2434
+ ${C.bold}tokrepo install${C.reset}
2435
+
2436
+ USAGE
2437
+ tokrepo install <uuid|url|name|pack/slug> [--target gemini|codex] [--yes] [--dry-run] [--stage] [--approve-mcp] [--json]
2438
+
2439
+ TARGETS
2440
+ codex Write Codex skills to ~/.codex/skills/<asset-slug>/SKILL.md
2441
+ gemini Write project instructions to .gemini/GEMINI.md
2442
+
2443
+ EXAMPLES
2444
+ tokrepo install awesome-cursor-rules
2445
+ tokrepo install ca000374-f5d8-4d75-a30c-460fda0b6b0e
2446
+ tokrepo install https://tokrepo.com/en/workflows/ca000374-...
2447
+ tokrepo install 91aeb22d-eff0-4310-abc6-811d2394b420 --target codex
2448
+ tokrepo install 91aeb22d-eff0-4310-abc6-811d2394b420 --target codex --dry-run --json
2449
+ tokrepo install 20bc3ffd-1d7a-41d1-86d0-b668e8500cee --target codex --stage
2450
+ tokrepo install c4b18aeb --target gemini
2451
+ `);
2452
+ }
2453
+
2454
+ function showListHelp() {
2455
+ log(`
2456
+ ${C.bold}tokrepo list${C.reset}
2457
+
2458
+ USAGE
2459
+ tokrepo list [--json] [--all] [--page-size N]
2460
+
2461
+ EXAMPLES
2462
+ tokrepo list
2463
+ tokrepo list --json --all
2464
+ `);
2465
+ }
2466
+
2467
+ function showCloneHelp() {
2468
+ log(`
2469
+ ${C.bold}tokrepo clone${C.reset}
2470
+
2471
+ USAGE
2472
+ tokrepo clone @username [--target codex] [--keyword query] [--types skill,prompt,knowledge] [--dry-run] [--stage] [--approve-mcp] [--json] [--manifest]
2473
+
2474
+ EXAMPLES
2475
+ tokrepo clone @henuwangkai --target codex --types skill,prompt,knowledge
2476
+ tokrepo clone @henuwangkai --target codex --keyword video
2477
+ tokrepo clone @me --target codex --dry-run --json --manifest
2478
+ `);
2479
+ }
2480
+
2481
+ function showSyncInstalledHelp() {
2482
+ log(`
2483
+ ${C.bold}tokrepo sync-installed${C.reset}
2484
+
2485
+ USAGE
2486
+ tokrepo sync-installed --target codex [--dry-run] [--stage] [--update] [--approve-mcp] [--json]
2487
+
2488
+ BEHAVIOR
2489
+ Reads ~/.codex/tokrepo/install-manifest.json, fetches each TokRepo asset again,
2490
+ rebuilds the Codex install plan, compares local files by sha256, then updates
2491
+ changed or missing files. Use --update to force reinstall unchanged assets.
2492
+
2493
+ EXAMPLES
2494
+ tokrepo sync-installed --target codex --dry-run --json
2495
+ tokrepo sync-installed --target codex --stage
2496
+ tokrepo sync-installed --target codex --update --approve-mcp
2497
+ `);
2498
+ }
2499
+
2500
+ function showCommandHelp(command) {
2501
+ switch (command) {
2502
+ case 'search':
2503
+ case 'find':
2504
+ showSearchHelp(); break;
2505
+ case 'detail':
2506
+ showDetailHelp(); break;
2507
+ case 'install':
2508
+ case 'i':
2509
+ showInstallHelp(); break;
2510
+ case 'list':
2511
+ showListHelp(); break;
2512
+ case 'clone':
2513
+ showCloneHelp(); break;
2514
+ case 'sync-installed':
2515
+ case 'sync':
2516
+ showSyncInstalledHelp(); break;
2517
+ default:
2518
+ showHelp(); break;
2519
+ }
2520
+ }
2521
+
1508
2522
  // ─── Main ───
1509
2523
 
1510
2524
  async function main() {
1511
2525
  const command = process.argv[2];
2526
+ const args = parseArgs(process.argv);
2527
+
2528
+ if (args.flags.help && command && !['help', '--help', '-h'].includes(command)) {
2529
+ showCommandHelp(command);
2530
+ return;
2531
+ }
1512
2532
 
1513
2533
  switch (command) {
1514
2534
  case 'login': await cmdLogin(); break;
@@ -1516,11 +2536,13 @@ async function main() {
1516
2536
  case 'push': await cmdPush(); break;
1517
2537
  case 'pull': await cmdPull(); break;
1518
2538
  case 'search': case 'find': await cmdSearch(); break;
2539
+ case 'detail': await cmdDetail(); break;
1519
2540
  case 'install': case 'i': await cmdInstall(); break;
1520
2541
  case 'list': await cmdList(); break;
1521
2542
  case 'update': await cmdUpdate(); break;
1522
2543
  case 'delete': await cmdDelete(); break;
1523
2544
  case 'clone': await cmdClone(); break;
2545
+ case 'sync-installed': case 'sync': await cmdSyncInstalled(); break;
1524
2546
  case 'tags': await cmdTags(); break;
1525
2547
  case 'status': case 'diff': await cmdStatus(); break;
1526
2548
  case 'whoami': await cmdWhoami(); break;
@@ -1533,7 +2555,9 @@ async function main() {
1533
2555
  }
1534
2556
 
1535
2557
  // Non-blocking update check after command completes
1536
- checkForUpdate();
2558
+ if (!wantsJson(process.argv) && !args.flags.help) {
2559
+ checkForUpdate();
2560
+ }
1537
2561
  }
1538
2562
 
1539
2563
  main().catch((e) => { error(e.message); });