metheus-governance-mcp-cli 0.2.102 → 0.2.104

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
@@ -31,9 +31,9 @@ Install creates local provider settings templates here:
31
31
  These files are for local provider bot secrets, local transport options, and optional per-bot local AI binding.
32
32
 
33
33
  - Store locally:
34
- - Telegram-wide settings in `~/.metheus/telegram-bots/_global.env`
35
- - `TELEGRAM_BOT_TOKEN` as a legacy Telegram fallback in `_global.env`
36
- - one Telegram bot file per entry in `~/.metheus/telegram-bots/<server-bot-name>.env`
34
+ - Telegram-wide settings in `~/.metheus/telegram-bots/global.env`
35
+ - `TELEGRAM_BOT_TOKEN` as a legacy Telegram fallback in `global.env`
36
+ - one Telegram bot file per entry in `~/.metheus/telegram-bots/<ServerBotName>.env`
37
37
  - `SLACK_BOT_TOKEN`
38
38
  - `KAKAOTALK_BOT_TOKEN`
39
39
  - Server-side Metheus stores project chat destination metadata separately:
@@ -41,12 +41,13 @@ These files are for local provider bot secrets, local transport options, and opt
41
41
  - `chat_id` / channel / room identifier
42
42
  - label / active state
43
43
  - Do not put project chat destination identifiers in local env files.
44
- - Telegram-wide settings now live in `telegram-bots/_global.env`; Telegram per-bot secrets and AI fields live in `telegram-bots/*.env`.
44
+ - Telegram-wide settings now live in `telegram-bots/global.env`; Telegram per-bot secrets and server metadata live in `telegram-bots/*.env`.
45
+ - Grouped server bots also persist `TELEGRAM_BOT_SERVER_ROLE_IDS`, so the local file keeps the exact role-to-server-bot-id mapping instead of pretending that one grouped bot has only one server UUID.
45
46
 
46
47
  Example templates:
47
48
 
48
49
  ```env
49
- # ~/.metheus/telegram-bots/_global.env
50
+ # ~/.metheus/telegram-bots/global.env
50
51
  TELEGRAM_API_BASE_URL=
51
52
  TELEGRAM_AUTO_CLEAR_WEBHOOK=true
52
53
  TELEGRAM_ALLOWED_UPDATES=message,edited_message
@@ -57,10 +58,11 @@ TELEGRAM_BOT_TOKEN=
57
58
  ```
58
59
 
