rigbox 0.1.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.
Files changed (64) hide show
  1. rigbox/__init__.py +1 -0
  2. rigbox/cli.py +142 -0
  3. rigbox/constants.py +89 -0
  4. rigbox/core/__init__.py +1 -0
  5. rigbox/core/cli_parser.py +145 -0
  6. rigbox/core/context.py +129 -0
  7. rigbox/core/history.py +44 -0
  8. rigbox/core/plugins.py +76 -0
  9. rigbox/core/registry.py +191 -0
  10. rigbox/core/render.py +253 -0
  11. rigbox/core/transport.py +78 -0
  12. rigbox/core/workflow.py +94 -0
  13. rigbox/core/workspace.py +96 -0
  14. rigbox/hooks.py +30 -0
  15. rigbox/plugins/__init__.py +1 -0
  16. rigbox/plugins/builtin.py +130 -0
  17. rigbox/plugins/code/__init__.py +1 -0
  18. rigbox/plugins/code/commands.py +84 -0
  19. rigbox/plugins/code/generator.py +154 -0
  20. rigbox/plugins/code/macros/c/__init__.py +0 -0
  21. rigbox/plugins/code/macros/c/c_autogen_banner.j2 +27 -0
  22. rigbox/plugins/code/macros/c/c_autogen_banner_editable.j2 +27 -0
  23. rigbox/plugins/code/macros/c/c_autogen_banner_state_machine.j2 +29 -0
  24. rigbox/plugins/code/macros/c/c_enum.j2 +10 -0
  25. rigbox/plugins/code/macros/c/c_machine_actions.j2 +7 -0
  26. rigbox/plugins/code/macros/c/c_machine_constants.j2 +5 -0
  27. rigbox/plugins/code/macros/c/c_machine_errors.j2 +10 -0
  28. rigbox/plugins/code/macros/c/c_machine_events.j2 +10 -0
  29. rigbox/plugins/code/macros/c/c_machine_state_table_entry.j2 +20 -0
  30. rigbox/plugins/code/macros/c/c_machine_state_transition_table.j2 +20 -0
  31. rigbox/plugins/code/macros/c/c_machine_states.j2 +9 -0
  32. rigbox/plugins/code/macros/c/c_machine_struct.j2 +6 -0
  33. rigbox/plugins/code/macros/c/c_machine_version.j2 +3 -0
  34. rigbox/plugins/code/macros/c/c_struct.j2 +8 -0
  35. rigbox/plugins/code/macros/c/c_union.j2 +8 -0
  36. rigbox/plugins/code/render.py +106 -0
  37. rigbox/plugins/code/template.py +64 -0
  38. rigbox/plugins/code/templates/c/state_machine.h.j2 +42 -0
  39. rigbox/plugins/device/__init__.py +1 -0
  40. rigbox/plugins/device/commands.py +236 -0
  41. rigbox/plugins/device/connection.py +160 -0
  42. rigbox/plugins/device/replay_transport.py +77 -0
  43. rigbox/plugins/device/serial_transport.py +84 -0
  44. rigbox/plugins/manifests/__init__.py +1 -0
  45. rigbox/plugins/manifests/api.py +96 -0
  46. rigbox/plugins/manifests/build.py +98 -0
  47. rigbox/plugins/manifests/check.py +79 -0
  48. rigbox/plugins/manifests/commands.py +98 -0
  49. rigbox/plugins/manifests/schema.py +130 -0
  50. rigbox/plugins/plugin_template.py +43 -0
  51. rigbox/plugins/statemachine/__init__.py +1 -0
  52. rigbox/plugins/statemachine/check.py +169 -0
  53. rigbox/plugins/statemachine/commands.py +357 -0
  54. rigbox/plugins/statemachine/coverage.py +156 -0
  55. rigbox/plugins/statemachine/engine.py +186 -0
  56. rigbox/plugins/statemachine/fuzz.py +181 -0
  57. rigbox/plugins/statemachine/schema.py +241 -0
  58. rigbox/repl.py +135 -0
  59. rigbox-0.1.0.dist-info/METADATA +99 -0
  60. rigbox-0.1.0.dist-info/RECORD +64 -0
  61. rigbox-0.1.0.dist-info/WHEEL +5 -0
  62. rigbox-0.1.0.dist-info/entry_points.txt +2 -0
  63. rigbox-0.1.0.dist-info/licenses/LICENSE +21 -0
  64. rigbox-0.1.0.dist-info/top_level.txt +1 -0
