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.
- elizaos_plugin_cli-2.0.0a4/.gitignore +12 -0
- elizaos_plugin_cli-2.0.0a4/PKG-INFO +24 -0
- elizaos_plugin_cli-2.0.0a4/elizaos_plugin_cli/__init__.py +63 -0
- elizaos_plugin_cli-2.0.0a4/elizaos_plugin_cli/registry.py +99 -0
- elizaos_plugin_cli-2.0.0a4/elizaos_plugin_cli/types.py +224 -0
- elizaos_plugin_cli-2.0.0a4/elizaos_plugin_cli/utils.py +187 -0
- elizaos_plugin_cli-2.0.0a4/pyproject.toml +76 -0
- elizaos_plugin_cli-2.0.0a4/tests/__init__.py +1 -0
- elizaos_plugin_cli-2.0.0a4/tests/conftest.py +30 -0
- elizaos_plugin_cli-2.0.0a4/tests/test_registry.py +141 -0
- elizaos_plugin_cli-2.0.0a4/tests/test_utils.py +170 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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"
|