tokrepo 3.5.0 → 3.7.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 +986 -31
  2. package/package.json +1 -1
package/bin/tokrepo.js CHANGED
@@ -25,12 +25,13 @@ 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.5.0';
28
+ const CLI_VERSION = '3.7.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');
32
32
  const CODEX_TOKREPO_DIR = path.join(CODEX_DIR, 'tokrepo');
33
33
  const CODEX_MANIFEST_FILE = path.join(CODEX_TOKREPO_DIR, 'install-manifest.json');
34
+ const CODEX_SESSIONS_DIR = path.join(CODEX_TOKREPO_DIR, 'sessions');
34
35
  const SUPPORTED_INSTALL_TARGETS = ['gemini', 'codex'];
35
36
 
36
37
  // ─── Helpers ───
@@ -304,6 +305,7 @@ function parseArgs(argv) {
304
305
  'title', 'desc', 'tag', 'target', 'targets', 'keyword', 'types',
305
306
  'kind', 'install-mode', 'install_mode', 'entrypoint', 'asset-kind', 'asset_kind',
306
307
  'version',
308
+ 'policy', 'session',
307
309
  'page', 'page-size', 'page_size', 'sort-by', 'sort_by',
308
310
  'time-window', 'time_window',
309
311
  ]);
@@ -841,8 +843,23 @@ async function cmdSearch() {
841
843
  data = { ...data, list };
842
844
  }
843
845
 
846
+ const originalCount = (data.list || []).length;
847
+ data = { ...data, list: applyAgentWorkflowFilters(data.list || [], args.flags) };
848
+ const filters = {
849
+ target: args.flags.target || undefined,
850
+ kind: args.flags.kind || args.flags.assetKind || undefined,
851
+ policy: args.flags.policy || undefined,
852
+ };
853
+
844
854
  if (args.flags.json) {
845
- outputJson({ query, total: data.total || 0, count: (data.list || []).length, list: data.list || [] });
855
+ outputJson({
856
+ query,
857
+ total: data.total || 0,
858
+ fetched: originalCount,
859
+ count: (data.list || []).length,
860
+ filters,
861
+ list: data.list || [],
862
+ });
846
863
  return;
847
864
  }
848
865
 
@@ -859,7 +876,8 @@ async function cmdSearch() {
859
876
  return;
860
877
  }
861
878
 
862
- log(` ${C.bold}${data.total}${C.reset} results:\n`);
879
+ const filterText = [filters.target ? `target=${filters.target}` : '', filters.kind ? `kind=${filters.kind}` : '', filters.policy ? `policy=${filters.policy}` : ''].filter(Boolean).join(' · ');
880
+ log(` ${C.bold}${data.list.length}${C.reset} shown${filterText ? ` ${C.dim}(${filterText})${C.reset}` : ''}${data.total ? ` ${C.dim}from ${data.total} result(s)${C.reset}` : ''}:\n`);
863
881
 
