@inceptionstack/roundhouse 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +321 -9
  2. package/architecture.md +77 -8
  3. package/package.json +9 -6
  4. package/src/agents/pi.ts +433 -26
  5. package/src/agents/registry.ts +8 -0
  6. package/src/cli/cli.ts +384 -189
  7. package/src/cli/cron.ts +296 -0
  8. package/src/cli/doctor/checks/agent.ts +68 -0
  9. package/src/cli/doctor/checks/config.ts +88 -0
  10. package/src/cli/doctor/checks/credentials.ts +62 -0
  11. package/src/cli/doctor/checks/disk.ts +69 -0
  12. package/src/cli/doctor/checks/stt.ts +76 -0
  13. package/src/cli/doctor/checks/system.ts +86 -0
  14. package/src/cli/doctor/checks/systemd.ts +76 -0
  15. package/src/cli/doctor/output.ts +58 -0
  16. package/src/cli/doctor/runner.ts +142 -0
  17. package/src/cli/doctor/shell.ts +33 -0
  18. package/src/cli/doctor/types.ts +44 -0
  19. package/src/cli/doctor.ts +48 -0
  20. package/src/cli/setup-telegram.ts +148 -0
  21. package/src/cli/setup.ts +936 -0
  22. package/src/commands.ts +23 -0
  23. package/src/config.ts +188 -0
  24. package/src/cron/constants.ts +54 -0
  25. package/src/cron/durations.ts +33 -0
  26. package/src/cron/format.ts +139 -0
  27. package/src/cron/helpers.ts +30 -0
  28. package/src/cron/runner.ts +148 -0
  29. package/src/cron/schedule.ts +101 -0
  30. package/src/cron/scheduler.ts +295 -0
  31. package/src/cron/store.ts +125 -0
  32. package/src/cron/template.ts +89 -0
  33. package/src/cron/types.ts +76 -0
  34. package/src/gateway.ts +927 -18
  35. package/src/index.ts +1 -58
  36. package/src/memory/bootstrap.ts +98 -0
  37. package/src/memory/files.ts +100 -0
  38. package/src/memory/inject.ts +41 -0
  39. package/src/memory/lifecycle.ts +245 -0
  40. package/src/memory/policy.ts +122 -0
  41. package/src/memory/prompts.ts +42 -0
  42. package/src/memory/state.ts +43 -0
  43. package/src/memory/types.ts +90 -0
  44. package/src/notify/telegram.ts +48 -0
  45. package/src/types.ts +68 -1
  46. package/src/util.ts +28 -2
  47. package/src/voice/providers/whisper.ts +339 -0
  48. package/src/voice/stt-service.ts +284 -0
  49. package/src/voice/types.ts +63 -0
