elizaos-plugin-cli 2.0.0a4__tar.gz

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.
@@ -0,0 +1,12 @@
1
+ dist
2
+ node_modules
3
+ .env
4
+ .elizadb
5
+ .turbo
6
+ target/
7
+ __pycache__
8
+ *.pyc
9
+ .venv
10
+ *.egg-info
11
+ .DS_Store
12
+ package-lock.json
@@ -0,0 +1,24 @@
1
+ Metadata-Version: 2.4
2
+ Name: elizaos-plugin-cli
3
+ Version: 2.0.0a4
4
+ Summary: elizaOS CLI Plugin - CLI framework and command registration
5
+ Project-URL: Homepage, https://github.com/elizaos/eliza
6
+ Project-URL: Repository, https://github.com/elizaos/eliza
7
+ Author: elizaOS Contributors
8
+ License-Expression: MIT
9
+ Keywords: agents,ai,cli,commands,elizaos,framework
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.11
19
+ Provides-Extra: dev
20
+ Requires-Dist: mypy>=1.14.0; extra == 'dev'
21
+ Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
22
+ Requires-Dist: pytest-cov>=6.0.0; extra == 'dev'
23
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
24
+ Requires-Dist: ruff>=0.9.0; extra == 'dev'
@@ -0,0 +1,63 @@
1
+ """elizaOS CLI Plugin - CLI framework and command registration.
2
+
3
+ Provides:
4
+ - CLI command registration and management via :class:`CliRegistry`
5
+ - Type definitions for commands, arguments, and contexts
6
+ - Duration/timeout parsing and formatting utilities
7
+ - Progress reporting
8
+ """
9
+
10
+ from elizaos_plugin_cli.registry import CliRegistry
11
+ from elizaos_plugin_cli.types import (
12
+ CliArg,
13
+ CliCommand,
14
+ CliContext,
15
+ CliLogger,
16
+ CliPluginConfig,
17
+ CommonCommandOptions,
18
+ DefaultCliLogger,
19
+ ParsedDuration,
20
+ ProgressReporter,
21
+ )
22
+ from elizaos_plugin_cli.utils import (
23
+ DEFAULT_CLI_NAME,
24
+ DEFAULT_CLI_VERSION,
25
+ format_bytes,
26
+ format_cli_command,
27
+ format_duration,
28
+ parse_duration,
29
+ parse_timeout_ms,
30
+ truncate_string,
31
+ )
32
+
33
+ __version__ = "2.0.0"
34
+
35
+ PLUGIN_NAME = "cli"
36
+ PLUGIN_DESCRIPTION = "CLI framework plugin for command registration and execution"
37
+
38
+ __all__ = [
39
+ # Registry
40
+ "CliRegistry",
41
+ # Types
42
+ "CliArg",
43
+ "CliCommand",
44
+ "CliContext",
45
+ "CliLogger",
46
+ "CliPluginConfig",
47
+ "CommonCommandOptions",
48
+ "DefaultCliLogger",
49
+ "ParsedDuration",
50
+ "ProgressReporter",
51
+ # Utils
52
+ "DEFAULT_CLI_NAME",
53
+ "DEFAULT_CLI_VERSION",
54
+ "format_bytes",
55
+ "format_cli_command",
56
+ "format_duration",
57
+ "parse_duration",
58
+ "parse_timeout_ms",
59
+ "truncate_string",
60
+ # Constants
61
+ "PLUGIN_NAME",
62
+ "PLUGIN_DESCRIPTION",
63
+ ]
@@ -0,0 +1,99 @@
1
+ """CLI command registry.
2
+
3
+ Provides command registration and management for the CLI plugin.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ from typing import Iterator
10
+
11
+ from elizaos_plugin_cli.types import CliCommand
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class CliRegistry:
17
+ """Central registry for CLI commands.
18
+
19
+ Commands are stored by their primary name and can be looked up by name
20
+ or alias.
21
+
22
+ Example::
23
+
24
+ registry = CliRegistry()
25
+ cmd = CliCommand(name="run", description="Run the agent", handler_name="handle_run")
26
+ registry.register_command(cmd)
27
+ assert registry.has_command("run")
28
+ """
29
+
30
+ def __init__(self) -> None:
31
+ self._commands: dict[str, CliCommand] = {}
32
+
33
+ def register_command(self, cmd: CliCommand) -> CliCommand | None:
34
+ """Register a command.
35
+
36
+ If a command with the same name already exists, it is replaced and
37
+ the old command is returned.
38
+ """
39
+ logger.debug("Registering CLI command: %s", cmd.name)
40
+ old = self._commands.get(cmd.name)
41
+ self._commands[cmd.name] = cmd
42
+ return old
43
+
44
+ def unregister_command(self, name: str) -> CliCommand | None:
45
+ """Unregister a command by its primary name.
46
+
47
+ Returns the removed command if found, otherwise ``None``.
48
+ """
49
+ return self._commands.pop(name, None)
50
+
51
+ def get_command(self, name: str) -> CliCommand | None:
52
+ """Get a command by its primary name."""
53
+ return self._commands.get(name)
54
+
55
+ def find_command(self, name: str) -> CliCommand | None:
56
+ """Find a command by name or any of its aliases."""
57
+ # Direct lookup (fast path).
58
+ if name in self._commands:
59
+ return self._commands[name]
60
+ # Fall back to alias scan.
61
+ for cmd in self._commands.values():
62
+ if cmd.matches(name):
63
+ return cmd
64
+ return None
65
+
66
+ def list_commands(self) -> list[CliCommand]:
67
+ """List all registered commands, sorted by priority then name."""
68
+ return sorted(
69
+ self._commands.values(),
70
+ key=lambda c: (c.priority, c.name),
71
+ )
72
+
73
+ def has_command(self, name: str) -> bool:
74
+ """Check if a command with the given primary name is registered."""
75
+ return name in self._commands
76
+
77
+ def __len__(self) -> int:
78
+ """Return the number of registered commands."""
79
+ return len(self._commands)
80
+
81
+ def __bool__(self) -> bool:
82
+ """Return True if the registry has any commands."""
83
+ return bool(self._commands)
84
+
85
+ def __iter__(self) -> Iterator[CliCommand]:
86
+ """Iterate over all commands (unsorted)."""
87
+ return iter(self._commands.values())
88
+
89
+ def __contains__(self, name: str) -> bool:
90
+ """Support ``'name' in registry`` syntax."""
91
+ return self.has_command(name)
92
+
93
+ def clear(self) -> None:
94
+ """Remove all commands."""
95
+ self._commands.clear()
96
+
97
+ def command_names(self) -> list[str]:
98
+ """Get all primary command names, sorted alphabetically."""
99
+ return sorted(self._commands.keys())
@@ -0,0 +1,224 @@
1
+ """CLI plugin types.
2
+
3
+ Core type definitions for CLI command registration and execution.
4
+ All types are immutable dataclasses following the frozen pattern.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from abc import ABC, abstractmethod
10
+ from dataclasses import dataclass, field
11
+ from typing import Any
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class CliArg:
16
+ """A single argument definition for a CLI command."""
17
+
18
+ name: str
19
+ """Argument name (e.g. 'output', 'verbose')."""
20
+ description: str
21
+ """Human-readable description."""
22
+ required: bool = False
23
+ """Whether this argument is required."""
24
+ default_value: str | None = None
25
+ """Optional default value when not supplied."""
26
+
27
+ @classmethod
28
+ def required_arg(cls, name: str, description: str) -> CliArg:
29
+ """Create a required argument."""
30
+ return cls(name=name, description=description, required=True)
31
+
32
+ @classmethod
33
+ def optional_arg(cls, name: str, description: str, default: str) -> CliArg:
34
+ """Create an optional argument with a default value."""
35
+ return cls(name=name, description=description, required=False, default_value=default)
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class CliCommand:
40
+ """Definition of a CLI command that can be registered in the registry."""
41
+
42
+ name: str
43
+ """Primary command name (e.g. 'run', 'config')."""
44
+ description: str
45
+ """Human-readable description."""
46
+ handler_name: str
47
+ """Name of the handler function to invoke."""
48
+ aliases: tuple[str, ...] = ()
49
+ """Alternate names for this command."""
50
+ args: tuple[CliArg, ...] = ()
51
+ """Arguments accepted by this command."""
52
+ priority: int = 100
53
+ """Priority for registration order (lower = earlier)."""
54
+
55
+ def matches(self, name: str) -> bool:
56
+ """Check if this command matches a name or any of its aliases."""
57
+ return self.name == name or name in self.aliases
58
+
59
+ def with_alias(self, alias: str) -> CliCommand:
60
+ """Return a new command with an additional alias."""
61
+ return CliCommand(
62
+ name=self.name,
63
+ description=self.description,
64
+ handler_name=self.handler_name,
65
+ aliases=(*self.aliases, alias),
66
+ args=self.args,
67
+ priority=self.priority,
68
+ )
69
+
70
+ def with_arg(self, arg: CliArg) -> CliCommand:
71
+ """Return a new command with an additional argument."""
72
+ return CliCommand(
73
+ name=self.name,
74
+ description=self.description,
75
+ handler_name=self.handler_name,
76
+ aliases=self.aliases,
77
+ args=(*self.args, arg),
78
+ priority=self.priority,
79
+ )
80
+
81
+ def with_priority(self, priority: int) -> CliCommand:
82
+ """Return a new command with a different priority."""
83
+ return CliCommand(
84
+ name=self.name,
85
+ description=self.description,
86
+ handler_name=self.handler_name,
87
+ aliases=self.aliases,
88
+ args=self.args,
89
+ priority=priority,
90
+ )
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class CliContext:
95
+ """Context provided to CLI command handlers."""
96
+
97
+ program_name: str
98
+ """Program name (e.g. 'elizaos', 'otto')."""
99
+ version: str
100
+ """CLI version string."""
101
+ description: str
102
+ """Human-readable description of the CLI."""
103
+ workspace_dir: str | None = None
104
+ """Optional workspace directory for file operations."""
105
+ config: dict[str, Any] | None = None
106
+ """Optional configuration key-value pairs."""
107
+
108
+
109
+ class CliLogger(ABC):
110
+ """Abstract logger interface for CLI output, allowing pluggable backends."""
111
+
112
+ @abstractmethod
113
+ def info(self, msg: str) -> None:
114
+ """Log an informational message."""
115
+
116
+ @abstractmethod
117
+ def warn(self, msg: str) -> None:
118
+ """Log a warning message."""
119
+
120
+ @abstractmethod
121
+ def error(self, msg: str) -> None:
122
+ """Log an error message."""
123
+
124
+ def debug(self, msg: str) -> None:
125
+ """Log a debug message (optional, defaults to no-op)."""
126
+
127
+
128
+ class DefaultCliLogger(CliLogger):
129
+ """Default logger that writes to stdout/stderr."""
130
+
131
+ def info(self, msg: str) -> None:
132
+ print(f"[INFO] {msg}")
133
+
134
+ def warn(self, msg: str) -> None:
135
+ print(f"[WARN] {msg}")
136
+
137
+ def error(self, msg: str) -> None:
138
+ import sys
139
+
140
+ print(f"[ERROR] {msg}", file=sys.stderr)
141
+
142
+ def debug(self, msg: str) -> None:
143
+ print(f"[DEBUG] {msg}")
144
+
145
+
146
+ @dataclass
147
+ class ProgressReporter:
148
+ """Tracks progress of a long-running operation."""
149
+
150
+ current: int = 0
151
+ """Current step number."""
152
+ total: int = 0
153
+ """Total number of steps (0 if unknown)."""
154
+ message: str = ""
155
+ """Current status message."""
156
+
157
+ def __init__(self, total: int = 0, message: str = "") -> None:
158
+ self.current = 0
159
+ self.total = total
160
+ self.message = message
161
+
162
+ def advance(self, message: str) -> None:
163
+ """Advance by one step with a new message."""
164
+ self.current += 1
165
+ self.message = message
166
+
167
+ def set(self, current: int, message: str) -> None:
168
+ """Set absolute progress."""
169
+ self.current = current
170
+ self.message = message
171
+
172
+ def fraction(self) -> float | None:
173
+ """Return progress as a fraction in [0.0, 1.0], or None if total is 0."""
174
+ if self.total == 0:
175
+ return None
176
+ return self.current / self.total
177
+
178
+ def is_complete(self) -> bool:
179
+ """Whether the operation is complete."""
180
+ return self.total > 0 and self.current >= self.total
181
+
182
+ def display(self) -> str:
183
+ """Format as a human-readable string like '[3/10] Building...'."""
184
+ if self.total > 0:
185
+ return f"[{self.current}/{self.total}] {self.message}"
186
+ return f"[{self.current}] {self.message}"
187
+
188
+
189
+ @dataclass(frozen=True)
190
+ class CommonCommandOptions:
191
+ """Common options that many CLI commands accept."""
192
+
193
+ json: bool = False
194
+ """Output as JSON."""
195
+ verbose: bool = False
196
+ """Verbose output."""
197
+ quiet: bool = False
198
+ """Quiet mode (minimal output)."""
199
+ force: bool = False
200
+ """Force action without confirmation."""
201
+ dry_run: bool = False
202
+ """Dry run (show what would happen)."""
203
+
204
+
205
+ @dataclass(frozen=True)
206
+ class ParsedDuration:
207
+ """Result of parsing a duration string."""
208
+
209
+ ms: int
210
+ """Duration in milliseconds."""
211
+ original: str
212
+ """The original input string."""
213
+ valid: bool
214
+ """Whether parsing succeeded."""
215
+
216
+
217
+ @dataclass(frozen=True)
218
+ class CliPluginConfig:
219
+ """CLI plugin configuration."""
220
+
221
+ name: str = "elizaos"
222
+ """CLI name."""
223
+ version: str = "1.0.0"
224
+ """CLI version."""
@@ -0,0 +1,187 @@
1
+ """CLI utilities.
2
+
3
+ Common utilities for CLI operations including duration parsing,
4
+ byte formatting, and string helpers.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+
11
+ DEFAULT_CLI_NAME: str = "elizaos"
12
+ """Default CLI name."""
13
+
14
+ DEFAULT_CLI_VERSION: str = "1.0.0"
15
+ """Default CLI version."""
16
+
17
+ # Regex for matching duration components like "1h", "30m", "500ms".
18
+ _DURATION_PART_RE = re.compile(
19
+ r"(\d+(?:\.\d+)?)\s*"
20
+ r"(milliseconds?|ms|seconds?|sec|s|minutes?|min|m|hours?|hr|h|days?|d)?",
21
+ re.IGNORECASE,
22
+ )
23
+
24
+ _UNIT_TO_MS: dict[str, float] = {
25
+ "ms": 1,
26
+ "millisecond": 1,
27
+ "milliseconds": 1,
28
+ "s": 1_000,
29
+ "sec": 1_000,
30
+ "second": 1_000,
31
+ "seconds": 1_000,
32
+ "m": 60_000,
33
+ "min": 60_000,
34
+ "minute": 60_000,
35
+ "minutes": 60_000,
36
+ "h": 3_600_000,
37
+ "hr": 3_600_000,
38
+ "hour": 3_600_000,
39
+ "hours": 3_600_000,
40
+ "d": 86_400_000,
41
+ "day": 86_400_000,
42
+ "days": 86_400_000,
43
+ }
44
+
45
+
46
+ def parse_duration(s: str) -> int | None:
47
+ """Parse a duration string to milliseconds.
48
+
49
+ Supports compound formats and multiple unit suffixes::
50
+
51
+ "1h" -> 3600000
52
+ "30m" -> 1800000
53
+ "1h30m" -> 5400000
54
+ "2d" -> 172800000
55
+ "500ms" -> 500
56
+ "1000" -> 1000 (plain number = milliseconds)
57
+
58
+ Returns ``None`` for invalid input.
59
+ """
60
+ s = s.strip().lower()
61
+ if not s:
62
+ return None
63
+
64
+ # Plain numeric value -> milliseconds.
65
+ try:
66
+ return int(s)
67
+ except ValueError:
68
+ pass
69
+
70
+ # Try float as bare number.
71
+ try:
72
+ return round(float(s))
73
+ except ValueError:
74
+ pass
75
+
76
+ total_ms = 0
77
+ found_any = False
78
+ pos = 0
79
+
80
+ while pos < len(s):
81
+ # Skip whitespace.
82
+ while pos < len(s) and s[pos].isspace():
83
+ pos += 1
84
+ if pos >= len(s):
85
+ break
86
+
87
+ match = _DURATION_PART_RE.match(s, pos)
88
+ if not match:
89
+ return None
90
+
91
+ value = float(match.group(1))
92
+ unit = (match.group(2) or "").lower()
93
+
94
+ if not unit:
95
+ if found_any:
96
+ return None # Bare number in middle of compound.
97
+ # Bare number = ms.
98
+ multiplier = 1.0
99
+ else:
100
+ multiplier = _UNIT_TO_MS.get(unit)
101
+ if multiplier is None:
102
+ return None
103
+
104
+ total_ms += round(value * multiplier)
105
+ found_any = True
106
+ pos = match.end()
107
+
108
+ return total_ms if found_any else None
109
+
110
+
111
+ def format_duration(ms: int) -> str:
112
+ """Format milliseconds as a human-readable string.
113
+
114
+ Uses the largest appropriate unit:
115
+
116
+ - ``< 1s`` -> ``"450ms"``
117
+ - ``< 1m`` -> ``"12.3s"``
118
+ - ``< 1h`` -> ``"5.2m"``
119
+ - ``< 1d`` -> ``"3.5h"``
120
+ - ``>= 1d`` -> ``"2.0d"``
121
+ """
122
+ if ms < 1_000:
123
+ return f"{ms}ms"
124
+ if ms < 60_000:
125
+ return f"{ms / 1_000:.1f}s"
126
+ if ms < 3_600_000:
127
+ return f"{ms / 60_000:.1f}m"
128
+ if ms < 86_400_000:
129
+ return f"{ms / 3_600_000:.1f}h"
130
+ return f"{ms / 86_400_000:.1f}d"
131
+
132
+
133
+ def format_bytes(bytes_count: int) -> str:
134
+ """Format a byte count as a human-readable string (e.g. ``"1.5 MB"``).
135
+
136
+ Uses binary prefixes (1 KB = 1024 bytes).
137
+ """
138
+ units = ("B", "KB", "MB", "GB", "TB")
139
+ value = float(bytes_count)
140
+ unit_idx = 0
141
+
142
+ while value >= 1024.0 and unit_idx < len(units) - 1:
143
+ value /= 1024.0
144
+ unit_idx += 1
145
+
146
+ if unit_idx == 0:
147
+ return f"{bytes_count} B"
148
+ return f"{value:.1f} {units[unit_idx]}"
149
+
150
+
151
+ def truncate_string(s: str, max_len: int) -> str:
152
+ """Truncate a string to at most ``max_len`` characters.
153
+
154
+ Appends ``"..."`` if truncated. If the string fits, returns it unchanged.
155
+ """
156
+ if len(s) <= max_len:
157
+ return s
158
+ if max_len <= 3:
159
+ return "." * max_len
160
+ return s[: max_len - 3] + "..."
161
+
162
+
163
+ def parse_timeout_ms(input_str: str | None, default_ms: int) -> int:
164
+ """Parse a timeout string with a fallback default.
165
+
166
+ If ``input_str`` is ``None`` or parsing fails, returns ``default_ms``.
167
+ """
168
+ if input_str is None:
169
+ return default_ms
170
+ result = parse_duration(input_str)
171
+ return result if result is not None else default_ms
172
+
173
+
174
+ def format_cli_command(
175
+ command: str,
176
+ cli_name: str | None = None,
177
+ profile: str | None = None,
178
+ env: str | None = None,
179
+ ) -> str:
180
+ """Format a CLI command string with optional profile and env context."""
181
+ parts = [cli_name or DEFAULT_CLI_NAME]
182
+ if profile is not None:
183
+ parts.append(f"--profile {profile}")
184
+ if env is not None:
185
+ parts.append(f"--env {env}")
186
+ parts.append(command)
187
+ return " ".join(parts)
@@ -0,0 +1,76 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "elizaos-plugin-cli"
7
+ version = "2.0.0a4"
8
+ description = "elizaOS CLI Plugin - CLI framework and command registration"
9
+ license = "MIT"
10
+ requires-python = ">=3.11"
11
+ authors = [{ name = "elizaOS Contributors" }]
12
+ keywords = ["ai", "agents", "cli", "commands", "framework", "elizaos"]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Typing :: Typed",
22
+ ]
23
+ dependencies = []
24
+
25
+ [project.optional-dependencies]
26
+ dev = [
27
+ "pytest>=8.0.0",
28
+ "pytest-asyncio>=0.24.0",
29
+ "pytest-cov>=6.0.0",
30
+ "mypy>=1.14.0",
31
+ "ruff>=0.9.0",
32
+ ]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/elizaos/eliza"
36
+ Repository = "https://github.com/elizaos/eliza"
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["elizaos_plugin_cli"]
40
+
41
+ [tool.hatch.build.targets.sdist]
42
+ include = ["/elizaos_plugin_cli", "/tests"]
43
+
44
+ [tool.pytest.ini_options]
45
+ testpaths = ["tests"]
46
+ python_files = "test_*.py"
47
+ python_functions = "test_*"
48
+ addopts = "-v"
49
+ asyncio_mode = "auto"
50
+ asyncio_default_fixture_loop_scope = "function"
51
+
52
+ [tool.mypy]
53
+ python_version = "3.11"
54
+ strict = true
55
+ warn_return_any = true
56
+ warn_unused_ignores = true
57
+ disallow_untyped_defs = true
58
+ disallow_incomplete_defs = true
59
+ check_untyped_defs = true
60
+ no_implicit_optional = true
61
+
62
+ [tool.ruff]
63
+ target-version = "py311"
64
+ line-length = 100
65
+
66
+ [tool.ruff.lint]
67
+ select = ["E", "W", "F", "I", "B", "C4", "UP", "ANN", "S"]
68
+ ignore = [
69
+ "E501",
70
+ "ANN101",
71
+ "ANN102",
72
+ "S101",
73
+ ]
74
+
75
+ [tool.ruff.lint.isort]
76
+ known-first-party = ["elizaos_plugin_cli"]
@@ -0,0 +1,30 @@
1
+ """Shared test fixtures for plugin-cli tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from elizaos_plugin_cli.registry import CliRegistry
8
+ from elizaos_plugin_cli.types import CliArg, CliCommand
9
+
10
+
11
+ @pytest.fixture
12
+ def registry() -> CliRegistry:
13
+ """Create a fresh, empty CliRegistry for each test."""
14
+ return CliRegistry()
15
+
16
+
17
+ @pytest.fixture
18
+ def sample_command() -> CliCommand:
19
+ """A sample command for testing."""
20
+ return CliCommand(
21
+ name="run",
22
+ description="Run the agent",
23
+ handler_name="handle_run",
24
+ aliases=("start", "go"),
25
+ args=(
26
+ CliArg.required_arg("target", "Deployment target"),
27
+ CliArg.optional_arg("port", "Listen port", "3000"),
28
+ ),
29
+ priority=10,
30
+ )
@@ -0,0 +1,141 @@
1
+ """Tests for CLI registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from elizaos_plugin_cli.registry import CliRegistry
6
+ from elizaos_plugin_cli.types import CliArg, CliCommand
7
+
8
+
9
+ def test_register_and_lookup(registry: CliRegistry) -> None:
10
+ cmd = CliCommand(name="run", description="Run the agent", handler_name="handle_run")
11
+ registry.register_command(cmd)
12
+
13
+ found = registry.get_command("run")
14
+ assert found is not None
15
+ assert found.name == "run"
16
+ assert found.description == "Run the agent"
17
+
18
+
19
+ def test_has_command(registry: CliRegistry) -> None:
20
+ assert not registry.has_command("run")
21
+ registry.register_command(
22
+ CliCommand(name="run", description="Run", handler_name="handle_run")
23
+ )
24
+ assert registry.has_command("run")
25
+ assert not registry.has_command("build")
26
+
27
+
28
+ def test_list_sorted_by_priority(registry: CliRegistry) -> None:
29
+ registry.register_command(
30
+ CliCommand(name="config", description="Config", handler_name="h1", priority=50)
31
+ )
32
+ registry.register_command(
33
+ CliCommand(name="run", description="Run", handler_name="h2", priority=10)
34
+ )
35
+ registry.register_command(
36
+ CliCommand(name="build", description="Build", handler_name="h3", priority=30)
37
+ )
38
+
39
+ cmds = registry.list_commands()
40
+ assert len(cmds) == 3
41
+ assert cmds[0].name == "run" # priority 10
42
+ assert cmds[1].name == "build" # priority 30
43
+ assert cmds[2].name == "config" # priority 50
44
+
45
+
46
+ def test_unregister(registry: CliRegistry) -> None:
47
+ registry.register_command(
48
+ CliCommand(name="run", description="Run", handler_name="h")
49
+ )
50
+ assert registry.has_command("run")
51
+
52
+ removed = registry.unregister_command("run")
53
+ assert removed is not None
54
+ assert not registry.has_command("run")
55
+ assert len(registry) == 0
56
+
57
+
58
+ def test_replace_existing(registry: CliRegistry) -> None:
59
+ registry.register_command(
60
+ CliCommand(name="run", description="Old", handler_name="h_v1")
61
+ )
62
+ old = registry.register_command(
63
+ CliCommand(name="run", description="New", handler_name="h_v2")
64
+ )
65
+
66
+ assert old is not None
67
+ assert old.description == "Old"
68
+ assert registry.get_command("run") is not None
69
+ assert registry.get_command("run").description == "New"
70
+
71
+
72
+ def test_find_by_alias(registry: CliRegistry, sample_command: CliCommand) -> None:
73
+ registry.register_command(sample_command)
74
+
75
+ assert registry.find_command("start") is not None
76
+ assert registry.find_command("go") is not None
77
+ assert registry.find_command("run") is not None
78
+ assert registry.find_command("stop") is None
79
+
80
+
81
+ def test_command_names(registry: CliRegistry) -> None:
82
+ registry.register_command(CliCommand(name="build", description="B", handler_name="h1"))
83
+ registry.register_command(CliCommand(name="run", description="R", handler_name="h2"))
84
+ registry.register_command(CliCommand(name="config", description="C", handler_name="h3"))
85
+
86
+ names = registry.command_names()
87
+ assert names == ["build", "config", "run"]
88
+
89
+
90
+ def test_clear(registry: CliRegistry) -> None:
91
+ registry.register_command(CliCommand(name="a", description="A", handler_name="h1"))
92
+ registry.register_command(CliCommand(name="b", description="B", handler_name="h2"))
93
+ assert len(registry) == 2
94
+
95
+ registry.clear()
96
+ assert len(registry) == 0
97
+ assert not registry
98
+
99
+
100
+ def test_contains_protocol(registry: CliRegistry) -> None:
101
+ registry.register_command(CliCommand(name="run", description="R", handler_name="h"))
102
+ assert "run" in registry
103
+ assert "build" not in registry
104
+
105
+
106
+ def test_iter_protocol(registry: CliRegistry) -> None:
107
+ registry.register_command(CliCommand(name="a", description="A", handler_name="h1"))
108
+ registry.register_command(CliCommand(name="b", description="B", handler_name="h2"))
109
+ names = {cmd.name for cmd in registry}
110
+ assert names == {"a", "b"}
111
+
112
+
113
+ def test_command_with_args() -> None:
114
+ cmd = (
115
+ CliCommand(name="deploy", description="Deploy", handler_name="handle_deploy")
116
+ .with_arg(CliArg.required_arg("target", "Deployment target"))
117
+ .with_arg(CliArg.optional_arg("port", "Listen port", "3000"))
118
+ )
119
+ assert len(cmd.args) == 2
120
+ assert cmd.args[0].required is True
121
+ assert cmd.args[0].name == "target"
122
+ assert cmd.args[1].required is False
123
+ assert cmd.args[1].default_value == "3000"
124
+
125
+
126
+ def test_command_matches() -> None:
127
+ cmd = CliCommand(
128
+ name="run",
129
+ description="Run",
130
+ handler_name="h",
131
+ aliases=("start", "go"),
132
+ )
133
+ assert cmd.matches("run")
134
+ assert cmd.matches("start")
135
+ assert cmd.matches("go")
136
+ assert not cmd.matches("stop")
137
+
138
+
139
+ def test_unregister_nonexistent(registry: CliRegistry) -> None:
140
+ result = registry.unregister_command("nonexistent")
141
+ assert result is None
@@ -0,0 +1,170 @@
1
+ """Tests for CLI utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from elizaos_plugin_cli.types import (
6
+ CliContext,
7
+ CommonCommandOptions,
8
+ ProgressReporter,
9
+ )
10
+ from elizaos_plugin_cli.utils import (
11
+ DEFAULT_CLI_NAME,
12
+ DEFAULT_CLI_VERSION,
13
+ format_bytes,
14
+ format_cli_command,
15
+ format_duration,
16
+ parse_duration,
17
+ parse_timeout_ms,
18
+ truncate_string,
19
+ )
20
+ from elizaos_plugin_cli import PLUGIN_NAME
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Duration parsing
25
+ # ---------------------------------------------------------------------------
26
+
27
+
28
+ def test_parse_duration_seconds() -> None:
29
+ assert parse_duration("1s") == 1_000
30
+ assert parse_duration("90s") == 90_000
31
+ assert parse_duration("0s") == 0
32
+
33
+
34
+ def test_parse_duration_minutes() -> None:
35
+ assert parse_duration("30m") == 30 * 60_000
36
+ assert parse_duration("1min") == 60_000
37
+
38
+
39
+ def test_parse_duration_hours() -> None:
40
+ assert parse_duration("1h") == 3_600_000
41
+ assert parse_duration("2hr") == 7_200_000
42
+
43
+
44
+ def test_parse_duration_days() -> None:
45
+ assert parse_duration("2d") == 2 * 86_400_000
46
+ assert parse_duration("1day") == 86_400_000
47
+
48
+
49
+ def test_parse_duration_compound() -> None:
50
+ assert parse_duration("1h30m") == 5_400_000 # 90 minutes
51
+ assert parse_duration("2d12h") == 216_000_000 # 60 hours
52
+
53
+
54
+ def test_parse_duration_milliseconds() -> None:
55
+ assert parse_duration("500ms") == 500
56
+ assert parse_duration("1000") == 1000
57
+
58
+
59
+ def test_parse_duration_invalid() -> None:
60
+ assert parse_duration("") is None
61
+ assert parse_duration("abc") is None
62
+ assert parse_duration("1x") is None
63
+ assert parse_duration("h1") is None
64
+
65
+
66
+ def test_parse_duration_whitespace() -> None:
67
+ assert parse_duration(" 1h ") == 3_600_000
68
+ assert parse_duration(" 30m ") == 1_800_000
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Format utilities
73
+ # ---------------------------------------------------------------------------
74
+
75
+
76
+ def test_format_duration_ranges() -> None:
77
+ assert format_duration(450) == "450ms"
78
+ assert format_duration(1500) == "1.5s"
79
+ assert format_duration(90_000) == "1.5m"
80
+ assert format_duration(5_400_000) == "1.5h"
81
+ assert format_duration(172_800_000) == "2.0d"
82
+
83
+
84
+ def test_format_bytes_various() -> None:
85
+ assert format_bytes(0) == "0 B"
86
+ assert format_bytes(512) == "512 B"
87
+ assert format_bytes(1024) == "1.0 KB"
88
+ assert format_bytes(1536) == "1.5 KB"
89
+ assert format_bytes(1048576) == "1.0 MB"
90
+ assert format_bytes(1073741824) == "1.0 GB"
91
+ assert format_bytes(1099511627776) == "1.0 TB"
92
+
93
+
94
+ def test_truncate_string() -> None:
95
+ assert truncate_string("hello", 10) == "hello"
96
+ assert truncate_string("hello world!", 8) == "hello..."
97
+ assert truncate_string("ab", 2) == "ab"
98
+ assert truncate_string("abcdef", 3) == "..."
99
+
100
+
101
+ def test_parse_timeout_ms() -> None:
102
+ assert parse_timeout_ms("30s", 5000) == 30_000
103
+ assert parse_timeout_ms(None, 5000) == 5000
104
+ assert parse_timeout_ms("invalid!!", 5000) == 5000
105
+
106
+
107
+ def test_format_cli_command_basic() -> None:
108
+ assert format_cli_command("run") == "elizaos run"
109
+
110
+
111
+ def test_format_cli_command_full() -> None:
112
+ result = format_cli_command("run", cli_name="otto", profile="dev", env="staging")
113
+ assert result == "otto --profile dev --env staging run"
114
+
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # Type tests
118
+ # ---------------------------------------------------------------------------
119
+
120
+
121
+ def test_cli_context_creation() -> None:
122
+ ctx = CliContext(
123
+ program_name="otto",
124
+ version="2.0.0",
125
+ description="Otto CLI",
126
+ workspace_dir="/home/user/project",
127
+ )
128
+ assert ctx.program_name == "otto"
129
+ assert ctx.version == "2.0.0"
130
+ assert ctx.workspace_dir == "/home/user/project"
131
+
132
+
133
+ def test_progress_reporter() -> None:
134
+ progress = ProgressReporter(total=10, message="Starting...")
135
+ assert progress.fraction() == 0.0
136
+ assert not progress.is_complete()
137
+ assert progress.display() == "[0/10] Starting..."
138
+
139
+ progress.advance("Step 1 done")
140
+ assert progress.current == 1
141
+ assert progress.display() == "[1/10] Step 1 done"
142
+
143
+ progress.set(10, "Done!")
144
+ assert progress.is_complete()
145
+ assert progress.fraction() == 1.0
146
+
147
+
148
+ def test_progress_reporter_unknown_total() -> None:
149
+ progress = ProgressReporter(total=0, message="Processing...")
150
+ assert progress.fraction() is None
151
+ assert not progress.is_complete()
152
+ assert progress.display() == "[0] Processing..."
153
+
154
+ progress.advance("Item processed")
155
+ assert progress.display() == "[1] Item processed"
156
+
157
+
158
+ def test_common_command_options_default() -> None:
159
+ opts = CommonCommandOptions()
160
+ assert opts.json is False
161
+ assert opts.verbose is False
162
+ assert opts.quiet is False
163
+ assert opts.force is False
164
+ assert opts.dry_run is False
165
+
166
+
167
+ def test_plugin_constants() -> None:
168
+ assert PLUGIN_NAME == "cli"
169
+ assert DEFAULT_CLI_NAME == "elizaos"
170
+ assert DEFAULT_CLI_VERSION == "1.0.0"