mcpforunityserver 9.0.8__py3-none-any.whl → 9.2.0__py3-none-any.whl
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.
- cli/__init__.py +3 -0
- cli/commands/__init__.py +3 -0
- cli/commands/animation.py +87 -0
- cli/commands/asset.py +310 -0
- cli/commands/audio.py +133 -0
- cli/commands/batch.py +184 -0
- cli/commands/code.py +189 -0
- cli/commands/component.py +212 -0
- cli/commands/editor.py +487 -0
- cli/commands/gameobject.py +510 -0
- cli/commands/instance.py +101 -0
- cli/commands/lighting.py +128 -0
- cli/commands/material.py +268 -0
- cli/commands/prefab.py +144 -0
- cli/commands/scene.py +255 -0
- cli/commands/script.py +240 -0
- cli/commands/shader.py +238 -0
- cli/commands/ui.py +263 -0
- cli/commands/vfx.py +439 -0
- cli/main.py +248 -0
- cli/utils/__init__.py +31 -0
- cli/utils/config.py +58 -0
- cli/utils/connection.py +191 -0
- cli/utils/output.py +195 -0
- main.py +174 -60
- {mcpforunityserver-9.0.8.dist-info → mcpforunityserver-9.2.0.dist-info}/METADATA +3 -2
- {mcpforunityserver-9.0.8.dist-info → mcpforunityserver-9.2.0.dist-info}/RECORD +37 -14
- {mcpforunityserver-9.0.8.dist-info → mcpforunityserver-9.2.0.dist-info}/WHEEL +1 -1
- {mcpforunityserver-9.0.8.dist-info → mcpforunityserver-9.2.0.dist-info}/entry_points.txt +1 -0
- {mcpforunityserver-9.0.8.dist-info → mcpforunityserver-9.2.0.dist-info}/top_level.txt +1 -1
- services/custom_tool_service.py +168 -13
- services/resources/__init__.py +6 -1
- services/tools/__init__.py +6 -1
- services/tools/refresh_unity.py +66 -16
- transport/legacy/unity_connection.py +26 -8
- transport/plugin_hub.py +17 -0
- __init__.py +0 -0
- {mcpforunityserver-9.0.8.dist-info → mcpforunityserver-9.2.0.dist-info}/licenses/LICENSE +0 -0
cli/utils/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""CLI utility modules."""
|
|
2
|
+
|
|
3
|
+
from cli.utils.config import CLIConfig, get_config, set_config
|
|
4
|
+
from cli.utils.connection import (
|
|
5
|
+
run_command,
|
|
6
|
+
run_check_connection,
|
|
7
|
+
run_list_instances,
|
|
8
|
+
UnityConnectionError,
|
|
9
|
+
)
|
|
10
|
+
from cli.utils.output import (
|
|
11
|
+
format_output,
|
|
12
|
+
print_success,
|
|
13
|
+
print_error,
|
|
14
|
+
print_warning,
|
|
15
|
+
print_info,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"CLIConfig",
|
|
20
|
+
"UnityConnectionError",
|
|
21
|
+
"format_output",
|
|
22
|
+
"get_config",
|
|
23
|
+
"print_error",
|
|
24
|
+
"print_info",
|
|
25
|
+
"print_success",
|
|
26
|
+
"print_warning",
|
|
27
|
+
"run_check_connection",
|
|
28
|
+
"run_command",
|
|
29
|
+
"run_list_instances",
|
|
30
|
+
"set_config",
|
|
31
|
+
]
|
cli/utils/config.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""CLI Configuration utilities."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class CLIConfig:
|
|
10
|
+
"""Configuration for CLI connection to Unity."""
|
|
11
|
+
|
|
12
|
+
host: str = "127.0.0.1"
|
|
13
|
+
port: int = 8080
|
|
14
|
+
timeout: int = 30
|
|
15
|
+
format: str = "text" # text, json, table
|
|
16
|
+
unity_instance: Optional[str] = None
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def from_env(cls) -> "CLIConfig":
|
|
20
|
+
port_raw = os.environ.get("UNITY_MCP_HTTP_PORT", "8080")
|
|
21
|
+
try:
|
|
22
|
+
port = int(port_raw)
|
|
23
|
+
except (ValueError, TypeError):
|
|
24
|
+
raise ValueError(
|
|
25
|
+
f"Invalid UNITY_MCP_HTTP_PORT value: {port_raw!r}")
|
|
26
|
+
|
|
27
|
+
timeout_raw = os.environ.get("UNITY_MCP_TIMEOUT", "30")
|
|
28
|
+
try:
|
|
29
|
+
timeout = int(timeout_raw)
|
|
30
|
+
except (ValueError, TypeError):
|
|
31
|
+
raise ValueError(
|
|
32
|
+
f"Invalid UNITY_MCP_TIMEOUT value: {timeout_raw!r}")
|
|
33
|
+
|
|
34
|
+
return cls(
|
|
35
|
+
host=os.environ.get("UNITY_MCP_HOST", "127.0.0.1"),
|
|
36
|
+
port=port,
|
|
37
|
+
timeout=timeout,
|
|
38
|
+
format=os.environ.get("UNITY_MCP_FORMAT", "text"),
|
|
39
|
+
unity_instance=os.environ.get("UNITY_MCP_INSTANCE"),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Global config instance
|
|
44
|
+
_config: Optional[CLIConfig] = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_config() -> CLIConfig:
|
|
48
|
+
"""Get the current CLI configuration."""
|
|
49
|
+
global _config
|
|
50
|
+
if _config is None:
|
|
51
|
+
_config = CLIConfig.from_env()
|
|
52
|
+
return _config
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def set_config(config: CLIConfig) -> None:
|
|
56
|
+
"""Set the CLI configuration."""
|
|
57
|
+
global _config
|
|
58
|
+
_config = config
|
cli/utils/connection.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Connection utilities for CLI to communicate with Unity via MCP server."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from cli.utils.config import get_config, CLIConfig
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class UnityConnectionError(Exception):
|
|
14
|
+
"""Raised when connection to Unity fails."""
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def warn_if_remote_host(config: CLIConfig) -> None:
|
|
19
|
+
"""Warn user if connecting to a non-localhost server.
|
|
20
|
+
|
|
21
|
+
This is a security measure to alert users that connecting to remote
|
|
22
|
+
servers exposes Unity control to potential network attacks.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
config: CLI configuration with host setting
|
|
26
|
+
"""
|
|
27
|
+
import click
|
|
28
|
+
|
|
29
|
+
local_hosts = ("127.0.0.1", "localhost", "::1", "0.0.0.0")
|
|
30
|
+
if config.host.lower() not in local_hosts:
|
|
31
|
+
click.echo(
|
|
32
|
+
"⚠️ Security Warning: Connecting to non-localhost server.\n"
|
|
33
|
+
" The MCP CLI has no authentication. Anyone on the network could\n"
|
|
34
|
+
" intercept commands or send unauthorized commands to Unity.\n"
|
|
35
|
+
" Only proceed if you trust this network.\n",
|
|
36
|
+
err=True
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def send_command(
|
|
41
|
+
command_type: str,
|
|
42
|
+
params: Dict[str, Any],
|
|
43
|
+
config: Optional[CLIConfig] = None,
|
|
44
|
+
timeout: Optional[int] = None,
|
|
45
|
+
) -> Dict[str, Any]:
|
|
46
|
+
"""Send a command to Unity via the MCP HTTP server.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
command_type: The command type (e.g., 'manage_gameobject', 'manage_scene')
|
|
50
|
+
params: Command parameters
|
|
51
|
+
config: Optional CLI configuration
|
|
52
|
+
timeout: Optional timeout override
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Response dict from Unity
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
UnityConnectionError: If connection fails
|
|
59
|
+
"""
|
|
60
|
+
cfg = config or get_config()
|
|
61
|
+
url = f"http://{cfg.host}:{cfg.port}/api/command"
|
|
62
|
+
|
|
63
|
+
payload = {
|
|
64
|
+
"type": command_type,
|
|
65
|
+
"params": params,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if cfg.unity_instance:
|
|
69
|
+
payload["unity_instance"] = cfg.unity_instance
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
async with httpx.AsyncClient() as client:
|
|
73
|
+
response = await client.post(
|
|
74
|
+
url,
|
|
75
|
+
json=payload,
|
|
76
|
+
timeout=timeout or cfg.timeout,
|
|
77
|
+
)
|
|
78
|
+
response.raise_for_status()
|
|
79
|
+
return response.json()
|
|
80
|
+
except httpx.ConnectError as e:
|
|
81
|
+
raise UnityConnectionError(
|
|
82
|
+
f"Cannot connect to Unity MCP server at {cfg.host}:{cfg.port}. "
|
|
83
|
+
f"Make sure the server is running and Unity is connected.\n"
|
|
84
|
+
f"Error: {e}"
|
|
85
|
+
)
|
|
86
|
+
except httpx.TimeoutException:
|
|
87
|
+
raise UnityConnectionError(
|
|
88
|
+
f"Connection to Unity timed out after {timeout or cfg.timeout}s. "
|
|
89
|
+
f"Unity may be busy or unresponsive."
|
|
90
|
+
)
|
|
91
|
+
except httpx.HTTPStatusError as e:
|
|
92
|
+
raise UnityConnectionError(
|
|
93
|
+
f"HTTP error from server: {e.response.status_code} - {e.response.text}"
|
|
94
|
+
)
|
|
95
|
+
except Exception as e:
|
|
96
|
+
raise UnityConnectionError(f"Unexpected error: {e}")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def run_command(
|
|
100
|
+
command_type: str,
|
|
101
|
+
params: Dict[str, Any],
|
|
102
|
+
config: Optional[CLIConfig] = None,
|
|
103
|
+
timeout: Optional[int] = None,
|
|
104
|
+
) -> Dict[str, Any]:
|
|
105
|
+
"""Synchronous wrapper for send_command.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
command_type: The command type
|
|
109
|
+
params: Command parameters
|
|
110
|
+
config: Optional CLI configuration
|
|
111
|
+
timeout: Optional timeout override
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Response dict from Unity
|
|
115
|
+
"""
|
|
116
|
+
return asyncio.run(send_command(command_type, params, config, timeout))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def check_connection(config: Optional[CLIConfig] = None) -> bool:
|
|
120
|
+
"""Check if we can connect to the Unity MCP server.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
config: Optional CLI configuration
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
True if connection successful, False otherwise
|
|
127
|
+
"""
|
|
128
|
+
cfg = config or get_config()
|
|
129
|
+
url = f"http://{cfg.host}:{cfg.port}/health"
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
async with httpx.AsyncClient() as client:
|
|
133
|
+
response = await client.get(url, timeout=5)
|
|
134
|
+
return response.status_code == 200
|
|
135
|
+
except Exception:
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def run_check_connection(config: Optional[CLIConfig] = None) -> bool:
|
|
140
|
+
"""Synchronous wrapper for check_connection."""
|
|
141
|
+
return asyncio.run(check_connection(config))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
async def list_unity_instances(config: Optional[CLIConfig] = None) -> Dict[str, Any]:
|
|
145
|
+
"""List available Unity instances.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
config: Optional CLI configuration
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Dict with list of Unity instances
|
|
152
|
+
"""
|
|
153
|
+
cfg = config or get_config()
|
|
154
|
+
|
|
155
|
+
# Try the new /api/instances endpoint first, fall back to /plugin/sessions
|
|
156
|
+
urls_to_try = [
|
|
157
|
+
f"http://{cfg.host}:{cfg.port}/api/instances",
|
|
158
|
+
f"http://{cfg.host}:{cfg.port}/plugin/sessions",
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
async with httpx.AsyncClient() as client:
|
|
162
|
+
for url in urls_to_try:
|
|
163
|
+
try:
|
|
164
|
+
response = await client.get(url, timeout=10)
|
|
165
|
+
if response.status_code == 200:
|
|
166
|
+
data = response.json()
|
|
167
|
+
# Normalize response format
|
|
168
|
+
if "instances" in data:
|
|
169
|
+
return data
|
|
170
|
+
elif "sessions" in data:
|
|
171
|
+
# Convert sessions format to instances format
|
|
172
|
+
instances = []
|
|
173
|
+
for session_id, details in data["sessions"].items():
|
|
174
|
+
instances.append({
|
|
175
|
+
"session_id": session_id,
|
|
176
|
+
"project": details.get("project", "Unknown"),
|
|
177
|
+
"hash": details.get("hash", ""),
|
|
178
|
+
"unity_version": details.get("unity_version", "Unknown"),
|
|
179
|
+
"connected_at": details.get("connected_at", ""),
|
|
180
|
+
})
|
|
181
|
+
return {"success": True, "instances": instances}
|
|
182
|
+
except Exception:
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
raise UnityConnectionError(
|
|
186
|
+
"Failed to list Unity instances: No working endpoint found")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def run_list_instances(config: Optional[CLIConfig] = None) -> Dict[str, Any]:
|
|
190
|
+
"""Synchronous wrapper for list_unity_instances."""
|
|
191
|
+
return asyncio.run(list_unity_instances(config))
|
cli/utils/output.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Output formatting utilities for CLI."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def format_output(data: Any, format_type: str = "text") -> str:
|
|
10
|
+
"""Format output based on requested format type.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
data: Data to format
|
|
14
|
+
format_type: One of 'text', 'json', 'table'
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
Formatted string
|
|
18
|
+
"""
|
|
19
|
+
if format_type == "json":
|
|
20
|
+
return format_as_json(data)
|
|
21
|
+
elif format_type == "table":
|
|
22
|
+
return format_as_table(data)
|
|
23
|
+
else:
|
|
24
|
+
return format_as_text(data)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def format_as_json(data: Any) -> str:
|
|
28
|
+
"""Format data as pretty-printed JSON."""
|
|
29
|
+
try:
|
|
30
|
+
return json.dumps(data, indent=2, default=str)
|
|
31
|
+
except (TypeError, ValueError) as e:
|
|
32
|
+
return json.dumps({"error": f"JSON serialization failed: {e}", "raw": str(data)})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def format_as_text(data: Any, indent: int = 0) -> str:
|
|
36
|
+
"""Format data as human-readable text."""
|
|
37
|
+
prefix = " " * indent
|
|
38
|
+
|
|
39
|
+
if data is None:
|
|
40
|
+
return f"{prefix}(none)"
|
|
41
|
+
|
|
42
|
+
if isinstance(data, dict):
|
|
43
|
+
# Check for error response
|
|
44
|
+
if "success" in data and not data.get("success"):
|
|
45
|
+
error = data.get("error") or data.get("message") or "Unknown error"
|
|
46
|
+
return f"{prefix}❌ Error: {error}"
|
|
47
|
+
|
|
48
|
+
# Check for success response with data
|
|
49
|
+
if "success" in data and data.get("success"):
|
|
50
|
+
result = data.get("data") or data.get("result") or data
|
|
51
|
+
if result != data:
|
|
52
|
+
return format_as_text(result, indent)
|
|
53
|
+
|
|
54
|
+
lines = []
|
|
55
|
+
for key, value in data.items():
|
|
56
|
+
if key in ("success", "error", "message") and "success" in data:
|
|
57
|
+
continue # Skip meta fields
|
|
58
|
+
if isinstance(value, dict):
|
|
59
|
+
lines.append(f"{prefix}{key}:")
|
|
60
|
+
lines.append(format_as_text(value, indent + 1))
|
|
61
|
+
elif isinstance(value, list):
|
|
62
|
+
lines.append(f"{prefix}{key}: [{len(value)} items]")
|
|
63
|
+
if len(value) <= 10:
|
|
64
|
+
for i, item in enumerate(value):
|
|
65
|
+
lines.append(
|
|
66
|
+
f"{prefix} [{i}] {_format_list_item(item)}")
|
|
67
|
+
else:
|
|
68
|
+
for i, item in enumerate(value[:5]):
|
|
69
|
+
lines.append(
|
|
70
|
+
f"{prefix} [{i}] {_format_list_item(item)}")
|
|
71
|
+
lines.append(f"{prefix} ... ({len(value) - 10} more)")
|
|
72
|
+
for i, item in enumerate(value[-5:], len(value) - 5):
|
|
73
|
+
lines.append(
|
|
74
|
+
f"{prefix} [{i}] {_format_list_item(item)}")
|
|
75
|
+
else:
|
|
76
|
+
lines.append(f"{prefix}{key}: {value}")
|
|
77
|
+
return "\n".join(lines)
|
|
78
|
+
|
|
79
|
+
if isinstance(data, list):
|
|
80
|
+
if not data:
|
|
81
|
+
return f"{prefix}(empty list)"
|
|
82
|
+
lines = [f"{prefix}[{len(data)} items]"]
|
|
83
|
+
for i, item in enumerate(data[:20]):
|
|
84
|
+
lines.append(f"{prefix} [{i}] {_format_list_item(item)}")
|
|
85
|
+
if len(data) > 20:
|
|
86
|
+
lines.append(f"{prefix} ... ({len(data) - 20} more)")
|
|
87
|
+
return "\n".join(lines)
|
|
88
|
+
|
|
89
|
+
return f"{prefix}{data}"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _format_list_item(item: Any) -> str:
|
|
93
|
+
"""Format a single list item."""
|
|
94
|
+
if isinstance(item, dict):
|
|
95
|
+
# Try to find a name/id field for display
|
|
96
|
+
name = item.get("name") or item.get(
|
|
97
|
+
"Name") or item.get("id") or item.get("Id")
|
|
98
|
+
if name:
|
|
99
|
+
extra = ""
|
|
100
|
+
if "instanceID" in item:
|
|
101
|
+
extra = f" (ID: {item['instanceID']})"
|
|
102
|
+
elif "path" in item:
|
|
103
|
+
extra = f" ({item['path']})"
|
|
104
|
+
return f"{name}{extra}"
|
|
105
|
+
# Fallback to compact representation
|
|
106
|
+
return json.dumps(item, default=str)[:80]
|
|
107
|
+
return str(item)[:80]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def format_as_table(data: Any) -> str:
|
|
111
|
+
"""Format data as an ASCII table."""
|
|
112
|
+
if isinstance(data, dict):
|
|
113
|
+
# Check for success response with data
|
|
114
|
+
if "success" in data and data.get("success"):
|
|
115
|
+
result = data.get("data") or data.get(
|
|
116
|
+
"result") or data.get("items")
|
|
117
|
+
if isinstance(result, list):
|
|
118
|
+
return _build_table(result)
|
|
119
|
+
|
|
120
|
+
# Single dict as key-value table
|
|
121
|
+
rows = [[str(k), str(v)[:60]] for k, v in data.items()]
|
|
122
|
+
return _build_table(rows, headers=["Key", "Value"])
|
|
123
|
+
|
|
124
|
+
if isinstance(data, list):
|
|
125
|
+
return _build_table(data)
|
|
126
|
+
|
|
127
|
+
return str(data)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _build_table(data: list[Any], headers: list[str] | None = None) -> str:
|
|
131
|
+
"""Build an ASCII table from list data."""
|
|
132
|
+
if not data:
|
|
133
|
+
return "(no data)"
|
|
134
|
+
|
|
135
|
+
# Convert list of dicts to rows
|
|
136
|
+
if isinstance(data[0], dict):
|
|
137
|
+
if headers is None:
|
|
138
|
+
headers = list(data[0].keys())
|
|
139
|
+
rows = [[str(item.get(h, ""))[:40] for h in headers] for item in data]
|
|
140
|
+
elif isinstance(data[0], (list, tuple)):
|
|
141
|
+
rows = [[str(cell)[:40] for cell in row] for row in data]
|
|
142
|
+
if headers is None:
|
|
143
|
+
headers = [f"Col{i}" for i in range(len(data[0]))]
|
|
144
|
+
else:
|
|
145
|
+
rows = [[str(item)[:60]] for item in data]
|
|
146
|
+
headers = headers or ["Value"]
|
|
147
|
+
|
|
148
|
+
# Calculate column widths
|
|
149
|
+
col_widths = [len(h) for h in headers]
|
|
150
|
+
for row in rows:
|
|
151
|
+
for i, cell in enumerate(row):
|
|
152
|
+
if i < len(col_widths):
|
|
153
|
+
col_widths[i] = max(col_widths[i], len(cell))
|
|
154
|
+
|
|
155
|
+
# Build table
|
|
156
|
+
lines = []
|
|
157
|
+
|
|
158
|
+
# Header
|
|
159
|
+
header_line = " | ".join(
|
|
160
|
+
h.ljust(col_widths[i]) for i, h in enumerate(headers))
|
|
161
|
+
lines.append(header_line)
|
|
162
|
+
lines.append("-+-".join("-" * w for w in col_widths))
|
|
163
|
+
|
|
164
|
+
# Rows
|
|
165
|
+
for row in rows[:50]: # Limit rows
|
|
166
|
+
row_line = " | ".join(
|
|
167
|
+
(row[i] if i < len(row) else "").ljust(col_widths[i])
|
|
168
|
+
for i in range(len(headers))
|
|
169
|
+
)
|
|
170
|
+
lines.append(row_line)
|
|
171
|
+
|
|
172
|
+
if len(rows) > 50:
|
|
173
|
+
lines.append(f"... ({len(rows) - 50} more rows)")
|
|
174
|
+
|
|
175
|
+
return "\n".join(lines)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def print_success(message: str) -> None:
|
|
179
|
+
"""Print a success message."""
|
|
180
|
+
click.echo(f"✅ {message}")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def print_error(message: str) -> None:
|
|
184
|
+
"""Print an error message to stderr."""
|
|
185
|
+
click.echo(f"❌ {message}", err=True)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def print_warning(message: str) -> None:
|
|
189
|
+
"""Print a warning message."""
|
|
190
|
+
click.echo(f"⚠️ {message}")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def print_info(message: str) -> None:
|
|
194
|
+
"""Print an info message."""
|
|
195
|
+
click.echo(f"ℹ️ {message}")
|