@@ -0,0 +1,936 @@
1
+ /**
2
+ * cli/setup.ts — One-command install & configure for roundhouse
3
+ *
4
+ * Usage:
5
+ * TELEGRAM_BOT_TOKEN=... npx @inceptionstack/roundhouse setup --user badlogicgames
6
+ * roundhouse setup --bot-token "TOKEN" --user badlogicgames
7
+ *
8
+ * Installs roundhouse + pi + psst, configures everything, pairs Telegram,
9
+ * and starts the systemd service.
10
+ */
11
+
12
+ import { homedir, platform } from "node:os";
13
+ import { resolve, dirname } from "node:path";
14
+ import { readFile, writeFile, mkdir, rename, unlink, realpath, stat } from "node:fs/promises";
15
+ import { execFileSync, spawnSync } from "node:child_process";
16
+ import { randomBytes } from "node:crypto";
17
+ import { BOT_COMMANDS } from "../commands";
18
+ import {
19
+ ROUNDHOUSE_DIR,
20
+ CONFIG_PATH,
21
+ ENV_FILE_PATH as ENV_PATH,
22
+ fileExists,
23
+ } from "../config";
24
+ import {
25
+ validateBotToken,
26
+ checkWebhook,
27
+ registerBotCommands,
28
+ pairTelegram,
29
+ sendMessage,
30
+ type BotInfo,
31
+ type PairResult,
32
+ } from "./setup-telegram";
33
+
34
+ // ── Types ────────────────────────────────────────────
35
+
36
+ interface SetupOptions {
37
+ botToken: string;
38
+ users: string[];
39
+ provider: string;
40
+ model: string;
41
+ extensions: string[];
42
+ cwd: string;
43
+ notifyChatIds: number[];
44
+ systemd: boolean;
45
+ voice: boolean;
46
+ psst: boolean;
47
+ nonInteractive: boolean;
48
+ force: boolean;
49
+ dryRun: boolean;
50
+ }
51
+
52
+ type StepStatus = "ok" | "warn" | "skip" | "fail";
53
+
54
+ // ── Constants ────────────────────────────────────────
55
+
56
+ const PI_SETTINGS_PATH = resolve(homedir(), ".pi", "agent", "settings.json");
57
+
58
+ const DEFAULT_PROVIDER = "amazon-bedrock";
59
+ const DEFAULT_MODEL = "us.anthropic.claude-opus-4-6-v1";
60
+
61
+ const EXTENSION_NAME_RE = /^@?[a-z0-9][\w.\-/]*$/i;
62
+
63
+ // ── Helpers ──────────────────────────────────────────
64
+
65
+ function log(msg: string) { console.log(msg); }
66
+ function step(n: string, label: string) { log(`\n${n} ${label}`); }
67
+ function ok(msg: string) { log(` ✓ ${msg}`); }
68
+ function warn(msg: string) { log(` ⚠ ${msg}`); }
69
+ function fail(msg: string) { log(` ✗ ${msg}`); }
70
+
71
+ async function atomicWriteJson(path: string, data: unknown): Promise<void> {
72
+ const tmp = `${path}.tmp.${randomBytes(4).toString("hex")}`;
73
+ try {
74
+ await writeFile(tmp, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
75
+ await rename(tmp, path);
76
+ } catch (err) {
77
+ try { await unlink(tmp); } catch {}
78
+ throw err;
79
+ }
80
+ }
81
+
82
+ async function atomicWriteText(path: string, content: string, mode = 0o600): Promise<void> {
83
+ const tmp = `${path}.tmp.${randomBytes(4).toString("hex")}`;
84
+ try {
85
+ await writeFile(tmp, content, { mode });
86
+ await rename(tmp, path);
87
+ } catch (err) {
88
+ try { await unlink(tmp); } catch {}
89
+ throw err;
90
+ }
91
+ }
92
+
93
+ /** Shell-escape a value for env files */
94
+ function envQuote(value: string): string {
95
+ // Escape backslash, double-quote, dollar, backtick, newline
96
+ const escaped = value
97
+ .replace(/\\/g, "\\\\")
98
+ .replace(/"/g, '\\"')
99
+ .replace(/\$/g, "\\$")
100
+ .replace(/`/g, "\\`")
101
+ .replace(/\n/g, "\\n");
102
+ return `"${escaped}"`;
103
+ }
104
+
105
+ function execSafe(cmd: string, args: string[], opts: { silent?: boolean; input?: string } = {}): string {
106
+ try {
107
+ const result = execFileSync(cmd, args, {
108
+ encoding: "utf8",
109
+ stdio: opts.silent ? "pipe" : opts.input ? ["pipe", "pipe", "pipe"] : "pipe",
110
+ input: opts.input,
111
+ timeout: 120_000,
112
+ });
113
+ return result.trim();
114
+ } catch {
115
+ return "";
116
+ }
117
+ }
118
+
119
+ function execOrFail(cmd: string, args: string[], label: string): string {
120
+ try {
121
+ return execFileSync(cmd, args, { encoding: "utf8", stdio: "pipe", timeout: 120_000 }).trim();
122
+ } catch (err: any) {
123
+ throw new Error(`${label}: ${err.stderr?.trim() || err.message}`);
124
+ }
125
+ }
126
+
127
+ function whichSync(cmd: string): string | null {
128
+ const result = execSafe("which", [cmd], { silent: true });
129
+ return result || null;
130
+ }
131
+
132
+ // ── Arg parser ───────────────────────────────────────
133
+
134
+ export function parseSetupArgs(argv: string[]): SetupOptions {
135
+ const opts: SetupOptions = {
136
+ botToken: "",
137
+ users: [],
138
+ provider: DEFAULT_PROVIDER,
139
+ model: DEFAULT_MODEL,
140
+ extensions: [],
141
+ cwd: homedir(),
142
+ notifyChatIds: [],
143
+ systemd: platform() === "linux",
144
+ voice: true,
145
+ psst: true,
146
+ nonInteractive: false,
147
+ force: false,
148
+ dryRun: false,
149
+ };
150
+
151
+ for (let i = 0; i < argv.length; i++) {
152
+ const arg = argv[i];
153
+ const next = () => {
154
+ if (i + 1 >= argv.length) throw new Error(`Missing value for ${arg}`);
155
+ return argv[++i];
156
+ };
157
+
158
+ switch (arg) {
159
+ case "--bot-token": opts.botToken = next(); break;
160
+ case "--user": opts.users.push(next().replace(/^@/, "")); break;
161
+ case "--provider": opts.provider = next(); break;
162
+ case "--model": opts.model = next(); break;
163
+ case "--extension": opts.extensions.push(next()); break;
164
+ case "--cwd": opts.cwd = next(); break;
165
+ case "--notify-chat": opts.notifyChatIds.push(parseInt(next(), 10)); break;
166
+ case "--no-systemd": opts.systemd = false; break;
167
+ case "--no-voice": opts.voice = false; break;
168
+ case "--no-psst": opts.psst = false; break;
169
+ case "--non-interactive": opts.nonInteractive = true; break;
170
+ case "--force": opts.force = true; break;
171
+ case "--dry-run": opts.dryRun = true; break;
172
+ default:
173
+ if (arg.startsWith("-")) throw new Error(`Unknown flag: ${arg}`);
174
+ throw new Error(`Unexpected argument: ${arg}`);
175
+ }
176
+ }
177
+
178
+ // Token from env if not in flags
179
+ if (!opts.botToken) {
180
+ opts.botToken = process.env.TELEGRAM_BOT_TOKEN ?? "";
181
+ }
182
+
183
+ // Validate
184
+ if (!opts.botToken) {
185
+ throw new Error(
186
+ "Bot token required. Provide via:\n" +
187
+ " TELEGRAM_BOT_TOKEN=... roundhouse setup --user USERNAME\n" +
188
+ " roundhouse setup --bot-token TOKEN --user USERNAME",
189
+ );
190
+ }
191
+ if (opts.users.length === 0) {
192
+ throw new Error("At least one --user USERNAME is required.");
193
+ }
194
+ for (const ext of opts.extensions) {
195
+ if (!EXTENSION_NAME_RE.test(ext)) {
196
+ throw new Error(`Invalid extension name: ${ext}`);
197
+ }
198
+ }
199
+ if (opts.notifyChatIds.some(isNaN)) {
200
+ throw new Error("--notify-chat must be a number");
201
+ }
202
+
203
+ return opts;
204
+ }
205
+
206
+ // ── Steps ────────────────────────────────────────────
207
+
208
+ async function stepPreflight(opts: SetupOptions): Promise<void> {
209
+ step("①", "Preflight checks...");
210
+
211
+ // Node version
212
+ const nodeVer = process.version;
213
+ const major = parseInt(nodeVer.replace("v", ""));
214
+ if (major < 20) {
215
+ fail(`Node.js ${nodeVer} — version 20+ required`);
216
+ throw new Error("Node.js 20+ required");
217
+ }
218
+ ok(`Node.js ${nodeVer}`);
219
+
220
+ // npm
221
+ if (!whichSync("npm")) {
222
+ fail("npm not found on PATH");
223
+ throw new Error("npm required");
224
+ }
225
+ ok("npm available");
226
+
227
+ // Config dirs writable
228
+ for (const dir of [ROUNDHOUSE_DIR, dirname(PI_SETTINGS_PATH)]) {
229
+ try {
230
+ await mkdir(dir, { recursive: true });
231
+ ok(`Writable: ${dir.replace(homedir(), "~")}`);
232
+ } catch {
233
+ fail(`Cannot create: ${dir}`);
234
+ throw new Error(`Cannot write to ${dir}`);
235
+ }
236
+ }
237
+
238
+ // Disk space (rough check)
239
+ try {
240
+ const dfOut = execSafe("df", ["-BG", "--output=avail", homedir()], { silent: true });
241
+ const match = dfOut.match(/(\d+)G/);
242
+ if (match) {
243
+ const freeGB = parseInt(match[1]);
244
+ if (freeGB < 1) {
245
+ fail(`Disk: ${freeGB} GB free (need >= 1 GB)`);
246
+ throw new Error("Insufficient disk space");
247
+ }
248
+ ok(`Disk: ${freeGB} GB free`);
249
+ }
250
+ } catch { /* non-fatal, df might not support these flags */ }
251
+
252
+ // Provider credentials (warn only)
253
+ if (opts.provider === "amazon-bedrock") {
254
+ const hasAws =
255
+ process.env.AWS_ACCESS_KEY_ID ||
256
+ process.env.AWS_PROFILE ||
257
+ await fileExists(resolve(homedir(), ".aws", "credentials"));
258
+ if (hasAws) {
259
+ ok("AWS credentials found");
260
+ } else {
261
+ warn("AWS credentials not found — configure before first use");
262
+ }
263
+ }
264
+
265
+ // --cwd validation
266
+ try {
267
+ const resolved = await realpath(opts.cwd);
268
+ const st = await stat(resolved);
269
+ if (!st.isDirectory()) throw new Error("not a directory");
270
+ opts.cwd = resolved;
271
+ ok(`Working directory: ${resolved.replace(homedir(), "~")}`);
272
+ } catch {
273
+ fail(`--cwd path invalid: ${opts.cwd}`);
274
+ throw new Error(`Invalid --cwd: ${opts.cwd}`);
275
+ }
276
+ }
277
+
278
+ async function stepValidateToken(opts: SetupOptions): Promise<BotInfo> {
279
+ step("②", "Validating Telegram bot token...");
280
+
281
+ const botInfo = await validateBotToken(opts.botToken);
282
+ ok(`Bot: @${botInfo.username} (id: ${botInfo.id})`);
283
+
284
+ // Check for conflicting webhook
285
+ const webhook = await checkWebhook(opts.botToken);
286
+ if (webhook) {
287
+ warn(`Webhook active: ${webhook}`);
288
+ warn("Polling won't work while a webhook is set. Remove it or switch to webhook mode.");
289
+ }
290
+
291
+ return botInfo;
292
+ }
293
+
294
+ async function stepStopGateway(): Promise<void> {
295
+ step("③", "Checking for running gateway...");
296
+
297
+ if (platform() !== "linux") {
298
+ ok("Not Linux — skipping service check");
299
+ return;
300
+ }
301
+
302
+ const isActive = execSafe("systemctl", ["is-active", "roundhouse"], { silent: true });
303
+ if (isActive === "active") {
304
+ log(" Stopping existing gateway...");
305
+ try {
306
+ execFileSync("sudo", ["-n", "systemctl", "stop", "roundhouse"], { stdio: "pipe", timeout: 30_000 });
307
+ ok("Service stopped");
308
+ } catch {
309
+ warn("Could not stop service (may need sudo). Continuing anyway.");
310
+ }
311
+ } else {
312
+ ok("No running gateway");
313
+ }
314
+ }
315
+
316
+ async function stepInstallPackages(opts: SetupOptions): Promise<void> {
317
+ step("④", "Installing packages...");
318
+
319
+ // Roundhouse
320
+ const rhInstalled = whichSync("roundhouse");
321
+ if (rhInstalled && !opts.force) {
322
+ const ver = execSafe("roundhouse", ["--version"], { silent: true }) || "installed";
323
+ ok(`@inceptionstack/roundhouse (${ver}, already installed)`);
324
+ } else {
325
+ log(" Installing @inceptionstack/roundhouse...");
326
+ execOrFail("npm", ["install", "-g", "@inceptionstack/roundhouse"], "roundhouse install");
327
+ ok("@inceptionstack/roundhouse");
328
+ }
329
+
330
+ // Pi agent
331
+ const piInstalled = whichSync("pi");
332
+ if (piInstalled && !opts.force) {
333
+ const ver = execSafe("pi", ["--version"], { silent: true }) || "installed";
334
+ ok(`@mariozechner/pi-coding-agent (${ver}, already installed)`);
335
+ } else {
336
+ log(" Installing @mariozechner/pi-coding-agent...");
337
+ execOrFail("npm", ["install", "-g", "@mariozechner/pi-coding-agent"], "pi install");
338
+ ok("@mariozechner/pi-coding-agent");
339
+ }
340
+
341
+ // psst-cli
342
+ if (opts.psst) {
343
+ const psstInstalled = whichSync("psst");
344
+ if (psstInstalled && !opts.force) {
345
+ ok(`psst-cli (already installed)`);
346
+ } else {
347
+ log(" Installing psst-cli...");
348
+ execOrFail("npm", ["install", "-g", "psst-cli"], "psst-cli install");
349
+ ok("psst-cli");
350
+ }
351
+
352
+ // Initialize psst vault
353
+ const vaultExists = await fileExists(resolve(homedir(), ".psst", "envs"));
354
+ if (vaultExists) {
355
+ ok("psst vault exists");
356
+ } else {
357
+ log(" Initializing psst vault...");
358
+ execOrFail("psst", ["init"], "psst init");
359
+ ok("psst vault initialized");
360
+ }
361
+
362
+ // Install pi-psst extension
363
+ log(" Installing pi-psst extension...");
364
+ try {
365
+ execFileSync("pi", ["install", "npm:@miclivs/pi-psst"], { encoding: "utf8", stdio: "pipe", timeout: 120_000 });
366
+ ok("@miclivs/pi-psst extension");
367
+ } catch {
368
+ // May already be installed
369
+ ok("@miclivs/pi-psst extension (already installed)");
370
+ }
371
+ }
372
+
373
+ // User extensions
374
+ for (const ext of opts.extensions) {
375
+ log(` Installing extension: ${ext}...`);
376
+ execOrFail("pi", ["install", `npm:${ext}`], `extension ${ext}`);
377
+ ok(ext);
378
+ }
379
+ }
380
+
381
+ async function stepStoreSecrets(opts: SetupOptions, botInfo: BotInfo): Promise<void> {
382
+ if (!opts.psst) return;
383
+
384
+ step("⑤", "Storing secrets in psst...");
385
+
386
+ const secrets: [string, string][] = [
387
+ ["TELEGRAM_BOT_TOKEN", opts.botToken],
388
+ ["BOT_USERNAME", botInfo.username],
389
+ ["ALLOWED_USERS", opts.users.join(",")],
390
+ ];
391
+
392
+ for (const [name, value] of secrets) {
393
+ try {
394
+ execFileSync("psst", ["set", name, "--stdin"], {
395
+ input: value,
396
+ encoding: "utf8",
397
+ stdio: ["pipe", "pipe", "pipe"],
398
+ timeout: 10_000,
399
+ });
400
+ ok(`${name} → psst vault`);
401
+ } catch {
402
+ // May already exist with same value
403
+ // Try overwrite
404
+ try {
405
+ execFileSync("psst", ["set", name, "--stdin"], {
406
+ input: value,
407
+ encoding: "utf8",
408
+ stdio: ["pipe", "pipe", "pipe"],
409
+ timeout: 10_000,
410
+ env: { ...process.env, PSST_FORCE: "1" },
411
+ });
412
+ ok(`${name} → psst vault (updated)`);
413
+ } catch (err: any) {
414
+ warn(`Failed to store ${name} in psst: ${err.message}`);
415
+ }
416
+ }
417
+ }
418
+ }
419
+
420
+ async function stepConfigure(
421
+ opts: SetupOptions,
422
+ botInfo: BotInfo,
423
+ pairResult: PairResult | null,
424
+ ): Promise<void> {
425
+ step("⑥", "Configuring...");
426
+
427
+ await mkdir(ROUNDHOUSE_DIR, { recursive: true });
428
+ await mkdir(dirname(PI_SETTINGS_PATH), { recursive: true });
429
+
430
+ // ── Pi settings ──
431
+ let piSettings: Record<string, any> = {};
432
+ try {
433
+ piSettings = JSON.parse(await readFile(PI_SETTINGS_PATH, "utf8"));
434
+ } catch { /* doesn't exist */ }
435
+
436
+ if (opts.force) {
437
+ piSettings.defaultProvider = opts.provider;
438
+ piSettings.defaultModel = opts.model;
439
+ } else {
440
+ const existingProvider = piSettings.defaultProvider;
441
+ const existingModel = piSettings.defaultModel;
442
+ if (existingProvider && existingProvider !== opts.provider) {
443
+ warn(`Pi provider already set to '${existingProvider}' (keeping, use --force to override)`);
444
+ } else {
445
+ piSettings.defaultProvider = opts.provider;
446
+ }
447
+ if (existingModel && existingModel !== opts.model) {
448
+ warn(`Pi model already set to '${existingModel}' (keeping, use --force to override)`);
449
+ } else {
450
+ piSettings.defaultModel = opts.model;
451
+ }
452
+ }
453
+
454
+ // Ensure packages array includes pi-psst if using psst
455
+ if (!piSettings.packages) piSettings.packages = [];
456
+ if (opts.psst && !piSettings.packages.includes("npm:@miclivs/pi-psst")) {
457
+ piSettings.packages.push("npm:@miclivs/pi-psst");
458
+ }
459
+
460
+ await atomicWriteJson(PI_SETTINGS_PATH, piSettings);
461
+ ok(`~/.pi/agent/settings.json (${piSettings.defaultProvider}, ${piSettings.defaultModel})`);
462
+
463
+ // ── Gateway config ──
464
+ let gatewayConfig: Record<string, any> = {};
465
+ if (!opts.force) {
466
+ try {
467
+ gatewayConfig = JSON.parse(await readFile(CONFIG_PATH, "utf8"));
468
+ } catch { /* new install */ }
469
+ }
470
+
471
+ // Merge users
472
+ const existingUsers: string[] = gatewayConfig.chat?.allowedUsers ?? [];
473
+ const existingUserIds: number[] = gatewayConfig.chat?.allowedUserIds ?? [];
474
+ const existingNotifyIds: number[] = (gatewayConfig.chat?.notifyChatIds ?? []).map(Number).filter((n) => !isNaN(n));
475
+
476
+ const mergedUsers = [...new Set([...existingUsers, ...opts.users])];
477
+ const mergedUserIds = [...existingUserIds];
478
+ const mergedNotifyIds = [...new Set([...existingNotifyIds, ...opts.notifyChatIds])];
479
+
480
+ // Add paired user data
481
+ if (pairResult) {
482
+ if (!mergedUserIds.includes(pairResult.userId)) {
483
+ mergedUserIds.push(pairResult.userId);
484
+ }
485
+ if (!mergedNotifyIds.includes(pairResult.chatId)) {
486
+ mergedNotifyIds.push(pairResult.chatId);
487
+ }
488
+ }
489
+
490
+ gatewayConfig = {
491
+ ...gatewayConfig,
492
+ _version: 1, // Config schema version — for future migration support
493
+ agent: { ...gatewayConfig.agent, type: "pi", cwd: opts.cwd },
494
+ chat: {
495
+ ...gatewayConfig.chat,
496
+ botUsername: botInfo.username,
497
+ allowedUsers: mergedUsers,
498
+ allowedUserIds: mergedUserIds,
499
+ notifyChatIds: mergedNotifyIds,
500
+ adapters: gatewayConfig.chat?.adapters ?? { telegram: { mode: "polling" } },
501
+ },
502
+ ...(opts.voice === false ? { voice: { stt: { enabled: false } } } : {}),
503
+ };
504
+
505
+ await atomicWriteJson(CONFIG_PATH, gatewayConfig);
506
+ ok(`~/.roundhouse/gateway.config.json`);
507
+
508
+ // ── Env file ──
509
+ // With psst: only non-secret config
510
+ // Without psst: include secrets
511
+ const envLines: string[] = [];
512
+
513
+ if (!opts.psst) {
514
+ envLines.push(`TELEGRAM_BOT_TOKEN=${envQuote(opts.botToken)}`);
515
+ envLines.push(`BOT_USERNAME=${envQuote(botInfo.username)}`);
516
+ envLines.push(`ALLOWED_USERS=${envQuote(opts.users.join(","))}`);
517
+ }
518
+
519
+ if (opts.provider === "amazon-bedrock") {
520
+ // Preserve existing AWS config
521
+ let existingEnv: Record<string, string> = {};
522
+ try {
523
+ const raw = await readFile(ENV_PATH, "utf8");
524
+ for (const line of raw.split("\n")) {
525
+ const trimmed = line.trim();
526
+ if (!trimmed || trimmed.startsWith("#")) continue;
527
+ const eq = trimmed.indexOf("=");
528
+ if (eq > 0) existingEnv[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
529
+ }
530
+ } catch {}
531
+
532
+ if (!envLines.some((l) => l.startsWith("AWS_PROFILE="))) {
533
+ envLines.push(`AWS_PROFILE=${existingEnv.AWS_PROFILE ?? '"default"'}`);
534
+ }
535
+ if (!envLines.some((l) => l.startsWith("AWS_DEFAULT_REGION="))) {
536
+ envLines.push(`AWS_DEFAULT_REGION=${existingEnv.AWS_DEFAULT_REGION ?? '"us-east-1"'}`);
537
+ }
538
+ }
539
+
540
+ await atomicWriteText(ENV_PATH, envLines.join("\n") + "\n");
541
+ ok(`~/.roundhouse/env${opts.psst ? " (non-secret config only)" : ""}`);
542
+ }
543
+
544
+ async function stepPair(opts: SetupOptions, botInfo: BotInfo): Promise<PairResult | null> {
545
+ step("⑦", "Pairing with Telegram...");
546
+
547
+ // Skip if chat IDs already known
548
+ if (opts.notifyChatIds.length > 0) {
549
+ ok(`Using provided notify chat IDs: ${opts.notifyChatIds.join(", ")}`);
550
+
551
+ // Send test message
552
+ for (const chatId of opts.notifyChatIds) {
553
+ try {
554
+ await sendMessage(opts.botToken, chatId, "✅ Roundhouse setup complete! Gateway is starting.");
555
+ ok(`Sent test message to chat ${chatId}`);
556
+ } catch {
557
+ warn(`Could not send message to chat ${chatId}`);
558
+ }
559
+ }
560
+ return null;
561
+ }
562
+
563
+ // Skip if existing config already has notifyChatIds
564
+ if (!opts.force) {
565
+ try {
566
+ const existing = JSON.parse(await readFile(CONFIG_PATH, "utf8"));
567
+ const existingIds = existing.chat?.notifyChatIds ?? [];
568
+ if (existingIds.length > 0) {
569
+ ok(`Already paired (chat IDs: ${existingIds.join(", ")})`);
570
+ return null;
571
+ }
572
+ } catch {}
573
+ }
574
+
575
+ // Skip if non-interactive
576
+ if (opts.nonInteractive) {
577
+ warn("Skipping pairing (--non-interactive)");
578
+ warn("Startup notifications won't work until paired.");
579
+ warn("Run 'roundhouse pair' later to pair.");
580
+ return null;
581
+ }
582
+
583
+ const result = await pairTelegram(opts.botToken, botInfo.username, opts.users, 300_000, log);
584
+
585
+ if (result) {
586
+ ok(`Paired with @${result.username} (user id: ${result.userId}, chat: ${result.chatId})`);
587
+ return result;
588
+ }
589
+
590
+ warn("Pairing timed out.");
591
+ warn("Run 'roundhouse pair' later to pair.");
592
+ return null;
593
+ }
594
+
595
+ async function stepRegisterCommands(opts: SetupOptions): Promise<void> {
596
+ step("⑧", "Registering bot commands...");
597
+ await registerBotCommands(opts.botToken);
598
+ ok(`${BOT_COMMANDS.length} commands registered with Telegram`);
599
+ }
600
+
601
+ async function stepInstallSystemd(opts: SetupOptions): Promise<void> {
602
+ step("⑨", "Installing systemd service...");
603
+
604
+ if (!opts.systemd) {
605
+ ok("Skipped (--no-systemd)");
606
+ log(" Run manually: roundhouse start");
607
+ return;
608
+ }
609
+
610
+ if (platform() !== "linux") {
611
+ warn(`Systemd not available (${platform()})`);
612
+ log(" Run manually: roundhouse start");
613
+ return;
614
+ }
615
+
616
+ // Check sudo
617
+ const hasSudo = spawnSync("sudo", ["-n", "true"], { stdio: "pipe" }).status === 0;
618
+ if (!hasSudo) {
619
+ warn("No passwordless sudo — cannot install systemd service");
620
+ log(" Run manually: roundhouse start");
621
+ log(" Or install with: sudo roundhouse install");
622
+ return;
623
+ }
624
+
625
+ // Resolve paths
626
+ const roundhouseBin = whichSync("roundhouse") ?? resolve(dirname(process.execPath), "roundhouse");
627
+ const psstBin = opts.psst ? whichSync("psst") : null;
628
+ const nodeBin = process.execPath;
629
+ const nodeBinDir = dirname(nodeBin);
630
+ const user = process.env.USER || process.env.LOGNAME;
631
+ if (!user) {
632
+ warn("Cannot determine current user ($USER not set). Skipping systemd.");
633
+ log(" Run manually: roundhouse start");
634
+ return;
635
+ }
636
+
637
+ // Guard against newline injection in interpolated values
638
+ const unitPaths = { user, roundhouseBin, nodeBin, nodeBinDir, psstBin, home: homedir(), cwd: opts.cwd };
639
+ for (const [key, val] of Object.entries(unitPaths)) {
640
+ if (val && /[\n\r]/.test(val)) {
641
+ warn(`Unsafe value for ${key} (contains newline). Skipping systemd.`);
642
+ return;
643
+ }
644
+ }
645
+
646
+ // Build ExecStart
647
+ let execStart: string;
648
+ if (psstBin) {
649
+ execStart = `${psstBin} run ${nodeBin} ${roundhouseBin} start`;
650
+ } else {
651
+ execStart = `${nodeBin} ${roundhouseBin} start`;
652
+ }
653
+
654
+ // Always include env file for non-secret config (AWS_PROFILE, etc)
655
+ // When using psst, ExecStart wraps with `psst run` to inject secrets
656
+ const unit = `[Unit]
657
+ Description=Roundhouse Chat Gateway
658
+ After=network.target
659
+
660
+ [Service]
661
+ Type=simple
662
+ User=${user}
663
+ WorkingDirectory=${homedir()}
664
+ ExecStart=${execStart}
665
+ Restart=on-failure
666
+ RestartSec=5
667
+ EnvironmentFile=-${ENV_PATH}
668
+ Environment=ROUNDHOUSE_CONFIG=${CONFIG_PATH}
669
+ Environment=NODE_ENV=production
670
+ Environment=PATH=${nodeBinDir}:/usr/local/bin:/usr/bin:/bin
671
+ Environment=HOME=${homedir()}
672
+
673
+ [Install]
674
+ WantedBy=multi-user.target
675
+ `;
676
+
677
+ // Write to tmp, then sudo cp
678
+ const tmpPath = resolve(ROUNDHOUSE_DIR, `roundhouse.service.tmp.${randomBytes(4).toString("hex")}`);
679
+ const servicePath = `/etc/systemd/system/roundhouse.service`;
680
+
681
+ try {
682
+ await writeFile(tmpPath, unit, { mode: 0o600 });
683
+ execFileSync("sudo", ["-n", "cp", tmpPath, servicePath], { stdio: "pipe" });
684
+ await unlink(tmpPath);
685
+ execFileSync("sudo", ["-n", "systemctl", "daemon-reload"], { stdio: "pipe" });
686
+ execFileSync("sudo", ["-n", "systemctl", "enable", "roundhouse"], { stdio: "pipe" });
687
+ execFileSync("sudo", ["-n", "systemctl", "start", "roundhouse"], { stdio: "pipe" });
688
+ ok("roundhouse.service enabled and started");
689
+ } catch (err: any) {
690
+ try { await unlink(tmpPath); } catch {}
691
+ warn(`Systemd install failed: ${err.message}`);
692
+ log(" Run manually: roundhouse start");
693
+ }
694
+ }
695
+
696
+ async function stepPostflight(): Promise<void> {
697
+ step("⑩", "Postflight checks...");
698
+
699
+ if (platform() === "linux") {
700
+ const isActive = execSafe("systemctl", ["is-active", "roundhouse"], { silent: true });
701
+ if (isActive === "active") {
702
+ const pid = execSafe("systemctl", ["show", "-p", "MainPID", "--value", "roundhouse"], { silent: true });
703
+ ok(`Service active (PID ${pid})`);
704
+ } else {
705
+ warn("Service not active — check: roundhouse logs");
706
+ }
707
+ }
708
+
709
+ if (await fileExists(CONFIG_PATH)) {
710
+ ok("Config readable");
711
+ } else {
712
+ warn(`Config missing: ${CONFIG_PATH}`);
713
+ }
714
+
715
+ // Optional checks
716
+ if (!whichSync("ffmpeg")) {
717
+ warn("ffmpeg not found (install for voice support)");
718
+ }
719
+ }
720
+
721
+ // ── Orchestrator ─────────────────────────────────────
722
+
723
+ export async function cmdSetup(argv: string[]): Promise<void> {
724
+ let opts: SetupOptions;
725
+ try {
726
+ opts = parseSetupArgs(argv);
727
+ } catch (err: any) {
728
+ console.error(`\n❌ ${err.message}\n`);
729
+ printSetupHelp();
730
+ process.exit(1);
731
+ }
732
+
733
+ if (opts.dryRun) {
734
+ printDryRun(opts);
735
+ return;
736
+ }
737
+
738
+ log("\n🔧 Roundhouse Setup");
739
+ log("━━━━━━━━━━━━━━━━━━━");
740
+
741
+ try {
742
+ // Phase 1: Validate (no mutations)
743
+ await stepPreflight(opts);
744
+ const botInfo = await stepValidateToken(opts);
745
+ await stepStopGateway();
746
+
747
+ // Phase 2: Install packages
748
+ await stepInstallPackages(opts);
749
+
750
+ // Phase 3: Store secrets
751
+ await stepStoreSecrets(opts, botInfo);
752
+
753
+ // Phase 4: Pair (before config, so we can include chat ID)
754
+ const pairResult = await stepPair(opts, botInfo);
755
+
756
+ // Phase 5: Write config (includes pair data)
757
+ await stepConfigure(opts, botInfo, pairResult);
758
+
759
+ // Phase 6: Remote setup
760
+ await stepRegisterCommands(opts);
761
+
762
+ // Phase 7: Service
763
+ await stepInstallSystemd(opts);
764
+
765
+ // Phase 8: Verify
766
+ await stepPostflight();
767
+
768
+ // Final message
769
+ const warnings = !opts.notifyChatIds.length && !pairResult;
770
+ log("\n━━━━━━━━━━━━━━━━━━━");
771
+ if (warnings) {
772
+ log("⚠️ Installed, action required:");
773
+ log(` • Not paired — run: roundhouse pair`);
774
+ } else {
775
+ log("✅ Roundhouse is running!");
776
+ }
777
+ log(` Bot: @${botInfo.username}`);
778
+ log(` Memory: ${opts.extensions.some((e) => e.includes("pi-memory")) ? "agent-managed" : "roundhouse-managed"}`);
779
+ log(` Secrets: ${opts.psst ? "psst vault (encrypted)" : "~/.roundhouse/env (plaintext)"}`);
780
+ log(` Send /status to @${botInfo.username} on Telegram.\n`);
781
+ } catch (err: any) {
782
+ log("\n━━━━━━━━━━━━━━━━━━━");
783
+ log(`❌ Setup failed: ${err.message}`);
784
+ log(" Partial changes may have been applied.");
785
+ log(" Re-run setup to complete, or run: roundhouse doctor\n");
786
+ process.exit(1);
787
+ }
788
+ }
789
+
790
+ // ── Pair command ─────────────────────────────────────
791
+
792
+ export async function cmdPair(argv: string[]): Promise<void> {
793
+ // Load token from psst, env, or flag
794
+ let token = "";
795
+ let users: string[] = [];
796
+
797
+ for (let i = 0; i < argv.length; i++) {
798
+ if (argv[i] === "--bot-token" && argv[i + 1]) token = argv[++i];
799
+ else if (argv[i] === "--user" && argv[i + 1]) users.push(argv[++i].replace(/^@/, ""));
800
+ }
801
+
802
+ // Try env
803
+ if (!token) token = process.env.TELEGRAM_BOT_TOKEN ?? "";
804
+
805
+ // Try psst
806
+ if (!token) {
807
+ token = execSafe("psst", ["get", "TELEGRAM_BOT_TOKEN"], { silent: true });
808
+ }
809
+
810
+ // Try existing env file
811
+ if (!token) {
812
+ try {
813
+ const envContent = await readFile(ENV_PATH, "utf8");
814
+ const match = envContent.match(/TELEGRAM_BOT_TOKEN=["']?([^"'\n]+)["']?/);
815
+ if (match) token = match[1];
816
+ } catch {}
817
+ }
818
+
819
+ if (!token) {
820
+ console.error("No bot token found. Provide via --bot-token, TELEGRAM_BOT_TOKEN env, or psst vault.");
821
+ process.exit(1);
822
+ }
823
+
824
+ // Load users from config if not provided
825
+ if (users.length === 0) {
826
+ try {
827
+ const config = JSON.parse(await readFile(CONFIG_PATH, "utf8"));
828
+ users = config.chat?.allowedUsers ?? [];
829
+ } catch {}
830
+ }
831
+
832
+ if (users.length === 0) {
833
+ console.error("No users specified. Provide --user USERNAME or configure allowedUsers in gateway config.");
834
+ process.exit(1);
835
+ }
836
+
837
+ log("\n🔗 Roundhouse Pairing\n");
838
+
839
+ const botInfo = await validateBotToken(token);
840
+ ok(`Bot: @${botInfo.username}`);
841
+
842
+ const result = await pairTelegram(token, botInfo.username, users, 300_000, log);
843
+
844
+ if (!result) {
845
+ log("\n⚠ Pairing timed out. Try again: roundhouse pair\n");
846
+ process.exit(1);
847
+ }
848
+
849
+ ok(`Paired with @${result.username} (user id: ${result.userId}, chat: ${result.chatId})`);
850
+
851
+ // Update config
852
+ try {
853
+ const config = JSON.parse(await readFile(CONFIG_PATH, "utf8"));
854
+ if (!config.chat) config.chat = {};
855
+ const existingUserIds: number[] = config.chat.allowedUserIds ?? [];
856
+ const existingNotifyIds: number[] = (config.chat.notifyChatIds ?? []).map(Number).filter((n) => !isNaN(n));
857
+
858
+ if (!existingUserIds.includes(result.userId)) existingUserIds.push(result.userId);
859
+ if (!existingNotifyIds.includes(result.chatId)) existingNotifyIds.push(result.chatId);
860
+
861
+ config.chat.allowedUserIds = existingUserIds;
862
+ config.chat.notifyChatIds = existingNotifyIds;
863
+
864
+ await atomicWriteJson(CONFIG_PATH, config);
865
+ ok("Config updated with chat ID");
866
+ } catch {
867
+ warn("Could not update config — add notifyChatIds manually");
868
+ }
869
+
870
+ log("\n✅ Paired! Restart gateway to apply: roundhouse restart\n");
871
+ }
872
+
873
+ // ── Dry run ──────────────────────────────────────────
874
+
875
+ function printDryRun(opts: SetupOptions): void {
876
+ log("\n🔧 Roundhouse Setup (DRY RUN)");
877
+ log("━━━━━━━━━━━━━━━━━━━\n");
878
+ log("Would validate Telegram token");
879
+ log("Would stop existing gateway (if running)");
880
+ log(`Would install: npm install -g @inceptionstack/roundhouse`);
881
+ log(`Would install: npm install -g @mariozechner/pi-coding-agent`);
882
+ if (opts.psst) {
883
+ log(`Would install: npm install -g psst-cli`);
884
+ log(`Would initialize psst vault`);
885
+ log(`Would install: pi-psst extension`);
886
+ log(`Would store TELEGRAM_BOT_TOKEN, BOT_USERNAME, ALLOWED_USERS in psst`);
887
+ }
888
+ for (const ext of opts.extensions) log(`Would install extension: ${ext}`);
889
+ log(`Would configure: ~/.pi/agent/settings.json`);
890
+ log(` Set defaultProvider: ${opts.provider}`);
891
+ log(` Set defaultModel: ${opts.model}`);
892
+ log(`Would write: ~/.roundhouse/gateway.config.json`);
893
+ log(`Would write: ~/.roundhouse/env${opts.psst ? " (non-secret config only)" : ""}`);
894
+ log(`Would register ${BOT_COMMANDS.length} bot commands`);
895
+ if (!opts.nonInteractive && opts.notifyChatIds.length === 0) {
896
+ log(`Would pair via Telegram (interactive)`);
897
+ }
898
+ if (opts.systemd) log(`Would install systemd service`);
899
+ log("\nNo changes made.\n");
900
+ }
901
+
902
+ // ── Help ─────────────────────────────────────────────
903
+
904
+ function printSetupHelp(): void {
905
+ console.log(`
906
+ Usage:
907
+ TELEGRAM_BOT_TOKEN=... roundhouse setup --user USERNAME
908
+ roundhouse setup --bot-token TOKEN --user USERNAME [options]
909
+
910
+ Required:
911
+ --user <username> Telegram username (repeatable, strips @)
912
+
913
+ Token (one required):
914
+ TELEGRAM_BOT_TOKEN env Preferred — not in shell history
915
+ --bot-token <token> Fallback for scripts
916
+
917
+ Agent:
918
+ --provider <provider> AI provider (default: amazon-bedrock)
919
+ --model <model> AI model (default: us.anthropic.claude-opus-4-6-v1)
920
+ --extension <pkg> Pi extension (repeatable)
921
+ --cwd <path> Agent working directory (default: ~)
922
+
923
+ Channel:
924
+ --notify-chat <id> Telegram chat ID (repeatable, skips pairing)
925
+
926
+ Service:
927
+ --no-systemd Skip systemd install
928
+ --no-voice Disable voice/STT
929
+ --no-psst Skip psst, use plaintext env file
930
+
931
+ Behavior:
932
+ --non-interactive No pairing, no prompts
933
+ --force Overwrite existing configs
934
+ --dry-run Preview without changes
935
+ `);
936
+ }