864
882
  for (let i = 0; i < data.list.length; i++) {
865
883
  const wf = data.list[i];
@@ -874,6 +892,10 @@ async function cmdSearch() {
874
892
  log(` ${C.dim}${String(i + 1).padStart(2)}.${C.reset} ${C.bold}${wf.title}${C.reset}`);
875
893
  if (desc) log(` ${desc}`);
876
894
  if (tags) log(` ${C.cyan}${tags}${C.reset} ${C.dim}★${votes} 👁${views}${C.reset}`);
895
+ if (wf.compatibility?.codex) {
896
+ const c = wf.compatibility.codex;
897
+ log(` ${C.dim}codex: ${c.status} · policy=${c.policyDecision.decision} · kind=${c.assetKind || 'unknown'}${C.reset}`);
898
+ }
877
899
  log(` ${C.dim}tokrepo install ${wf.uuid}${C.reset}`);
878
900
  log('');
879
901
  }
@@ -984,6 +1006,40 @@ function getWorkflowAssetType(workflow) {
984
1006
  return (workflow.tags[0].slug || workflow.tags[0].name || '').toLowerCase();
985
1007
  }
986
1008
 
1009
+ function workflowAgentMetadata(workflow) {
1010
+ return workflow?.agent_metadata || workflow?.agentMetadata || {};
1011
+ }
1012
+
1013
+ function normalizeCodexInstallMode(mode) {
1014
+ const normalized = String(mode || '').trim().toLowerCase().replace(/-/g, '_');
1015
+ return ['single', 'bundle', 'split', 'stage_only'].includes(normalized) ? normalized : '';
1016
+ }
1017
+
1018
+ function workflowAssetKind(workflow) {
1019
+ const metadata = workflowAgentMetadata(workflow);
1020
+ const explicit = workflow?.asset_kind || workflow?.assetKind || metadata.asset_kind || metadata.assetKind || '';
1021
+ if (explicit) return normalizeToolName(explicit);
1022
+ const assetType = getWorkflowAssetType(workflow);
1023
+ const aliases = {
1024
+ skills: 'skill',
1025
+ prompts: 'prompt',
1026
+ knowledge: 'knowledge',
1027
+ 'mcp-configs': 'mcp_config',
1028
+ mcp: 'mcp_config',
1029
+ scripts: 'script',
1030
+ configs: 'config',
1031
+ tools: 'cli_tool',
1032
+ };
1033
+ return aliases[assetType] || normalizeToolName(assetType);
1034
+ }
1035
+
1036
+ function workflowTargetTools(workflow) {
1037
+ const metadata = workflowAgentMetadata(workflow);
1038
+ return parseCsvList(workflow?.target_tools || workflow?.targetTools || metadata.target_tools || metadata.targetTools)
1039
+ .map(normalizeToolName)
1040
+ .filter(Boolean);
1041
+ }
1042
+
987
1043
  function extractInstallableContents(workflow, assetType) {
988
1044
  const contents = [];
989
1045
  const files = workflow.files || [];
@@ -1129,8 +1185,7 @@ function explicitInstallMode(workflow) {
1129
1185
  workflow?.metadata?.installMode,
1130
1186
  workflow?.metadata?.install_mode,
1131
1187
  ].filter(Boolean);
1132
- const mode = String(candidates[0] || '').toLowerCase();
1133
- return ['single', 'bundle', 'split', 'stage_only'].includes(mode) ? mode : '';
1188
+ return normalizeCodexInstallMode(candidates[0]);
1134
1189
  }
1135
1190
 
1136
1191
  function inferCodexInstallMode(workflow, contents) {
@@ -1187,8 +1242,12 @@ function addPlanFile(plan, destPath, content, sourceName, type) {
1187
1242
  }
1188
1243
 
1189
1244
  function buildCodexInstallPlan(workflow, contents, opts = {}) {
1190
- const installMode = opts.installMode || inferCodexInstallMode(workflow, contents);
1191
- const agentMetadata = workflow?.agent_metadata || workflow?.agentMetadata || {};
1245
+ const serverPlan = opts.serverPlan || null;
1246
+ const serverMetadata = serverPlan?.metadata || serverPlan?.agentMetadata || serverPlan?.agent_metadata || {};
1247
+ const installMode = normalizeCodexInstallMode(opts.installMode)
1248
+ || normalizeCodexInstallMode(metadataValue(serverPlan, 'install_mode', 'installMode', ''))
1249
+ || inferCodexInstallMode(workflow, contents);
1250
+ const agentMetadata = Object.keys(serverMetadata || {}).length > 0 ? serverMetadata : workflowAgentMetadata(workflow);
1192
1251
  const plan = {
1193
1252
  uuid: workflow.uuid,
1194
1253
  title: workflow.title,
@@ -1199,7 +1258,8 @@ function buildCodexInstallPlan(workflow, contents, opts = {}) {
1199
1258
  files: [],
1200
1259
  risks: [],
1201
1260
  agentMetadata,
1202
- contentHash: workflow.content_hash || workflow.contentHash || agentMetadata.content_hash || '',
1261
+ contentHash: workflow.content_hash || workflow.contentHash || agentMetadata.content_hash || agentMetadata.contentHash || metadataValue(serverPlan, 'content_hash', 'contentHash', ''),
1262
+ serverPlan,
1203
1263
  };
1204
1264
 
1205
1265
  if (installMode === 'stage_only') {
@@ -1278,8 +1338,222 @@ function buildCodexInstallPlan(workflow, contents, opts = {}) {
1278
1338
  return plan;
1279
1339
  }
1280
1340
 
1341
+ function metadataValue(metadata, snakeName, camelName, fallback) {
1342
+ if (!metadata) return fallback;
1343
+ if (metadata[snakeName] !== undefined) return metadata[snakeName];
1344
+ if (metadata[camelName] !== undefined) return metadata[camelName];
1345
+ return fallback;
1346
+ }
1347
+
1348
+ function normalizeToolName(value) {
1349
+ return String(value || '').trim().toLowerCase().replace(/-/g, '_');
1350
+ }
1351
+
1352
+ function riskProfileFromFlags(flags = []) {
1353
+ const set = new Set(flags || []);
1354
+ return {
1355
+ executes_code: set.has('executable'),
1356
+ modifies_global_config: set.has('mcp'),
1357
+ requires_secrets: set.has('env') ? ['ENV'] : [],
1358
+ uses_absolute_paths: set.has('absolute-path'),
1359
+ network_access: set.has('network'),
1360
+ };
1361
+ }
1362
+
1363
+ function mergedPlanRiskProfile(plan) {
1364
+ const metadata = plan.agentMetadata || {};
1365
+ const rp = metadataValue(metadata, 'risk_profile', 'riskProfile', {}) || {};
1366
+ const flags = new Set(plan.risks || []);
1367
+ return {
1368
+ executes_code: Boolean(rp.executes_code || rp.executesCode || flags.has('executable')),
1369
+ modifies_global_config: Boolean(rp.modifies_global_config || rp.modifiesGlobalConfig || flags.has('mcp')),
1370
+ requires_secrets: rp.requires_secrets || rp.requiresSecrets || (flags.has('env') ? ['ENV'] : []),
1371
+ uses_absolute_paths: Boolean(rp.uses_absolute_paths || rp.usesAbsolutePaths || flags.has('absolute-path')),
1372
+ network_access: Boolean(rp.network_access || rp.networkAccess || flags.has('network')),
1373
+ };
1374
+ }
1375
+
1376
+ function policyDecisionFromServerPlan(plan) {
1377
+ const serverPlan = plan?.serverPlan;
1378
+ if (!serverPlan) return null;
1379
+ const raw = metadataValue(serverPlan, 'policy_decision', 'policyDecision', null);
1380
+ if (!raw) return null;
1381
+ if (typeof raw === 'string') {
1382
+ return {
1383
+ decision: raw,
1384
+ requiresConfirmation: raw === 'confirm',
1385
+ reasons: [],
1386
+ };
1387
+ }
1388
+ const decision = String(raw.decision || raw.action || 'allow').trim().toLowerCase();
1389
+ const requiresConfirmation = Boolean(
1390
+ raw.requires_confirmation
1391
+ || raw.requiresConfirmation
1392
+ || metadataValue(serverPlan, 'requires_confirmation', 'requiresConfirmation', false)
1393
+ );
1394
+ const reasons = raw.reasons || raw.reason || [];
1395
+ return {
1396
+ decision,
1397
+ requiresConfirmation,
1398
+ reasons: Array.isArray(reasons) ? reasons : [String(reasons)].filter(Boolean),
1399
+ };
1400
+ }
1401
+
1402
+ function decideCodexPolicy(plan) {
1403
+ const serverPolicy = policyDecisionFromServerPlan(plan);
1404
+ if (serverPolicy) return serverPolicy;
1405
+
1406
+ const metadata = plan.agentMetadata || {};
1407
+ const targetTools = metadataValue(metadata, 'target_tools', 'targetTools', []) || [];
1408
+ const assetKind = normalizeToolName(metadataValue(metadata, 'asset_kind', 'assetKind', ''));
1409
+ const risk = mergedPlanRiskProfile(plan);
1410
+ let decision = 'allow';
1411
+ const reasons = [];
1412
+ const raise = (next) => {
1413
+ const rank = { allow: 0, confirm: 1, stage_only: 2, deny: 3 };
1414
+ if ((rank[next] || 0) > (rank[decision] || 0)) decision = next;
1415
+ };
1416
+
1417
+ if (targetTools.length && !targetTools.map(normalizeToolName).includes('codex')) {
1418
+ raise('confirm');
1419
+ reasons.push('metadata target_tools does not include codex');
1420
+ }
1421
+ if (['script', 'cli_tool', 'mcp_config'].includes(assetKind)) {
1422
+ raise('stage_only');
1423
+ reasons.push(`asset_kind ${assetKind} is not activated directly for Codex`);
1424
+ }
1425
+ if (plan.installMode === 'stage_only') {
1426
+ raise('stage_only');
1427
+ reasons.push('install_mode is stage_only');
1428
+ }
1429
+ if (risk.executes_code) {
1430
+ raise('stage_only');
1431
+ reasons.push('risk_profile.executes_code is true');
1432
+ }
1433
+ if (risk.modifies_global_config) {
1434
+ raise('stage_only');
1435
+ reasons.push('risk_profile.modifies_global_config is true');
1436
+ }
1437
+ if ((risk.requires_secrets || []).length) {
1438
+ raise('stage_only');
1439
+ reasons.push('risk_profile.requires_secrets is not empty');
1440
+ }
1441
+ if (risk.uses_absolute_paths) {
1442
+ raise('confirm');
1443
+ reasons.push('risk_profile.uses_absolute_paths is true');
1444
+ }
1445
+ if (risk.network_access) {
1446
+ raise('confirm');
1447
+ reasons.push('risk_profile.network_access is true');
1448
+ }
1449
+ if (reasons.length === 0) reasons.push('safe markdown-only Codex install');
1450
+
1451
+ return {
1452
+ decision,
1453
+ requiresConfirmation: decision === 'confirm',
1454
+ reasons: Array.from(new Set(reasons)),
1455
+ };
1456
+ }
1457
+
1458
+ function buildPublicPlanActions(plan) {
1459
+ const serverActions = metadataValue(plan.serverPlan, 'actions', 'actions', null);
1460
+ if (serverConcretePlanMatchesLocal(plan) && Array.isArray(serverActions) && serverActions.length > 0) return serverActions;
1461
+
1462
+ const stage = plan.installMode === 'stage_only';
1463
+ return plan.files.map(file => ({
1464
+ type: stage ? 'stage_file' : 'write_file',
1465
+ path: file.path,
1466
+ sourceName: file.sourceName,
1467
+ sha256: file.sha256,
1468
+ bytes: file.bytes,
1469
+ ifExists: 'overwrite',
1470
+ entrypoint: path.basename(file.path).toLowerCase() === 'skill.md',
1471
+ risk: riskProfileFromFlags(file.riskFlags || []),
1472
+ }));
1473
+ }
1474
+
1475
+ function serverConcretePlanMatchesLocal(plan) {
1476
+ const serverActions = metadataValue(plan.serverPlan, 'actions', 'actions', null);
1477
+ if (!Array.isArray(serverActions) || serverActions.length !== (plan.files || []).length) return false;
1478
+ return serverActions.every((action, index) => {
1479
+ const file = plan.files[index];
1480
+ if (!file) return false;
1481
+ const serverPath = path.resolve(expandHomePath(action.path || ''));
1482
+ const localPath = path.resolve(file.path || '');
1483
+ const serverSha = action.sha256 || action.sha || '';
1484
+ return serverPath === localPath && (!serverSha || serverSha === file.sha256);
1485
+ });
1486
+ }
1487
+
1488
+ function buildPublicPlanPreconditions(plan, policyDecision) {
1489
+ const serverPreconditions = metadataValue(plan.serverPlan, 'preconditions', 'preconditions', null);
1490
+ if (Array.isArray(serverPreconditions) && serverPreconditions.length > 0) return serverPreconditions;
1491
+
1492
+ const metadata = plan.agentMetadata || {};
1493
+ const targetTools = metadataValue(metadata, 'target_tools', 'targetTools', []) || [];
1494
+ const out = [
1495
+ { type: 'target_supported', status: 'pass', message: 'codex install target is supported' },
1496
+ { type: 'install_root', status: 'pass', message: '~/.codex/skills for activated skills; ~/.codex/tokrepo/staged for staged assets' },
1497
+ ];
1498
+ if (!targetTools.length || targetTools.map(normalizeToolName).includes('codex')) {
1499
+ out.push({ type: 'target_tool_metadata', status: 'pass', message: 'metadata allows codex' });
1500
+ } else {
1501
+ out.push({ type: 'target_tool_metadata', status: 'warn', message: 'metadata target_tools does not include codex' });
1502
+ }
1503
+ out.push({
1504
+ type: 'content_hash',
1505
+ status: plan.contentHash ? 'pass' : 'warn',
1506
+ message: plan.contentHash ? 'asset metadata includes content_hash' : 'asset metadata does not include content_hash',
1507
+ });
1508
+ const policyStatus = policyDecision.decision === 'deny' ? 'block'
1509
+ : policyDecision.decision === 'allow' ? 'pass'
1510
+ : 'warn';
1511
+ out.push({ type: 'policy_decision', status: policyStatus, message: `${policyDecision.decision} for ${plan.uuid}` });
1512
+ return out;
1513
+ }
1514
+
1515
+ function buildPublicPlanRollback(plan) {
1516
+ const serverRollback = metadataValue(plan.serverPlan, 'rollback', 'rollback', null);
1517
+ if (serverConcretePlanMatchesLocal(plan) && Array.isArray(serverRollback) && serverRollback.length > 0) return serverRollback;
1518
+
1519
+ const seen = new Set();
1520
+ const rollback = [];
1521
+ for (const file of plan.files) {
1522
+ if (!file.path || seen.has(file.path)) continue;
1523
+ seen.add(file.path);
1524
+ rollback.push({ type: 'remove_file', path: file.path });
1525
+ }
1526
+ return rollback;
1527
+ }
1528
+
1529
+ function buildPublicPlanPostVerify(plan) {
1530
+ const serverPostVerify = metadataValue(plan.serverPlan, 'post_verify', 'postVerify', null);
1531
+ if (serverConcretePlanMatchesLocal(plan) && Array.isArray(serverPostVerify) && serverPostVerify.length > 0) return serverPostVerify;
1532
+
1533
+ const metadata = plan.agentMetadata || {};
1534
+ const verification = metadataValue(metadata, 'verification', 'verification', {}) || {};
1535
+ const out = plan.files.map(file => ({ type: 'file_sha256', path: file.path, sha256: file.sha256 }));
1536
+ const installedPaths = new Set(plan.files.map(file => path.resolve(file.path)));
1537
+ for (const expected of (verification.expected_files || verification.expectedFiles || [])) {
1538
+ const resolvedExpected = path.resolve(resolveVerifyPath(expected, { baseDir: plan.baseDir, files: plan.files }));
1539
+ if (installedPaths.has(resolvedExpected)) {
1540
+ out.push({ type: 'expected_file', path: expected });
1541
+ }
1542
+ }
1543
+ for (const command of (verification.commands || [])) {
1544
+ out.push({ type: 'command', command });
1545
+ }
1546
+ return out;
1547
+ }
1548
+
1281
1549
  function publicInstallPlan(plan) {
1550
+ const policyDecision = decideCodexPolicy(plan);
1551
+ const actions = buildPublicPlanActions(plan);
1552
+ const schemaVersion = Number(metadataValue(plan.serverPlan, 'schema_version', 'schemaVersion', 2)) || 2;
1282
1553
  return {
1554
+ schemaVersion,
1555
+ sourceOfTruth: plan.serverPlan ? 'api_install_plan_v2' : 'local_fallback',
1556
+ concretePlanSource: serverConcretePlanMatchesLocal(plan) ? 'api_install_plan_v2' : 'local_fallback',
1283
1557
  uuid: plan.uuid,
1284
1558
  title: plan.title,
1285
1559
  sourceUrl: plan.sourceUrl,
@@ -1288,7 +1562,12 @@ function publicInstallPlan(plan) {
1288
1562
  manifestPath: plan.manifestPath,
1289
1563
  baseDir: plan.baseDir,
1290
1564
  risks: plan.risks,
1291
- requiresConfirmation: hasCodexInstallRisks(plan),
1565
+ preconditions: buildPublicPlanPreconditions(plan, policyDecision),
1566
+ actions,
1567
+ policyDecision,
1568
+ requiresConfirmation: policyDecision.requiresConfirmation,
1569
+ rollback: buildPublicPlanRollback(plan),
1570
+ postVerify: buildPublicPlanPostVerify(plan),
1292
1571
  contentHash: plan.contentHash || '',
1293
1572
  agentMetadata: plan.agentMetadata || {},
1294
1573
  files: plan.files.map(file => ({
@@ -1302,8 +1581,98 @@ function publicInstallPlan(plan) {
1302
1581
  };
1303
1582
  }
1304
1583
 
1584
+ function workflowCodexCompatibility(workflow) {
1585
+ const metadata = workflowAgentMetadata(workflow);
1586
+ const assetKind = workflowAssetKind(workflow);
1587
+ const targetTools = workflowTargetTools(workflow);
1588
+ const installMode = normalizeCodexInstallMode(metadata.install_mode || metadata.installMode || workflow.install_mode || workflow.installMode) || 'single';
1589
+ const policy = decideCodexPolicy({
1590
+ agentMetadata: {
1591
+ ...metadata,
1592
+ asset_kind: assetKind,
1593
+ target_tools: targetTools,
1594
+ install_mode: installMode,
1595
+ },
1596
+ risks: [],
1597
+ installMode,
1598
+ });
1599
+ const scores = { allow: 100, confirm: 70, stage_only: 40, deny: 0 };
1600
+ const statuses = {
1601
+ allow: 'native',
1602
+ confirm: 'requires_confirmation',
1603
+ stage_only: 'stage_only',
1604
+ deny: 'denied',
1605
+ };
1606
+ return {
1607
+ targetTool: 'codex',
1608
+ status: statuses[policy.decision] || 'unknown',
1609
+ score: scores[policy.decision] ?? 50,
1610
+ assetKind,
1611
+ targetTools,
1612
+ installMode,
1613
+ policyDecision: policy,
1614
+ };
1615
+ }
1616
+
1617
+ function workflowMatchesAgentFilters(workflow, flags = {}) {
1618
+ const target = normalizeInstallTarget(flags.target || '');
1619
+ const requestedKinds = parseCsvList(flags.kind || flags.assetKind || flags.asset_kind).map(normalizeToolName);
1620
+ const requestedPolicies = parseCsvList(flags.policy).map(s => String(s).trim().toLowerCase());
1621
+ const assetKind = workflowAssetKind(workflow);
1622
+ const targetTools = workflowTargetTools(workflow);
1623
+ const compatibility = workflowCodexCompatibility(workflow);
1624
+
1625
+ if (target === 'codex') {
1626
+ if (targetTools.length > 0 && !targetTools.includes('codex')) return false;
1627
+ } else if (target && targetTools.length > 0 && !targetTools.includes(target)) {
1628
+ return false;
1629
+ }
1630
+
1631
+ if (requestedKinds.length > 0) {
1632
+ const kindAliases = new Set([assetKind, `${assetKind}s`, assetKind.replace(/_/g, '-')]);
1633
+ const tags = (workflow.tags || []).flatMap(t => [t.slug, t.name]).filter(Boolean).map(normalizeToolName);
1634
+ const matchesKind = requestedKinds.some(kind => kindAliases.has(kind) || tags.includes(kind) || tags.includes(`${kind}s`));
1635
+ if (!matchesKind) return false;
1636
+ }
1637
+
1638
+ if (requestedPolicies.length > 0) {
1639
+ const decision = compatibility.policyDecision.decision;
1640
+ const aliases = {
1641
+ safe: 'allow',
1642
+ staged: 'stage_only',
1643
+ stage: 'stage_only',
1644
+ block: 'deny',
1645
+ blocked: 'deny',
1646
+ };
1647
+ const normalizedPolicies = requestedPolicies.map(policy => aliases[policy] || policy);
1648
+ if (!normalizedPolicies.includes(decision)) return false;
1649
+ }
1650
+
1651
+ return true;
1652
+ }
1653
+
1654
+ function enrichWorkflowForAgent(workflow) {
1655
+ const compatibility = workflowCodexCompatibility(workflow);
1656
+ return {
1657
+ ...workflow,
1658
+ assetKind: compatibility.assetKind,
1659
+ targetTools: compatibility.targetTools,
1660
+ compatibility: {
1661
+ codex: compatibility,
1662
+ },
1663
+ policyDecision: compatibility.policyDecision,
1664
+ };
1665
+ }
1666
+
1667
+ function applyAgentWorkflowFilters(list, flags = {}) {
1668
+ const shouldEnrich = flags.target || flags.kind || flags.assetKind || flags.asset_kind || flags.policy;
1669
+ const filtered = (list || []).filter(item => workflowMatchesAgentFilters(item, flags));
1670
+ return shouldEnrich ? filtered.map(enrichWorkflowForAgent) : filtered;
1671
+ }
1672
+
1305
1673
  function hasCodexInstallRisks(plan) {
1306
- return (plan.risks || []).some(risk => ['mcp', 'executable', 'env', 'absolute-path'].includes(risk));
1674
+ const decision = decideCodexPolicy(plan).decision;
1675
+ return decision === 'confirm' || decision === 'stage_only' || decision === 'deny';
1307
1676
  }
1308
1677
 
1309
1678
  function formatRiskLine(file) {
@@ -1312,15 +1681,19 @@ function formatRiskLine(file) {
1312
1681
  }
1313
1682
 
1314
1683
  async function confirmCodexInstallRisks(plan, opts = {}) {
1684
+ const policy = decideCodexPolicy(plan);
1685
+ if (policy.decision === 'deny') {
1686
+ throw new Error(`Install policy denied this asset: ${policy.reasons.join('; ')}`);
1687
+ }
1315
1688
  if (plan.installMode === 'stage_only') return;
1316
- if (opts.dryRun || opts.stage || !hasCodexInstallRisks(plan)) return;
1689
+ if (opts.dryRun || opts.stage || policy.decision === 'allow') return;
1317
1690
  if (opts.approveMcp || opts.approve_mcp || opts.yes) return;
1318
1691
 
1319
1692
  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.`);
1693
+ 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.`);
1321
1694
  }
1322
1695
 
1323
- warn(`This asset contains ${plan.risks.join(', ')} content.`);
1696
+ warn(`Install policy is ${policy.decision}: ${policy.reasons.join('; ')}`);
1324
1697
  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
1698
  const riskyFiles = plan.files
1326
1699
  .map(formatRiskLine)
@@ -1375,6 +1748,102 @@ function executeStageOnlyCodexPlan(plan) {
1375
1748
  return { dryRun: true, staged: true, stageOnly: true, stagePath, plan: publicInstallPlan(plan), installedFiles };
1376
1749
  }
1377
1750
 
1751
+ function expandHomePath(input) {
1752
+ const value = String(input || '');
1753
+ if (value === '~') return os.homedir();
1754
+ if (value.startsWith('~/')) return path.join(os.homedir(), value.slice(2));
1755
+ return value;
1756
+ }
1757
+
1758
+ function resolveVerifyPath(checkPath, publicPlan) {
1759
+ const expanded = expandHomePath(checkPath);
1760
+ if (path.isAbsolute(expanded)) return expanded;
1761
+ const baseDir = publicPlan.baseDir || path.dirname(publicPlan.files?.[0]?.path || CODEX_SKILLS_DIR);
1762
+ return path.join(baseDir, expanded);
1763
+ }
1764
+
1765
+ function runCodexPostVerify(publicPlan, opts = {}) {
1766
+ const checks = [];
1767
+ let ok = true;
1768
+ for (const check of (publicPlan.postVerify || [])) {
1769
+ if (check.type === 'file_sha256') {
1770
+ const filePath = resolveVerifyPath(check.path, publicPlan);
1771
+ const exists = fs.existsSync(filePath);
1772
+ const actualSha = exists ? currentFileSha(filePath) : '';
1773
+ const passed = Boolean(exists && actualSha === check.sha256);
1774
+ if (!passed) ok = false;
1775
+ checks.push({ ...check, path: filePath, status: passed ? 'pass' : 'fail', actualSha });
1776
+ } else if (check.type === 'expected_file') {
1777
+ const filePath = resolveVerifyPath(check.path, publicPlan);
1778
+ const passed = fs.existsSync(filePath);
1779
+ if (!passed) ok = false;
1780
+ checks.push({ ...check, path: filePath, status: passed ? 'pass' : 'fail' });
1781
+ } else if (check.type === 'command') {
1782
+ if (!opts.verifyCommands) {
1783
+ checks.push({ ...check, status: 'skipped', message: 'command verification is opt-in; re-run with --verify-commands' });
1784
+ continue;
1785
+ }
1786
+ try {
1787
+ const childProcess = require('child_process');
1788
+ childProcess.execSync(String(check.command || ''), { stdio: 'pipe', shell: true, timeout: 30000 });
1789
+ checks.push({ ...check, status: 'pass' });
1790
+ } catch (e) {
1791
+ ok = false;
1792
+ checks.push({ ...check, status: 'fail', message: e.message });
1793
+ }
1794
+ } else {
1795
+ checks.push({ ...check, status: 'skipped', message: 'unknown verification check type' });
1796
+ }
1797
+ }
1798
+ return { ok, checks };
1799
+ }
1800
+
1801
+ function createCodexSessionId(operation = 'session') {
1802
+ const stamp = new Date().toISOString().replace(/[-:.]/g, '').replace('T', '-').replace('Z', '');
1803
+ const random = crypto.randomBytes(4).toString('hex');
1804
+ return `${slugify(operation, 'session')}-${stamp}-${random}`;
1805
+ }
1806
+
1807
+ function writeCodexSession(record) {
1808
+ if (!fs.existsSync(CODEX_SESSIONS_DIR)) {
1809
+ fs.mkdirSync(CODEX_SESSIONS_DIR, { recursive: true, mode: 0o700 });
1810
+ }
1811
+ const sessionId = record.sessionId || createCodexSessionId(record.operation || 'session');
1812
+ const sessionPath = path.join(CODEX_SESSIONS_DIR, `${sessionId}.json`);
1813
+ const payload = {
1814
+ schemaVersion: 1,
1815
+ sessionId,
1816
+ createdAt: new Date().toISOString(),
1817
+ cliVersion: CLI_VERSION,
1818
+ argv: process.argv.slice(2),
1819
+ ...record,
1820
+ sessionId,
1821
+ };
1822
+ fs.writeFileSync(sessionPath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
1823
+ return { sessionId, sessionPath };
1824
+ }
1825
+
1826
+ function readCodexSessions() {
1827
+ try {
1828
+ if (!fs.existsSync(CODEX_SESSIONS_DIR)) return [];
1829
+ return fs.readdirSync(CODEX_SESSIONS_DIR)
1830
+ .filter(name => name.endsWith('.json'))
1831
+ .map(name => {
1832
+ const sessionPath = path.join(CODEX_SESSIONS_DIR, name);
1833
+ try {
1834
+ const parsed = JSON.parse(fs.readFileSync(sessionPath, 'utf8'));
1835
+ return { ...parsed, sessionPath };
1836
+ } catch {
1837
+ return null;
1838
+ }
1839
+ })
1840
+ .filter(Boolean)
1841
+ .sort((a, b) => String(a.createdAt || '').localeCompare(String(b.createdAt || '')));
1842
+ } catch {
1843
+ return [];
1844
+ }
1845
+ }
1846
+
1378
1847
  function readCodexManifest() {
1379
1848
  try {
1380
1849
  const parsed = JSON.parse(fs.readFileSync(CODEX_MANIFEST_FILE, 'utf8'));
@@ -1383,7 +1852,15 @@ function readCodexManifest() {
1383
1852
  return { schemaVersion: 1, installs: [] };
1384
1853
  }
1385
1854
 
1386
- function writeCodexManifestRecord(plan, installedFiles) {
1855
+ function writeCodexManifest(manifest) {
1856
+ if (!fs.existsSync(CODEX_TOKREPO_DIR)) {
1857
+ fs.mkdirSync(CODEX_TOKREPO_DIR, { recursive: true, mode: 0o700 });
1858
+ }
1859
+ manifest.updatedAt = new Date().toISOString();
1860
+ fs.writeFileSync(CODEX_MANIFEST_FILE, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
1861
+ }
1862
+
1863
+ function writeCodexManifestRecord(plan, installedFiles, sessionInfo = {}, verification = null) {
1387
1864
  if (!fs.existsSync(CODEX_TOKREPO_DIR)) {
1388
1865
  fs.mkdirSync(CODEX_TOKREPO_DIR, { recursive: true, mode: 0o700 });
1389
1866
  }
@@ -1398,6 +1875,9 @@ function writeCodexManifestRecord(plan, installedFiles) {
1398
1875
  installedAt,
1399
1876
  contentHash: plan.contentHash || '',
1400
1877
  agentMetadata: plan.agentMetadata || {},
1878
+ sessionId: sessionInfo.sessionId,
1879
+ sessionPath: sessionInfo.sessionPath,
1880
+ verification,
1401
1881
  installedFiles: installedFiles.map(file => ({
1402
1882
  path: file.path,
1403
1883
  sourceName: file.sourceName,
@@ -1410,16 +1890,58 @@ function writeCodexManifestRecord(plan, installedFiles) {
1410
1890
  manifest.installs = manifest.installs.filter(item => !(item.uuid === plan.uuid && item.targetTool === 'codex'));
1411
1891
  manifest.installs.push(record);
1412
1892
  manifest.updatedAt = installedAt;
1413
- fs.writeFileSync(CODEX_MANIFEST_FILE, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
1893
+ writeCodexManifest(manifest);
1414
1894
  return record;
1415
1895
  }
1416
1896
 
1417
1897
  function executeCodexInstallPlan(plan, opts = {}) {
1418
- if (opts.dryRun) return { dryRun: true, plan: publicInstallPlan(plan), installedFiles: [] };
1419
- if (plan.installMode === 'stage_only') return executeStageOnlyCodexPlan(plan);
1898
+ const publicPlan = publicInstallPlan(plan);
1899
+ if (opts.dryRun) {
1900
+ const session = writeCodexSession({
1901
+ operation: 'install',
1902
+ status: 'dry_run',
1903
+ targetTool: 'codex',
1904
+ uuid: plan.uuid,
1905
+ title: plan.title,
1906
+ sourceUrl: plan.sourceUrl,
1907
+ policyDecision: publicPlan.policyDecision,
1908
+ plan: publicPlan,
1909
+ result: { dryRun: true, installedFiles: [] },
1910
+ });
1911
+ return { dryRun: true, plan: publicPlan, installedFiles: [], ...session };
1912
+ }
1913
+ if (plan.installMode === 'stage_only') {
1914
+ const result = executeStageOnlyCodexPlan(plan);
1915
+ const verification = runCodexPostVerify(result.plan, opts);
1916
+ const session = writeCodexSession({
1917
+ operation: 'install',
1918
+ status: 'stage_only',
1919
+ targetTool: 'codex',
1920
+ uuid: plan.uuid,
1921
+ title: plan.title,
1922
+ sourceUrl: plan.sourceUrl,
1923
+ policyDecision: result.plan.policyDecision,
1924
+ plan: result.plan,
1925
+ installedFiles: result.installedFiles,
1926
+ verification,
1927
+ result: { staged: true, stageOnly: true, stagePath: result.stagePath },
1928
+ });
1929
+ return { ...result, verification, ...session };
1930
+ }
1420
1931
  if (opts.stage) {
1421
1932
  const stagePath = stageCodexInstallPlan(plan);
1422
- return { dryRun: true, staged: true, stagePath, plan: publicInstallPlan(plan), installedFiles: [] };
1933
+ const session = writeCodexSession({
1934
+ operation: 'install',
1935
+ status: 'staged',
1936
+ targetTool: 'codex',
1937
+ uuid: plan.uuid,
1938
+ title: plan.title,
1939
+ sourceUrl: plan.sourceUrl,
1940
+ policyDecision: publicPlan.policyDecision,
1941
+ plan: publicPlan,
1942
+ result: { staged: true, stagePath },
1943
+ });
1944
+ return { dryRun: true, staged: true, stagePath, plan: publicPlan, installedFiles: [], ...session };
1423
1945
  }
1424
1946
 
1425
1947
  const installedFiles = [];
@@ -1439,8 +1961,22 @@ function executeCodexInstallPlan(plan, opts = {}) {
1439
1961
  });
1440
1962
  }
1441
1963
 
1442
- const manifestRecord = writeCodexManifestRecord(plan, installedFiles);
1443
- return { dryRun: false, plan: publicInstallPlan(plan), installedFiles, manifestRecord };
1964
+ const verification = runCodexPostVerify(publicPlan, opts);
1965
+ const session = writeCodexSession({
1966
+ operation: 'install',
1967
+ status: 'installed',
1968
+ targetTool: 'codex',
1969
+ uuid: plan.uuid,
1970
+ title: plan.title,
1971
+ sourceUrl: plan.sourceUrl,
1972
+ policyDecision: publicPlan.policyDecision,
1973
+ plan: publicPlan,
1974
+ installedFiles,
1975
+ verification,
1976
+ result: { installedFiles },
1977
+ });
1978
+ const manifestRecord = writeCodexManifestRecord(plan, installedFiles, session, verification);
1979
+ return { dryRun: false, plan: publicPlan, installedFiles, manifestRecord, verification, ...session };
1444
1980
  }
1445
1981
 
1446
1982
  async function installCodexAsset(workflow, contents, opts = {}) {
@@ -1468,6 +2004,7 @@ async function cmdInstall() {
1468
2004
  dryRun: Boolean(args.flags.dryRun || args.flags.dry_run),
1469
2005
  stage: Boolean(args.flags.stage),
1470
2006
  approveMcp: Boolean(args.flags.approveMcp || args.flags.approve_mcp),
2007
+ verifyCommands: Boolean(args.flags.verify_commands || args.flags.verifyCommands),
1471
2008
  json: Boolean(args.flags.json),
1472
2009
  manifest: Boolean(args.flags.manifest),
1473
2010
  };
@@ -1488,6 +2025,33 @@ async function cmdInstall() {
1488
2025
  }
1489
2026
  }
1490
2027
 
2028
+ async function cmdPlan() {
2029
+ const args = parseArgs(process.argv);
2030
+ const target = args.positional[0];
2031
+ if (!target) {
2032
+ showPlanHelp();
2033
+ process.exit(1);
2034
+ }
2035
+
2036
+ const config = readConfig();
2037
+ const apiBase = config?.api || DEFAULT_API;
2038
+ const targetTool = validateInstallTarget(args.flags.target || 'codex');
2039
+ if (targetTool !== 'codex') {
2040
+ error(`plan currently supports --target codex only`);
2041
+ }
2042
+ const result = await installOneAsset(target, config, apiBase, {
2043
+ targetTool,
2044
+ dryRun: true,
2045
+ stage: Boolean(args.flags.stage),
2046
+ installMode: args.flags.installMode,
2047
+ json: true,
2048
+ manifest: true,
2049
+ throwOnError: true,
2050
+ });
2051
+
2052
+ outputJson(result.plan);
2053
+ }
2054
+
1491
2055
  // Install all assets in a theme pack — sequentially, continue past per-item errors
1492
2056
  async function installPack(slug, config, apiBase, opts) {
1493
2057
  info(`Fetching pack ${C.bold}${slug}${C.reset}...`);
@@ -1617,7 +2181,8 @@ async function installOneAsset(target, config, apiBase, opts) {
1617
2181
  if (targetTool === 'codex') {
1618
2182
  let result;
1619
2183
  try {
1620
- result = await installCodexAsset(workflow, contents, opts);
2184
+ const serverPlan = opts.serverPlan !== undefined ? opts.serverPlan : await fetchServerCodexInstallPlan(uuid, config, apiBase);
2185
+ result = await installCodexAsset(workflow, contents, { ...opts, serverPlan });
1621
2186
  } catch (e) {
1622
2187
  die(e.message);
1623
2188
  }
@@ -1631,6 +2196,7 @@ async function installOneAsset(target, config, apiBase, opts) {
1631
2196
  } else {
1632
2197
  info(`No Codex skill files were written. Re-run with --approve-mcp or --yes to install.`);
1633
2198
  }
2199
+ if (result.sessionPath) log(` ${C.dim}Session: ${result.sessionPath}${C.reset}`);
1634
2200
  } else if (opts.dryRun) {
1635
2201
  info(`Dry run: ${plan.files.length} file(s) would be installed to ${CODEX_SKILLS_DIR}`);
1636
2202
  for (const file of plan.files) {
@@ -1638,6 +2204,7 @@ async function installOneAsset(target, config, apiBase, opts) {
1638
2204
  log(` ${C.dim}•${C.reset} ~/${rel}`);
1639
2205
  if (file.riskFlags.length) log(` ${C.yellow}${file.riskFlags.join(', ')}${C.reset}`);
1640
2206
  }
2207
+ if (result.sessionPath) log(` ${C.dim}Session: ${result.sessionPath}${C.reset}`);
1641
2208
  } else {
1642
2209
  for (const file of result.installedFiles) {
1643
2210
  const relPath = path.relative(os.homedir(), file.path);
@@ -1646,6 +2213,8 @@ async function installOneAsset(target, config, apiBase, opts) {
1646
2213
  log('');
1647
2214
  success(`${result.installedFiles.length} file(s) installed from ${C.bold}${workflow.title}${C.reset}`);
1648
2215
  log(` ${C.dim}Manifest: ${CODEX_MANIFEST_FILE}${C.reset}`);
2216
+ if (result.sessionPath) log(` ${C.dim}Session: ${result.sessionPath}${C.reset}`);
2217
+ if (result.verification && !result.verification.ok) log(` ${C.yellow}Verification: failed${C.reset}`);
1649
2218
  log(` ${C.dim}Source: https://tokrepo.com/en/workflows/${uuid}${C.reset}\n`);
1650
2219
  }
1651
2220
  }
@@ -1661,6 +2230,9 @@ async function installOneAsset(target, config, apiBase, opts) {
1661
2230
  installedFiles: result.installedFiles || [],
1662
2231
  plan: result.plan,
1663
2232
  manifestPath: CODEX_MANIFEST_FILE,
2233
+ sessionId: result.sessionId,
2234
+ sessionPath: result.sessionPath,
2235
+ verification: result.verification,
1664
2236
  };
1665
2237
  }
1666
2238
 
@@ -1822,8 +2394,16 @@ async function cmdList() {
1822
2394
  data = { ...data, list };
1823
2395
  }
1824
2396
 
2397
+ const originalCount = (data.list || []).length;
2398
+ data = { ...data, list: applyAgentWorkflowFilters(data.list || [], args.flags) };
2399
+ const filters = {
2400
+ target: args.flags.target || undefined,
2401
+ kind: args.flags.kind || args.flags.assetKind || undefined,
2402
+ policy: args.flags.policy || undefined,
2403
+ };
2404
+
1825
2405
  if (args.flags.json) {
1826
- outputJson({ total: data.total || 0, count: (data.list || []).length, list: data.list || [] });
2406
+ outputJson({ total: data.total || 0, fetched: originalCount, count: (data.list || []).length, filters, list: data.list || [] });
1827
2407
  return;
1828
2408
  }
1829
2409
 
@@ -1832,11 +2412,16 @@ async function cmdList() {
1832
2412
  return;
1833
2413
  }
1834
2414
 
1835
- log(` ${C.bold}${data.total}${C.reset} assets:\n`);
2415
+ const filterText = [filters.target ? `target=${filters.target}` : '', filters.kind ? `kind=${filters.kind}` : '', filters.policy ? `policy=${filters.policy}` : ''].filter(Boolean).join(' · ');
2416
+ log(` ${C.bold}${data.list.length}${C.reset} assets${filterText ? ` ${C.dim}(${filterText})${C.reset}` : ''}${data.total ? ` ${C.dim}from ${data.total}${C.reset}` : ''}:\n`);
1836
2417
 
1837
2418
  for (const wf of data.list) {
1838
2419
  const views = wf.view_count || 0;
1839
2420
  log(` ${C.cyan}${wf.uuid.substring(0,8)}${C.reset} ${C.bold}${wf.title}${C.reset}`);
2421
+ if (wf.compatibility?.codex) {
2422
+ const c = wf.compatibility.codex;
2423
+ log(` ${C.dim} codex=${c.status} · policy=${c.policyDecision.decision} · kind=${c.assetKind || 'unknown'}${C.reset}`);
2424
+ }
1840
2425
  log(` ${C.dim} ${views} views · https://tokrepo.com/en/workflows/${wf.uuid}${C.reset}\n`);
1841
2426
  }
1842
2427
  } catch (e) {
@@ -2046,13 +2631,16 @@ async function cmdClone() {
2046
2631
  const assetType = getWorkflowAssetType(workflow);
2047
2632
  const contents = extractInstallableContents(workflow, assetType);
2048
2633
  if (contents.length === 0) throw new Error('No installable content found');
2634
+ const serverPlan = await fetchServerCodexInstallPlan(workflow.uuid, config, apiBase);
2049
2635
  const result = await installCodexAsset(workflow, contents, {
2050
2636
  ...args.flags,
2051
2637
  dryRun,
2052
2638
  stage: Boolean(args.flags.stage),
2053
2639
  approveMcp: Boolean(args.flags.approveMcp || args.flags.approve_mcp),
2640
+ verifyCommands: Boolean(args.flags.verify_commands || args.flags.verifyCommands),
2054
2641
  json: true,
2055
2642
  throwOnError: true,
2643
+ serverPlan,
2056
2644
  });
2057
2645
  if (!dryRun) installedCount += result.installedFiles.length;
2058
2646
  results.push({
@@ -2065,6 +2653,9 @@ async function cmdClone() {
2065
2653
  files: result.plan.files,
2066
2654
  installedFiles: result.installedFiles || [],
2067
2655
  risks: result.plan.risks,
2656
+ sessionId: result.sessionId,
2657
+ sessionPath: result.sessionPath,
2658
+ verification: result.verification,
2068
2659
  });
2069
2660
  if (!json) {
2070
2661
  const fileCount = (dryRun || args.flags.stage) ? result.plan.files.length : result.installedFiles.length;
@@ -2193,6 +2784,15 @@ async function fetchWorkflowForInstall(uuid, config, apiBase) {
2193
2784
  return { workflow, contents };
2194
2785
  }
2195
2786
 
2787
+ async function fetchServerCodexInstallPlan(uuid, config, apiBase) {
2788
+ try {
2789
+ const data = await apiRequest('GET', `/api/v1/tokenboard/workflows/install-plan?uuid=${encodeURIComponent(uuid)}&target=codex`, null, config?.token, apiBase);
2790
+ return data?.plan || data || null;
2791
+ } catch {
2792
+ return null;
2793
+ }
2794
+ }
2795
+
2196
2796
  async function cmdSyncInstalled() {
2197
2797
  const args = parseArgs(process.argv);
2198
2798
  const targetTool = validateInstallTarget(args.flags.target || 'codex');
@@ -2227,7 +2827,8 @@ async function cmdSyncInstalled() {
2227
2827
 
2228
2828
  try {
2229
2829
  const { workflow, contents } = await fetchWorkflowForInstall(uuid, config, apiBase);
2230
- const plan = buildCodexInstallPlan(workflow, contents, { installMode: record.installMode || record.install_mode });
2830
+ const serverPlan = await fetchServerCodexInstallPlan(uuid, config, apiBase);
2831
+ const plan = buildCodexInstallPlan(workflow, contents, { installMode: record.installMode || record.install_mode, serverPlan });
2231
2832
  const diff = diffCodexPlanWithLocal(plan, record);
2232
2833
  const shouldWrite = force || diff.needsUpdate;
2233
2834
 
@@ -2265,8 +2866,10 @@ async function cmdSyncInstalled() {
2265
2866
  stage,
2266
2867
  installMode: record.installMode || record.install_mode,
2267
2868
  approveMcp: Boolean(args.flags.approveMcp || args.flags.approve_mcp),
2869
+ verifyCommands: Boolean(args.flags.verify_commands || args.flags.verifyCommands),
2268
2870
  json: true,
2269
2871
  throwOnError: true,
2872
+ serverPlan,
2270
2873
  });
2271
2874
 
2272
2875
  results.push({
@@ -2278,6 +2881,9 @@ async function cmdSyncInstalled() {
2278
2881
  stagePath: installResult.stagePath,
2279
2882
  installedFiles: installResult.installedFiles || [],
2280
2883
  plan: installResult.plan,
2884
+ sessionId: installResult.sessionId,
2885
+ sessionPath: installResult.sessionPath,
2886
+ verification: installResult.verification,
2281
2887
  });
2282
2888
  if (!json) success(stage ? `Staged ${installResult.plan.files.length} file(s)` : `Updated ${(installResult.installedFiles || []).length} file(s)`);
2283
2889
  } catch (e) {
@@ -2336,6 +2942,8 @@ async function cmdInstalled() {
2336
2942
  installMode: record.installMode || record.install_mode,
2337
2943
  installedAt: record.installedAt || record.installed_at,
2338
2944
  contentHash: record.contentHash || record.content_hash || '',
2945
+ sessionId: record.sessionId || record.session_id,
2946
+ sessionPath: record.sessionPath || record.session_path,
2339
2947
  risks: record.risks || [],
2340
2948
  files,
2341
2949
  status: files.some(file => !file.exists) ? 'missing-files' : files.some(file => file.changed) ? 'local-changes' : 'installed',
@@ -2359,6 +2967,281 @@ async function cmdInstalled() {
2359
2967
  }
2360
2968
  }
2361
2969
 
2970
+ function isCodexManagedPath(filePath) {
2971
+ const resolved = path.resolve(expandHomePath(filePath));
2972
+ return ensureInside(CODEX_SKILLS_DIR, resolved) || ensureInside(path.join(CODEX_TOKREPO_DIR, 'staged'), resolved);
2973
+ }
2974
+
2975
+ function removeEmptyCodexDirs(startDir) {
2976
+ const roots = [CODEX_SKILLS_DIR, path.join(CODEX_TOKREPO_DIR, 'staged')].map(root => path.resolve(root));
2977
+ let dir = path.resolve(startDir);
2978
+ const root = roots.find(candidate => dir === candidate || dir.startsWith(candidate + path.sep));
2979
+ if (!root) return;
2980
+ while (dir !== root && dir.startsWith(root + path.sep)) {
2981
+ try {
2982
+ if (!fs.existsSync(dir) || fs.readdirSync(dir).length > 0) break;
2983
+ fs.rmdirSync(dir);
2984
+ } catch {
2985
+ break;
2986
+ }
2987
+ dir = path.dirname(dir);
2988
+ }
2989
+ }
2990
+
2991
+ function findCodexManifestRecord(selector) {
2992
+ const manifest = readCodexManifest();
2993
+ const records = (manifest.installs || []).filter(item => (item.targetTool || item.target_tool) === 'codex');
2994
+ const needle = String(selector || '').trim();
2995
+ if (!needle) return null;
2996
+ const lower = needle.toLowerCase();
2997
+ const exact = records.find(record => String(record.uuid || '').toLowerCase() === lower);
2998
+ if (exact) return exact;
2999
+
3000
+ const prefixMatches = /^[a-f0-9-]{8,}$/i.test(needle)
3001
+ ? records.filter(record => String(record.uuid || '').toLowerCase().startsWith(lower))
3002
+ : [];
3003
+ if (prefixMatches.length === 1) return prefixMatches[0];
3004
+ if (prefixMatches.length > 1) throw new Error(`Multiple installed assets match "${selector}". Use the full UUID.`);
3005
+
3006
+ const slugNeedle = slugify(needle, '');
3007
+ const titleMatches = records.filter(record => {
3008
+ const title = String(record.title || '').toLowerCase();
3009
+ const sourceUrl = String(record.sourceUrl || record.source_url || '').toLowerCase();
3010
+ return title === lower || slugify(record.title || '', '') === slugNeedle || sourceUrl.includes(lower);
3011
+ });
3012
+ if (titleMatches.length === 1) return titleMatches[0];
3013
+ if (titleMatches.length > 1) throw new Error(`Multiple installed assets match "${selector}". Use the UUID.`);
3014
+
3015
+ const fuzzy = records.filter(record => String(record.title || '').toLowerCase().includes(lower));
3016
+ if (fuzzy.length === 1) return fuzzy[0];
3017
+ if (fuzzy.length > 1) throw new Error(`Multiple installed assets match "${selector}". Use the UUID.`);
3018
+ return null;
3019
+ }
3020
+
3021
+ function buildCodexRemovalPlan(record, files, opts = {}) {
3022
+ const actions = (files || []).map(file => {
3023
+ const filePath = path.resolve(expandHomePath(file.path));
3024
+ const exists = fs.existsSync(filePath);
3025
+ const actualSha = exists ? currentFileSha(filePath) : '';
3026
+ const expectedSha = file.sha256 || '';
3027
+ const changed = Boolean(exists && expectedSha && actualSha !== expectedSha);
3028
+ const managed = isCodexManagedPath(filePath);
3029
+ const allowed = managed && (!changed || opts.force);
3030
+ const reason = !managed ? 'outside-managed-roots'
3031
+ : changed && !opts.force ? 'local-changes'
3032
+ : exists ? 'remove'
3033
+ : 'already-missing';
3034
+ return {
3035
+ type: 'remove_file',
3036
+ path: filePath,
3037
+ sourceName: file.sourceName || file.source_name,
3038
+ expectedSha,
3039
+ actualSha,
3040
+ exists,
3041
+ changed,
3042
+ allowed,
3043
+ reason,
3044
+ };
3045
+ });
3046
+ return {
3047
+ schemaVersion: 1,
3048
+ operation: opts.operation || 'uninstall',
3049
+ targetTool: 'codex',
3050
+ uuid: record.uuid,
3051
+ title: record.title,
3052
+ sourceUrl: record.sourceUrl || record.source_url,
3053
+ manifestPath: CODEX_MANIFEST_FILE,
3054
+ force: Boolean(opts.force),
3055
+ dryRun: Boolean(opts.dryRun),
3056
+ requiresConfirmation: actions.some(action => !action.allowed),
3057
+ actions,
3058
+ };
3059
+ }
3060
+
3061
+ function executeCodexRemovalPlan(plan, opts = {}) {
3062
+ const blocked = plan.actions.filter(action => !action.allowed);
3063
+ if (blocked.length > 0) {
3064
+ const first = blocked[0];
3065
+ throw new Error(`Refusing to remove ${first.path}: ${first.reason}. Use --force only if you want to remove local changes.`);
3066
+ }
3067
+
3068
+ const removedFiles = [];
3069
+ const skippedFiles = [];
3070
+ for (const action of plan.actions) {
3071
+ if (!action.exists) {
3072
+ skippedFiles.push({ path: action.path, reason: 'already-missing' });
3073
+ continue;
3074
+ }
3075
+ fs.unlinkSync(action.path);
3076
+ removedFiles.push({ path: action.path, sha256: action.actualSha || action.expectedSha });
3077
+ removeEmptyCodexDirs(path.dirname(action.path));
3078
+ }
3079
+
3080
+ const session = writeCodexSession({
3081
+ operation: plan.operation,
3082
+ status: plan.operation === 'rollback' ? 'rolled_back' : 'uninstalled',
3083
+ targetTool: 'codex',
3084
+ uuid: plan.uuid,
3085
+ title: plan.title,
3086
+ sourceUrl: plan.sourceUrl,
3087
+ plan,
3088
+ result: { removedFiles, skippedFiles },
3089
+ });
3090
+
3091
+ if (opts.removeManifest !== false && plan.uuid) {
3092
+ const manifest = readCodexManifest();
3093
+ manifest.installs = (manifest.installs || []).filter(item => !((item.targetTool || item.target_tool) === 'codex' && item.uuid === plan.uuid));
3094
+ writeCodexManifest(manifest);
3095
+ }
3096
+
3097
+ return { dryRun: false, plan, removedFiles, skippedFiles, ...session };
3098
+ }
3099
+
3100
+ async function cmdUninstall() {
3101
+ const args = parseArgs(process.argv);
3102
+ const target = args.positional[0];
3103
+ if (!target) {
3104
+ showUninstallHelp();
3105
+ process.exit(1);
3106
+ }
3107
+ const targetTool = validateInstallTarget(args.flags.target || 'codex');
3108
+ if (targetTool !== 'codex') error(`uninstall currently supports --target codex only`);
3109
+
3110
+ const json = Boolean(args.flags.json);
3111
+ const dryRun = Boolean(args.flags.dryRun || args.flags.dry_run);
3112
+ const force = Boolean(args.flags.force);
3113
+ if (!json) log(`\n${C.bold}tokrepo uninstall${C.reset}\n`);
3114
+
3115
+ try {
3116
+ const record = findCodexManifestRecord(target);
3117
+ if (!record) error(`No installed Codex asset found for "${target}". Run: tokrepo installed --target codex`);
3118
+ const files = record.installedFiles || record.installed_files || [];
3119
+ const plan = buildCodexRemovalPlan(record, files, { operation: 'uninstall', dryRun, force });
3120
+ if (dryRun) {
3121
+ const session = writeCodexSession({
3122
+ operation: 'uninstall',
3123
+ status: 'dry_run',
3124
+ targetTool: 'codex',
3125
+ uuid: record.uuid,
3126
+ title: record.title,
3127
+ sourceUrl: record.sourceUrl || record.source_url,
3128
+ plan,
3129
+ result: { dryRun: true },
3130
+ });
3131
+ const response = { dryRun: true, plan, removedFiles: [], ...session };
3132
+ if (json) outputJson(response);
3133
+ else {
3134
+ info(`Dry run: ${plan.actions.length} file(s) would be removed`);
3135
+ for (const action of plan.actions) {
3136
+ const rel = path.relative(os.homedir(), action.path);
3137
+ log(` ${action.allowed ? C.dim : C.yellow}•${C.reset} ~/${rel} ${C.dim}${action.reason}${C.reset}`);
3138
+ }
3139
+ log(` ${C.dim}Session: ${session.sessionPath}${C.reset}`);
3140
+ }
3141
+ return;
3142
+ }
3143
+
3144
+ const result = executeCodexRemovalPlan(plan, { force });
3145
+ if (json) outputJson(result);
3146
+ else {
3147
+ for (const file of result.removedFiles) {
3148
+ success(`Removed: ~/${path.relative(os.homedir(), file.path)}`);
3149
+ }
3150
+ success(`Uninstalled ${record.title || record.uuid}`);
3151
+ log(` ${C.dim}Manifest: ${CODEX_MANIFEST_FILE}${C.reset}`);
3152
+ log(` ${C.dim}Session: ${result.sessionPath}${C.reset}\n`);
3153
+ }
3154
+ } catch (e) {
3155
+ error(`Uninstall failed: ${e.message}`);
3156
+ }
3157
+ }
3158
+
3159
+ function findRollbackSession(selector) {
3160
+ const sessions = readCodexSessions();
3161
+ if (selector === 'last') {
3162
+ return [...sessions].reverse().find(session => (
3163
+ session.operation === 'install'
3164
+ && ['installed', 'staged', 'stage_only'].includes(session.status)
3165
+ && (session.installedFiles?.length || session.result?.stagePath || session.plan?.rollback?.length)
3166
+ ));
3167
+ }
3168
+ const needle = String(selector || '').trim();
3169
+ if (!needle) return null;
3170
+ return sessions.find(session => session.sessionId === needle || String(session.sessionId || '').startsWith(needle));
3171
+ }
3172
+
3173
+ function filesFromRollbackSession(session) {
3174
+ if (!session) return [];
3175
+ if (session.status === 'staged' && session.result?.stagePath) {
3176
+ return [{ path: session.result.stagePath, sha256: currentFileSha(session.result.stagePath), sourceName: 'install-plan.json' }];
3177
+ }
3178
+ if (Array.isArray(session.installedFiles) && session.installedFiles.length > 0) return session.installedFiles;
3179
+ return (session.plan?.rollback || [])
3180
+ .filter(action => action.type === 'remove_file' && action.path)
3181
+ .map(action => ({ path: action.path, sha256: action.sha256 || '', sourceName: path.basename(action.path) }));
3182
+ }
3183
+
3184
+ async function cmdRollback() {
3185
+ const args = parseArgs(process.argv);
3186
+ const selector = args.flags.last ? 'last' : (args.flags.session || args.positional[0]);
3187
+ if (!selector) {
3188
+ showRollbackHelp();
3189
+ process.exit(1);
3190
+ }
3191
+ const targetTool = validateInstallTarget(args.flags.target || 'codex');
3192
+ if (targetTool !== 'codex') error(`rollback currently supports --target codex only`);
3193
+
3194
+ const json = Boolean(args.flags.json);
3195
+ const dryRun = Boolean(args.flags.dryRun || args.flags.dry_run);
3196
+ const force = Boolean(args.flags.force);
3197
+ if (!json) log(`\n${C.bold}tokrepo rollback${C.reset}\n`);
3198
+
3199
+ try {
3200
+ const session = findRollbackSession(selector);
3201
+ if (!session) error(`No rollback session found for "${selector}". Run: tokrepo installed --target codex --json`);
3202
+ const files = filesFromRollbackSession(session);
3203
+ const plan = buildCodexRemovalPlan(session, files, { operation: 'rollback', dryRun, force });
3204
+ plan.rollbackSessionId = session.sessionId;
3205
+ plan.rollbackSessionPath = session.sessionPath;
3206
+
3207
+ if (dryRun) {
3208
+ const audit = writeCodexSession({
3209
+ operation: 'rollback',
3210
+ status: 'dry_run',
3211
+ targetTool: 'codex',
3212
+ uuid: session.uuid,
3213
+ title: session.title,
3214
+ sourceUrl: session.sourceUrl,
3215
+ plan,
3216
+ result: { dryRun: true },
3217
+ });
3218
+ const response = { dryRun: true, plan, removedFiles: [], ...audit };
3219
+ if (json) outputJson(response);
3220
+ else {
3221
+ info(`Dry run: rollback ${session.sessionId} would remove ${plan.actions.length} file(s)`);
3222
+ for (const action of plan.actions) {
3223
+ const rel = path.relative(os.homedir(), action.path);
3224
+ log(` ${action.allowed ? C.dim : C.yellow}•${C.reset} ~/${rel} ${C.dim}${action.reason}${C.reset}`);
3225
+ }
3226
+ log(` ${C.dim}Session: ${audit.sessionPath}${C.reset}`);
3227
+ }
3228
+ return;
3229
+ }
3230
+
3231
+ const result = executeCodexRemovalPlan(plan, { force, removeManifest: Boolean(session.uuid) });
3232
+ if (json) outputJson(result);
3233
+ else {
3234
+ for (const file of result.removedFiles) {
3235
+ success(`Removed: ~/${path.relative(os.homedir(), file.path)}`);
3236
+ }
3237
+ success(`Rolled back ${session.sessionId}`);
3238
+ log(` ${C.dim}Session: ${result.sessionPath}${C.reset}\n`);
3239
+ }
3240
+ } catch (e) {
3241
+ error(`Rollback failed: ${e.message}`);
3242
+ }
3243
+ }
3244
+
2362
3245
  async function cmdOutdated() {
2363
3246
  const args = parseArgs(process.argv);
2364
3247
  const targetTool = validateInstallTarget(args.flags.target || 'codex');
@@ -2384,7 +3267,8 @@ async function cmdOutdated() {
2384
3267
  for (const record of installed) {
2385
3268
  try {
2386
3269
  const { workflow, contents } = await fetchWorkflowForInstall(record.uuid, config, apiBase);
2387
- const plan = buildCodexInstallPlan(workflow, contents, { installMode: record.installMode || record.install_mode });
3270
+ const serverPlan = await fetchServerCodexInstallPlan(record.uuid, config, apiBase);
3271
+ const plan = buildCodexInstallPlan(workflow, contents, { installMode: record.installMode || record.install_mode, serverPlan });
2388
3272
  const diff = diffCodexPlanWithLocal(plan, record);
2389
3273
  if (diff.needsUpdate) {
2390
3274
  list.push({
@@ -2546,12 +3430,15 @@ ${C.bold}USAGE${C.reset}
2546
3430
  ${C.bold}DISCOVER & INSTALL${C.reset}
2547
3431
  ${C.cyan}search${C.reset} <query> Search assets by keyword
2548
3432
  ${C.cyan}detail${C.reset} <name|uuid> Show full asset metadata
3433
+ ${C.cyan}plan${C.reset} <name|uuid> Print agent-native Codex install plan
2549
3434
  ${C.cyan}install${C.reset} <name|uuid> Smart install (auto-detects type & placement)
2550
3435
  ${C.cyan}pull${C.reset} <url|uuid|@u/n> Download raw asset files
2551
3436
  ${C.cyan}clone${C.reset} @username Clone all assets from a user
2552
3437
  ${C.cyan}installed${C.reset} List installed Codex assets from manifest
2553
3438
  ${C.cyan}outdated${C.reset} Check installed Codex assets for updates
2554
3439
  ${C.cyan}sync-installed${C.reset} Update installed Codex assets from manifest
3440
+ ${C.cyan}uninstall${C.reset} <uuid> Remove a managed Codex install
3441
+ ${C.cyan}rollback${C.reset} --last Roll back the latest Codex install session
2555
3442
 
2556
3443
  ${C.bold}PUBLISH${C.reset}
2557
3444
  ${C.cyan}push${C.reset} [files...] Push files/directory (idempotent upsert)
@@ -2589,8 +3476,9 @@ ${C.bold}INSTALL BEHAVIOR${C.reset}
2589
3476
 
2590
3477
  ${C.bold}EXAMPLES${C.reset}
2591
3478
  tokrepo search "mcp server" # Find MCP configs
2592
- tokrepo search video --json # Machine-readable search
3479
+ tokrepo search video --target codex --kind skill --policy allow --json
2593
3480
  tokrepo detail ca000374-f5d8-... --json # Machine-readable detail
3481
+ tokrepo plan 91aeb22d-eff0-4310-... # Install plan v2 for agents
2594
3482
  tokrepo install ca000374-f5d8-... # Install by UUID
2595
3483
  tokrepo install ca000374-f5d8-... --target codex
2596
3484
  tokrepo install c4b18aeb --target gemini # Install for Gemini CLI
@@ -2599,6 +3487,8 @@ ${C.bold}EXAMPLES${C.reset}
2599
3487
  tokrepo outdated --target codex --json
2600
3488
  tokrepo update --target codex --all
2601
3489
  tokrepo sync-installed --target codex --dry-run
3490
+ tokrepo uninstall 91aeb22d --target codex --dry-run
3491
+ tokrepo rollback --last --target codex --dry-run
2602
3492
  tokrepo push --private my-rules.md # Save one file privately
2603
3493
  tokrepo push . --kind skill --target codex --install-mode bundle
2604
3494
  tokrepo push --public skill.md # Share one file publicly
@@ -2626,11 +3516,12 @@ function showSearchHelp() {
2626
3516
  ${C.bold}tokrepo search${C.reset}
2627
3517
 
2628
3518
  USAGE
2629
- tokrepo search <query> [--json] [--all] [--page-size N] [--sort-by views|latest|stars|popular]
3519
+ tokrepo search <query> [--json] [--all] [--target codex] [--kind skill] [--policy allow|confirm|stage_only|deny] [--page-size N] [--sort-by views|latest|stars|popular]
2630
3520
 
2631
3521
  EXAMPLES
2632
3522
  tokrepo search video
2633
3523
  tokrepo search video --json
3524
+ tokrepo search video --target codex --kind skill --policy allow --json
2634
3525
  tokrepo search "mcp server" --json --all
2635
3526
  `);
2636
3527
  }
@@ -2653,7 +3544,7 @@ function showInstallHelp() {
2653
3544
  ${C.bold}tokrepo install${C.reset}
2654
3545
 
2655
3546
  USAGE
2656
- tokrepo install <uuid|url|name|pack/slug> [--target gemini|codex] [--yes] [--dry-run] [--stage] [--approve-mcp] [--json]
3547
+ tokrepo install <uuid|url|name|pack/slug> [--target gemini|codex] [--yes] [--dry-run] [--stage] [--approve-mcp] [--verify-commands] [--json]
2657
3548
 
2658
3549
  TARGETS
2659
3550
  codex Write Codex skills to ~/.codex/skills/<asset-slug>/SKILL.md
@@ -2670,16 +3561,33 @@ EXAMPLES
2670
3561
  `);
2671
3562
  }
2672
3563
 
3564
+ function showPlanHelp() {
3565
+ log(`
3566
+ ${C.bold}tokrepo plan${C.reset}
3567
+
3568
+ USAGE
3569
+ tokrepo plan <uuid|url|name> [--target codex] [--stage]
3570
+
3571
+ OUTPUT
3572
+ Machine-readable install plan v2 with preconditions, actions, policyDecision,
3573
+ rollback, postVerify, risk metadata, and destination file hashes.
3574
+
3575
+ EXAMPLES
3576
+ tokrepo plan 91aeb22d-eff0-4310-abc6-811d2394b420
3577
+ tokrepo plan https://tokrepo.com/en/workflows/91aeb22d-eff0-4310-abc6-811d2394b420
3578
+ `);
3579
+ }
3580
+
2673
3581
  function showListHelp() {
2674
3582
  log(`
2675
3583
  ${C.bold}tokrepo list${C.reset}
2676
3584
 
2677
3585
  USAGE
2678
- tokrepo list [--json] [--all] [--page-size N]
3586
+ tokrepo list [--json] [--all] [--target codex] [--kind skill] [--policy allow] [--page-size N]
2679
3587
 
2680
3588
  EXAMPLES
2681
3589
  tokrepo list
2682
- tokrepo list --json --all
3590
+ tokrepo list --json --all --target codex
2683
3591
  `);
2684
3592
  }
2685
3593
 
@@ -2722,6 +3630,42 @@ EXAMPLES
2722
3630
  `);
2723
3631
  }
2724
3632
 
3633
+ function showUninstallHelp() {
3634
+ log(`
3635
+ ${C.bold}tokrepo uninstall${C.reset}
3636
+
3637
+ USAGE
3638
+ tokrepo uninstall <uuid|uuid-prefix|title> --target codex [--dry-run] [--force] [--json]
3639
+
3640
+ BEHAVIOR
3641
+ Removes only files recorded in ~/.codex/tokrepo/install-manifest.json and only
3642
+ under ~/.codex/skills or ~/.codex/tokrepo/staged. Local changes are blocked
3643
+ unless --force is provided.
3644
+
3645
+ EXAMPLES
3646
+ tokrepo uninstall 91aeb22d --target codex --dry-run --json
3647
+ tokrepo uninstall 91aeb22d-eff0-4310-abc6-811d2394b420 --target codex
3648
+ `);
3649
+ }
3650
+
3651
+ function showRollbackHelp() {
3652
+ log(`
3653
+ ${C.bold}tokrepo rollback${C.reset}
3654
+
3655
+ USAGE
3656
+ tokrepo rollback --last --target codex [--dry-run] [--force] [--json]
3657
+ tokrepo rollback <session-id> --target codex [--dry-run] [--force] [--json]
3658
+
3659
+ BEHAVIOR
3660
+ Replays the rollback section from ~/.codex/tokrepo/sessions/<session-id>.json.
3661
+ Local changes are blocked unless --force is provided.
3662
+
3663
+ EXAMPLES
3664
+ tokrepo rollback --last --target codex --dry-run --json
3665
+ tokrepo rollback install-20260506-120000-abc123 --target codex
3666
+ `);
3667
+ }
3668
+
2725
3669
  function showCommandHelp(command) {
2726
3670
  switch (command) {
2727
3671
  case 'search':
@@ -2729,6 +3673,8 @@ function showCommandHelp(command) {
2729
3673
  showSearchHelp(); break;
2730
3674
  case 'detail':
2731
3675
  showDetailHelp(); break;
3676
+ case 'plan':
3677
+ showPlanHelp(); break;
2732
3678
  case 'install':
2733
3679
  case 'i':
2734
3680
  showInstallHelp(); break;
@@ -2741,6 +3687,12 @@ function showCommandHelp(command) {
2741
3687
  case 'installed':
2742
3688
  case 'outdated':
2743
3689
  showSyncInstalledHelp(); break;
3690
+ case 'uninstall':
3691
+ case 'remove':
3692
+ case 'rm':
3693
+ showUninstallHelp(); break;
3694
+ case 'rollback':
3695
+ showRollbackHelp(); break;
2744
3696
  default:
2745
3697
  showHelp(); break;
2746
3698
  }
@@ -2764,12 +3716,15 @@ async function main() {
2764
3716
  case 'pull': await cmdPull(); break;
2765
3717
  case 'search': case 'find': await cmdSearch(); break;
2766
3718
  case 'detail': await cmdDetail(); break;
3719
+ case 'plan': await cmdPlan(); break;
2767
3720
  case 'install': case 'i': await cmdInstall(); break;
2768
3721
  case 'list': await cmdList(); break;
2769
3722
  case 'update': await cmdUpdate(); break;
2770
3723
  case 'delete': await cmdDelete(); break;
2771
3724
  case 'clone': await cmdClone(); break;
2772
3725
  case 'installed': await cmdInstalled(); break;
3726
+ case 'uninstall': case 'remove': case 'rm': await cmdUninstall(); break;
3727
+ case 'rollback': await cmdRollback(); break;
2773
3728
  case 'outdated': await cmdOutdated(); break;
2774
3729
  case 'sync-installed': case 'sync': await cmdSyncInstalled(); break;
2775
3730
  case 'tags': await cmdTags(); break;
@@ -2784,7 +3739,7 @@ async function main() {
2784
3739
  }
2785
3740
 
2786
3741
  // Non-blocking update check after command completes
2787
- if (!wantsJson(process.argv) && !args.flags.help) {
3742
+ if (command !== 'plan' && !wantsJson(process.argv) && !args.flags.help) {
2788
3743
  checkForUpdate();
2789
3744
  }
2790
3745
  }