feat(cli): align actor commands to YAML #412

Merged
freemo merged 1 commits from feature/m3-actor-cli into master 2026-02-24 19:39:45 +00:00
7 changed files with 1041 additions and 10 deletions
+172
View File
@@ -0,0 +1,172 @@
"""ASV benchmarks for Actor CLI command throughput.
Measures the performance of:
- Actor add (config parse + validate)
- Actor list rendering (rich / json / yaml / plain)
- Actor show rendering (rich / json / yaml / plain)
- Actor remove
"""
from __future__ import annotations
import importlib
import json
import os
import sys
import tempfile
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
# Ensure the local *source* tree is importable even when ASV has an
# older build of the package installed.
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
import cleveragents # noqa: E402
importlib.reload(cleveragents)
from typer.testing import CliRunner # noqa: E402
from cleveragents.cli.commands.actor import app as actor_app # noqa: E402
from cleveragents.domain.models.core.actor import Actor # noqa: E402
_VALID_CONFIG = {
"provider": "openai",
"model": "gpt-4",
"temperature": 0.5,
"max_tokens": 256,
}
_runner = CliRunner()
def _mock_actor(
name: str = "local/bench-actor",
provider: str = "openai",
model: str = "gpt-4",
config: dict[str, Any] | None = None,
) -> Actor:
blob = config or {"provider": provider, "model": model}
return Actor(
id=1,
name=name,
provider=provider,
model=model,
config_blob=blob,
config_hash=Actor.compute_hash(blob),
unsafe=False,
is_built_in=False,
is_default=False,
)
class ActorCLIAddSuite:
"""Benchmark actor add --config throughput."""
def setup(self) -> None:
fd, self._path = tempfile.mkstemp(suffix=".json")
with os.fdopen(fd, "w") as fh:
json.dump(_VALID_CONFIG, fh)
self._mock_registry = MagicMock()
self._mock_registry.upsert_actor.return_value = _mock_actor()
self._patcher = patch(
"cleveragents.cli.commands.actor._get_services",
return_value=(MagicMock(), self._mock_registry),
)
self._patcher.start()
def teardown(self) -> None:
self._patcher.stop()
Path(self._path).unlink(missing_ok=True)
def time_add_from_config(self) -> None:
"""Benchmark add --config end-to-end."""
_runner.invoke(actor_app, ["add", "local/bench", "--config", self._path])
class ActorCLIListSuite:
"""Benchmark actor list throughput."""
def setup(self) -> None:
self._mock_registry = MagicMock()
self._mock_registry.list_actors.return_value = [
_mock_actor(f"local/actor-{i}") for i in range(50)
]
self._patcher = patch(
"cleveragents.cli.commands.actor._get_services",
return_value=(MagicMock(), self._mock_registry),
)
self._patcher.start()
def teardown(self) -> None:
self._patcher.stop()
def time_list_rich(self) -> None:
"""Benchmark listing actors (rich default)."""
_runner.invoke(actor_app, ["list"])
def time_list_json(self) -> None:
"""Benchmark listing actors (json format)."""
_runner.invoke(actor_app, ["list", "--format", "json"])
def time_list_yaml(self) -> None:
"""Benchmark listing actors (yaml format)."""
_runner.invoke(actor_app, ["list", "--format", "yaml"])
def time_list_plain(self) -> None:
"""Benchmark listing actors (plain format)."""
_runner.invoke(actor_app, ["list", "--format", "plain"])
class ActorCLIShowSuite:
"""Benchmark actor show throughput."""
def setup(self) -> None:
self._mock_registry = MagicMock()
self._mock_registry.get_actor.return_value = _mock_actor()
self._patcher = patch(
"cleveragents.cli.commands.actor._get_services",
return_value=(MagicMock(), self._mock_registry),
)
self._patcher.start()
def teardown(self) -> None:
self._patcher.stop()
def time_show_rich(self) -> None:
"""Benchmark showing actor (rich default)."""
_runner.invoke(actor_app, ["show", "local/bench-actor"])
def time_show_json(self) -> None:
"""Benchmark showing actor (json format)."""
_runner.invoke(actor_app, ["show", "local/bench-actor", "--format", "json"])
def time_show_yaml(self) -> None:
"""Benchmark showing actor (yaml format)."""
_runner.invoke(actor_app, ["show", "local/bench-actor", "--format", "yaml"])
def time_show_plain(self) -> None:
"""Benchmark showing actor (plain format)."""
_runner.invoke(actor_app, ["show", "local/bench-actor", "--format", "plain"])
class ActorCLIRemoveSuite:
"""Benchmark actor remove throughput."""
def setup(self) -> None:
self._mock_registry = MagicMock()
self._patcher = patch(
"cleveragents.cli.commands.actor._get_services",
return_value=(MagicMock(), self._mock_registry),
)
self._patcher.start()
def teardown(self) -> None:
self._patcher.stop()
def time_remove(self) -> None:
"""Benchmark removing an actor."""
_runner.invoke(actor_app, ["remove", "local/bench-actor"])
+180
View File
@@ -0,0 +1,180 @@
# Actor CLI Reference
The `agents actor` command group manages actor configurations for the
CleverAgents v3 actor system.
## Commands
| Command | Description |
|--------------------------|----------------------------------------------|
| `agents actor add` | Add actor from YAML/JSON config file |
| `agents actor update` | Update an existing actor |
| `agents actor remove` | Remove a custom actor by namespaced name |
| `agents actor list` | List all registered actors |
| `agents actor show` | Show actor details |
| `agents actor set-default` | Set the default actor |
| `agents actor run` | Run the reactive network with actor configs |
## Namespaced Names
Actor names follow the `[[server:]namespace/]name` format. When no
namespace is provided, the name defaults to `local/`. Built-in actors
use `<provider>/<model>` (e.g. `openai/gpt-4`).
## `agents actor add`
Add a new actor from a YAML or JSON configuration file.
### Synopsis
```bash
agents actor add <NAME> --config <FILE> [--update] [--unsafe] [--set-default] [--option key=value] [--format FORMAT]
```
### YAML Configuration File
```yaml
provider: openai
model: gpt-4
temperature: 0.7
max_tokens: 1024
options:
stream: true
```
### Examples
```bash
# Add an actor from YAML config
agents actor add local/my-actor --config ./actors/my-actor.yaml
# Add or update an existing actor
agents actor add local/my-actor --config ./actors/my-actor.yaml --update
# Add with option overrides
agents actor add local/my-actor --config actor.yaml --option temperature=0.9
# Add with JSON output
agents actor add local/my-actor --config actor.yaml --format json
```
## `agents actor update`
Update an existing actor configuration.
### Synopsis
```bash
agents actor update <NAME> [--config <FILE>] [--unsafe|--safe] [--set-default] [--option key=value] [--format FORMAT]
```
### Examples
```bash
# Update actor with new config
agents actor update local/my-actor --config ./actors/updated.yaml
# Mark actor as safe
agents actor update local/my-actor --safe
# Update with JSON output
agents actor update local/my-actor --format json
```
## `agents actor remove`
Remove a custom actor by its namespaced name.
### Synopsis
```bash
agents actor remove <NAME>
```
### Examples
```bash
agents actor remove local/my-actor
```
## `agents actor list`
List all registered actors.
### Synopsis
```bash
agents actor list [--format FORMAT]
```
### Output Formats
| Format | Description |
|---------|--------------------------------------|
| `rich` | Rich table with colours (default) |
| `json` | JSON array of actor objects |
| `yaml` | YAML list of actor objects |
| `plain` | Plain text key-value pairs |
| `table` | ASCII table without Rich styling |
### Examples
```bash
# Default rich table
agents actor list
# JSON output for scripting
agents actor list --format json
# YAML output
agents actor list --format yaml
```
## `agents actor show`
Show details for a specific actor.
### Synopsis
```bash
agents actor show <NAME> [--format FORMAT]
```
### Examples
```bash
# Rich panel (default)
agents actor show local/my-actor
# JSON output
agents actor show local/my-actor --format json
# YAML output
agents actor show openai/gpt-4 --format yaml
```
## `agents actor set-default`
Set the default actor used when no actor is specified.
### Synopsis
```bash
agents actor set-default <NAME>
```
### Examples
```bash
agents actor set-default openai/gpt-4
```
## Error Handling
| Error | Cause |
|----------------------------|------------------------------------------|
| `Config file not found` | Specified config file doesn't exist |
| `Config must be a JSON/YAML object` | Config file is not a valid object |
| `Config file is required` | No `--config` flag provided for `add` |
| `Actor config is marked unsafe` | Unsafe config without `--unsafe` flag |
| `Actor not found` | Named actor doesn't exist |
+63
View File
@@ -0,0 +1,63 @@
Feature: Actor CLI YAML-first alignment
As a developer
I want actor CLI commands aligned to YAML-first configs
So that actors can be managed with namespaced names and multiple output formats
Scenario: Actor list outputs JSON format
Given an actor CLI runner
When I run actor list with two actors using format json
Then the actor list output should be valid JSON
Scenario: Actor list outputs YAML format
Given an actor CLI runner
When I run actor list with two actors using format yaml
Then the actor list output should contain YAML keys
Scenario: Actor list outputs plain format
Given an actor CLI runner
When I run actor list with two actors using format plain
Then the actor list output should contain plain text fields
Scenario: Actor show outputs JSON format
Given an actor CLI runner
When I run actor show with format json
Then the actor show output should be valid JSON with actor fields
Scenario: Actor show outputs YAML format
Given an actor CLI runner
When I run actor show with format yaml
Then the actor show output should contain YAML actor keys
Scenario: Actor show outputs plain format
Given an actor CLI runner
When I run actor show with format plain
Then the actor show output should contain plain actor fields
Scenario: Actor add with update flag
Given an actor CLI runner
And I have an actor JSON config file
When I run actor add with update flag
Then the actor add should pass the loaded config
And the output should say Actor updated
Scenario: Actor add outputs JSON format
Given an actor CLI runner
And I have an actor JSON config file
When I run actor add with format json
Then the actor add output should contain JSON data
Scenario: Actor add with YAML config file and format
Given an actor CLI runner
And I have an actor YAML-only config file
When I run actor add with format yaml
Then the actor add output should contain YAML data
Scenario: Actor remove by namespaced name
Given an actor CLI runner
When I run actor remove with namespaced name
Then the actor remove should succeed for namespaced name
Scenario: Actor update outputs JSON format
Given an actor CLI runner
When I run actor update with format json
Then the actor update output should contain JSON data
+357
View File
@@ -0,0 +1,357 @@
# pyright: reportRedeclaration=false
"""Step definitions for the Actor CLI YAML-first alignment feature."""
from __future__ import annotations
import json
from typing import Any
from unittest.mock import MagicMock, patch
from behave import then, when
from cleveragents.cli.commands.actor import app as actor_app
from cleveragents.domain.models.core.actor import Actor
def _make_actor(
*,
name: str = "local/test-actor",
provider: str = "default-provider",
model: str = "default-model",
config: dict[str, Any] | None = None,
graph_descriptor: dict[str, Any] | None = None,
unsafe: bool = False,
is_default: bool = False,
is_built_in: bool = False,
) -> Actor:
blob = config or {}
return Actor(
id=1,
name=name,
provider=provider,
model=model,
config_blob=blob,
config_hash=Actor.compute_hash(blob),
graph_descriptor=graph_descriptor,
unsafe=unsafe,
is_built_in=is_built_in,
is_default=is_default,
)
# ------------------------------------------------------------------
# List with format
# ------------------------------------------------------------------
@when("I run actor list with two actors using format json")
def step_list_json(context: Any) -> None:
actors = [
_make_actor(name="local/first", provider="p1", model="m1"),
_make_actor(name="local/second", provider="p2", model="m2", unsafe=True),
]
with patch("cleveragents.cli.commands.actor._get_services") as mock_svc:
mock_svc.return_value = (
MagicMock(),
MagicMock(list_actors=MagicMock(return_value=actors)),
)
context.result = context.runner.invoke(actor_app, ["list", "--format", "json"])
context.actors = actors
@when("I run actor list with two actors using format yaml")
def step_list_yaml(context: Any) -> None:
actors = [
_make_actor(name="local/first", provider="p1", model="m1"),
_make_actor(name="local/second", provider="p2", model="m2"),
]
with patch("cleveragents.cli.commands.actor._get_services") as mock_svc:
mock_svc.return_value = (
MagicMock(),
MagicMock(list_actors=MagicMock(return_value=actors)),
)
context.result = context.runner.invoke(actor_app, ["list", "--format", "yaml"])
context.actors = actors
@when("I run actor list with two actors using format plain")
def step_list_plain(context: Any) -> None:
actors = [
_make_actor(name="local/first", provider="p1", model="m1"),
_make_actor(name="local/second", provider="p2", model="m2"),
]
with patch("cleveragents.cli.commands.actor._get_services") as mock_svc:
mock_svc.return_value = (
MagicMock(),
MagicMock(list_actors=MagicMock(return_value=actors)),
)
context.result = context.runner.invoke(actor_app, ["list", "--format", "plain"])
context.actors = actors
@then("the actor list output should be valid JSON")
def step_list_json_valid(context: Any) -> None:
assert context.result.exit_code == 0
data = json.loads(context.result.output.strip())
assert isinstance(data, list)
assert len(data) == 2
assert data[0]["name"] == "local/first"
assert data[1]["name"] == "local/second"
@then("the actor list output should contain YAML keys")
def step_list_yaml_valid(context: Any) -> None:
assert context.result.exit_code == 0
output = context.result.output
assert "name:" in output
assert "provider:" in output
assert "local/first" in output
@then("the actor list output should contain plain text fields")
def step_list_plain_valid(context: Any) -> None:
assert context.result.exit_code == 0
output = context.result.output
assert "name:" in output
assert "provider:" in output
assert "local/first" in output
# ------------------------------------------------------------------
# Show with format
# ------------------------------------------------------------------
@when("I run actor show with format json")
def step_show_json(context: Any) -> None:
actor = _make_actor(name="local/show-json", provider="openai", model="gpt-4")
with patch("cleveragents.cli.commands.actor._get_services") as mock_svc:
registry = MagicMock()
registry.get_actor.return_value = actor
mock_svc.return_value = (MagicMock(), registry)
context.result = context.runner.invoke(
actor_app, ["show", actor.name, "--format", "json"]
)
context.actor = actor
@when("I run actor show with format yaml")
def step_show_yaml(context: Any) -> None:
actor = _make_actor(name="local/show-yaml", provider="openai", model="gpt-4")
with patch("cleveragents.cli.commands.actor._get_services") as mock_svc:
registry = MagicMock()
registry.get_actor.return_value = actor
mock_svc.return_value = (MagicMock(), registry)
context.result = context.runner.invoke(
actor_app, ["show", actor.name, "--format", "yaml"]
)
context.actor = actor
@when("I run actor show with format plain")
def step_show_plain(context: Any) -> None:
actor = _make_actor(name="local/show-plain", provider="openai", model="gpt-4")
with patch("cleveragents.cli.commands.actor._get_services") as mock_svc:
registry = MagicMock()
registry.get_actor.return_value = actor
mock_svc.return_value = (MagicMock(), registry)
context.result = context.runner.invoke(
actor_app, ["show", actor.name, "--format", "plain"]
)
context.actor = actor
@then("the actor show output should be valid JSON with actor fields")
def step_show_json_valid(context: Any) -> None:
assert context.result.exit_code == 0
data = json.loads(context.result.output.strip())
assert isinstance(data, dict)
assert data["name"] == "local/show-json"
assert data["provider"] == "openai"
assert data["model"] == "gpt-4"
assert "config_hash" in data
assert "updated_at" in data
@then("the actor show output should contain YAML actor keys")
def step_show_yaml_valid(context: Any) -> None:
assert context.result.exit_code == 0
output = context.result.output
assert "name:" in output
assert "local/show-yaml" in output
assert "provider:" in output
@then("the actor show output should contain plain actor fields")
def step_show_plain_valid(context: Any) -> None:
assert context.result.exit_code == 0
output = context.result.output
assert "name:" in output
assert "local/show-plain" in output
assert "provider:" in output
# ------------------------------------------------------------------
# Add with --update flag
# ------------------------------------------------------------------
@when("I run actor add with update flag")
def step_add_update(context: Any) -> None:
with patch("cleveragents.cli.commands.actor._get_services") as mock_svc:
mock_registry = MagicMock()
mock_service = MagicMock()
mock_registry.ensure_built_in_actors.return_value = []
mock_actor = _make_actor(config=context.actor_config_data)
mock_registry.upsert_actor.return_value = mock_actor
mock_svc.return_value = (mock_service, mock_registry)
context.result = context.runner.invoke(
actor_app,
[
"add",
"test-actor",
"--config",
str(context.actor_config_path),
"--update",
],
)
context.mock_actor_registry = mock_registry
context.expected_config_blob = dict(context.actor_config_data)
context.expected_config_blob.setdefault("unsafe", False)
context.expected_allow_unsafe = False
context.expected_error = None
@then("the output should say Actor updated")
def step_output_actor_updated(context: Any) -> None:
assert context.result.exit_code == 0
assert "Actor updated" in context.result.output
# ------------------------------------------------------------------
# Add with --format
# ------------------------------------------------------------------
@when("I run actor add with format json")
def step_add_format_json(context: Any) -> None:
with patch("cleveragents.cli.commands.actor._get_services") as mock_svc:
mock_registry = MagicMock()
mock_service = MagicMock()
mock_registry.ensure_built_in_actors.return_value = []
mock_actor = _make_actor(config=context.actor_config_data)
mock_registry.upsert_actor.return_value = mock_actor
mock_svc.return_value = (mock_service, mock_registry)
context.result = context.runner.invoke(
actor_app,
[
"add",
"test-actor",
"--config",
str(context.actor_config_path),
"--format",
"json",
],
)
@when("I run actor add with format yaml")
def step_add_format_yaml(context: Any) -> None:
with patch("cleveragents.cli.commands.actor._get_services") as mock_svc:
mock_registry = MagicMock()
mock_service = MagicMock()
mock_registry.ensure_built_in_actors.return_value = []
mock_actor = _make_actor(config=context.actor_config_data)
mock_registry.upsert_actor.return_value = mock_actor
mock_svc.return_value = (mock_service, mock_registry)
context.result = context.runner.invoke(
actor_app,
[
"add",
"test-actor",
"--config",
str(context.actor_config_path),
"--format",
"yaml",
],
)
@then("the actor add output should contain JSON data")
def step_add_json_valid(context: Any) -> None:
assert context.result.exit_code == 0
data = json.loads(context.result.output.strip())
assert isinstance(data, dict)
assert "name" in data
@then("the actor add output should contain YAML data")
def step_add_yaml_valid(context: Any) -> None:
assert context.result.exit_code == 0
output = context.result.output
assert "name:" in output
assert "provider:" in output
# ------------------------------------------------------------------
# Remove by namespaced name
# ------------------------------------------------------------------
@when("I run actor remove with namespaced name")
def step_remove_namespaced(context: Any) -> None:
with patch("cleveragents.cli.commands.actor._get_services") as mock_svc:
mock_registry = MagicMock()
mock_svc.return_value = (MagicMock(), mock_registry)
context.result = context.runner.invoke(
actor_app, ["remove", "local/my-custom-actor"]
)
context.mock_actor_registry = mock_registry
@then("the actor remove should succeed for namespaced name")
def step_remove_namespaced_ok(context: Any) -> None:
assert context.result.exit_code == 0
context.mock_actor_registry.remove_actor.assert_called_once_with(
"local/my-custom-actor"
)
assert "Removed actor" in context.result.output
# ------------------------------------------------------------------
# Update with --format
# ------------------------------------------------------------------
@when("I run actor update with format json")
def step_update_format_json(context: Any) -> None:
with patch("cleveragents.cli.commands.actor._get_services") as mock_svc:
mock_registry = MagicMock()
mock_service = MagicMock()
current = _make_actor(
name="local/update-json",
provider="openai",
model="gpt-4",
config={"provider": "openai", "model": "gpt-4"},
)
mock_registry.get_actor.return_value = current
updated = _make_actor(
name=current.name,
provider=current.provider,
model=current.model,
config=current.config_blob,
)
mock_registry.upsert_actor.return_value = updated
mock_svc.return_value = (mock_service, mock_registry)
context.result = context.runner.invoke(
actor_app,
["update", current.name, "--format", "json"],
)
@then("the actor update output should contain JSON data")
def step_update_json_valid(context: Any) -> None:
assert context.result.exit_code == 0
data = json.loads(context.result.output.strip())
assert isinstance(data, dict)
assert data["name"] == "local/update-json"
+33
View File
@@ -0,0 +1,33 @@
*** Settings ***
Documentation Integration tests for actor show CLI output fields
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER} ${CURDIR}/helper_actor_cli_show.py
*** Test Cases ***
Actor Show JSON Contains Required Fields
[Documentation] Verify that ``actor show --format json`` returns all required fields
${result}= Run Process ${PYTHON} ${HELPER} show-json-fields cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} actor-cli-show-json-fields-ok
Actor Show YAML Produces Valid Output
[Documentation] Verify that ``actor show --format yaml`` produces YAML output
${result}= Run Process ${PYTHON} ${HELPER} show-yaml-output cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} actor-cli-show-yaml-output-ok
Actor List JSON Returns Array
[Documentation] Verify that ``actor list --format json`` returns a JSON array
${result}= Run Process ${PYTHON} ${HELPER} list-json-format cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} actor-cli-list-json-format-ok
+128
View File
@@ -0,0 +1,128 @@
"""Helper script for actor CLI show output fields Robot test."""
from __future__ import annotations
import json
import sys
from typing import Any
from unittest.mock import MagicMock, patch
from typer.testing import CliRunner
from cleveragents.cli.commands.actor import app as actor_app
from cleveragents.domain.models.core.actor import Actor
def _make_actor(
*,
name: str = "local/robot-actor",
provider: str = "openai",
model: str = "gpt-4",
config: dict[str, Any] | None = None,
unsafe: bool = False,
is_default: bool = False,
is_built_in: bool = False,
) -> Actor:
blob = config or {"provider": provider, "model": model}
return Actor(
id=1,
name=name,
provider=provider,
model=model,
config_blob=blob,
config_hash=Actor.compute_hash(blob),
unsafe=unsafe,
is_built_in=is_built_in,
is_default=is_default,
)
def test_show_json_fields() -> None:
"""Verify show --format json contains expected fields."""
runner = CliRunner()
actor = _make_actor()
with patch("cleveragents.cli.commands.actor._get_services") as mock_svc:
registry = MagicMock()
registry.get_actor.return_value = actor
mock_svc.return_value = (MagicMock(), registry)
result = runner.invoke(actor_app, ["show", actor.name, "--format", "json"])
assert result.exit_code == 0, (
f"exit_code={result.exit_code}, output={result.output}"
)
data = json.loads(result.output.strip())
required_fields = [
"name",
"provider",
"model",
"unsafe",
"is_default",
"is_built_in",
"config_hash",
"schema_version",
"updated_at",
]
missing = [f for f in required_fields if f not in data]
assert not missing, f"Missing fields: {missing}"
assert data["name"] == "local/robot-actor"
assert data["provider"] == "openai"
assert data["model"] == "gpt-4"
print("actor-cli-show-json-fields-ok")
def test_show_yaml_output() -> None:
"""Verify show --format yaml produces valid YAML."""
runner = CliRunner()
actor = _make_actor()
with patch("cleveragents.cli.commands.actor._get_services") as mock_svc:
registry = MagicMock()
registry.get_actor.return_value = actor
mock_svc.return_value = (MagicMock(), registry)
result = runner.invoke(actor_app, ["show", actor.name, "--format", "yaml"])
assert result.exit_code == 0
assert "name:" in result.output
assert "local/robot-actor" in result.output
print("actor-cli-show-yaml-output-ok")
def test_list_json_format() -> None:
"""Verify list --format json returns array of actors."""
runner = CliRunner()
actors = [
_make_actor(name="local/a1", provider="p1", model="m1"),
_make_actor(name="local/a2", provider="p2", model="m2"),
]
with patch("cleveragents.cli.commands.actor._get_services") as mock_svc:
registry = MagicMock()
registry.list_actors.return_value = actors
mock_svc.return_value = (MagicMock(), registry)
result = runner.invoke(actor_app, ["list", "--format", "json"])
assert result.exit_code == 0
data = json.loads(result.output.strip())
assert isinstance(data, list)
assert len(data) == 2
print("actor-cli-list-json-format-ok")
def main() -> None:
command = sys.argv[1] if len(sys.argv) > 1 else "show-json-fields"
dispatch = {
"show-json-fields": test_show_json_fields,
"show-yaml-output": test_show_yaml_output,
"list-json-format": test_list_json_format,
}
fn = dispatch.get(command)
if fn is None:
print(f"Unknown command: {command}", file=sys.stderr)
sys.exit(1)
fn()
if __name__ == "__main__":
main()
+108 -10
View File
@@ -12,6 +12,7 @@ from rich.table import Table
from cleveragents.actor.config import ActorConfiguration
from cleveragents.application.container import get_container
from cleveragents.cli.formatting import OutputFormat, format_output
from cleveragents.core.exceptions import (
BusinessRuleViolation,
CleverAgentsError,
@@ -33,6 +34,9 @@ app = typer.Typer(
)
console = Console()
# Reusable --format option description
_FORMAT_HELP = "Output format: json, yaml, plain, table, or rich (default: rich)"
@app.command()
def run(
@@ -286,7 +290,41 @@ def _canonicalize_actor_config(
return resolved, canonical_blob, requires_confirmation
def _print_actor(actor: Actor, title: str = "Actor") -> None:
def _actor_spec_dict(actor: Actor) -> dict[str, object]:
"""Return actor data using spec field names for format_output.
Keys: name, provider, model, unsafe, is_default, is_built_in,
config_hash, schema_version, updated_at.
"""
result: dict[str, object] = {
"name": actor.name,
"provider": actor.provider,
"model": actor.model,
"unsafe": actor.unsafe,
"is_default": actor.is_default,
"is_built_in": actor.is_built_in,
"config_hash": actor.config_hash,
"schema_version": actor.schema_version,
"updated_at": actor.updated_at.isoformat(),
}
if actor.graph_descriptor is not None:
result["graph_descriptor"] = actor.graph_descriptor
if actor.config_blob:
result["config_blob"] = actor.config_blob
return result
def _print_actor(
actor: Actor,
title: str = "Actor",
fmt: str = OutputFormat.RICH.value,
) -> None:
"""Print actor details in the requested format."""
if fmt != OutputFormat.RICH.value:
data = _actor_spec_dict(actor)
console.print(format_output(data, fmt))
return
details = (
f"[bold]Name:[/bold] {actor.name}\n"
f"[bold]Provider:[/bold] {actor.provider}\n"
@@ -313,6 +351,10 @@ def add(
set_default: Annotated[
bool, typer.Option("--set-default", help="Set this actor as default")
] = False,
update_existing: Annotated[
bool,
typer.Option("--update", help="Update actor if it already exists"),
] = False,
option: Annotated[
list[str] | None,
typer.Option(
@@ -321,8 +363,22 @@ def add(
help="Override or add actor option (key=value). Repeat for multiple.",
),
] = None,
fmt: Annotated[
str,
typer.Option("--format", "-f", help=_FORMAT_HELP),
] = "rich",
) -> None:
"""Add a new actor configuration."""
"""Add a new actor configuration.
Actors are defined via YAML configuration files (``--config``).
The actor name follows the ``[[server:]namespace/]name`` format;
names without a namespace default to ``local/``.
Examples:
agents actor add local/my-actor --config ./actors/my-actor.yaml
agents actor add local/my-actor --config ./actors/my-actor.yaml --update
agents actor add local/my-actor --config actor.yaml --format json
"""
service, registry = _get_services()
config_blob = _load_config(config)
option_overrides = _parse_option_overrides(option)
@@ -368,7 +424,8 @@ def add(
unsafe=resolved.unsafe,
set_default=set_default,
)
_print_actor(actor, title="Actor added")
title = "Actor updated" if update_existing else "Actor added"
_print_actor(actor, title=title, fmt=fmt)
except (ValidationError, BusinessRuleViolation) as exc:
console.print(f"[red]Error:[/red] {exc}")
raise typer.Abort() from exc
@@ -398,6 +455,10 @@ def update(
help="Override or add actor option (key=value). Repeat for multiple.",
),
] = None,
fmt: Annotated[
str,
typer.Option("--format", "-f", help=_FORMAT_HELP),
] = "rich",
) -> None:
"""Update an existing actor."""
if unsafe and safe:
@@ -485,7 +546,7 @@ def update(
set_default=set_default,
is_built_in=current.is_built_in,
)
_print_actor(actor, title="Actor updated")
_print_actor(actor, title="Actor updated", fmt=fmt)
except (ValidationError, BusinessRuleViolation) as exc:
console.print(f"[red]Error:[/red] {exc}")
raise typer.Abort() from exc
@@ -493,7 +554,10 @@ def update(
@app.command()
def remove(name: Annotated[str, typer.Argument(help="Actor name to remove")]) -> None:
"""Remove a custom actor."""
"""Remove a custom actor.
Specify the namespaced name (e.g. ``local/my-actor``).
"""
service, registry = _get_services()
try:
@@ -508,8 +572,22 @@ def remove(name: Annotated[str, typer.Argument(help="Actor name to remove")]) ->
@app.command("list")
def list_actors() -> None:
"""List all actors."""
def list_actors(
fmt: Annotated[
str,
typer.Option("--format", "-f", help=_FORMAT_HELP),
] = "rich",
) -> None:
"""List all actors.
Shows actors with optional output format selection.
Examples:
agents actor list
agents actor list --format json
agents actor list --format yaml
agents actor list --format plain
"""
service, registry = _get_services()
actors = registry.list_actors() if registry else service.list_actors()
@@ -517,6 +595,12 @@ def list_actors() -> None:
console.print("[yellow]No actors configured.[/yellow]")
return
# Non-rich formats use the formatting helper
if fmt != OutputFormat.RICH.value:
data = [_actor_spec_dict(a) for a in actors]
console.print(format_output(data, fmt))
return
table = Table(title=f"Actors ({len(actors)} total)")
table.add_column("Name", style="cyan")
table.add_column("Provider", style="magenta")
@@ -541,13 +625,27 @@ def list_actors() -> None:
@app.command()
def show(name: Annotated[str, typer.Argument(help="Actor name to show")]) -> None:
"""Show details for an actor."""
def show(
name: Annotated[str, typer.Argument(help="Actor name to show")],
fmt: Annotated[
str,
typer.Option("--format", "-f", help=_FORMAT_HELP),
] = "rich",
) -> None:
"""Show details for an actor.
Specify the namespaced name (e.g. ``local/my-actor``).
Examples:
agents actor show local/my-actor
agents actor show local/my-actor --format json
agents actor show local/my-actor --format yaml
"""
service, registry = _get_services()
try:
actor = registry.get_actor(name) if registry else service.get_actor(name)
_print_actor(actor, title="Actor details")
_print_actor(actor, title="Actor details", fmt=fmt)
except (ValidationError, NotFoundError) as exc:
console.print(f"[red]Error:[/red] {exc}")
raise typer.Abort() from exc