tokrepo 3.4.0 → 3.6.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 +456 -15
  2. package/package.json +1 -1
package/bin/tokrepo.js CHANGED
@@ -25,7 +25,7 @@ const CONFIG_DIR = path.join(os.homedir(), '.tokrepo');
25
25
  const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
26
26
  const PROJECT_CONFIG = '.tokrepo.json';
27
27
  const DEFAULT_API = 'https://api.tokrepo.com';
28
- const CLI_VERSION = '3.4.0';
28
+ const CLI_VERSION = '3.6.0';
29
29
  const VERSION_CHECK_FILE = path.join(os.homedir(), '.tokrepo', '.version-check');
30
30
  const CODEX_DIR = path.join(os.homedir(), '.codex');
31
31
  const CODEX_SKILLS_DIR = path.join(CODEX_DIR, 'skills');
@@ -236,6 +236,12 @@ function guessTag(fileType) {
236
236
  return map[fileType] || null;
237
237
  }
238
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
+
239
245
  // ─── Glob matching ───
240
246
 
241
247
  function matchGlob(pattern, filename) {
@@ -295,7 +301,9 @@ function parseArgs(argv) {
295
301
  }
296
302
 
297
303
  const valueFlags = new Set([
298
- 'title', 'desc', 'tag', 'target', 'keyword', 'types',
304
+ 'title', 'desc', 'tag', 'target', 'targets', 'keyword', 'types',
305
+ 'kind', 'install-mode', 'install_mode', 'entrypoint', 'asset-kind', 'asset_kind',
306
+ 'version',
299
307
  'page', 'page-size', 'page_size', 'sort-by', 'sort_by',
300
308
  'time-window', 'time_window',
301
309
  ]);
@@ -318,6 +326,10 @@ function parseArgs(argv) {
318
326
  args.flags.dryRun = value;
319
327
  } else if (normalized === 'approve_mcp') {
320
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;
321
333
  }
322
334
  args.flags[normalized] = value;
323
335
  };
