forgeo-cli 0.3.0__py3-none-any.whl

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.
forgeo/cli.py ADDED
@@ -0,0 +1,759 @@
1
+ """Command-line interface.
2
+
3
+ Commands:
4
+
5
+ * ``forgeo`` / ``forgeo init`` — guided first-time setup: asks for the
6
+ forgeo folder, the coding agent command, and the refactor prompt, then
7
+ writes a ``forgeo.yaml``. Running ``forgeo`` or ``forgeo start`` without
8
+ a config triggers it automatically.
9
+ * ``forgeo start --config forgeo.yaml`` — run the scheduled forgeo on one
10
+ repository. Every ``interval_minutes`` it picks an ``OPEN`` task from the
11
+ backlog, or runs a refactoring pass when the backlog is empty; everything
12
+ is committed and pushed on the main branch. When the agent needs human
13
+ input, a detailed ``BLOCKER.md`` file is written with what you must do.
14
+ The daemon binds no ports; live state is written to ``daemon.state.json``
15
+ and is served to you by ``forgeo web``.
16
+ * ``forgeo once --config forgeo.yaml`` — run exactly one cycle and exit.
17
+ Shares the per-forgeo lock with the daemon, so it never overlaps a
18
+ running ``start``.
19
+ * ``forgeo status --config forgeo.yaml`` — print a read-only summary of the
20
+ forgeo (config, backlog, daemon lock, last log outcome) and exit. Never
21
+ starts an agent.
22
+ * ``forgeo stop --config forgeo.yaml`` — stop a running daemon gracefully
23
+ (SIGTERM; a cycle in progress finishes first).
24
+ * ``forgeo restart --config forgeo.yaml`` — stop the daemon when running,
25
+ then start it again detached in the background, re-reading the config.
26
+ * ``forgeo instance add NAME --config PATH`` — register an existing
27
+ ``forgeo.yaml`` under a stable instance name. Optional: ``start`` and
28
+ ``stop`` register Forgeo automatically under its config's ``name``
29
+ when it is not in the registry yet.
30
+ * ``forgeo instance rm NAME`` — unregister an instance (never touches its
31
+ config file or repository).
32
+ * ``forgeo instance list`` / ``forgeo list`` — a table of every registered
33
+ instance: config path, repository, daemon state, last outcome, and
34
+ backlog counts.
35
+ * ``forgeo web [--host HOST] [--port PORT]`` — serve the central
36
+ multi-instance dashboard in the foreground (default ``0.0.0.0:8790``),
37
+ aggregating every registered instance straight from its files.
38
+
39
+ ``start``, ``once``, ``status``, ``stop`` and ``restart`` each accept either
40
+ ``--config PATH`` (a config file) or ``--name NAME`` (an instance resolved
41
+ from the registry); the two options are mutually exclusive.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import argparse
47
+ import asyncio
48
+ import logging
49
+ import os
50
+ import signal
51
+ import subprocess
52
+ import sys
53
+ import time
54
+ from collections.abc import Callable
55
+ from logging.handlers import RotatingFileHandler
56
+ from pathlib import Path
57
+ from typing import Any
58
+
59
+ import yaml
60
+ from rich.console import Console
61
+ from rich.panel import Panel
62
+ from rich.prompt import Confirm
63
+ from rich.table import Table
64
+
65
+ from forgeo import __version__
66
+ from forgeo.agent import DockerSandboxAgent, SandboxUnavailableError, ShellAgent
67
+ from forgeo.backlog import JSONBacklog, backlog_status_counts, oldest_open_task
68
+ from forgeo.central import DEFAULT_HOST, DEFAULT_PORT
69
+ from forgeo.config import load_config
70
+ from forgeo.daemon import ForgeoDaemon, acquire_run_lock, is_lock_held, read_lock_pid
71
+ from forgeo.forgeo import Forgeo
72
+ from forgeo.git import GitManager
73
+ from forgeo.instances import (
74
+ InstanceInfo,
75
+ add_instance,
76
+ ensure_registered,
77
+ list_instances,
78
+ remove_instance,
79
+ resolve_instance,
80
+ )
81
+ from forgeo.models import ForgeoConfig, SandboxMode, Task
82
+ from forgeo.runs import RunRecorder, runs_path_for
83
+ from forgeo.setup import run_setup
84
+
85
+ DEFAULT_CONFIG = Path("forgeo.yaml")
86
+
87
+ STOP_TIMEOUT_SECONDS = 600.0
88
+ START_TIMEOUT_SECONDS = 15.0
89
+ _POLL_SECONDS = 0.5
90
+
91
+ console = Console()
92
+
93
+
94
+ def build_parser() -> argparse.ArgumentParser:
95
+ """Construct the CLI argument parser."""
96
+ parser = argparse.ArgumentParser(
97
+ prog="forgeo",
98
+ description="A scheduled software forgeo: executes backlog tasks on main, "
99
+ "refactors when idle, and writes BLOCKER.md when it needs human input.",
100
+ )
101
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
102
+ sub = parser.add_subparsers(dest="action")
103
+
104
+ init_parser = sub.add_parser(
105
+ "init", help="Guided first-time setup: interactively write a forgeo.yaml."
106
+ )
107
+ init_parser.add_argument(
108
+ "--config",
109
+ type=Path,
110
+ default=DEFAULT_CONFIG,
111
+ help="Where to write the config (default: forgeo.yaml).",
112
+ )
113
+ init_parser.add_argument(
114
+ "--force", action="store_true", help="Overwrite an existing config file."
115
+ )
116
+
117
+ start_parser = sub.add_parser("start", help="Start the scheduled forgeo for a repository.")
118
+ _add_config_or_name(start_parser)
119
+ start_parser.add_argument(
120
+ "--interval-minutes",
121
+ type=int,
122
+ default=None,
123
+ help="Override the schedule interval from the config file.",
124
+ )
125
+
126
+ once_parser = sub.add_parser("once", help="Run exactly one forgeo cycle and exit.")
127
+ _add_config_or_name(once_parser)
128
+
129
+ status_parser = sub.add_parser(
130
+ "status",
131
+ help="Print a read-only summary of Forgeo (never starts an agent).",
132
+ )
133
+ _add_config_or_name(status_parser)
134
+
135
+ stop_parser = sub.add_parser(
136
+ "stop",
137
+ help="Stop a running forgeo daemon gracefully (SIGTERM).",
138
+ )
139
+ _add_config_or_name(stop_parser)
140
+ stop_parser.add_argument(
141
+ "--timeout",
142
+ type=float,
143
+ default=STOP_TIMEOUT_SECONDS,
144
+ help="Seconds to wait for the daemon to exit (default: 600); a cycle "
145
+ "in progress always finishes first.",
146
+ )
147
+
148
+ restart_parser = sub.add_parser(
149
+ "restart",
150
+ help="Restart Forgeo daemon in the background, re-reading the config.",
151
+ )
152
+ _add_config_or_name(restart_parser)
153
+ restart_parser.add_argument(
154
+ "--timeout",
155
+ type=float,
156
+ default=STOP_TIMEOUT_SECONDS,
157
+ help="Seconds to wait for the old daemon to exit (default: 600); a "
158
+ "cycle in progress always finishes first.",
159
+ )
160
+
161
+ instance_parser = sub.add_parser(
162
+ "instance",
163
+ help="Register, list, and unregister named forgeo instances.",
164
+ )
165
+ instance_sub = instance_parser.add_subparsers(dest="instance_action")
166
+
167
+ instance_add_parser = instance_sub.add_parser(
168
+ "add", help="Register an existing forgeo.yaml under a stable name."
169
+ )
170
+ instance_add_parser.add_argument(
171
+ "name", help="Unique instance name (must match ^[a-zA-Z0-9._-]+$)."
172
+ )
173
+ instance_add_parser.add_argument(
174
+ "--config",
175
+ type=Path,
176
+ required=True,
177
+ help="Path to Forgeo.yaml to register.",
178
+ )
179
+
180
+ instance_rm_parser = instance_sub.add_parser("rm", help="Unregister an instance.")
181
+ instance_rm_parser.add_argument("name", help="Instance name to unregister.")
182
+
183
+ instance_sub.add_parser(
184
+ "list", help="List every registered instance and its state."
185
+ )
186
+
187
+ sub.add_parser(
188
+ "list",
189
+ help="List every registered instance (alias for `forgeo instance list`).",
190
+ )
191
+
192
+ web_parser = sub.add_parser(
193
+ "web",
194
+ help="Serve the central multi-instance dashboard in the foreground.",
195
+ )
196
+ web_parser.add_argument(
197
+ "--host",
198
+ default=DEFAULT_HOST,
199
+ help=f"Bind address (default: {DEFAULT_HOST}).",
200
+ )
201
+ web_parser.add_argument(
202
+ "--port",
203
+ type=int,
204
+ default=DEFAULT_PORT,
205
+ help=f"Bind port (default: {DEFAULT_PORT}).",
206
+ )
207
+ return parser
208
+
209
+
210
+ def _add_config_or_name(
211
+ parser: argparse.ArgumentParser, *, default_config: Path = DEFAULT_CONFIG
212
+ ) -> None:
213
+ """Add a mutually-exclusive ``--config``/``--name`` option pair.
214
+
215
+ ``--config`` keeps its default so plain ``forgeo start`` (etc.) keeps
216
+ resolving to ``forgeo.yaml``; argparse still rejects explicitly passing
217
+ both options together.
218
+ """
219
+ group = parser.add_mutually_exclusive_group()
220
+ group.add_argument(
221
+ "--config",
222
+ type=Path,
223
+ default=default_config,
224
+ help="Forgeo YAML file (default: forgeo.yaml).",
225
+ )
226
+ group.add_argument(
227
+ "--name",
228
+ default=None,
229
+ help="Registered instance name resolved from the registry "
230
+ "(see `forgeo instance`).",
231
+ )
232
+
233
+
234
+ def setup_logging(log_file: str | Path) -> None:
235
+ """Configure the ``forgeo`` logger with a rotating file handler."""
236
+ logger = logging.getLogger("forgeo")
237
+ for handler in list(logger.handlers):
238
+ logger.removeHandler(handler)
239
+ logger.setLevel(logging.INFO)
240
+ logger.propagate = False
241
+ formatter = logging.Formatter(
242
+ "%(asctime)s %(levelname)-8s %(name)s: %(message)s",
243
+ datefmt="%Y-%m-%d %H:%M:%S",
244
+ )
245
+ file_path = Path(log_file)
246
+ file_path.parent.mkdir(parents=True, exist_ok=True)
247
+ handler = RotatingFileHandler(
248
+ file_path, maxBytes=5 * 1024 * 1024, backupCount=3, encoding="utf-8"
249
+ )
250
+ handler.setFormatter(formatter)
251
+ logger.addHandler(handler)
252
+
253
+
254
+ def _offer_setup(config_path: Path) -> bool:
255
+ """Offer the guided setup; returns True when a config now exists."""
256
+ if not Confirm.ask("No config found. Run the guided first-time setup now?", default=True):
257
+ return False
258
+ return run_setup(base_dir=config_path.parent.resolve(), config_path=config_path) is not None
259
+
260
+
261
+ def _resolved_config_path(args: argparse.Namespace) -> Path | None:
262
+ """Resolve the config path: ``--name`` from the registry, else ``--config``.
263
+
264
+ Prints an error and returns ``None`` when the instance name is not
265
+ registered.
266
+ """
267
+ name = getattr(args, "name", None)
268
+ if name is None:
269
+ return Path(args.config)
270
+ config_path = resolve_instance(name)
271
+ if config_path is None:
272
+ console.print(
273
+ f"[red]Unknown instance: {name}. Register it with "
274
+ f"`forgeo instance add {name} --config PATH`.[/red]"
275
+ )
276
+ return None
277
+ return config_path
278
+
279
+
280
+ def _register_if_missing(
281
+ args: argparse.Namespace, config_path: Path, config: ForgeoConfig
282
+ ) -> None:
283
+ """Auto-register Forgeo under ``config.name`` when not registered.
284
+
285
+ Only ``--config`` invocations register: with ``--name`` the instance
286
+ must already exist (``_resolved_config_path`` errors otherwise). The
287
+ instance name is the config's ``name`` field, so ``start``/``stop``
288
+ always leave Forgeo visible in the registry.
289
+ """
290
+ if getattr(args, "name", None) is not None:
291
+ return
292
+ if ensure_registered(config.name, config_path):
293
+ console.print(
294
+ f"[green]Registered instance {config.name!r} -> "
295
+ f"{config_path.resolve()}.[/green]"
296
+ )
297
+
298
+
299
+ def _resolve_config(args: argparse.Namespace) -> ForgeoConfig | None:
300
+ """Load the config, offering the guided setup when missing.
301
+
302
+ Resolves ``--name`` through the instance registry. Applies the optional
303
+ ``interval_minutes`` override. Returns ``None`` when no config can be
304
+ produced.
305
+ """
306
+ config_path = _resolved_config_path(args)
307
+ if config_path is None:
308
+ return None
309
+ if not config_path.exists():
310
+ console.print(f"[yellow]Config file not found: {config_path}[/yellow]")
311
+ if not _offer_setup(config_path):
312
+ console.print(
313
+ "[yellow]Create one with `forgeo init`, or pass --config <file>.[/yellow]"
314
+ )
315
+ return None
316
+ config = load_config(config_path)
317
+ interval = getattr(args, "interval_minutes", None)
318
+ if interval is not None:
319
+ config = config.model_copy(update={"interval_minutes": interval})
320
+ return config
321
+
322
+
323
+ def _acquire_run_lock(config: ForgeoConfig) -> Any | None:
324
+ """Take the per-forgeo lock; prints an error and returns None when busy."""
325
+ lock = acquire_run_lock(config.backlog.with_suffix(".lock"))
326
+ if lock is None:
327
+ console.print(
328
+ f"[red]Another forgeo process (daemon or `once`) is already "
329
+ f"running for {config.name!r}.[/red]"
330
+ )
331
+ return lock
332
+
333
+
334
+ def _build_agent(config: ForgeoConfig) -> ShellAgent:
335
+ """Build the configured agent, sandboxed when ``agent_sandbox`` demands it."""
336
+ if config.agent_sandbox is SandboxMode.DOCKER:
337
+ return DockerSandboxAgent(
338
+ config.agent_command,
339
+ image=config.agent_sandbox_image or "",
340
+ network=config.agent_sandbox_network,
341
+ mounts=config.agent_sandbox_mounts,
342
+ timeout_seconds=config.agent_timeout_seconds,
343
+ env=config.agent_env,
344
+ blocked_exit_code=config.blocked_exit_code,
345
+ )
346
+ return ShellAgent(
347
+ config.agent_command,
348
+ timeout_seconds=config.agent_timeout_seconds,
349
+ env=config.agent_env,
350
+ blocked_exit_code=config.blocked_exit_code,
351
+ )
352
+
353
+
354
+ def _make_forgeo(config: ForgeoConfig) -> Forgeo:
355
+ """Build a :class:`Forgeo` wired to the config.
356
+
357
+ Raises:
358
+ SandboxUnavailableError: When the configured sandbox backend is
359
+ unavailable (e.g. no docker binary) — callers turn that into a
360
+ clear startup error.
361
+ """
362
+ backlog = JSONBacklog(config.backlog)
363
+ agent = _build_agent(config)
364
+ return Forgeo(
365
+ config,
366
+ backlog,
367
+ agent,
368
+ GitManager(config.repo, timeout_seconds=config.git_timeout_seconds),
369
+ )
370
+
371
+
372
+ def _prepare_worker(
373
+ args: argparse.Namespace,
374
+ ) -> tuple[Path, ForgeoConfig, Forgeo, Any] | None:
375
+ """Resolve the config, take the run lock, and build Forgeo.
376
+
377
+ Shared by ``start`` and ``once``. Returns ``None`` (after printing an
378
+ error) when any step fails; on success the caller owns the lock and must
379
+ close it.
380
+ """
381
+ config_path = _resolved_config_path(args)
382
+ if config_path is None:
383
+ return None
384
+ config = _resolve_config(args)
385
+ if config is None:
386
+ return None
387
+ setup_logging(config.log_file)
388
+ log = logging.getLogger("forgeo.cli")
389
+ log.info("Loading forgeo config from %s", config_path)
390
+ lock = _acquire_run_lock(config)
391
+ if lock is None:
392
+ return None
393
+ try:
394
+ forgeo = _make_forgeo(config)
395
+ except SandboxUnavailableError as exc:
396
+ lock.close()
397
+ console.print(f"[red]{exc}[/red]")
398
+ log.error("Sandbox unavailable: %s", exc)
399
+ return None
400
+ return config_path, config, forgeo, lock
401
+
402
+
403
+ def cmd_start(args: argparse.Namespace) -> int:
404
+ """Handle ``forgeo start``: the persistent scheduled worker."""
405
+ prepared = _prepare_worker(args)
406
+ if prepared is None:
407
+ return 1
408
+ config_path, config, forgeo, lock = prepared
409
+ _register_if_missing(args, config_path, config)
410
+
411
+ async def _serve() -> None:
412
+ daemon = ForgeoDaemon(config, forgeo)
413
+ loop = asyncio.get_running_loop()
414
+ for sig in (signal.SIGINT, signal.SIGTERM):
415
+ try:
416
+ loop.add_signal_handler(sig, daemon.stop)
417
+ except NotImplementedError:
418
+ pass
419
+ console.print(
420
+ Panel.fit(
421
+ f"[bold]Forgeo:[/bold] {config.name}\n"
422
+ f"[bold]Repo:[/bold] {config.repo}\n"
423
+ f"[bold]Interval:[/bold] {config.interval_minutes} min\n"
424
+ f"[bold]Backlog:[/bold] {config.backlog}\n"
425
+ f"[bold]Branch:[/bold] {config.branch}\n"
426
+ f"[bold]Log:[/bold] {config.log_file}",
427
+ title="Forgeo",
428
+ border_style="green",
429
+ )
430
+ )
431
+ await daemon.run_forever()
432
+
433
+ try:
434
+ asyncio.run(_serve())
435
+ except KeyboardInterrupt:
436
+ pass
437
+ finally:
438
+ lock.close()
439
+ return 0
440
+
441
+
442
+ def cmd_once(args: argparse.Namespace) -> int:
443
+ """Handle ``forgeo once``: run exactly one cycle and exit."""
444
+ prepared = _prepare_worker(args)
445
+ if prepared is None:
446
+ return 1
447
+ _config_path, _config, forgeo, lock = prepared
448
+
449
+ async def _run_once() -> None:
450
+ outcome = await forgeo.run_cycle()
451
+ console.print(f"[green]Cycle finished: {outcome}[/green]")
452
+
453
+ try:
454
+ asyncio.run(_run_once())
455
+ except KeyboardInterrupt:
456
+ pass
457
+ finally:
458
+ lock.close()
459
+ return 0
460
+
461
+
462
+ def cmd_init(args: argparse.Namespace) -> int:
463
+ """Handle ``forgeo init``: the guided first-time setup."""
464
+ if args.config.exists() and not args.force:
465
+ console.print(f"[red]{args.config} already exists. Pass --force to overwrite.[/red]")
466
+ return 2
467
+ if run_setup(base_dir=args.config.parent.resolve(), config_path=args.config) is None:
468
+ console.print("[yellow]Setup aborted; nothing was written.[/yellow]")
469
+ return 130
470
+ return 0
471
+
472
+
473
+ def last_outcome_from_runs(config: ForgeoConfig) -> str | None:
474
+ """Return the last run's outcome from ``runs.jsonl``, or ``None``.
475
+
476
+ Never raises on a missing or corrupt file; corrupt lines are skipped
477
+ with a warning.
478
+ """
479
+ last_run = RunRecorder(runs_path_for(config.backlog)).read_last()
480
+ if last_run is None:
481
+ return None
482
+ return last_run.outcome.value
483
+
484
+
485
+ def render_status(
486
+ config: ForgeoConfig,
487
+ tasks: list[Task],
488
+ *,
489
+ daemon_running: bool,
490
+ last_outcome: str | None,
491
+ ) -> str:
492
+ """Render the human-readable status summary as plain text."""
493
+ counts = backlog_status_counts(tasks)
494
+ count_text = " ".join(f"{status}={counts[status]}" for status in counts)
495
+ nxt = oldest_open_task(tasks)
496
+ next_text = f"{nxt.id} — {nxt.title}" if nxt is not None else "(none)"
497
+ daemon_text = "running" if daemon_running else "not running"
498
+ outcome_text = last_outcome if last_outcome is not None else "(none)"
499
+ return "\n".join(
500
+ [
501
+ f"name: {config.name}",
502
+ f"repo: {config.repo}",
503
+ f"interval: {config.interval_minutes} min",
504
+ f"branch: {config.branch}",
505
+ f"backlog: {count_text}",
506
+ f"next: {next_text}",
507
+ f"daemon: {daemon_text}",
508
+ f"last outcome: {outcome_text}",
509
+ ]
510
+ )
511
+
512
+
513
+ def _load_config_or_error(config_path: Path) -> ForgeoConfig | None:
514
+ """Load an existing config; prints an error and returns None when missing."""
515
+ if not config_path.exists():
516
+ console.print(f"[red]Config file not found: {config_path}[/red]")
517
+ return None
518
+ return load_config(config_path)
519
+
520
+
521
+ def cmd_status(args: argparse.Namespace) -> int:
522
+ """Handle ``forgeo status``: read-only summary; never starts an agent."""
523
+ config_path = _resolved_config_path(args)
524
+ if config_path is None:
525
+ return 1
526
+ config = _load_config_or_error(config_path)
527
+ if config is None:
528
+ return 1
529
+ tasks = asyncio.run(JSONBacklog(config.backlog).list_tasks())
530
+ daemon_running = is_lock_held(config.backlog.with_suffix(".lock"))
531
+ last_outcome = last_outcome_from_runs(config)
532
+ console.print(
533
+ render_status(
534
+ config,
535
+ tasks,
536
+ daemon_running=daemon_running,
537
+ last_outcome=last_outcome,
538
+ )
539
+ )
540
+ return 0
541
+
542
+
543
+ def _wait_for_lock_release(lock_path: Path, timeout: float) -> bool:
544
+ """Poll until the daemon lock is released; False on timeout."""
545
+ deadline = time.monotonic() + timeout
546
+ while time.monotonic() < deadline:
547
+ if not is_lock_held(lock_path):
548
+ return True
549
+ time.sleep(_POLL_SECONDS)
550
+ return not is_lock_held(lock_path)
551
+
552
+
553
+ def _stop_daemon(config: ForgeoConfig, timeout: float) -> bool:
554
+ """SIGTERM the running daemon and wait for it to exit; False on failure."""
555
+ lock_path = config.backlog.with_suffix(".lock")
556
+ pid = read_lock_pid(lock_path)
557
+ if pid is None:
558
+ console.print(
559
+ f"[red]The lock file {lock_path} records no PID; find the daemon "
560
+ f"with `pgrep -af forgeo` and stop it manually.[/red]"
561
+ )
562
+ return False
563
+ console.print(f"Stopping forgeo {config.name!r} (pid {pid})…")
564
+ try:
565
+ os.kill(pid, signal.SIGTERM)
566
+ except ProcessLookupError:
567
+ if not is_lock_held(lock_path):
568
+ console.print(f"[green]Forgeo {config.name!r} stopped.[/green]")
569
+ return True
570
+ console.print(
571
+ f"[red]Recorded pid {pid} is gone but the lock is still held; "
572
+ f"check with `pgrep -af forgeo`.[/red]"
573
+ )
574
+ return False
575
+ except PermissionError:
576
+ console.print(f"[red]No permission to stop process {pid}.[/red]")
577
+ return False
578
+ if _wait_for_lock_release(lock_path, timeout):
579
+ console.print(f"[green]Forgeo {config.name!r} stopped.[/green]")
580
+ return True
581
+ console.print(
582
+ f"[yellow]Forgeo is still shutting down after {timeout:.0f}s "
583
+ f"(a cycle in progress finishes first); giving up.[/yellow]"
584
+ )
585
+ return False
586
+
587
+
588
+ def cmd_stop(args: argparse.Namespace) -> int:
589
+ """Handle ``forgeo stop``: graceful daemon shutdown via SIGTERM."""
590
+ config_path = _resolved_config_path(args)
591
+ if config_path is None:
592
+ return 1
593
+ config = _load_config_or_error(config_path)
594
+ if config is None:
595
+ return 1
596
+ _register_if_missing(args, config_path, config)
597
+ if not is_lock_held(config.backlog.with_suffix(".lock")):
598
+ console.print(f"[yellow]Forgeo {config.name!r} is not running.[/yellow]")
599
+ return 1
600
+ return 0 if _stop_daemon(config, args.timeout) else 1
601
+
602
+
603
+ def cmd_restart(args: argparse.Namespace) -> int:
604
+ """Handle ``forgeo restart``: stop when running, then start detached."""
605
+ config_path = _resolved_config_path(args)
606
+ if config_path is None:
607
+ return 1
608
+ config = _load_config_or_error(config_path)
609
+ if config is None:
610
+ return 1
611
+ lock_path = config.backlog.with_suffix(".lock")
612
+ if is_lock_held(lock_path) and not _stop_daemon(config, args.timeout):
613
+ return 1
614
+ proc = subprocess.Popen(
615
+ [sys.executable, "-m", "forgeo", "start", "--config", str(config_path)],
616
+ stdin=subprocess.DEVNULL,
617
+ stdout=subprocess.DEVNULL,
618
+ stderr=subprocess.DEVNULL,
619
+ start_new_session=True,
620
+ )
621
+ deadline = time.monotonic() + START_TIMEOUT_SECONDS
622
+ while time.monotonic() < deadline:
623
+ if is_lock_held(lock_path):
624
+ console.print(
625
+ f"[green]Forgeo {config.name!r} restarted "
626
+ f"(pid {read_lock_pid(lock_path) or proc.pid}, "
627
+ f"interval {config.interval_minutes} min).[/green]"
628
+ )
629
+ return 0
630
+ if proc.poll() is not None:
631
+ break
632
+ time.sleep(_POLL_SECONDS)
633
+ console.print(f"[red]Forgeo daemon did not start; see {config.log_file} for details.[/red]")
634
+ return 1
635
+
636
+
637
+ def cmd_default() -> int:
638
+ """Bare ``forgeo``: show help when configured, run the wizard otherwise."""
639
+ if DEFAULT_CONFIG.exists():
640
+ build_parser().print_help()
641
+ return 0
642
+ console.print("[yellow]No forgeo.yaml found — starting the guided setup.[/yellow]")
643
+ return cmd_init(argparse.Namespace(config=DEFAULT_CONFIG, force=False))
644
+
645
+
646
+ def cmd_instance_add(args: argparse.Namespace) -> int:
647
+ """Handle ``forgeo instance add``: register an existing forgeo.yaml.
648
+
649
+ Normally unnecessary — ``forgeo start`` and ``forgeo stop`` register
650
+ the config under its ``name`` automatically — but handy to pre-register
651
+ an explicit name or one that differs from ``config.name``.
652
+ """
653
+ try:
654
+ add_instance(args.name, args.config)
655
+ except (ValueError, FileNotFoundError, yaml.YAMLError) as exc:
656
+ console.print(f"[red]{exc}[/red]")
657
+ return 1
658
+ console.print(
659
+ f"[green]Registered instance {args.name!r} -> {args.config.resolve()}.[/green]"
660
+ )
661
+ return 0
662
+
663
+
664
+ def cmd_instance_rm(args: argparse.Namespace) -> int:
665
+ """Handle ``forgeo instance rm``: unregister without touching the repo."""
666
+ if remove_instance(args.name):
667
+ console.print(f"[green]Unregistered instance {args.name!r}.[/green]")
668
+ return 0
669
+ console.print(f"[red]Unknown instance: {args.name}[/red]")
670
+ return 1
671
+
672
+
673
+ def _instance_row(info: InstanceInfo) -> tuple[str, ...]:
674
+ """Render one instance's table row (name, daemon state, last outcome)."""
675
+ if info.config is None:
676
+ return (
677
+ info.name,
678
+ "stopped",
679
+ "(none)",
680
+ )
681
+ last_outcome = last_outcome_from_runs(info.config) or "(none)"
682
+ return (
683
+ info.name,
684
+ "running" if info.daemon_running else "stopped",
685
+ last_outcome,
686
+ )
687
+
688
+
689
+ def cmd_instance_list(args: argparse.Namespace) -> int:
690
+ """Handle ``forgeo instance list`` / ``forgeo list``."""
691
+ infos = list_instances()
692
+ if not infos:
693
+ console.print("[yellow]No registered instances.[/yellow]")
694
+ console.print(
695
+ "[yellow]Register one with `forgeo instance add NAME --config PATH`.[/yellow]"
696
+ )
697
+ return 0
698
+ table = Table(title="Forgeo instances")
699
+ for column in ("Name", "Daemon", "Last outcome"):
700
+ table.add_column(column, overflow="fold")
701
+ for info in infos:
702
+ table.add_row(*_instance_row(info))
703
+ # Fits any terminal width: three short columns, no long paths.
704
+ console.print(table)
705
+ return 0
706
+
707
+
708
+ def cmd_instance(args: argparse.Namespace) -> int:
709
+ """Handle ``forgeo instance``: the registry subcommand group."""
710
+ action = args.instance_action
711
+ if action == "add":
712
+ return cmd_instance_add(args)
713
+ if action == "rm":
714
+ return cmd_instance_rm(args)
715
+ if action == "list":
716
+ return cmd_instance_list(args)
717
+ build_parser().print_help()
718
+ return 0
719
+
720
+
721
+ def cmd_web(args: argparse.Namespace) -> int:
722
+ """Handle ``forgeo web``: the central multi-instance dashboard.
723
+
724
+ Runs in the foreground like ``forgeo start``, serving an aggregate view
725
+ of every registered instance straight from each instance's files.
726
+ """
727
+ from forgeo.central import run_foreground
728
+
729
+ return run_foreground(host=args.host, port=args.port)
730
+
731
+
732
+ _COMMANDS: dict[str, Callable[[argparse.Namespace], int]] = {
733
+ "start": cmd_start,
734
+ "once": cmd_once,
735
+ "init": cmd_init,
736
+ "status": cmd_status,
737
+ "stop": cmd_stop,
738
+ "restart": cmd_restart,
739
+ "instance": cmd_instance,
740
+ "list": cmd_instance_list,
741
+ "web": cmd_web,
742
+ }
743
+
744
+
745
+ def main(argv: list[str] | None = None) -> int:
746
+ """CLI entry point used by both ``forgeo`` and ``python -m forgeo``."""
747
+ parser = build_parser()
748
+ args = parser.parse_args(argv)
749
+ if args.action is None:
750
+ return cmd_default()
751
+ command = _COMMANDS.get(args.action)
752
+ if command is None:
753
+ parser.error(f"unknown command: {args.action}")
754
+ return 2
755
+ return command(args)
756
+
757
+
758
+ if __name__ == "__main__":
759
+ sys.exit(main())