@inceptionstack/roundhouse 0.3.17 → 0.3.19

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/src/cli/setup.ts CHANGED
@@ -32,6 +32,12 @@ import {
32
32
  writeServiceUnit,
33
33
  hasSudoAccess,
34
34
  } from "./systemd";
35
+ import {
36
+ getAgentDefinition,
37
+ listAvailableAgentTypes,
38
+ type AgentDefinition,
39
+ type AgentSetupContext,
40
+ } from "../agents/registry";
35
41
  import {
36
42
  validateBotToken,
37
43
  checkWebhook,
@@ -41,6 +47,16 @@ import {
41
47
  type BotInfo,
42
48
  type PairResult,
43
49
  } from "./setup-telegram";
50
+ import { promptText, promptMasked } from "./setup-prompts";
51
+ import { createTextLogger, createJsonLogger, type SetupLogger, type SetupDiagnostics, printDiagnosticError } from "./setup-logger";
52
+ import { printQr } from "./qr";
53
+ import {
54
+ createPairingNonce,
55
+ createPairingLink,
56
+ readPendingPairing,
57
+ writePendingPairing,
58
+ type PendingPairing,
59
+ } from "../pairing";
44
60
 
45
61
  // ── Types ────────────────────────────────────────────
46
62
 
@@ -58,6 +74,14 @@ interface SetupOptions {
58
74
  nonInteractive: boolean;
59
75
  force: boolean;
60
76
  dryRun: boolean;
77
+ /** Telegram-focused setup flow */
78
+ telegram: boolean;
79
+ /** Fully headless automation (no TTY prompts) */
80
+ headless: boolean;
81
+ /** QR code display mode */
82
+ qr: "auto" | "always" | "never";
83
+ /** Agent type (default: pi) */
84
+ agent: string;
61
85
  }
62
86
 
63
87
  type StepStatus = "ok" | "warn" | "skip" | "fail";
@@ -71,13 +95,69 @@ const DEFAULT_MODEL = "us.anthropic.claude-opus-4-6-v1";
71
95
 
72
96
  const EXTENSION_NAME_RE = /^@?[a-z0-9][\w.\-/]*$/i;
73
97
 
98
+ /**
99
+ * Resolve agent definition and wire up setup-specific functions.
100
+ * Pi: configure writes ~/.pi/agent/settings.json, installExtension calls pi install.
101
+ */
102
+ function resolveAgentForSetup(opts: SetupOptions): AgentDefinition {
103
+ const agent = { ...getAgentDefinition(opts.agent) };
104
+
105
+ if (agent.type === "pi") {
106
+ agent.configure = async (ctx: AgentSetupContext) => {
107
+ // Read existing settings if present
108
+ let existing: Record<string, unknown> = {};
109
+ try {
110
+ existing = JSON.parse(await readFile(PI_SETTINGS_PATH, "utf8"));
111
+ } catch {}
112
+
113
+ const settings: Record<string, unknown> = { ...existing };
114
+
115
+ if (ctx.force) {
116
+ settings.defaultProvider = ctx.provider;
117
+ settings.defaultModel = ctx.model;
118
+ } else {
119
+ if (existing.defaultProvider && existing.defaultProvider !== ctx.provider) {
120
+ warn(`Pi provider already set to '${existing.defaultProvider}' (keeping, use --force to override)`);
121
+ } else {
122
+ settings.defaultProvider = ctx.provider;
123
+ }
124
+ if (existing.defaultModel && existing.defaultModel !== ctx.model) {
125
+ warn(`Pi model already set to '${existing.defaultModel}' (keeping, use --force to override)`);
126
+ } else {
127
+ settings.defaultModel = ctx.model;
128
+ }
129
+ }
130
+
131
+ // Ensure packages array exists
132
+ if (!Array.isArray(settings.packages)) settings.packages = [];
133
+
134
+ // Add pi-psst if using psst
135
+ if (ctx.psst) {
136
+ const psstPkg = "npm:@miclivs/pi-psst";
137
+ const pkgs = settings.packages as string[];
138
+ if (!pkgs.includes(psstPkg)) pkgs.push(psstPkg);
139
+ }
140
+
141
+ await mkdir(dirname(PI_SETTINGS_PATH), { recursive: true });
142
+ await atomicWriteJson(PI_SETTINGS_PATH, settings);
143
+ ok(`~/.pi/agent/settings.json (${settings.defaultProvider}, ${settings.defaultModel})`);
144
+ };
145
+
146
+ agent.installExtension = async (ext: string) => {
147
+ execOrFail("pi", ["install", `npm:${ext}`], `extension ${ext}`);
148
+ };
149
+ }
150
+
151
+ return agent;
152
+ }
153
+
74
154
  // ── Helpers ──────────────────────────────────────────
75
155
 
76
- function log(msg: string) { console.log(msg); }
77
- function step(n: string, label: string) { log(`\n${n} ${label}`); }
78
- function ok(msg: string) { log(` ✓ ${msg}`); }
79
- function warn(msg: string) { log(` ⚠ ${msg}`); }
80
- function fail(msg: string) { log(` ✗ ${msg}`); }
156
+ let log = (msg: string) => { console.log(msg); };
157
+ let step = (n: string, label: string) => { log(`\n${n} ${label}`); };
158
+ let ok = (msg: string) => { log(` ✓ ${msg}`); };
159
+ let warn = (msg: string) => { log(` ⚠ ${msg}`); };
160
+ let fail = (msg: string) => { log(` ✗ ${msg}`); };
81
161
 
82
162
  async function atomicWriteJson(path: string, data: unknown): Promise<void> {
83
163
  const tmp = `${path}.tmp.${randomBytes(4).toString("hex")}`;
@@ -140,6 +220,10 @@ export function parseSetupArgs(argv: string[]): SetupOptions {
140
220
  nonInteractive: false,
141
221
  force: false,
142
222
  dryRun: false,
223
+ telegram: false,
224
+ headless: false,
225
+ qr: "auto",
226
+ agent: "pi",
143
227
  };
144
228
 
145
229
  for (let i = 0; i < argv.length; i++) {
@@ -161,6 +245,11 @@ export function parseSetupArgs(argv: string[]): SetupOptions {
161
245
  case "--no-voice": opts.voice = false; break;
162
246
  case "--with-psst": opts.psst = true; break;
163
247
  case "--non-interactive": opts.nonInteractive = true; break;
248
+ case "--telegram": opts.telegram = true; break;
249
+ case "--headless": opts.headless = true; opts.nonInteractive = true; break;
250
+ case "--agent": opts.agent = next().toLowerCase(); break;
251
+ case "--qr": opts.qr = "always"; break;
252
+ case "--no-qr": opts.qr = "never"; break;
164
253
  case "--force": opts.force = true; break;
165
254
  case "--dry-run": opts.dryRun = true; break;
166
255
  default:
@@ -174,15 +263,33 @@ export function parseSetupArgs(argv: string[]): SetupOptions {
174
263
  opts.botToken = process.env.TELEGRAM_BOT_TOKEN ?? "";
175
264
  }
176
265
 
266
+ // Headless: reject --bot-token (argv visible in process listings)
267
+ if (opts.headless && argv.some((a) => a === "--bot-token")) {
268
+ throw new Error(
269
+ "--bot-token is not accepted in --headless mode (argv visible in process listings).\n" +
270
+ "Use: TELEGRAM_BOT_TOKEN=... roundhouse setup --telegram --headless --user USERNAME",
271
+ );
272
+ }
273
+
274
+ // Validate agent type
275
+ try {
276
+ getAgentDefinition(opts.agent);
277
+ } catch (err: any) {
278
+ throw new Error(err.message);
279
+ }
280
+
281
+ // Interactive --telegram defers token/user prompting to the wizard
282
+ const isInteractiveTelegram = opts.telegram && !opts.headless && !opts.nonInteractive && process.stdin.isTTY;
283
+
177
284
  // Validate
178
- if (!opts.botToken && !opts.dryRun) {
285
+ if (!opts.botToken && !opts.dryRun && !isInteractiveTelegram) {
179
286
  throw new Error(
180
287
  "Bot token required. Provide via:\n" +
181
288
  " TELEGRAM_BOT_TOKEN=... roundhouse setup --user USERNAME\n" +
182
289
  " roundhouse setup --bot-token TOKEN --user USERNAME",
183
290
  );
184
291
  }
185
- if (opts.users.length === 0) {
292
+ if (opts.users.length === 0 && !isInteractiveTelegram) {
186
293
  throw new Error(
187
294
  "At least one --user USERNAME is required.\n" +
188
295
  "This is your Telegram username (without @).",
@@ -202,7 +309,7 @@ export function parseSetupArgs(argv: string[]): SetupOptions {
202
309
 
203
310
  // ── Steps ────────────────────────────────────────────
204
311
 
205
- async function stepPreflight(opts: SetupOptions): Promise<void> {
312
+ async function stepPreflight(opts: SetupOptions, agent: AgentDefinition): Promise<void> {
206
313
  step("①", "Preflight checks...");
207
314
 
208
315
  // Node version
@@ -222,7 +329,8 @@ async function stepPreflight(opts: SetupOptions): Promise<void> {
222
329
  ok("npm available");
223
330
 
224
331
  // Config dirs writable
225
- for (const dir of [ROUNDHOUSE_DIR, dirname(PI_SETTINGS_PATH)]) {
332
+ const dirs = [ROUNDHOUSE_DIR, ...(agent.configDirs ?? [])];
333
+ for (const dir of dirs) {
226
334
  try {
227
335
  await mkdir(dir, { recursive: true });
228
336
  ok(`Writable: ${dir.replace(homedir(), "~")}`);
@@ -339,7 +447,7 @@ async function stepStopGateway(): Promise<void> {
339
447
  }
340
448
  }
341
449
 
342
- async function stepInstallPackages(opts: SetupOptions): Promise<void> {
450
+ async function stepInstallPackages(opts: SetupOptions, agent: AgentDefinition): Promise<void> {
343
451
  step("④", "Installing packages...");
344
452
 
345
453
  // Roundhouse
@@ -352,14 +460,20 @@ async function stepInstallPackages(opts: SetupOptions): Promise<void> {
352
460
  ok("@inceptionstack/roundhouse");
353
461
  }
354
462
 
355
- // Pi agent
356
- const piInstalled = whichSync("pi");
357
- if (piInstalled && !opts.force) {
358
- ok(`@mariozechner/pi-coding-agent (already installed)`);
359
- } else {
360
- log(" Installing @mariozechner/pi-coding-agent...");
361
- execOrFail("npm", ["install", "-g", "@mariozechner/pi-coding-agent"], "pi install");
362
- ok("@mariozechner/pi-coding-agent");
463
+ // Agent packages (driven by agent definition)
464
+ for (const pkg of agent.packages) {
465
+ const label = pkg.name ?? pkg.packageName;
466
+ const installed = pkg.binary ? whichSync(pkg.binary) : false;
467
+ if (installed && !opts.force) {
468
+ ok(`${label} (already installed)`);
469
+ } else {
470
+ log(` Installing ${label}...`);
471
+ const args = pkg.install === "global"
472
+ ? ["install", "-g", pkg.packageName]
473
+ : ["install", pkg.packageName];
474
+ execOrFail("npm", args, `${label} install`);
475
+ ok(label);
476
+ }
363
477
  }
364
478
 
365
479
  // psst-cli (requires bun runtime)
@@ -440,21 +554,26 @@ async function stepInstallPackages(opts: SetupOptions): Promise<void> {
440
554
  }
441
555
  }
442
556
 
443
- // Install pi-psst extension
444
- log(" Installing pi-psst extension...");
445
- try {
446
- execFileSync("pi", ["install", "npm:@miclivs/pi-psst"], { encoding: "utf8", stdio: "pipe", timeout: 120_000 });
447
- ok("@miclivs/pi-psst extension");
448
- } catch {
449
- // May already be installed
450
- ok("@miclivs/pi-psst extension (already installed)");
557
+ // Install agent-specific psst extension (Pi: pi-psst)
558
+ if (agent.installExtension) {
559
+ log(" Installing agent psst extension...");
560
+ try {
561
+ await agent.installExtension("@miclivs/pi-psst");
562
+ ok("@miclivs/pi-psst extension");
563
+ } catch {
564
+ ok("@miclivs/pi-psst extension (already installed)");
565
+ }
451
566
  }
452
567
  }
453
568
 
454
569
  // User extensions
455
570
  for (const ext of opts.extensions) {
571
+ if (!agent.installExtension) {
572
+ fail(`--extension is not supported for agent "${agent.type}"`);
573
+ throw new Error(`Agent "${agent.type}" does not support extensions`);
574
+ }
456
575
  log(` Installing extension: ${ext}...`);
457
- execOrFail("pi", ["install", `npm:${ext}`], `extension ${ext}`);
576
+ await agent.installExtension(ext);
458
577
  ok(ext);
459
578
  }
460
579
  }
@@ -506,45 +625,24 @@ async function stepConfigure(
506
625
  opts: SetupOptions,
507
626
  botInfo: BotInfo,
508
627
  pairResult: PairResult | null,
628
+ agent: AgentDefinition,
509
629
  ): Promise<void> {
510
630
  step("⑦", "Configuring...");
511
631
 
512
632
  await mkdir(ROUNDHOUSE_DIR, { recursive: true });
513
- await mkdir(dirname(PI_SETTINGS_PATH), { recursive: true });
514
-
515
- // ── Pi settings ──
516
- let piSettings: Record<string, any> = {};
517
- try {
518
- piSettings = JSON.parse(await readFile(PI_SETTINGS_PATH, "utf8"));
519
- } catch { /* doesn't exist */ }
520
633
 
521
- if (opts.force) {
522
- piSettings.defaultProvider = opts.provider;
523
- piSettings.defaultModel = opts.model;
524
- } else {
525
- const existingProvider = piSettings.defaultProvider;
526
- const existingModel = piSettings.defaultModel;
527
- if (existingProvider && existingProvider !== opts.provider) {
528
- warn(`Pi provider already set to '${existingProvider}' (keeping, use --force to override)`);
529
- } else {
530
- piSettings.defaultProvider = opts.provider;
531
- }
532
- if (existingModel && existingModel !== opts.model) {
533
- warn(`Pi model already set to '${existingModel}' (keeping, use --force to override)`);
534
- } else {
535
- piSettings.defaultModel = opts.model;
536
- }
537
- }
538
-
539
- // Ensure packages array includes pi-psst if using psst
540
- if (!piSettings.packages) piSettings.packages = [];
541
- if (opts.psst && !piSettings.packages.includes("npm:@miclivs/pi-psst")) {
542
- piSettings.packages.push("npm:@miclivs/pi-psst");
634
+ // Agent-specific config (Pi: ~/.pi/agent/settings.json)
635
+ if (agent.configure) {
636
+ await agent.configure({
637
+ provider: opts.provider,
638
+ model: opts.model,
639
+ cwd: opts.cwd,
640
+ force: opts.force,
641
+ psst: opts.psst,
642
+ extensions: opts.extensions,
643
+ });
543
644
  }
544
645
 
545
- await atomicWriteJson(PI_SETTINGS_PATH, piSettings);
546
- ok(`~/.pi/agent/settings.json (${piSettings.defaultProvider}, ${piSettings.defaultModel})`);
547
-
548
646
  // ── Gateway config ──
549
647
  let gatewayConfig: Record<string, any> = {};
550
648
  if (!opts.force) {
@@ -575,7 +673,7 @@ async function stepConfigure(
575
673
  gatewayConfig = {
576
674
  ...gatewayConfig,
577
675
  _version: 1, // Config schema version — for future migration support
578
- agent: { ...gatewayConfig.agent, type: "pi", cwd: opts.cwd },
676
+ agent: { ...gatewayConfig.agent, ...agent.configDefaults, type: agent.type, cwd: opts.cwd },
579
677
  chat: {
580
678
  ...gatewayConfig.chat,
581
679
  botUsername: botInfo.username,
@@ -768,6 +866,252 @@ async function stepPostflight(): Promise<void> {
768
866
  }
769
867
  }
770
868
 
869
+ // ── BotFather Guide ──────────────────────────────────
870
+
871
+ function printBotFatherGuide(): void {
872
+ log("");
873
+ log(" 🤖 Create a Telegram Bot");
874
+ log(" ────────────────────────");
875
+ log(" 1. Open https://t.me/BotFather");
876
+ log(" 2. Send /newbot");
877
+ log(" 3. Choose a display name (e.g. 'My Roundhouse')");
878
+ log(" 4. Choose a username ending in 'bot' (e.g. 'my_roundhouse_bot')");
879
+ log(" 5. Copy the token BotFather returns");
880
+ log("");
881
+ }
882
+
883
+ // ── Interactive Telegram Setup ───────────────────────
884
+
885
+ async function runInteractiveTelegramSetup(opts: SetupOptions): Promise<void> {
886
+ const agent = resolveAgentForSetup(opts);
887
+ log("\n🔧 Roundhouse Telegram Setup");
888
+ log("━━━━━━━━━━━━━━━━━━━━━━━━━━━");
889
+
890
+ try {
891
+ // Step 1: Preflight
892
+ await stepPreflight(opts, agent);
893
+
894
+ // Step 2: Get bot token (prompt if not provided)
895
+ if (!opts.botToken) {
896
+ log("");
897
+ printBotFatherGuide();
898
+ opts.botToken = await promptMasked(" Paste your bot token");
899
+ if (!opts.botToken) {
900
+ fail("No token provided");
901
+ process.exit(2);
902
+ }
903
+ }
904
+ const botInfo = await stepValidateToken(opts);
905
+
906
+ // Step 3: Get username (prompt if not provided)
907
+ if (opts.users.length === 0) {
908
+ step("③", "Telegram username...");
909
+ const username = await promptText(" Your Telegram username (without @)");
910
+ if (!username) {
911
+ fail("Username required");
912
+ process.exit(2);
913
+ }
914
+ opts.users.push(username.replace(/^@/, ""));
915
+ ok(`Allowed: ${opts.users.map(u => `@${u}`).join(", ")}`);
916
+ }
917
+
918
+ // Step 4: Stop existing gateway
919
+ await stepStopGateway();
920
+
921
+ // Step 5: Install packages
922
+ await stepInstallPackages(opts, agent);
923
+
924
+ // Step 6: Pair via Telegram
925
+ step("⑥", "Pairing with Telegram...");
926
+ const nonce = createPairingNonce();
927
+ const pairingLink = createPairingLink(botInfo.username, nonce);
928
+ log(`\n Open this link to pair:\n`);
929
+ log(` 🔗 ${pairingLink}\n`);
930
+ printQr(pairingLink, opts.qr);
931
+ log(` Or send /start ${nonce} to @${botInfo.username}`);
932
+ log("");
933
+
934
+ const pairResult = await pairTelegram(
935
+ opts.botToken, botInfo.username, opts.users,
936
+ 300_000, log, { nonce, showLink: false },
937
+ );
938
+ if (!pairResult) {
939
+ warn("Pairing timed out. Run 'roundhouse pair' later.");
940
+ } else {
941
+ ok(`Paired with @${pairResult.username} (chat: ${pairResult.chatId})`);
942
+ if (!opts.notifyChatIds.includes(pairResult.chatId)) {
943
+ opts.notifyChatIds.push(pairResult.chatId);
944
+ }
945
+ }
946
+
947
+ // Step 7: Store secrets
948
+ await stepStoreSecrets(opts, botInfo);
949
+
950
+ // Step 8: Write config
951
+ await stepConfigure(opts, botInfo, pairResult, agent);
952
+
953
+ // Step 9: Register commands + install service
954
+ await stepRegisterCommands(opts);
955
+ await stepInstallSystemd(opts);
956
+
957
+ // Step 10: Verify
958
+ await stepPostflight();
959
+
960
+ // Done!
961
+ log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━");
962
+ log("✅ Roundhouse is ready!");
963
+ log(` Bot: @${botInfo.username}`);
964
+ log(` Send /status to @${botInfo.username} on Telegram.\n`);
965
+ } catch (err: any) {
966
+ log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━");
967
+ log(`❌ Setup failed: ${err.message}`);
968
+ log(" Re-run: roundhouse setup --telegram\n");
969
+ process.exit(1);
970
+ }
971
+ }
972
+
973
+ // ── Headless Telegram Setup ─────────────────────────
974
+
975
+ async function runHeadlessTelegramSetup(opts: SetupOptions): Promise<void> {
976
+ const agent = resolveAgentForSetup(opts);
977
+ const logger = createJsonLogger();
978
+
979
+ // Override module-level log helpers to emit JSON instead of text
980
+ const savedLog = log, savedStep = step, savedOk = ok, savedWarn = warn, savedFail = fail;
981
+ log = (msg) => logger.info("log", msg);
982
+ step = (_n, label) => logger.info("step", label);
983
+ ok = (msg) => logger.ok(msg);
984
+ warn = (msg) => logger.warn("warn", msg);
985
+ fail = (msg) => logger.fail(msg);
986
+
987
+ try {
988
+ // Validate required inputs
989
+ if (!opts.botToken) {
990
+ logger.error("validation.failed", "TELEGRAM_BOT_TOKEN env var required for --headless");
991
+ process.exit(2);
992
+ }
993
+ if (opts.users.length === 0) {
994
+ logger.error("validation.failed", "--user is required for --headless");
995
+ process.exit(2);
996
+ }
997
+
998
+ // Step 1: Preflight
999
+ logger.step(1, 9, "preflight.start", "Running preflight checks");
1000
+ await stepPreflight(opts, agent);
1001
+ logger.ok("Preflight passed");
1002
+
1003
+ // Step 2: Validate token
1004
+ logger.step(2, 9, "telegram.validate", "Validating Telegram bot token");
1005
+ const botInfo = await stepValidateToken(opts);
1006
+ logger.ok(`Bot: @${botInfo.username} (id: ${botInfo.id})`);
1007
+
1008
+ // Step 3: Stop existing gateway
1009
+ logger.step(3, 9, "gateway.stop", "Checking for running gateway");
1010
+ await stepStopGateway();
1011
+
1012
+ // Step 4: Install packages
1013
+ logger.step(4, 9, "packages.install", "Installing packages");
1014
+ await stepInstallPackages(opts, agent);
1015
+ logger.ok("Packages installed");
1016
+
1017
+ // Step 5: Create pending pairing
1018
+ logger.step(5, 9, "pairing.pending", "Creating pending pairing");
1019
+ let nonce: string;
1020
+ const existing = await readPendingPairing();
1021
+ if (existing?.status === "pending" && !opts.force) {
1022
+ nonce = existing.nonce;
1023
+ logger.info("pairing.reuse", `Reusing existing nonce: ${nonce}`);
1024
+ } else {
1025
+ nonce = createPairingNonce();
1026
+ }
1027
+ const pairingLink = createPairingLink(botInfo.username, nonce);
1028
+ const pendingPairing: PendingPairing = {
1029
+ version: 1,
1030
+ nonce,
1031
+ botUsername: botInfo.username,
1032
+ allowedUsers: opts.users,
1033
+ createdAt: new Date().toISOString(),
1034
+ status: "pending",
1035
+ };
1036
+ await writePendingPairing(pendingPairing);
1037
+ logger.info("pairing.link", `Pairing link: ${pairingLink}`, { pairingLink, nonce });
1038
+
1039
+ // Step 6: Store secrets
1040
+ logger.step(6, 9, "secrets.store", "Storing secrets");
1041
+ await stepStoreSecrets(opts, botInfo);
1042
+
1043
+ // Step 7: Write config (no pair result yet — gateway will complete pairing)
1044
+ logger.step(7, 9, "config.write", "Writing configuration");
1045
+ await stepConfigure(opts, botInfo, null, agent);
1046
+ logger.ok("Config written");
1047
+
1048
+ // Step 8: Register commands
1049
+ logger.step(8, 9, "commands.register", "Registering bot commands");
1050
+ await stepRegisterCommands(opts);
1051
+ logger.ok("Bot commands registered");
1052
+
1053
+ // Step 9: Install and start service
1054
+ logger.step(9, 9, "service.install", "Installing and starting service");
1055
+ if (!opts.systemd) {
1056
+ logger.warn("service.skip", "--no-systemd: service not installed. Start manually: roundhouse start");
1057
+ } else {
1058
+ await stepInstallSystemd(opts);
1059
+ logger.ok("Service installed and started");
1060
+
1061
+ // Verify service is active
1062
+ try {
1063
+ const { execFileSync } = await import("node:child_process");
1064
+ const state = execFileSync("systemctl", ["is-active", "roundhouse"], { encoding: "utf8" }).trim();
1065
+ if (state === "active") {
1066
+ logger.ok("Service is active");
1067
+ } else {
1068
+ logger.warn("service.state", `Service state: ${state}`);
1069
+ }
1070
+ } catch {
1071
+ logger.warn("service.state", "Could not verify service state");
1072
+ }
1073
+ }
1074
+
1075
+ // Success
1076
+ logger.info("setup.complete", "Headless setup complete", {
1077
+ botUsername: botInfo.username,
1078
+ pairingLink,
1079
+ pairingStatus: "pending",
1080
+ serviceInstalled: opts.systemd,
1081
+ });
1082
+ log("");
1083
+ log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
1084
+ log(`✅ Roundhouse installed and running!`);
1085
+ log(``);
1086
+ log(` Bot: @${botInfo.username}`);
1087
+ log(` Pairing: Open ${pairingLink} to complete setup`);
1088
+ log(` Gateway is running and will accept pairing automatically.`);
1089
+ log(``);
1090
+ } catch (err: any) {
1091
+ const diag: SetupDiagnostics = {
1092
+ node: process.version,
1093
+ platform: platform(),
1094
+ arch: process.arch,
1095
+ cwd: process.cwd(),
1096
+ roundhouseDir: ROUNDHOUSE_DIR,
1097
+ configExists: await fileExists(CONFIG_PATH).catch(() => false),
1098
+ envExists: await fileExists(ENV_PATH).catch(() => false),
1099
+ pairingStatus: (await readPendingPairing())?.status ?? "not found",
1100
+ serviceState: "unknown",
1101
+ error: { name: err.name, message: err.message, stack: err.stack },
1102
+ };
1103
+ try {
1104
+ const { execFileSync } = await import("node:child_process");
1105
+ diag.serviceState = execFileSync("systemctl", ["is-active", "roundhouse"], { encoding: "utf8" }).trim();
1106
+ } catch {}
1107
+ printDiagnosticError(diag, true);
1108
+ process.exit(1);
1109
+ } finally {
1110
+ // Restore module-level log helpers
1111
+ log = savedLog; step = savedStep; ok = savedOk; warn = savedWarn; fail = savedFail;
1112
+ }
1113
+ }
1114
+
771
1115
  // ── Orchestrator ─────────────────────────────────────
772
1116
 
773
1117
  export async function cmdSetup(argv: string[]): Promise<void> {
@@ -785,17 +1129,29 @@ export async function cmdSetup(argv: string[]): Promise<void> {
785
1129
  return;
786
1130
  }
787
1131
 
1132
+ // Route to --telegram flows
1133
+ if (opts.telegram) {
1134
+ if (opts.headless) {
1135
+ await runHeadlessTelegramSetup(opts);
1136
+ } else {
1137
+ await runInteractiveTelegramSetup(opts);
1138
+ }
1139
+ return;
1140
+ }
1141
+
1142
+ // Legacy flow (no --telegram flag)
1143
+ const agent = resolveAgentForSetup(opts);
788
1144
  log("\n🔧 Roundhouse Setup");
789
1145
  log("━━━━━━━━━━━━━━━━━━━");
790
1146
 
791
1147
  try {
792
1148
  // Phase 1: Validate (no mutations)
793
- await stepPreflight(opts);
1149
+ await stepPreflight(opts, agent);
794
1150
  const botInfo = await stepValidateToken(opts);
795
1151
  await stepStopGateway();
796
1152
 
797
1153
  // Phase 2: Install packages
798
- await stepInstallPackages(opts);
1154
+ await stepInstallPackages(opts, agent);
799
1155
 
800
1156
  // Phase 3: Pair (before secrets/config, so paired username is included)
801
1157
  const pairResult = await stepPair(opts, botInfo);
@@ -804,7 +1160,7 @@ export async function cmdSetup(argv: string[]): Promise<void> {
804
1160
  await stepStoreSecrets(opts, botInfo);
805
1161
 
806
1162
  // Phase 5: Write config (includes pair data)
807
- await stepConfigure(opts, botInfo, pairResult);
1163
+ await stepConfigure(opts, botInfo, pairResult, agent);
808
1164
 
809
1165
  // Phase 6: Remote setup
810
1166
  await stepRegisterCommands(opts);
@@ -923,12 +1279,17 @@ export async function cmdPair(argv: string[]): Promise<void> {
923
1279
  // ── Dry run ──────────────────────────────────────────
924
1280
 
925
1281
  function printDryRun(opts: SetupOptions): void {
1282
+ const agent = getAgentDefinition(opts.agent);
926
1283
  log("\n🔧 Roundhouse Setup (DRY RUN)");
927
1284
  log("━━━━━━━━━━━━━━━━━━━\n");
1285
+ log(`Agent: ${agent.name} (${agent.type})`);
928
1286
  log("Would validate Telegram token");
929
1287
  log("Would stop existing gateway (if running)");
930
1288
  log(`Would install: npm install -g @inceptionstack/roundhouse`);
931
- log(`Would install: npm install -g @mariozechner/pi-coding-agent`);
1289
+ for (const pkg of agent.packages) {
1290
+ const scope = pkg.install === "global" ? "-g " : "";
1291
+ log(`Would install: npm install ${scope}${pkg.packageName}`);
1292
+ }
932
1293
  if (opts.psst) {
933
1294
  log(`Would install: bun runtime (if not present)`);
934
1295
  log(`Would install: npm install -g psst-cli`);
@@ -942,7 +1303,10 @@ function printDryRun(opts: SetupOptions): void {
942
1303
  if (opts.psst) {
943
1304
  log(`Would store TELEGRAM_BOT_TOKEN, BOT_USERNAME, ALLOWED_USERS in psst`);
944
1305
  }
945
- log(`Would configure: ~/.pi/agent/settings.json`);
1306
+ if (agent.configDirs?.length) {
1307
+ log(`Would configure: agent-specific settings`);
1308
+ log(` Agent: ${agent.name}`);
1309
+ }
946
1310
  log(` Set defaultProvider: ${opts.provider}`);
947
1311
  log(` Set defaultModel: ${opts.model}`);
948
1312
  log(`Would write: ~/.roundhouse/gateway.config.json`);
@@ -957,20 +1321,27 @@ function printDryRun(opts: SetupOptions): void {
957
1321
  function printSetupHelp(): void {
958
1322
  console.log(`
959
1323
  Usage:
960
- TELEGRAM_BOT_TOKEN=... roundhouse setup --user USERNAME
961
- roundhouse setup --bot-token TOKEN --user USERNAME [options]
1324
+ roundhouse setup --telegram Interactive wizard (recommended)
1325
+ TELEGRAM_BOT_TOKEN=... roundhouse setup \\\n --telegram --headless --user USERNAME Headless automation (SSM/cloud-init)
1326
+ TELEGRAM_BOT_TOKEN=... roundhouse setup \\\n --user USERNAME Legacy (non-wizard) setup
962
1327
 
963
- Required:
1328
+ Modes:
1329
+ --telegram Telegram-focused setup (wizard or headless)
1330
+ --headless Non-interactive automation (implies --non-interactive)
1331
+ Requires TELEGRAM_BOT_TOKEN env var and --user
1332
+
1333
+ Required (or prompted in interactive --telegram):
964
1334
  --user <username> Telegram username (repeatable, strips @)
965
1335
 
966
- Token (one required):
1336
+ Token:
967
1337
  TELEGRAM_BOT_TOKEN env Preferred — not in shell history
968
- --bot-token <token> Fallback for scripts
1338
+ --bot-token <token> Accepted in interactive mode only
969
1339
 
970
1340
  Agent:
1341
+ --agent <type> Agent type (default: pi; available: ${listAvailableAgentTypes().join(", ")})
971
1342
  --provider <provider> AI provider (default: amazon-bedrock)
972
1343
  --model <model> AI model (default: us.anthropic.claude-opus-4-6-v1)
973
- --extension <pkg> Pi extension (repeatable)
1344
+ --extension <pkg> Agent extension (repeatable)
974
1345
  --cwd <path> Agent working directory (default: ~)
975
1346
 
976
1347
  Channel:
@@ -981,6 +1352,10 @@ Service:
981
1352
  --no-voice Disable voice/STT
982
1353
  --with-psst Use psst vault for secrets (default: .env file)
983
1354
 
1355
+ Display:
1356
+ --qr Force QR code display
1357
+ --no-qr Disable QR code display
1358
+
984
1359
  Behavior:
985
1360
  --non-interactive No pairing, no prompts
986
1361
  --force Overwrite existing configs