@@ -574,6 +586,10 @@ async function cmdPush() {
574
586
  description = args.flags.desc || description || '';
575
587
  visibility = args.flags.public ? 1 : (args.flags.private ? 0 : (projectConfig?.visibility ?? 0));
576
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 || '';
577
593
 
578
594
  // Read files and detect types
579
595
  const pushFiles = [];
@@ -613,6 +629,15 @@ async function cmdPush() {
613
629
  if (detectedTags.size > 0) {
614
630
  log(` ${C.bold}Tags:${C.reset} ${Array.from(detectedTags).join(', ')}`);
615
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
+ }
616
641
  log('');
617
642
 
618
643
  for (const f of pushFiles) {
@@ -634,6 +659,10 @@ async function cmdPush() {
634
659
  tags: Array.from(detectedTags),
635
660
  token_cost: String(Math.round(totalChars / 4)),
636
661
  visibility: visibility,
662
+ kind,
663
+ target_tools: targetTools,
664
+ install_mode: installMode,
665
+ entrypoint,
637
666
  }, config.token, config.api);
638
667
 
639
668
  log('');
@@ -1095,11 +1124,13 @@ function explicitInstallMode(workflow) {
1095
1124
  const candidates = [
1096
1125
  workflow?.installMode,
1097
1126
  workflow?.install_mode,
1127
+ workflow?.agent_metadata?.install_mode,
1128
+ workflow?.agentMetadata?.installMode,
1098
1129
  workflow?.metadata?.installMode,
1099
1130
  workflow?.metadata?.install_mode,
1100
1131
  ].filter(Boolean);
1101
1132
  const mode = String(candidates[0] || '').toLowerCase();
1102
- return ['single', 'bundle', 'split'].includes(mode) ? mode : '';
1133
+ return ['single', 'bundle', 'split', 'stage_only'].includes(mode) ? mode : '';
1103
1134
  }
1104
1135
 
1105
1136
  function inferCodexInstallMode(workflow, contents) {
@@ -1157,6 +1188,7 @@ function addPlanFile(plan, destPath, content, sourceName, type) {
1157
1188
 
1158
1189
  function buildCodexInstallPlan(workflow, contents, opts = {}) {
1159
1190
  const installMode = opts.installMode || inferCodexInstallMode(workflow, contents);
1191
+ const agentMetadata = workflow?.agent_metadata || workflow?.agentMetadata || {};
1160
1192
  const plan = {
1161
1193
  uuid: workflow.uuid,
1162
1194
  title: workflow.title,
@@ -1166,8 +1198,20 @@ function buildCodexInstallPlan(workflow, contents, opts = {}) {
1166
1198
  manifestPath: CODEX_MANIFEST_FILE,
1167
1199
  files: [],
1168
1200
  risks: [],
1201
+ agentMetadata,
1202
+ contentHash: workflow.content_hash || workflow.contentHash || agentMetadata.content_hash || '',
1169
1203
  };
1170
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
+
1171
1215
  if (installMode === 'split') {
1172
1216
  const usedDirs = new Set();
1173
1217
  contents.forEach((item, index) => {
@@ -1234,8 +1278,161 @@ function buildCodexInstallPlan(workflow, contents, opts = {}) {
1234
1278
  return plan;
1235
1279
  }
1236
1280
 
1281
+ function metadataValue(metadata, snakeName, camelName, fallback) {
1282
+ if (!metadata) return fallback;
1283
+ if (metadata[snakeName] !== undefined) return metadata[snakeName];
1284
+ if (metadata[camelName] !== undefined) return metadata[camelName];
1285
+ return fallback;
1286
+ }
1287
+
1288
+ function normalizeToolName(value) {
1289
+ return String(value || '').trim().toLowerCase().replace(/-/g, '_');
1290
+ }
1291
+
1292
+ function riskProfileFromFlags(flags = []) {
1293
+ const set = new Set(flags || []);
1294
+ return {
1295
+ executes_code: set.has('executable'),
1296
+ modifies_global_config: set.has('mcp'),
1297
+ requires_secrets: set.has('env') ? ['ENV'] : [],
1298
+ uses_absolute_paths: set.has('absolute-path'),
1299
+ network_access: set.has('network'),
1300
+ };
1301
+ }
1302
+
1303
+ function mergedPlanRiskProfile(plan) {
1304
+ const metadata = plan.agentMetadata || {};
1305
+ const rp = metadataValue(metadata, 'risk_profile', 'riskProfile', {}) || {};
1306
+ const flags = new Set(plan.risks || []);
1307
+ return {
1308
+ executes_code: Boolean(rp.executes_code || rp.executesCode || flags.has('executable')),
1309
+ modifies_global_config: Boolean(rp.modifies_global_config || rp.modifiesGlobalConfig || flags.has('mcp')),
1310
+ requires_secrets: rp.requires_secrets || rp.requiresSecrets || (flags.has('env') ? ['ENV'] : []),
1311
+ uses_absolute_paths: Boolean(rp.uses_absolute_paths || rp.usesAbsolutePaths || flags.has('absolute-path')),
1312
+ network_access: Boolean(rp.network_access || rp.networkAccess || flags.has('network')),
1313
+ };
1314
+ }
1315
+
1316
+ function decideCodexPolicy(plan) {
1317
+ const metadata = plan.agentMetadata || {};
1318
+ const targetTools = metadataValue(metadata, 'target_tools', 'targetTools', []) || [];
1319
+ const assetKind = normalizeToolName(metadataValue(metadata, 'asset_kind', 'assetKind', ''));
1320
+ const risk = mergedPlanRiskProfile(plan);
1321
+ let decision = 'allow';
1322
+ const reasons = [];
1323
+ const raise = (next) => {
1324
+ const rank = { allow: 0, confirm: 1, stage_only: 2, deny: 3 };
1325
+ if ((rank[next] || 0) > (rank[decision] || 0)) decision = next;
1326
+ };
1327
+
1328
+ if (targetTools.length && !targetTools.map(normalizeToolName).includes('codex')) {
1329
+ raise('confirm');
1330
+ reasons.push('metadata target_tools does not include codex');
1331
+ }
1332
+ if (['script', 'cli_tool', 'mcp_config'].includes(assetKind)) {
1333
+ raise('stage_only');
1334
+ reasons.push(`asset_kind ${assetKind} is not activated directly for Codex`);
1335
+ }
1336
+ if (plan.installMode === 'stage_only') {
1337
+ raise('stage_only');
1338
+ reasons.push('install_mode is stage_only');
1339
+ }
1340
+ if (risk.executes_code) {
1341
+ raise('stage_only');
1342
+ reasons.push('risk_profile.executes_code is true');
1343
+ }
1344
+ if (risk.modifies_global_config) {
1345
+ raise('stage_only');
1346
+ reasons.push('risk_profile.modifies_global_config is true');
1347
+ }
1348
+ if ((risk.requires_secrets || []).length) {
1349
+ raise('stage_only');
1350
+ reasons.push('risk_profile.requires_secrets is not empty');
1351
+ }
1352
+ if (risk.uses_absolute_paths) {
1353
+ raise('confirm');
1354
+ reasons.push('risk_profile.uses_absolute_paths is true');
1355
+ }
1356
+ if (risk.network_access) {
1357
+ raise('confirm');
1358
+ reasons.push('risk_profile.network_access is true');
1359
+ }
1360
+ if (reasons.length === 0) reasons.push('safe markdown-only Codex install');
1361
+
1362
+ return {
1363
+ decision,
1364
+ requiresConfirmation: decision === 'confirm',
1365
+ reasons: Array.from(new Set(reasons)),
1366
+ };
1367
+ }
1368
+
1369
+ function buildPublicPlanActions(plan) {
1370
+ const stage = plan.installMode === 'stage_only';
1371
+ return plan.files.map(file => ({
1372
+ type: stage ? 'stage_file' : 'write_file',
1373
+ path: file.path,
1374
+ sourceName: file.sourceName,
1375
+ sha256: file.sha256,
1376
+ bytes: file.bytes,
1377
+ ifExists: 'overwrite',
1378
+ entrypoint: path.basename(file.path).toLowerCase() === 'skill.md',
1379
+ risk: riskProfileFromFlags(file.riskFlags || []),
1380
+ }));
1381
+ }
1382
+
1383
+ function buildPublicPlanPreconditions(plan, policyDecision) {
1384
+ const metadata = plan.agentMetadata || {};
1385
+ const targetTools = metadataValue(metadata, 'target_tools', 'targetTools', []) || [];
1386
+ const out = [
1387
+ { type: 'target_supported', status: 'pass', message: 'codex install target is supported' },
1388
+ { type: 'install_root', status: 'pass', message: '~/.codex/skills for activated skills; ~/.codex/tokrepo/staged for staged assets' },
1389
+ ];
1390
+ if (!targetTools.length || targetTools.map(normalizeToolName).includes('codex')) {
1391
+ out.push({ type: 'target_tool_metadata', status: 'pass', message: 'metadata allows codex' });
1392
+ } else {
1393
+ out.push({ type: 'target_tool_metadata', status: 'warn', message: 'metadata target_tools does not include codex' });
1394
+ }
1395
+ out.push({
1396
+ type: 'content_hash',
1397
+ status: plan.contentHash ? 'pass' : 'warn',
1398
+ message: plan.contentHash ? 'asset metadata includes content_hash' : 'asset metadata does not include content_hash',
1399
+ });
1400
+ const policyStatus = policyDecision.decision === 'deny' ? 'block'
1401
+ : policyDecision.decision === 'allow' ? 'pass'
1402
+ : 'warn';
1403
+ out.push({ type: 'policy_decision', status: policyStatus, message: `${policyDecision.decision} for ${plan.uuid}` });
1404
+ return out;
1405
+ }
1406
+
1407
+ function buildPublicPlanRollback(plan) {
1408
+ const seen = new Set();
1409
+ const rollback = [];
1410
+ for (const file of plan.files) {
1411
+ if (!file.path || seen.has(file.path)) continue;
1412
+ seen.add(file.path);
1413
+ rollback.push({ type: 'remove_file', path: file.path });
1414
+ }
1415
+ return rollback;
1416
+ }
1417
+
1418
+ function buildPublicPlanPostVerify(plan) {
1419
+ const metadata = plan.agentMetadata || {};
1420
+ const verification = metadataValue(metadata, 'verification', 'verification', {}) || {};
1421
+ const out = plan.files.map(file => ({ type: 'file_sha256', path: file.path, sha256: file.sha256 }));
1422
+ for (const expected of (verification.expected_files || verification.expectedFiles || [])) {
1423
+ out.push({ type: 'expected_file', path: expected });
1424
+ }
1425
+ for (const command of (verification.commands || [])) {
1426
+ out.push({ type: 'command', command });
1427
+ }
1428
+ return out;
1429
+ }
1430
+
1237
1431
  function publicInstallPlan(plan) {
1432
+ const policyDecision = decideCodexPolicy(plan);
1433
+ const actions = buildPublicPlanActions(plan);
1238
1434
  return {
1435
+ schemaVersion: 2,
1239
1436
  uuid: plan.uuid,
1240
1437
  title: plan.title,
1241
1438
  sourceUrl: plan.sourceUrl,
@@ -1244,6 +1441,14 @@ function publicInstallPlan(plan) {
1244
1441
  manifestPath: plan.manifestPath,
1245
1442
  baseDir: plan.baseDir,
1246
1443
  risks: plan.risks,
1444
+ preconditions: buildPublicPlanPreconditions(plan, policyDecision),
1445
+ actions,
1446
+ policyDecision,
1447
+ requiresConfirmation: policyDecision.requiresConfirmation,
1448
+ rollback: buildPublicPlanRollback(plan),
1449
+ postVerify: buildPublicPlanPostVerify(plan),
1450
+ contentHash: plan.contentHash || '',
1451
+ agentMetadata: plan.agentMetadata || {},
1247
1452
  files: plan.files.map(file => ({
1248
1453
  path: file.path,
1249
1454
  sourceName: file.sourceName,
@@ -1256,7 +1461,8 @@ function publicInstallPlan(plan) {
1256
1461
  }
1257
1462
 
1258
1463
  function hasCodexInstallRisks(plan) {
1259
- return (plan.risks || []).some(risk => ['mcp', 'executable', 'env', 'absolute-path'].includes(risk));
1464
+ const decision = decideCodexPolicy(plan).decision;
1465
+ return decision === 'confirm' || decision === 'stage_only' || decision === 'deny';
1260
1466
  }
1261
1467
 
1262
1468
  function formatRiskLine(file) {
@@ -1265,14 +1471,19 @@ function formatRiskLine(file) {
1265
1471
  }
1266
1472
 
1267
1473
  async function confirmCodexInstallRisks(plan, opts = {}) {
1268
- if (opts.dryRun || opts.stage || !hasCodexInstallRisks(plan)) return;
1474
+ const policy = decideCodexPolicy(plan);
1475
+ if (policy.decision === 'deny') {
1476
+ throw new Error(`Install policy denied this asset: ${policy.reasons.join('; ')}`);
1477
+ }
1478
+ if (plan.installMode === 'stage_only') return;
1479
+ if (opts.dryRun || opts.stage || policy.decision === 'allow') return;
1269
1480
  if (opts.approveMcp || opts.approve_mcp || opts.yes) return;
1270
1481
 
1271
1482
  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.`);
1483
+ throw new Error(`Install policy is ${policy.decision}: ${policy.reasons.join('; ')}. Re-run with --dry-run to inspect, --stage to stage the plan, or --approve-mcp to approve writing the Codex skill bundle.`);
1273
1484
  }
1274
1485
 
1275
- warn(`This asset contains ${plan.risks.join(', ')} content.`);
1486
+ warn(`Install policy is ${policy.decision}: ${policy.reasons.join('; ')}`);
1276
1487
  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
1488
  const riskyFiles = plan.files
1278
1489
  .map(formatRiskLine)
@@ -1301,6 +1512,32 @@ function stageCodexInstallPlan(plan) {
1301
1512
  return stagePath;
1302
1513
  }
1303
1514
 
1515
+ function executeStageOnlyCodexPlan(plan) {
1516
+ const installedFiles = [];
1517
+ const stageRoot = path.join(CODEX_TOKREPO_DIR, 'staged', plan.uuid);
1518
+ if (!fs.existsSync(stageRoot)) fs.mkdirSync(stageRoot, { recursive: true, mode: 0o700 });
1519
+
1520
+ for (const file of plan.files) {
1521
+ if (!ensureInside(stageRoot, file.path)) {
1522
+ throw new Error(`Stage path escaped TokRepo staging directory: ${file.path}`);
1523
+ }
1524
+ const destDir = path.dirname(file.path);
1525
+ if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true, mode: 0o700 });
1526
+ fs.writeFileSync(file.path, file.content, { mode: 0o600 });
1527
+ installedFiles.push({
1528
+ path: file.path,
1529
+ sourceName: file.sourceName,
1530
+ sha256: sha256(file.content),
1531
+ bytes: Buffer.byteLength(String(file.content || '')),
1532
+ riskFlags: file.riskFlags,
1533
+ });
1534
+ }
1535
+
1536
+ const stagePath = path.join(stageRoot, 'install-plan.json');
1537
+ fs.writeFileSync(stagePath, `${JSON.stringify(publicInstallPlan(plan), null, 2)}\n`, { mode: 0o600 });
1538
+ return { dryRun: true, staged: true, stageOnly: true, stagePath, plan: publicInstallPlan(plan), installedFiles };
1539
+ }
1540
+
1304
1541
  function readCodexManifest() {
1305
1542
  try {
1306
1543
  const parsed = JSON.parse(fs.readFileSync(CODEX_MANIFEST_FILE, 'utf8'));
@@ -1322,6 +1559,8 @@ function writeCodexManifestRecord(plan, installedFiles) {
1322
1559
  targetTool: 'codex',
1323
1560
  installMode: plan.installMode,
1324
1561
  installedAt,
1562
+ contentHash: plan.contentHash || '',
1563
+ agentMetadata: plan.agentMetadata || {},
1325
1564
  installedFiles: installedFiles.map(file => ({
1326
1565
  path: file.path,
1327
1566
  sourceName: file.sourceName,
@@ -1340,6 +1579,7 @@ function writeCodexManifestRecord(plan, installedFiles) {
1340
1579
 
1341
1580
  function executeCodexInstallPlan(plan, opts = {}) {
1342
1581
  if (opts.dryRun) return { dryRun: true, plan: publicInstallPlan(plan), installedFiles: [] };
1582
+ if (plan.installMode === 'stage_only') return executeStageOnlyCodexPlan(plan);
1343
1583
  if (opts.stage) {
1344
1584
  const stagePath = stageCodexInstallPlan(plan);
1345
1585
  return { dryRun: true, staged: true, stagePath, plan: publicInstallPlan(plan), installedFiles: [] };
@@ -1411,6 +1651,33 @@ async function cmdInstall() {
1411
1651
  }
1412
1652
  }
1413
1653
 
1654
+ async function cmdPlan() {
1655
+ const args = parseArgs(process.argv);
1656
+ const target = args.positional[0];
1657
+ if (!target) {
1658
+ showPlanHelp();
1659
+ process.exit(1);
1660
+ }
1661
+
1662
+ const config = readConfig();
1663
+ const apiBase = config?.api || DEFAULT_API;
1664
+ const targetTool = validateInstallTarget(args.flags.target || 'codex');
1665
+ if (targetTool !== 'codex') {
1666
+ error(`plan currently supports --target codex only`);
1667
+ }
1668
+ const result = await installOneAsset(target, config, apiBase, {
1669
+ targetTool,
1670
+ dryRun: true,
1671
+ stage: Boolean(args.flags.stage),
1672
+ installMode: args.flags.installMode,
1673
+ json: true,
1674
+ manifest: true,
1675
+ throwOnError: true,
1676
+ });
1677
+
1678
+ outputJson(result.plan);
1679
+ }
1680
+
1414
1681
  // Install all assets in a theme pack — sequentially, continue past per-item errors
1415
1682
  async function installPack(slug, config, apiBase, opts) {
1416
1683
  info(`Fetching pack ${C.bold}${slug}${C.reset}...`);
@@ -1547,9 +1814,13 @@ async function installOneAsset(target, config, apiBase, opts) {
1547
1814
 
1548
1815
  if (!opts.json) {
1549
1816
  const plan = result.plan;
1550
- if (opts.stage) {
1817
+ if (result.staged || opts.stage) {
1551
1818
  info(`Staged install plan: ${result.stagePath}`);
1552
- info(`No Codex skill files were written. Re-run with --approve-mcp or --yes to install.`);
1819
+ if (result.stageOnly) {
1820
+ info(`stage_only asset: files were written only under ${path.dirname(result.stagePath)}; no Codex skill was activated.`);
1821
+ } else {
1822
+ info(`No Codex skill files were written. Re-run with --approve-mcp or --yes to install.`);
1823
+ }
1553
1824
  } else if (opts.dryRun) {
1554
1825
  info(`Dry run: ${plan.files.length} file(s) would be installed to ${CODEX_SKILLS_DIR}`);
1555
1826
  for (const file of plan.files) {
@@ -1764,6 +2035,12 @@ async function cmdList() {
1764
2035
  }
1765
2036
 
1766
2037
  async function cmdUpdate() {
2038
+ const args = parseArgs(process.argv);
2039
+ if (args.flags.target || args.flags.all || args.flags.force) {
2040
+ await cmdSyncInstalled();
2041
+ return;
2042
+ }
2043
+
1767
2044
  const uuid = process.argv[3];
1768
2045
  if (!uuid) error('Usage: tokrepo update <uuid> [file]');
1769
2046
 
@@ -1833,9 +2110,11 @@ function tagMatchesTypes(workflow, requestedTypes) {
1833
2110
  if (!requestedTypes || requestedTypes.length === 0) return true;
1834
2111
  const tags = (workflow.tags || []).flatMap(t => [t.slug, t.name]).filter(Boolean).map(t => String(t).toLowerCase());
1835
2112
  const assetType = getWorkflowAssetType(workflow);
2113
+ const metadataKind = String(workflow.asset_kind || workflow.agent_metadata?.asset_kind || workflow.agentMetadata?.assetKind || '').toLowerCase();
1836
2114
  return requestedTypes.some(type => {
1837
2115
  const needle = String(type).trim().toLowerCase();
1838
2116
  if (!needle) return false;
2117
+ if (metadataKind === needle || metadataKind === `${needle}s`) return true;
1839
2118
  if (assetType === needle || assetType === `${needle}s`) return true;
1840
2119
  return tags.some(tag => tag === needle || tag === `${needle}s` || tag.includes(needle));
1841
2120
  });
@@ -2116,17 +2395,17 @@ async function cmdSyncInstalled() {
2116
2395
 
2117
2396
  const manifest = readCodexManifest();
2118
2397
  const installed = (manifest.installs || []).filter(item => (item.targetTool || item.target_tool) === 'codex');
2398
+ const dryRun = Boolean(args.flags.dryRun || args.flags.dry_run);
2399
+ const stage = Boolean(args.flags.stage);
2119
2400
  if (installed.length === 0) {
2120
- if (json) outputJson({ targetTool: 'codex', count: 0, results: [] });
2401
+ if (json) outputJson({ targetTool: 'codex', manifestPath: CODEX_MANIFEST_FILE, dryRun, stage, count: 0, results: [] });
2121
2402
  else info(`No Codex installs found in ${CODEX_MANIFEST_FILE}`);
2122
2403
  return;
2123
2404
  }
2124
2405
 
2125
2406
  const config = readConfig();
2126
2407
  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);
2408
+ const force = Boolean(args.flags.update || args.flags.force || args.flags.all);
2130
2409
  const results = [];
2131
2410
 
2132
2411
  for (let i = 0; i < installed.length; i++) {
@@ -2218,6 +2497,126 @@ async function cmdSyncInstalled() {
2218
2497
  }
2219
2498
  }
2220
2499
 
2500
+ async function cmdInstalled() {
2501
+ const args = parseArgs(process.argv);
2502
+ const targetTool = validateInstallTarget(args.flags.target || 'codex');
2503
+ if (targetTool !== 'codex') error(`installed currently supports --target codex only`);
2504
+
2505
+ const json = Boolean(args.flags.json);
2506
+ if (!json) log(`\n${C.bold}tokrepo installed${C.reset}\n`);
2507
+
2508
+ const manifest = readCodexManifest();
2509
+ const records = (manifest.installs || []).filter(item => (item.targetTool || item.target_tool) === 'codex');
2510
+ const list = records.map(record => {
2511
+ const files = (record.installedFiles || record.installed_files || []).map(file => {
2512
+ const actualSha = file.path && fs.existsSync(file.path) ? currentFileSha(file.path) : '';
2513
+ return {
2514
+ path: file.path,
2515
+ sourceName: file.sourceName || file.source_name,
2516
+ sha256: file.sha256,
2517
+ exists: Boolean(file.path && fs.existsSync(file.path)),
2518
+ changed: Boolean(actualSha && file.sha256 && actualSha !== file.sha256),
2519
+ };
2520
+ });
2521
+ return {
2522
+ uuid: record.uuid,
2523
+ title: record.title,
2524
+ sourceUrl: record.sourceUrl || record.source_url,
2525
+ targetTool: 'codex',
2526
+ installMode: record.installMode || record.install_mode,
2527
+ installedAt: record.installedAt || record.installed_at,
2528
+ contentHash: record.contentHash || record.content_hash || '',
2529
+ risks: record.risks || [],
2530
+ files,
2531
+ status: files.some(file => !file.exists) ? 'missing-files' : files.some(file => file.changed) ? 'local-changes' : 'installed',
2532
+ };
2533
+ });
2534
+
2535
+ if (json) {
2536
+ outputJson({ targetTool: 'codex', manifestPath: CODEX_MANIFEST_FILE, count: list.length, list });
2537
+ return;
2538
+ }
2539
+
2540
+ if (list.length === 0) {
2541
+ info(`No Codex installs found in ${CODEX_MANIFEST_FILE}`);
2542
+ return;
2543
+ }
2544
+
2545
+ for (const item of list) {
2546
+ const color = item.status === 'installed' ? C.green : C.yellow;
2547
+ log(` ${color}${item.status}${C.reset} ${C.bold}${item.title || item.uuid}${C.reset}`);
2548
+ log(` ${C.dim}${item.uuid} · ${item.installMode || 'unknown'} · ${item.files.length} file(s)${C.reset}\n`);
2549
+ }
2550
+ }
2551
+
2552
+ async function cmdOutdated() {
2553
+ const args = parseArgs(process.argv);
2554
+ const targetTool = validateInstallTarget(args.flags.target || 'codex');
2555
+ if (targetTool !== 'codex') error(`outdated currently supports --target codex only`);
2556
+
2557
+ const json = Boolean(args.flags.json);
2558
+ if (!json) log(`\n${C.bold}tokrepo outdated${C.reset}\n`);
2559
+
2560
+ const manifest = readCodexManifest();
2561
+ const installed = (manifest.installs || []).filter(item => (item.targetTool || item.target_tool) === 'codex');
2562
+ if (installed.length === 0) {
2563
+ if (json) outputJson({ targetTool: 'codex', count: 0, outdated: 0, list: [] });
2564
+ else info(`No Codex installs found in ${CODEX_MANIFEST_FILE}`);
2565
+ return;
2566
+ }
2567
+
2568
+ const config = readConfig();
2569
+ const apiBase = config?.api || DEFAULT_API;
2570
+ const list = [];
2571
+ let unchanged = 0;
2572
+ let failed = 0;
2573
+
2574
+ for (const record of installed) {
2575
+ try {
2576
+ const { workflow, contents } = await fetchWorkflowForInstall(record.uuid, config, apiBase);
2577
+ const plan = buildCodexInstallPlan(workflow, contents, { installMode: record.installMode || record.install_mode });
2578
+ const diff = diffCodexPlanWithLocal(plan, record);
2579
+ if (diff.needsUpdate) {
2580
+ list.push({
2581
+ uuid: record.uuid,
2582
+ title: workflow.title,
2583
+ status: 'outdated',
2584
+ reasons: diff.reasons,
2585
+ plan: publicInstallPlan(plan),
2586
+ });
2587
+ } else {
2588
+ unchanged++;
2589
+ }
2590
+ } catch (e) {
2591
+ failed++;
2592
+ list.push({ uuid: record.uuid, title: record.title || record.uuid, status: 'failed', error: e.message });
2593
+ }
2594
+ }
2595
+
2596
+ if (json) {
2597
+ outputJson({ targetTool: 'codex', manifestPath: CODEX_MANIFEST_FILE, count: installed.length, outdated: list.filter(i => i.status === 'outdated').length, unchanged, failed, list });
2598
+ return;
2599
+ }
2600
+
2601
+ const outdated = list.filter(item => item.status === 'outdated');
2602
+ if (outdated.length === 0 && failed === 0) {
2603
+ success(`All ${unchanged} Codex install(s) are up to date.`);
2604
+ return;
2605
+ }
2606
+ for (const item of list) {
2607
+ if (item.status === 'failed') {
2608
+ warn(`${item.title}: ${item.error}`);
2609
+ } else {
2610
+ log(` ${C.yellow}outdated${C.reset} ${C.bold}${item.title}${C.reset}`);
2611
+ for (const reason of item.reasons.slice(0, 3)) {
2612
+ log(` ${C.dim}${reason.type}: ${reason.path || ''}${C.reset}`);
2613
+ }
2614
+ }
2615
+ }
2616
+ log('');
2617
+ info(`Run ${C.cyan}tokrepo update --target codex --all${C.reset} to update installed Codex assets.`);
2618
+ }
2619
+
2221
2620
  async function cmdTags() {
2222
2621
  log(`\n${C.bold}tokrepo tags${C.reset}\n`);
2223
2622
 
@@ -2337,16 +2736,20 @@ ${C.bold}USAGE${C.reset}
2337
2736
  ${C.bold}DISCOVER & INSTALL${C.reset}
2338
2737
  ${C.cyan}search${C.reset} <query> Search assets by keyword
2339
2738
  ${C.cyan}detail${C.reset} <name|uuid> Show full asset metadata
2739
+ ${C.cyan}plan${C.reset} <name|uuid> Print agent-native Codex install plan
2340
2740
  ${C.cyan}install${C.reset} <name|uuid> Smart install (auto-detects type & placement)
2341
2741
  ${C.cyan}pull${C.reset} <url|uuid|@u/n> Download raw asset files
2342
2742
  ${C.cyan}clone${C.reset} @username Clone all assets from a user
2743
+ ${C.cyan}installed${C.reset} List installed Codex assets from manifest
2744
+ ${C.cyan}outdated${C.reset} Check installed Codex assets for updates
2343
2745
  ${C.cyan}sync-installed${C.reset} Update installed Codex assets from manifest
2344
2746
 
2345
2747
  ${C.bold}PUBLISH${C.reset}
2346
2748
  ${C.cyan}push${C.reset} [files...] Push files/directory (idempotent upsert)
2347
2749
  ${C.cyan}status${C.reset} Compare local vs remote (like git status)
2348
2750
  ${C.cyan}init${C.reset} Create .tokrepo.json project config
2349
- ${C.cyan}update${C.reset} <uuid> [f] Update existing asset
2751
+ ${C.cyan}update${C.reset} <uuid> [f] Update existing remote asset
2752
+ ${C.cyan}update${C.reset} --target codex --all Update installed Codex assets
2350
2753
  ${C.cyan}delete${C.reset} <uuid> Delete an asset
2351
2754
 
2352
2755
  ${C.bold}ACCOUNT${C.reset}
@@ -2362,6 +2765,9 @@ ${C.bold}PUSH OPTIONS${C.reset}
2362
2765
  ${C.cyan}--title${C.reset} "..." Set title (auto-detected from README or dir name)
2363
2766
  ${C.cyan}--desc${C.reset} "..." Set description
2364
2767
  ${C.cyan}--tag${C.reset} Skills Add tag (repeatable)
2768
+ ${C.cyan}--kind${C.reset} skill Set agent asset_kind
2769
+ ${C.cyan}--target${C.reset} codex Add target tool metadata on push
2770
+ ${C.cyan}--install-mode${C.reset} bundle Set install_mode metadata
2365
2771
 
2366
2772
  ${C.bold}INSTALL BEHAVIOR${C.reset}
2367
2773
  Skills → .claude/skills/ (if .claude/ exists)
@@ -2376,12 +2782,17 @@ ${C.bold}EXAMPLES${C.reset}
2376
2782
  tokrepo search "mcp server" # Find MCP configs
2377
2783
  tokrepo search video --json # Machine-readable search
2378
2784
  tokrepo detail ca000374-f5d8-... --json # Machine-readable detail
2785
+ tokrepo plan 91aeb22d-eff0-4310-... # Install plan v2 for agents
2379
2786
  tokrepo install ca000374-f5d8-... # Install by UUID
2380
2787
  tokrepo install ca000374-f5d8-... --target codex
2381
2788
  tokrepo install c4b18aeb --target gemini # Install for Gemini CLI
2382
2789
  tokrepo clone @henuwangkai --target codex --keyword video
2790
+ tokrepo installed --target codex --json
2791
+ tokrepo outdated --target codex --json
2792
+ tokrepo update --target codex --all
2383
2793
  tokrepo sync-installed --target codex --dry-run
2384
2794
  tokrepo push --private my-rules.md # Save one file privately
2795
+ tokrepo push . --kind skill --target codex --install-mode bundle
2385
2796
  tokrepo push --public skill.md # Share one file publicly
2386
2797
  tokrepo push --private . # Push current dir as private
2387
2798
  tokrepo push --public --title "My MCP" . # Push dir publicly with title
@@ -2451,6 +2862,23 @@ EXAMPLES
2451
2862
  `);
2452
2863
  }
2453
2864
 
2865
+ function showPlanHelp() {
2866
+ log(`
2867
+ ${C.bold}tokrepo plan${C.reset}
2868
+
2869
+ USAGE
2870
+ tokrepo plan <uuid|url|name> [--target codex] [--stage]
2871
+
2872
+ OUTPUT
2873
+ Machine-readable install plan v2 with preconditions, actions, policyDecision,
2874
+ rollback, postVerify, risk metadata, and destination file hashes.
2875
+
2876
+ EXAMPLES
2877
+ tokrepo plan 91aeb22d-eff0-4310-abc6-811d2394b420
2878
+ tokrepo plan https://tokrepo.com/en/workflows/91aeb22d-eff0-4310-abc6-811d2394b420
2879
+ `);
2880
+ }
2881
+
2454
2882
  function showListHelp() {
2455
2883
  log(`
2456
2884
  ${C.bold}tokrepo list${C.reset}
@@ -2484,6 +2912,9 @@ ${C.bold}tokrepo sync-installed${C.reset}
2484
2912
 
2485
2913
  USAGE
2486
2914
  tokrepo sync-installed --target codex [--dry-run] [--stage] [--update] [--approve-mcp] [--json]
2915
+ tokrepo installed --target codex [--json]
2916
+ tokrepo outdated --target codex [--json]
2917
+ tokrepo update --target codex --all [--stage] [--approve-mcp] [--json]
2487
2918
 
2488
2919
  BEHAVIOR
2489
2920
  Reads ~/.codex/tokrepo/install-manifest.json, fetches each TokRepo asset again,
@@ -2491,6 +2922,9 @@ BEHAVIOR
2491
2922
  changed or missing files. Use --update to force reinstall unchanged assets.
2492
2923
 
2493
2924
  EXAMPLES
2925
+ tokrepo installed --target codex --json
2926
+ tokrepo outdated --target codex --json
2927
+ tokrepo update --target codex --all
2494
2928
  tokrepo sync-installed --target codex --dry-run --json
2495
2929
  tokrepo sync-installed --target codex --stage
2496
2930
  tokrepo sync-installed --target codex --update --approve-mcp
@@ -2504,6 +2938,8 @@ function showCommandHelp(command) {
2504
2938
  showSearchHelp(); break;
2505
2939
  case 'detail':
2506
2940
  showDetailHelp(); break;
2941
+ case 'plan':
2942
+ showPlanHelp(); break;
2507
2943
  case 'install':
2508
2944
  case 'i':
2509
2945
  showInstallHelp(); break;
@@ -2513,6 +2949,8 @@ function showCommandHelp(command) {
2513
2949
  showCloneHelp(); break;
2514
2950
  case 'sync-installed':
2515
2951
  case 'sync':
2952
+ case 'installed':
2953
+ case 'outdated':
2516
2954
  showSyncInstalledHelp(); break;
2517
2955
  default:
2518
2956
  showHelp(); break;
@@ -2537,11 +2975,14 @@ async function main() {
2537
2975
  case 'pull': await cmdPull(); break;
2538
2976
  case 'search': case 'find': await cmdSearch(); break;
2539
2977
  case 'detail': await cmdDetail(); break;
2978
+ case 'plan': await cmdPlan(); break;
2540
2979
  case 'install': case 'i': await cmdInstall(); break;
2541
2980
  case 'list': await cmdList(); break;
2542
2981
  case 'update': await cmdUpdate(); break;
2543
2982
  case 'delete': await cmdDelete(); break;
2544
2983
  case 'clone': await cmdClone(); break;
2984
+ case 'installed': await cmdInstalled(); break;
2985
+ case 'outdated': await cmdOutdated(); break;
2545
2986
  case 'sync-installed': case 'sync': await cmdSyncInstalled(); break;
2546
2987
  case 'tags': await cmdTags(); break;
2547
2988
  case 'status': case 'diff': await cmdStatus(); break;
@@ -2555,7 +2996,7 @@ async function main() {
2555
2996
  }
2556
2997
 
2557
2998
  // Non-blocking update check after command completes
2558
- if (!wantsJson(process.argv) && !args.flags.help) {
2999
+ if (command !== 'plan' && !wantsJson(process.argv) && !args.flags.help) {
2559
3000
  checkForUpdate();
2560
3001
  }
2561
3002
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokrepo",
3
- "version": "3.4.0",
3
+ "version": "3.6.0",
4
4
  "description": "AI assets for humans and agents — search, install, push. Like GitHub, for AI experience.",
5
5
  "bin": {
6
6
  "tokrepo": "bin/tokrepo.js"