@inceptionstack/roundhouse 0.5.4 → 0.5.5

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 (34) hide show
  1. package/architecture.md +37 -18
  2. package/package.json +2 -1
  3. package/skills/pr-merge-discipline/SKILL.md +36 -0
  4. package/skills/roundhouse-cron/SKILL.md +136 -0
  5. package/src/agents/kiro/kiro-adapter.ts +1 -4
  6. package/src/agents/pi/pi-adapter.ts +1 -4
  7. package/src/cli/cli.ts +1 -1
  8. package/src/cli/setup/args.ts +8 -9
  9. package/src/cli/setup/flows.ts +47 -14
  10. package/src/cli/{setup-logger.ts → setup/logger.ts} +4 -4
  11. package/src/cli/{setup-prompts.ts → setup/prompts.ts} +23 -2
  12. package/src/cli/setup/runtime.ts +1 -1
  13. package/src/cli/setup/steps.ts +3 -3
  14. package/src/cli/{setup-telegram.ts → setup/telegram.ts} +4 -4
  15. package/src/cli/setup/types.ts +4 -3
  16. package/src/cli/setup.ts +8 -8
  17. package/src/cli/systemd.ts +2 -0
  18. package/src/{commands → cli}/update.ts +1 -1
  19. package/src/cron/runner.ts +2 -1
  20. package/src/gateway/commands.ts +4 -3
  21. package/src/{gateway.ts → gateway/gateway.ts} +63 -97
  22. package/src/gateway/helpers.ts +1 -1
  23. package/src/gateway/index.ts +2 -5
  24. package/src/gateway/streaming.ts +1 -1
  25. package/src/{bundle.ts → provisioning/bundle.ts} +32 -0
  26. package/src/transports/index.ts +6 -0
  27. package/src/{telegram-html.ts → transports/telegram/html.ts} +2 -2
  28. package/src/{pairing.ts → transports/telegram/pairing.ts} +1 -1
  29. package/src/transports/telegram/telegram-adapter.ts +111 -0
  30. package/src/transports/types.ts +71 -0
  31. /package/src/{commands.ts → transports/telegram/bot-commands.ts} +0 -0
  32. /package/src/{telegram-format.ts → transports/telegram/format.ts} +0 -0
  33. /package/src/{notify/telegram.ts → transports/telegram/notify.ts} +0 -0
  34. /package/src/{telegram-progress.ts → transports/telegram/progress.ts} +0 -0
package/architecture.md CHANGED
@@ -282,15 +282,13 @@ src/
282
282
 
283
283
  ├── gateway.ts # Gateway class: chat SDK wiring, handleAgentTurn
284
284
  │ ├── gateway/
285
- │ │ ├── commands.ts # 9 Telegram command handlers (/new, /stop, /status, etc.)
285
+ │ │ ├── commands.ts # 9 command handlers (/new, /stop, /status, etc.)
286
286
  │ │ ├── streaming.ts # Agent event → Telegram message stream mapper
287
287
  │ │ ├── attachments.ts # File save, validation, size limits
288
- │ │ ├── helpers.ts # Pure utils: splitMessage, isAllowed, threadIdToDir
288
+ │ │ ├── helpers.ts # isCommand, resolveAgentThreadId, getSystemResources, toolIcon
289
289
  │ │ └── index.ts # Barrel re-export
290
- │ ├── commands/update.ts # /update handler → bundle provisioning
291
290
  │ ├── cron/scheduler.ts # Tick loop, catch-up, job dispatch
292
291
  │ ├── memory/ # Session memory hooks (flush, compact, inject)
293
- │ ├── notify/telegram.ts # Startup/error notifications
294
292
  │ └── voice/
295
293
  │ ├── stt-service.ts # STT orchestration (provider chain)
296
294
  │ └── providers/whisper.ts # Whisper CLI provider
@@ -309,15 +307,15 @@ src/
309
307
  │ ├── setup.ts # Setup dispatcher (300 lines): cmdSetup, cmdPair, help
310
308
  │ ├── setup/
311
309
  │ │ ├── steps.ts # 11 step functions (preflight → postflight)