59
60
  ```env
60
- # ~/.metheus/telegram-bots/ryoai_bot.env
61
+ # ~/.metheus/telegram-bots/RyoAI_bot.env
61
62
  TELEGRAM_BOT_SERVER_BOT_ID=<server_bot_uuid>
62
63
  TELEGRAM_BOT_SERVER_NAME=RyoAI_bot
63
64
  TELEGRAM_BOT_SERVER_ROLES=monitor,review,worker,approval
65
+ TELEGRAM_BOT_SERVER_ROLE_IDS=monitor:<uuid>,review:<uuid>,worker:<uuid>,approval:<uuid>
64
66
  # Optional fallback only when server bot binding is unavailable
65
67
  # TELEGRAM_BOT_USERNAME=ryoai_bot
66
68
  TELEGRAM_BOT_TOKEN=
@@ -172,10 +174,12 @@ metheus-governance-mcp-cli setup --project-id <project_uuid> --ctxpack-key "<ctx
172
174
  - `~/.metheus/kakaotalk.env`
173
175
  - `~/.metheus/bot-runner.json`
174
176
 
177
+ Bootstrap or setup does not create one file per server bot automatically. Per-bot Telegram files are created later when you run `bot add`, `bot verify`, or `bot edit` against a real server bot identity.
178
+
175
179
  When Telegram local config already exists, bootstrap/setup keeps your secrets but auto-normalizes the layout to the latest split structure:
176
180
 
177
- - Telegram-wide settings stay in `~/.metheus/telegram-bots/_global.env`
178
- - per-bot secrets move to `~/.metheus/telegram-bots/<server-bot-name>.env`
181
+ - Telegram-wide settings stay in `~/.metheus/telegram-bots/global.env`
182
+ - per-bot secrets move to `~/.metheus/telegram-bots/<ServerBotName>.env`
179
183
  - stale inline keys such as `TELEGRAM_BOT_<NAME>_BOT_*` are rewritten into generic per-bot keys
180
184
 
181
185
  Fill provider bot secrets and provider-local transport options locally. Project chat destination identifiers should be managed on the Metheus server as project chat destinations, not as local env values and not inside legacy Chat Hooks/webhooks.
@@ -185,7 +189,7 @@ Fill provider bot secrets and provider-local transport options locally. Project
185
189
  - which provider/role bot profile to use
186
190
  - which `project_id -> workspace_dir` mapping to apply locally
187
191
  - which role profile maps to which local CLI/model/permission/reasoning policy
188
- - which server bot maps to which local LLM execution profile via `telegram-bots/_global.env`, `telegram-bots/*.env`, or fallback `bot_bindings`
192
+ - which server bot maps to which local LLM execution profile via `telegram-bots/global.env`, `telegram-bots/*.env`, or fallback `bot_bindings`
189
193
 
190
194
  Built-in helper command for legacy fallback/testing:
191
195
 
@@ -261,10 +265,11 @@ Behavior:
261
265
  - `bot set-default` without flags starts a guided numbered flow: provider -> bot entry -> confirm default change.
262
266
  - `bot verify` without flags starts a guided numbered flow: provider -> bot entry -> output format.
263
267
  - `bot remove` without flags starts a guided numbered flow: provider -> bot entry -> confirm removal.
264
- - Telegram stores one bot file per entry under `~/.metheus/telegram-bots/<server-bot-name>.env` with generic fields:
268
+ - Telegram stores one bot file per entry under `~/.metheus/telegram-bots/<ServerBotName>.env` with generic fields:
265
269
  - `TELEGRAM_BOT_SERVER_BOT_ID`
266
270
  - `TELEGRAM_BOT_SERVER_NAME`
267
271
  - `TELEGRAM_BOT_SERVER_ROLES`
272
+ - `TELEGRAM_BOT_SERVER_ROLE_IDS`
268
273
  - `TELEGRAM_BOT_USERNAME` only as a fallback when server bot binding is unavailable
269
274
  - `TELEGRAM_BOT_TOKEN`
270
275
  - `TELEGRAM_BOT_ROLE_PROFILE`
@@ -275,7 +280,7 @@ Behavior:
275
280
  - Slack and KakaoTalk currently use a single local token entry per provider in this command flow.
276
281
  - `bot verify` checks the configured local token, cross-checks the server bot binding, prints the effective runtime role profile summary that the runner will use, and shows which local runner routes currently point at this bot entry.
277
282
  - `bot show` prints one local bot entry in detail, including grouped role summaries when one server bot name expands to multiple roles and any linked local runner routes.
278
- - In `bot show` and `bot verify`, `stored_*` fields come from the local env file and `live_server_*` fields come from the current server `me/bots` response.
283
+ - In `bot show` and `bot verify`, `saved_local_file` means the values already stored on disk under `~/.metheus/telegram-bots/*.env`, while `current_server_protocol` means the fresh `/api/v1/me/bots` response from the server right now.
279
284
  - `bot global` edits Telegram-wide local settings such as API base URL, allowed updates, and default bot key.
280
285
  - `bot set-default` updates `TELEGRAM_DEFAULT_BOT_KEY`.
281
286
  - `bot migrate` moves legacy `TELEGRAM_BOT_TOKEN` into a named Telegram bot entry.
@@ -293,10 +298,12 @@ metheus-governance-mcp-cli bot remove --provider telegram --bot-name RyoAI_bot -
293
298
  metheus-governance-mcp-cli bot verify --provider telegram --bot-name RyoAI_bot --json true
294
299
  ```
295
300
 
296
- For direct Telegram adds, the CLI derives the local file name from the matched server bot name. You normally do not need `--bot-key` or `--username`. Treat `--bot-key` as an advanced compatibility selector only when you intentionally need a different local file stem.
301
+ For direct Telegram adds, the CLI derives the local file name from the matched server bot name. You normally do not need `--bot-key` or `--username`. Treat `--bot-key` as an advanced compatibility selector only when you intentionally need to target an older local selector directly.
297
302
 
298
303
  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 advanced 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.
299
304
 
305
+ 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.
306
+
300
307
  Current support status:
301
308
 
302
309
  - Telegram: full local bot entry management, token verification, bot-to-AI binding, inbound runner support
@@ -377,6 +384,7 @@ Execution model:
377
384
  Commands:
378
385
 
379
386
  ```bash
387
+ metheus-governance-mcp-cli runner list
380
388
  metheus-governance-mcp-cli runner once --route-name telegram-monitor
381
389
  metheus-governance-mcp-cli runner start --route-name telegram-monitor
382
390
  ```
@@ -403,7 +411,7 @@ Recommended production path:
403
411
  - keep `project_mappings.<project_id>.workspace_dir` aligned to that teammate's actual local project folder
404
412
  - let `ctxpack pull` or project connection refresh the mapping automatically
405
413
  - keep per-role execution policy under `role_profiles`
406
- - keep Telegram-wide binding defaults in `~/.metheus/telegram-bots/_global.env`
414
+ - keep Telegram-wide binding defaults in `~/.metheus/telegram-bots/global.env`
407
415
  - use `bot_bindings` in `bot-runner.json` only as local fallback/override
408
416
  - runner resolution order is: explicit `route.role_profile` -> provider env bot binding -> `bot_bindings` -> server bot role -> `route.role`
409
417
 
@@ -476,7 +484,7 @@ Notes:
476
484
  - `local-bot-bridge` reads stdin JSON from the runner and can call Codex/Claude/Gemini for you
477
485
  - `route.command` fallback is disabled by default; enable it only temporarily with `METHEUS_ALLOW_LEGACY_RUNNER_COMMAND=1`
478
486
  - today this automation path is implemented for Telegram end-to-end
479
- - prefer `TELEGRAM_API_BASE_URL=` inside `~/.metheus/telegram-bots/_global.env` for local Telegram API overrides; `METHEUS_TELEGRAM_API_BASE_URL` remains a process-level fallback mainly for mock/regression testing
487
+ - prefer `TELEGRAM_API_BASE_URL=` inside `~/.metheus/telegram-bots/global.env` for local Telegram API overrides; `METHEUS_TELEGRAM_API_BASE_URL` remains a process-level fallback mainly for mock/regression testing
480
488
  - Slack can use direct local send, but automatic inbound runner flow is not completed yet
481
489
  - KakaoTalk config can be stored now, but direct send/runner flow is not implemented yet
482
490
  - `doctor` now reports provider support for both enabled runner routes and active project chat destinations
package/cli.mjs CHANGED
@@ -154,8 +154,9 @@ const AUTH_STORE_RELATIVE_PATH = path.join(".metheus", "governance-mcp-auth.json
154
154
  const BOT_RUNNER_CONFIG_RELATIVE_PATH = path.join(".metheus", "bot-runner.json");
155
155
  const BOT_RUNNER_STATE_RELATIVE_PATH = path.join(".metheus", "bot-runner-state.json");
156
156
  const BOT_RUNNER_CONFIG_VERSION = 2;
157
- const TELEGRAM_LEGACY_ENV_RELATIVE_PATH = path.join(".metheus", "telegram.env");
158
- const TELEGRAM_GLOBAL_ENV_RELATIVE_PATH = path.join(".metheus", "telegram-bots", "_global.env");
157
+ const TELEGRAM_ROOT_LEGACY_ENV_RELATIVE_PATH = path.join(".metheus", "telegram.env");
158
+ const TELEGRAM_LEGACY_GLOBAL_ENV_RELATIVE_PATH = path.join(".metheus", "telegram-bots", "_global.env");
159
+ const TELEGRAM_GLOBAL_ENV_RELATIVE_PATH = path.join(".metheus", "telegram-bots", "global.env");
159
160
  const PROVIDER_ENV_CONFIG = {
160
161
  telegram: {
161
162
  relativePath: TELEGRAM_GLOBAL_ENV_RELATIVE_PATH,
@@ -241,20 +242,21 @@ function printUsage() {
241
242
  ` ${cmd} setup [--project-id <uuid>] [--ctxpack-key <key>] [--base-url <url>] [--workspace-dir <path|auto>] [--workspace-fallback-dir <path>] [--name <server_name>]`,
242
243
  ` ${cmd} bot setup [--provider <telegram|slack|kakaotalk>] [--base-url <url>] [--timeout-seconds <n>]`,
243
244
  ` ${cmd} bot list [--provider <telegram|slack|kakaotalk>] [--json <true|false>]`,
244
- ` ${cmd} bot show [--provider <telegram|slack|kakaotalk>] [--bot-name <server_name>] [--bot-id <uuid>] [--bot-key <advanced_key>] [--json <true|false>]`,
245
+ ` ${cmd} bot show [--provider <telegram|slack|kakaotalk>] [--bot-name <server_name>] [--bot-id <uuid>] [--json <true|false>]`,
245
246
  ` ${cmd} bot add [--provider <telegram|slack|kakaotalk>] [--base-url <url>] [--timeout-seconds <n>]`,
246
247
  ` ${cmd} bot edit [--provider <telegram|slack|kakaotalk>] [--base-url <url>] [--timeout-seconds <n>]`,
247
248
  ` ${cmd} bot remove [--provider <telegram|slack|kakaotalk>]`,
248
- ` ${cmd} bot set-default --provider telegram [--bot-name <server_name>] [--bot-id <uuid>] [--bot-key <advanced_key>]`,
249
- ` ${cmd} bot migrate --provider telegram [--bot-name <server_name>] [--bot-id <uuid>] [--bot-key <advanced_key>]`,
250
- ` ${cmd} bot verify [--provider <telegram|slack|kakaotalk>] [--bot-name <server_name>] [--bot-id <uuid>] [--bot-key <advanced_key>] [--timeout-seconds <n>] [--json <true|false>]`,
249
+ ` ${cmd} bot set-default --provider telegram [--bot-name <server_name>] [--bot-id <uuid>]`,
250
+ ` ${cmd} bot migrate --provider telegram [--bot-name <server_name>] [--bot-id <uuid>]`,
251
+ ` ${cmd} bot verify [--provider <telegram|slack|kakaotalk>] [--bot-name <server_name>] [--bot-id <uuid>] [--timeout-seconds <n>] [--json <true|false>]`,
251
252
  ` ${cmd} bot global --provider telegram [--api-base-url <url>] [--auto-clear-webhook <true|false>] [--allowed-updates <csv>] [--default-bot-key <key>]`,
252
253
  ` ${cmd} doctor [--project-id <uuid>] [--ctxpack-key <key>] [--base-url <url>] [--timeout-seconds <n>] [--strict <true|false>]`,
253
254
  ` ${cmd} proxy [--project-id <uuid>] [--ctxpack-key <key>] [--base-url <url>] [--workspace-dir <path|auto>] [--include-drafts <true|false>] [--auto-pull-on-conflict <true|false>] [--timeout-seconds <n>]`,
254
255
  ` ${cmd} selftest [--json <true|false>]`,
255
256
  ` ${cmd} local-bot-bridge [--client <codex|claude|gemini|sample>] [--cwd <path>] [--model <name>] [--permission-mode <read_only|workspace_write|danger_full_access>] [--reasoning-effort <low|medium|high>]`,
256
- ` ${cmd} runner once [--route-name <name>] [--project-id <uuid>] [--provider <telegram|slack|kakaotalk>] [--role <monitor|review|worker|approval>] [--role-profile <name>] [--bot-name <name>] [--bot-id <uuid>] [--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>]`,
257
- ` ${cmd} runner start [--route-name <name>] [--project-id <uuid>] [--provider <telegram|slack|kakaotalk>] [--role <monitor|review|worker|approval>] [--role-profile <name>] [--bot-name <name>] [--bot-id <uuid>] [--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>]`,
257
+ ` ${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>]`,
258
260
  ` ${cmd} ctxpack pull [--project-id <uuid>] [--base-url <url>] [--workspace-dir <path|auto>] [--paths <csv>] [--timeout-seconds <n>]`,
259
261
  ` ${cmd} auth status`,
260
262
  ` ${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>]`,
@@ -739,18 +741,57 @@ function providerEnvFilePath(provider) {
739
741
  }
740
742
 
741
743
  function telegramLegacyEnvFilePath() {
742
- return resolveHomeFilePath(TELEGRAM_LEGACY_ENV_RELATIVE_PATH);
744
+ return resolveHomeFilePath(TELEGRAM_ROOT_LEGACY_ENV_RELATIVE_PATH);
745
+ }
746
+
747
+ function telegramLegacyGlobalEnvFilePath() {
748
+ return resolveHomeFilePath(TELEGRAM_LEGACY_GLOBAL_ENV_RELATIVE_PATH);
743
749
  }
744
750
 
745
751
  function telegramBotEntriesDirPath() {
746
752
  return resolveHomeFilePath(".metheus/telegram-bots");
747
753
  }
748
754
 
755
+ function normalizeComparablePath(rawPath) {
756
+ const resolved = path.resolve(String(rawPath || ""));
757
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
758
+ }
759
+
760
+ function sanitizeTelegramBotFileStem(rawValue, fallback = "telegram_bot") {
761
+ const text = String(rawValue || "")
762
+ .trim()
763
+ .replace(/[<>:"/\\|?*\x00-\x1f]/g, "_")
764
+ .replace(/\s+/g, "_")
765
+ .replace(/^_+|_+$/g, "");
766
+ return text || fallback;
767
+ }
768
+
749
769
  function telegramBotEntryFilePath(botKey) {
750
770
  const normalizedKey = normalizeTelegramBotEnvKey(botKey || "", "telegram_bot") || "telegram_bot";
751
771
  return path.join(telegramBotEntriesDirPath(), `${normalizedKey}.env`);
752
772
  }
753
773
 
774
+ function telegramBotEntryFilePathForEntry(entryRaw) {
775
+ const entry = safeObject(entryRaw);
776
+ const fallbackKey = normalizeTelegramBotEnvKey(entry.key || "", "telegram_bot") || "telegram_bot";
777
+ const preferredStem = sanitizeTelegramBotFileStem(
778
+ firstNonEmptyString([entry.serverBotName, entry.username, entry.key]),
779
+ fallbackKey,
780
+ );
781
+ return path.join(telegramBotEntriesDirPath(), `${preferredStem}.env`);
782
+ }
783
+
784
+ function findTelegramEntryPathVariant(targetPath) {
785
+ const dirPath = path.dirname(targetPath);
786
+ if (!fs.existsSync(dirPath)) return "";
787
+ const targetComparable = normalizeComparablePath(targetPath);
788
+ const match = fs.readdirSync(dirPath, { withFileTypes: true })
789
+ .filter((item) => item.isFile() && /\.env$/i.test(item.name))
790
+ .map((item) => path.join(dirPath, item.name))
791
+ .find((candidatePath) => normalizeComparablePath(candidatePath) === targetComparable);
792
+ return String(match || "").trim();
793
+ }
794
+
754
795
  function providerEnvTemplate(provider) {
755
796
  const config = providerEnvConfig(provider);
756
797
  if (normalizeBotProvider(provider) === "telegram") {
@@ -758,7 +799,7 @@ function providerEnvTemplate(provider) {
758
799
  "# Metheus local Telegram bot settings",
759
800
  "# Keep this file on your machine only. Do not commit it.",
760
801
  "# Store Telegram-wide settings here.",
761
- "# This global file now lives in ~/.metheus/telegram-bots/_global.env.",
802
+ "# This global file now lives in ~/.metheus/telegram-bots/global.env.",
762
803
  "# Per-bot secrets and AI settings live in ~/.metheus/telegram-bots/<server-bot-name>.env.",
763
804
  "# Project chat destinations must be managed on the Metheus server.",
764
805
  "",
@@ -2525,6 +2566,75 @@ async function runRunnerOnce(flags) {
2525
2566
  }
2526
2567
  }
2527
2568
 
2569
+ function buildRunnerRouteListRows() {
2570
+ const config = loadBotRunnerConfig({ persistIfNeeded: true });
2571
+ const telegramState = readTelegramEnvState();
2572
+ const telegramEntries = ensureArray(telegramState.entries);
2573
+ return ensureArray(config.routes).map((rawRoute, index) => {
2574
+ const route = normalizeRunnerRoute(rawRoute);
2575
+ const matchedTelegramEntry = route.provider === "telegram"
2576
+ ? telegramEntries.find((entry) => {
2577
+ const current = safeObject(entry);
2578
+ return (
2579
+ (route.botID && current.serverBotID && current.serverBotID === route.botID)
2580
+ || (route.botName && current.serverBotName && current.serverBotName === route.botName)
2581
+ );
2582
+ })
2583
+ : null;
2584
+ const resolvedBotName = firstNonEmptyString([
2585
+ route.botName,
2586
+ matchedTelegramEntry?.serverBotName,
2587
+ matchedTelegramEntry?.key,
2588
+ "-",
2589
+ ]);
2590
+ return {
2591
+ index: index + 1,
2592
+ name: route.name || runnerRouteKey(route),
2593
+ enabled: route.enabled !== false,
2594
+ provider: route.provider || "-",
2595
+ projectID: route.projectID || "-",
2596
+ botName: resolvedBotName,
2597
+ botID: route.botID || "-",
2598
+ role: route.role || "-",
2599
+ roleProfile: route.roleProfile || "-",
2600
+ destinationLabel: route.destinationLabel || "-",
2601
+ pollIntervalMs: route.pollIntervalMs || 0,
2602
+ configFilePath: config.filePath,
2603
+ };
2604
+ });
2605
+ }
2606
+
2607
+ async function runRunnerList(flags) {
2608
+ const rows = buildRunnerRouteListRows();
2609
+ if (boolFromRaw(flags.json, false)) {
2610
+ process.stdout.write(`${JSON.stringify({ ok: true, routes: rows }, null, 2)}\n`);
2611
+ return;
2612
+ }
2613
+ process.stdout.write("Runner routes\n");
2614
+ if (!rows.length) {
2615
+ process.stdout.write(" none configured\n");
2616
+ return;
2617
+ }
2618
+ process.stdout.write(` file: ${rows[0].configFilePath}\n`);
2619
+ rows.forEach((row) => {
2620
+ process.stdout.write(
2621
+ [
2622
+ ` - ${row.name}${row.enabled ? "" : " [disabled]"}`,
2623
+ ` provider: ${row.provider}`,
2624
+ ` project_id: ${row.projectID}`,
2625
+ ` server_bot_name: ${row.botName}`,
2626
+ ` bot_id: ${row.botID}`,
2627
+ ` role: ${row.role}`,
2628
+ ` role_profile: ${row.roleProfile}`,
2629
+ ` destination_label: ${row.destinationLabel}`,
2630
+ ` poll_interval_ms: ${row.pollIntervalMs}`,
2631
+ ` run_once: ${CLI_NAME} runner once --route-name ${row.name}`,
2632
+ row.botName ? ` run_once_by_bot_name: ${CLI_NAME} runner once --bot-name "${row.botName}"` : "",
2633
+ ].join("\n") + "\n",
2634
+ );
2635
+ });
2636
+ }
2637
+
2528
2638
  async function runRunnerStart(flags) {
2529
2639
  const jsonMode = boolFromRaw(flags.json, false);
2530
2640
  const routes = resolveRunnerRoutes(flags, "start");
@@ -2585,6 +2695,10 @@ async function runRunner(argv) {
2585
2695
  const [subcommandRaw = "", ...rest] = argv;
2586
2696
  const subcommand = String(subcommandRaw || "").trim().toLowerCase();
2587
2697
  const flags = parseArgs(rest);
2698
+ if (subcommand === "list") {
2699
+ await runRunnerList(flags);
2700
+ return;
2701
+ }
2588
2702
  if (subcommand === "once") {
2589
2703
  await runRunnerOnce(flags);
2590
2704
  return;
@@ -2593,7 +2707,7 @@ async function runRunner(argv) {
2593
2707
  await runRunnerStart(flags);
2594
2708
  return;
2595
2709
  }
2596
- throw new Error("runner requires a subcommand: once | start");
2710
+ throw new Error("runner requires a subcommand: list | once | start");
2597
2711
  }
2598
2712
 
2599
2713
  async function runLocalBotBridge(argv) {
@@ -2707,12 +2821,39 @@ function parseTelegramServerRoles(rawValue) {
2707
2821
  return Array.from(new Set(preferredTelegramServerRoleSort(values)));
2708
2822
  }
2709
2823
 
2824
+ function parseTelegramServerRoleIDs(rawValue) {
2825
+ const output = {};
2826
+ String(rawValue || "")
2827
+ .split(",")
2828
+ .map((value) => String(value || "").trim())
2829
+ .filter(Boolean)
2830
+ .forEach((pair) => {
2831
+ const separatorIndex = pair.indexOf(":");
2832
+ if (separatorIndex <= 0) return;
2833
+ const role = String(pair.slice(0, separatorIndex) || "").trim();
2834
+ const id = String(pair.slice(separatorIndex + 1) || "").trim();
2835
+ if (!role || !id) return;
2836
+ output[role] = id;
2837
+ });
2838
+ return preferredTelegramServerRoleSort(Object.keys(output)).reduce((acc, role) => {
2839
+ acc[role] = output[role];
2840
+ return acc;
2841
+ }, {});
2842
+ }
2843
+
2844
+ function formatTelegramServerRoleIDs(roleIDs) {
2845
+ return preferredTelegramServerRoleSort(Object.keys(safeObject(roleIDs)))
2846
+ .map((role) => `${role}:${String(safeObject(roleIDs)[role] || "").trim()}`)
2847
+ .filter((item) => !item.endsWith(":"))
2848
+ .join(",");
2849
+ }
2850
+
2710
2851
  function collectTelegramEnvBotEntries(parsedEnv) {
2711
2852
  const parsed = safeObject(parsedEnv);
2712
2853
  const entries = new Map();
2713
2854
  for (const [rawKey, rawValue] of Object.entries(parsed)) {
2714
2855
  const match = String(rawKey || "").trim().match(
2715
- /^TELEGRAM_BOT_([A-Z0-9_]+)_(TOKEN|USERNAME|SERVER_BOT_ID|SERVER_NAME|SERVER_ROLES|ROLE_PROFILE|AI_CLIENT|AI_MODEL|AI_PERMISSION_MODE|AI_REASONING_EFFORT)$/i,
2856
+ /^TELEGRAM_BOT_([A-Z0-9_]+)_(TOKEN|USERNAME|SERVER_BOT_ID|SERVER_NAME|SERVER_ROLES|SERVER_ROLE_IDS|ROLE_PROFILE|AI_CLIENT|AI_MODEL|AI_PERMISSION_MODE|AI_REASONING_EFFORT)$/i,
2716
2857
  );
2717
2858
  if (!match) continue;
2718
2859
  const botKey = normalizeTelegramBotEnvKey(match[1], "");
@@ -2725,6 +2866,7 @@ function collectTelegramEnvBotEntries(parsedEnv) {
2725
2866
  serverBotID: "",
2726
2867
  serverBotName: "",
2727
2868
  serverRoles: [],
2869
+ serverRoleIDs: {},
2728
2870
  roleProfile: "",
2729
2871
  client: "",
2730
2872
  model: "",
@@ -2742,6 +2884,8 @@ function collectTelegramEnvBotEntries(parsedEnv) {
2742
2884
  entry.serverBotName = textValue;
2743
2885
  } else if (field === "SERVER_ROLES") {
2744
2886
  entry.serverRoles = parseTelegramServerRoles(textValue);
2887
+ } else if (field === "SERVER_ROLE_IDS") {
2888
+ entry.serverRoleIDs = parseTelegramServerRoleIDs(textValue);
2745
2889
  } else if (field === "ROLE_PROFILE") {
2746
2890
  entry.roleProfile = normalizeRunnerRoleProfileName(textValue);
2747
2891
  } else if (field === "AI_CLIENT") {
@@ -2788,6 +2932,7 @@ function parseTelegramBotEntryFile(filePath) {
2788
2932
  serverBotID: String(parsed.TELEGRAM_BOT_SERVER_BOT_ID || "").trim(),
2789
2933
  serverBotName: String(parsed.TELEGRAM_BOT_SERVER_NAME || "").trim(),
2790
2934
  serverRoles: parseTelegramServerRoles(parsed.TELEGRAM_BOT_SERVER_ROLES || ""),
2935
+ serverRoleIDs: parseTelegramServerRoleIDs(parsed.TELEGRAM_BOT_SERVER_ROLE_IDS || ""),
2791
2936
  token: String(parsed.TELEGRAM_BOT_TOKEN || "").trim(),
2792
2937
  roleProfile: normalizeRunnerRoleProfileName(parsed.TELEGRAM_BOT_ROLE_PROFILE || ""),
2793
2938
  client: normalizeLocalAIClientName(parsed.TELEGRAM_BOT_AI_CLIENT || "", ""),
@@ -2824,7 +2969,11 @@ function loadTelegramBotEntriesFromFiles() {
2824
2969
  const dirPath = telegramBotEntriesDirPath();
2825
2970
  if (!fs.existsSync(dirPath)) return [];
2826
2971
  return fs.readdirSync(dirPath, { withFileTypes: true })
2827
- .filter((item) => item.isFile() && /\.env$/i.test(item.name) && item.name.toLowerCase() !== "_global.env")
2972
+ .filter((item) => item.isFile() && /\.env$/i.test(item.name))
2973
+ .filter((item) => {
2974
+ const lowered = item.name.toLowerCase();
2975
+ return lowered !== "_global.env" && lowered !== "global.env";
2976
+ })
2828
2977
  .map((item) => path.join(dirPath, item.name))
2829
2978
  .sort((left, right) => left.localeCompare(right))
2830
2979
  .map((filePath) => {
@@ -2851,6 +3000,7 @@ function buildMergedTelegramEnvParsed(globalParsed, entries) {
2851
3000
  merged[`TELEGRAM_BOT_${upper}_SERVER_BOT_ID`] = String(entry.serverBotID || "").trim();
2852
3001
  merged[`TELEGRAM_BOT_${upper}_SERVER_NAME`] = String(entry.serverBotName || "").trim();
2853
3002
  merged[`TELEGRAM_BOT_${upper}_SERVER_ROLES`] = ensureArray(entry.serverRoles).join(",");
3003
+ merged[`TELEGRAM_BOT_${upper}_SERVER_ROLE_IDS`] = formatTelegramServerRoleIDs(entry.serverRoleIDs);
2854
3004
  merged[`TELEGRAM_BOT_${upper}_USERNAME`] = normalizeTelegramBotUsername(entry.username || "");
2855
3005
  merged[`TELEGRAM_BOT_${upper}_TOKEN`] = String(entry.token || "").trim();
2856
3006
  merged[`TELEGRAM_BOT_${upper}_ROLE_PROFILE`] = String(entry.roleProfile || "").trim();
@@ -2865,9 +3015,12 @@ function buildMergedTelegramEnvParsed(globalParsed, entries) {
2865
3015
  function readTelegramEnvState() {
2866
3016
  const filePath = providerEnvFilePath("telegram");
2867
3017
  const legacyFilePath = telegramLegacyEnvFilePath();
3018
+ const legacyGlobalFilePath = telegramLegacyGlobalEnvFilePath();
2868
3019
  let sourceFilePath = filePath;
2869
3020
  try {
2870
- if (!fs.existsSync(filePath) && fs.existsSync(legacyFilePath)) {
3021
+ if (!fs.existsSync(filePath) && fs.existsSync(legacyGlobalFilePath)) {
3022
+ sourceFilePath = legacyGlobalFilePath;
3023
+ } else if (!fs.existsSync(filePath) && fs.existsSync(legacyFilePath)) {
2871
3024
  sourceFilePath = legacyFilePath;
2872
3025
  } else if (!fs.existsSync(filePath)) {
2873
3026
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
@@ -2877,6 +3030,7 @@ function readTelegramEnvState() {
2877
3030
  return {
2878
3031
  filePath,
2879
3032
  legacyFilePath,
3033
+ legacyGlobalFilePath,
2880
3034
  sourceFilePath,
2881
3035
  entriesDirPath: telegramBotEntriesDirPath(),
2882
3036
  parsed: {},
@@ -2903,6 +3057,7 @@ function readTelegramEnvState() {
2903
3057
  return {
2904
3058
  filePath,
2905
3059
  legacyFilePath,
3060
+ legacyGlobalFilePath,
2906
3061
  sourceFilePath,
2907
3062
  entriesDirPath: telegramBotEntriesDirPath(),
2908
3063
  parsed: buildMergedTelegramEnvParsed(globalParsed, entries),
@@ -2912,7 +3067,7 @@ function readTelegramEnvState() {
2912
3067
  }
2913
3068
 
2914
3069
  function isTelegramEntryEnvKey(rawKey) {
2915
- return /^TELEGRAM_BOT_([A-Z0-9_]+)_(TOKEN|USERNAME|SERVER_BOT_ID|SERVER_NAME|SERVER_ROLES|ROLE_PROFILE|AI_CLIENT|AI_MODEL|AI_PERMISSION_MODE|AI_REASONING_EFFORT)$/i
3070
+ return /^TELEGRAM_BOT_([A-Z0-9_]+)_(TOKEN|USERNAME|SERVER_BOT_ID|SERVER_NAME|SERVER_ROLES|SERVER_ROLE_IDS|ROLE_PROFILE|AI_CLIENT|AI_MODEL|AI_PERMISSION_MODE|AI_REASONING_EFFORT)$/i
2916
3071
  .test(String(rawKey || "").trim());
2917
3072
  }
2918
3073
 
@@ -2935,7 +3090,7 @@ function renderNormalizedTelegramEnv(parsedEnv) {
2935
3090
  "# Metheus local Telegram bot settings",
2936
3091
  "# Keep this file on your machine only. Do not commit it.",
2937
3092
  "# Store Telegram-wide settings here.",
2938
- "# This global file lives in ~/.metheus/telegram-bots/_global.env.",
3093
+ "# This global file lives in ~/.metheus/telegram-bots/global.env.",
2939
3094
  "# Per-bot secrets and AI settings live in ~/.metheus/telegram-bots/<server-bot-name>.env.",
2940
3095
  "",
2941
3096
  `TELEGRAM_API_BASE_URL=${formatProviderEnvValue(parsed.TELEGRAM_API_BASE_URL || "")}`,
@@ -2971,6 +3126,7 @@ function renderTelegramBotEntryEnv(entryRaw) {
2971
3126
  const entry = safeObject(entryRaw);
2972
3127
  const botName = telegramEntryCommentNameForEnv(entry);
2973
3128
  const serverRoles = parseTelegramServerRoles(entry.serverRoles || "");
3129
+ const serverRoleIDs = formatTelegramServerRoleIDs(entry.serverRoleIDs);
2974
3130
  const lines = [
2975
3131
  "# Metheus local Telegram bot entry",
2976
3132
  `# Server bot: ${botName}`,
@@ -2978,6 +3134,7 @@ function renderTelegramBotEntryEnv(entryRaw) {
2978
3134
  `TELEGRAM_BOT_SERVER_BOT_ID=${formatProviderEnvValue(entry.serverBotID || "")}`,
2979
3135
  `TELEGRAM_BOT_SERVER_NAME=${formatProviderEnvValue(entry.serverBotName || "")}`,
2980
3136
  `TELEGRAM_BOT_SERVER_ROLES=${formatProviderEnvValue(serverRoles.join(","))}`,
3137
+ `TELEGRAM_BOT_SERVER_ROLE_IDS=${formatProviderEnvValue(serverRoleIDs)}`,
2981
3138
  ];
2982
3139
  if (!String(entry.serverBotID || "").trim() && !String(entry.serverBotName || "").trim() && String(entry.username || "").trim()) {
2983
3140
  lines.push(
@@ -3006,6 +3163,7 @@ function writeTelegramEnvState(parsedEnv) {
3006
3163
  || entry.serverBotID
3007
3164
  || entry.serverBotName
3008
3165
  || ensureArray(entry.serverRoles).length
3166
+ || Object.keys(safeObject(entry.serverRoleIDs)).length
3009
3167
  || entry.roleProfile
3010
3168
  || entry.client
3011
3169
  || entry.model
@@ -3021,6 +3179,7 @@ function writeTelegramEnvState(parsedEnv) {
3021
3179
  });
3022
3180
  const globalFilePath = providerEnvFilePath("telegram");
3023
3181
  const legacyFilePath = telegramLegacyEnvFilePath();
3182
+ const legacyGlobalFilePath = telegramLegacyGlobalEnvFilePath();
3024
3183
  fs.mkdirSync(path.dirname(globalFilePath), { recursive: true });
3025
3184
  fs.writeFileSync(globalFilePath, renderNormalizedTelegramEnv(globalParsed), {
3026
3185
  encoding: "utf8",
@@ -3030,24 +3189,46 @@ function writeTelegramEnvState(parsedEnv) {
3030
3189
  fs.mkdirSync(entriesDirPath, { recursive: true });
3031
3190
  const activeFiles = new Set();
3032
3191
  entries.forEach((entry) => {
3033
- const entryFilePath = telegramBotEntryFilePath(entry.key);
3034
- activeFiles.add(path.resolve(entryFilePath));
3192
+ const entryFilePath = telegramBotEntryFilePathForEntry(entry);
3193
+ const previousEntryFilePath = String(entry.entryFilePath || "").trim();
3194
+ const discoveredEntryFilePath = previousEntryFilePath || findTelegramEntryPathVariant(entryFilePath);
3195
+ const sourceEntryFilePath = discoveredEntryFilePath || previousEntryFilePath;
3196
+ if (sourceEntryFilePath && sourceEntryFilePath !== entryFilePath && fs.existsSync(sourceEntryFilePath)) {
3197
+ const previousComparable = normalizeComparablePath(sourceEntryFilePath);
3198
+ const nextComparable = normalizeComparablePath(entryFilePath);
3199
+ if (previousComparable === nextComparable) {
3200
+ const tempRenamePath = path.join(
3201
+ path.dirname(entryFilePath),
3202
+ `.__rename__.${Date.now()}-${Math.random().toString(16).slice(2)}.env`,
3203
+ );
3204
+ fs.renameSync(sourceEntryFilePath, tempRenamePath);
3205
+ fs.renameSync(tempRenamePath, entryFilePath);
3206
+ }
3207
+ }
3208
+ activeFiles.add(normalizeComparablePath(entryFilePath));
3035
3209
  fs.writeFileSync(entryFilePath, renderTelegramBotEntryEnv(entry), {
3036
3210
  encoding: "utf8",
3037
3211
  mode: 0o600,
3038
3212
  });
3039
3213
  });
3040
3214
  fs.readdirSync(entriesDirPath, { withFileTypes: true })
3041
- .filter((item) => item.isFile() && /\.env$/i.test(item.name) && item.name.toLowerCase() !== "_global.env")
3215
+ .filter((item) => item.isFile() && /\.env$/i.test(item.name))
3216
+ .filter((item) => {
3217
+ const lowered = item.name.toLowerCase();
3218
+ return lowered !== "_global.env" && lowered !== "global.env";
3219
+ })
3042
3220
  .forEach((item) => {
3043
- const filePath = path.resolve(path.join(entriesDirPath, item.name));
3221
+ const filePath = normalizeComparablePath(path.join(entriesDirPath, item.name));
3044
3222
  if (!activeFiles.has(filePath)) {
3045
- fs.rmSync(filePath, { force: true });
3223
+ fs.rmSync(path.join(entriesDirPath, item.name), { force: true });
3046
3224
  }
3047
3225
  });
3048
3226
  if (legacyFilePath !== globalFilePath && fs.existsSync(legacyFilePath)) {
3049
3227
  fs.rmSync(legacyFilePath, { force: true });
3050
3228
  }
3229
+ if (legacyGlobalFilePath !== globalFilePath && fs.existsSync(legacyGlobalFilePath)) {
3230
+ fs.rmSync(legacyGlobalFilePath, { force: true });
3231
+ }
3051
3232
  return globalFilePath;
3052
3233
  }
3053
3234
 
@@ -3067,6 +3248,7 @@ function resolveTelegramEnvConfig(parsedEnv, filePath, config, selectors = {}) {
3067
3248
  || entry.serverBotID
3068
3249
  || entry.serverBotName
3069
3250
  || ensureArray(entry.serverRoles).length
3251
+ || Object.keys(safeObject(entry.serverRoleIDs)).length
3070
3252
  ));
3071
3253
  const desiredBotID = firstNonEmptyString([
3072
3254
  selectors.serverBotID,
@@ -3118,7 +3300,8 @@ function resolveTelegramEnvConfig(parsedEnv, filePath, config, selectors = {}) {
3118
3300
  provider: "telegram",
3119
3301
  providerLabel: config.label,
3120
3302
  filePath,
3121
- error: `TELEGRAM_BOT_${selected.key.toUpperCase()}_TOKEN is missing in ${filePath}`,
3303
+ entryFilePath: telegramBotEntryFilePathForEntry(selected),
3304
+ error: `TELEGRAM_BOT_TOKEN is missing in ${telegramBotEntryFilePathForEntry(selected) || filePath}`,
3122
3305
  token: "",
3123
3306
  };
3124
3307
  }
@@ -3127,14 +3310,16 @@ function resolveTelegramEnvConfig(parsedEnv, filePath, config, selectors = {}) {
3127
3310
  provider: "telegram",
3128
3311
  providerLabel: config.label,
3129
3312
  filePath,
3313
+ entryFilePath: telegramBotEntryFilePathForEntry(selected),
3130
3314
  token: selected.token,
3131
3315
  source: "telegram_env_v2",
3132
- tokenKey: `TELEGRAM_BOT_${selected.key.toUpperCase()}_TOKEN`,
3316
+ tokenKey: "TELEGRAM_BOT_TOKEN",
3133
3317
  botKey: selected.key,
3134
3318
  botUsername: selected.username,
3135
3319
  serverBotID: selected.serverBotID,
3136
3320
  serverBotName: selected.serverBotName,
3137
3321
  serverRoles: ensureArray(selected.serverRoles),
3322
+ serverRoleIDs: safeObject(selected.serverRoleIDs),
3138
3323
  roleProfile: selected.roleProfile,
3139
3324
  client: selected.client,
3140
3325
  model: selected.model,
@@ -3161,6 +3346,7 @@ function resolveTelegramEnvConfig(parsedEnv, filePath, config, selectors = {}) {
3161
3346
  serverBotID: "",
3162
3347
  serverBotName: "",
3163
3348
  serverRoles: [],
3349
+ serverRoleIDs: {},
3164
3350
  roleProfile: "",
3165
3351
  client: "",
3166
3352
  model: "",
@@ -3732,6 +3918,7 @@ function buildBotCommandDeps() {
3732
3918
  providerEnvFilePath,
3733
3919
  telegramBotEntriesDirPath,
3734
3920
  telegramBotEntryFilePath,
3921
+ telegramBotEntryFilePathForEntry,
3735
3922
  ensureProviderEnvTemplate,
3736
3923
  parseCommandAndFlags,
3737
3924
  parseSimpleEnvText,
@@ -5521,7 +5708,7 @@ TELEGRAM_BOT_REVIEW_TOKEN=review-token
5521
5708
  const normalizedState = readTelegramEnvState();
5522
5709
  writeTelegramEnvState(normalizedState.parsed);
5523
5710
  normalizedGlobalText = fs.readFileSync(providerEnvFilePath("telegram"), "utf8");
5524
- normalizedBotText = fs.readFileSync(telegramBotEntryFilePath("ryoai_bot"), "utf8");
5711
+ normalizedBotText = fs.readFileSync(telegramBotEntryFilePathForEntry({ serverBotName: "RyoAI_bot", key: "ryoai_bot" }), "utf8");
5525
5712
  } finally {
5526
5713
  if (previousUserProfile === undefined) delete process.env.USERPROFILE;
5527
5714
  else process.env.USERPROFILE = previousUserProfile;