opensell-cli 0.1.0__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.
- opensell_cli-0.1.0/.gitignore +52 -0
- opensell_cli-0.1.0/PKG-INFO +10 -0
- opensell_cli-0.1.0/README.md +36 -0
- opensell_cli-0.1.0/opensell_cli/__init__.py +0 -0
- opensell_cli-0.1.0/opensell_cli/catalog.py +39 -0
- opensell_cli-0.1.0/opensell_cli/cli.py +76 -0
- opensell_cli-0.1.0/opensell_cli/execute.py +26 -0
- opensell_cli-0.1.0/opensell_cli/output.py +34 -0
- opensell_cli-0.1.0/opensell_cli/schema_to_click.py +38 -0
- opensell_cli-0.1.0/pyproject.toml +24 -0
- opensell_cli-0.1.0/tests/__init__.py +0 -0
- opensell_cli-0.1.0/tests/conftest.py +9 -0
- opensell_cli-0.1.0/tests/test_catalog.py +28 -0
- opensell_cli-0.1.0/tests/test_cli_e2e.py +55 -0
- opensell_cli-0.1.0/tests/test_execute.py +41 -0
- opensell_cli-0.1.0/tests/test_output.py +29 -0
- opensell_cli-0.1.0/tests/test_schema_to_click.py +44 -0
- opensell_cli-0.1.0/uv.lock +735 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# .omx 内部
|
|
2
|
+
.omx/
|
|
3
|
+
|
|
4
|
+
# Python
|
|
5
|
+
__pycache__/
|
|
6
|
+
*.py[cod]
|
|
7
|
+
*.egg-info/
|
|
8
|
+
.venv/
|
|
9
|
+
.pytest_cache/
|
|
10
|
+
.mypy_cache/
|
|
11
|
+
.ruff_cache/
|
|
12
|
+
.coverage
|
|
13
|
+
htmlcov/
|
|
14
|
+
|
|
15
|
+
# Node
|
|
16
|
+
node_modules/
|
|
17
|
+
.next/
|
|
18
|
+
out/
|
|
19
|
+
dist/
|
|
20
|
+
*.tsbuildinfo
|
|
21
|
+
.turbo/
|
|
22
|
+
|
|
23
|
+
# 环境与密钥
|
|
24
|
+
.env
|
|
25
|
+
.env.*
|
|
26
|
+
!.env.example
|
|
27
|
+
!.env.*.example
|
|
28
|
+
*.pem
|
|
29
|
+
*.key
|
|
30
|
+
secrets/
|
|
31
|
+
thisisserver.conf
|
|
32
|
+
|
|
33
|
+
# IDE / OS
|
|
34
|
+
.DS_Store
|
|
35
|
+
.vscode/
|
|
36
|
+
.idea/
|
|
37
|
+
*.swp
|
|
38
|
+
|
|
39
|
+
# 构建产物
|
|
40
|
+
build/
|
|
41
|
+
*.log
|
|
42
|
+
|
|
43
|
+
# 本地研究与会话上下文(不进 grant 仓库)
|
|
44
|
+
/arc/
|
|
45
|
+
*-context.txt
|
|
46
|
+
.superpowers/
|
|
47
|
+
|
|
48
|
+
# 本地工具/IDE 配置与录制源(不进 grant 仓库)
|
|
49
|
+
.qoder/
|
|
50
|
+
.mcp.json
|
|
51
|
+
.codegraph/
|
|
52
|
+
docs/arc/recordings/
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: opensell-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: OPENSELL marketplace CLI for AI agents
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: aixianyu-mcp
|
|
7
|
+
Requires-Dist: click>=8.1
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
|
|
10
|
+
Requires-Dist: pytest>=8.2.0; extra == 'dev'
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# opensell-cli
|
|
2
|
+
|
|
3
|
+
A CLI front door to the OPENSELL marketplace for AI agents — the same
|
|
4
|
+
capabilities as the `aixianyu-mcp` MCP server, as shell commands. Commands are
|
|
5
|
+
generated from the shared `TOOL_REGISTRY`, so MCP and CLI never drift.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
uvx opensell-cli # or: pip install opensell-cli
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Configure
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
export AIXIANYU_BASE_URL="https://<host>"
|
|
17
|
+
export AIXIANYU_AGENT_TOKEN="ats_xxx" # from /console/agent-tokens
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Use
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
opensell catalog # machine-readable command list
|
|
24
|
+
opensell search-items --q "GPT-4o key" --max-price 50
|
|
25
|
+
opensell place-order --item-id 18
|
|
26
|
+
opensell pay-order --order-id 7
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Result contract
|
|
30
|
+
|
|
31
|
+
- Success: JSON payload to stdout, exit 0.
|
|
32
|
+
- Failure: normalized `{"error","message"}` to stderr, non-zero exit (e.g.
|
|
33
|
+
`AGENT_SANDBOX_LIMIT`=4, `UNAUTHORIZED`=2). See `opensell catalog` for scopes.
|
|
34
|
+
|
|
35
|
+
Scopes and per-transaction / balance sandbox limits are enforced by the backend,
|
|
36
|
+
exactly as for the MCP server.
|
|
File without changes
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# opensell_cli/catalog.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
from aixianyu_mcp.registry import TOOL_REGISTRY
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_catalog() -> dict:
|
|
11
|
+
"""The CLI's machine-readable surface — the analog of MCP list_tools."""
|
|
12
|
+
commands = []
|
|
13
|
+
for name, spec in TOOL_REGISTRY.items():
|
|
14
|
+
props = spec.input_schema.get("properties", {})
|
|
15
|
+
required = set(spec.input_schema.get("required", []))
|
|
16
|
+
commands.append(
|
|
17
|
+
{
|
|
18
|
+
"command": name.replace("_", "-"),
|
|
19
|
+
"tool": name,
|
|
20
|
+
"scope": spec.scope,
|
|
21
|
+
"tier": spec.tier,
|
|
22
|
+
"description": spec.description,
|
|
23
|
+
"args": [
|
|
24
|
+
{
|
|
25
|
+
"name": pname,
|
|
26
|
+
"type": pspec.get("type", "string"),
|
|
27
|
+
"required": pname in required,
|
|
28
|
+
"default": pspec.get("default"),
|
|
29
|
+
}
|
|
30
|
+
for pname, pspec in props.items()
|
|
31
|
+
],
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
return {"commands": commands}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@click.command("catalog", help="Emit the full command surface as JSON (for agents).")
|
|
38
|
+
def catalog_command() -> None:
|
|
39
|
+
click.echo(json.dumps(build_catalog(), ensure_ascii=False, indent=2))
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# opensell_cli/cli.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from aixianyu_mcp.errors import MCPToolError
|
|
9
|
+
from aixianyu_mcp.registry import TOOL_REGISTRY, ToolSpec
|
|
10
|
+
|
|
11
|
+
from .catalog import catalog_command
|
|
12
|
+
from .execute import run_tool
|
|
13
|
+
from .output import emit_error, emit_success
|
|
14
|
+
from .schema_to_click import options_for_schema
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _decode_object_args(spec: ToolSpec, args: dict) -> dict:
|
|
18
|
+
"""Return a copy of args with any type:object property (passed as a JSON
|
|
19
|
+
string) decoded. Malformed JSON becomes a clean Click usage error."""
|
|
20
|
+
props = spec.input_schema.get("properties", {})
|
|
21
|
+
out = dict(args)
|
|
22
|
+
for name, pspec in props.items():
|
|
23
|
+
if pspec.get("type") == "object" and isinstance(out.get(name), str):
|
|
24
|
+
try:
|
|
25
|
+
out[name] = json.loads(out[name])
|
|
26
|
+
except json.JSONDecodeError as exc:
|
|
27
|
+
raise click.BadParameter(
|
|
28
|
+
f"must be a valid JSON object ({exc})",
|
|
29
|
+
param_hint=f"--{name.replace('_', '-')}",
|
|
30
|
+
)
|
|
31
|
+
return out
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _make_command(tool_name: str, spec: ToolSpec) -> click.Command:
|
|
35
|
+
def callback(**kwargs):
|
|
36
|
+
ctx = click.get_current_context()
|
|
37
|
+
args = {k: v for k, v in kwargs.items() if v is not None}
|
|
38
|
+
args = _decode_object_args(spec, args)
|
|
39
|
+
try:
|
|
40
|
+
result = asyncio.run(
|
|
41
|
+
run_tool(
|
|
42
|
+
tool_name,
|
|
43
|
+
args,
|
|
44
|
+
base_url=ctx.obj["base_url"],
|
|
45
|
+
token=ctx.obj["token"],
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
emit_success(result)
|
|
49
|
+
except MCPToolError as err:
|
|
50
|
+
emit_error(err)
|
|
51
|
+
|
|
52
|
+
return click.Command(
|
|
53
|
+
name=tool_name.replace("_", "-"),
|
|
54
|
+
params=options_for_schema(spec.input_schema),
|
|
55
|
+
callback=callback,
|
|
56
|
+
help=f"{spec.description}\n\nRequires scope: {spec.scope}",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_cli() -> click.Group:
|
|
61
|
+
@click.group()
|
|
62
|
+
@click.option("--token", envvar="AIXIANYU_AGENT_TOKEN", default="")
|
|
63
|
+
@click.option("--base-url", envvar="AIXIANYU_BASE_URL", default=None)
|
|
64
|
+
@click.pass_context
|
|
65
|
+
def cli(ctx: click.Context, token: str, base_url: str | None):
|
|
66
|
+
"""OPENSELL marketplace CLI for AI agents."""
|
|
67
|
+
ctx.obj = {"token": token, "base_url": base_url}
|
|
68
|
+
|
|
69
|
+
for name, spec in TOOL_REGISTRY.items():
|
|
70
|
+
cli.add_command(_make_command(name, spec))
|
|
71
|
+
cli.add_command(catalog_command)
|
|
72
|
+
return cli
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def main() -> None:
|
|
76
|
+
build_cli()()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# opensell_cli/execute.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from aixianyu_mcp.errors import MCPError, MCPToolError
|
|
8
|
+
from aixianyu_mcp.rest_client import AixianyuRESTClient
|
|
9
|
+
from aixianyu_mcp.server import TOOL_HANDLERS
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def run_tool(name: str, args: dict, *, base_url: str | None, token: str) -> Any:
|
|
13
|
+
"""Execute a registry tool by reusing the MCP handler, then unwrap the
|
|
14
|
+
TextContent result back into a plain Python object.
|
|
15
|
+
|
|
16
|
+
`name` is the snake_case registry/tool name (e.g. "get_item").
|
|
17
|
+
Raises MCPToolError on backend errors (handled by the caller).
|
|
18
|
+
"""
|
|
19
|
+
client = AixianyuRESTClient(base_url=base_url, agent_token=token)
|
|
20
|
+
if name == "ping":
|
|
21
|
+
return await client.ping()
|
|
22
|
+
handler = TOOL_HANDLERS.get(name)
|
|
23
|
+
if handler is None:
|
|
24
|
+
raise MCPToolError(MCPError.SERVER_ERROR)
|
|
25
|
+
blocks = await handler(client, args)
|
|
26
|
+
return json.loads(blocks[0].text)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# opensell_cli/output.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from aixianyu_mcp.errors import MCPToolError
|
|
9
|
+
|
|
10
|
+
# Stable exit codes per MCPError name. Covers every enum member so a newly
|
|
11
|
+
# added error can never fall through to an undefined code.
|
|
12
|
+
EXIT_CODES: dict[str, int] = {
|
|
13
|
+
"INVALID_ARGS": 1,
|
|
14
|
+
"UNAUTHORIZED": 2,
|
|
15
|
+
"INSUFFICIENT_SCOPE": 3,
|
|
16
|
+
"AGENT_SANDBOX_LIMIT": 4,
|
|
17
|
+
"INSUFFICIENT_BALANCE": 5,
|
|
18
|
+
"RATE_LIMITED": 6,
|
|
19
|
+
"ITEM_NOT_FOUND": 7,
|
|
20
|
+
"CONVERSATION_NOT_FOUND": 7,
|
|
21
|
+
"SERVER_ERROR": 8,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def emit_success(payload: Any) -> None:
|
|
26
|
+
"""Print the tool payload as JSON to stdout and exit 0."""
|
|
27
|
+
sys.stdout.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
|
28
|
+
raise SystemExit(0)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def emit_error(err: MCPToolError) -> None:
|
|
32
|
+
"""Print the normalized error to stderr and exit with its mapped code."""
|
|
33
|
+
sys.stderr.write(json.dumps(err.to_dict(), ensure_ascii=False) + "\n")
|
|
34
|
+
raise SystemExit(EXIT_CODES.get(err.error.name, 8))
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# opensell_cli/schema_to_click.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
_SCALAR = {"integer": click.INT, "number": click.FLOAT, "string": click.STRING}
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def options_for_schema(schema: dict) -> list[click.Option]:
|
|
10
|
+
"""Build one click.Option per JSON-Schema property.
|
|
11
|
+
|
|
12
|
+
- property name `item_id` -> flag `--item-id` (Click maps it back to the
|
|
13
|
+
`item_id` callback kwarg automatically).
|
|
14
|
+
- integer/number/string -> matching Click types; enum -> click.Choice;
|
|
15
|
+
boolean -> is_flag; object -> STRING (a JSON string, decoded at dispatch).
|
|
16
|
+
- names in `required` are required unless they declare a `default`.
|
|
17
|
+
"""
|
|
18
|
+
props = schema.get("properties", {})
|
|
19
|
+
required = set(schema.get("required", []))
|
|
20
|
+
options: list[click.Option] = []
|
|
21
|
+
for name, spec in props.items():
|
|
22
|
+
flag = "--" + name.replace("_", "-")
|
|
23
|
+
help_text = spec.get("description", "")
|
|
24
|
+
if spec.get("type") == "boolean":
|
|
25
|
+
options.append(click.Option([flag], is_flag=True, help=help_text))
|
|
26
|
+
continue
|
|
27
|
+
kwargs: dict = {"help": help_text, "required": name in required}
|
|
28
|
+
if "default" in spec:
|
|
29
|
+
kwargs["default"] = spec["default"]
|
|
30
|
+
kwargs["required"] = False
|
|
31
|
+
if "enum" in spec:
|
|
32
|
+
kwargs["type"] = click.Choice(spec["enum"])
|
|
33
|
+
elif spec.get("type") == "object":
|
|
34
|
+
kwargs["type"] = click.STRING
|
|
35
|
+
else:
|
|
36
|
+
kwargs["type"] = _SCALAR.get(spec.get("type"), click.STRING)
|
|
37
|
+
options.append(click.Option([flag], **kwargs))
|
|
38
|
+
return options
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "opensell-cli"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "OPENSELL marketplace CLI for AI agents"
|
|
5
|
+
requires-python = ">=3.12"
|
|
6
|
+
dependencies = ["aixianyu-mcp", "click>=8.1"]
|
|
7
|
+
|
|
8
|
+
[project.optional-dependencies]
|
|
9
|
+
dev = ["pytest>=8.2.0", "pytest-asyncio>=0.23.0"]
|
|
10
|
+
|
|
11
|
+
[project.scripts]
|
|
12
|
+
opensell = "opensell_cli.cli:main"
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ["hatchling"]
|
|
16
|
+
build-backend = "hatchling.build"
|
|
17
|
+
|
|
18
|
+
[tool.uv.sources]
|
|
19
|
+
aixianyu-mcp = { path = "../mcp-server", editable = true }
|
|
20
|
+
|
|
21
|
+
[tool.pytest.ini_options]
|
|
22
|
+
asyncio_mode = "auto"
|
|
23
|
+
testpaths = ["tests"]
|
|
24
|
+
pythonpath = ["."]
|
|
File without changes
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from aixianyu_mcp.registry import TOOL_REGISTRY
|
|
4
|
+
from click.testing import CliRunner
|
|
5
|
+
from opensell_cli.catalog import build_catalog, catalog_command
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_build_catalog_covers_every_tool():
|
|
9
|
+
cat = build_catalog()
|
|
10
|
+
commands = {c["tool"] for c in cat["commands"]}
|
|
11
|
+
assert commands == set(TOOL_REGISTRY.keys())
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_catalog_entry_shape():
|
|
15
|
+
cat = build_catalog()
|
|
16
|
+
by_tool = {c["tool"]: c for c in cat["commands"]}
|
|
17
|
+
place = by_tool["place_order"]
|
|
18
|
+
assert place["command"] == "place-order"
|
|
19
|
+
assert place["scope"] == "orders:create"
|
|
20
|
+
arg_names = {a["name"]: a for a in place["args"]}
|
|
21
|
+
assert arg_names["item_id"]["required"] is True
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_catalog_command_emits_json():
|
|
25
|
+
result = CliRunner().invoke(catalog_command, [])
|
|
26
|
+
assert result.exit_code == 0
|
|
27
|
+
parsed = json.loads(result.output)
|
|
28
|
+
assert "commands" in parsed and len(parsed["commands"]) == len(TOOL_REGISTRY)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# opensell-cli/tests/test_cli_e2e.py
|
|
2
|
+
import json
|
|
3
|
+
from unittest.mock import AsyncMock, patch
|
|
4
|
+
|
|
5
|
+
from aixianyu_mcp.errors import MCPError, MCPToolError
|
|
6
|
+
from aixianyu_mcp.registry import TOOL_REGISTRY
|
|
7
|
+
from click.testing import CliRunner
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_all_registry_tools_plus_catalog_are_commands(cli):
|
|
11
|
+
names = set(cli.commands.keys())
|
|
12
|
+
for tool in TOOL_REGISTRY:
|
|
13
|
+
assert tool.replace("_", "-") in names
|
|
14
|
+
assert "catalog" in names
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_success_prints_json_and_exit_zero(cli):
|
|
18
|
+
with patch(
|
|
19
|
+
"opensell_cli.cli.run_tool",
|
|
20
|
+
new=AsyncMock(return_value={"id": 18, "title": "GPT-4o key"}),
|
|
21
|
+
):
|
|
22
|
+
result = CliRunner().invoke(cli, ["get-item", "--item-id", "18"])
|
|
23
|
+
assert result.exit_code == 0
|
|
24
|
+
assert json.loads(result.stdout) == {"id": 18, "title": "GPT-4o key"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_backend_error_maps_to_exit_code(cli):
|
|
28
|
+
err = MCPToolError(MCPError.UNAUTHORIZED)
|
|
29
|
+
with patch("opensell_cli.cli.run_tool", new=AsyncMock(side_effect=err)):
|
|
30
|
+
result = CliRunner().invoke(cli, ["get-wallet"])
|
|
31
|
+
assert result.exit_code == 2
|
|
32
|
+
assert json.loads(result.stderr)["error"] == "UNAUTHORIZED"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_missing_required_arg_is_usage_error(cli):
|
|
36
|
+
result = CliRunner().invoke(cli, ["get-item"]) # no --item-id
|
|
37
|
+
assert result.exit_code == 2 # Click usage error
|
|
38
|
+
assert "Missing option" in result.output
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_invalid_object_arg_is_usage_error(cli):
|
|
42
|
+
result = CliRunner().invoke(
|
|
43
|
+
cli, ["update-item", "--item-id", "1", "--structured-attributes", "not json"]
|
|
44
|
+
)
|
|
45
|
+
assert result.exit_code == 2
|
|
46
|
+
assert "valid JSON" in result.output
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_two_commands_dispatch_independently(cli):
|
|
50
|
+
from unittest.mock import AsyncMock, patch
|
|
51
|
+
with patch("opensell_cli.cli.run_tool", new=AsyncMock(return_value={})) as mock:
|
|
52
|
+
CliRunner().invoke(cli, ["get-item", "--item-id", "1"])
|
|
53
|
+
CliRunner().invoke(cli, ["get-wallet"])
|
|
54
|
+
assert mock.await_args_list[0].args[0] == "get_item"
|
|
55
|
+
assert mock.await_args_list[1].args[0] == "get_wallet"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from unittest.mock import AsyncMock, patch
|
|
3
|
+
|
|
4
|
+
import mcp.types as types
|
|
5
|
+
import pytest
|
|
6
|
+
from opensell_cli.execute import run_tool
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.mark.asyncio
|
|
10
|
+
async def test_run_tool_unwraps_handler_textcontent():
|
|
11
|
+
fake_handler = AsyncMock(
|
|
12
|
+
return_value=[types.TextContent(type="text", text=json.dumps({"id": 18}))]
|
|
13
|
+
)
|
|
14
|
+
with patch.dict("opensell_cli.execute.TOOL_HANDLERS", {"get_item": fake_handler}):
|
|
15
|
+
with patch("opensell_cli.execute.AixianyuRESTClient") as Client:
|
|
16
|
+
result = await run_tool(
|
|
17
|
+
"get_item", {"item_id": 18}, base_url="http://x", token="t"
|
|
18
|
+
)
|
|
19
|
+
assert result == {"id": 18}
|
|
20
|
+
fake_handler.assert_awaited_once()
|
|
21
|
+
# handler called as (client, args)
|
|
22
|
+
args = fake_handler.await_args.args
|
|
23
|
+
assert args[1] == {"item_id": 18}
|
|
24
|
+
Client.assert_called_once_with(base_url="http://x", agent_token="t")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.mark.asyncio
|
|
28
|
+
async def test_run_tool_ping_is_inline():
|
|
29
|
+
with patch("opensell_cli.execute.AixianyuRESTClient") as Client:
|
|
30
|
+
Client.return_value.ping = AsyncMock(return_value={"status": "pong"})
|
|
31
|
+
result = await run_tool("ping", {}, base_url=None, token="")
|
|
32
|
+
assert result == {"status": "pong"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.mark.asyncio
|
|
36
|
+
async def test_run_tool_unknown_tool_raises_mcptoolerror():
|
|
37
|
+
from aixianyu_mcp.errors import MCPToolError
|
|
38
|
+
with patch("opensell_cli.execute.AixianyuRESTClient"):
|
|
39
|
+
with patch.dict("opensell_cli.execute.TOOL_HANDLERS", {}, clear=False):
|
|
40
|
+
with pytest.raises(MCPToolError):
|
|
41
|
+
await run_tool("does_not_exist", {}, base_url=None, token="")
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import pytest
|
|
3
|
+
from aixianyu_mcp.errors import MCPError, MCPToolError
|
|
4
|
+
from opensell_cli.output import emit_success, emit_error, EXIT_CODES
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_emit_success_writes_json_and_exits_zero(capsys):
|
|
8
|
+
with pytest.raises(SystemExit) as exc:
|
|
9
|
+
emit_success({"order_id": 7, "status": "escrow_hold"})
|
|
10
|
+
assert exc.value.code == 0
|
|
11
|
+
out = json.loads(capsys.readouterr().out)
|
|
12
|
+
assert out == {"order_id": 7, "status": "escrow_hold"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_emit_error_writes_to_stderr_with_mapped_exit(capsys):
|
|
16
|
+
err = MCPToolError(MCPError.AGENT_SANDBOX_LIMIT, limit="20000")
|
|
17
|
+
with pytest.raises(SystemExit) as exc:
|
|
18
|
+
emit_error(err)
|
|
19
|
+
assert exc.value.code == 4
|
|
20
|
+
captured = capsys.readouterr()
|
|
21
|
+
assert captured.out == ""
|
|
22
|
+
body = json.loads(captured.err)
|
|
23
|
+
assert body["error"] == "AGENT_SANDBOX_LIMIT"
|
|
24
|
+
assert "20000" in body["message"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_every_mcp_error_has_an_exit_code():
|
|
28
|
+
for member in MCPError:
|
|
29
|
+
assert member.name in EXIT_CODES
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import click
|
|
2
|
+
from opensell_cli.schema_to_click import options_for_schema
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _by_name(options):
|
|
6
|
+
return {o.name: o for o in options}
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_required_typed_and_kebab_cased():
|
|
10
|
+
schema = {
|
|
11
|
+
"type": "object",
|
|
12
|
+
"properties": {
|
|
13
|
+
"item_id": {"type": "integer", "description": "Item ID"},
|
|
14
|
+
"quantity": {"type": "integer", "default": 1},
|
|
15
|
+
},
|
|
16
|
+
"required": ["item_id"],
|
|
17
|
+
}
|
|
18
|
+
opts = _by_name(options_for_schema(schema))
|
|
19
|
+
assert opts["item_id"].opts == ["--item-id"]
|
|
20
|
+
assert opts["item_id"].required is True
|
|
21
|
+
assert opts["item_id"].type is click.INT
|
|
22
|
+
# default makes it optional
|
|
23
|
+
assert opts["quantity"].required is False
|
|
24
|
+
assert opts["quantity"].default == 1
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_enum_becomes_choice_and_bool_is_flag():
|
|
28
|
+
schema = {
|
|
29
|
+
"type": "object",
|
|
30
|
+
"properties": {
|
|
31
|
+
"sort": {"type": "string", "enum": ["newest", "price_asc"]},
|
|
32
|
+
"verbose": {"type": "boolean"},
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
opts = _by_name(options_for_schema(schema))
|
|
36
|
+
assert isinstance(opts["sort"].type, click.Choice)
|
|
37
|
+
assert list(opts["sort"].type.choices) == ["newest", "price_asc"]
|
|
38
|
+
assert opts["verbose"].is_flag is True
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_object_type_maps_to_string():
|
|
42
|
+
schema = {"type": "object", "properties": {"structured_attributes": {"type": "object"}}}
|
|
43
|
+
opts = _by_name(options_for_schema(schema))
|
|
44
|
+
assert opts["structured_attributes"].type is click.STRING
|