metheus-governance-mcp-cli 0.2.112 → 0.2.114

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.
package/README.md CHANGED
@@ -305,7 +305,7 @@ For direct Telegram adds, the CLI derives the local file name from the matched s
305
305
 
306
306
  For direct Telegram edits, prefer `--bot-name` or `--bot-id` because server bot identity is the source of truth. Use `--bot-key` only when you intentionally need the legacy local selector. If one server bot name expands to multiple roles such as `approval / worker / review / monitor`, prefer the guided `bot edit` flow so you can keep the current grouped settings, edit one role only, or walk every role in sequence instead of forcing one entry-level AI override.
307
307
 
308
- For runner commands, you can still use `--route-name` directly, but normal operator workflows can also use `--bot-name` or `--bot-id` when those identify one enabled route uniquely. Use `runner list` first if you are not sure which route belongs to which server bot.
308
+ For runner commands, routes are the executable unit. Use `--route-name` as the primary selector. `--bot-name` and `--bot-id` are convenience route aliases only when they identify one enabled route uniquely. Use `runner list` first if you are not sure which route belongs to which server bot, and use `runner show` to inspect the resolved route, server identity, workspace mapping, and execution profile together.
309
309
 
310
310
  Current support status:
311
311
 
@@ -388,10 +388,20 @@ Commands:
388
388
 
389
389
  ```bash
390
390
  metheus-governance-mcp-cli runner list
391
+ metheus-governance-mcp-cli runner route add
392
+ metheus-governance-mcp-cli runner route edit --route-name telegram-monitor
393
+ metheus-governance-mcp-cli runner route remove --route-name telegram-monitor
394
+ metheus-governance-mcp-cli runner show --route-name telegram-monitor
391
395
  metheus-governance-mcp-cli runner once --route-name telegram-monitor
392
396
  metheus-governance-mcp-cli runner start --route-name telegram-monitor
393
397
  ```
394
398
 
399
+ Route management:
400
+ - `runner route add` creates one executable route by selecting the project, provider, role, server bot, and project chat destination in order.
401
+ - `runner route edit` updates one stored route. Use it when the destination, role, or server bot for an existing route changes.
402
+ - `runner route remove` deletes one stored route from `~/.metheus/bot-runner.json`.
403
+ - `runner list` and `runner route list` are equivalent. They show which routes are executable and how `--bot-name` would resolve.
404
+
395
405
  Recommended operational path:
396
406
 
397
407
  ```bash
@@ -399,9 +409,17 @@ metheus-governance-mcp-cli runner once --route-name telegram-monitor --dry-run-d
399
409
  metheus-governance-mcp-cli runner start --route-name telegram-monitor
400
410
  ```
401
411
 
412
+ What `runner list` means:
413
+ - `server_bot_name` may come from `route.server_bot_name` or be resolved from the Telegram bot env file by `server_bot_id`
414
+ - `server_bot_name_source=route_config` means the route file already stores the name directly
415
+ - `server_bot_name_source=telegram_env_lookup` means the CLI resolved the name from the local Telegram bot file
416
+ - `route_alias_by_server_bot_name` is a convenience selector, not a separate execution target
417
+ - `run_once_by_server_bot_name` in `runner show` or docs means "find the one enabled route that matches this server bot name, then run that route"
418
+
402
419
  Debug/selection overrides:
403
420
 
