tokrepo 3.3.3 → 3.5.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 +1399 -152
  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.3';
28
- const VERSION_CHECK_FILE = path.join(require('os').homedir(), '.tokrepo', '.version-check');
28
+ const CLI_VERSION = '3.5.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;
@@ -215,6 +236,12 @@ function guessTag(fileType) {
215
236
  return map[fileType] || null;
216
237
  }
217
238
 
239
+ function parseCsvList(value) {
240
+ if (!value) return [];
241
+ if (Array.isArray(value)) return value.flatMap(parseCsvList);
242
+ return String(value).split(',').map(s => s.trim()).filter(Boolean);
243
+ }
244
+
218
245
  // ─── Glob matching ───
219
246
 
220
247
  function matchGlob(pattern, filename) {
@@ -272,32 +299,61 @@ function parseArgs(argv) {
272
299
  args.command = argv[i];
273
300
  i++;
274
301
  }
302
+
303
+ const valueFlags = new Set([
304
+ 'title', 'desc', 'tag', 'target', 'targets', 'keyword', 'types',
305
+ 'kind', 'install-mode', 'install_mode', 'entrypoint', 'asset-kind', 'asset_kind',
306
+ 'version',
307
+ 'page', 'page-size', 'page_size', 'sort-by', 'sort_by',
308
+ 'time-window', 'time_window',
309
+ ]);
310
+
311
+ const assignFlag = (rawName, value = true) => {
312
+ const name = rawName.replace(/^--?/, '');
313
+ const normalized = name.replace(/-/g, '_');
314
+ if (normalized === 'tag') {
315
+ if (!args.flags.tags) args.flags.tags = [];
316
+ args.flags.tags.push(value);
317
+ return;
318
+ }
319
+ if (normalized === 'page_size') {
320
+ args.flags.pageSize = value;
321
+ } else if (normalized === 'sort_by') {
322
+ args.flags.sortBy = value;
323
+ } else if (normalized === 'time_window') {
324
+ args.flags.timeWindow = value;
325
+ } else if (normalized === 'dry_run') {
326
+ args.flags.dryRun = value;
327
+ } else if (normalized === 'approve_mcp') {
328
+ args.flags.approveMcp = value;
329
+ } else if (normalized === 'install_mode') {
330
+ args.flags.installMode = value;
331
+ } else if (normalized === 'asset_kind') {
332
+ args.flags.assetKind = value;
333
+ }
334
+ args.flags[normalized] = value;
335
+ };
336
+
275
337
  while (i < argv.length) {
276
338
  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('=');
339
+ if (arg === '-h' || arg === '--help') {
340
+ args.flags.help = true;
299
341
  } else if (arg === '-y' || arg === '--yes') {
300
342
  args.flags.yes = true;
343
+ } else if (arg.startsWith('--')) {
344
+ const eqIndex = arg.indexOf('=');
345
+ if (eqIndex !== -1) {
346
+ const name = arg.slice(2, eqIndex);
347
+ const value = arg.slice(eqIndex + 1);
348
+ assignFlag(name, value);
349
+ } else {
350
+ const name = arg.slice(2);
351
+ if (valueFlags.has(name) && i + 1 < argv.length && !argv[i + 1].startsWith('-')) {
352
+ assignFlag(name, argv[++i]);
353
+ } else {
354
+ assignFlag(name, true);
355
+ }
356
+ }
301
357
  } else if (!arg.startsWith('-')) {
302
358
  args.positional.push(arg);
303
359
  }
@@ -388,10 +444,10 @@ async function cmdLogin() {
388
444
 
389
445
  if (useToken) {
390
446
  // Manual token flow
391
- info('Paste your API token (from https://tokrepo.com/en/my/settings)');
447
+ info('Paste your API key (from https://tokrepo.com/en/my/settings)');
392
448
  log('');
393
- const token = await ask('API Token:');
394
- if (!token) error('Token is required');
449
+ const token = await ask('API Key:');
450
+ if (!token) error('API key is required');
395
451
  return await saveAndVerifyToken(token);
396
452
  }
397
453
 
@@ -530,6 +586,10 @@ async function cmdPush() {
530
586
  description = args.flags.desc || description || '';
531
587
  visibility = args.flags.public ? 1 : (args.flags.private ? 0 : (projectConfig?.visibility ?? 0));
532
588
  tags = args.flags.tags || tags || [];
589
+ const kind = args.flags.kind || args.flags.assetKind || projectConfig?.kind || projectConfig?.asset_kind || '';
590
+ const targetTools = parseCsvList(args.flags.targets || args.flags.target || projectConfig?.target_tools || projectConfig?.targetTools);
591
+ const installMode = args.flags.installMode || projectConfig?.install_mode || projectConfig?.installMode || '';
592
+ const entrypoint = args.flags.entrypoint || projectConfig?.entrypoint || '';
533
593
 
534
594
  // Read files and detect types
535
595
  const pushFiles = [];
@@ -569,6 +629,15 @@ async function cmdPush() {
569
629
  if (detectedTags.size > 0) {
570
630
  log(` ${C.bold}Tags:${C.reset} ${Array.from(detectedTags).join(', ')}`);
571
631
  }
632
+ const metadataSummary = [
633
+ kind ? `kind=${kind}` : '',
634
+ targetTools.length ? `target_tools=${targetTools.join(',')}` : '',
635
+ installMode ? `install_mode=${installMode}` : '',
636
+ entrypoint ? `entrypoint=${entrypoint}` : '',
637
+ ].filter(Boolean);
638
+ if (metadataSummary.length > 0) {
639
+ log(` ${C.bold}Agent meta:${C.reset} ${metadataSummary.join(' · ')}`);
640
+ }
572
641
  log('');
573
642
 
574
643
  for (const f of pushFiles) {
@@ -590,6 +659,10 @@ async function cmdPush() {
590
659
  tags: Array.from(detectedTags),
591
660
  token_cost: String(Math.round(totalChars / 4)),
592
661
  visibility: visibility,
662
+ kind,
663
+ target_tools: targetTools,
664
+ install_mode: installMode,
665
+ entrypoint,
593
666
  }, config.token, config.api);
594
667
 
595
668
  log('');
@@ -684,7 +757,10 @@ function normalizeQuery(q) {
684
757
  // - Full URL: "https://tokrepo.com/en/workflows/ca000374-f5d8-..."
685
758
  // - @username/asset-name: search by author + keyword
686
759
  // - Plain name: search by keyword
687
- async function resolveAssetId(input, config, apiBase) {
760
+ async function resolveAssetId(input, config, apiBase, opts = {}) {
761
+ const emitInfo = (msg) => { if (!opts.quiet) info(msg); };
762
+ const emitWarn = (msg) => { if (!opts.quiet) warn(msg); };
763
+
688
764
  // Already a UUID
689
765
  if (/^[a-f0-9-]{36}$/.test(input)) return input;
690
766
 
@@ -697,7 +773,7 @@ async function resolveAssetId(input, config, apiBase) {
697
773
  if (atMatch) {
698
774
  const [, username, assetName] = atMatch;
699
775
  const normalizedName = normalizeQuery(assetName);
700
- info(`Searching for "${normalizedName}" by @${username}...`);
776
+ emitInfo(`Searching for "${normalizedName}" by @${username}...`);
701
777
  // Search by keyword, then filter by author nickname
702
778
  const encoded = encodeURIComponent(normalizedName);
703
779
  try {
@@ -710,7 +786,7 @@ async function resolveAssetId(input, config, apiBase) {
710
786
  if (match) return match.uuid;
711
787
  // Fallback: return first result
712
788
  if (items.length > 0) {
713
- warn(`No exact match for @${username}, using best match: "${items[0].title}"`);
789
+ emitWarn(`No exact match for @${username}, using best match: "${items[0].title}"`);
714
790
  return items[0].uuid;
715
791
  }
716
792
  } catch { /* fall through */ }
@@ -719,7 +795,7 @@ async function resolveAssetId(input, config, apiBase) {
719
795
 
720
796
  // Plain name: search by keyword (normalize separators)
721
797
  const normalizedInput = normalizeQuery(input);
722
- info(`Searching for "${normalizedInput}"...`);
798
+ emitInfo(`Searching for "${normalizedInput}"...`);
723
799
  const encoded = encodeURIComponent(normalizedInput);
724
800
  try {
725
801
  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 +808,43 @@ async function resolveAssetId(input, config, apiBase) {
732
808
  // ─── Search ───
733
809
 
734
810
  async function cmdSearch() {
735
- const rawQuery = process.argv.slice(3).join(' ');
736
- if (!rawQuery) error('Usage: tokrepo search <keyword>');
811
+ const args = parseArgs(process.argv);
812
+ const rawQuery = args.flags.keyword || args.positional.join(' ');
813
+ if (!rawQuery) {
814
+ showSearchHelp();
815
+ process.exit(1);
816
+ }
737
817
 
738
818
  const query = normalizeQuery(rawQuery);
739
819
  const displayQuery = query !== rawQuery ? `"${rawQuery}" → "${query}"` : `"${query}"`;
740
- log(`\n${C.bold}tokrepo search${C.reset} ${displayQuery}\n`);
820
+ if (!args.flags.json) log(`\n${C.bold}tokrepo search${C.reset} ${displayQuery}\n`);
741
821
 
742
822
  const config = readConfig();
743
823
  const apiBase = config?.api || DEFAULT_API;
744
824
 
745
825
  try {
746
826
  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);
827
+ const pageSize = Number(args.flags.pageSize || (args.flags.all ? 200 : 20)) || 20;
828
+ const sortBy = args.flags.sortBy || 'views';
829
+ let page = Number(args.flags.page || 1) || 1;
830
+ 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);
831
+
832
+ if (args.flags.all) {
833
+ const list = [...(data.list || [])];
834
+ while (list.length < (data.total || 0)) {
835
+ page++;
836
+ 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);
837
+ const items = next.list || [];
838
+ if (items.length === 0) break;
839
+ list.push(...items);
840
+ }
841
+ data = { ...data, list };
842
+ }
843
+
844
+ if (args.flags.json) {
845
+ outputJson({ query, total: data.total || 0, count: (data.list || []).length, list: data.list || [] });
846
+ return;
847
+ }
748
848
 
749
849
  if (!data.list || data.list.length === 0) {
750
850
  info('No assets found.');
@@ -782,6 +882,44 @@ async function cmdSearch() {
782
882
  }
783
883
  }
784
884
 
885
+ async function cmdDetail() {
886
+ const args = parseArgs(process.argv);
887
+ const target = args.positional[0];
888
+ if (!target) {
889
+ showDetailHelp();
890
+ process.exit(1);
891
+ }
892
+
893
+ const config = readConfig();
894
+ const apiBase = config?.api || DEFAULT_API;
895
+
896
+ try {
897
+ const uuid = await resolveAssetId(target, config, apiBase, { quiet: Boolean(args.flags.json) });
898
+ const data = await apiRequest('GET', `/api/v1/tokenboard/workflows/detail?uuid=${encodeURIComponent(uuid)}`, null, config?.token, apiBase);
899
+ if (args.flags.json) {
900
+ outputJson(data);
901
+ return;
902
+ }
903
+
904
+ const workflow = data.workflow;
905
+ log(`\n${C.bold}tokrepo detail${C.reset}\n`);
906
+ log(` ${C.bold}${workflow.title}${C.reset}`);
907
+ if (workflow.description) log(` ${C.dim}${workflow.description}${C.reset}`);
908
+ log(`\n ${C.bold}UUID:${C.reset} ${workflow.uuid}`);
909
+ log(` ${C.bold}URL:${C.reset} ${C.cyan}https://tokrepo.com/en/workflows/${workflow.uuid}${C.reset}`);
910
+ if (workflow.tags && workflow.tags.length) {
911
+ log(` ${C.bold}Tags:${C.reset} ${workflow.tags.map(t => t.name || t.slug).join(', ')}`);
912
+ }
913
+ const fileCount = (workflow.files || []).length;
914
+ const stepCount = (workflow.steps || []).length;
915
+ log(` ${C.bold}Files:${C.reset} ${fileCount}`);
916
+ log(` ${C.bold}Steps:${C.reset} ${stepCount}`);
917
+ log('');
918
+ } catch (e) {
919
+ error(`Detail failed: ${e.message}`);
920
+ }
921
+ }
922
+
785
923
  // ─── Install (smart pull with correct placement) ───
786
924
 
787
925
  function normalizeInstallTarget(target) {
@@ -790,6 +928,9 @@ function normalizeInstallTarget(target) {
790
928
  const aliases = {
791
929
  gemini: 'gemini',
792
930
  'gemini-cli': 'gemini',
931
+ codex: 'codex',
932
+ 'codex-cli': 'codex',
933
+ 'openai-codex': 'codex',
793
934
  };
794
935
  return aliases[normalized] || normalized;
795
936
  }
@@ -797,8 +938,8 @@ function normalizeInstallTarget(target) {
797
938
  function validateInstallTarget(target) {
798
939
  if (!target) return '';
799
940
  const normalized = normalizeInstallTarget(target);
800
- if (normalized !== 'gemini') {
801
- error(`Unsupported install target: ${target}. Supported targets: gemini`);
941
+ if (!SUPPORTED_INSTALL_TARGETS.includes(normalized)) {
942
+ error(`Unsupported install target: ${target}. Supported targets: ${SUPPORTED_INSTALL_TARGETS.join(', ')}`);
802
943
  }
803
944
  return normalized;
804
945
  }
@@ -838,27 +979,497 @@ function formatGeminiContent(workflow, contents) {
838
979
  return `${parts.join('\n\n').trim()}\n`;
839
980
  }
840
981
 
982
+ function getWorkflowAssetType(workflow) {
983
+ if (!workflow || !workflow.tags || workflow.tags.length === 0) return 'other';
984
+ return (workflow.tags[0].slug || workflow.tags[0].name || '').toLowerCase();
985
+ }
986
+
987
+ function extractInstallableContents(workflow, assetType) {
988
+ const contents = [];
989
+ const files = workflow.files || [];
990
+
991
+ if (files.length > 0) {
992
+ for (const f of files) {
993
+ if (f.content && !f.content.startsWith('PK')) {
994
+ contents.push({
995
+ name: f.name || 'SKILL.md',
996
+ content: f.content,
997
+ type: f.type || f.file_type || f.fileType || detectFileType(f.name || ''),
998
+ });
999
+ }
1000
+ }
1001
+ }
1002
+
1003
+ if (contents.length === 0 && workflow.steps) {
1004
+ for (const step of workflow.steps) {
1005
+ const content = step.prompt_template || step.promptTemplate;
1006
+ if (content && !content.startsWith('PK')) {
1007
+ const name = (step.title || `step-${step.step_order || contents.length + 1}`).replace(/[/\\?%*:|"<>]/g, '-');
1008
+ contents.push({ name, content, type: assetType || 'other' });
1009
+ }
1010
+ }
1011
+ }
1012
+
1013
+ return contents;
1014
+ }
1015
+
1016
+ function sha256(content) {
1017
+ return crypto.createHash('sha256').update(String(content || '')).digest('hex');
1018
+ }
1019
+
1020
+ function slugify(input, fallback = 'asset') {
1021
+ const raw = String(input || '')
1022
+ .normalize('NFKD')
1023
+ .replace(/[^\x00-\x7F]/g, '')
1024
+ .toLowerCase()
1025
+ .replace(/['"]/g, '')
1026
+ .replace(/[^a-z0-9]+/g, '-')
1027
+ .replace(/^-+|-+$/g, '')
1028
+ .replace(/-{2,}/g, '-');
1029
+ return raw || fallback;
1030
+ }
1031
+
1032
+ function sanitizePathSegment(input, fallback = 'file') {
1033
+ const cleaned = String(input || '')
1034
+ .replace(/[/\\?%*:|"<>]/g, '-')
1035
+ .replace(/^\.+$/, '')
1036
+ .replace(/^\.+/, '')
1037
+ .trim();
1038
+ return cleaned || fallback;
1039
+ }
1040
+
1041
+ function sanitizeRelativePath(input, fallback = 'file.md') {
1042
+ const normalized = String(input || fallback).replace(/\\/g, '/');
1043
+ const parts = normalized
1044
+ .split('/')
1045
+ .filter(Boolean)
1046
+ .map((part, index) => sanitizePathSegment(part, index === 0 ? fallback : 'file'));
1047
+ let rel = parts.join('/');
1048
+ if (!rel) rel = fallback;
1049
+ if (!path.extname(rel)) rel += '.md';
1050
+ return rel;
1051
+ }
1052
+
1053
+ function ensureInside(baseDir, destPath) {
1054
+ const resolvedBase = path.resolve(baseDir);
1055
+ const resolvedDest = path.resolve(destPath);
1056
+ return resolvedDest === resolvedBase || resolvedDest.startsWith(resolvedBase + path.sep);
1057
+ }
1058
+
1059
+ function getFrontmatter(content) {
1060
+ const text = String(content || '').replace(/^\uFEFF/, '');
1061
+ const match = text.match(/^---\s*\n([\s\S]*?)\n---\s*\n?/);
1062
+ if (!match) return null;
1063
+ return { raw: match[0], body: match[1], rest: text.slice(match[0].length) };
1064
+ }
1065
+
1066
+ function getFrontmatterValue(content, key) {
1067
+ const fm = getFrontmatter(content);
1068
+ if (!fm) return '';
1069
+ const re = new RegExp(`^${key}\\s*:\\s*(.+)$`, 'im');
1070
+ const match = fm.body.match(re);
1071
+ if (!match) return '';
1072
+ return match[1].trim().replace(/^['"]|['"]$/g, '');
1073
+ }
1074
+
1075
+ function isCodexSkillDocument(item) {
1076
+ const content = item?.content || '';
1077
+ const name = getFrontmatterValue(content, 'name');
1078
+ const description = getFrontmatterValue(content, 'description');
1079
+ return Boolean(name && description) || /^skill\.md$/i.test(path.basename(item?.name || ''));
1080
+ }
1081
+
1082
+ function yamlQuoted(value) {
1083
+ return JSON.stringify(String(value || '').replace(/\s+/g, ' ').trim());
1084
+ }
1085
+
1086
+ function ensureCodexSkillFrontmatter(content, name, description) {
1087
+ const text = String(content || '').replace(/^\uFEFF/, '').trim();
1088
+ const fm = getFrontmatter(text);
1089
+ if (!fm) {
1090
+ return `---\nname: ${name}\ndescription: ${yamlQuoted(description)}\n---\n\n${text}\n`;
1091
+ }
1092
+
1093
+ const lines = fm.body.split('\n');
1094
+ const hasName = lines.some(line => /^name\s*:/i.test(line));
1095
+ const hasDescription = lines.some(line => /^description\s*:/i.test(line));
1096
+ const next = [];
1097
+ if (!hasName) next.push(`name: ${name}`);
1098
+ if (!hasDescription) next.push(`description: ${yamlQuoted(description)}`);
1099
+ next.push(...lines);
1100
+ return `---\n${next.join('\n')}\n---\n${fm.rest.trim() ? `\n${fm.rest.trim()}\n` : '\n'}`;
1101
+ }
1102
+
1103
+ function getCodexDescription(workflow, item) {
1104
+ const fromItem = getFrontmatterValue(item?.content || '', 'description');
1105
+ if (fromItem) return fromItem;
1106
+ const title = workflow?.title || item?.name || 'TokRepo asset';
1107
+ const desc = workflow?.description || '';
1108
+ return desc ? desc.substring(0, 240) : `Use ${title} from TokRepo.`;
1109
+ }
1110
+
1111
+ function codexSkillDirName(workflow, item, suffix = '') {
1112
+ const uuid8 = (workflow?.uuid || '').substring(0, 8) || 'asset';
1113
+ const fmName = getFrontmatterValue(item?.content || '', 'name');
1114
+ const base = fmName || item?.name || workflow?.slug || workflow?.title || uuid8;
1115
+ const baseSlug = slugify(base, uuid8).replace(/-md$/, '');
1116
+ const withSuffix = suffix ? `${baseSlug}-${slugify(suffix, 'part')}` : baseSlug;
1117
+ if (withSuffix.startsWith('tokrepo-') && withSuffix.endsWith(`-${uuid8}`)) return withSuffix;
1118
+ if (withSuffix.startsWith('tokrepo-')) return `${withSuffix}-${uuid8}`;
1119
+ if (withSuffix.endsWith(`-${uuid8}`)) return `tokrepo-${withSuffix}`;
1120
+ return `tokrepo-${withSuffix}-${uuid8}`;
1121
+ }
1122
+
1123
+ function explicitInstallMode(workflow) {
1124
+ const candidates = [
1125
+ workflow?.installMode,
1126
+ workflow?.install_mode,
1127
+ workflow?.agent_metadata?.install_mode,
1128
+ workflow?.agentMetadata?.installMode,
1129
+ workflow?.metadata?.installMode,
1130
+ workflow?.metadata?.install_mode,
1131
+ ].filter(Boolean);
1132
+ const mode = String(candidates[0] || '').toLowerCase();
1133
+ return ['single', 'bundle', 'split', 'stage_only'].includes(mode) ? mode : '';
1134
+ }
1135
+
1136
+ function inferCodexInstallMode(workflow, contents) {
1137
+ const explicit = explicitInstallMode(workflow);
1138
+ if (explicit) return explicit;
1139
+ if (contents.length <= 1) return 'single';
1140
+ const skillDocs = contents.filter(isCodexSkillDocument);
1141
+ if (skillDocs.length > 1 && skillDocs.length === contents.length) return 'split';
1142
+ return 'bundle';
1143
+ }
1144
+
1145
+ function analyzeInstallRisks(fileName, content, type) {
1146
+ const risks = new Set();
1147
+ const lowerName = String(fileName || '').toLowerCase();
1148
+ const text = String(content || '');
1149
+ if (type === 'script' || /\.(sh|py|js|mjs|ts|rb|go|rs|lua)$/.test(lowerName) || /^#!\//.test(text)) {
1150
+ risks.add('executable');
1151
+ }
1152
+ if (lowerName.endsWith('.mcp.json') || /"mcpServers"\s*:/.test(text) || /\bmcpServers\s*:/.test(text)) {
1153
+ risks.add('mcp');
1154
+ }
1155
+ if (/\b(PATH|HOME|TOKEN|API_KEY|SECRET|PASSWORD|OPENAI_API_KEY|ANTHROPIC_API_KEY)\b/.test(text)) {
1156
+ risks.add('env');
1157
+ }
1158
+ if (/(^|[\s"'=])\/(Users|opt|usr|var|etc|tmp)\//.test(text) || /[A-Za-z]:\\/.test(text)) {
1159
+ risks.add('absolute-path');
1160
+ }
1161
+ return Array.from(risks);
1162
+ }
1163
+
1164
+ function buildBundleEntrypoint(workflow, contents, skillName) {
1165
+ const title = workflow.title || 'TokRepo Asset';
1166
+ const sourceUrl = `https://tokrepo.com/en/workflows/${workflow.uuid}`;
1167
+ const fileList = contents
1168
+ .map((item, index) => `- ${sanitizeRelativePath(item.name || `file-${index + 1}.md`)}`)
1169
+ .join('\n');
1170
+ 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`;
1171
+ return ensureCodexSkillFrontmatter(body, skillName, getCodexDescription(workflow));
1172
+ }
1173
+
1174
+ function addPlanFile(plan, destPath, content, sourceName, type) {
1175
+ const riskFlags = analyzeInstallRisks(sourceName || destPath, content, type);
1176
+ plan.files.push({
1177
+ path: destPath,
1178
+ sourceName: sourceName || path.basename(destPath),
1179
+ sha256: sha256(content),
1180
+ bytes: Buffer.byteLength(String(content || '')),
1181
+ riskFlags,
1182
+ content,
1183
+ });
1184
+ for (const risk of riskFlags) {
1185
+ if (!plan.risks.includes(risk)) plan.risks.push(risk);
1186
+ }
1187
+ }
1188
+
1189
+ function buildCodexInstallPlan(workflow, contents, opts = {}) {
1190
+ const installMode = opts.installMode || inferCodexInstallMode(workflow, contents);
1191
+ const agentMetadata = workflow?.agent_metadata || workflow?.agentMetadata || {};
1192
+ const plan = {
1193
+ uuid: workflow.uuid,
1194
+ title: workflow.title,
1195
+ sourceUrl: `https://tokrepo.com/en/workflows/${workflow.uuid}`,
1196
+ targetTool: 'codex',
1197
+ installMode,
1198
+ manifestPath: CODEX_MANIFEST_FILE,
1199
+ files: [],
1200
+ risks: [],
1201
+ agentMetadata,
1202
+ contentHash: workflow.content_hash || workflow.contentHash || agentMetadata.content_hash || '',
1203
+ };
1204
+
1205
+ if (installMode === 'stage_only') {
1206
+ const stageDir = path.join(CODEX_TOKREPO_DIR, 'staged', workflow.uuid);
1207
+ plan.baseDir = stageDir;
1208
+ contents.forEach((item, index) => {
1209
+ const relName = sanitizeRelativePath(item.name || `file-${index + 1}.md`);
1210
+ addPlanFile(plan, path.join(stageDir, relName), `${String(item.content || '').trim()}\n`, item.name, item.type);
1211
+ });
1212
+ return plan;
1213
+ }
1214
+
1215
+ if (installMode === 'split') {
1216
+ const usedDirs = new Set();
1217
+ contents.forEach((item, index) => {
1218
+ const skillName = slugify(getFrontmatterValue(item.content, 'name') || item.name || `${workflow.title}-${index + 1}`, `${workflow.uuid.substring(0, 8)}-${index + 1}`);
1219
+ const baseDirName = codexSkillDirName(workflow, item, contents.length > 1 && !getFrontmatterValue(item.content, 'name') ? String(index + 1) : '');
1220
+ let dirName = baseDirName;
1221
+ let duplicateIndex = 2;
1222
+ while (usedDirs.has(dirName)) {
1223
+ dirName = `${baseDirName}-${duplicateIndex}`;
1224
+ duplicateIndex++;
1225
+ }
1226
+ usedDirs.add(dirName);
1227
+ const destDir = path.join(CODEX_SKILLS_DIR, dirName);
1228
+ const destPath = path.join(destDir, 'SKILL.md');
1229
+ const content = ensureCodexSkillFrontmatter(item.content, skillName, getCodexDescription(workflow, item));
1230
+ addPlanFile(plan, destPath, content, item.name, item.type);
1231
+ });
1232
+ return plan;
1233
+ }
1234
+
1235
+ const primaryItem = contents.find(item => /^skill\.md$/i.test(path.basename(item.name || ''))) || contents[0];
1236
+ const skillName = slugify(getFrontmatterValue(primaryItem?.content || '', 'name') || workflow.slug || workflow.title, workflow.uuid.substring(0, 8));
1237
+ const dirItem = getFrontmatterValue(primaryItem?.content || '', 'name') ? primaryItem : null;
1238
+ const destDir = path.join(CODEX_SKILLS_DIR, codexSkillDirName(workflow, dirItem));
1239
+ plan.baseDir = destDir;
1240
+
1241
+ if (installMode === 'single' || contents.length === 1) {
1242
+ const item = primaryItem;
1243
+ const content = ensureCodexSkillFrontmatter(item.content, skillName, getCodexDescription(workflow, item));
1244
+ addPlanFile(plan, path.join(destDir, 'SKILL.md'), content, item.name, item.type);
1245
+ return plan;
1246
+ }
1247
+
1248
+ let hasEntrypoint = false;
1249
+ const usedRelNames = new Set();
1250
+ for (let i = 0; i < contents.length; i++) {
1251
+ const item = contents[i];
1252
+ const relName = sanitizeRelativePath(item.name || `file-${i + 1}.md`);
1253
+ let destName = /^skill\.md$/i.test(path.basename(relName)) ? 'SKILL.md' : relName;
1254
+ if (usedRelNames.has(destName)) {
1255
+ const ext = path.extname(destName);
1256
+ const base = destName.slice(0, destName.length - ext.length);
1257
+ let duplicateIndex = 2;
1258
+ let candidate = `${base}-${duplicateIndex}${ext}`;
1259
+ while (usedRelNames.has(candidate)) {
1260
+ duplicateIndex++;
1261
+ candidate = `${base}-${duplicateIndex}${ext}`;
1262
+ }
1263
+ destName = candidate;
1264
+ }
1265
+ usedRelNames.add(destName);
1266
+ const destPath = path.join(destDir, destName);
1267
+ const content = destName === 'SKILL.md'
1268
+ ? ensureCodexSkillFrontmatter(item.content, skillName, getCodexDescription(workflow, item))
1269
+ : `${String(item.content || '').trim()}\n`;
1270
+ if (destName === 'SKILL.md') hasEntrypoint = true;
1271
+ addPlanFile(plan, destPath, content, item.name, item.type);
1272
+ }
1273
+
1274
+ if (!hasEntrypoint) {
1275
+ addPlanFile(plan, path.join(destDir, 'SKILL.md'), buildBundleEntrypoint(workflow, contents, skillName), 'SKILL.md', 'skill');
1276
+ }
1277
+
1278
+ return plan;
1279
+ }
1280
+
1281
+ function publicInstallPlan(plan) {
1282
+ return {
1283
+ uuid: plan.uuid,
1284
+ title: plan.title,
1285
+ sourceUrl: plan.sourceUrl,
1286
+ targetTool: plan.targetTool,
1287
+ installMode: plan.installMode,
1288
+ manifestPath: plan.manifestPath,
1289
+ baseDir: plan.baseDir,
1290
+ risks: plan.risks,
1291
+ requiresConfirmation: hasCodexInstallRisks(plan),
1292
+ contentHash: plan.contentHash || '',
1293
+ agentMetadata: plan.agentMetadata || {},
1294
+ files: plan.files.map(file => ({
1295
+ path: file.path,
1296
+ sourceName: file.sourceName,
1297
+ sha256: file.sha256,
1298
+ bytes: file.bytes,
1299
+ riskFlags: file.riskFlags,
1300
+ exists: fs.existsSync(file.path),
1301
+ })),
1302
+ };
1303
+ }
1304
+
1305
+ function hasCodexInstallRisks(plan) {
1306
+ return (plan.risks || []).some(risk => ['mcp', 'executable', 'env', 'absolute-path'].includes(risk));
1307
+ }
1308
+
1309
+ function formatRiskLine(file) {
1310
+ if (!file.riskFlags || file.riskFlags.length === 0) return '';
1311
+ return `${file.sourceName || path.basename(file.path)}: ${file.riskFlags.join(', ')}`;
1312
+ }
1313
+
1314
+ async function confirmCodexInstallRisks(plan, opts = {}) {
1315
+ if (plan.installMode === 'stage_only') return;
1316
+ if (opts.dryRun || opts.stage || !hasCodexInstallRisks(plan)) return;
1317
+ if (opts.approveMcp || opts.approve_mcp || opts.yes) return;
1318
+
1319
+ if (opts.json || opts.throwOnError || process.env.TOKREPO_NONINTERACTIVE === '1') {
1320
+ 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.`);
1321
+ }
1322
+
1323
+ warn(`This asset contains ${plan.risks.join(', ')} content.`);
1324
+ 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}`);
1325
+ const riskyFiles = plan.files
1326
+ .map(formatRiskLine)
1327
+ .filter(Boolean)
1328
+ .slice(0, 8);
1329
+ for (const line of riskyFiles) {
1330
+ log(` ${C.yellow}!${C.reset} ${line}`);
1331
+ }
1332
+ if (plan.files.length > riskyFiles.length) {
1333
+ log(` ${C.dim}...and ${plan.files.length - riskyFiles.length} more file(s) in the plan${C.reset}`);
1334
+ }
1335
+
1336
+ const answer = await ask('Write this Codex skill bundle anyway? (y/N):');
1337
+ if (answer.toLowerCase() !== 'y') {
1338
+ throw new Error('Install aborted.');
1339
+ }
1340
+ }
1341
+
1342
+ function stageCodexInstallPlan(plan) {
1343
+ const stagedDir = path.join(CODEX_TOKREPO_DIR, 'staged');
1344
+ if (!fs.existsSync(stagedDir)) {
1345
+ fs.mkdirSync(stagedDir, { recursive: true, mode: 0o700 });
1346
+ }
1347
+ const stagePath = path.join(stagedDir, `${plan.uuid}.install-plan.json`);
1348
+ fs.writeFileSync(stagePath, `${JSON.stringify(publicInstallPlan(plan), null, 2)}\n`, { mode: 0o600 });
1349
+ return stagePath;
1350
+ }
1351
+
1352
+ function executeStageOnlyCodexPlan(plan) {
1353
+ const installedFiles = [];
1354
+ const stageRoot = path.join(CODEX_TOKREPO_DIR, 'staged', plan.uuid);
1355
+ if (!fs.existsSync(stageRoot)) fs.mkdirSync(stageRoot, { recursive: true, mode: 0o700 });
1356
+
1357
+ for (const file of plan.files) {
1358
+ if (!ensureInside(stageRoot, file.path)) {
1359
+ throw new Error(`Stage path escaped TokRepo staging directory: ${file.path}`);
1360
+ }
1361
+ const destDir = path.dirname(file.path);
1362
+ if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true, mode: 0o700 });
1363
+ fs.writeFileSync(file.path, file.content, { mode: 0o600 });
1364
+ installedFiles.push({
1365
+ path: file.path,
1366
+ sourceName: file.sourceName,
1367
+ sha256: sha256(file.content),
1368
+ bytes: Buffer.byteLength(String(file.content || '')),
1369
+ riskFlags: file.riskFlags,
1370
+ });
1371
+ }
1372
+
1373
+ const stagePath = path.join(stageRoot, 'install-plan.json');
1374
+ fs.writeFileSync(stagePath, `${JSON.stringify(publicInstallPlan(plan), null, 2)}\n`, { mode: 0o600 });
1375
+ return { dryRun: true, staged: true, stageOnly: true, stagePath, plan: publicInstallPlan(plan), installedFiles };
1376
+ }
1377
+
1378
+ function readCodexManifest() {
1379
+ try {
1380
+ const parsed = JSON.parse(fs.readFileSync(CODEX_MANIFEST_FILE, 'utf8'));
1381
+ if (Array.isArray(parsed.installs)) return parsed;
1382
+ } catch {}
1383
+ return { schemaVersion: 1, installs: [] };
1384
+ }
1385
+
1386
+ function writeCodexManifestRecord(plan, installedFiles) {
1387
+ if (!fs.existsSync(CODEX_TOKREPO_DIR)) {
1388
+ fs.mkdirSync(CODEX_TOKREPO_DIR, { recursive: true, mode: 0o700 });
1389
+ }
1390
+ const manifest = readCodexManifest();
1391
+ const installedAt = new Date().toISOString();
1392
+ const record = {
1393
+ uuid: plan.uuid,
1394
+ title: plan.title,
1395
+ sourceUrl: plan.sourceUrl,
1396
+ targetTool: 'codex',
1397
+ installMode: plan.installMode,
1398
+ installedAt,
1399
+ contentHash: plan.contentHash || '',
1400
+ agentMetadata: plan.agentMetadata || {},
1401
+ installedFiles: installedFiles.map(file => ({
1402
+ path: file.path,
1403
+ sourceName: file.sourceName,
1404
+ sha256: file.sha256,
1405
+ bytes: file.bytes,
1406
+ riskFlags: file.riskFlags,
1407
+ })),
1408
+ risks: plan.risks,
1409
+ };
1410
+ manifest.installs = manifest.installs.filter(item => !(item.uuid === plan.uuid && item.targetTool === 'codex'));
1411
+ manifest.installs.push(record);
1412
+ manifest.updatedAt = installedAt;
1413
+ fs.writeFileSync(CODEX_MANIFEST_FILE, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
1414
+ return record;
1415
+ }
1416
+
1417
+ function executeCodexInstallPlan(plan, opts = {}) {
1418
+ if (opts.dryRun) return { dryRun: true, plan: publicInstallPlan(plan), installedFiles: [] };
1419
+ if (plan.installMode === 'stage_only') return executeStageOnlyCodexPlan(plan);
1420
+ if (opts.stage) {
1421
+ const stagePath = stageCodexInstallPlan(plan);
1422
+ return { dryRun: true, staged: true, stagePath, plan: publicInstallPlan(plan), installedFiles: [] };
1423
+ }
1424
+
1425
+ const installedFiles = [];
1426
+ for (const file of plan.files) {
1427
+ const destDir = path.dirname(file.path);
1428
+ if (!ensureInside(CODEX_SKILLS_DIR, file.path)) {
1429
+ throw new Error(`Install path escaped Codex skills directory: ${file.path}`);
1430
+ }
1431
+ if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
1432
+ fs.writeFileSync(file.path, file.content);
1433
+ installedFiles.push({
1434
+ path: file.path,
1435
+ sourceName: file.sourceName,
1436
+ sha256: sha256(file.content),
1437
+ bytes: Buffer.byteLength(String(file.content || '')),
1438
+ riskFlags: file.riskFlags,
1439
+ });
1440
+ }
1441
+
1442
+ const manifestRecord = writeCodexManifestRecord(plan, installedFiles);
1443
+ return { dryRun: false, plan: publicInstallPlan(plan), installedFiles, manifestRecord };
1444
+ }
1445
+
1446
+ async function installCodexAsset(workflow, contents, opts = {}) {
1447
+ const plan = buildCodexInstallPlan(workflow, contents, opts);
1448
+ await confirmCodexInstallRisks(plan, opts);
1449
+ return executeCodexInstallPlan(plan, opts);
1450
+ }
1451
+
841
1452
  async function cmdInstall() {
842
1453
  const args = parseArgs(process.argv);
843
1454
  const target = args.positional[0];
844
1455
  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`);
1456
+ showInstallHelp();
1457
+ process.exit(1);
853
1458
  }
854
1459
 
855
- log(`\n${C.bold}tokrepo install${C.reset}\n`);
1460
+ if (!args.flags.json) log(`\n${C.bold}tokrepo install${C.reset}\n`);
856
1461
 
857
1462
  const config = readConfig();
858
1463
  const apiBase = config?.api || DEFAULT_API;
859
1464
  const installOpts = {
860
1465
  targetTool: validateInstallTarget(args.flags.target),
861
1466
  yes: Boolean(args.flags.yes),
1467
+ update: Boolean(args.flags.update),
1468
+ dryRun: Boolean(args.flags.dryRun || args.flags.dry_run),
1469
+ stage: Boolean(args.flags.stage),
1470
+ approveMcp: Boolean(args.flags.approveMcp || args.flags.approve_mcp),
1471
+ json: Boolean(args.flags.json),
1472
+ manifest: Boolean(args.flags.manifest),
862
1473
  };
863
1474
 
864
1475
  // pack/<slug> dispatch — install entire theme pack
@@ -871,7 +1482,10 @@ Examples:
871
1482
  return;
872
1483
  }
873
1484
 
874
- await installOneAsset(target, config, apiBase, installOpts);
1485
+ const result = await installOneAsset(target, config, apiBase, installOpts);
1486
+ if (args.flags.json) {
1487
+ outputJson(result);
1488
+ }
875
1489
  }
876
1490
 
877
1491
  // Install all assets in a theme pack — sequentially, continue past per-item errors
@@ -916,6 +1530,7 @@ async function installPack(slug, config, apiBase, opts) {
916
1530
  async function installOneAsset(target, config, apiBase, opts) {
917
1531
  opts = opts || {};
918
1532
  const die = (msg) => { if (opts.throwOnError) throw new Error(msg); error(msg); };
1533
+ const emitInfo = (msg) => { if (!opts.json) info(msg); };
919
1534
 
920
1535
  // Resolve target to UUID
921
1536
  let uuid = target;
@@ -946,7 +1561,7 @@ async function installOneAsset(target, config, apiBase, opts) {
946
1561
  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
1562
  // Search by name (normalize separators for better matching)
948
1563
  const normalizedTarget = normalizeQuery(uuid);
949
- info(`Searching for "${normalizedTarget}"...`);
1564
+ emitInfo(`Searching for "${normalizedTarget}"...`);
950
1565
  try {
951
1566
  const encoded = encodeURIComponent(normalizedTarget);
952
1567
  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 +1579,91 @@ async function installOneAsset(target, config, apiBase, opts) {
964
1579
  const chosen = exact || searchData.list[0];
965
1580
 
966
1581
  uuid = chosen.uuid;
967
- info(`Found: ${C.bold}${chosen.title}${C.reset}`);
1582
+ emitInfo(`Found: ${C.bold}${chosen.title}${C.reset}`);
968
1583
  } catch (e) {
969
1584
  die(`Search failed: ${e.message}`);
970
1585
  }
971
1586
  }
972
1587
 
973
1588
  // Fetch the asset
974
- info(`Fetching ${uuid.substring(0, 8)}...`);
1589
+ emitInfo(`Fetching ${uuid.substring(0, 8)}...`);
975
1590
 
976
- let workflow, files;
1591
+ let workflow;
977
1592
  try {
978
1593
  const data = await apiRequest('GET', `/api/v1/tokenboard/workflows/detail?uuid=${uuid}`, null, config?.token, apiBase);
979
1594
  workflow = data.workflow;
980
- files = data.workflow.files || [];
981
1595
  } catch (e) {
982
1596
  die(`Fetch failed: ${e.message}`);
983
1597
  }
984
1598
 
985
- log(`\n ${C.bold}${workflow.title}${C.reset}`);
986
- if (workflow.description) log(` ${C.dim}${workflow.description.substring(0, 100)}${C.reset}`);
1599
+ if (!opts.json) {
1600
+ log(`\n ${C.bold}${workflow.title}${C.reset}`);
1601
+ if (workflow.description) log(` ${C.dim}${workflow.description.substring(0, 100)}${C.reset}`);
1602
+ }
987
1603
 
988
1604
  // 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
- }
1605
+ let assetType = getWorkflowAssetType(workflow);
993
1606
 
994
1607
  // Get content — prefer files, fallback to steps
995
- const contents = [];
1608
+ const contents = extractInstallableContents(workflow, assetType);
996
1609
 
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
- }
1610
+ if (contents.length === 0) {
1611
+ die('No installable content found in this asset.');
1003
1612
  }
1004
1613
 
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 });
1614
+ if (!opts.json) log('');
1615
+ const targetTool = normalizeInstallTarget(opts.targetTool);
1616
+
1617
+ if (targetTool === 'codex') {
1618
+ let result;
1619
+ try {
1620
+ result = await installCodexAsset(workflow, contents, opts);
1621
+ } catch (e) {
1622
+ die(e.message);
1623
+ }
1624
+
1625
+ if (!opts.json) {
1626
+ const plan = result.plan;
1627
+ if (result.staged || opts.stage) {
1628
+ info(`Staged install plan: ${result.stagePath}`);
1629
+ if (result.stageOnly) {
1630
+ info(`stage_only asset: files were written only under ${path.dirname(result.stagePath)}; no Codex skill was activated.`);
1631
+ } else {
1632
+ info(`No Codex skill files were written. Re-run with --approve-mcp or --yes to install.`);
1633
+ }
1634
+ } else if (opts.dryRun) {
1635
+ info(`Dry run: ${plan.files.length} file(s) would be installed to ${CODEX_SKILLS_DIR}`);
1636
+ for (const file of plan.files) {
1637
+ const rel = path.relative(os.homedir(), file.path);
1638
+ log(` ${C.dim}•${C.reset} ~/${rel}`);
1639
+ if (file.riskFlags.length) log(` ${C.yellow}${file.riskFlags.join(', ')}${C.reset}`);
1640
+ }
1641
+ } else {
1642
+ for (const file of result.installedFiles) {
1643
+ const relPath = path.relative(os.homedir(), file.path);
1644
+ success(`Installed: ~/${relPath}`);
1645
+ }
1646
+ log('');
1647
+ success(`${result.installedFiles.length} file(s) installed from ${C.bold}${workflow.title}${C.reset}`);
1648
+ log(` ${C.dim}Manifest: ${CODEX_MANIFEST_FILE}${C.reset}`);
1649
+ log(` ${C.dim}Source: https://tokrepo.com/en/workflows/${uuid}${C.reset}\n`);
1011
1650
  }
1012
1651
  }
1013
- }
1014
1652
 
1015
- if (contents.length === 0) {
1016
- die('No installable content found in this asset.');
1653
+ return {
1654
+ uuid,
1655
+ title: workflow.title,
1656
+ targetTool: 'codex',
1657
+ dryRun: Boolean(opts.dryRun || opts.stage),
1658
+ staged: Boolean(result.staged),
1659
+ stagePath: result.stagePath,
1660
+ installMode: result.plan.installMode,
1661
+ installedFiles: result.installedFiles || [],
1662
+ plan: result.plan,
1663
+ manifestPath: CODEX_MANIFEST_FILE,
1664
+ };
1017
1665
  }
1018
1666
 
1019
- log('');
1020
- const targetTool = normalizeInstallTarget(opts.targetTool);
1021
-
1022
1667
  if (targetTool === 'gemini') {
1023
1668
  const destDir = path.join(process.cwd(), '.gemini');
1024
1669
  if (!fs.existsSync(destDir)) {
@@ -1039,7 +1684,13 @@ async function installOneAsset(target, config, apiBase, opts) {
1039
1684
  log('');
1040
1685
  success(`1 file installed from ${C.bold}${workflow.title}${C.reset}`);
1041
1686
  log(` ${C.dim}Source: https://tokrepo.com/en/workflows/${uuid}${C.reset}\n`);
1042
- return;
1687
+ return {
1688
+ uuid,
1689
+ title: workflow.title,
1690
+ targetTool: 'gemini',
1691
+ installedFiles: [{ path: destPath }],
1692
+ sourceUrl: `https://tokrepo.com/en/workflows/${uuid}`,
1693
+ };
1043
1694
  }
1044
1695
 
1045
1696
  // Smart install based on asset type
@@ -1122,6 +1773,13 @@ async function installOneAsset(target, config, apiBase, opts) {
1122
1773
  log('');
1123
1774
  success(`${installed} file(s) installed from ${C.bold}${workflow.title}${C.reset}`);
1124
1775
  log(` ${C.dim}Source: https://tokrepo.com/en/workflows/${uuid}${C.reset}\n`);
1776
+ return {
1777
+ uuid,
1778
+ title: workflow.title,
1779
+ targetTool: targetTool || 'project',
1780
+ installed,
1781
+ sourceUrl: `https://tokrepo.com/en/workflows/${uuid}`,
1782
+ };
1125
1783
  }
1126
1784
 
1127
1785
  async function cmdWhoami() {
@@ -1141,13 +1799,33 @@ async function cmdWhoami() {
1141
1799
  }
1142
1800
 
1143
1801
  async function cmdList() {
1144
- log(`\n${C.bold}tokrepo list${C.reset}\n`);
1802
+ const args = parseArgs(process.argv);
1803
+ if (!args.flags.json) log(`\n${C.bold}tokrepo list${C.reset}\n`);
1145
1804
 
1146
1805
  const config = readConfig();
1147
1806
  if (!config || !config.token) error('Not logged in. Run: tokrepo login');
1148
1807
 
1149
1808
  try {
1150
- const data = await apiRequest('GET', '/api/v1/tokenboard/workflows/my?page=1&page_size=50', null, config.token, config.api);
1809
+ const pageSize = Number(args.flags.pageSize || (args.flags.all ? 200 : 50)) || 50;
1810
+ let page = Number(args.flags.page || 1) || 1;
1811
+ let data = await apiRequest('GET', `/api/v1/tokenboard/workflows/my?page=${page}&page_size=${pageSize}`, null, config.token, config.api);
1812
+
1813
+ if (args.flags.all) {
1814
+ const list = [...(data.list || [])];
1815
+ while (list.length < (data.total || 0)) {
1816
+ page++;
1817
+ const next = await apiRequest('GET', `/api/v1/tokenboard/workflows/my?page=${page}&page_size=${pageSize}`, null, config.token, config.api);
1818
+ const items = next.list || [];
1819
+ if (items.length === 0) break;
1820
+ list.push(...items);
1821
+ }
1822
+ data = { ...data, list };
1823
+ }
1824
+
1825
+ if (args.flags.json) {
1826
+ outputJson({ total: data.total || 0, count: (data.list || []).length, list: data.list || [] });
1827
+ return;
1828
+ }
1151
1829
 
1152
1830
  if (!data.list || data.list.length === 0) {
1153
1831
  info('No assets found. Run: tokrepo push');
@@ -1167,6 +1845,12 @@ async function cmdList() {
1167
1845
  }
1168
1846
 
1169
1847
  async function cmdUpdate() {
1848
+ const args = parseArgs(process.argv);
1849
+ if (args.flags.target || args.flags.all || args.flags.force) {
1850
+ await cmdSyncInstalled();
1851
+ return;
1852
+ }
1853
+
1170
1854
  const uuid = process.argv[3];
1171
1855
  if (!uuid) error('Usage: tokrepo update <uuid> [file]');
1172
1856
 
@@ -1232,107 +1916,515 @@ async function cmdDelete() {
1232
1916
  }
1233
1917
  }
1234
1918
 
1919
+ function tagMatchesTypes(workflow, requestedTypes) {
1920
+ if (!requestedTypes || requestedTypes.length === 0) return true;
1921
+ const tags = (workflow.tags || []).flatMap(t => [t.slug, t.name]).filter(Boolean).map(t => String(t).toLowerCase());
1922
+ const assetType = getWorkflowAssetType(workflow);
1923
+ const metadataKind = String(workflow.asset_kind || workflow.agent_metadata?.asset_kind || workflow.agentMetadata?.assetKind || '').toLowerCase();
1924
+ return requestedTypes.some(type => {
1925
+ const needle = String(type).trim().toLowerCase();
1926
+ if (!needle) return false;
1927
+ if (metadataKind === needle || metadataKind === `${needle}s`) return true;
1928
+ if (assetType === needle || assetType === `${needle}s`) return true;
1929
+ return tags.some(tag => tag === needle || tag === `${needle}s` || tag.includes(needle));
1930
+ });
1931
+ }
1932
+
1933
+ function itemMatchesKeyword(workflow, keyword) {
1934
+ if (!keyword) return true;
1935
+ const needle = normalizeQuery(keyword).toLowerCase();
1936
+ const fields = [
1937
+ workflow.title,
1938
+ workflow.slug,
1939
+ workflow.description,
1940
+ ...(workflow.tags || []).flatMap(t => [t.name, t.slug]),
1941
+ ].filter(Boolean).join(' ').toLowerCase();
1942
+ return needle.split(/\s+/).every(word => fields.includes(word));
1943
+ }
1944
+
1945
+ async function fetchCloneItems(username, config, apiBase, args) {
1946
+ const pageSize = Number(args.flags.pageSize || 200) || 200;
1947
+ const keyword = args.flags.keyword || '';
1948
+ const requestedTypes = String(args.flags.types || '')
1949
+ .split(',')
1950
+ .map(s => s.trim())
1951
+ .filter(Boolean);
1952
+
1953
+ let effectiveUsername = username.startsWith('@') ? username.slice(1) : username;
1954
+ const result = { username: effectiveUsername, source: 'public', list: [], total: 0 };
1955
+
1956
+ let cloneSelf = effectiveUsername === 'me';
1957
+ if (config?.token) {
1958
+ try {
1959
+ const me = await apiRequest('GET', '/api/v1/tokenboard/auth/me', null, config.token, apiBase);
1960
+ if (effectiveUsername === 'me' || me.nickname?.toLowerCase() === effectiveUsername.toLowerCase()) {
1961
+ cloneSelf = true;
1962
+ effectiveUsername = me.nickname || effectiveUsername;
1963
+ result.username = effectiveUsername;
1964
+ }
1965
+ } catch { /* anonymous/public clone still works */ }
1966
+ }
1967
+
1968
+ if (cloneSelf) {
1969
+ if (!config?.token) error('Cloning @me requires login or TOKREPO_TOKEN.');
1970
+ let page = 1;
1971
+ while (true) {
1972
+ const data = await apiRequest('GET', `/api/v1/tokenboard/workflows/my?page=${page}&page_size=${pageSize}`, null, config.token, apiBase);
1973
+ const items = data.list || [];
1974
+ result.total = data.total || result.total;
1975
+ result.list.push(...items);
1976
+ if (items.length < pageSize || result.list.length >= result.total) break;
1977
+ page++;
1978
+ }
1979
+ result.source = 'my';
1980
+ } else {
1981
+ let page = 1;
1982
+ while (true) {
1983
+ const params = [
1984
+ `author_name=${encodeURIComponent(effectiveUsername)}`,
1985
+ `page=${page}`,
1986
+ `page_size=${pageSize}`,
1987
+ 'sort_by=latest',
1988
+ ];
1989
+ if (keyword) params.push(`keyword=${encodeURIComponent(normalizeQuery(keyword))}`);
1990
+ const data = await apiRequest('GET', `/api/v1/tokenboard/workflows/list?${params.join('&')}`, null, config?.token, apiBase);
1991
+ const items = data.list || data.items || [];
1992
+ result.total = data.total || result.total;
1993
+ result.list.push(...items);
1994
+ if (items.length < pageSize || result.list.length >= result.total) break;
1995
+ page++;
1996
+ }
1997
+ }
1998
+
1999
+ result.list = result.list.filter(item => itemMatchesKeyword(item, keyword) && tagMatchesTypes(item, requestedTypes));
2000
+ result.count = result.list.length;
2001
+ result.keyword = keyword || undefined;
2002
+ result.types = requestedTypes;
2003
+ return result;
2004
+ }
2005
+
1235
2006
  async function cmdClone() {
1236
- const target = process.argv[3];
1237
- if (!target) error('Usage: tokrepo clone @username');
2007
+ const args = parseArgs(process.argv);
2008
+ const target = args.positional[0];
2009
+ if (!target) {
2010
+ showCloneHelp();
2011
+ process.exit(1);
2012
+ }
1238
2013
 
1239
- log(`\n${C.bold}tokrepo clone${C.reset}\n`);
2014
+ const json = Boolean(args.flags.json);
2015
+ if (!json) log(`\n${C.bold}tokrepo clone${C.reset}\n`);
1240
2016
 
1241
2017
  const config = readConfig();
1242
2018
  const apiBase = config?.api || DEFAULT_API;
2019
+ const targetTool = validateInstallTarget(args.flags.target);
2020
+ const dryRun = Boolean(args.flags.dryRun || args.flags.dry_run);
1243
2021
 
1244
- // Extract username from @username format
1245
- let username = target;
1246
- if (username.startsWith('@')) username = username.slice(1);
1247
-
1248
- // Step 1: Find user's UUID by searching for their workflows
1249
- info(`Finding user @${username}...`);
1250
- let authorUuid = '';
1251
2022
  try {
1252
- 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);
1253
- const items = searchData.list || searchData.items || [];
1254
- for (const item of items) {
1255
- const authorName = (item.author?.nickname || item.nickname || '').toLowerCase();
1256
- if (authorName === username.toLowerCase()) {
1257
- authorUuid = item.author?.uuid || item.author_uuid || '';
1258
- break;
2023
+ if (!json) info(`Fetching assets from ${target}...`);
2024
+ const cloneItems = await fetchCloneItems(target, config, apiBase, args);
2025
+
2026
+ if (cloneItems.list.length === 0) {
2027
+ if (json) {
2028
+ outputJson({ target, count: 0, list: [] });
2029
+ } else {
2030
+ info(`${target} has no matching assets.`);
1259
2031
  }
2032
+ return;
1260
2033
  }
1261
- } catch { /* fall through */ }
1262
2034
 
1263
- if (!authorUuid) {
1264
- // Try fetching user's own workflows if logged in and cloning self
1265
- if (config?.token) {
2035
+ if (!json) log(` Found ${C.bold}${cloneItems.list.length}${C.reset} matching asset(s)\n`);
2036
+
2037
+ if (targetTool === 'codex') {
2038
+ const results = [];
2039
+ let installedCount = 0;
2040
+ for (let i = 0; i < cloneItems.list.length; i++) {
2041
+ const item = cloneItems.list[i];
2042
+ if (!json) log(`${C.dim}[${i + 1}/${cloneItems.list.length}]${C.reset} ${item.title}`);
2043
+ try {
2044
+ const detail = await apiRequest('GET', `/api/v1/tokenboard/workflows/detail?uuid=${item.uuid}`, null, config?.token, apiBase);
2045
+ const workflow = detail.workflow;
2046
+ const assetType = getWorkflowAssetType(workflow);
2047
+ const contents = extractInstallableContents(workflow, assetType);
2048
+ if (contents.length === 0) throw new Error('No installable content found');
2049
+ const result = await installCodexAsset(workflow, contents, {
2050
+ ...args.flags,
2051
+ dryRun,
2052
+ stage: Boolean(args.flags.stage),
2053
+ approveMcp: Boolean(args.flags.approveMcp || args.flags.approve_mcp),
2054
+ json: true,
2055
+ throwOnError: true,
2056
+ });
2057
+ if (!dryRun) installedCount += result.installedFiles.length;
2058
+ results.push({
2059
+ uuid: workflow.uuid,
2060
+ title: workflow.title,
2061
+ dryRun: Boolean(dryRun || args.flags.stage),
2062
+ staged: Boolean(result.staged),
2063
+ stagePath: result.stagePath,
2064
+ installMode: result.plan.installMode,
2065
+ files: result.plan.files,
2066
+ installedFiles: result.installedFiles || [],
2067
+ risks: result.plan.risks,
2068
+ });
2069
+ if (!json) {
2070
+ const fileCount = (dryRun || args.flags.stage) ? result.plan.files.length : result.installedFiles.length;
2071
+ success(`${args.flags.stage ? 'Staged' : dryRun ? 'Planned' : 'Installed'} ${fileCount} file(s)`);
2072
+ }
2073
+ } catch (e) {
2074
+ results.push({ uuid: item.uuid, title: item.title, error: e.message });
2075
+ if (!json) warn(`Skipped "${item.title}": ${e.message}`);
2076
+ }
2077
+ }
2078
+
2079
+ const response = {
2080
+ target,
2081
+ username: cloneItems.username,
2082
+ targetTool: 'codex',
2083
+ dryRun,
2084
+ total: cloneItems.total,
2085
+ count: cloneItems.count,
2086
+ manifestPath: CODEX_MANIFEST_FILE,
2087
+ results,
2088
+ };
2089
+ if (json) {
2090
+ outputJson(response);
2091
+ } else {
2092
+ log('');
2093
+ if (args.flags.stage) {
2094
+ success(`Staged ${results.filter(r => !r.error).length}/${cloneItems.list.length} asset install plan(s)`);
2095
+ } else if (dryRun) {
2096
+ success(`Dry run complete: ${results.filter(r => !r.error).length}/${cloneItems.list.length} assets planned`);
2097
+ } else {
2098
+ success(`Installed ${installedCount} Codex file(s) from ${results.filter(r => !r.error).length}/${cloneItems.list.length} assets`);
2099
+ log(` ${C.dim}Manifest: ${CODEX_MANIFEST_FILE}${C.reset}\n`);
2100
+ }
2101
+ }
2102
+ return;
2103
+ }
2104
+
2105
+ if (targetTool && targetTool !== 'codex') {
2106
+ error(`clone --target ${targetTool} is not implemented yet. Supported clone target: codex`);
2107
+ }
2108
+
2109
+ if (json) {
2110
+ outputJson(cloneItems);
2111
+ return;
2112
+ }
2113
+
2114
+ // Legacy raw clone behavior for users who do not specify a target.
2115
+ const outDir = path.join(process.cwd(), cloneItems.username);
2116
+ if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
2117
+
2118
+ let downloaded = 0;
2119
+ for (const item of cloneItems.list) {
2120
+ const title = item.title || item.uuid;
2121
+ const safeDirName = title.replace(/[/\\?%*:|"<>]/g, '-').substring(0, 80);
2122
+ const assetDir = path.join(outDir, safeDirName);
2123
+
1266
2124
  try {
1267
- const me = await apiRequest('GET', '/api/v1/tokenboard/auth/me', null, config.token, apiBase);
1268
- if (me.nickname?.toLowerCase() === username.toLowerCase()) {
1269
- authorUuid = me.uuid;
2125
+ const detail = await apiRequest('GET', `/api/v1/tokenboard/workflows/detail?uuid=${item.uuid}`, null, config?.token, apiBase);
2126
+ const workflow = detail.workflow;
2127
+ const contents = extractInstallableContents(workflow, getWorkflowAssetType(workflow));
2128
+
2129
+ if (contents.length > 0) {
2130
+ if (!fs.existsSync(assetDir)) fs.mkdirSync(assetDir, { recursive: true });
2131
+ for (const contentItem of contents) {
2132
+ const safeName = sanitizeRelativePath(contentItem.name || 'content.md');
2133
+ fs.writeFileSync(path.join(assetDir, safeName), contentItem.content);
2134
+ }
2135
+ downloaded++;
2136
+ log(` ${C.green}✓${C.reset} ${safeDirName} ${C.dim}(${contents.length} files)${C.reset}`);
1270
2137
  }
1271
- } catch { /* fall through */ }
2138
+ } catch (e) {
2139
+ log(` ${C.yellow}!${C.reset} ${safeDirName} ${C.dim}(skipped: ${e.message})${C.reset}`);
2140
+ }
1272
2141
  }
1273
- if (!authorUuid) error(`User @${username} not found. Make sure they have published assets.`);
2142
+
2143
+ log('');
2144
+ success(`Cloned ${downloaded}/${cloneItems.list.length} assets to ./${cloneItems.username}/`);
2145
+ } catch (e) {
2146
+ error(`Clone failed: ${e.message}`);
2147
+ }
2148
+ }
2149
+
2150
+ function currentFileSha(filePath) {
2151
+ try {
2152
+ return sha256(fs.readFileSync(filePath, 'utf8'));
2153
+ } catch {
2154
+ return '';
2155
+ }
2156
+ }
2157
+
2158
+ function diffCodexPlanWithLocal(plan, manifestRecord = {}) {
2159
+ const reasons = [];
2160
+ const desired = new Map(plan.files.map(file => [file.path, file.sha256]));
2161
+ const recordedFiles = manifestRecord.installedFiles || manifestRecord.installed_files || [];
2162
+
2163
+ for (const file of plan.files) {
2164
+ if (!fs.existsSync(file.path)) {
2165
+ reasons.push({ type: 'missing', path: file.path });
2166
+ continue;
2167
+ }
2168
+ const actualSha = currentFileSha(file.path);
2169
+ if (actualSha !== file.sha256) {
2170
+ reasons.push({ type: 'changed', path: file.path, actualSha, expectedSha: file.sha256 });
2171
+ }
2172
+ }
2173
+
2174
+ for (const file of recordedFiles) {
2175
+ if (file.path && !desired.has(file.path)) {
2176
+ reasons.push({ type: 'obsolete-manifest-path', path: file.path });
2177
+ }
2178
+ }
2179
+
2180
+ return {
2181
+ needsUpdate: reasons.length > 0,
2182
+ reasons,
2183
+ };
2184
+ }
2185
+
2186
+ async function fetchWorkflowForInstall(uuid, config, apiBase) {
2187
+ const data = await apiRequest('GET', `/api/v1/tokenboard/workflows/detail?uuid=${encodeURIComponent(uuid)}`, null, config?.token, apiBase);
2188
+ const workflow = data.workflow;
2189
+ const contents = extractInstallableContents(workflow, getWorkflowAssetType(workflow));
2190
+ if (contents.length === 0) {
2191
+ throw new Error('No installable content found');
2192
+ }
2193
+ return { workflow, contents };
2194
+ }
2195
+
2196
+ async function cmdSyncInstalled() {
2197
+ const args = parseArgs(process.argv);
2198
+ const targetTool = validateInstallTarget(args.flags.target || 'codex');
2199
+ if (targetTool !== 'codex') {
2200
+ error(`sync-installed currently supports --target codex only`);
2201
+ }
2202
+
2203
+ const json = Boolean(args.flags.json);
2204
+ if (!json) log(`\n${C.bold}tokrepo sync-installed${C.reset}\n`);
2205
+
2206
+ const manifest = readCodexManifest();
2207
+ const installed = (manifest.installs || []).filter(item => (item.targetTool || item.target_tool) === 'codex');
2208
+ const dryRun = Boolean(args.flags.dryRun || args.flags.dry_run);
2209
+ const stage = Boolean(args.flags.stage);
2210
+ if (installed.length === 0) {
2211
+ if (json) outputJson({ targetTool: 'codex', manifestPath: CODEX_MANIFEST_FILE, dryRun, stage, count: 0, results: [] });
2212
+ else info(`No Codex installs found in ${CODEX_MANIFEST_FILE}`);
2213
+ return;
1274
2214
  }
1275
2215
 
1276
- // Step 2: List all public workflows by this author
1277
- info(`Fetching all assets by @${username}...`);
1278
- let allItems = [];
1279
- let page = 1;
1280
- while (true) {
2216
+ const config = readConfig();
2217
+ const apiBase = config?.api || DEFAULT_API;
2218
+ const force = Boolean(args.flags.update || args.flags.force || args.flags.all);
2219
+ const results = [];
2220
+
2221
+ for (let i = 0; i < installed.length; i++) {
2222
+ const record = installed[i];
2223
+ const uuid = record.uuid;
2224
+ if (!uuid) continue;
2225
+
2226
+ if (!json) log(`${C.dim}[${i + 1}/${installed.length}]${C.reset} ${record.title || uuid}`);
2227
+
1281
2228
  try {
1282
- 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);
1283
- const items = data.list || data.items || [];
1284
- if (items.length === 0) break;
1285
- allItems = allItems.concat(items);
1286
- if (items.length < 50) break;
1287
- page++;
2229
+ const { workflow, contents } = await fetchWorkflowForInstall(uuid, config, apiBase);
2230
+ const plan = buildCodexInstallPlan(workflow, contents, { installMode: record.installMode || record.install_mode });
2231
+ const diff = diffCodexPlanWithLocal(plan, record);
2232
+ const shouldWrite = force || diff.needsUpdate;
2233
+
2234
+ if (dryRun) {
2235
+ results.push({
2236
+ uuid,
2237
+ title: workflow.title,
2238
+ status: shouldWrite ? 'would-update' : 'unchanged',
2239
+ needsUpdate: shouldWrite,
2240
+ reasons: diff.reasons,
2241
+ plan: publicInstallPlan(plan),
2242
+ });
2243
+ if (!json) {
2244
+ const label = shouldWrite ? `${C.yellow}would update${C.reset}` : `${C.green}unchanged${C.reset}`;
2245
+ log(` ${label}`);
2246
+ }
2247
+ continue;
2248
+ }
2249
+
2250
+ if (!shouldWrite) {
2251
+ results.push({
2252
+ uuid,
2253
+ title: workflow.title,
2254
+ status: 'unchanged',
2255
+ needsUpdate: false,
2256
+ reasons: [],
2257
+ });
2258
+ if (!json) success('Unchanged');
2259
+ continue;
2260
+ }
2261
+
2262
+ const installResult = await installCodexAsset(workflow, contents, {
2263
+ ...args.flags,
2264
+ dryRun: false,
2265
+ stage,
2266
+ installMode: record.installMode || record.install_mode,
2267
+ approveMcp: Boolean(args.flags.approveMcp || args.flags.approve_mcp),
2268
+ json: true,
2269
+ throwOnError: true,
2270
+ });
2271
+
2272
+ results.push({
2273
+ uuid,
2274
+ title: workflow.title,
2275
+ status: stage ? 'staged' : 'updated',
2276
+ needsUpdate: true,
2277
+ reasons: diff.reasons,
2278
+ stagePath: installResult.stagePath,
2279
+ installedFiles: installResult.installedFiles || [],
2280
+ plan: installResult.plan,
2281
+ });
2282
+ if (!json) success(stage ? `Staged ${installResult.plan.files.length} file(s)` : `Updated ${(installResult.installedFiles || []).length} file(s)`);
1288
2283
  } catch (e) {
1289
- error(`Failed to list assets: ${e.message}`);
2284
+ results.push({ uuid, title: record.title || uuid, status: 'failed', error: e.message });
2285
+ if (!json) warn(`Failed: ${e.message}`);
1290
2286
  }
1291
2287
  }
1292
2288
 
1293
- if (allItems.length === 0) {
1294
- info(`@${username} has no public assets.`);
2289
+ const summary = {
2290
+ targetTool: 'codex',
2291
+ manifestPath: CODEX_MANIFEST_FILE,
2292
+ dryRun,
2293
+ stage,
2294
+ count: results.length,
2295
+ updated: results.filter(item => item.status === 'updated').length,
2296
+ staged: results.filter(item => item.status === 'staged').length,
2297
+ unchanged: results.filter(item => item.status === 'unchanged').length,
2298
+ failed: results.filter(item => item.status === 'failed').length,
2299
+ results,
2300
+ };
2301
+
2302
+ if (json) {
2303
+ outputJson(summary);
2304
+ } else {
2305
+ log('');
2306
+ success(`Sync complete: ${summary.updated} updated, ${summary.staged} staged, ${summary.unchanged} unchanged, ${summary.failed} failed`);
2307
+ }
2308
+ }
2309
+
2310
+ async function cmdInstalled() {
2311
+ const args = parseArgs(process.argv);
2312
+ const targetTool = validateInstallTarget(args.flags.target || 'codex');
2313
+ if (targetTool !== 'codex') error(`installed currently supports --target codex only`);
2314
+
2315
+ const json = Boolean(args.flags.json);
2316
+ if (!json) log(`\n${C.bold}tokrepo installed${C.reset}\n`);
2317
+
2318
+ const manifest = readCodexManifest();
2319
+ const records = (manifest.installs || []).filter(item => (item.targetTool || item.target_tool) === 'codex');
2320
+ const list = records.map(record => {
2321
+ const files = (record.installedFiles || record.installed_files || []).map(file => {
2322
+ const actualSha = file.path && fs.existsSync(file.path) ? currentFileSha(file.path) : '';
2323
+ return {
2324
+ path: file.path,
2325
+ sourceName: file.sourceName || file.source_name,
2326
+ sha256: file.sha256,
2327
+ exists: Boolean(file.path && fs.existsSync(file.path)),
2328
+ changed: Boolean(actualSha && file.sha256 && actualSha !== file.sha256),
2329
+ };
2330
+ });
2331
+ return {
2332
+ uuid: record.uuid,
2333
+ title: record.title,
2334
+ sourceUrl: record.sourceUrl || record.source_url,
2335
+ targetTool: 'codex',
2336
+ installMode: record.installMode || record.install_mode,
2337
+ installedAt: record.installedAt || record.installed_at,
2338
+ contentHash: record.contentHash || record.content_hash || '',
2339
+ risks: record.risks || [],
2340
+ files,
2341
+ status: files.some(file => !file.exists) ? 'missing-files' : files.some(file => file.changed) ? 'local-changes' : 'installed',
2342
+ };
2343
+ });
2344
+
2345
+ if (json) {
2346
+ outputJson({ targetTool: 'codex', manifestPath: CODEX_MANIFEST_FILE, count: list.length, list });
2347
+ return;
2348
+ }
2349
+
2350
+ if (list.length === 0) {
2351
+ info(`No Codex installs found in ${CODEX_MANIFEST_FILE}`);
1295
2352
  return;
1296
2353
  }
1297
2354
 
1298
- log(` Found ${C.bold}${allItems.length}${C.reset} assets\n`);
2355
+ for (const item of list) {
2356
+ const color = item.status === 'installed' ? C.green : C.yellow;
2357
+ log(` ${color}${item.status}${C.reset} ${C.bold}${item.title || item.uuid}${C.reset}`);
2358
+ log(` ${C.dim}${item.uuid} · ${item.installMode || 'unknown'} · ${item.files.length} file(s)${C.reset}\n`);
2359
+ }
2360
+ }
2361
+
2362
+ async function cmdOutdated() {
2363
+ const args = parseArgs(process.argv);
2364
+ const targetTool = validateInstallTarget(args.flags.target || 'codex');
2365
+ if (targetTool !== 'codex') error(`outdated currently supports --target codex only`);
2366
+
2367
+ const json = Boolean(args.flags.json);
2368
+ if (!json) log(`\n${C.bold}tokrepo outdated${C.reset}\n`);
1299
2369
 
1300
- // Step 3: Create directory and pull each asset
1301
- const outDir = path.join(process.cwd(), username);
1302
- if (!fs.existsSync(outDir)) {
1303
- fs.mkdirSync(outDir, { recursive: true });
2370
+ const manifest = readCodexManifest();
2371
+ const installed = (manifest.installs || []).filter(item => (item.targetTool || item.target_tool) === 'codex');
2372
+ if (installed.length === 0) {
2373
+ if (json) outputJson({ targetTool: 'codex', count: 0, outdated: 0, list: [] });
2374
+ else info(`No Codex installs found in ${CODEX_MANIFEST_FILE}`);
2375
+ return;
1304
2376
  }
1305
2377
 
1306
- let downloaded = 0;
1307
- for (const item of allItems) {
1308
- const title = item.title || item.uuid;
1309
- const safeDirName = title.replace(/[/\\?%*:|"<>]/g, '-').substring(0, 80);
1310
- const assetDir = path.join(outDir, safeDirName);
2378
+ const config = readConfig();
2379
+ const apiBase = config?.api || DEFAULT_API;
2380
+ const list = [];
2381
+ let unchanged = 0;
2382
+ let failed = 0;
1311
2383
 
2384
+ for (const record of installed) {
1312
2385
  try {
1313
- const detail = await apiRequest('GET', `/api/v1/tokenboard/workflows/detail?uuid=${item.uuid}`, null, config?.token, apiBase);
1314
- const workflow = detail.workflow;
1315
-
1316
- if (workflow.steps && workflow.steps.length > 0) {
1317
- if (!fs.existsSync(assetDir)) fs.mkdirSync(assetDir, { recursive: true });
1318
- for (const step of workflow.steps) {
1319
- const content = step.prompt_template || step.promptTemplate;
1320
- if (content) {
1321
- const fileName = `${step.title || 'step-' + step.step_order}`;
1322
- const safeName = fileName.replace(/[/\\?%*:|"<>]/g, '-');
1323
- fs.writeFileSync(path.join(assetDir, safeName), content);
1324
- }
1325
- }
1326
- downloaded++;
1327
- log(` ${C.green}✓${C.reset} ${safeDirName} ${C.dim}(${workflow.steps.length} files)${C.reset}`);
2386
+ const { workflow, contents } = await fetchWorkflowForInstall(record.uuid, config, apiBase);
2387
+ const plan = buildCodexInstallPlan(workflow, contents, { installMode: record.installMode || record.install_mode });
2388
+ const diff = diffCodexPlanWithLocal(plan, record);
2389
+ if (diff.needsUpdate) {
2390
+ list.push({
2391
+ uuid: record.uuid,
2392
+ title: workflow.title,
2393
+ status: 'outdated',
2394
+ reasons: diff.reasons,
2395
+ plan: publicInstallPlan(plan),
2396
+ });
2397
+ } else {
2398
+ unchanged++;
1328
2399
  }
1329
2400
  } catch (e) {
1330
- log(` ${C.yellow}!${C.reset} ${safeDirName} ${C.dim}(skipped: ${e.message})${C.reset}`);
2401
+ failed++;
2402
+ list.push({ uuid: record.uuid, title: record.title || record.uuid, status: 'failed', error: e.message });
1331
2403
  }
1332
2404
  }
1333
2405
 
2406
+ if (json) {
2407
+ outputJson({ targetTool: 'codex', manifestPath: CODEX_MANIFEST_FILE, count: installed.length, outdated: list.filter(i => i.status === 'outdated').length, unchanged, failed, list });
2408
+ return;
2409
+ }
2410
+
2411
+ const outdated = list.filter(item => item.status === 'outdated');
2412
+ if (outdated.length === 0 && failed === 0) {
2413
+ success(`All ${unchanged} Codex install(s) are up to date.`);
2414
+ return;
2415
+ }
2416
+ for (const item of list) {
2417
+ if (item.status === 'failed') {
2418
+ warn(`${item.title}: ${item.error}`);
2419
+ } else {
2420
+ log(` ${C.yellow}outdated${C.reset} ${C.bold}${item.title}${C.reset}`);
2421
+ for (const reason of item.reasons.slice(0, 3)) {
2422
+ log(` ${C.dim}${reason.type}: ${reason.path || ''}${C.reset}`);
2423
+ }
2424
+ }
2425
+ }
1334
2426
  log('');
1335
- success(`Cloned ${downloaded}/${allItems.length} assets to ./${username}/`);
2427
+ info(`Run ${C.cyan}tokrepo update --target codex --all${C.reset} to update installed Codex assets.`);
1336
2428
  }
1337
2429
 
1338
2430
  async function cmdTags() {
@@ -1453,19 +2545,24 @@ ${C.bold}USAGE${C.reset}
1453
2545
 
1454
2546
  ${C.bold}DISCOVER & INSTALL${C.reset}
1455
2547
  ${C.cyan}search${C.reset} <query> Search assets by keyword
2548
+ ${C.cyan}detail${C.reset} <name|uuid> Show full asset metadata
1456
2549
  ${C.cyan}install${C.reset} <name|uuid> Smart install (auto-detects type & placement)
1457
2550
  ${C.cyan}pull${C.reset} <url|uuid|@u/n> Download raw asset files
1458
2551
  ${C.cyan}clone${C.reset} @username Clone all assets from a user
2552
+ ${C.cyan}installed${C.reset} List installed Codex assets from manifest
2553
+ ${C.cyan}outdated${C.reset} Check installed Codex assets for updates
2554
+ ${C.cyan}sync-installed${C.reset} Update installed Codex assets from manifest
1459
2555
 
1460
2556
  ${C.bold}PUBLISH${C.reset}
1461
2557
  ${C.cyan}push${C.reset} [files...] Push files/directory (idempotent upsert)
1462
2558
  ${C.cyan}status${C.reset} Compare local vs remote (like git status)
1463
2559
  ${C.cyan}init${C.reset} Create .tokrepo.json project config
1464
- ${C.cyan}update${C.reset} <uuid> [f] Update existing asset
2560
+ ${C.cyan}update${C.reset} <uuid> [f] Update existing remote asset
2561
+ ${C.cyan}update${C.reset} --target codex --all Update installed Codex assets
1465
2562
  ${C.cyan}delete${C.reset} <uuid> Delete an asset
1466
2563
 
1467
2564
  ${C.bold}ACCOUNT${C.reset}
1468
- ${C.cyan}login${C.reset} Save API token (or set TOKREPO_TOKEN env var)
2565
+ ${C.cyan}login${C.reset} Save API key (or set TOKREPO_TOKEN env var)
1469
2566
  ${C.cyan}list${C.reset} List your published assets
1470
2567
  ${C.cyan}tags${C.reset} List available tags
1471
2568
  ${C.cyan}whoami${C.reset} Show current user
@@ -1477,10 +2574,14 @@ ${C.bold}PUSH OPTIONS${C.reset}
1477
2574
  ${C.cyan}--title${C.reset} "..." Set title (auto-detected from README or dir name)
1478
2575
  ${C.cyan}--desc${C.reset} "..." Set description
1479
2576
  ${C.cyan}--tag${C.reset} Skills Add tag (repeatable)
2577
+ ${C.cyan}--kind${C.reset} skill Set agent asset_kind
2578
+ ${C.cyan}--target${C.reset} codex Add target tool metadata on push
2579
+ ${C.cyan}--install-mode${C.reset} bundle Set install_mode metadata
1480
2580
 
1481
2581
  ${C.bold}INSTALL BEHAVIOR${C.reset}
1482
2582
  Skills → .claude/skills/ (if .claude/ exists)
1483
2583
  Gemini → .gemini/GEMINI.md (with --target gemini)
2584
+ Codex → ~/.codex/skills/ (with --target codex)
1484
2585
  Scripts → current dir (chmod +x)
1485
2586
  Configs → project root
1486
2587
  MCP → current dir (.json)
@@ -1488,9 +2589,18 @@ ${C.bold}INSTALL BEHAVIOR${C.reset}
1488
2589
 
1489
2590
  ${C.bold}EXAMPLES${C.reset}
1490
2591
  tokrepo search "mcp server" # Find MCP configs
2592
+ tokrepo search video --json # Machine-readable search
2593
+ tokrepo detail ca000374-f5d8-... --json # Machine-readable detail
1491
2594
  tokrepo install ca000374-f5d8-... # Install by UUID
2595
+ tokrepo install ca000374-f5d8-... --target codex
1492
2596
  tokrepo install c4b18aeb --target gemini # Install for Gemini CLI
2597
+ tokrepo clone @henuwangkai --target codex --keyword video
2598
+ tokrepo installed --target codex --json
2599
+ tokrepo outdated --target codex --json
2600
+ tokrepo update --target codex --all
2601
+ tokrepo sync-installed --target codex --dry-run
1493
2602
  tokrepo push --private my-rules.md # Save one file privately
2603
+ tokrepo push . --kind skill --target codex --install-mode bundle
1494
2604
  tokrepo push --public skill.md # Share one file publicly
1495
2605
  tokrepo push --private . # Push current dir as private
1496
2606
  tokrepo push --public --title "My MCP" . # Push dir publicly with title
@@ -1511,10 +2621,141 @@ ${C.bold}GET YOUR TOKEN${C.reset}
1511
2621
  `);
1512
2622
  }
1513
2623
 
2624
+ function showSearchHelp() {
2625
+ log(`
2626
+ ${C.bold}tokrepo search${C.reset}
2627
+
2628
+ USAGE
2629
+ tokrepo search <query> [--json] [--all] [--page-size N] [--sort-by views|latest|stars|popular]
2630
+
2631
+ EXAMPLES
2632
+ tokrepo search video
2633
+ tokrepo search video --json
2634
+ tokrepo search "mcp server" --json --all
2635
+ `);
2636
+ }
2637
+
2638
+ function showDetailHelp() {
2639
+ log(`
2640
+ ${C.bold}tokrepo detail${C.reset}
2641
+
2642
+ USAGE
2643
+ tokrepo detail <uuid|url|name> [--json]
2644
+
2645
+ EXAMPLES
2646
+ tokrepo detail 91aeb22d-eff0-4310-abc6-811d2394b420 --json
2647
+ tokrepo detail https://tokrepo.com/en/workflows/91aeb22d-eff0-4310-abc6-811d2394b420
2648
+ `);
2649
+ }
2650
+
2651
+ function showInstallHelp() {
2652
+ log(`
2653
+ ${C.bold}tokrepo install${C.reset}
2654
+
2655
+ USAGE
2656
+ tokrepo install <uuid|url|name|pack/slug> [--target gemini|codex] [--yes] [--dry-run] [--stage] [--approve-mcp] [--json]
2657
+
2658
+ TARGETS
2659
+ codex Write Codex skills to ~/.codex/skills/<asset-slug>/SKILL.md
2660
+ gemini Write project instructions to .gemini/GEMINI.md
2661
+
2662
+ EXAMPLES
2663
+ tokrepo install awesome-cursor-rules
2664
+ tokrepo install ca000374-f5d8-4d75-a30c-460fda0b6b0e
2665
+ tokrepo install https://tokrepo.com/en/workflows/ca000374-...
2666
+ tokrepo install 91aeb22d-eff0-4310-abc6-811d2394b420 --target codex
2667
+ tokrepo install 91aeb22d-eff0-4310-abc6-811d2394b420 --target codex --dry-run --json
2668
+ tokrepo install 20bc3ffd-1d7a-41d1-86d0-b668e8500cee --target codex --stage
2669
+ tokrepo install c4b18aeb --target gemini
2670
+ `);
2671
+ }
2672
+
2673
+ function showListHelp() {
2674
+ log(`
2675
+ ${C.bold}tokrepo list${C.reset}
2676
+
2677
+ USAGE
2678
+ tokrepo list [--json] [--all] [--page-size N]
2679
+
2680
+ EXAMPLES
2681
+ tokrepo list
2682
+ tokrepo list --json --all
2683
+ `);
2684
+ }
2685
+
2686
+ function showCloneHelp() {
2687
+ log(`
2688
+ ${C.bold}tokrepo clone${C.reset}
2689
+
2690
+ USAGE
2691
+ tokrepo clone @username [--target codex] [--keyword query] [--types skill,prompt,knowledge] [--dry-run] [--stage] [--approve-mcp] [--json] [--manifest]
2692
+
2693
+ EXAMPLES
2694
+ tokrepo clone @henuwangkai --target codex --types skill,prompt,knowledge
2695
+ tokrepo clone @henuwangkai --target codex --keyword video
2696
+ tokrepo clone @me --target codex --dry-run --json --manifest
2697
+ `);
2698
+ }
2699
+
2700
+ function showSyncInstalledHelp() {
2701
+ log(`
2702
+ ${C.bold}tokrepo sync-installed${C.reset}
2703
+
2704
+ USAGE
2705
+ tokrepo sync-installed --target codex [--dry-run] [--stage] [--update] [--approve-mcp] [--json]
2706
+ tokrepo installed --target codex [--json]
2707
+ tokrepo outdated --target codex [--json]
2708
+ tokrepo update --target codex --all [--stage] [--approve-mcp] [--json]
2709
+
2710
+ BEHAVIOR
2711
+ Reads ~/.codex/tokrepo/install-manifest.json, fetches each TokRepo asset again,
2712
+ rebuilds the Codex install plan, compares local files by sha256, then updates
2713
+ changed or missing files. Use --update to force reinstall unchanged assets.
2714
+
2715
+ EXAMPLES
2716
+ tokrepo installed --target codex --json
2717
+ tokrepo outdated --target codex --json
2718
+ tokrepo update --target codex --all
2719
+ tokrepo sync-installed --target codex --dry-run --json
2720
+ tokrepo sync-installed --target codex --stage
2721
+ tokrepo sync-installed --target codex --update --approve-mcp
2722
+ `);
2723
+ }
2724
+
2725
+ function showCommandHelp(command) {
2726
+ switch (command) {
2727
+ case 'search':
2728
+ case 'find':
2729
+ showSearchHelp(); break;
2730
+ case 'detail':
2731
+ showDetailHelp(); break;
2732
+ case 'install':
2733
+ case 'i':
2734
+ showInstallHelp(); break;
2735
+ case 'list':
2736
+ showListHelp(); break;
2737
+ case 'clone':
2738
+ showCloneHelp(); break;
2739
+ case 'sync-installed':
2740
+ case 'sync':
2741
+ case 'installed':
2742
+ case 'outdated':
2743
+ showSyncInstalledHelp(); break;
2744
+ default:
2745
+ showHelp(); break;
2746
+ }
2747
+ }
2748
+
1514
2749
  // ─── Main ───
1515
2750
 
1516
2751
  async function main() {
1517
2752
  const command = process.argv[2];
2753
+ const args = parseArgs(process.argv);
2754
+
2755
+ if (args.flags.help && command && !['help', '--help', '-h'].includes(command)) {
2756
+ showCommandHelp(command);
2757
+ return;
2758
+ }
1518
2759
 
1519
2760
  switch (command) {
1520
2761
  case 'login': await cmdLogin(); break;
@@ -1522,11 +2763,15 @@ async function main() {
1522
2763
  case 'push': await cmdPush(); break;
1523
2764
  case 'pull': await cmdPull(); break;
1524
2765
  case 'search': case 'find': await cmdSearch(); break;
2766
+ case 'detail': await cmdDetail(); break;
1525
2767
  case 'install': case 'i': await cmdInstall(); break;
1526
2768
  case 'list': await cmdList(); break;
1527
2769
  case 'update': await cmdUpdate(); break;
1528
2770
  case 'delete': await cmdDelete(); break;
1529
2771
  case 'clone': await cmdClone(); break;
2772
+ case 'installed': await cmdInstalled(); break;
2773
+ case 'outdated': await cmdOutdated(); break;
2774
+ case 'sync-installed': case 'sync': await cmdSyncInstalled(); break;
1530
2775
  case 'tags': await cmdTags(); break;
1531
2776
  case 'status': case 'diff': await cmdStatus(); break;
1532
2777
  case 'whoami': await cmdWhoami(); break;
@@ -1539,7 +2784,9 @@ async function main() {
1539
2784
  }
1540
2785
 
1541
2786
  // Non-blocking update check after command completes
1542
- checkForUpdate();
2787
+ if (!wantsJson(process.argv) && !args.flags.help) {
2788
+ checkForUpdate();
2789
+ }
1543
2790
  }
1544
2791
 
1545
2792
  main().catch((e) => { error(e.message); });