ensue-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.
- ensue_cli-0.1.0/.gitignore +8 -0
- ensue_cli-0.1.0/Makefile +39 -0
- ensue_cli-0.1.0/PKG-INFO +62 -0
- ensue_cli-0.1.0/README.md +30 -0
- ensue_cli-0.1.0/pyproject.toml +78 -0
- ensue_cli-0.1.0/src/ensue_cli/__init__.py +3 -0
- ensue_cli-0.1.0/src/ensue_cli/cli.py +141 -0
- ensue_cli-0.1.0/src/ensue_cli/client.py +38 -0
- ensue_cli-0.1.0/tests/__init__.py +0 -0
- ensue_cli-0.1.0/tests/test_integration.py +169 -0
- ensue_cli-0.1.0/tests/test_unit.py +112 -0
ensue_cli-0.1.0/Makefile
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
.PHONY: format lint check test test-cov install clean build publish
|
|
2
|
+
|
|
3
|
+
# Formatting
|
|
4
|
+
format:
|
|
5
|
+
ruff format .
|
|
6
|
+
|
|
7
|
+
# Linting
|
|
8
|
+
lint:
|
|
9
|
+
ruff check --fix .
|
|
10
|
+
|
|
11
|
+
# Check without modifying (for CI)
|
|
12
|
+
check:
|
|
13
|
+
ruff format --check .
|
|
14
|
+
ruff check .
|
|
15
|
+
|
|
16
|
+
# Run tests
|
|
17
|
+
test:
|
|
18
|
+
pytest -v
|
|
19
|
+
|
|
20
|
+
# Run tests with coverage
|
|
21
|
+
test-cov:
|
|
22
|
+
pytest --cov=ensue_cli --cov-report=term-missing --cov-fail-under=100
|
|
23
|
+
|
|
24
|
+
# Install in development mode
|
|
25
|
+
install:
|
|
26
|
+
pip install -e ".[dev]"
|
|
27
|
+
|
|
28
|
+
# Clean build artifacts
|
|
29
|
+
clean:
|
|
30
|
+
rm -rf dist/ build/ *.egg-info src/*.egg-info .pytest_cache .ruff_cache
|
|
31
|
+
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
|
32
|
+
|
|
33
|
+
# Build package
|
|
34
|
+
build: clean
|
|
35
|
+
python -m build
|
|
36
|
+
|
|
37
|
+
# Publish to PyPI
|
|
38
|
+
publish: build
|
|
39
|
+
twine upload dist/*
|
ensue_cli-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ensue-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI for Ensue Memory - a distributed memory network for AI agents built on MCP, enabling persistent, shared context across conversations and applications
|
|
5
|
+
Project-URL: Homepage, https://www.ensue-network.ai
|
|
6
|
+
Project-URL: Repository, https://github.com/mutable-state-inc/ensue-cli
|
|
7
|
+
Project-URL: Documentation, https://www.ensue-network.ai/docs
|
|
8
|
+
Author: Mutable State Inc
|
|
9
|
+
Keywords: agents,ai,cli,ensue,mcp,memory,model-context-protocol
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Requires-Dist: click>=8.0
|
|
22
|
+
Requires-Dist: mcp>=1.0
|
|
23
|
+
Requires-Dist: rich>=13.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: build>=1.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff>=0.8; extra == 'dev'
|
|
30
|
+
Requires-Dist: twine>=4.0; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# ensue-cli
|
|
34
|
+
|
|
35
|
+
CLI for Ensue Memory - a distributed memory network for AI agents built on MCP, enabling persistent, shared context across conversations and applications.
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install ensue-cli
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
Set your authentication token:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
export ENSUE_TOKEN=your-token
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
List available commands:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
ensue --help
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Commands are loaded dynamically from the MCP server.
|
|
58
|
+
|
|
59
|
+
## Configuration
|
|
60
|
+
|
|
61
|
+
- `ENSUE_TOKEN` (required): Your Ensue API token
|
|
62
|
+
- `ENSUE_URL` (optional): API endpoint (defaults to https://www.ensue-network.ai/api/)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# ensue-cli
|
|
2
|
+
|
|
3
|
+
CLI for Ensue Memory - a distributed memory network for AI agents built on MCP, enabling persistent, shared context across conversations and applications.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install ensue-cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
Set your authentication token:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
export ENSUE_TOKEN=your-token
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
List available commands:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
ensue --help
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Commands are loaded dynamically from the MCP server.
|
|
26
|
+
|
|
27
|
+
## Configuration
|
|
28
|
+
|
|
29
|
+
- `ENSUE_TOKEN` (required): Your Ensue API token
|
|
30
|
+
- `ENSUE_URL` (optional): API endpoint (defaults to https://www.ensue-network.ai/api/)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ensue-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "CLI for Ensue Memory - a distributed memory network for AI agents built on MCP, enabling persistent, shared context across conversations and applications"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "Mutable State Inc" }
|
|
13
|
+
]
|
|
14
|
+
keywords = ["mcp", "memory", "ai", "agents", "cli", "ensue", "model-context-protocol"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"click>=8.0",
|
|
29
|
+
"mcp>=1.0",
|
|
30
|
+
"rich>=13.0",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
dev = [
|
|
35
|
+
"pytest>=7.0",
|
|
36
|
+
"pytest-asyncio>=0.21",
|
|
37
|
+
"pytest-cov>=4.0",
|
|
38
|
+
"build>=1.0",
|
|
39
|
+
"twine>=4.0",
|
|
40
|
+
"ruff>=0.8",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[project.scripts]
|
|
44
|
+
ensue = "ensue_cli.cli:main"
|
|
45
|
+
|
|
46
|
+
[project.urls]
|
|
47
|
+
Homepage = "https://www.ensue-network.ai"
|
|
48
|
+
Repository = "https://github.com/mutable-state-inc/ensue-cli"
|
|
49
|
+
Documentation = "https://www.ensue-network.ai/docs"
|
|
50
|
+
|
|
51
|
+
[tool.hatch.build.targets.wheel]
|
|
52
|
+
packages = ["src/ensue_cli"]
|
|
53
|
+
|
|
54
|
+
[tool.pytest.ini_options]
|
|
55
|
+
asyncio_mode = "auto"
|
|
56
|
+
testpaths = ["tests"]
|
|
57
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
58
|
+
|
|
59
|
+
[tool.ruff]
|
|
60
|
+
line-length = 100
|
|
61
|
+
target-version = "py39"
|
|
62
|
+
|
|
63
|
+
[tool.ruff.lint]
|
|
64
|
+
select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
65
|
+
ignore = ["SIM117"] # nested with statements are clearer for async context managers
|
|
66
|
+
|
|
67
|
+
[tool.ruff.format]
|
|
68
|
+
quote-style = "double"
|
|
69
|
+
|
|
70
|
+
[tool.coverage.run]
|
|
71
|
+
source = ["src/ensue_cli"]
|
|
72
|
+
omit = ["*/__main__.py"]
|
|
73
|
+
|
|
74
|
+
[tool.coverage.report]
|
|
75
|
+
exclude_lines = [
|
|
76
|
+
"if __name__ == .__main__.:",
|
|
77
|
+
"pragma: no cover",
|
|
78
|
+
]
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Dynamic CLI for Ensue Memory Network."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.json import JSON
|
|
11
|
+
|
|
12
|
+
from . import client
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
DEFAULT_URL = "https://www.ensue-network.ai/api/"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run_async(coro):
|
|
19
|
+
"""Run async coroutine, handling nested event loops."""
|
|
20
|
+
try:
|
|
21
|
+
asyncio.get_running_loop()
|
|
22
|
+
except RuntimeError:
|
|
23
|
+
return asyncio.run(coro)
|
|
24
|
+
# If there's already a running loop, create a new one in a thread
|
|
25
|
+
import concurrent.futures
|
|
26
|
+
|
|
27
|
+
with concurrent.futures.ThreadPoolExecutor() as pool:
|
|
28
|
+
return pool.submit(asyncio.run, coro).result()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_config():
|
|
32
|
+
url = os.environ.get("ENSUE_URL", DEFAULT_URL)
|
|
33
|
+
token = os.environ.get("ENSUE_TOKEN")
|
|
34
|
+
if not token:
|
|
35
|
+
console.print("[red]Error:[/red] ENSUE_TOKEN environment variable required")
|
|
36
|
+
sys.exit(1)
|
|
37
|
+
return url, token
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def print_result(result):
|
|
41
|
+
if hasattr(result, "content"):
|
|
42
|
+
for item in result.content:
|
|
43
|
+
if hasattr(item, "text"):
|
|
44
|
+
try:
|
|
45
|
+
console.print(JSON(item.text))
|
|
46
|
+
except Exception:
|
|
47
|
+
console.print(item.text)
|
|
48
|
+
else:
|
|
49
|
+
console.print(JSON(json.dumps(result, indent=2)))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
TYPE_MAP = {
|
|
53
|
+
"integer": click.INT,
|
|
54
|
+
"number": click.FLOAT,
|
|
55
|
+
"boolean": click.BOOL,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parse_arg(value, schema_type):
|
|
60
|
+
if schema_type in ("array", "object") and isinstance(value, str):
|
|
61
|
+
try:
|
|
62
|
+
return json.loads(value)
|
|
63
|
+
except json.JSONDecodeError:
|
|
64
|
+
pass
|
|
65
|
+
return value
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def build_command(tool):
|
|
69
|
+
"""Build a Click command from an MCP tool definition."""
|
|
70
|
+
schema = tool.get("inputSchema", {})
|
|
71
|
+
props = schema.get("properties", {})
|
|
72
|
+
required = set(schema.get("required", []))
|
|
73
|
+
|
|
74
|
+
params = [
|
|
75
|
+
click.Option(
|
|
76
|
+
[f"--{name.replace('_', '-')}"],
|
|
77
|
+
type=TYPE_MAP.get(p.get("type"), click.STRING),
|
|
78
|
+
required=name in required,
|
|
79
|
+
help=p.get("description", ""),
|
|
80
|
+
)
|
|
81
|
+
for name, p in props.items()
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
def callback(**kwargs):
|
|
85
|
+
url, token = get_config()
|
|
86
|
+
args = {
|
|
87
|
+
k.replace("-", "_"): parse_arg(v, props.get(k.replace("-", "_"), {}).get("type"))
|
|
88
|
+
for k, v in kwargs.items()
|
|
89
|
+
if v is not None
|
|
90
|
+
}
|
|
91
|
+
result = run_async(client.call_tool(url, token, tool["name"], args))
|
|
92
|
+
print_result(result)
|
|
93
|
+
|
|
94
|
+
return click.Command(
|
|
95
|
+
name=tool["name"],
|
|
96
|
+
callback=callback,
|
|
97
|
+
params=params,
|
|
98
|
+
help=tool.get("description", ""),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class MCPToolsCLI(click.Group):
|
|
103
|
+
"""CLI that loads commands dynamically from MCP server."""
|
|
104
|
+
|
|
105
|
+
def __init__(self, **kwargs):
|
|
106
|
+
super().__init__(**kwargs)
|
|
107
|
+
self._tools = None
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def tools(self):
|
|
111
|
+
if self._tools is None:
|
|
112
|
+
url, token = get_config()
|
|
113
|
+
self._tools = {t["name"]: t for t in run_async(client.list_tools(url, token))}
|
|
114
|
+
return self._tools
|
|
115
|
+
|
|
116
|
+
def list_commands(self, ctx):
|
|
117
|
+
try:
|
|
118
|
+
return sorted(self.tools.keys())
|
|
119
|
+
except Exception as e:
|
|
120
|
+
console.print("[red]Connection error:[/red] Could not connect to MCP server")
|
|
121
|
+
console.print(f"[dim]{e}[/dim]")
|
|
122
|
+
return []
|
|
123
|
+
|
|
124
|
+
def get_command(self, ctx, name):
|
|
125
|
+
if name not in self.tools:
|
|
126
|
+
return None
|
|
127
|
+
return build_command(self.tools[name])
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@click.group(cls=MCPToolsCLI)
|
|
131
|
+
@click.version_option()
|
|
132
|
+
def main():
|
|
133
|
+
"""Ensue Memory CLI - A distributed memory network for AI agents.
|
|
134
|
+
|
|
135
|
+
Commands are loaded dynamically from the MCP server.
|
|
136
|
+
Set ENSUE_TOKEN to authenticate.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
if __name__ == "__main__":
|
|
141
|
+
main()
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""MCP client for communicating with the Ensue Memory Network."""
|
|
2
|
+
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from mcp import ClientSession
|
|
7
|
+
from mcp.client.streamable_http import streamablehttp_client
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@asynccontextmanager
|
|
11
|
+
async def create_session(url: str, token: str):
|
|
12
|
+
"""Create an MCP client session connected to the Ensue service."""
|
|
13
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
14
|
+
async with streamablehttp_client(url, headers=headers) as (read_stream, write_stream, _):
|
|
15
|
+
async with ClientSession(read_stream, write_stream) as session:
|
|
16
|
+
await session.initialize()
|
|
17
|
+
yield session
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def list_tools(url: str, token: str) -> list[dict[str, Any]]:
|
|
21
|
+
"""Fetch the list of available tools from the MCP server."""
|
|
22
|
+
async with create_session(url, token) as session:
|
|
23
|
+
result = await session.list_tools()
|
|
24
|
+
return [
|
|
25
|
+
{
|
|
26
|
+
"name": tool.name,
|
|
27
|
+
"description": tool.description,
|
|
28
|
+
"inputSchema": tool.inputSchema,
|
|
29
|
+
}
|
|
30
|
+
for tool in result.tools
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def call_tool(url: str, token: str, name: str, arguments: dict[str, Any]) -> Any:
|
|
35
|
+
"""Call a tool on the MCP server."""
|
|
36
|
+
async with create_session(url, token) as session:
|
|
37
|
+
result = await session.call_tool(name, arguments)
|
|
38
|
+
return result
|
|
File without changes
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Integration tests for Ensue CLI dynamic functionality."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import uuid
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from click.testing import CliRunner
|
|
10
|
+
|
|
11
|
+
from ensue_cli import client
|
|
12
|
+
from ensue_cli.cli import main
|
|
13
|
+
|
|
14
|
+
ENSUE_URL = os.environ.get("ENSUE_URL", "https://www.ensue-network.ai/api/")
|
|
15
|
+
ENSUE_TOKEN = os.environ.get("ENSUE_TOKEN")
|
|
16
|
+
|
|
17
|
+
pytestmark = pytest.mark.skipif(not ENSUE_TOKEN, reason="ENSUE_TOKEN environment variable not set")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture
|
|
21
|
+
def runner():
|
|
22
|
+
return CliRunner(env={"ENSUE_TOKEN": ENSUE_TOKEN, "ENSUE_URL": ENSUE_URL})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture
|
|
26
|
+
def test_key():
|
|
27
|
+
return f"cli-test-{uuid.uuid4()}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.mark.asyncio
|
|
31
|
+
async def test_cli_commands_match_server_tools(runner):
|
|
32
|
+
"""CLI commands should match tools available from the MCP server."""
|
|
33
|
+
# Get tools directly from server
|
|
34
|
+
server_tools = await client.list_tools(ENSUE_URL, ENSUE_TOKEN)
|
|
35
|
+
server_tool_names = {t["name"] for t in server_tools}
|
|
36
|
+
|
|
37
|
+
# Get commands from CLI
|
|
38
|
+
result = runner.invoke(main, ["--help"])
|
|
39
|
+
assert result.exit_code == 0
|
|
40
|
+
|
|
41
|
+
# Each server tool should appear in CLI help
|
|
42
|
+
for tool_name in server_tool_names:
|
|
43
|
+
assert tool_name in result.output, f"Tool '{tool_name}' not found in CLI"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_cli_shows_help_without_error(runner):
|
|
47
|
+
"""CLI --help should work and show available commands."""
|
|
48
|
+
result = runner.invoke(main, ["--help"])
|
|
49
|
+
|
|
50
|
+
assert result.exit_code == 0
|
|
51
|
+
assert "Ensue Memory CLI" in result.output
|
|
52
|
+
assert "Commands are loaded dynamically" in result.output
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_cli_shows_version(runner):
|
|
56
|
+
"""CLI --version should show version."""
|
|
57
|
+
result = runner.invoke(main, ["--version"])
|
|
58
|
+
|
|
59
|
+
assert result.exit_code == 0
|
|
60
|
+
assert "0.1.0" in result.output
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@pytest.mark.asyncio
|
|
64
|
+
async def test_cli_command_has_correct_options(runner):
|
|
65
|
+
"""CLI command options should match the tool's input schema."""
|
|
66
|
+
# Get a tool's schema from server
|
|
67
|
+
tools = await client.list_tools(ENSUE_URL, ENSUE_TOKEN)
|
|
68
|
+
tool = next((t for t in tools if t["name"] == "create_memory"), None)
|
|
69
|
+
|
|
70
|
+
if not tool:
|
|
71
|
+
pytest.skip("create_memory tool not available")
|
|
72
|
+
|
|
73
|
+
schema = tool.get("inputSchema", {})
|
|
74
|
+
properties = schema.get("properties", {})
|
|
75
|
+
|
|
76
|
+
# Get CLI command help
|
|
77
|
+
result = runner.invoke(main, ["create_memory", "--help"])
|
|
78
|
+
assert result.exit_code == 0
|
|
79
|
+
|
|
80
|
+
# Each schema property should be a CLI option
|
|
81
|
+
for prop_name in properties:
|
|
82
|
+
option_name = f"--{prop_name.replace('_', '-')}"
|
|
83
|
+
assert option_name in result.output, f"Option '{option_name}' not in CLI"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_cli_create_and_delete_memory(runner, test_key):
|
|
87
|
+
"""Test full CLI workflow: create and delete a memory."""
|
|
88
|
+
# Value must be base64 encoded
|
|
89
|
+
test_value = base64.b64encode(json.dumps({"test": True, "key": test_key}).encode()).decode()
|
|
90
|
+
|
|
91
|
+
# Create memory via CLI
|
|
92
|
+
create_result = runner.invoke(
|
|
93
|
+
main,
|
|
94
|
+
[
|
|
95
|
+
"create_memory",
|
|
96
|
+
"--key-name",
|
|
97
|
+
test_key,
|
|
98
|
+
"--value",
|
|
99
|
+
test_value,
|
|
100
|
+
"--description",
|
|
101
|
+
"Integration test memory",
|
|
102
|
+
],
|
|
103
|
+
)
|
|
104
|
+
assert create_result.exit_code == 0, f"Create failed: {create_result.output}"
|
|
105
|
+
|
|
106
|
+
# Delete memory via CLI
|
|
107
|
+
delete_result = runner.invoke(
|
|
108
|
+
main,
|
|
109
|
+
[
|
|
110
|
+
"delete_memory",
|
|
111
|
+
"--key-name",
|
|
112
|
+
test_key,
|
|
113
|
+
],
|
|
114
|
+
)
|
|
115
|
+
assert delete_result.exit_code == 0, f"Delete failed: {delete_result.output}"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_cli_missing_required_option(runner):
|
|
119
|
+
"""CLI should error when required options are missing."""
|
|
120
|
+
result = runner.invoke(main, ["create_memory"])
|
|
121
|
+
|
|
122
|
+
# Should fail due to missing required options
|
|
123
|
+
assert result.exit_code != 0
|
|
124
|
+
assert "Missing option" in result.output or "required" in result.output.lower()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@pytest.mark.asyncio
|
|
128
|
+
async def test_all_tools_translate_to_valid_cli_commands(runner):
|
|
129
|
+
"""Every tool from the server should translate to a working CLI command with correct options."""
|
|
130
|
+
tools = await client.list_tools(ENSUE_URL, ENSUE_TOKEN)
|
|
131
|
+
|
|
132
|
+
for tool in tools:
|
|
133
|
+
tool_name = tool["name"]
|
|
134
|
+
schema = tool.get("inputSchema", {})
|
|
135
|
+
properties = schema.get("properties", {})
|
|
136
|
+
|
|
137
|
+
# Each tool's help should be accessible
|
|
138
|
+
result = runner.invoke(main, [tool_name, "--help"])
|
|
139
|
+
assert result.exit_code == 0, f"Tool '{tool_name}' --help failed: {result.output}"
|
|
140
|
+
|
|
141
|
+
# Verify each property is translated to a CLI option
|
|
142
|
+
for prop_name, prop_schema in properties.items():
|
|
143
|
+
option_name = f"--{prop_name.replace('_', '-')}"
|
|
144
|
+
assert option_name in result.output, (
|
|
145
|
+
f"Tool '{tool_name}' missing option '{option_name}'"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Verify type hints appear for typed options
|
|
149
|
+
prop_type = prop_schema.get("type")
|
|
150
|
+
if prop_type == "integer":
|
|
151
|
+
assert "INTEGER" in result.output or option_name in result.output
|
|
152
|
+
elif prop_type == "number":
|
|
153
|
+
assert "FLOAT" in result.output or option_name in result.output
|
|
154
|
+
elif prop_type == "boolean":
|
|
155
|
+
assert "BOOLEAN" in result.output or option_name in result.output
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@pytest.mark.asyncio
|
|
159
|
+
async def test_all_tools_callable_with_help(runner):
|
|
160
|
+
"""Verify every tool can at least show its help without errors."""
|
|
161
|
+
tools = await client.list_tools(ENSUE_URL, ENSUE_TOKEN)
|
|
162
|
+
|
|
163
|
+
errors = []
|
|
164
|
+
for tool in tools:
|
|
165
|
+
result = runner.invoke(main, [tool["name"], "--help"])
|
|
166
|
+
if result.exit_code != 0:
|
|
167
|
+
errors.append(f"{tool['name']}: {result.output}")
|
|
168
|
+
|
|
169
|
+
assert not errors, "Tools failed to show help:\n" + "\n".join(errors)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Unit tests for CLI internals (no network required)."""
|
|
2
|
+
|
|
3
|
+
from unittest.mock import MagicMock, patch
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from click.testing import CliRunner
|
|
7
|
+
|
|
8
|
+
from ensue_cli.cli import get_config, main, parse_arg, print_result
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestGetConfig:
|
|
12
|
+
def test_missing_token_exits(self):
|
|
13
|
+
"""get_config should exit when ENSUE_TOKEN is not set."""
|
|
14
|
+
runner = CliRunner(env={"ENSUE_TOKEN": ""})
|
|
15
|
+
with runner.isolated_filesystem():
|
|
16
|
+
with patch.dict("os.environ", {"ENSUE_TOKEN": ""}, clear=False):
|
|
17
|
+
with pytest.raises(SystemExit):
|
|
18
|
+
get_config()
|
|
19
|
+
|
|
20
|
+
def test_returns_url_and_token(self):
|
|
21
|
+
"""get_config should return URL and token from env."""
|
|
22
|
+
with patch.dict(
|
|
23
|
+
"os.environ",
|
|
24
|
+
{"ENSUE_TOKEN": "test-token", "ENSUE_URL": "http://test.com"},
|
|
25
|
+
clear=False,
|
|
26
|
+
):
|
|
27
|
+
url, token = get_config()
|
|
28
|
+
assert url == "http://test.com"
|
|
29
|
+
assert token == "test-token"
|
|
30
|
+
|
|
31
|
+
def test_default_url(self):
|
|
32
|
+
"""get_config should use default URL when not set."""
|
|
33
|
+
with patch.dict("os.environ", {"ENSUE_TOKEN": "test-token"}, clear=False):
|
|
34
|
+
# Remove ENSUE_URL if present
|
|
35
|
+
import os
|
|
36
|
+
|
|
37
|
+
os.environ.pop("ENSUE_URL", None)
|
|
38
|
+
url, token = get_config()
|
|
39
|
+
assert "ensue-network.ai" in url
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TestParseArg:
|
|
43
|
+
def test_parses_json_array(self):
|
|
44
|
+
"""parse_arg should parse JSON arrays."""
|
|
45
|
+
result = parse_arg('["a", "b"]', "array")
|
|
46
|
+
assert result == ["a", "b"]
|
|
47
|
+
|
|
48
|
+
def test_parses_json_object(self):
|
|
49
|
+
"""parse_arg should parse JSON objects."""
|
|
50
|
+
result = parse_arg('{"key": "value"}', "object")
|
|
51
|
+
assert result == {"key": "value"}
|
|
52
|
+
|
|
53
|
+
def test_returns_original_on_invalid_json(self):
|
|
54
|
+
"""parse_arg should return original value on JSON parse failure."""
|
|
55
|
+
result = parse_arg("not-json", "array")
|
|
56
|
+
assert result == "not-json"
|
|
57
|
+
|
|
58
|
+
def test_returns_value_for_other_types(self):
|
|
59
|
+
"""parse_arg should return value unchanged for non-array/object types."""
|
|
60
|
+
assert parse_arg("hello", "string") == "hello"
|
|
61
|
+
assert parse_arg(42, "integer") == 42
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TestPrintResult:
|
|
65
|
+
def test_prints_mcp_content(self, capsys):
|
|
66
|
+
"""print_result should handle MCP CallToolResult with content."""
|
|
67
|
+
mock_result = MagicMock()
|
|
68
|
+
mock_item = MagicMock()
|
|
69
|
+
mock_item.text = '{"success": true}'
|
|
70
|
+
mock_result.content = [mock_item]
|
|
71
|
+
|
|
72
|
+
print_result(mock_result)
|
|
73
|
+
# Should not raise
|
|
74
|
+
|
|
75
|
+
def test_prints_mcp_content_invalid_json(self, capsys):
|
|
76
|
+
"""print_result should handle non-JSON text content."""
|
|
77
|
+
mock_result = MagicMock()
|
|
78
|
+
mock_item = MagicMock()
|
|
79
|
+
mock_item.text = "plain text response"
|
|
80
|
+
mock_result.content = [mock_item]
|
|
81
|
+
|
|
82
|
+
print_result(mock_result)
|
|
83
|
+
captured = capsys.readouterr()
|
|
84
|
+
assert "plain text response" in captured.out
|
|
85
|
+
|
|
86
|
+
def test_prints_dict_result(self, capsys):
|
|
87
|
+
"""print_result should handle dict results."""
|
|
88
|
+
print_result({"key": "value"})
|
|
89
|
+
# Should not raise
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class TestCLIErrorHandling:
|
|
93
|
+
def setup_method(self):
|
|
94
|
+
"""Reset the CLI tools cache before each test."""
|
|
95
|
+
main._tools = None
|
|
96
|
+
|
|
97
|
+
def test_unknown_command(self):
|
|
98
|
+
"""CLI should handle unknown commands gracefully."""
|
|
99
|
+
runner = CliRunner(env={"ENSUE_TOKEN": "test"})
|
|
100
|
+
# Mock client.list_tools to return known tools
|
|
101
|
+
with patch("ensue_cli.cli.client.list_tools") as mock_list:
|
|
102
|
+
mock_list.return_value = [{"name": "known_cmd", "inputSchema": {}}]
|
|
103
|
+
result = runner.invoke(main, ["unknown_command"])
|
|
104
|
+
assert result.exit_code != 0
|
|
105
|
+
|
|
106
|
+
def test_connection_error_on_list(self):
|
|
107
|
+
"""CLI should handle connection errors when listing tools."""
|
|
108
|
+
runner = CliRunner(env={"ENSUE_TOKEN": "invalid"})
|
|
109
|
+
|
|
110
|
+
with patch("ensue_cli.cli.client.list_tools", side_effect=Exception("Connection failed")):
|
|
111
|
+
result = runner.invoke(main, ["--help"])
|
|
112
|
+
assert "Connection error" in result.output
|