@inceptionstack/roundhouse 0.5.1 → 0.5.3

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/cli.ts CHANGED
@@ -14,7 +14,6 @@ import { performUpdate } from "../commands/update";
14
14
  import {
15
15
  CONFIG_PATH,
16
16
  SESSIONS_DIR,
17
- SERVICE_NAME,
18
17
  fileExists,
19
18
  loadConfig,
20
19
  resolveEnvFilePath,
@@ -22,14 +21,7 @@ import {
22
21
  import { getAgentSdkPackage } from "../agents/registry";
23
22
  import { threadIdToDir } from "../util";
24
23
  import { parseEnvFile, unquoteEnvValue } from "./env-file";
25
- import {
26
- SERVICE_PATH,
27
- systemctl,
28
- runSudo,
29
- isServiceInstalled,
30
- isServiceActive,
31
- systemctlShow,
32
- } from "./systemd";
24
+ import { getServiceManager } from "./service-manager";
33
25
 
34
26
  const __filename = fileURLToPath(import.meta.url);
35
27
  const __dirname = dirname(__filename);
@@ -41,7 +33,7 @@ const __dirname = dirname(__filename);
41
33
  * Only call with trusted/hardcoded strings. Any dynamic segments must be
42
34
  * validated (e.g. `/^\d+$/.test(pid)`) before interpolation.
43
35
  */
44
- function run(cmd: string, opts?: { silent?: boolean }): string {
36
+ function shellExec(cmd: string, opts?: { silent?: boolean }): string {
45
37
  try {
46
38
  const out = execSync(cmd, { encoding: "utf8", stdio: opts?.silent ? "pipe" : "inherit" });
47
39
  return (out ?? "").trim();
@@ -54,31 +46,32 @@ function run(cmd: string, opts?: { silent?: boolean }): string {
54
46
  // ── Commands ────────────────────────────────────────
55
47
 
56
48
  async function cmdStart() {
57
- if (isServiceInstalled()) {
58
- if (isServiceActive()) {
59
- console.log("Roundhouse is already running.");
60
- console.log(" Use: roundhouse restart to restart");
61
- console.log(" roundhouse status to check status");
62
- console.log(" roundhouse logs to tail logs");
63
- return;
49
+ const svc = getServiceManager();
50
+ const result = await svc.start();
51
+
52
+ if (result.message === "no-service") {
53
+ // No service installed — fall back to foreground
54
+ if (!(await fileExists(CONFIG_PATH))) {
55
+ console.error("No config found. Run 'roundhouse setup --telegram' first.");
56
+ process.exit(1);
64
57
  }
65
- systemctl("start", "Daemon started.");
58
+ console.log("No service found. Running in foreground (use Ctrl+C to stop)...");
59
+ if (process.platform !== "darwin") {
60
+ console.log(" Tip: run 'roundhouse setup --telegram' to install as systemd daemon.\n");
61
+ } else {
62
+ console.log("");
63
+ }
64
+ await cmdRun();
66
65
  return;
67
66
  }
68
67
 
69
- // No systemd service — fall back to foreground. Check config before launching.
70
- if (!(await fileExists(CONFIG_PATH))) {
71
- console.error("No config found. Run 'roundhouse setup --telegram' first.");
72
- process.exit(1);
73
- }
74
-
75
- console.log("No systemd service found. Running in foreground (use Ctrl+C to stop)...");
76
- if (process.platform !== "darwin") {
77
- console.log(" Tip: run 'roundhouse setup --telegram' to install as systemd daemon.\n");
78
- } else {
79
- console.log("");
68
+ console.log(result.message);
69
+ if (result.started && process.platform === "darwin") {
70
+ console.log(" Logs: ~/.roundhouse/logs/roundhouse.log");
71
+ } else if (!result.started) {
72
+ console.log(" Logs: roundhouse logs");
73
+ console.log(" Stop: roundhouse stop");
80
74
  }
81
- await cmdRun();
82
75
  }
83
76
 
84
77
  async function cmdRun() {
@@ -105,7 +98,7 @@ async function cmdRun() {
105
98
  env: {
106
99
  ...process.env,
107
100
  ROUNDHOUSE_CONFIG: CONFIG_PATH,
108
- NODE_NO_WARNINGS: "1", // Suppress npm deprecation spam
101
+ NODE_NO_WARNINGS: "1",
109
102
  },
110
103
  });
111
104
  }
@@ -146,14 +139,11 @@ async function cmdInstall() {
146
139
  console.log(" This sets up config, installs packages, pairs Telegram,");
147
140
  console.log(" and installs the systemd service — all in one command.\n");
148
141
  }
149
- async function cmdUninstall() {
150
142
 
151
- console.log("[roundhouse] Removing systemd daemon...");
152
- try { systemctl("stop"); } catch {}
153
- try { systemctl("disable"); } catch {}
154
- try { runSudo("rm", "-f", SERVICE_PATH); } catch {}
155
- runSudo("systemctl", "daemon-reload");
156
- console.log(" ✅ Daemon removed. Config preserved at:", CONFIG_PATH);
143
+ async function cmdUninstall() {
144
+ const svc = getServiceManager();
145
+ const result = await svc.uninstall();
146
+ console.log(` ✅ ${result.message} Config preserved at:`, CONFIG_PATH);
157
147
  }
158
148
 
159
149
  async function cmdUpdate() {
@@ -167,12 +157,16 @@ async function cmdUpdate() {
167
157
 
168
158
  console.log(`[roundhouse] Updated to v${result.latestVersion}`);
169
159
 
170
- if (process.platform === "darwin" || !isServiceInstalled()) {
160
+ const svc = getServiceManager();
161
+ const status = await svc.status();
162
+
163
+ if (!status.installed) {
171
164
  console.log("\n ✅ Update complete. Restart with: roundhouse start");
172
165
  } else {
173
- console.log("\n[roundhouse] Restarting daemon...");
166
+ console.log("\n[roundhouse] Restarting service...");
174
167
  try {
175
- systemctl("restart", "Updated and restarted.");
168
+ const restartResult = await svc.restart();
169
+ console.log(` ✅ ${restartResult.message}`);
176
170
  } catch {
177
171
  console.log(" ⚠️ Could not restart. Run: roundhouse start");
178
172
  }
@@ -180,24 +174,34 @@ async function cmdUpdate() {
180
174
  }
181
175
 
182
176
  async function cmdStatus() {
183
- if (!isServiceActive()) {
184
- console.log("\n Roundhouse is not running.\n");
177
+ const svc = getServiceManager();
178
+ const svcStatus = await svc.status();
179
+
180
+ if (!svcStatus.running) {
181
+ const icon = svcStatus.installed ? "⚠️" : "❌";
182
+ console.log(`\n ${icon} ${svcStatus.message}\n`);
185
183
  console.log(" Start with: roundhouse start\n");
186
184
  return;
187
185
  }
188
186
 
189
- // Load config for details
187
+ // macOS: simple status
188
+ if (process.platform === "darwin") {
189
+ console.log("\n ✅ Roundhouse is running (LaunchAgent).\n");
190
+ console.log(" Logs: ~/.roundhouse/logs/roundhouse.log");
191
+ console.log(" Stop: roundhouse stop\n");
192
+ return;
193
+ }
194
+
195
+ // Linux: detailed systemd status
196
+ const { systemctlShow } = await import("./systemd");
197
+
190
198
  let config: Awaited<ReturnType<typeof loadConfig>> | null = null;
191
- try {
192
- config = await loadConfig();
193
- } catch {}
199
+ try { config = await loadConfig(); } catch {}
194
200
 
195
- // Gather systemd info
196
201
  const pid = systemctlShow("MainPID");
197
202
  const activeState = systemctlShow("ActiveState");
198
203
  const startedAt = systemctlShow("ActiveEnterTimestamp");
199
204
 
200
- // Compute uptime
201
205
  let uptimeStr = "unknown";
202
206
  if (startedAt) {
203
207
  const startMs = new Date(startedAt).getTime();
@@ -208,17 +212,15 @@ async function cmdStatus() {
208
212
  }
209
213
  }
210
214
 
211
- // Memory from PID
212
215
  let memStr = "unknown";
213
216
  if (pid && pid !== "0" && /^\d+$/.test(pid)) {
214
- const rssKb = run(`ps -o rss= -p ${pid}`, { silent: true }).trim();
217
+ const rssKb = shellExec(`ps -o rss= -p ${pid}`, { silent: true }).trim();
215
218
  if (rssKb) {
216
219
  const parsed = parseInt(rssKb, 10);
217
220
  if (!isNaN(parsed)) memStr = `${(parsed / 1024).toFixed(1)} MB`;
218
221
  }
219
222
  }
220
223
 
221
- // Read env file for debug flags
222
224
  let debugStream = false;
223
225
  const statusEnvPath = await resolveEnvFilePath();
224
226
  try {
@@ -226,7 +228,6 @@ async function cmdStatus() {
226
228
  debugStream = envContent.includes("ROUNDHOUSE_DEBUG_STREAM=1") || envContent.includes('ROUNDHOUSE_DEBUG_STREAM="1"');
227
229
  } catch {}
228
230
 
229
- // Read versions
230
231
  let roundhouseVersion = "unknown";
231
232
  let agentVersion = "unknown";
232
233
  try {
@@ -235,7 +236,6 @@ async function cmdStatus() {
235
236
  roundhouseVersion = pkg.version;
236
237
  } catch {}
237
238
 
238
- // Resolve agent SDK version from registry
239
239
  const agentPkg = config ? getAgentSdkPackage(config.agent.type) : undefined;
240
240
  if (agentPkg) {
241
241
  try {
@@ -268,15 +268,22 @@ async function cmdStatus() {
268
268
  console.log();
269
269
  }
270
270
 
271
- function cmdLogs() {
272
- const child = spawn("journalctl", ["-u", SERVICE_NAME, "-f", "--no-pager", "-n", "100"], {
273
- stdio: "inherit",
274
- });
275
- child.on("error", () => console.log("Could not read logs. Is the daemon installed?"));
271
+ async function cmdStop() {
272
+ const svc = getServiceManager();
273
+ const result = await svc.stop();
274
+ console.log(result.message);
275
+ }
276
+
277
+ async function cmdRestart() {
278
+ const svc = getServiceManager();
279
+ const result = await svc.restart();
280
+ console.log(result.message);
276
281
  }
277
282
 
278
- function cmdStop() { systemctl("stop", "Daemon stopped."); }
279
- function cmdRestart() { systemctl("restart", "Daemon restarted."); }
283
+ async function cmdLogs() {
284
+ const svc = getServiceManager();
285
+ svc.logs();
286
+ }
280
287
 
281
288
  async function cmdConfig() {
282
289
  console.log(`Config path: ${CONFIG_PATH}\n`);
@@ -371,162 +378,9 @@ Environment:
371
378
 
372
379
  // ── Main ────────────────────────────────────────────
373
380
 
374
- async function cmdAgent() {
375
- // Usage: roundhouse agent <message>
376
- // roundhouse agent --thread <id> <message>
377
- // roundhouse agent --ephemeral <message>
378
- // echo "message" | roundhouse agent --stdin
379
- const args = process.argv.slice(3);
380
- let threadId = "";
381
- let messageText = "";
382
- let useStdin = false;
383
- let timeoutMs = 120_000;
384
- let verbose = false;
385
- let ephemeral = false;
386
-
387
- for (let i = 0; i < args.length; i++) {
388
- if (args[i] === "--thread" && args[i + 1]) {
389
- threadId = args[++i];
390
- } else if (args[i] === "--stdin") {
391
- useStdin = true;
392
- } else if (args[i] === "--timeout" && args[i + 1]) {
393
- const val = parseInt(args[++i], 10);
394
- if (isNaN(val) || val <= 0) { console.error("--timeout must be a positive number (seconds)"); process.exit(1); }
395
- timeoutMs = val * 1000;
396
- } else if (args[i] === "--no-timeout") {
397
- timeoutMs = 0;
398
- } else if (args[i] === "--verbose") {
399
- verbose = true;
400
- } else if (args[i] === "--ephemeral") {
401
- ephemeral = true;
402
- } else if (args[i].startsWith("-")) {
403
- console.error(`Unknown flag: ${args[i]}`);
404
- process.exit(1);
405
- } else {
406
- messageText = args.slice(i).join(" ");
407
- break;
408
- }
409
- }
410
-
411
- if (useStdin) {
412
- const chunks: Buffer[] = [];
413
- let totalBytes = 0;
414
- const MAX_INPUT = 1024 * 1024; // 1 MB
415
- for await (const chunk of process.stdin) {
416
- totalBytes += chunk.length;
417
- if (totalBytes > MAX_INPUT) {
418
- console.error(`Input exceeds ${MAX_INPUT / 1024}KB limit. Use a file instead.`);
419
- process.exit(1);
420
- }
421
- chunks.push(chunk);
422
- }
423
- // Strip single trailing newline (shell echo adds one)
424
- let raw = Buffer.concat(chunks).toString("utf8");
425
- if (raw.endsWith("\n")) raw = raw.slice(0, -1);
426
- messageText = raw;
427
- }
428
-
429
- if (!messageText) {
430
- console.error("Usage: roundhouse agent <message>");
431
- console.error(" roundhouse agent --thread <id> <message>");
432
- console.error(" echo \"message\" | roundhouse agent --stdin");
433
- console.error(" roundhouse agent --timeout 60 <message>");
434
- console.error(" roundhouse agent --verbose <message>");
435
- console.error(" roundhouse agent --ephemeral <message>");
436
- process.exit(1);
437
- }
438
-
439
- if (threadId && ephemeral) {
440
- console.error("--thread and --ephemeral cannot be used together");
441
- process.exit(1);
442
- }
443
-
444
- // Default: shared main session. --ephemeral restores one-off CLI behavior.
445
- if (!threadId) {
446
- threadId = ephemeral
447
- ? `cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
448
- : "main";
449
- }
450
-
451
- // Suppress debug/info logs unless --verbose
452
- const origLog = console.log;
453
- const origWarn = console.warn;
454
- const origError = console.error;
455
- if (!verbose) {
456
- console.log = () => {};
457
- console.warn = () => {};
458
- }
459
-
460
- let agent: import("../types").AgentAdapter | undefined;
461
- let aborted = false;
462
-
463
- // Clean abort on SIGINT/SIGTERM
464
- const handleSignal = async () => {
465
- if (aborted) return;
466
- aborted = true;
467
- console.log = origLog;
468
- console.warn = origWarn;
469
- console.error = origError;
470
- try { await agent?.abort?.(threadId); } catch {}
471
- try { await agent?.dispose(); } catch {}
472
- process.exit(130);
473
- };
474
- process.on("SIGINT", handleSignal);
475
- process.on("SIGTERM", handleSignal);
476
-
477
- // Timeout race
478
- let timer: ReturnType<typeof setTimeout> | undefined;
479
- const timeoutPromise = timeoutMs > 0
480
- ? new Promise<never>((_, reject) => {
481
- timer = setTimeout(async () => {
482
- aborted = true;
483
- try { await agent?.abort?.(threadId); } catch {}
484
- reject(new Error(`Timeout after ${timeoutMs / 1000}s`));
485
- }, timeoutMs);
486
- })
487
- : null;
488
-
489
- try {
490
- const config = await loadConfig();
491
- const { getAgentFactory } = await import("../agents/registry");
492
- const factory = getAgentFactory(config.agent.type);
493
- agent = factory(config.agent);
494
-
495
- const runAgent = async () => {
496
- if (agent!.promptStream) {
497
- for await (const event of agent!.promptStream(threadId, { text: messageText })) {
498
- if (event.type === "text_delta") {
499
- process.stdout.write(event.text);
500
- }
501
- }
502
- process.stdout.write("\n");
503
- } else {
504
- const response = await agent!.prompt(threadId, { text: messageText });
505
- origLog(response.text);
506
- }
507
- };
508
-
509
- if (timeoutPromise) {
510
- await Promise.race([runAgent(), timeoutPromise]);
511
- } else {
512
- await runAgent();
513
- }
514
- } catch (err: any) {
515
- console.error = origError;
516
- console.error(`Error: ${err.message}`);
517
- process.exit(aborted ? 124 : 1); // 124 = timeout (like coreutils)
518
- } finally {
519
- if (timer) clearTimeout(timer);
520
- process.off("SIGINT", handleSignal);
521
- process.off("SIGTERM", handleSignal);
522
- console.log = origLog;
523
- console.warn = origWarn;
524
- console.error = origError;
525
- if (!aborted) await agent?.dispose();
526
- }
527
- }
528
381
 
529
382
  import { cmdDoctor } from "./doctor";
383
+ import { cmdAgent } from "./agent-command";
530
384
  import { cmdCron } from "./cron";
531
385
  import { cmdSetup, cmdPair } from "./setup";
532
386
 
@@ -0,0 +1,258 @@
1
+ /**
2
+ * cli/cron-commands.ts — Individual cron subcommand handlers
3
+ *
4
+ * Each handler receives store, positional args, and flags.
5
+ * Extracted from the monolithic cmdCron switch statement.
6
+ */
7
+
8
+ import { CronStore, validateJobId } from "../cron/store";
9
+ import { isBuiltinJob } from "../cron/helpers";
10
+ import { CronRunner } from "../cron/runner";
11
+ import { validateSchedule } from "../cron/schedule";
12
+ import { validateTemplate } from "../cron/template";
13
+ import { parseDuration } from "../cron/durations";
14
+ import type { CronJobConfig, CronSchedule } from "../cron/types";
15
+ import { DEFAULT_TIMEOUT_MS, DEFAULT_TIMEZONE, VALID_NOTIFY_ON, DEFAULT_RUNS_LIMIT } from "../cron/constants";
16
+ import { formatSchedule, formatRunCounts, formatJobSummary, formatJobDetail, formatRunLine, runStatusIcon, jobEnabledIcon } from "../cron/format";
17
+
18
+ function rejectBuiltin(id: string): void {
19
+ if (isBuiltinJob(id)) {
20
+ console.error(`Job ID "${id}" is reserved for built-in jobs.`);
21
+ process.exit(1);
22
+ }
23
+ }
24
+
25
+ function validateNotifyOn(value?: string): "always" | "success" | "failure" {
26
+ const v = value ?? "always";
27
+ if (!(VALID_NOTIFY_ON as readonly string[]).includes(v)) {
28
+ console.error(`Invalid --notify-on: "${v}". Use ${VALID_NOTIFY_ON.join(", ")}.`);
29
+ process.exit(1);
30
+ }
31
+ return v as "always" | "success" | "failure";
32
+ }
33
+
34
+ export async function cronAdd(store: CronStore, positional: string[], flags: Record<string, string>): Promise<void> {
35
+ const id = positional[1];
36
+ if (!id) { console.error("Usage: roundhouse cron add <id> --prompt '...' --cron '...' --tz '...'"); process.exit(1); }
37
+ validateJobId(id);
38
+ rejectBuiltin(id);
39
+
40
+ const existing = await store.getJob(id);
41
+ if (existing && !flags.replace) {
42
+ console.error(`Job "${id}" already exists. Use --replace to overwrite.`);
43
+ process.exit(1);
44
+ }
45
+
46
+ const prompt = flags.prompt;
47
+ if (!prompt) { console.error("--prompt is required"); process.exit(1); }
48
+
49
+ const schedCount = [flags.cron, flags.every, flags.at].filter(Boolean).length;
50
+ if (schedCount > 1) { console.error("Specify only one of --cron, --every, or --at"); process.exit(1); }
51
+ let schedule: CronSchedule;
52
+ if (flags.cron) {
53
+ schedule = { type: "cron", cron: flags.cron, tz: flags.tz ?? DEFAULT_TIMEZONE };
54
+ } else if (flags.every) {
55
+ schedule = { type: "interval", every: flags.every };
56
+ } else if (flags.at) {
57
+ schedule = { type: "once", at: flags.at, tz: flags.tz };
58
+ } else {
59
+ console.error("Schedule required: --cron '...', --every '...', or --at '...'");
60
+ process.exit(1);
61
+ }
62
+
63
+ validateSchedule(schedule);
64
+
65
+ const vars: Record<string, string> = {};
66
+ if (flags.var) {
67
+ for (const v of flags.var.split(",")) {
68
+ const [k, ...rest] = v.split("=");
69
+ if (k && rest.length) vars[k.trim()] = rest.join("=").trim();
70
+ }
71
+ }
72
+
73
+ const templateErrors = validateTemplate(prompt, new Set(Object.keys(vars)));
74
+ if (templateErrors.length) {
75
+ console.error("Template errors:");
76
+ templateErrors.forEach((e) => console.error(` ${e}`));
77
+ process.exit(1);
78
+ }
79
+
80
+ const notify: CronJobConfig["notify"] = {};
81
+ if (flags.telegram) {
82
+ notify.telegram = {
83
+ chatIds: flags.telegram.split(",").map((s) => s.trim()),
84
+ onlyOn: validateNotifyOn(flags["notify-on"]),
85
+ };
86
+ }
87
+
88
+ const now = new Date().toISOString();
89
+ const job: CronJobConfig = {
90
+ id,
91
+ enabled: true,
92
+ description: flags.description,
93
+ createdAt: existing?.createdAt ?? now,
94
+ updatedAt: now,
95
+ schedule,
96
+ prompt,
97
+ vars: Object.keys(vars).length ? vars : undefined,
98
+ timeoutMs: flags.timeout ? parseDuration(flags.timeout) : undefined,
99
+ notify: Object.keys(notify).length ? notify : undefined,
100
+ };
101
+
102
+ await store.writeJob(job);
103
+ console.log(`✅ Cron job "${id}" ${existing ? "updated" : "created"}.`);
104
+ if (flags.json) console.log(JSON.stringify(job, null, 2));
105
+ }
106
+
107
+ export async function cronList(store: CronStore, _positional: string[], flags: Record<string, string>): Promise<void> {
108
+ const jobs = await store.listJobs();
109
+ if (jobs.length === 0) {
110
+ console.log("No cron jobs configured.");
111
+ return;
112
+ }
113
+ if (flags.json) {
114
+ console.log(JSON.stringify(jobs, null, 2));
115
+ } else {
116
+ for (const j of jobs) {
117
+ const state = await store.getState(j.id);
118
+ console.log(` ${formatJobSummary(j, state)}`);
119
+ }
120
+ }
121
+ }
122
+
123
+ export async function cronShow(store: CronStore, positional: string[], flags: Record<string, string>): Promise<void> {
124
+ const id = positional[1];
125
+ if (!id) { console.error("Usage: roundhouse cron show <id>"); process.exit(1); }
126
+ const job = await store.getJob(id);
127
+ if (!job) { console.error(`Job not found: ${id}`); process.exit(1); }
128
+ const state = await store.getState(id);
129
+ const runs = await store.listRuns(id, 5);
130
+ if (flags.json) {
131
+ console.log(JSON.stringify({ job, state, recentRuns: runs }, null, 2));
132
+ } else {
133
+ console.log(`\n${formatJobDetail(job, state, runs)}`);
134
+ }
135
+ }
136
+
137
+ export async function cronTrigger(store: CronStore, positional: string[], _flags: Record<string, string>): Promise<void> {
138
+ const id = positional[1];
139
+ if (!id) { console.error("Usage: roundhouse cron trigger <id>"); process.exit(1); }
140
+ rejectBuiltin(id);
141
+ const job = await store.getJob(id);
142
+ if (!job) { console.error(`Job not found: ${id}`); process.exit(1); }
143
+ console.log(`Triggering ${id}...`);
144
+ const runner = new CronRunner(store);
145
+ const record = await runner.runJob(job, new Date(), "manual");
146
+ console.log(`\nResult: ${record.status} (${record.durationMs}ms)`);
147
+ if (record.responseText) console.log(`\n${record.responseText.slice(0, 2000)}`);
148
+ if (record.error) console.log(`\nError: ${record.error}`);
149
+ process.exit(record.status === "completed" ? 0 : 1);
150
+ }
151
+
152
+ export async function cronRuns(store: CronStore, positional: string[], flags: Record<string, string>): Promise<void> {
153
+ const id = positional[1];
154
+ if (!id) { console.error("Usage: roundhouse cron runs <id>"); process.exit(1); }
155
+ const runs = await store.listRuns(id, parseInt(flags.limit ?? String(DEFAULT_RUNS_LIMIT), 10));
156
+ if (runs.length === 0) {
157
+ console.log(`No runs for ${id}.`);
158
+ } else if (flags.json) {
159
+ console.log(JSON.stringify(runs, null, 2));
160
+ } else {
161
+ for (const r of runs) {
162
+ console.log(` ${formatRunLine(r)} (${r.kind})`);
163
+ }
164
+ }
165
+ }
166
+
167
+ export async function cronPause(store: CronStore, positional: string[], _flags: Record<string, string>): Promise<void> {
168
+ const id = positional[1];
169
+ if (!id) { console.error("Usage: roundhouse cron pause <id>"); process.exit(1); }
170
+ rejectBuiltin(id);
171
+ const job = await store.getJob(id);
172
+ if (!job) { console.error(`Job not found: ${id}`); process.exit(1); }
173
+ job.enabled = false;
174
+ job.updatedAt = new Date().toISOString();
175
+ await store.writeJob(job);
176
+ console.log(`⏸️ Job "${id}" paused.`);
177
+ }
178
+
179
+ export async function cronResume(store: CronStore, positional: string[], _flags: Record<string, string>): Promise<void> {
180
+ const id = positional[1];
181
+ if (!id) { console.error("Usage: roundhouse cron resume <id>"); process.exit(1); }
182
+ rejectBuiltin(id);
183
+ const job = await store.getJob(id);
184
+ if (!job) { console.error(`Job not found: ${id}`); process.exit(1); }
185
+ job.enabled = true;
186
+ job.updatedAt = new Date().toISOString();
187
+ await store.writeJob(job);
188
+ console.log(`▶️ Job "${id}" resumed.`);
189
+ }
190
+
191
+ export async function cronEdit(store: CronStore, positional: string[], flags: Record<string, string>): Promise<void> {
192
+ const id = positional[1];
193
+ if (!id) { console.error("Usage: roundhouse cron edit <id> [--prompt '...'] [--cron '...'] ..."); process.exit(1); }
194
+ rejectBuiltin(id);
195
+ const job = await store.getJob(id);
196
+ if (!job) { console.error(`Job not found: ${id}`); process.exit(1); }
197
+
198
+ if (flags.prompt) job.prompt = flags.prompt;
199
+ if (flags.description) job.description = flags.description;
200
+ if (flags.timeout) job.timeoutMs = parseDuration(flags.timeout);
201
+ const editSchedCount = [flags.cron, flags.every, flags.at].filter(Boolean).length;
202
+ if (editSchedCount > 1) { console.error("Specify only one of --cron, --every, or --at"); process.exit(1); }
203
+ if (flags.cron) job.schedule = { type: "cron", cron: flags.cron, tz: flags.tz ?? (job.schedule.type === "cron" ? job.schedule.tz : DEFAULT_TIMEZONE) };
204
+ if (flags.every) job.schedule = { type: "interval", every: flags.every };
205
+ if (flags.at) job.schedule = { type: "once", at: flags.at, tz: flags.tz };
206
+ if (flags.telegram) {
207
+ job.notify = { ...job.notify, telegram: { chatIds: flags.telegram.split(",").map((s) => s.trim()), onlyOn: validateNotifyOn(flags["notify-on"]) } };
208
+ }
209
+
210
+ validateSchedule(job.schedule);
211
+ const editVars = new Set(Object.keys(job.vars ?? {}));
212
+ const editErrors = validateTemplate(job.prompt, editVars);
213
+ if (editErrors.length) {
214
+ console.error("Template errors:");
215
+ editErrors.forEach((e) => console.error(` ${e}`));
216
+ process.exit(1);
217
+ }
218
+ job.updatedAt = new Date().toISOString();
219
+ await store.writeJob(job);
220
+ console.log(`✅ Job "${id}" updated.`);
221
+ }
222
+
223
+ export async function cronDelete(store: CronStore, positional: string[], _flags: Record<string, string>): Promise<void> {
224
+ const id = positional[1];
225
+ if (!id) { console.error("Usage: roundhouse cron delete <id>"); process.exit(1); }
226
+ rejectBuiltin(id);
227
+ const job = await store.getJob(id);
228
+ if (!job) { console.error(`Job not found: ${id}`); process.exit(1); }
229
+ await store.deleteJob(id);
230
+ console.log(`🗑️ Job "${id}" deleted.`);
231
+ }
232
+
233
+ export function cronHelp(): void {
234
+ console.log(`roundhouse cron <command>
235
+
236
+ Commands:
237
+ add <id> [flags] Create a cron job
238
+ list List all jobs
239
+ show <id> Show job details
240
+ trigger <id> Run job now
241
+ runs <id> Show run history
242
+ edit <id> [flags] Edit a job
243
+ pause <id> Disable a job
244
+ resume <id> Enable a job
245
+ delete <id> Delete a job
246
+
247
+ Flags for add/edit:
248
+ --prompt "..." Prompt template (required for add)
249
+ --cron "..." Cron expression (e.g. "0 8 * * *")
250
+ --every "..." Interval (e.g. "6h")
251
+ --at "..." One-shot time (e.g. "30m" or ISO date)
252
+ --tz "..." Timezone (e.g. "Asia/Jerusalem")
253
+ --telegram "..." Telegram chat IDs (comma-separated)
254
+ --var "k=v,..." Template variables (comma-separated)
255
+ --timeout "..." Timeout (e.g. "30m")
256
+ --description "..." Job description
257
+ --json JSON output`);
258
+ }