404
421
  ```bash
422
+ metheus-governance-mcp-cli runner show --bot-name <server_bot_name>
405
423
  metheus-governance-mcp-cli runner once --project-id <project_uuid> --provider telegram --role monitor
406
424
  metheus-governance-mcp-cli runner start --project-id <project_uuid> --provider telegram --role monitor --poll-interval-ms 5000
407
425
  metheus-governance-mcp-cli runner once --project-id <project_uuid> --provider telegram --role monitor --role-profile review
package/cli.mjs CHANGED
@@ -91,7 +91,15 @@ import {
91
91
  writeSelftestReport,
92
92
  } from "./lib/selftest-support.mjs";
93
93
  import { runSelftestBotCommands } from "./lib/selftest-bot-commands.mjs";
94
- import { runBotCommand } from "./lib/bot-commands.mjs";
94
+ import {
95
+ createPrompter,
96
+ promptChoice,
97
+ promptConfirmChoice,
98
+ promptLine,
99
+ promptRequiredLine,
100
+ shouldRenderPromptChrome,
101
+ runBotCommand,
102
+ } from "./lib/bot-commands.mjs";
95
103
  import { handleLocalBotMessageToolCall as handleLocalBotMessageToolCallImpl } from "./lib/local-tool-shims.mjs";
96
104
  import { handleLocalProjectToolDispatch as handleLocalProjectToolDispatchImpl } from "./lib/local-project-dispatch.mjs";
97
105
  import {
@@ -255,8 +263,13 @@ function printUsage() {
255
263
  ` ${cmd} selftest [--json <true|false>]`,
256
264
  ` ${cmd} local-bot-bridge [--client <gpt|claude|gemini|sample>] [--cwd <path>] [--model <name>] [--permission-mode <read_only|workspace_write|danger_full_access>] [--reasoning-effort <low|medium|high>]`,
257
265
  ` ${cmd} runner list [--json <true|false>]`,
258
- ` ${cmd} runner once [--route-name <name> | --bot-name <server_name> | --bot-id <uuid>] [--project-id <uuid>] [--provider <telegram|slack|kakaotalk>] [--role <monitor|review|worker|approval>] [--role-profile <name>] [--mentions-only <true|false>] [--reply-to-bot-messages <true|false>] [--direct-messages <true|false>] [--ignore-edited-messages <true|false>] [--dry-run-delivery <true|false>] [--context-comments <n>] [--archive-replies <true|false>]`,
259
- ` ${cmd} runner start [--route-name <name> | --bot-name <server_name> | --bot-id <uuid>] [--project-id <uuid>] [--provider <telegram|slack|kakaotalk>] [--role <monitor|review|worker|approval>] [--role-profile <name>] [--mentions-only <true|false>] [--reply-to-bot-messages <true|false>] [--direct-messages <true|false>] [--ignore-edited-messages <true|false>] [--dry-run-delivery <true|false>] [--poll-interval-ms <n>] [--context-comments <n>] [--archive-replies <true|false>]`,
266
+ ` ${cmd} runner route list [--json <true|false>]`,
267
+ ` ${cmd} runner route add [--project-id <uuid>] [--provider <telegram|slack|kakaotalk>] [--role <monitor|review|worker|approval>] [--bot-name <server_name> | --bot-id <uuid>] [--destination-label <label> | --destination-id <uuid>] [--poll-interval-ms <n>] [--enabled <true|false>]`,
268
+ ` ${cmd} runner route edit [--route-name <name> | --bot-name <server_name> | --bot-id <uuid>]`,
269
+ ` ${cmd} runner route remove [--route-name <name> | --bot-name <server_name> | --bot-id <uuid>]`,
270
+ ` ${cmd} runner show [--route-name <name> | --bot-name <server_name> | --bot-id <uuid>] [--json <true|false>]`,
271
+ ` ${cmd} runner once [--route-name <name> | --bot-name <server_name when one enabled route matches> | --bot-id <uuid when one enabled route matches>] [--project-id <uuid>] [--provider <telegram|slack|kakaotalk>] [--role <monitor|review|worker|approval>] [--role-profile <name>] [--mentions-only <true|false>] [--reply-to-bot-messages <true|false>] [--direct-messages <true|false>] [--ignore-edited-messages <true|false>] [--dry-run-delivery <true|false>] [--context-comments <n>] [--archive-replies <true|false>]`,
272
+ ` ${cmd} runner start [--route-name <name> | --bot-name <server_name when one enabled route matches> | --bot-id <uuid when one enabled route matches>] [--project-id <uuid>] [--provider <telegram|slack|kakaotalk>] [--role <monitor|review|worker|approval>] [--role-profile <name>] [--mentions-only <true|false>] [--reply-to-bot-messages <true|false>] [--direct-messages <true|false>] [--ignore-edited-messages <true|false>] [--dry-run-delivery <true|false>] [--poll-interval-ms <n>] [--context-comments <n>] [--archive-replies <true|false>]`,
260
273
  ` ${cmd} ctxpack pull [--project-id <uuid>] [--base-url <url>] [--workspace-dir <path|auto>] [--paths <csv>] [--timeout-seconds <n>]`,
261
274
  ` ${cmd} auth status`,
262
275
  ` ${cmd} auth login [--base-url <url>] [--flow <auto|device|callback|manual>] [--keycloak-url <url>] [--realm <name>] [--client-id <id>] [--open-browser <true|false>] [--callback-port <n>] [--timeout-seconds <n>] [--manual <true|false>]`,
@@ -2632,6 +2645,11 @@ function buildRunnerRouteListRows() {
2632
2645
  );
2633
2646
  })
2634
2647
  : null;
2648
+ const botNameSource = route.botName
2649
+ ? "route_config"
2650
+ : matchedTelegramEntry?.serverBotName
2651
+ ? "telegram_env_lookup"
2652
+ : "unresolved";
2635
2653
  const resolvedBotName = firstNonEmptyString([
2636
2654
  route.botName,
2637
2655
  matchedTelegramEntry?.serverBotName,
@@ -2644,6 +2662,7 @@ function buildRunnerRouteListRows() {
2644
2662
  provider: route.provider || "-",
2645
2663
  projectID: route.projectID || "-",
2646
2664
  botName: resolvedBotName,
2665
+ botNameSource,
2647
2666
  botID: route.botID || "-",
2648
2667
  role: route.role || "-",
2649
2668
  roleProfile: route.roleProfile || "-",
@@ -2654,6 +2673,595 @@ function buildRunnerRouteListRows() {
2654
2673
  });
2655
2674
  }
2656
2675
 
2676
+ function slugifyRunnerRouteSegment(rawValue) {
2677
+ return String(rawValue || "")
2678
+ .trim()
2679
+ .toLowerCase()
2680
+ .replace(/[^a-z0-9]+/g, "-")
2681
+ .replace(/^-+|-+$/g, "");
2682
+ }
2683
+
2684
+ function buildRunnerRouteNameSuggestion({ provider = "", role = "", botName = "" }, existingNames = []) {
2685
+ const segments = [
2686
+ slugifyRunnerRouteSegment(provider),
2687
+ slugifyRunnerRouteSegment(role),
2688
+ slugifyRunnerRouteSegment(botName),
2689
+ ].filter(Boolean);
2690
+ const base = segments.join("-") || "runner-route";
2691
+ const existing = new Set(ensureArray(existingNames).map((name) => String(name || "").trim().toLowerCase()).filter(Boolean));
2692
+ if (!existing.has(base)) {
2693
+ return base;
2694
+ }
2695
+ let suffix = 2;
2696
+ while (existing.has(`${base}-${suffix}`)) {
2697
+ suffix += 1;
2698
+ }
2699
+ return `${base}-${suffix}`;
2700
+ }
2701
+
2702
+ function upsertRunnerRouteConfig(config, route) {
2703
+ const normalizedConfig = safeObject(config);
2704
+ const normalizedRoute = normalizeRunnerRoute(route);
2705
+ const routeName = String(normalizedRoute.name || "").trim();
2706
+ if (!routeName) {
2707
+ throw new Error("route name is required");
2708
+ }
2709
+ const routes = ensureArray(normalizedConfig.routes).map((item) => normalizeRunnerRoute(item));
2710
+ const index = routes.findIndex((item) => String(item.name || "").trim().toLowerCase() === routeName.toLowerCase());
2711
+ if (index >= 0) {
2712
+ routes[index] = normalizedRoute;
2713
+ } else {
2714
+ routes.push(normalizedRoute);
2715
+ }
2716
+ return {
2717
+ ...normalizedConfig,
2718
+ routes,
2719
+ };
2720
+ }
2721
+
2722
+ function removeRunnerRouteFromConfig(config, routeName) {
2723
+ const normalizedConfig = safeObject(config);
2724
+ const target = String(routeName || "").trim().toLowerCase();
2725
+ return {
2726
+ ...normalizedConfig,
2727
+ routes: ensureArray(normalizedConfig.routes)
2728
+ .map((route) => normalizeRunnerRoute(route))
2729
+ .filter((route) => String(route.name || "").trim().toLowerCase() !== target),
2730
+ };
2731
+ }
2732
+
2733
+ function formatRunnerRouteChoiceLabel(route, telegramEntries = []) {
2734
+ const normalizedRoute = normalizeRunnerRoute(route);
2735
+ const resolvedName = resolveConfiguredRunnerRouteServerBotName(normalizedRoute, telegramEntries);
2736
+ const routeName = normalizedRoute.name || runnerRouteKey(normalizedRoute);
2737
+ const role = normalizedRoute.role || "-";
2738
+ const botName = resolvedName || normalizedRoute.botName || normalizedRoute.botID || "-";
2739
+ const destination = normalizedRoute.destinationLabel || normalizedRoute.destinationID || "-";
2740
+ return `${routeName}${normalizedRoute.enabled ? "" : " [disabled]"} - ${normalizedRoute.provider || "-"} | ${role} | ${botName} | ${destination}`;
2741
+ }
2742
+
2743
+ async function selectRunnerManagementRoute(ui, flags, { title = "Select runner route" } = {}) {
2744
+ const config = loadBotRunnerConfig({ persistIfNeeded: true });
2745
+ const routes = ensureArray(config.routes).map((route) => normalizeRunnerRoute(route));
2746
+ if (!routes.length) {
2747
+ throw new Error(`No runner route is configured in ${config.filePath}.`);
2748
+ }
2749
+ const selectionFilters = {
2750
+ "route-name": flags["route-name"],
2751
+ "bot-name": flags["bot-name"],
2752
+ "bot-id": flags["bot-id"],
2753
+ };
2754
+ const filtered = routes.filter((route) => {
2755
+ if (selectionFilters["route-name"] && !matchesRunnerRouteText(route.name, selectionFilters["route-name"])) return false;
2756
+ const candidateBotName = resolveConfiguredRunnerRouteServerBotName(route, ensureArray(readTelegramEnvState().entries));
2757
+ if (selectionFilters["bot-name"] && !matchesRunnerRouteText(candidateBotName, selectionFilters["bot-name"])) return false;
2758
+ if (selectionFilters["bot-id"] && String(route.botID || "").trim() !== String(selectionFilters["bot-id"] || "").trim()) return false;
2759
+ return true;
2760
+ });
2761
+ if (filtered.length === 1) {
2762
+ return {
2763
+ route: filtered[0],
2764
+ config,
2765
+ };
2766
+ }
2767
+ if (filtered.length > 1) {
2768
+ const matches = filtered.map((route) => route.name || runnerRouteKey(route)).join(", ");
2769
+ throw new Error(`Multiple runner routes matched the provided selectors. Narrow with --route-name. Matches: ${matches}`);
2770
+ }
2771
+ const telegramEntries = ensureArray(readTelegramEnvState().entries);
2772
+ const selected = await promptChoice(
2773
+ ui,
2774
+ title,
2775
+ routes.map((route) => ({
2776
+ value: route.name || runnerRouteKey(route),
2777
+ label: route.name || runnerRouteKey(route),
2778
+ description: formatRunnerRouteChoiceLabel(route, telegramEntries).replace(/^[^-]+ - /, ""),
2779
+ })),
2780
+ { defaultIndex: 0 },
2781
+ );
2782
+ const selectedName = String(selected?.value || "").trim().toLowerCase();
2783
+ const matchedRoute = routes.find((route) => String(route.name || runnerRouteKey(route)).trim().toLowerCase() === selectedName);
2784
+ if (!matchedRoute) {
2785
+ throw new Error("selected runner route was not found");
2786
+ }
2787
+ return {
2788
+ route: matchedRoute,
2789
+ config,
2790
+ };
2791
+ }
2792
+
2793
+ async function selectRunnerRouteProvider(ui, flags, currentProvider = "") {
2794
+ const flagged = normalizeBotProvider(flags.provider || "");
2795
+ if (flagged) {
2796
+ return flagged;
2797
+ }
2798
+ if (currentProvider) {
2799
+ return currentProvider;
2800
+ }
2801
+ const options = PROVIDER_ENV_ORDER.map((provider) => ({
2802
+ value: provider,
2803
+ label: providerEnvConfig(provider).label,
2804
+ description: summarizeProviderSupport(provider),
2805
+ }));
2806
+ const selected = await promptChoice(ui, "Select runner route provider", options, { defaultIndex: 0 });
2807
+ return normalizeBotProvider(selected?.value || "telegram") || "telegram";
2808
+ }
2809
+
2810
+ async function resolveRunnerRouteProjectID(ui, flags, config, currentProjectID = "") {
2811
+ const flagged = String(flags["project-id"] || "").trim();
2812
+ if (flagged) {
2813
+ if (!isUUID(flagged)) {
2814
+ throw new Error("project_id must be a valid UUID");
2815
+ }
2816
+ return flagged;
2817
+ }
2818
+ if (currentProjectID && isUUID(currentProjectID)) {
2819
+ return currentProjectID;
2820
+ }
2821
+ const projectMappings = safeObject(config.projectMappings);
2822
+ const options = Object.entries(projectMappings)
2823
+ .filter(([projectID]) => isUUID(projectID))
2824
+ .map(([projectID, rawMapping]) => ({
2825
+ value: projectID,
2826
+ label: projectID,
2827
+ description: String(safeObject(rawMapping).workspaceDir || "").trim() || "-",
2828
+ }));
2829
+ if (options.length === 1) {
2830
+ return options[0].value;
2831
+ }
2832
+ if (options.length > 1) {
2833
+ const selected = await promptChoice(ui, "Select runner route project", options, { defaultIndex: 0 });
2834
+ return String(selected?.value || "").trim();
2835
+ }
2836
+ while (true) {
2837
+ const projectID = String(await promptRequiredLine(ui, "Project UUID", "") || "").trim();
2838
+ if (isUUID(projectID)) return projectID;
2839
+ process.stdout.write("Project UUID must be valid.\n");
2840
+ }
2841
+ }
2842
+
2843
+ async function selectRunnerRole(ui, flags, currentRole = "") {
2844
+ const flagged = normalizeBotRole(flags.role || "");
2845
+ if (flagged) {
2846
+ return flagged;
2847
+ }
2848
+ const roleOptions = ["monitor", "review", "worker", "approval"].map((role) => ({
2849
+ value: role,
2850
+ label: role,
2851
+ description: role === "monitor"
2852
+ ? "inbound triage and monitoring"
2853
+ : role === "review"
2854
+ ? "review and analysis"
2855
+ : role === "worker"
2856
+ ? "workspace execution"
2857
+ : "final approval actions",
2858
+ }));
2859
+ const defaultIndex = Math.max(0, roleOptions.findIndex((item) => item.value === currentRole));
2860
+ const selected = await promptChoice(ui, "Select runner route role", roleOptions, { defaultIndex });
2861
+ return normalizeBotRole(selected?.value || currentRole) || "monitor";
2862
+ }
2863
+
2864
+ async function resolveRunnerCommandToken(baseURL, timeoutSeconds) {
2865
+ const resolved = await resolveAccessTokenForCommand(baseURL || DEFAULT_SITE_URL, timeoutSeconds);
2866
+ if (!resolved.token) {
2867
+ throw new Error("auth token missing; run auth login first");
2868
+ }
2869
+ return {
2870
+ siteBaseURL: normalizeSiteBaseURL(baseURL || DEFAULT_SITE_URL),
2871
+ token: resolved.token,
2872
+ };
2873
+ }
2874
+
2875
+ async function selectRunnerServerBotProfile(ui, { provider, role, flags, currentBotID = "", currentBotName = "" }) {
2876
+ const flaggedBotID = String(flags["bot-id"] || "").trim();
2877
+ const flaggedBotName = String(flags["bot-name"] || "").trim();
2878
+ const timeoutSeconds = intFromRaw(flags["timeout-seconds"], 15) || 15;
2879
+ const { siteBaseURL, token } = await resolveRunnerCommandToken(flags["base-url"], timeoutSeconds);
2880
+ const bots = await listUserBotsForRunner({
2881
+ siteBaseURL,
2882
+ token,
2883
+ timeoutSeconds,
2884
+ });
2885
+ const candidates = ensureArray(bots)
2886
+ .filter((bot) => bot.isActive && bot.provider === provider && bot.role === role);
2887
+ if (!candidates.length) {
2888
+ throw new Error(`No active ${providerEnvConfig(provider).label} server bot exists for role "${role}".`);
2889
+ }
2890
+ const resolveChoice = (bot) => ({
2891
+ name: String(bot.name || "").trim(),
2892
+ id: String(bot.id || "").trim(),
2893
+ });
2894
+ if (flaggedBotID) {
2895
+ const matched = candidates.find((bot) => bot.id === flaggedBotID);
2896
+ if (!matched) {
2897
+ throw new Error(`No active ${providerEnvConfig(provider).label} server bot matched bot-id ${flaggedBotID} for role "${role}".`);
2898
+ }
2899
+ return resolveChoice(matched);
2900
+ }
2901
+ if (flaggedBotName) {
2902
+ const matchedByName = candidates.filter((bot) => matchesRunnerRouteText(bot.name, flaggedBotName));
2903
+ if (matchedByName.length === 1) {
2904
+ return resolveChoice(matchedByName[0]);
2905
+ }
2906
+ if (matchedByName.length > 1) {
2907
+ throw new Error(`Multiple active ${providerEnvConfig(provider).label} server bots matched "${flaggedBotName}" for role "${role}". Use --bot-id instead.`);
2908
+ }
2909
+ throw new Error(`No active ${providerEnvConfig(provider).label} server bot matched "${flaggedBotName}" for role "${role}".`);
2910
+ }
2911
+ const currentIndex = Math.max(0, candidates.findIndex((bot) => (
2912
+ (currentBotID && bot.id === currentBotID)
2913
+ || (currentBotName && matchesRunnerRouteText(bot.name, currentBotName))
2914
+ )));
2915
+ if (candidates.length === 1) {
2916
+ return resolveChoice(candidates[0]);
2917
+ }
2918
+ const selected = await promptChoice(
2919
+ ui,
2920
+ `Select server bot for role "${role}"`,
2921
+ candidates.map((bot) => ({
2922
+ value: bot.id,
2923
+ label: String(bot.name || "").trim() || bot.id,
2924
+ description: `server_bot_id:${bot.id}`,
2925
+ })),
2926
+ { defaultIndex: currentIndex },
2927
+ );
2928
+ const matched = candidates.find((bot) => bot.id === String(selected?.value || "").trim());
2929
+ if (!matched) {
2930
+ throw new Error("selected server bot was not found");
2931
+ }
2932
+ return resolveChoice(matched);
2933
+ }
2934
+
2935
+ async function selectRunnerDestination(ui, { projectID, provider, flags, currentDestinationID = "", currentDestinationLabel = "" }) {
2936
+ const timeoutSeconds = intFromRaw(flags["timeout-seconds"], 15) || 15;
2937
+ const { siteBaseURL, token } = await resolveRunnerCommandToken(flags["base-url"], timeoutSeconds);
2938
+ const destinations = await listProjectChatDestinations({
2939
+ siteBaseURL,
2940
+ projectID,
2941
+ token,
2942
+ timeoutSeconds,
2943
+ });
2944
+ const candidates = ensureArray(destinations)
2945
+ .map((item) => normalizeChatDestination(item))
2946
+ .filter((item) => item.isActive && item.provider === provider);
2947
+ const flaggedID = String(flags["destination-id"] || "").trim();
2948
+ const flaggedLabel = String(flags["destination-label"] || "").trim();
2949
+ if (flaggedID) {
2950
+ const matched = candidates.find((item) => item.id === flaggedID);
2951
+ if (!matched) {
2952
+ throw new Error(`No active ${providerEnvConfig(provider).label} destination matched destination-id ${flaggedID}.`);
2953
+ }
2954
+ return { id: matched.id, label: matched.label || matched.id };
2955
+ }
2956
+ if (flaggedLabel) {
2957
+ const matchedByLabel = candidates.filter((item) => matchesRunnerRouteText(item.label, flaggedLabel));
2958
+ if (matchedByLabel.length === 1) {
2959
+ return { id: matchedByLabel[0].id, label: matchedByLabel[0].label || matchedByLabel[0].id };
2960
+ }
2961
+ if (matchedByLabel.length > 1) {
2962
+ throw new Error(`Multiple active ${providerEnvConfig(provider).label} destinations matched "${flaggedLabel}". Use --destination-id instead.`);
2963
+ }
2964
+ throw new Error(`No active ${providerEnvConfig(provider).label} destination matched "${flaggedLabel}".`);
2965
+ }
2966
+ const currentIndex = Math.max(0, candidates.findIndex((item) => (
2967
+ (currentDestinationID && item.id === currentDestinationID)
2968
+ || (currentDestinationLabel && matchesRunnerRouteText(item.label, currentDestinationLabel))
2969
+ )));
2970
+ if (candidates.length === 1) {
2971
+ return { id: candidates[0].id, label: candidates[0].label || candidates[0].id };
2972
+ }
2973
+ if (!candidates.length) {
2974
+ throw new Error(`No active ${providerEnvConfig(provider).label} destination exists for project ${projectID}.`);
2975
+ }
2976
+ const selected = await promptChoice(
2977
+ ui,
2978
+ "Select project chat destination",
2979
+ candidates.map((item) => ({
2980
+ value: item.id,
2981
+ label: item.label || item.id,
2982
+ description: `chat_id:${item.chatID}`,
2983
+ })),
2984
+ { defaultIndex: currentIndex },
2985
+ );
2986
+ const matched = candidates.find((item) => item.id === String(selected?.value || "").trim());
2987
+ if (!matched) {
2988
+ throw new Error("selected destination was not found");
2989
+ }
2990
+ return { id: matched.id, label: matched.label || matched.id };
2991
+ }
2992
+
2993
+ function buildRunnerRoutePayload({
2994
+ currentRoute = null,
2995
+ name = "",
2996
+ enabled = true,
2997
+ projectID = "",
2998
+ provider = "",
2999
+ role = "",
3000
+ roleProfile = "",
3001
+ serverBotName = "",
3002
+ serverBotID = "",
3003
+ destinationID = "",
3004
+ destinationLabel = "",
3005
+ pollIntervalMs = 5000,
3006
+ }) {
3007
+ const triggerPolicy = currentRoute
3008
+ ? safeObject(currentRoute.triggerPolicy)
3009
+ : defaultRunnerTriggerPolicyForRole(roleProfile || role);
3010
+ const archivePolicy = currentRoute
3011
+ ? safeObject(currentRoute.archivePolicy)
3012
+ : defaultRunnerArchivePolicyForRole(roleProfile || role);
3013
+ return normalizeRunnerRoute({
3014
+ ...(currentRoute ? serializeRunnerRoute(currentRoute) : {}),
3015
+ name,
3016
+ enabled,
3017
+ project_id: projectID,
3018
+ provider,
3019
+ role,
3020
+ role_profile: roleProfile,
3021
+ server_bot_name: serverBotName,
3022
+ server_bot_id: serverBotID,
3023
+ destination_id: destinationID,
3024
+ destination_label: destinationLabel,
3025
+ trigger_policy: triggerPolicy,
3026
+ archive_policy: archivePolicy,
3027
+ poll_interval_ms: pollIntervalMs,
3028
+ });
3029
+ }
3030
+
3031
+ async function runRunnerRouteAdd(flags) {
3032
+ const ui = createPrompter();
3033
+ try {
3034
+ if (shouldRenderPromptChrome(flags)) {
3035
+ ui.setFlow("RUNNER ROUTE ADD", "Create one executable runner route from server bot and destination");
3036
+ }
3037
+ const config = loadBotRunnerConfig({ persistIfNeeded: true });
3038
+ const provider = await selectRunnerRouteProvider(ui, flags, "");
3039
+ const projectID = await resolveRunnerRouteProjectID(ui, flags, config);
3040
+ const role = await selectRunnerRole(ui, flags, "");
3041
+ const botSelection = await selectRunnerServerBotProfile(ui, { provider, role, flags });
3042
+ const destination = await selectRunnerDestination(ui, {
3043
+ projectID,
3044
+ provider,
3045
+ flags,
3046
+ });
3047
+ const suggestedName = buildRunnerRouteNameSuggestion({
3048
+ provider,
3049
+ role,
3050
+ botName: botSelection.name,
3051
+ }, ensureArray(config.routes).map((route) => normalizeRunnerRoute(route).name));
3052
+ const routeName = String(flags.name || "").trim() || await promptRequiredLine(ui, "Runner route name", suggestedName);
3053
+ const pollIntervalMs = intFromRaw(flags["poll-interval-ms"], 0) || intFromRaw(await promptLine(ui, "Poll interval (ms)", "5000"), 5000);
3054
+ const enabled = !Object.prototype.hasOwnProperty.call(flags, "enabled")
3055
+ ? true
3056
+ : boolFromRaw(flags.enabled, true);
3057
+ if (!Object.prototype.hasOwnProperty.call(flags, "enabled")) {
3058
+ const enabledChoice = await promptChoice(
3059
+ ui,
3060
+ "Route enabled state",
3061
+ [
3062
+ { value: "enabled", label: "Enabled", description: "route will run immediately" },
3063
+ { value: "disabled", label: "Disabled", description: "save the route but keep it inactive" },
3064
+ ],
3065
+ { defaultIndex: enabled ? 0 : 1 },
3066
+ );
3067
+ flags.enabled = enabledChoice?.value === "disabled" ? "false" : "true";
3068
+ }
3069
+ const nextRoute = buildRunnerRoutePayload({
3070
+ name: routeName,
3071
+ enabled: boolFromRaw(flags.enabled, true),
3072
+ projectID,
3073
+ provider,
3074
+ role,
3075
+ roleProfile: role,
3076
+ serverBotName: botSelection.name,
3077
+ serverBotID: botSelection.id,
3078
+ destinationID: destination.id,
3079
+ destinationLabel: destination.label,
3080
+ pollIntervalMs,
3081
+ });
3082
+ const saved = upsertRunnerRouteConfig(config, nextRoute);
3083
+ const filePath = saveBotRunnerConfig(saved, config.filePath);
3084
+ process.stdout.write(`Saved runner route "${nextRoute.name}" to ${filePath}\n`);
3085
+ } finally {
3086
+ ui.close();
3087
+ }
3088
+ }
3089
+
3090
+ async function runRunnerRouteEdit(flags) {
3091
+ const ui = createPrompter();
3092
+ try {
3093
+ if (shouldRenderPromptChrome(flags)) {
3094
+ ui.setFlow("RUNNER ROUTE EDIT", "Update one executable runner route");
3095
+ }
3096
+ const selection = await selectRunnerManagementRoute(ui, flags, { title: "Select runner route to edit" });
3097
+ const currentRoute = normalizeRunnerRoute(selection.route);
3098
+ const config = selection.config;
3099
+
3100
+ const projectChange = await promptChoice(
3101
+ ui,
3102
+ "Project selection",
3103
+ [
3104
+ { value: "keep", label: "Keep current project", description: currentRoute.projectID || "-" },
3105
+ { value: "change", label: "Change project", description: "select another mapped project or enter a UUID" },
3106
+ ],
3107
+ { defaultIndex: 0 },
3108
+ );
3109
+ const projectID = projectChange?.value === "change"
3110
+ ? await resolveRunnerRouteProjectID(ui, {}, config)
3111
+ : currentRoute.projectID;
3112
+
3113
+ const nameAction = await promptChoice(
3114
+ ui,
3115
+ "Runner route name",
3116
+ [
3117
+ { value: "keep", label: "Keep current value", description: currentRoute.name || runnerRouteKey(currentRoute) },
3118
+ { value: "change", label: "Change value", description: "rename this route" },
3119
+ ],
3120
+ { defaultIndex: 0 },
3121
+ );
3122
+ const routeName = nameAction?.value === "change"
3123
+ ? await promptRequiredLine(ui, "Runner route name", currentRoute.name || "")
3124
+ : currentRoute.name;
3125
+
3126
+ const roleAction = await promptChoice(
3127
+ ui,
3128
+ "Runner role",
3129
+ [
3130
+ { value: "keep", label: "Keep current role", description: currentRoute.role || "-" },
3131
+ { value: "change", label: "Change role", description: "select a different execution role" },
3132
+ ],
3133
+ { defaultIndex: 0 },
3134
+ );
3135
+ const role = roleAction?.value === "change"
3136
+ ? await selectRunnerRole(ui, {}, currentRoute.role)
3137
+ : currentRoute.role;
3138
+
3139
+ const botAction = await promptChoice(
3140
+ ui,
3141
+ "Server bot selection",
3142
+ [
3143
+ { value: "keep", label: "Keep current server bot", description: currentRoute.botName || currentRoute.botID || "-" },
3144
+ { value: "change", label: "Change server bot", description: "pick the server bot that should own this route" },
3145
+ ],
3146
+ { defaultIndex: role !== currentRoute.role ? 1 : 0 },
3147
+ );
3148
+ const botSelection = botAction?.value === "change" || role !== currentRoute.role
3149
+ ? await selectRunnerServerBotProfile(ui, {
3150
+ provider: currentRoute.provider,
3151
+ role,
3152
+ flags: {},
3153
+ currentBotID: currentRoute.botID,
3154
+ currentBotName: currentRoute.botName,
3155
+ })
3156
+ : { name: currentRoute.botName, id: currentRoute.botID };
3157
+
3158
+ const destinationAction = await promptChoice(
3159
+ ui,
3160
+ "Project chat destination",
3161
+ [
3162
+ { value: "keep", label: "Keep current destination", description: currentRoute.destinationLabel || currentRoute.destinationID || "-" },
3163
+ { value: "change", label: "Change destination", description: "select another active project destination" },
3164
+ ],
3165
+ { defaultIndex: projectID !== currentRoute.projectID ? 1 : 0 },
3166
+ );
3167
+ const destination = destinationAction?.value === "change" || projectID !== currentRoute.projectID
3168
+ ? await selectRunnerDestination(ui, {
3169
+ projectID,
3170
+ provider: currentRoute.provider,
3171
+ flags: {},
3172
+ currentDestinationID: currentRoute.destinationID,
3173
+ currentDestinationLabel: currentRoute.destinationLabel,
3174
+ })
3175
+ : { id: currentRoute.destinationID, label: currentRoute.destinationLabel };
3176
+
3177
+ const pollAction = await promptChoice(
3178
+ ui,
3179
+ "Poll interval (ms)",
3180
+ [
3181
+ { value: "keep", label: "Keep current value", description: String(currentRoute.pollIntervalMs || 5000) },
3182
+ { value: "change", label: "Change value", description: "set a different poll interval" },
3183
+ ],
3184
+ { defaultIndex: 0 },
3185
+ );
3186
+ const pollIntervalMs = pollAction?.value === "change"
3187
+ ? intFromRaw(await promptRequiredLine(ui, "Poll interval (ms)", String(currentRoute.pollIntervalMs || 5000)), currentRoute.pollIntervalMs || 5000)
3188
+ : currentRoute.pollIntervalMs;
3189
+
3190
+ const enabledChoice = await promptChoice(
3191
+ ui,
3192
+ "Route enabled state",
3193
+ [
3194
+ { value: "keep", label: "Keep current state", description: currentRoute.enabled ? "enabled" : "disabled" },
3195
+ { value: "enabled", label: "Enable route", description: "runner can execute this route" },
3196
+ { value: "disabled", label: "Disable route", description: "keep it configured but inactive" },
3197
+ ],
3198
+ { defaultIndex: 0 },
3199
+ );
3200
+ const enabled = enabledChoice?.value === "enabled"
3201
+ ? true
3202
+ : enabledChoice?.value === "disabled"
3203
+ ? false
3204
+ : currentRoute.enabled;
3205
+
3206
+ const confirmed = await promptConfirmChoice(ui, "Save runner route changes now?", {
3207
+ confirmDescription: "write the updated route to bot-runner.json",
3208
+ cancelDescription: "leave the route unchanged",
3209
+ });
3210
+ if (!confirmed) {
3211
+ process.stdout.write("Cancelled.\n");
3212
+ return;
3213
+ }
3214
+ const nextRoute = buildRunnerRoutePayload({
3215
+ currentRoute,
3216
+ name: routeName,
3217
+ enabled,
3218
+ projectID,
3219
+ provider: currentRoute.provider,
3220
+ role,
3221
+ roleProfile: role,
3222
+ serverBotName: botSelection.name,
3223
+ serverBotID: botSelection.id,
3224
+ destinationID: destination.id,
3225
+ destinationLabel: destination.label,
3226
+ pollIntervalMs,
3227
+ });
3228
+ const nextConfig = {
3229
+ ...config,
3230
+ routes: ensureArray(config.routes)
3231
+ .map((route) => normalizeRunnerRoute(route))
3232
+ .filter((route) => String(route.name || "").trim().toLowerCase() !== String(currentRoute.name || "").trim().toLowerCase()),
3233
+ };
3234
+ const filePath = saveBotRunnerConfig(upsertRunnerRouteConfig(nextConfig, nextRoute), config.filePath);
3235
+ process.stdout.write(`Updated runner route "${nextRoute.name}" in ${filePath}\n`);
3236
+ } finally {
3237
+ ui.close();
3238
+ }
3239
+ }
3240
+
3241
+ async function runRunnerRouteRemove(flags) {
3242
+ const ui = createPrompter();
3243
+ try {
3244
+ if (shouldRenderPromptChrome(flags)) {
3245
+ ui.setFlow("RUNNER ROUTE REMOVE", "Delete one executable runner route");
3246
+ }
3247
+ const selection = await selectRunnerManagementRoute(ui, flags, { title: "Select runner route to remove" });
3248
+ const currentRoute = normalizeRunnerRoute(selection.route);
3249
+ const confirmed = await promptConfirmChoice(ui, `Remove runner route "${currentRoute.name || runnerRouteKey(currentRoute)}"?`, {
3250
+ confirmDescription: "delete the route from bot-runner.json",
3251
+ cancelDescription: "keep the route unchanged",
3252
+ });
3253
+ if (!confirmed) {
3254
+ process.stdout.write("Cancelled.\n");
3255
+ return;
3256
+ }
3257
+ const nextConfig = removeRunnerRouteFromConfig(selection.config, currentRoute.name);
3258
+ const filePath = saveBotRunnerConfig(nextConfig, selection.config.filePath);
3259
+ process.stdout.write(`Removed runner route "${currentRoute.name}" from ${filePath}\n`);
3260
+ } finally {
3261
+ ui.close();
3262
+ }
3263
+ }
3264
+
2657
3265
  async function runRunnerList(flags) {
2658
3266
  const rows = buildRunnerRouteListRows();
2659
3267
  if (boolFromRaw(flags.json, false)) {
@@ -2661,6 +3269,7 @@ async function runRunnerList(flags) {
2661
3269
  return;
2662
3270
  }
2663
3271
  process.stdout.write("Runner routes\n");
3272
+ process.stdout.write(" note: routes are the executable unit. --bot-name and --bot-id are convenience selectors that resolve one enabled route when the match is unique.\n");
2664
3273
  if (!rows.length) {
2665
3274
  process.stdout.write(" none configured\n");
2666
3275
  return;
@@ -2673,18 +3282,141 @@ async function runRunnerList(flags) {
2673
3282
  ` provider: ${row.provider}`,
2674
3283
  ` project_id: ${row.projectID}`,
2675
3284
  ` server_bot_name: ${row.botName}`,
3285
+ ` server_bot_name_source: ${row.botNameSource}`,
2676
3286
  ` server_bot_id: ${row.botID}`,
2677
3287
  ` role: ${row.role}`,
2678
3288
  ` role_profile: ${row.roleProfile}`,
2679
3289
  ` destination_label: ${row.destinationLabel}`,
2680
3290
  ` poll_interval_ms: ${row.pollIntervalMs}`,
2681
3291
  ` run_once: ${CLI_NAME} runner once --route-name ${row.name}`,
2682
- row.botName ? ` run_once_by_server_bot_name: ${CLI_NAME} runner once --bot-name "${row.botName}"` : "",
3292
+ row.botName && row.botNameSource !== "unresolved"
3293
+ ? ` route_alias_by_server_bot_name: ${CLI_NAME} runner once --bot-name "${row.botName}"`
3294
+ : "",
2683
3295
  ].join("\n") + "\n",
2684
3296
  );
2685
3297
  });
2686
3298
  }
2687
3299
 
3300
+ function resolveRunnerShowSelection(flags) {
3301
+ const routes = resolveRunnerRoutes(flags, "once");
3302
+ if (!routes.length) {
3303
+ throw new Error("no runner route matched the provided filters");
3304
+ }
3305
+ if (routes.length > 1) {
3306
+ const names = routes.map((route) => normalizeRunnerRoute(route).name || runnerRouteKey(route)).join(", ");
3307
+ throw new Error(`Multiple enabled runner routes matched. Narrow with --route-name, --bot-name, or --bot-id. Matches: ${names}`);
3308
+ }
3309
+ return normalizeRunnerRoute(routes[0]);
3310
+ }
3311
+
3312
+ function findRunnerTelegramEntryForRoute(route, telegramEntries) {
3313
+ const normalizedRoute = normalizeRunnerRoute(route);
3314
+ if (normalizedRoute.provider !== "telegram") return null;
3315
+ return ensureArray(telegramEntries).find((entry) => {
3316
+ const current = safeObject(entry);
3317
+ return (
3318
+ (normalizedRoute.botID && current.serverBotID && current.serverBotID === normalizedRoute.botID)
3319
+ || (normalizedRoute.botName && current.serverBotName && current.serverBotName === normalizedRoute.botName)
3320
+ );
3321
+ }) || null;
3322
+ }
3323
+
3324
+ function buildRunnerShowPayload(route, flags = {}) {
3325
+ const normalizedRoute = normalizeRunnerRoute(route);
3326
+ const runnerConfig = loadBotRunnerConfig({ persistIfNeeded: true });
3327
+ const diagnostics = collectRunnerRouteDiagnostics(normalizedRoute, runnerConfig);
3328
+ const telegramState = readTelegramEnvState();
3329
+ const telegramEntries = ensureArray(telegramState.entries);
3330
+ const matchedTelegramEntry = findRunnerTelegramEntryForRoute(normalizedRoute, telegramEntries);
3331
+ const botNameSource = normalizedRoute.botName
3332
+ ? "route_config"
3333
+ : matchedTelegramEntry?.serverBotName
3334
+ ? "telegram_env_lookup"
3335
+ : "unresolved";
3336
+ const resolvedServerBotName = firstNonEmptyString([
3337
+ normalizedRoute.botName,
3338
+ matchedTelegramEntry?.serverBotName,
3339
+ "-",
3340
+ ]);
3341
+ const envConfig = normalizedRoute.provider
3342
+ ? loadProviderEnvConfig(normalizedRoute.provider, {
3343
+ botID: normalizedRoute.botID,
3344
+ botName: resolvedServerBotName !== "-" ? resolvedServerBotName : "",
3345
+ route: normalizedRoute,
3346
+ })
3347
+ : null;
3348
+ return {
3349
+ ok: diagnostics.errors.length === 0,
3350
+ route_name: normalizedRoute.name || runnerRouteKey(normalizedRoute),
3351
+ route_key: runnerRouteKey(normalizedRoute),
3352
+ route_config_file: runnerConfig.filePath,
3353
+ route_config: serializeRunnerRoute(normalizedRoute),
3354
+ resolved_server_identity: {
3355
+ server_bot_name: resolvedServerBotName,
3356
+ server_bot_name_source: botNameSource,
3357
+ server_bot_id: normalizedRoute.botID || "-",
3358
+ telegram_entry_file: String(envConfig?.entryFilePath || "").trim() || "-",
3359
+ },
3360
+ workspace_mapping: {
3361
+ workspace_dir: diagnostics.workspaceDir || "-",
3362
+ workspace_source: diagnostics.workspaceSource || "-",
3363
+ },
3364
+ execution_profile: {
3365
+ route_role: normalizedRoute.role || "-",
3366
+ role_profile_name: diagnostics.roleProfileName || "-",
3367
+ client: String(diagnostics.roleProfile?.client || "").trim() || "-",
3368
+ model: String(diagnostics.roleProfile?.model || "").trim() || "-",
3369
+ permission_mode: String(diagnostics.roleProfile?.permissionMode || "").trim() || "-",
3370
+ reasoning_effort: String(diagnostics.roleProfile?.reasoningEffort || "").trim() || "-",
3371
+ },
3372
+ route_selection_note: "Routes are the executable unit. --bot-name and --bot-id are convenience selectors that resolve one enabled route when the match is unique.",
3373
+ warnings: diagnostics.warnings,
3374
+ errors: diagnostics.errors,
3375
+ };
3376
+ }
3377
+
3378
+ async function runRunnerShow(flags) {
3379
+ const route = resolveRunnerShowSelection(flags);
3380
+ const payload = buildRunnerShowPayload(route, flags);
3381
+ if (boolFromRaw(flags.json, false)) {
3382
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
3383
+ return;
3384
+ }
3385
+ process.stdout.write("Runner route details\n");
3386
+ process.stdout.write(" note: routes are the executable unit. --bot-name and --bot-id are convenience selectors that resolve one enabled route when the match is unique.\n");
3387
+ process.stdout.write(
3388
+ [
3389
+ ` route_name: ${payload.route_name}`,
3390
+ ` route_key: ${payload.route_key}`,
3391
+ ` route_config_file: ${payload.route_config_file}`,
3392
+ " route_config:",
3393
+ ` provider: ${payload.route_config.provider || "-"}`,
3394
+ ` project_id: ${payload.route_config.project_id || "-"}`,
3395
+ ` role: ${payload.route_config.role || "-"}`,
3396
+ ` role_profile: ${payload.route_config.role_profile || "-"}`,
3397
+ ` destination_label: ${payload.route_config.destination_label || "-"}`,
3398
+ ` poll_interval_ms: ${payload.route_config.poll_interval_ms || 0}`,
3399
+ " resolved_server_identity:",
3400
+ ` server_bot_name: ${payload.resolved_server_identity.server_bot_name}`,
3401
+ ` server_bot_name_source: ${payload.resolved_server_identity.server_bot_name_source}`,
3402
+ ` server_bot_id: ${payload.resolved_server_identity.server_bot_id}`,
3403
+ ` telegram_entry_file: ${payload.resolved_server_identity.telegram_entry_file}`,
3404
+ " workspace_mapping:",
3405
+ ` workspace_dir: ${payload.workspace_mapping.workspace_dir}`,
3406
+ ` workspace_source: ${payload.workspace_mapping.workspace_source}`,
3407
+ " execution_profile:",
3408
+ ` route_role: ${payload.execution_profile.route_role}`,
3409
+ ` role_profile_name: ${payload.execution_profile.role_profile_name}`,
3410
+ ` client: ${payload.execution_profile.client}`,
3411
+ ` model: ${payload.execution_profile.model}`,
3412
+ ` permission_mode: ${payload.execution_profile.permission_mode}`,
3413
+ ` reasoning_effort: ${payload.execution_profile.reasoning_effort}`,
3414
+ payload.warnings.length ? ` warnings: ${payload.warnings.join("; ")}` : " warnings: -",
3415
+ payload.errors.length ? ` errors: ${payload.errors.join("; ")}` : " errors: -",
3416
+ ].join("\n") + "\n",
3417
+ );
3418
+ }
3419
+
2688
3420
  async function runRunnerStart(flags) {
2689
3421
  const jsonMode = boolFromRaw(flags.json, false);
2690
3422
  const routes = resolveRunnerRoutes(flags, "start");
@@ -2744,11 +3476,40 @@ async function runRunnerStart(flags) {
2744
3476
  async function runRunner(argv) {
2745
3477
  const [subcommandRaw = "", ...rest] = argv;
2746
3478
  const subcommand = String(subcommandRaw || "").trim().toLowerCase();
3479
+ if (subcommand === "route" || subcommand === "routes") {
3480
+ const [routeSubcommandRaw = "", ...routeRest] = rest;
3481
+ const routeSubcommand = String(routeSubcommandRaw || "").trim().toLowerCase();
3482
+ const routeArgv = !routeSubcommand || routeSubcommand.startsWith("-")
3483
+ ? [routeSubcommandRaw, ...routeRest].filter((value) => String(value || "").trim())
3484
+ : routeRest;
3485
+ const routeFlags = parseArgs(routeArgv);
3486
+ if (!routeSubcommand || routeSubcommand === "list" || routeSubcommand.startsWith("-")) {
3487
+ await runRunnerList(routeFlags);
3488
+ return;
3489
+ }
3490
+ if (routeSubcommand === "add") {
3491
+ await runRunnerRouteAdd(routeFlags);
3492
+ return;
3493
+ }
3494
+ if (routeSubcommand === "edit") {
3495
+ await runRunnerRouteEdit(routeFlags);
3496
+ return;
3497
+ }
3498
+ if (routeSubcommand === "remove" || routeSubcommand === "rm" || routeSubcommand === "delete") {
3499
+ await runRunnerRouteRemove(routeFlags);
3500
+ return;
3501
+ }
3502
+ throw new Error("runner route requires a subcommand: list | add | edit | remove");
3503
+ }
2747
3504
  const flags = parseArgs(rest);
2748
3505
  if (subcommand === "list") {
2749
3506
  await runRunnerList(flags);
2750
3507
  return;
2751
3508
  }
3509
+ if (subcommand === "show") {
3510
+ await runRunnerShow(flags);
3511
+ return;
3512
+ }
2752
3513
  if (subcommand === "once") {
2753
3514
  await runRunnerOnce(flags);
2754
3515
  return;
@@ -2757,7 +3518,7 @@ async function runRunner(argv) {
2757
3518
  await runRunnerStart(flags);
2758
3519
  return;
2759
3520
  }
2760
- throw new Error("runner requires a subcommand: list | once | start");
3521
+ throw new Error("runner requires a subcommand: list | show | once | start | route");
2761
3522
  }
2762
3523
 
2763
3524
  async function runLocalBotBridge(argv) {
@@ -5838,6 +6599,10 @@ TELEGRAM_BOT_REVIEW_TOKEN=review-token
5838
6599
  defaultLocalBotBridgeCommand,
5839
6600
  resolveRunnerExecutionPlan,
5840
6601
  normalizeRunnerRoute,
6602
+ buildRunnerRouteNameSuggestion,
6603
+ buildRunnerRoutePayload,
6604
+ upsertRunnerRouteConfig,
6605
+ removeRunnerRouteFromConfig,
5841
6606
  buildRunnerExecutionDeps,
5842
6607
  defaultBotRunnerRoleProfiles,
5843
6608
  resolveRunnerRoutes,
@@ -201,7 +201,7 @@ function providerTokenKey(provider, deps) {
201
201
  return safeObject(requireDependency(deps, "providerEnvConfig")(provider)).tokenKey || "";
202
202
  }
203
203
 
204
- function shouldRenderPromptChrome(flags) {
204
+ export function shouldRenderPromptChrome(flags) {
205
205
  const parsedFlags = safeObject(flags);
206
206
  if (boolFromRaw(parsedFlags["non-interactive"] ?? parsedFlags.yes, false)) return false;
207
207
  if (boolFromRaw(parsedFlags.json, false)) return false;
@@ -240,7 +240,7 @@ function printBotUsage(deps) {
240
240
  );
241
241
  }
242
242
 
243
- function createPrompter() {
243
+ export function createPrompter() {
244
244
  let flowTitle = "";
245
245
  let flowSubtitle = "";
246
246
  let stepIndex = 0;
@@ -311,7 +311,7 @@ function createPrompter() {
311
311
  };
312
312
  }
313
313
 
314
- async function promptLine(ui, promptText, defaultValue = "") {
314
+ export async function promptLine(ui, promptText, defaultValue = "") {
315
315
  ui.beginPrompt(promptText, [
316
316
  String(defaultValue || "").trim()
317
317
  ? `Press Enter to keep the current value: ${defaultValue}`
@@ -323,7 +323,7 @@ async function promptLine(ui, promptText, defaultValue = "") {
323
323
  return text || String(defaultValue || "").trim();
324
324
  }
325
325
 
326
- async function promptRequiredLine(ui, promptText, defaultValue = "") {
326
+ export async function promptRequiredLine(ui, promptText, defaultValue = "") {
327
327
  while (true) {
328
328
  const answer = await promptLine(ui, promptText, defaultValue);
329
329
  if (answer) return answer;
@@ -331,7 +331,7 @@ async function promptRequiredLine(ui, promptText, defaultValue = "") {
331
331
  }
332
332
  }
333
333
 
334
- async function promptYesNo(ui, promptText, defaultValue = true) {
334
+ export async function promptYesNo(ui, promptText, defaultValue = true) {
335
335
  ui.beginPrompt(promptText, ["Choose y or n, then press Enter."]);
336
336
  const hint = defaultValue ? "Y/n" : "y/N";
337
337
  while (true) {
@@ -343,7 +343,7 @@ async function promptYesNo(ui, promptText, defaultValue = true) {
343
343
  }
344
344
  }
345
345
 
346
- async function promptConfirmChoice(
346
+ export async function promptConfirmChoice(
347
347
  ui,
348
348
  title,
349
349
  {
@@ -367,7 +367,7 @@ function formatChoiceLabel(option) {
367
367
  return `${String(option.label || option.value || "").trim()}${option.description ? ` - ${option.description}` : ""}`;
368
368
  }
369
369
 
370
- async function promptChoice(ui, title, options, { defaultIndex = 0, allowCancel = false } = {}) {
370
+ export async function promptChoice(ui, title, options, { defaultIndex = 0, allowCancel = false } = {}) {
371
371
  const list = ensureArray(options).filter((item) => item && item.value !== undefined);
372
372
  if (!list.length) {
373
373
  throw new Error(`no options available for ${title}`);
@@ -399,7 +399,7 @@ async function promptChoice(ui, title, options, { defaultIndex = 0, allowCancel
399
399
  }
400
400
  }
401
401
 
402
- async function promptKeepChangeClear(ui, title, { allowClear = true, defaultValue = "keep" } = {}) {
402
+ export async function promptKeepChangeClear(ui, title, { allowClear = true, defaultValue = "keep" } = {}) {
403
403
  const options = [
404
404
  { value: "keep", label: "Keep current value" },
405
405
  { value: "change", label: "Change value" },
@@ -2054,36 +2054,18 @@ async function maybePromptGroupedServerRoleProfiles(ui, serverBot, deps) {
2054
2054
  }
2055
2055
  return true;
2056
2056
  }
2057
- const remaining = new Set(roles);
2058
- while (remaining.size) {
2059
- const roleChoice = await promptChoice(
2060
- ui,
2061
- "Select role to edit",
2062
- [
2063
- ...Array.from(remaining).map((role) => ({
2064
- value: role,
2065
- label: role,
2066
- description: formatRoleProfileSummary(currentRoleProfileState(role, deps)),
2067
- })),
2068
- {
2069
- value: "__done__",
2070
- label: "Done",
2071
- description: "finish grouped role editing",
2072
- },
2073
- ],
2074
- { defaultIndex: 0 },
2075
- );
2076
- if (!roleChoice || roleChoice.value === "__done__") {
2077
- break;
2078
- }
2057
+ const roleChoice = await promptChoice(
2058
+ ui,
2059
+ "Select role to edit",
2060
+ roles.map((role) => ({
2061
+ value: role,
2062
+ label: role,
2063
+ description: formatRoleProfileSummary(currentRoleProfileState(role, deps)),
2064
+ })),
2065
+ { defaultIndex: 0 },
2066
+ );
2067
+ if (roleChoice?.value) {
2079
2068
  await promptRoleExecutionProfile(ui, roleChoice.value, deps);
2080
- remaining.delete(roleChoice.value);
2081
- if (!remaining.size) {
2082
- break;
2083
- }
2084
- if (!await promptYesNo(ui, "Edit another role?", false)) {
2085
- break;
2086
- }
2087
2069
  }
2088
2070
  return true;
2089
2071
  }
@@ -479,14 +479,6 @@ export async function runSelftestBotCommands(push, deps) {
479
479
  "2", // worker AI model: Sonnet 4.6r
480
480
  "4", // worker permission: danger_full_access
481
481
  "4", // worker reasoning: high
482
- "y", // edit another role
483
- "3", // select role to edit: approval
484
- "2", // approval: edit settings
485
- "4", // approval AI client: gemini
486
- "2", // approval AI model: gemini-3.1-pro
487
- "4", // approval permission: danger_full_access
488
- "4", // approval reasoning: high
489
- "n", // stop editing roles
490
482
  "1", // keep current default setting
491
483
  "y", // save
492
484
  ]),
@@ -501,13 +493,13 @@ export async function runSelftestBotCommands(push, deps) {
501
493
  const groupedRunnerConfig = readJSON(fs.readFileSync(groupedRunnerConfigPath, "utf8"));
502
494
  const groupedRoleProfiles = safeObject(groupedRunnerConfig.role_profiles || groupedRunnerConfig.roleProfiles);
503
495
  push(
504
- "bot_edit_grouped_server_roles_updates_role_profiles",
496
+ "bot_edit_grouped_server_roles_updates_role_profiles",
505
497
  String(safeObject(safeObject(groupedRoleProfiles).worker).client || "") === "claude"
506
498
  && String(safeObject(safeObject(groupedRoleProfiles).worker).model || "") === "Sonnet 4.6r"
507
499
  && String(safeObject(safeObject(groupedRoleProfiles).worker).permission_mode || "") === "danger_full_access"
508
500
  && String(safeObject(safeObject(groupedRoleProfiles).worker).reasoning_effort || "") === "high"
509
- && String(safeObject(safeObject(groupedRoleProfiles).approval).client || "") === "gemini"
510
- && String(safeObject(safeObject(groupedRoleProfiles).approval).model || "") === "gemini-3.1-pro",
501
+ && String(safeObject(safeObject(groupedRoleProfiles).approval).client || "") === "claude"
502
+ && String(safeObject(safeObject(groupedRoleProfiles).approval).model || "") === "Sonnet 4.6r",
511
503
  `worker=${JSON.stringify(safeObject(safeObject(groupedRoleProfiles).worker))} approval=${JSON.stringify(safeObject(safeObject(groupedRoleProfiles).approval))}`,
512
504
  );
513
505
 
@@ -527,7 +519,7 @@ export async function runSelftestBotCommands(push, deps) {
527
519
  "bot_show_reports_grouped_server_roles",
528
520
  safeObject(groupedShowPayload.serverBinding).mode === "group"
529
521
  && safeObject(safeObject(groupedShowPayload.serverBinding).effectiveRoleProfiles).worker?.client === "claude"
530
- && safeObject(safeObject(groupedShowPayload.serverBinding).effectiveRoleProfiles).approval?.client === "gemini"
522
+ && safeObject(safeObject(groupedShowPayload.serverBinding).effectiveRoleProfiles).approval?.client === "claude"
531
523
  && String(safeObject(safeObject(groupedShowPayload.serverBinding).effectiveRoleProfiles).monitor?.model || "") === "gpt-5.4",
532
524
  `mode=${String(safeObject(groupedShowPayload.serverBinding).mode || "")} worker=${String(safeObject(safeObject(groupedShowPayload.serverBinding).effectiveRoleProfiles).worker?.client || "")} approval=${String(safeObject(safeObject(groupedShowPayload.serverBinding).effectiveRoleProfiles).approval?.client || "")} monitor_model=${String(safeObject(safeObject(groupedShowPayload.serverBinding).effectiveRoleProfiles).monitor?.model || "")}`,
533
525
  );
@@ -28,6 +28,10 @@ export async function runSelftestRunnerScenarios(push, deps) {
28
28
  const defaultLocalBotBridgeCommand = requireDependency(deps, "defaultLocalBotBridgeCommand");
29
29
  const resolveRunnerExecutionPlan = requireDependency(deps, "resolveRunnerExecutionPlan");
30
30
  const normalizeRunnerRoute = requireDependency(deps, "normalizeRunnerRoute");
31
+ const buildRunnerRouteNameSuggestion = requireDependency(deps, "buildRunnerRouteNameSuggestion");
32
+ const buildRunnerRoutePayload = requireDependency(deps, "buildRunnerRoutePayload");
33
+ const upsertRunnerRouteConfig = requireDependency(deps, "upsertRunnerRouteConfig");
34
+ const removeRunnerRouteFromConfig = requireDependency(deps, "removeRunnerRouteFromConfig");
31
35
  const buildRunnerExecutionDeps = requireDependency(deps, "buildRunnerExecutionDeps");
32
36
  const defaultBotRunnerRoleProfiles = requireDependency(deps, "defaultBotRunnerRoleProfiles");
33
37
  const resolveRunnerRoutes = requireDependency(deps, "resolveRunnerRoutes");
@@ -139,6 +143,51 @@ export async function runSelftestRunnerScenarios(push, deps) {
139
143
  `mapping=${String(migratedRunnerConfig.projectMappings?.[selftestProjectID]?.workspaceDir || "(none)")}`,
140
144
  );
141
145
 
146
+ const suggestedRouteName = buildRunnerRouteNameSuggestion(
147
+ { provider: "telegram", role: "monitor", botName: "Server Protocol Monitor Bot" },
148
+ ["telegram-monitor-server-protocol-monitor-bot"],
149
+ );
150
+ push(
151
+ "runner_route_name_suggestion_is_slugged_and_unique",
152
+ suggestedRouteName === "telegram-monitor-server-protocol-monitor-bot-2",
153
+ `suggested=${suggestedRouteName}`,
154
+ );
155
+
156
+ const routePayload = buildRunnerRoutePayload({
157
+ name: "telegram-monitor-server-protocol-monitor-bot",
158
+ enabled: true,
159
+ projectID: selftestProjectID,
160
+ provider: "telegram",
161
+ role: "monitor",
162
+ roleProfile: "monitor",
163
+ serverBotName: "Server Protocol Monitor Bot",
164
+ serverBotID: "11111111-2222-3333-4444-555555555555",
165
+ destinationID: "dest-1",
166
+ destinationLabel: "Main Room",
167
+ pollIntervalMs: 5000,
168
+ });
169
+ const routeConfigWithPayload = upsertRunnerRouteConfig(
170
+ normalizeBotRunnerConfigContents({ version: 2, routes: [] }, "selftest-runner-route-add.json"),
171
+ routePayload,
172
+ );
173
+ const savedRoute = (Array.isArray(routeConfigWithPayload.routes) ? routeConfigWithPayload.routes : [])
174
+ .map((route) => normalizeRunnerRoute(route))[0];
175
+ push(
176
+ "runner_route_payload_persists_server_identity_fields",
177
+ String(savedRoute.name || "") === "telegram-monitor-server-protocol-monitor-bot"
178
+ && String(savedRoute.botName || "") === "Server Protocol Monitor Bot"
179
+ && String(savedRoute.botID || "") === "11111111-2222-3333-4444-555555555555"
180
+ && String(savedRoute.destinationID || "") === "dest-1",
181
+ `name=${String(savedRoute.name || "")} bot_name=${String(savedRoute.botName || "")} bot_id=${String(savedRoute.botID || "")} destination_id=${String(savedRoute.destinationID || "")}`,
182
+ );
183
+
184
+ const routeConfigWithoutPayload = removeRunnerRouteFromConfig(routeConfigWithPayload, "telegram-monitor-server-protocol-monitor-bot");
185
+ push(
186
+ "runner_route_remove_drops_named_route",
187
+ (Array.isArray(routeConfigWithoutPayload.routes) ? routeConfigWithoutPayload.routes.length : 0) === 0,
188
+ `remaining=${Array.isArray(routeConfigWithoutPayload.routes) ? routeConfigWithoutPayload.routes.length : 0}`,
189
+ );
190
+
142
191
  const roleProfileOverrideConfig = normalizeBotRunnerConfigContents(
143
192
  {
144
193
  version: 2,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metheus-governance-mcp-cli",
3
- "version": "0.2.112",
3
+ "version": "0.2.114",
4
4
  "description": "Metheus Governance MCP CLI (setup + stdio proxy)",
5
5
  "type": "module",
6
6
  "files": [