rigbox/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Main package for Rigbox."""
rigbox/cli.py ADDED
@@ -0,0 +1,142 @@
1
+ """Main CLI entrypoint for Rigbox."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from rigbox.constants import RenderFormat, RenderLevel
11
+ from rigbox.core.context import Context
12
+ from rigbox.core.plugins import load_all
13
+ from rigbox.core.registry import CommandError, Registry, UsageError
14
+ from rigbox.core.render import RenderError, RigboxCmdResult
15
+ from rigbox.core.workspace import Workspace
16
+
17
+ cli = typer.Typer()
18
+
19
+
20
+ def _build_context(workspace: str | None, fmt: RenderFormat) -> Context:
21
+ """Find the workspace (from `workspace` or the cwd), load plugins and return a ready Context."""
22
+ ws = Workspace.find(Path(workspace) if workspace else Path.cwd())
23
+ registry = Registry()
24
+ load_all(registry, ws)
25
+ return Context(registry=registry, workspace=ws, fmt=fmt)
26
+
27
+
28
+ @cli.callback(invoke_without_command=True)
29
+ def main(ctx: typer.Context) -> None:
30
+ """Main entrypoint for the Rigbox CLI. Overridden to allow for REPL launch when no arguments are provided.
31
+
32
+ Args:
33
+ ctx (typer.Context): The Typer context object for the CLI invocation.
34
+ """
35
+ # If no subcommand was invoked, we default to spawning the interactive REPL interface
36
+ if ctx.invoked_subcommand is None:
37
+ from rigbox.repl import run_repl
38
+
39
+ rigbox_ctx = _build_context(None, RenderFormat.default())
40
+ run_repl(rigbox_ctx)
41
+
42
+
43
+ @cli.command(help="Initialize a new Rigbox workspace.")
44
+ def init(
45
+ path: Path = typer.Argument(Path("."), help="The path to create the workspace in."),
46
+ name: str | None = typer.Option(None, "--name", help="The name of the workspace."),
47
+ ) -> None:
48
+ """Initialize a new Rigbox workspace.
49
+
50
+ Args:
51
+ path (Path, optional): The path to create the workspace in. Defaults to the current directory.
52
+ name (str | None, optional): The name of the workspace. Defaults to None.
53
+
54
+ Raises:
55
+ typer.Exit(code=1): Raised if the workspace already exists.
56
+ typer.Exit(code=0): Raised after successful initialization.
57
+ """
58
+ try:
59
+ ws = Workspace.init(path, name)
60
+ except FileExistsError as e:
61
+ typer.echo(f"Error: {e}")
62
+ raise typer.Exit(code=1) from e
63
+ typer.echo(f"Workspace initialized at {ws.root}")
64
+ raise typer.Exit(code=0)
65
+
66
+
67
+ @cli.command(context_settings={"ignore_unknown_options": True, "allow_interspersed_args": False}, help="Run one command non-interactively.")
68
+ def run(
69
+ tokens: Annotated[list[str], typer.Argument(help="<command> [args...]")],
70
+ workspace: Annotated[str | None, typer.Option(help="Start the workspace search here (default: cwd)")] = None,
71
+ fmt: Annotated[RenderFormat, typer.Option("--format", "-f", help=f"Output format (default: {RenderFormat.default()})")] = RenderFormat.default(),
72
+ ) -> None:
73
+ """Run one command non-interactively.
74
+
75
+ Args:
76
+ tokens (Annotated[list[str], typer.Argument): The command and its arguments to run.
77
+ workspace (str | None, optional): The workspace to start the search from. Defaults to cwd.
78
+ fmt: The output format, including plain text, tables, JSON, and NDJSON. Defaults to "auto".
79
+
80
+ Raises:
81
+ typer.Exit(code=2): Raised if no command is specified.
82
+ typer.Exit(code=2): Raised if there is a usage error.
83
+ typer.Exit(code=1): Raised if there is a command error.
84
+ typer.Exit(code=0): Raised after successful execution.
85
+ """
86
+ if not tokens:
87
+ typer.echo("Error: No command specified.")
88
+ raise typer.Exit(code=2)
89
+ ctx = _build_context(workspace, fmt)
90
+ try:
91
+ response = ctx.run_tokens(tokens)
92
+ out = response.render(fmt)
93
+ except UsageError as e:
94
+ raise typer.Exit(code=e.exit_code) from e
95
+ except (CommandError, RenderError) as e:
96
+ typer.echo(RigboxCmdResult(text=str(e), level=RenderLevel.ERROR).render(RenderFormat.PLAINTEXT), err=True)
97
+ raise typer.Exit(code=1) from e
98
+ finally:
99
+ ctx.close()
100
+ if out:
101
+ typer.echo(out)
102
+ raise typer.Exit(code=0)
103
+
104
+
105
+ @cli.command(help="Execute a script file within the Rigbox workspace.")
106
+ def script(
107
+ file: Annotated[Path, typer.Argument(help="The script file to execute.")],
108
+ workspace: Annotated[str | None, typer.Option(help="Start the workspace search here (default: cwd)")] = None,
109
+ fmt: Annotated[RenderFormat, typer.Option("--format", "-f", help=f"Output format (default: {RenderFormat.default()})")] = RenderFormat.default(),
110
+ var: Annotated[list[str] | None, typer.Option("--var", "-v", help="key=value override")] = None,
111
+ ) -> None:
112
+ """Execute a script file within the Rigbox workspace.
113
+
114
+ Args:
115
+ file (Annotated[Path, typer.Argument): The script file to execute.
116
+ workspace (str | None, optional): The workspace to start the search from. Defaults to cwd.
117
+ fmt: The output format, including plain text, rich renderables, JSON, and NDJSON. Defaults to "auto".
118
+ var (Annotated[list[str] | None, typer.Option, optional): Key=value overrides for the script. Defaults to None.
119
+
120
+ Raises:
121
+ typer.Exit(code=2): Raised if there is a usage error.
122
+ typer.Exit(code=1): Raised if there is a command error.
123
+ typer.Exit(code=0): Raised after successful execution.
124
+ """
125
+ ctx = _build_context(workspace, fmt)
126
+ try:
127
+ response = ctx.run("workflow.run", {"workflow": str(file), "var": var or []})
128
+ out = response.render(fmt)
129
+ except UsageError as e:
130
+ raise typer.Exit(code=e.exit_code) from e
131
+ except (CommandError, RenderError) as e:
132
+ typer.echo(RigboxCmdResult(text=str(e), level=RenderLevel.ERROR).render(RenderFormat.PLAINTEXT), err=True)
133
+ raise typer.Exit(code=1) from e
134
+ finally:
135
+ ctx.close()
136
+ if out:
137
+ typer.echo(out)
138
+ raise typer.Exit(code=0)
139
+
140
+
141
+ if __name__ == "__main__":
142
+ cli()
rigbox/constants.py ADDED
@@ -0,0 +1,89 @@
1
+ """Common constants for Rigbox."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum
6
+ from importlib.metadata import version
7
+ from typing import Final, Self
8
+
9
+
10
+ class AppMetadata:
11
+ """Namespace class for application metadata-related constants."""
12
+
13
+ NAME: Final[str] = "rigbox"
14
+ VERSION: Final[str] = version(NAME)
15
+
16
+
17
+ class AppFiles:
18
+ """Namespace class for file-related constants."""
19
+
20
+ SHELL_HISTORY: Final[str] = "shell_history.json"
21
+ WORKSPACE_CONFIG: Final[str] = "rigbox.yaml"
22
+
23
+
24
+ class RenderFormat(StrEnum):
25
+ """Enumeration of output rendering formats.
26
+
27
+ This dictates how the output should be formatted when rendered, including options for JSON, NDJSON, Rich text, plain text,
28
+ or automatic detection based on the terminal capabilities.
29
+ """
30
+
31
+ JSON = "json"
32
+ NDJSON = "ndjson"
33
+ RICH = "rich"
34
+ PLAINTEXT = "plaintext"
35
+ AUTO = "auto"
36
+
37
+ @classmethod
38
+ def default(cls) -> Self:
39
+ """Get the default rendering format."""
40
+ return cls.AUTO
41
+
42
+
43
+ class RenderLevel(StrEnum):
44
+ """Enumeration of output rendering severity levels.
45
+
46
+ This dictates how messages of different severity levels should be rendered, including their associated colors.
47
+ """
48
+
49
+ ERROR = "error"
50
+ WARNING = "warning"
51
+ INFO = "info"
52
+ SUCCESS = "success"
53
+
54
+ @classmethod
55
+ def default(cls) -> Self:
56
+ """Get the default rendering level."""
57
+ return cls.INFO
58
+
59
+ @property
60
+ def color(self) -> str:
61
+ """Get the associated color for this `RenderLevel`.
62
+
63
+ Raises:
64
+ KeyError: If the `RenderLevel` does not have an associated color mapping.
65
+
66
+ Returns:
67
+ str: The rich color associated with this `RenderLevel`.
68
+ """
69
+ return {
70
+ "error": "bold red",
71
+ "warning": "yellow",
72
+ "info": "default", # No color
73
+ "success": "green",
74
+ }[self]
75
+
76
+
77
+ class Plugins:
78
+ """Namespace class for plugin-related constants."""
79
+
80
+ PROJECT: Final[str] = AppMetadata.NAME
81
+ ENTRYPOINT_GROUP: Final[str] = f"{PROJECT}.plugins".lower()
82
+ BUILTIN_MODULES: tuple[str, ...] = (
83
+ f"{ENTRYPOINT_GROUP}.builtin",
84
+ f"{ENTRYPOINT_GROUP}.manifests.commands",
85
+ f"{ENTRYPOINT_GROUP}.device.commands",
86
+ f"{ENTRYPOINT_GROUP}.statemachine.commands",
87
+ f"{ENTRYPOINT_GROUP}.code.commands",
88
+ f"{ENTRYPOINT_GROUP}.dsp.commands",
89
+ )
@@ -0,0 +1 @@
1
+ """Framework core: registry, context, parsing, rendering, workspace, workflows and plugin loading."""
@@ -0,0 +1,145 @@
1
+ """Module for command-line parsing using pydantic models.
2
+
3
+ Rigbox defines all commands using pydantic models to represent their arguments.
4
+
5
+ This module provides functions to convert these pydantic models into argparse parsers,
6
+ so that commands can be run both from the command line and the interactive shell with consistent argument handling.
7
+
8
+ Required fields in the pydantic model become positional arguments, while optional fields become named options with `--`.
9
+ Boolean fields become bare flags, list fields can repeat, and Literal fields become validated choices.
10
+
11
+ Values stay as strings when parsed from the command line, and pydantic handles conversion and validation when the command runs.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import functools
18
+ import shlex
19
+ import typing
20
+ from types import UnionType
21
+ from typing import Any, NoReturn
22
+
23
+ import cmd2
24
+ from pydantic import BaseModel, Field
25
+ from pydantic.fields import FieldInfo
26
+
27
+ from rigbox.core.registry import Command, UsageError
28
+
29
+
30
+ def path_field(default: Any = ..., **kwargs: Any) -> Any: # noqa: ANN401 - mirrors pydantic's Field()
31
+ """Declare a ``str`` argument that holds a file path, so the shell can tab-complete file names for it.
32
+
33
+ This exists because some fields may represent both a file path
34
+ and other types of string data, and we want the shell to provide
35
+ tab-completion for the file path case.
36
+
37
+ Takes the same arguments as pydantic's ``Field``; with no default the argument is required.
38
+ """
39
+ return Field(default, json_schema_extra={"path": True}, **kwargs)
40
+
41
+
42
+ class RigboxParser(cmd2.Cmd2ArgumentParser):
43
+ """The parser for one command. It remembers the last error so non-interactive callers can report it."""
44
+
45
+ fields: tuple[str, ...] = ()
46
+ """Names of the args model's fields, used to pick them out of the parsed namespace."""
47
+
48
+ error_message: str = ""
49
+
50
+ def error(self, message: str) -> NoReturn:
51
+ """Record `message`, then let argparse print the usage and exit as usual."""
52
+ self.error_message = message
53
+ super().error(message)
54
+
55
+
56
+ def _unwrap(annotation: object) -> object:
57
+ """Strip Optional[...] so `bool | None` still counts as bool."""
58
+ if typing.get_origin(annotation) in (typing.Union, UnionType):
59
+ args = [a for a in typing.get_args(annotation) if a is not type(None)]
60
+ if len(args) == 1:
61
+ return args[0]
62
+ return annotation
63
+
64
+
65
+ def _is_bool(info: FieldInfo) -> bool:
66
+ return _unwrap(info.annotation) is bool
67
+
68
+
69
+ def _is_list(info: FieldInfo) -> bool:
70
+ return typing.get_origin(_unwrap(info.annotation)) in (list, tuple, set)
71
+
72
+
73
+ def _is_path(info: FieldInfo) -> bool:
74
+ extra = info.json_schema_extra
75
+ return isinstance(extra, dict) and extra.get("path") is True
76
+
77
+
78
+ def _help_text(info: FieldInfo) -> str:
79
+ """The field's description, with its default appended; `%` is doubled because argparse formats help text."""
80
+ text = info.description or ""
81
+ if info.default_factory is None and info.default not in (None, "", False) and not info.is_required():
82
+ text = f"{text} (default: {info.default})".strip()
83
+ return text.replace("%", "%%")
84
+
85
+
86
+ def build_parser(name: str, model: type[BaseModel], summary: str) -> RigboxParser:
87
+ """Build the parser for command `name` from its args `model`; `summary` is shown as the description."""
88
+ # First, create a parser instance
89
+ parser = RigboxParser(prog=name, description=summary, allow_abbrev=False, suggest_on_error=True)
90
+ # Then, create the fields using the pydantic model's field definitions
91
+ parser.fields = tuple(model.model_fields)
92
+ # Now, we add each field as an argument to the parser based on its properties
93
+ for field, info in model.model_fields.items():
94
+ annotation = _unwrap(info.annotation)
95
+ options: dict[str, Any] = {"help": _help_text(info)}
96
+ if typing.get_origin(annotation) is typing.Literal:
97
+ options["choices"] = list(typing.get_args(annotation))
98
+ # Automatically set up path completion for path fields
99
+ if _is_path(info):
100
+ options["completer"] = cmd2.Cmd.path_complete
101
+ if info.is_required():
102
+ parser.add_argument(field, **options)
103
+ continue
104
+ flag = "--" + field.replace("_", "-")
105
+ if _is_bool(info):
106
+ parser.add_argument(flag, dest=field, action="store_true", default=argparse.SUPPRESS, help=options["help"])
107
+ elif _is_list(info):
108
+ parser.add_argument(flag, dest=field, action="append", default=argparse.SUPPRESS, **options)
109
+ else:
110
+ parser.add_argument(flag, dest=field, default=argparse.SUPPRESS, **options)
111
+ return parser
112
+
113
+
114
+ @functools.cache # Cache the parser for each command to avoid rebuilding it multiple times
115
+ def parser_for(command: Command) -> RigboxParser:
116
+ """Return the (cached) parser for a registered command."""
117
+ return build_parser(command.name, command.args_model, command.help)
118
+
119
+
120
+ def parse_args(parser: RigboxParser, tokens: list[str]) -> dict[str, Any]:
121
+ """Parse command-line `tokens` into a raw args dict holding only the options actually given.
122
+
123
+ Raises:
124
+ UsageError: If the tokens are invalid, or ``--help`` was given. The parser has already printed the usage
125
+ message or help text; the error's ``exit_code`` is 2 for a bad command line and 0 for help.
126
+ """
127
+ try:
128
+ namespace = parser.parse_args(tokens)
129
+ except SystemExit as exit_:
130
+ code = exit_.code
131
+ raise UsageError(parser.error_message, code if isinstance(code, int) else 0) from None
132
+ return {key: value for key, value in vars(namespace).items() if key in parser.fields}
133
+
134
+
135
+ def split_line(line: str) -> list[str]:
136
+ """Split a line into tokens. Quotes group words; backslashes are kept (Windows paths)."""
137
+ lexer = shlex.shlex(line, posix=False)
138
+ lexer.whitespace_split = True
139
+ lexer.commenters = ""
140
+ tokens: list[str] = []
141
+ for tok in lexer:
142
+ if len(tok) >= 2 and tok[0] == tok[-1] and tok[0] in "\"'":
143
+ tok = tok[1:-1]
144
+ tokens.append(tok)
145
+ return tokens
rigbox/core/context.py ADDED
@@ -0,0 +1,129 @@
1
+ """Execution context shared by REPL, CLI and workflows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Protocol, TextIO
8
+
9
+ from pydantic import ValidationError
10
+ from rich.console import Console, RenderableType
11
+
12
+ from rigbox.constants import RenderFormat
13
+ from rigbox.core.cli_parser import parse_args, parser_for, split_line
14
+ from rigbox.core.history import History
15
+ from rigbox.core.registry import CommandError, Registry
16
+ from rigbox.core.render import RigboxCmdResult, resolve_render_format
17
+ from rigbox.core.workspace import Workspace
18
+
19
+
20
+ class Closable(Protocol):
21
+ """Anything with a ``close()`` method, such as a device connection."""
22
+
23
+ def close(self) -> None:
24
+ """Release the resource."""
25
+
26
+
27
+ @dataclass
28
+ class Context:
29
+ """Shared state for one session: registry, workspace, history, output format and open connections."""
30
+
31
+ registry: Registry
32
+ workspace: Workspace | None = None
33
+ fmt: RenderFormat = RenderFormat.default()
34
+ history: History = field(init=False) # created in __post_init__
35
+ # Open resources owned by plugins (e.g. device connections), closed in `close()`; any object with .close()
36
+ connections: dict[str, Closable] = field(default_factory=dict)
37
+ output_stream: TextIO | None = None
38
+
39
+ def __post_init__(self) -> None:
40
+ """Create the session history, persisted in the workspace if there is one."""
41
+ path = self.workspace.data_dir / "history.jsonl" if self.workspace else None
42
+ self.history = History(path)
43
+
44
+ def close(self) -> None:
45
+ """Attempt every resource's cleanup, log failures, and forget the closed session."""
46
+ connections = list(self.connections.items())
47
+ self.connections.clear()
48
+ for name, conn in connections:
49
+ try:
50
+ conn.close()
51
+ except Exception as error:
52
+ self.log(f"warning: cannot close connection '{name}': {error}")
53
+
54
+ def log(self, message: str) -> None:
55
+ """Write a progress or diagnostic message to stderr, keeping stdout pipe-clean.
56
+
57
+ Args:
58
+ message (str): The message to log.
59
+ """
60
+ print(message, file=sys.stderr)
61
+
62
+ def print(self, renderable: RenderableType, *, style: str | None = None) -> None:
63
+ """Print a Rich renderable to stdout in plaintext or rich mode.
64
+
65
+ Args:
66
+ renderable: The text or Rich renderable to display.
67
+ style: Optional Rich style applied to the renderable.
68
+
69
+ Raises:
70
+ CommandError: If the selected output format is not plaintext or rich.
71
+ """
72
+ stream = self.output_stream if self.output_stream is not None else sys.stdout
73
+ resolved_fmt = resolve_render_format(self.fmt, stream)
74
+ if resolved_fmt not in (RenderFormat.RICH, RenderFormat.PLAINTEXT):
75
+ raise CommandError("Rich output is only available in plaintext or rich format")
76
+ Console(file=stream).print(renderable, style=style, markup=False)
77
+
78
+ def run(self, name: str, raw_args: dict[str, Any] | None = None) -> RigboxCmdResult:
79
+ """Validate arguments, invoke the handler, and normalize its response.
80
+
81
+ Args:
82
+ name (str): The name of the command to run.
83
+ raw_args (dict[str, Any] | None, optional): The raw arguments to pass to the command. Defaults to None.
84
+
85
+ Returns:
86
+ The response containing normalized data and any explicit plaintext message.
87
+
88
+ Raises:
89
+ CommandError: If the command is unknown, the args are invalid, or the handler fails.
90
+ """
91
+ cmd = self.registry.get(name)
92
+ try:
93
+ args = cmd.args_model.model_validate(raw_args or {})
94
+ except ValidationError as e:
95
+ problems = "; ".join(f"{'.'.join(str(p) for p in err['loc']) or 'args'}: {err['msg']}" for err in e.errors())
96
+ raise CommandError(f"{name}: invalid arguments: {problems}") from None
97
+ result = cmd.handler(self, args)
98
+ if cmd.record:
99
+ self.history.append(name, args.model_dump(mode="json", exclude_unset=True))
100
+ return RigboxCmdResult.normalized(result)
101
+
102
+ def run_tokens(self, tokens: list[str]) -> RigboxCmdResult:
103
+ """Run a command from tokens: the command name followed by CLI-style arguments.
104
+
105
+ Args:
106
+ tokens (list[str]): The command name followed by CLI-style arguments.
107
+
108
+ Returns:
109
+ The normalized command response.
110
+
111
+ Raises:
112
+ UsageError: If the arguments are invalid or ``--help`` was given; the parser has already printed
113
+ the usage message or help text.
114
+ """
115
+ if not tokens:
116
+ return RigboxCmdResult.empty()
117
+ cmd = self.registry.get(tokens[0])
118
+ return self.run(cmd.name, parse_args(parser_for(cmd), tokens[1:]))
119
+
120
+ def run_line(self, line: str) -> RigboxCmdResult:
121
+ """Split `line` into tokens and run it as a command.
122
+
123
+ Args:
124
+ line (str): The command line to split and run.
125
+
126
+ Returns:
127
+ The normalized command response.
128
+ """
129
+ return self.run_tokens(split_line(line))
rigbox/core/history.py ADDED
@@ -0,0 +1,44 @@
1
+ """Structured session history (JSONL). Steps replay and convert to workflows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ class History:
12
+ """The commands run in a session, optionally persisted to a JSONL file."""
13
+
14
+ def __init__(self, path: Path | None = None) -> None:
15
+ """Load existing entries from `path` if it exists; with no path, keep history in memory only."""
16
+ self.path = path
17
+ self._entries: list[dict[str, Any]] = []
18
+ if path is not None and path.is_file():
19
+ for line in path.read_text(encoding="utf-8").splitlines():
20
+ if line.strip():
21
+ self._entries.append(json.loads(line))
22
+
23
+ def append(self, run: str, args: dict[str, Any], ok: bool = True) -> None:
24
+ """Record a command run (name, args, success flag) and write it to the file, if any.
25
+
26
+ Args:
27
+ run (str): The name of the command that was run.
28
+ args (dict[str, Any]): The arguments passed to the command.
29
+ ok (bool, optional): Whether the command ran successfully. Defaults to True.
30
+ """
31
+ entry = {"ts": round(time.time(), 3), "run": run, "args": args, "ok": ok}
32
+ self._entries.append(entry)
33
+ if self.path is not None:
34
+ self.path.parent.mkdir(parents=True, exist_ok=True)
35
+ with self.path.open("a", encoding="utf-8") as f:
36
+ f.write(json.dumps(entry) + "\n")
37
+
38
+ def entries(self) -> list[dict[str, Any]]:
39
+ """Return a copy of all entries, oldest first.
40
+
41
+ Returns:
42
+ list[dict[str, Any]]: A list of all history entries, each being a dictionary with keys "ts", "run", "args", and "ok".
43
+ """
44
+ return list(self._entries)
rigbox/core/plugins.py ADDED
@@ -0,0 +1,76 @@
1
+ """Plugin discovery on top of pluggy: built-ins, installed entry points, workspace-local files.
2
+
3
+ pluggy owns hook definition and dispatch; this module only gathers plugin modules from our three
4
+ sources and records per-plugin failures (a broken plugin never aborts startup).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import importlib
10
+ import importlib.util
11
+ from collections.abc import Callable
12
+ from functools import partial
13
+ from importlib.metadata import entry_points
14
+ from pathlib import Path
15
+ from types import ModuleType
16
+
17
+ import pluggy
18
+
19
+ from rigbox import hooks
20
+ from rigbox.constants import Plugins as PluginConstants
21
+ from rigbox.core.registry import PluginInfo, Registry
22
+ from rigbox.core.workspace import Workspace
23
+
24
+
25
+ def load_all(registry: Registry, workspace: Workspace | None = None) -> None:
26
+ """Load every plugin and let each register its commands on `registry`.
27
+
28
+ Failures are recorded in ``registry.plugins`` rather than raised.
29
+
30
+ Args:
31
+ registry (Registry): The registry where plugins should register their commands.
32
+ workspace (Workspace | None, optional): The workspace to look for workspace-local plugins. Defaults to None.
33
+ """
34
+ pm = pluggy.PluginManager(PluginConstants.PROJECT)
35
+ pm.add_hookspecs(hooks)
36
+ sources: dict[str, str] = {}
37
+
38
+ def add(name: str, source: str, loader: Callable[[], object]) -> None:
39
+ try:
40
+ pm.register(loader(), name=name)
41
+ sources[name] = source
42
+ except Exception as e: # plugin code must never crash the host
43
+ registry.plugins.append(PluginInfo(name, source, error=f"{type(e).__name__}: {e}"))
44
+
45
+ for mod in PluginConstants.BUILTIN_MODULES:
46
+ add(mod, "builtin", partial(importlib.import_module, mod))
47
+ for ep in entry_points(group=PluginConstants.ENTRYPOINT_GROUP):
48
+ add(ep.name, "entry-point", ep.load)
49
+ if workspace is not None and workspace.plugins_dir.is_dir():
50
+ for path in sorted(workspace.plugins_dir.glob("*.py")):
51
+ if not path.name.startswith("_"):
52
+ add(path.stem, "workspace", partial(_import_file, path))
53
+
54
+ called: set[str] = set()
55
+ for impl in pm.hook.rigbox_register.get_hookimpls():
56
+ called.add(impl.plugin_name)
57
+ before = set(registry.names())
58
+ info = PluginInfo(impl.plugin_name, sources[impl.plugin_name])
59
+ try:
60
+ impl.function(registry=registry)
61
+ except Exception as e:
62
+ info.error = f"{type(e).__name__}: {e}"
63
+ info.commands = sorted(set(registry.names()) - before)
64
+ registry.plugins.append(info)
65
+ for name, source in sources.items():
66
+ if name not in called:
67
+ registry.plugins.append(PluginInfo(name, source, error="no rigbox_register hook"))
68
+
69
+
70
+ def _import_file(path: Path) -> ModuleType:
71
+ spec = importlib.util.spec_from_file_location(f"rigbox_workspace_plugin_{path.stem}", path)
72
+ if spec is None or spec.loader is None:
73
+ raise ImportError(f"cannot load plugin file: {path}")
74
+ module = importlib.util.module_from_spec(spec)
75
+ spec.loader.exec_module(module)
76
+ return module