312
- │ │ ├── flows.ts # Interactive + headless orchestrators
313
- │ │ ├── runtime.ts # Logger factories, agent resolution
310
+ │ │ ├── flows.ts # Interactive + non-interactive orchestrators
311
+ │ │ ├── runtime.ts # Agent resolution, StepLog bridge
314
312
  │ │ ├── args.ts # Argument parser
315
313
  │ │ ├── helpers.ts # Atomic writes, exec wrappers
316
314
  │ │ ├── types.ts # SetupOptions, StepLog interface
315
+ │ │ ├── prompts.ts # TTY prompt helpers (text, masked, choice)
316
+ │ │ ├── logger.ts # JSON/text logger for non-interactive diagnostics
317
+ │ │ ├── telegram.ts # Telegram API: validate token, pair, register commands
317
318
  │ │ └── index.ts # Barrel export
318
- │ ├── setup-telegram.ts # Telegram API: validate token, pair, register commands
319
- │ ├── setup-prompts.ts # TTY prompt helpers
320
- │ ├── setup-logger.ts # JSON/text logger for headless diagnostics
321
319
  │ ├── qr.ts # QR code generation for pairing links
322
320
  │ └── doctor/ # Health checks (8 check modules + runner)
323
321
 
@@ -338,19 +336,40 @@ src/
338
336
  │ ├── bootstrap.ts, inject.ts # Session bootstrapping, context injection
339
337
  │ ├── state.ts, types.ts # State tracking, interfaces
340
338
 
341
- ├── telegram-format.ts # Markdown Telegram HTML converter
342
- ├── telegram-html.ts # HTML entity utilities
343
- ├── telegram-progress.ts # Typing indicator + progress edits
344
- ├── bundle.ts # Skill/extension bundle provisioning
345
- ├── pairing.ts # Nonce-based Telegram pairing protocol
346
- ├── commands.ts # Bot command definitions
339
+ ├── transports/ # Transport adapter layer
340
+ ├── types.ts # TransportAdapter interface + PairingResult
341
+ ├── index.ts # Barrel export
342
+ │ └── telegram/ # Telegram implementation
343
+ ├── telegram-adapter.ts # TelegramAdapter (implements TransportAdapter)
344
+ ├── format.ts # Markdown Telegram HTML converter
345
+ │ ├── html.ts # HTML streaming + entity utilities
346
+ │ ├── progress.ts # Typing indicator + progress edits
347
+ │ ├── bot-commands.ts # Bot command definitions
348
+ │ ├── pairing.ts # Nonce-based Telegram pairing protocol
349
+ │ └── notify.ts # Send messages to notify chat IDs
350
+
351
+ ├── provisioning/
352
+ │ └── bundle.ts # Skill/extension bundle provisioning
353
+
347
354
  └── util.ts # Runtime helpers (crypto, path)
