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.
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .eggs/
8
+ .coverage
@@ -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/*
@@ -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,3 @@
1
+ """Ensue CLI - Command line interface for the Ensue Memory Network."""
2
+
3
+ __version__ = "0.1.0"
@@ -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