command-router 1.0.0b1__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.
Files changed (53) hide show
  1. command_router/__init__.py +654 -0
  2. command_router/__main__.py +17 -0
  3. command_router/_example/fixtures/README.md +72 -0
  4. command_router/_example/fixtures/__init__.py +86 -0
  5. command_router/_example/fixtures/err.json5 +28 -0
  6. command_router/_example/fixtures/grammars.json5 +15 -0
  7. command_router/_example/fixtures/grammars.py +164 -0
  8. command_router/_example/fixtures/grammars.toml +8 -0
  9. command_router/lib/commands/__init__.py +73 -0
  10. command_router/lib/commands/context.py +159 -0
  11. command_router/lib/commands/dispatcher/__init__.py +277 -0
  12. command_router/lib/commands/dispatcher/nodes/__init__.py +16 -0
  13. command_router/lib/commands/dispatcher/nodes/argument.py +55 -0
  14. command_router/lib/commands/dispatcher/nodes/command.py +94 -0
  15. command_router/lib/commands/dispatcher/nodes/kinds.py +17 -0
  16. command_router/lib/commands/dispatcher/nodes/literal.py +23 -0
  17. command_router/lib/commands/dispatcher/nodes/root.py +21 -0
  18. command_router/lib/commands/typing.py +123 -0
  19. command_router/lib/control/__init__.py +24 -0
  20. command_router/lib/control/api/__init__.py +103 -0
  21. command_router/lib/control/api/context.py +182 -0
  22. command_router/lib/control/api/control.py +577 -0
  23. command_router/lib/control/api/result.py +352 -0
  24. command_router/lib/control/compiler.py +356 -0
  25. command_router/lib/control/fixture_loader.py +137 -0
  26. command_router/lib/control/grammar.py +421 -0
  27. command_router/lib/grammar/loader.py +84 -0
  28. command_router/lib/grammar/parsers.py +58 -0
  29. command_router/py.typed +0 -0
  30. command_router/sdk/__init__.py +74 -0
  31. command_router/sdk/backend/__init__.py +24 -0
  32. command_router/sdk/backend/builder.py +253 -0
  33. command_router/sdk/backend/holder.py +73 -0
  34. command_router/sdk/backend/loader.py +55 -0
  35. command_router/sdk/backend/settings.py +300 -0
  36. command_router/sdk/backend/setup.py +149 -0
  37. command_router/sdk/user.py +106 -0
  38. command_router/suggestions/__init__.py +314 -0
  39. command_router/suggestions/algo.py +173 -0
  40. command_router/suggestions/context.py +98 -0
  41. command_router/suite/__init__.py +392 -0
  42. command_router/suite/css.py +98 -0
  43. command_router/utils/__init__.py +10 -0
  44. command_router/utils/cli/__init__.py +426 -0
  45. command_router/utils/cli/env_flags.py +284 -0
  46. command_router/utils/context.py +183 -0
  47. command_router/utils/lazy_server.py +60 -0
  48. command_router/utils/logger.py +383 -0
  49. command_router/utils/status.py +119 -0
  50. command_router-1.0.0b1.dist-info/METADATA +120 -0
  51. command_router-1.0.0b1.dist-info/RECORD +53 -0
  52. command_router-1.0.0b1.dist-info/WHEEL +4 -0
  53. command_router-1.0.0b1.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,654 @@