348
355
  ```
349
356
 
357
+ **Repo-root directories (outside `src/`):**
358
+ ```
359
+ skills/ # Bundled skills (shipped in package)
360
+ ├── roundhouse-cron/SKILL.md # Cron job skill for pi
361
+ └── pr-merge-discipline/SKILL.md # PR merge workflow skill
362
+ ```
363
+
350
364
  **Dependency rules:**
351
365
  - No circular dependencies
352
366
  - `types.ts`, `config.ts`, `util.ts` are pure leaf modules
353
- - `bundle.ts` is a leaf (only `node:*` imports)
354
- - Gateway modules (`gateway/*.ts`) import from `../types`, `../config`, `../util`, `../memory/*`, `../telegram-*`
355
- - CLI modules never import from `gateway.ts` (separation of concerns)
367
+ - `provisioning/bundle.ts` is a leaf (only `node:*` imports)
368
+ - Gateway modules (`gateway/*.ts`) import from `../transports` (via TransportAdapter interface), `../types`, `../config`, `../util`, `../memory/*`
369
+ - Gateway holds a `TransportAdapter` instance all platform-specific operations go through this interface
370
+ - `transports/telegram/progress.ts` is still imported directly by gateway (deferred from adapter extraction)
371
+ - `gateway/streaming.ts` imports `transports/telegram/html.ts` directly (deferred — streaming is tightly coupled to Telegram HTML wire format)
372
+ - `cron/runner.ts` imports `transports/telegram/notify` directly (deferred — will route through adapter when multi-transport lands)
373
+ - CLI modules never import from `gateway/` (separation of concerns)
374
+ - CLI setup modules (`cli/setup/*.ts`) import from `transports/telegram/` directly (by design — setup is inherently transport-specific)
356
375
  - Agent adapters depend on their SDK + `../../types`, `../../config`, `../../util`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inceptionstack/roundhouse",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "type": "module",
5
5
  "description": "Multi-platform chat gateway that routes messages through a configured AI agent",
6
6
  "license": "MIT",
@@ -31,6 +31,7 @@
31
31
  "files": [
32
32
  "src/",
33
33
  "bin/",
34
+ "skills/",
34
35
  "LICENSE",
35
36
  "README.md",
36
37
  "architecture.md",
@@ -0,0 +1,36 @@
1
+ # PR Merge Discipline
2
+
3
+ Never merge a PR without checking for review comments first.
4
+
5
+ ## When to Use
6
+
7
+ Activate this skill when:
8
+ - About to merge a PR
9
+ - Using `gh pr merge`
10
+ - PR has been approved or CI passed
11
+
12
+ ## Rules
13
+
14
+ 1. **Always check PR comments before merging**: `gh pr view <number> --comments`
15
+ 2. **If there are unresolved comments**, read and address them first
16
+ 3. **If there are review requests**, wait for or acknowledge them
17
+ 4. **Only merge when**: CI green + no unresolved comments + no pending reviews
18
+ 5. **After merge**: pull main and verify clean state
19
+
20
+ ## Workflow
21
+
22
+ ```bash
23
+ # Before any merge:
24
+ gh pr view <number> --comments
25
+ gh pr checks <number>
26
+
27
+ # Only then:
28
+ gh pr merge <number> --squash --admin
29
+ ```
30
+
31
+ ## Anti-Patterns
32
+
33
+ - ❌ Merging immediately after CI passes without checking comments
34
+ - ❌ Using `sleep && gh pr merge` without a comment check in between
35
+ - ❌ Force-merging over unresolved review feedback
36
+ - ❌ Assuming "no issues found" from automated review means no human comments exist
@@ -0,0 +1,136 @@
1
+ # Roundhouse Cron Jobs
2
+
3
+ Schedule tasks, add cron jobs, trigger actions at specific times or intervals using the `roundhouse cron` CLI.
4
+
5
+ ## When to Use
6
+
7
+ Activate this skill when the user asks to:
8
+ - Add a scheduled job or cron job
9
+ - Run something every X minutes/hours/days
10
+ - Trigger something at a specific time
11
+ - List, edit, pause, resume, or delete scheduled jobs
12
+ - Check job run history
13
+
14
+ ## CLI Reference
15
+
16
+ ### Add a job
17
+
18
+ ```bash
19
+ roundhouse cron add <id> --prompt "..." --cron "0 8 * * *"
20
+ roundhouse cron add <id> --prompt "..." --every "6h"
21
+ roundhouse cron add <id> --prompt "..." --at "30m"
22
+ ```
23
+
24
+ **Required flags:**
25
+ - `--prompt "..."` — The prompt sent to the agent when the job fires
26
+ - One schedule type (pick one):
27
+ - `--cron "0 8 * * *"` — Standard cron expression
28
+ - `--every "6h"` — Interval (e.g., `30m`, `2h`, `1d`)
29
+ - `--at "..."` — One-shot (e.g., `30m` from now, or ISO date `2026-05-10T14:00:00Z`)
30
+
31
+ **Optional flags:**
32
+ - `--tz "Asia/Jerusalem"` — Timezone (default: system timezone)
33
+ - `--telegram "123456,789012"` — Notify these Telegram chat IDs
34
+ - `--notify-on "always|success|failure"` — When to send notifications
35
+ - `--var "key=value,key2=value2"` — Template variables for the prompt
36
+ - `--timeout "30m"` — Max execution time
37
+ - `--description "..."` — Human-readable description
38
+ - `--replace` — Overwrite existing job with same ID
39
+ - `--json` — Output job config as JSON
40
+
41
+ ### List jobs
42
+
43
+ ```bash
44
+ roundhouse cron list
45
+ roundhouse cron list --json
46
+ ```
47
+
48
+ ### Show job details
49
+
50
+ ```bash
51
+ roundhouse cron show <id>
52
+ roundhouse cron show <id> --json
53
+ ```
54
+
55
+ ### Trigger a job manually
56
+
57
+ ```bash
58
+ roundhouse cron trigger <id>
59
+ ```
60
+
61
+ ### View run history
62
+
63
+ ```bash
64
+ roundhouse cron runs <id>
65
+ roundhouse cron runs <id> --limit 20
66
+ ```
67
+
68
+ ### Edit a job
69
+
70
+ ```bash
71
+ roundhouse cron edit <id> --prompt "new prompt"
72
+ roundhouse cron edit <id> --every "12h"
73
+ roundhouse cron edit <id> --cron "0 */4 * * *" --tz "UTC"
74
+ ```
75
+
76
+ ### Pause / Resume
77
+
78
+ ```bash
79
+ roundhouse cron pause <id>
80
+ roundhouse cron resume <id>
81
+ ```
82
+
83
+ ### Delete a job
84
+
85
+ ```bash
86
+ roundhouse cron delete <id>
87
+ ```
88
+
89
+ ## Rules
90
+
91
+ 1. **Always use `roundhouse cron` CLI** — do not edit cron files directly
92
+ 2. **Job IDs** must be lowercase alphanumeric with hyphens (e.g., `daily-report`, `check-ssl`)
93
+ 3. **Prompts** are sent to the agent as-is — write them as clear instructions
94
+ 4. **Template variables** use `{{var}}` syntax in prompts (e.g., `--prompt "Check {{url}}" --var "url=https://example.com"`)
95
+ 5. **One-shot jobs** (`--at`) run once and stay in history — delete them after if not needed
96
+ 6. **Built-in jobs** (prefixed `_`) cannot be edited or deleted
97
+
98
+ ## Examples
99
+
100
+ ### Daily morning briefing at 8am
101
+ ```bash
102
+ roundhouse cron add morning-brief \
103
+ --prompt "Give me a summary of overnight alerts, pending PRs, and today's calendar" \
104
+ --cron "0 8 * * *" \
105
+ --tz "America/New_York" \
106
+ --telegram "123456" \
107
+ --description "Morning briefing"
108
+ ```
109
+
110
+ ### Check SSL expiry every 12 hours
111
+ ```bash
112
+ roundhouse cron add check-ssl \
113
+ --prompt "Check SSL certificate expiry for {{domain}} and alert if < 14 days" \
114
+ --every "12h" \
115
+ --var "domain=loki.run" \
116
+ --telegram "123456" \
117
+ --notify-on "failure"
118
+ ```
119
+
120
+ ### Run a one-shot reminder in 30 minutes
121
+ ```bash
122
+ roundhouse cron add remind-standup \
123
+ --prompt "Reminder: standup starts now!" \
124
+ --at "30m" \
125
+ --telegram "123456"
126
+ ```
127
+
128
+ ### Weekly dependency audit
129
+ ```bash
130
+ roundhouse cron add weekly-deps \
131
+ --prompt "Run npm audit on all repos in ~/repos/ and report any high/critical vulnerabilities" \
132
+ --cron "0 9 * * 1" \
133
+ --tz "UTC" \
134
+ --timeout "10m" \
135
+ --description "Monday dependency audit"
136
+ ```
@@ -180,10 +180,7 @@ class KiroAdapter extends BaseAdapter {
180
180
  };
181
181
  }
182
182
 
183
- prepareMessage(_threadId: string, message: AgentMessage, context: MessageContext): AgentMessage {
184
- if (context.platform === "telegram" && message.text) {
185
- return { ...message, text: message.text + "\n\n[Format your final answer for Telegram: concise, use markdown sparingly, avoid long code blocks.]" };
186
- }
183
+ prepareMessage(_threadId: string, message: AgentMessage, _context: MessageContext): AgentMessage {
187
184
  return message;
188
185
  }
189
186
 
@@ -576,10 +576,7 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
576
576
  };
577
577
  },
578
578
 
579
- prepareMessage(_threadId: string, message: AgentMessage, context: MessageContext): AgentMessage {
580
- if (context.platform === "telegram" && message.text) {
581
- return { ...message, text: message.text + "\n\n[Format your final answer for Telegram: concise, use markdown sparingly, avoid long code blocks.]" };
582
- }
579
+ prepareMessage(_threadId: string, message: AgentMessage, _context: MessageContext): AgentMessage {
583
580
  return message;
584
581
  },
585
582
  };
package/src/cli/cli.ts CHANGED
@@ -9,7 +9,7 @@ import { readFile } from "node:fs/promises";
9
9
  import { readdirSync, statSync } from "node:fs";
10
10
  import { execSync, execFileSync, spawn } from "node:child_process";
11
11
  import { fileURLToPath } from "node:url";
12
- import { performUpdate } from "../commands/update";
12
+ import { performUpdate } from "./update";
13
13
 
14
14
  import {
15
15
  CONFIG_PATH,
@@ -18,11 +18,10 @@ export function parseSetupArgs(argv: string[]): SetupOptions {
18
18
  systemd: platform() === "linux",
19
19
  voice: platform() === "linux", // Default off on macOS (whisper install is heavy)
20
20
  psst: false,
21
- nonInteractive: false,
22
21
  force: false,
23
22
  dryRun: false,
24
23
  telegram: false,
25
- headless: false,
24
+ nonInteractive: false,
26
25
  qr: "auto",
27
26
  agent: "pi",
28
27
  };
@@ -46,9 +45,9 @@ export function parseSetupArgs(argv: string[]): SetupOptions {
46
45
  case "--no-voice": opts.voice = false; break;
47
46
  case "--with-psst": opts.psst = true; break;
48
47
  case "--non-interactive": opts.nonInteractive = true; break;
48
+ case "--headless": opts.nonInteractive = true; break; // alias
49
49
  case "--telegram": opts.telegram = true; break;
50
- case "--headless": opts.headless = true; opts.nonInteractive = true; break;
51
- case "--agent": opts.agent = next().toLowerCase(); break;
50
+ case "--agent": opts.agent = next().toLowerCase(); opts._agentExplicit = true; break;
52
51
  case "--qr": opts.qr = "always"; break;
53
52
  case "--no-qr": opts.qr = "never"; break;
54
53
  case "--force": opts.force = true; break;
@@ -64,11 +63,11 @@ export function parseSetupArgs(argv: string[]): SetupOptions {
64
63
  opts.botToken = process.env.TELEGRAM_BOT_TOKEN ?? "";
65
64
  }
66
65
 
67
- // Headless: reject --bot-token (argv visible in process listings)
68
- if (opts.headless && argv.some((a) => a === "--bot-token")) {
66
+ // Non-interactive: warn about --bot-token (argv visible in process listings)
67
+ if (opts.nonInteractive && argv.some((a) => a === "--bot-token")) {
69
68
  throw new Error(
70
- "--bot-token is not accepted in --headless mode (argv visible in process listings).\n" +
71
- "Use: TELEGRAM_BOT_TOKEN=... roundhouse setup --telegram --headless --user USERNAME",
69
+ "--bot-token is not accepted in --non-interactive mode (argv visible in process listings).\n" +
70
+ "Use: TELEGRAM_BOT_TOKEN=... roundhouse setup --telegram --non-interactive --user USERNAME",
72
71
  );
73
72
  }
74
73
 
@@ -80,7 +79,7 @@ export function parseSetupArgs(argv: string[]): SetupOptions {
80
79
  }
81
80
 
82
81
  // Interactive --telegram defers token/user prompting to the wizard
83
- const isInteractiveTelegram = opts.telegram && !opts.headless && !opts.nonInteractive && process.stdin.isTTY;
82
+ const isInteractiveTelegram = opts.telegram && !opts.nonInteractive && process.stdin.isTTY;
84
83
 
85
84
  // Validate
86
85
  if (!opts.botToken && !opts.dryRun && !isInteractiveTelegram) {
@@ -1,8 +1,8 @@
1
1
  import { platform } from "node:os";
2
2
  import { execFileSync } from "node:child_process";
3
3
  import { type SetupOptions } from "./types";
4
- import { promptText, promptMasked } from "../setup-prompts";
5
- import { createJsonLogger, type SetupDiagnostics, printDiagnosticError } from "../setup-logger";
4
+ import { promptText, promptMasked, promptChoice } from "./prompts";
5
+ import { createJsonLogger, type SetupDiagnostics, printDiagnosticError } from "./logger";
6
6
  import { printQr } from "../qr";
7
7
  import {
8
8
  createPairingNonce,
@@ -10,10 +10,11 @@ import {
10
10
  readPendingPairing,
11
11
  writePendingPairing,
12
12
  type PendingPairing,
13
- } from "../../pairing";
13
+ } from "../../transports/telegram/pairing";
14
14
  import { detectEnvironment, formatDetectionResults } from "../detect";
15
+ import { listAvailableAgentTypes } from "../../agents/registry";
15
16
  import { fileExists, ROUNDHOUSE_DIR, CONFIG_PATH, ENV_FILE_PATH as ENV_PATH } from "../../config";
16
- import { pairTelegram } from "../setup-telegram";
17
+ import { pairTelegram } from "./telegram";
17
18
  import {
18
19
  stepPreflight,
19
20
  stepValidateToken,
@@ -30,7 +31,7 @@ import { resolveAgentForSetup, textLog, textStepLog, createStepLog } from "./run
30
31
 
31
32
  export async function runInteractiveTelegramSetup(opts: SetupOptions): Promise<void> {
32
33
  const logger = textStepLog;
33
- const agent = resolveAgentForSetup(opts, logger);
34
+ let agent = resolveAgentForSetup(opts, logger);
34
35
  textLog("\n🔧 Roundhouse Telegram Setup");
35
36
  textLog("━━━━━━━━━━━━━━━━━━━━━━━━━━━");
36
37
 
@@ -44,14 +45,37 @@ export async function runInteractiveTelegramSetup(opts: SetupOptions): Promise<v
44
45
  for (const line of formatDetectionResults(env)) {
45
46
  logger.ok(line);
46
47
  }
47
- if (!opts.force) {
48
- const selected = env.agents.find(a => a.type === opts.agent);
49
- if (selected?.configured) {
50
- opts._skipAgentInstall = true;
51
- }
48
+ }
49
+
50
+ // Agent chooser: only prompt if kiro is available and --agent not explicit
51
+ if (!opts._agentExplicit && !opts.nonInteractive) {
52
+ const availableTypes = listAvailableAgentTypes();
53
+ const kiroDetected = env.agents.some(a => a.type === "kiro" && availableTypes.includes(a.type));
54
+ if (kiroDetected && availableTypes.length > 1) {
55
+ const choices = availableTypes.map((t, i) => ({
56
+ label: t,
57
+ hint: env.agents.find(a => a.type === t)?.configured ? "configured" : i === 0 ? "default" : undefined,
58
+ }));
59
+ textLog("");
60
+ textLog(" Kiro CLI detected. Choose agent backend:");
61
+ const idx = await promptChoice(choices, { defaultIndex: 0 });
62
+ opts.agent = availableTypes[idx];
63
+ logger.ok(`Using ${opts.agent}`);
64
+ }
65
+ // else: no kiro detected, silently use default (pi)
66
+ }
67
+
68
+ // Determine if agent install can be skipped (based on final opts.agent)
69
+ if (!opts.force) {
70
+ const selected = env.agents.find(a => a.type === opts.agent);
71
+ if (selected?.configured) {
72
+ opts._skipAgentInstall = true;
52
73
  }
53
74
  }
54
75
 
76
+ // Re-resolve agent definition if chooser changed it
77
+ agent = resolveAgentForSetup(opts, logger);
78
+
55
79
  if (!opts.botToken) {
56
80
  textLog("");
57
81
  printBotFatherGuide();
@@ -127,18 +151,18 @@ export async function runInteractiveTelegramSetup(opts: SetupOptions): Promise<v
127
151
  }
128
152
  }
129
153
 
130
- export async function runHeadlessTelegramSetup(opts: SetupOptions): Promise<void> {
154
+ export async function runNonInteractiveTelegramSetup(opts: SetupOptions): Promise<void> {
131
155
  const logger = createJsonLogger();
132
156
  const stepLogger = createStepLog(logger);
133
157
  const agent = resolveAgentForSetup(opts, stepLogger);
134
158
 
135
159
  try {
136
160
  if (!opts.botToken) {
137
- logger.error("validation.failed", "TELEGRAM_BOT_TOKEN env var required for --headless");
161
+ logger.error("validation.failed", "TELEGRAM_BOT_TOKEN env var required for --non-interactive");
138
162
  process.exit(2);
139
163
  }
140
164
  if (opts.users.length === 0) {
141
- logger.error("validation.failed", "--user is required for --headless");
165
+ logger.error("validation.failed", "--user is required for --non-interactive");
142
166
  process.exit(2);
143
167
  }
144
168
 
@@ -146,6 +170,15 @@ export async function runHeadlessTelegramSetup(opts: SetupOptions): Promise<void
146
170
  await stepPreflight(stepLogger, opts, agent);
147
171
  logger.ok("Preflight passed");
148
172
 
173
+ // Detect existing agent to skip redundant install
174
+ const env = detectEnvironment();
175
+ if (!opts.force) {
176
+ const selected = env.agents.find(a => a.type === opts.agent);
177
+ if (selected?.configured) {
178
+ opts._skipAgentInstall = true;
179
+ }
180
+ }
181
+
149
182
  logger.step(2, 9, "telegram.validate", "Validating Telegram bot token");
150
183
  const botInfo = await stepValidateToken(stepLogger, opts);
151
184
  logger.ok(`Bot: @${botInfo.username} (id: ${botInfo.id})`);
@@ -225,7 +258,7 @@ export async function runHeadlessTelegramSetup(opts: SetupOptions): Promise<void
225
258
  }
226
259
  }
227
260
 
228
- logger.info("setup.complete", "Headless setup complete", {
261
+ logger.info("setup.complete", "Non-interactive setup complete", {
229
262
  botUsername: botInfo.username,
230
263
  pairingLink,
231
264
  pairingStatus: "pending",
@@ -1,8 +1,8 @@
1
1
  /**
2
- * cli/setup-logger.ts — Structured logging for setup.
2
+ * cli/setup/logger.ts — Structured logging for setup.
3
3
  *
4
4
  * Interactive mode: human-friendly text with step numbers and emoji.
5
- * Headless mode: JSON lines for SSM/cloud-init/Docker log parsing.
5
+ * Non-interactive mode: JSON lines for SSM/cloud-init/Docker log parsing.
6
6
  */
7
7
 
8
8
  export interface SetupLogger {
@@ -113,8 +113,8 @@ export interface SetupDiagnostics {
113
113
  error: { name: string; message: string; stack?: string };
114
114
  }
115
115
 
116
- export function printDiagnosticError(diag: SetupDiagnostics, headless: boolean): void {
117
- if (headless) {
116
+ export function printDiagnosticError(diag: SetupDiagnostics, nonInteractive: boolean): void {
117
+ if (nonInteractive) {
118
118
  console.error(JSON.stringify({
119
119
  ts: ts(),
120
120
  level: "error",
@@ -1,8 +1,8 @@
1
1
  /**
2
- * cli/setup-prompts.ts — Interactive prompts using only Node built-in readline.
2
+ * cli/setup/prompts.ts — Interactive prompts using only Node built-in readline.
3
3
  * No external dependencies.
4
4
  */
5
- import { createInterface, Interface } from "node:readline";
5
+ import { createInterface } from "node:readline";
6
6
 
7
7
  /**
8
8
  * Prompt the user for text input with optional default.
@@ -76,3 +76,24 @@ export async function promptConfirm(
76
76
  if (!answer) return !!options?.defaultYes;
77
77
  return answer.toLowerCase().startsWith("y");
78
78
  }
79
+
80
+ /**
81
+ * Prompt user to pick from a numbered list of choices.
82
+ * Returns the index of the selected item (0-based).
83
+ */
84
+ export async function promptChoice(
85
+ items: { label: string; hint?: string }[],
86
+ options?: { defaultIndex?: number },
87
+ ): Promise<number> {
88
+ if (items.length === 0) throw new Error("promptChoice: items must not be empty");
89
+ const def = Math.min(Math.max(0, options?.defaultIndex ?? 0), items.length - 1);
90
+ for (let i = 0; i < items.length; i++) {
91
+ const marker = i === def ? "*" : " ";
92
+ const hint = items[i].hint ? ` (${items[i].hint})` : "";
93
+ console.log(` ${marker} ${i + 1}. ${items[i].label}${hint}`);
94
+ }
95
+ const answer = await promptText(` Select`, { defaultValue: String(def + 1) });
96
+ const num = parseInt(answer || String(def + 1), 10);
97
+ if (isNaN(num) || num < 1 || num > items.length) return def;
98
+ return num - 1;
99
+ }
@@ -7,7 +7,7 @@ import {
7
7
  type AgentDefinition,
8
8
  type AgentSetupContext,
9
9
  } from "../../agents/registry";
10
- import { type SetupLogger } from "../setup-logger";
10
+ import { type SetupLogger } from "./logger";
11
11
 
12
12
  export function resolveAgentForSetup(opts: SetupOptions, logger: StepLog): AgentDefinition {
13
13
  const agent = { ...getAgentDefinition(opts.agent) };
@@ -3,8 +3,8 @@ import { resolve } from "node:path";
3
3
  import { readFile, writeFile, mkdir, unlink, realpath, stat } from "node:fs/promises";
4
4
  import { execFileSync } from "node:child_process";
5
5
  import { randomBytes } from "node:crypto";
6
- import { BOT_COMMANDS } from "../../commands";
7
- import { provisionBundle, type ProvisionLog } from "../../bundle";
6
+ import { BOT_COMMANDS } from "../../transports/telegram/bot-commands";
7
+ import { provisionBundle, type ProvisionLog } from "../../provisioning/bundle";
8
8
  import {
9
9
  ROUNDHOUSE_DIR,
10
10
  CONFIG_PATH,
@@ -33,7 +33,7 @@ import {
33
33
  sendMessage,
34
34
  type BotInfo,
35
35
  type PairResult,
36
- } from "../setup-telegram";
36
+ } from "./telegram";
37
37
 
38
38
  export async function stepPreflight(logger: StepLog, opts: SetupOptions, agent: AgentDefinition): Promise<void> {
39
39
  logger.step("①", "Preflight checks...");
@@ -1,12 +1,12 @@
1
1
  /**
2
- * cli/setup-telegram.ts — Telegram API helpers for setup
2
+ * cli/setup/telegram.ts — Telegram API helpers for setup
3
3
  *
4
4
  * Zero-dependency Telegram Bot API client using global fetch.
5
5
  * Token is never logged — redacted in all error messages.
6
6
  */
7
7
 
8
- import { randomBytes } from "node:crypto";
9
- import { BOT_COMMANDS } from "../commands";
8
+ import { createPairingNonce } from "../../transports/telegram/pairing";
9
+ import { BOT_COMMANDS } from "../../transports/telegram/bot-commands";
10
10
 
11
11
  // ── Types ────────────────────────────────────────────
12
12
 
@@ -90,7 +90,7 @@ export async function pairTelegram(
90
90
  log: (msg: string) => void = console.log,
91
91
  opts?: { nonce?: string; showLink?: boolean },
92
92
  ): Promise<PairResult | null> {
93
- const nonce = opts?.nonce ?? `rh-${randomBytes(8).toString("hex")}`;
93
+ const nonce = opts?.nonce ?? createPairingNonce();
94
94
  const normalizedUsers = allowedUsers.map((u) => u.replace(/^@/, "").toLowerCase());
95
95
 
96
96
  // Clear stale updates — advance offset past existing
@@ -18,17 +18,18 @@ export interface SetupOptions {
18
18
  systemd: boolean;
19
19
  voice: boolean;
20
20
  psst: boolean;
21
- nonInteractive: boolean;
22
21
  force: boolean;
23
22
  dryRun: boolean;
24
23
  /** Telegram-focused setup flow */
25
24
  telegram: boolean;
26
- /** Fully headless automation (no TTY prompts) */
27
- headless: boolean;
25
+ /** Non-interactive mode (no TTY prompts) */
26
+ nonInteractive: boolean;
28
27
  /** QR code display mode */
29
28
  qr: "auto" | "always" | "never";
30
29
  /** Agent type (default: pi) */
31
30
  agent: string;
31
+ /** Whether --agent was explicitly passed on CLI */
32
+ _agentExplicit?: boolean;
32
33
  /** Set by detection: skip agent package install if already configured */
33
34
  _skipAgentInstall?: boolean;
34
35
  }