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.
@@ -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,9 @@
1
+ # opensell-cli/tests/conftest.py
2
+ import pytest
3
+ from opensell_cli.cli import build_cli
4
+
5
+
6
+ @pytest.fixture
7
+ def cli():
8
+ """The fully-assembled Click group (registry commands + catalog)."""
9
+ return build_cli()
@@ -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