1
+ # The Clear BSD License
2
+ #
3
+ # Copyright (c) 2026 Ian Hylton
4
+ # All rights reserved.
5
+
6
+ __all__ = ("start", "init_flags")
7
+
8
+ import json
9
+ import shutil
10
+ import sys
11
+ import threading
12
+ import tomllib
13
+ from http.server import HTTPServer
14
+ from importlib import resources
15
+ from pathlib import Path
16
+ from typing import Any
17
+ from uuid import uuid4
18
+
19
+ import json5
20
+
21
+ from command_router.lib.control import ControlInitialization, ControlResult
22
+ from command_router.lib.control.api import *
23
+ from command_router.lib.grammar.loader import *
24
+ from command_router.sdk import Fixtures
25
+ from command_router.suggestions import *
26
+ from command_router.suite import *
27
+ from command_router.utils import (
28
+ LazyServer,
29
+ Status,
30
+ default_fixtures_dir,
31
+ describe_flags,
32
+ flag_names,
33
+ flags,
34
+ init_flags,
35
+ log,
36
+ normalize_bare_options,
37
+ paths,
38
+ stat,
39
+ uctx,
40
+ )
41
+
42
+ _Dict = dict[str, Any]
43
+
44
+
45
+ def _check_genesis_dir(path: Path) -> str | None:
46
+ """Return an error message when *path* cannot serve as a fixture directory."""
47
+ if not path.exists():
48
+ return f"genesis given, but directory {path} does not exist"
49
+ if not (path / "__init__.py").exists():
50
+ return "genesis given, but the existing directory does not contain its `__init__.py` file"
51
+ return None
52
+
53
+
54
+ class __Router:
55
+ grammars: _Dict = {}
56
+ info: _Dict = {}
57
+ control = Api.Control
58
+
59
+ def normalize(self, *t: _Dict) -> None:
60
+ grammars, info = t
61
+ self.grammars.update(grammars)
62
+ self.info.update(info)
63
+ log.debug(
64
+ "normalized grammar batch (commands=%d, info_keys=%s, totals=%d)",
65
+ len(grammars),
66
+ tuple(info),
67
+ len(self.grammars),
68
+ )
69
+
70
+ def lazy_init(self) -> None:
71
+ """Load the first valid grammar received through the local HTTP endpoint."""
72
+
73
+ log.info("starting lazy grammar server")
74
+ # Keep HTTP grammars in a persistent cache between application runs.
75
+ # The packaged templates live somewhere read-only once installed, so
76
+ # fall back to a working-directory cache when no repo checkout exists.
77
+ http_dir = paths.FIXTURES_HTTP if paths.FIXTURES.is_dir() else Path.cwd() / ".command-router" / "http"
78
+ # Stop serving requests after one grammar loads successfully.
79
+ state = {"success": False}
80
+ # Let the request handler update this router instance.
81
+ router = self
82
+
83
+ # noinspection bad-argument-type
84
+ # Define the small HTTP protocol used to receive a grammar.
85
+ class Lazy(LazyServer):
86
+ # noinspection pep8-naming
87
+ def do_POST(self) -> None:
88
+ self.post(self.__class__.__name__)
89
+
90
+ # Detect whether the body is a JSON5 or TOML object.
91
+ suffix: str | None = None
92
+ for candidate, parser in ((".json5", json5.loads), (".toml", tomllib.loads)):
93
+ try:
94
+ parsed = parser(self.text)
95
+ except ValueError, tomllib.TOMLDecodeError:
96
+ continue
97
+ if isinstance(parsed, dict):
98
+ suffix = candidate
99
+ break
100
+
101
+ if suffix is None:
102
+ self._invalid("HTTP body is not valid JSON5 or TOML.")
103
+ return
104
+ log.debug("lazy request recognized as %s", suffix)
105
+
106
+ # Reuse an identical grammar already saved by an earlier run.
107
+ existing: Path | None = None
108
+ try:
109
+ for candidate in http_dir.glob(f"grammar-*{suffix}"):
110
+ try:
111
+ if candidate.is_file() and candidate.read_bytes() == self.payload:
112
+ existing = candidate
113
+ break
114
+ except OSError:
115
+ continue
116
+ except OSError:
117
+ # Handle the request as new when the cache cannot be scanned.
118
+ log.warning("could not scan the persisted lazy grammar cache")
119
+ log.error("continuing with this request as a new grammar")
120
+
121
+ if existing is not None:
122
+ # Load the cached grammar into this router instance.
123
+ log.info("reusing persisted lazy grammar %s", existing.name)
124
+ result = load_grammars(existing)
125
+ if isinstance(result, int) or isinstance(result, Status):
126
+ self._invalid("HTTP grammar has an invalid schema.")
127
+ return
128
+
129
+ # noinspection not-iterable
130
+ router.normalize(*result)
131
+ log.stderr(0)
132
+ log.debug("reused grammar normalized (%d commands(s))", len(result[0]))
133
+ state["success"] = True
134
+ self._reply(204)
135
+ return
136
+
137
+ # Save unseen grammars under a unique filename.
138
+ target = http_dir / f"grammar-{uuid4().hex}{suffix}"
139
+ try:
140
+ http_dir.mkdir(parents=True, exist_ok=True)
141
+ target.write_bytes(self.payload)
142
+ result = load_grammars(target)
143
+ except OSError as exception:
144
+ log.error("could not persist lazy grammar %s: %s", target, exception)
145
+ self._invalid("Could not save the HTTP grammar.")
146
+ return
147
+
148
+ # Remove files that fail schema validation.
149
+ if isinstance(result, int) or isinstance(result, Status):
150
+ target.unlink(missing_ok=True)
151
+ self._invalid("HTTP grammar has an invalid schema.")
152
+ return
153
+
154
+ # Store the grammar and signal that initialization is complete.
155
+ # noinspection not-iterable
156
+ router.normalize(*result)
157
+ log.stderr(0)
158
+ log.info("saved and loaded lazy grammar %s", target.name)
159
+ log.debug("saved grammar normalized (%d commands(s))", len(result[0]))
160
+ state["success"] = True
161
+ self._reply(204)
162
+
163
+ # noinspection bad-argument-type
164
+ # Bind an ephemeral localhost port and announce it to the client.
165
+ addr = uctx.CMD_ROUTER_DEFAULT_ADDRESS
166
+ server = HTTPServer((addr, uctx.CMD_ROUTER_DEFAULT_PORT), Lazy)
167
+ log.info("lazy grammar server listening on %s", addr)
168
+ log.stderr(server.server_port)
169
+ try:
170
+ # Process requests until a valid grammar is accepted.
171
+ while not state["success"]:
172
+ server.handle_request()
173
+ finally:
174
+ # Always release the listening socket.
175
+ server.server_close()
176
+ log.info("lazy grammar server stopped")
177
+ log.raw("lazy grammar server: ", end="")
178
+ log.raw("OK" if state["success"] else "FAILURE")
179
+
180
+ def grammar_init(self, f: list[Path] | Path) -> tuple[_Dict, _Dict] | Status:
181
+ files = f if isinstance(f, list) else [f]
182
+ log.info("loading %d grammar file(s)", len(files))
183
+
184
+ # Keep bare filenames fast while also accepting full file or directory paths.
185
+ ignored_names: set[str] = set()
186
+ ignored_paths: set[Path] = set()
187
+ for value in flags.ignore:
188
+ candidate = Path(value).expanduser()
189
+ if not candidate.is_absolute() and len(candidate.parts) == 1 and not candidate.is_dir():
190
+ ignored_names.add(candidate.name)
191
+ continue
192
+ try:
193
+ ignored_paths.add(candidate.resolve())
194
+ except OSError, RuntimeError:
195
+ ignored_paths.add(candidate.absolute())
196
+
197
+ # Load grammars unless their name or path was explicitly ignored.
198
+ for file in files:
199
+ if file.name in ignored_names:
200
+ log.debug("ignoring grammar file by name: %s", file.name)
201
+ continue
202
+ if ignored_paths:
203
+ try:
204
+ file_path = file.resolve()
205
+ except OSError, RuntimeError:
206
+ file_path = file.absolute()
207
+ if any(file_path == ignored or ignored in file_path.parents for ignored in ignored_paths):
208
+ log.debug("ignoring grammar file by path: %s", file)
209
+ continue
210
+ log.debug("loading grammar file %s", file)
211
+ result = load_grammars(file)
212
+ if isinstance(result, Status):
213
+ if result.name == stat.UnsupportedGrammarFormatError().name:
214
+ log.debug("skipped unsupported grammar file %s", file)
215
+ continue
216
+ log.error("grammar file %s failed with code %s", file, result)
217
+ return result
218
+ # noinspection not-iterable
219
+ self.normalize(*result)
220
+
221
+ log.info("grammar loading completed (%d commands(s))", len(self.grammars))
222
+ return self.grammars, self.info
223
+
224
+ @property
225
+ def genesis_source(self) -> Path | None:
226
+ """Return the validated genesis directory, or None when unset or invalid.
227
+
228
+ A genesis directory must exist and contain a ``__init__.py`` file so
229
+ the fixture loader can treat it as a package. String values are
230
+ coerced for callers that bypass ``init_flags``. Failures are logged
231
+ as critical diagnostics; callers decide whether that aborts startup,
232
+ so grammar discovery and control initialization share one check.
233
+ """
234
+ if flags.genesis is None:
235
+ return None
236
+ genesis = flags.genesis if isinstance(flags.genesis, Path) else Path(flags.genesis)
237
+ if (message := _check_genesis_dir(genesis)) is not None:
238
+ log.critical("%s", message)
239
+ return None
240
+ return genesis
241
+
242
+ def control_init(self) -> bool:
243
+ """Load fixture behavior and compile the active command surface."""
244
+ log.info("initializing control")
245
+
246
+ r: ControlInitialization
247
+
248
+ if flags.genesis is None:
249
+ fixture = default_fixtures_dir()
250
+ else:
251
+ genesis = self.genesis_source
252
+ if genesis is None:
253
+ return False
254
+ log.info("genesis given, fixtures will be loaded from %s", genesis)
255
+ fixture = genesis
256
+ r = self.control.initialize(
257
+ self.grammars,
258
+ fixture=fixture,
259
+ keep_help=not flags.no_help,
260
+ )
261
+
262
+ if not r.ok:
263
+ log.error("control initialization failed (%s): %s", r.code, r.message)
264
+ return False
265
+ log.info("control ready (%d grammar(s))", r.command_count)
266
+ return True
267
+
268
+
269
+ _router = __Router()
270
+
271
+
272
+ class __CommandRouter:
273
+ _was_i_initialized = False
274
+ """Literal variable to track whether the router was initialized using ``__init__``."""
275
+
276
+ def __init__(self) -> None:
277
+ self._grammars = _router.grammars
278
+ self._info = _router.info
279
+ self.control = _router.control
280
+ self._was_i_initialized = True
281
+ log.info("router instance was initialized. use `initialize` to actually start the router")
282
+
283
+ @property
284
+ def initialize(self) -> Status:
285
+ """The main entry point for the router."""
286
+ if not self._was_i_initialized:
287
+ return stat.ImpossibleControlState()
288
+ log.debug(
289
+ "flags (lazy=%s, test_suite=%s, no_help=%s, ignore=%s, genesis=%s)",
290
+ flags.lazy,
291
+ flags.test_suite,
292
+ flags.no_help,
293
+ flags.ignore,
294
+ flags.genesis,
295
+ )
296
+
297
+ # Informational and scaffolding flags short-circuit before anything
298
+ # boots: verbose help is read-only, and init only writes files.
299
+ if flags.verbose_help is not None:
300
+ return self._verbose_help(flags.verbose_help)
301
+ if flags.init is not None:
302
+ return self._scaffold(flags.init)
303
+
304
+ # Get every single file in fixtures/*
305
+ if flags.lazy:
306
+ log.info("lazy grammar loading enabled")
307
+ _router.lazy_init()
308
+ ctrl_init = _router.control_init()
309
+ if ctrl_init and flags.test_suite:
310
+ c = self._test_suite_loop()
311
+ return c
312
+ log.info("initialization completed")
313
+
314
+ genesis = flags.genesis
315
+ if genesis is None:
316
+ grammar_source = default_fixtures_dir()
317
+ else:
318
+ grammar_source = Path(genesis)
319
+ try:
320
+ files = [path for path in grammar_source.iterdir() if path.is_file()]
321
+ except OSError as exception:
322
+ log.critical("cannot list grammar source %s: %s", grammar_source, exception)
323
+ return stat.Abort()
324
+ log.debug("discovered %d fixture file(s) in %s", len(files), grammar_source)
325
+ result = _router.grammar_init(files)
326
+ if isinstance(result, Status):
327
+ log.error("grammar initialization failed (%s); continuing with loaded data", result)
328
+
329
+ # The test-suite loop doesn't need to be initialized for embedded use.
330
+ ctrl_init = _router.control_init()
331
+
332
+ log.debug("normalized grammars=%r; info=%r", self._grammars, self._info)
333
+
334
+ suggestions_server: HTTPServer | None = None
335
+ if flags.no_suggestions_server:
336
+ log.info("suggestions server completely disabled; not binding")
337
+ elif flags.serve:
338
+ log.info("serve mode owns the stderr transport; suggestions server not binding")
339
+ else:
340
+ # Bind even when `suggestions_server` is off, so disabled use stays
341
+ # visible instead of a dropped connection: the handler logs at
342
+ # error level, ignores POST bodies (204), and answers GET with an
343
+ # empty JSON list (200), while returning SuggestionServerDisabled.
344
+ try:
345
+ sgs_addr = lazy_suggest_srv_ctx.address
346
+ sgs_port = lazy_suggest_srv_ctx.port
347
+ suggestions_server = HTTPServer((sgs_addr, sgs_port), LazySuggestionsServer)
348
+ log.debug("initializing thread of lazy suggestions server")
349
+ _sgs_thread = threading.Thread(
350
+ target=suggestions_server.serve_forever,
351
+ kwargs={"poll_interval": 0.2},
352
+ name="lazy-suggestions-server",
353
+ daemon=True,
354
+ )
355
+ _sgs_thread.start()
356
+ log.info("lazy suggestions server listening on %s:%d", sgs_addr, suggestions_server.server_port)
357
+ except OSError as exception:
358
+ log.error("could not start lazy suggestions server: %s", exception)
359
+ suggestions_server = None
360
+
361
+ ultima: Status
362
+
363
+ # Technically, a lazy initialization is possible, but it's not worth the complexity.
364
+ if ctrl_init and flags.test_suite:
365
+ log.warning("test-suite enabled, giving up control to our suite")
366
+ try:
367
+ ultima = self._test_suite_loop()
368
+ log.info("test-suite loop exited with status %s", ultima)
369
+ return ultima
370
+ finally:
371
+ if suggestions_server is not None:
372
+ suggestions_server.shutdown()
373
+ suggestions_server.server_close()
374
+
375
+ log.info("ready (%d commands grammar(s))", len(self._grammars))
376
+
377
+ if ctrl_init and flags.serve:
378
+ if not flags.json_out:
379
+ log.debug("serve mode implies JSON responses")
380
+ flags.json_out = True
381
+ log.info("serve mode enabled; entering stdin loop")
382
+ try:
383
+ ultima = self._serve_loop()
384
+ log.info("serve loop exited with status %s", ultima)
385
+ return ultima
386
+ finally:
387
+ if suggestions_server is not None:
388
+ suggestions_server.shutdown()
389
+ suggestions_server.server_close()
390
+
391
+ if not ctrl_init and flags.genesis is not None:
392
+ log.critical("router cannot start without an initialized control surface")
393
+ return stat.Abort()
394
+ return stat.Success()
395
+
396
+ @property
397
+ def main(self) -> Status:
398
+ """(Alias) The main entry point for the router."""
399
+ return self.initialize
400
+
401
+ def execute(self, command: Any) -> ControlResult:
402
+ """Execute through the configured control surface."""
403
+ return self.control.execute(command)
404
+
405
+ async def execute_async(self, command: Any) -> ControlResult:
406
+ """Async counterpart to :meth:`execute`."""
407
+ return await self.control.execute_async(command)
408
+
409
+ @property
410
+ def deeper_level(self) -> Any:
411
+ """Expose the live Python control state for embedded callers."""
412
+ return self.control.deeper_context
413
+
414
+ def _test_suite_loop(self) -> Status:
415
+ """Run the Textual REPL until it exits with a status.
416
+
417
+ The router owns this loop because the control API only knows how to
418
+ initialize and execute a command surface; it does not know whether
419
+ the surrounding application wants an interactive session. Command
420
+ failures are represented by ``ControlResult`` (unknown -> suggestions)
421
+ and therefore do not end the session. Failures in the loop itself are
422
+ converted to a status so a bad app crash cannot escape router startup.
423
+ """
424
+ try:
425
+ initialized = self.control.deeper_context.initialized
426
+ except Exception as exception:
427
+ log.critical("cannot inspect test state before starting the loop: %s", exception)
428
+ return stat.Abort()
429
+
430
+ if not initialized:
431
+ log.error("cannot start test loop; control is not initialized")
432
+ return stat.ControlNotInitializedError()
433
+
434
+ log.info("test loop has started")
435
+ app = REPL(handle=self._handle_command)
436
+ try:
437
+ ultima: Status | None = app.run() # blocks this thread, like input() did
438
+ except KeyboardInterrupt:
439
+ log.warning("test loop interrupted")
440
+ return stat.Interrupted()
441
+ except Exception as exception: # app-level crash
442
+ log.error("repl crashed: %r", exception)
443
+ return stat.Abort()
444
+ if ultima is None:
445
+ log.error("repl exited without a status")
446
+ return stat.ImpossibleControlState()
447
+ log.info("repl exited with status %s", ultima)
448
+ return ultima
449
+
450
+ def _serve_loop(self) -> Status:
451
+ """Feed dirty stdin lines to the control surface; answers flow to stderr.
452
+
453
+ There is no input schema: every line is fed whole to ``execute``,
454
+ the same path the test suite uses, so empty lines, plain text, and
455
+ broken commands are all accepted eagerly. The control layer prints
456
+ exactly one JSON response line per execution on ``stderr`` itself,
457
+ which keeps framing strictly one-to-one with no printing done here.
458
+ The loop only writes when that emission could not have happened: an
459
+ execution failure, or an action value that JSON cannot serialize
460
+ (re-serialized here as a watchdog, since the control layer swallows
461
+ that emission failure internally). EOF ends the loop with ``Success``.
462
+ """
463
+ log.info("serve loop started; reading commands from stdin")
464
+ try:
465
+ for line in sys.stdin:
466
+ text = line.rstrip("\n")
467
+ try:
468
+ result = self.execute(text)
469
+ except Exception as exception:
470
+ log.error("serve loop failed to execute %r: %s", text, exception)
471
+ self._serve_fallback(text, str(exception))
472
+ continue
473
+ try:
474
+ result.to_json()
475
+ except (TypeError, ValueError) as exception:
476
+ log.error("serve loop could not serialize the answer to %r: %s", text, exception)
477
+ self._serve_fallback(text, f"unserializable result: {exception}")
478
+ except KeyboardInterrupt:
479
+ log.warning("serve loop interrupted")
480
+ return stat.Interrupted()
481
+ log.info("serve loop reached EOF")
482
+ return stat.Success()
483
+
484
+ @staticmethod
485
+ def _serve_fallback(text: str, message: str) -> None:
486
+ """Print one JSON error line on ``stderr`` to preserve 1:1 framing.
487
+
488
+ Only used when the control layer could not emit its own response
489
+ line, so a pipe consumer waiting on the next line never hangs.
490
+ """
491
+ print(
492
+ json.dumps(
493
+ {
494
+ "ok": False,
495
+ "input": text,
496
+ "value": None,
497
+ "suggestions": [],
498
+ "error": {"message": message},
499
+ "message": message,
500
+ }
501
+ ),
502
+ file=sys.stderr,
503
+ flush=True,
504
+ )
505
+
506
+ def _verbose_help(self, name: str) -> Status:
507
+ """Print flag documentation; a full dump asks first on terminals.
508
+
509
+ Dumping every flag behind a bare `--verbose-help` prints the whole
510
+ environment, so interactive use confirms first. Piped input cannot
511
+ answer, so it dumps directly instead of hanging.
512
+ """
513
+ if name == "all" and sys.stdin.isatty():
514
+ try:
515
+ answer = input(f"Print documentation for {len(flag_names())} flags? [y/N] ")
516
+ except EOFError:
517
+ answer = ""
518
+ if answer.strip().casefold() not in ("y", "yes"):
519
+ print("cancelled.", flush=True)
520
+ return stat.Success()
521
+ try:
522
+ text = describe_flags(name)
523
+ except ValueError as exception:
524
+ print(exception, flush=True)
525
+ return stat.Abort()
526
+ print(text, flush=True)
527
+ return stat.Success()
528
+
529
+ def _scaffold(self, dest: Path | None) -> Status:
530
+ """Copy the bundled fixture templates to *dest* and explain `--genesis`.
531
+
532
+ A missing destination is treated as a name for a new directory under
533
+ the working directory (`None` means `./fixtures`); an existing
534
+ directory is used as-is, but a non-empty one is refused rather than
535
+ merged into. The copied tree is validated with the same rules the
536
+ fixture loader enforces, so the printed next step is guaranteed to
537
+ work.
538
+ """
539
+ target = Path("fixtures") if dest is None else Path(dest)
540
+ try:
541
+ if target.exists():
542
+ if not target.is_dir():
543
+ log.critical("init destination %s exists and is not a directory", target)
544
+ return stat.Abort()
545
+ if any(target.iterdir()):
546
+ log.critical("init destination %s is not empty; refusing to overwrite", target)
547
+ return stat.Abort()
548
+ else:
549
+ target.mkdir(parents=True)
550
+ except OSError as exception:
551
+ log.critical("cannot prepare init destination %s: %s", target, exception)
552
+ return stat.Abort()
553
+ try:
554
+ with resources.as_file(resources.files("command_router") / "_example" / "fixtures") as source:
555
+ shutil.copytree(source, target, ignore=shutil.ignore_patterns("__pycache__"), dirs_exist_ok=True)
556
+ except OSError as exception:
557
+ log.critical("cannot copy fixture templates to %s: %s", target, exception)
558
+ return stat.Abort()
559
+ if (message := _check_genesis_dir(target)) is not None:
560
+ log.critical("%s", message)
561
+ return stat.Abort()
562
+ print(f"Done. Run the program with 'cmd-router --genesis {target}'.", flush=True)
563
+ return stat.Success()
564
+
565
+ def _handle_command(self, command: str) -> Outcome:
566
+ """Execute one REPL line: exit-status, suggestions, or keep-going.
567
+
568
+ Returns ``Status`` to exit the REPL, ``list[str]`` of suggestions for
569
+ an unknown command, or ``None`` for a known command (keep listening).
570
+ Mirrors the old ``input()`` loop body minus the blocking read.
571
+ """
572
+ if not isinstance(command, str):
573
+ log.error("test loop received non-string input (%s)", type(command).__name__)
574
+ return stat.TokenizeUnsupportedTypeError()
575
+
576
+ command_marker = command.strip().casefold()
577
+ quitters: tuple[str, ...] = ("q", "quit", "e", "exit", "!q", "!quit")
578
+ quitters += tuple(f"{Fixtures.cmd_prefix}{name}" for name in ("q", "quit", "e", "exit"))
579
+ if command_marker in quitters:
580
+ log.info("test loop requested to stop")
581
+ return stat.Success()
582
+ if not command_marker:
583
+ log.warning("test loop received empty input! is it a typo on an error?")
584
+ return None
585
+
586
+ try:
587
+ result = self.execute(command)
588
+ except KeyboardInterrupt:
589
+ log.warning("test loop interrupted during commands execution")
590
+ return stat.Interrupted()
591
+ except Exception as exception:
592
+ log.critical("test loop failed while executing a commands: %s", exception)
593
+ return stat.Abort()
594
+
595
+ if not isinstance(result, ControlResult):
596
+ log.critical("test execution returned an invalid result")
597
+ return stat.Abort()
598
+
599
+ was_not_initialized = stat.ControlNotInitializedError()
600
+ if result.code.code == was_not_initialized.code:
601
+ log.critical("test became uninitialized while the loop was running")
602
+ return was_not_initialized
603
+
604
+ if result.ok:
605
+ if result.kind != "input" and result.value is not None:
606
+ log.info("test result: %r", result.value)
607
+ if result.command == "help" and isinstance(result.value, dict):
608
+ log.info(
609
+ "commands: '%s'\n prefix: '%s'\n target: '%s'\n suggestions: '%s'",
610
+ result.value.get("commands", None),
611
+ result.value.get("prefix", None),
612
+ result.value.get("target", None),
613
+ result.value.get("suggestions", None),
614
+ )
615
+ return None
616
+
617
+ final_result = result.suggestions
618
+ return [] if final_result is None else final_result
619
+
620
+
621
+ def start(entry: tuple[str, ...] | None = None) -> Status:
622
+ """Start the command router and return its process exit status name.
623
+
624
+ When *entry* is given, it is parsed as CLI arguments first, so importers
625
+ can initialize the flag environment without touching ``sys.argv``::
626
+
627
+ start(("--serve", "--quiet"))
628
+
629
+ An absent *entry* parses ``sys.argv`` instead. Bare `--init` and
630
+ `--verbose-help` occurrences are expanded to explicit values before
631
+ parsing, since stock click cannot express an option that is valid
632
+ both bare and valued. Invalid entries raise ``SystemExit`` exactly
633
+ like the command line does.
634
+ """
635
+
636
+ if entry is not None:
637
+ init_flags(normalize_bare_options(list(entry)), standalone_mode=False)
638
+ else:
639
+ init_flags(normalize_bare_options(sys.argv[1:]), standalone_mode=False)
640
+
641
+ log.info("starting command router")
642
+ ultima: Status
643
+ try:
644
+ ultima = __CommandRouter().main
645
+ except KeyboardInterrupt:
646
+ log.warning("interrupted; shutting down")
647
+ return stat.Interrupted()
648
+ except Exception as exception:
649
+ log.debug("unrecoverable startup exception was raised")
650
+ log.critical(str(exception))
651
+ return stat.Abort()
652
+ log.info("command router exited with code %s (%s)", ultima.code, ultima.name)
653
+ log.info("contract message (if any): %s", ultima.message)
654
+ return ultima
@@ -0,0 +1,17 @@
1
+ # The Clear BSD License
2
+ #
3
+ # Copyright (c) 2026 Ian Hylton
4
+ # All rights reserved.
5
+
6
+ """Console entry point for the installed ``command-router`` script."""
7
+
8
+ from command_router import start
9
+
10
+
11
+ def console_main() -> int:
12
+ """Parse ``sys.argv`` and run; returns the process exit code."""
13
+ return start().code
14
+
15
+
16
+ if __name__ == "__main__":
17
+ raise SystemExit(console_main())