hatch-xclam 0.7.1.dev3__py3-none-any.whl → 0.8.0.dev1__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.
- hatch/__init__.py +1 -1
- hatch/cli/__init__.py +71 -0
- hatch/cli/__main__.py +1035 -0
- hatch/cli/cli_env.py +865 -0
- hatch/cli/cli_mcp.py +1965 -0
- hatch/cli/cli_package.py +566 -0
- hatch/cli/cli_system.py +136 -0
- hatch/cli/cli_utils.py +1289 -0
- hatch/cli_hatch.py +160 -2838
- hatch/mcp_host_config/__init__.py +10 -10
- hatch/mcp_host_config/adapters/__init__.py +34 -0
- hatch/mcp_host_config/adapters/base.py +170 -0
- hatch/mcp_host_config/adapters/claude.py +105 -0
- hatch/mcp_host_config/adapters/codex.py +104 -0
- hatch/mcp_host_config/adapters/cursor.py +83 -0
- hatch/mcp_host_config/adapters/gemini.py +75 -0
- hatch/mcp_host_config/adapters/kiro.py +78 -0
- hatch/mcp_host_config/adapters/lmstudio.py +79 -0
- hatch/mcp_host_config/adapters/registry.py +149 -0
- hatch/mcp_host_config/adapters/vscode.py +83 -0
- hatch/mcp_host_config/backup.py +5 -3
- hatch/mcp_host_config/fields.py +126 -0
- hatch/mcp_host_config/models.py +161 -456
- hatch/mcp_host_config/reporting.py +57 -16
- hatch/mcp_host_config/strategies.py +155 -87
- hatch/template_generator.py +1 -1
- {hatch_xclam-0.7.1.dev3.dist-info → hatch_xclam-0.8.0.dev1.dist-info}/METADATA +3 -2
- {hatch_xclam-0.7.1.dev3.dist-info → hatch_xclam-0.8.0.dev1.dist-info}/RECORD +52 -43
- {hatch_xclam-0.7.1.dev3.dist-info → hatch_xclam-0.8.0.dev1.dist-info}/WHEEL +1 -1
- hatch_xclam-0.8.0.dev1.dist-info/entry_points.txt +2 -0
- tests/cli_test_utils.py +280 -0
- tests/integration/cli/__init__.py +14 -0
- tests/integration/cli/test_cli_reporter_integration.py +2439 -0
- tests/integration/mcp/__init__.py +0 -0
- tests/integration/mcp/test_adapter_serialization.py +173 -0
- tests/regression/cli/__init__.py +16 -0
- tests/regression/cli/test_color_logic.py +268 -0
- tests/regression/cli/test_consequence_type.py +298 -0
- tests/regression/cli/test_error_formatting.py +328 -0
- tests/regression/cli/test_result_reporter.py +586 -0
- tests/regression/cli/test_table_formatter.py +211 -0
- tests/regression/mcp/__init__.py +0 -0
- tests/regression/mcp/test_field_filtering.py +162 -0
- tests/test_cli_version.py +7 -5
- tests/test_data/fixtures/cli_reporter_fixtures.py +184 -0
- tests/unit/__init__.py +0 -0
- tests/unit/mcp/__init__.py +0 -0
- tests/unit/mcp/test_adapter_protocol.py +138 -0
- tests/unit/mcp/test_adapter_registry.py +158 -0
- tests/unit/mcp/test_config_model.py +146 -0
- hatch_xclam-0.7.1.dev3.dist-info/entry_points.txt +0 -2
- tests/integration/test_mcp_kiro_integration.py +0 -153
- tests/regression/test_mcp_codex_backup_integration.py +0 -162
- tests/regression/test_mcp_codex_host_strategy.py +0 -163
- tests/regression/test_mcp_codex_model_validation.py +0 -117
- tests/regression/test_mcp_kiro_backup_integration.py +0 -241
- tests/regression/test_mcp_kiro_cli_integration.py +0 -141
- tests/regression/test_mcp_kiro_decorator_registration.py +0 -71
- tests/regression/test_mcp_kiro_host_strategy.py +0 -214
- tests/regression/test_mcp_kiro_model_validation.py +0 -116
- tests/regression/test_mcp_kiro_omni_conversion.py +0 -104
- tests/test_mcp_atomic_operations.py +0 -276
- tests/test_mcp_backup_integration.py +0 -308
- tests/test_mcp_cli_all_host_specific_args.py +0 -496
- tests/test_mcp_cli_backup_management.py +0 -295
- tests/test_mcp_cli_direct_management.py +0 -456
- tests/test_mcp_cli_discovery_listing.py +0 -582
- tests/test_mcp_cli_host_config_integration.py +0 -823
- tests/test_mcp_cli_package_management.py +0 -360
- tests/test_mcp_cli_partial_updates.py +0 -859
- tests/test_mcp_environment_integration.py +0 -520
- tests/test_mcp_host_config_backup.py +0 -257
- tests/test_mcp_host_configuration_manager.py +0 -331
- tests/test_mcp_host_registry_decorator.py +0 -348
- tests/test_mcp_pydantic_architecture_v4.py +0 -603
- tests/test_mcp_server_config_models.py +0 -242
- tests/test_mcp_server_config_type_field.py +0 -221
- tests/test_mcp_sync_functionality.py +0 -316
- tests/test_mcp_user_feedback_reporting.py +0 -359
- {hatch_xclam-0.7.1.dev3.dist-info → hatch_xclam-0.8.0.dev1.dist-info}/licenses/LICENSE +0 -0
- {hatch_xclam-0.7.1.dev3.dist-info → hatch_xclam-0.8.0.dev1.dist-info}/top_level.txt +0 -0
hatch/cli/cli_utils.py
ADDED
|
@@ -0,0 +1,1289 @@
|
|
|
1
|
+
"""Shared utilities for Hatch CLI.
|
|
2
|
+
|
|
3
|
+
This module provides common utilities used across CLI handlers, extracted
|
|
4
|
+
from the monolithic cli_hatch.py to enable cleaner handler-based architecture
|
|
5
|
+
and easier testing.
|
|
6
|
+
|
|
7
|
+
Constants:
|
|
8
|
+
EXIT_SUCCESS (int): Exit code for successful operations (0)
|
|
9
|
+
EXIT_ERROR (int): Exit code for failed operations (1)
|
|
10
|
+
|
|
11
|
+
Classes:
|
|
12
|
+
Color: ANSI color codes with brightness variants for tense distinction
|
|
13
|
+
|
|
14
|
+
Functions:
|
|
15
|
+
get_hatch_version(): Retrieve version from package metadata
|
|
16
|
+
request_confirmation(): Interactive user confirmation with auto-approve support
|
|
17
|
+
parse_env_vars(): Parse KEY=VALUE environment variable arguments
|
|
18
|
+
parse_header(): Parse KEY=VALUE HTTP header arguments
|
|
19
|
+
parse_input(): Parse VSCode input configurations
|
|
20
|
+
parse_host_list(): Parse comma-separated host list or 'all'
|
|
21
|
+
get_package_mcp_server_config(): Extract MCP server config from package metadata
|
|
22
|
+
_colors_enabled(): Check if color output should be enabled
|
|
23
|
+
|
|
24
|
+
Example:
|
|
25
|
+
>>> from hatch.cli.cli_utils import EXIT_SUCCESS, EXIT_ERROR, request_confirmation
|
|
26
|
+
>>> if request_confirmation("Proceed?", auto_approve=False):
|
|
27
|
+
... return EXIT_SUCCESS
|
|
28
|
+
... else:
|
|
29
|
+
... return EXIT_ERROR
|
|
30
|
+
|
|
31
|
+
>>> from hatch.cli.cli_utils import parse_env_vars
|
|
32
|
+
>>> env_dict = parse_env_vars(["API_KEY=secret", "DEBUG=true"])
|
|
33
|
+
>>> # Returns: {"API_KEY": "secret", "DEBUG": "true"}
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from enum import Enum
|
|
37
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# =============================================================================
|
|
41
|
+
# Color Infrastructure for CLI Output
|
|
42
|
+
# =============================================================================
|
|
43
|
+
|
|
44
|
+
import os as _os
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _supports_truecolor() -> bool:
|
|
48
|
+
"""Detect if terminal supports 24-bit true color.
|
|
49
|
+
|
|
50
|
+
Checks environment variables and terminal identifiers to determine
|
|
51
|
+
if the terminal supports true color (24-bit RGB) output.
|
|
52
|
+
|
|
53
|
+
Reference: R12 §3.1 (12-enhancing_colors_v0.md)
|
|
54
|
+
|
|
55
|
+
Detection Logic:
|
|
56
|
+
1. COLORTERM='truecolor' or '24bit' → True
|
|
57
|
+
2. TERM contains 'truecolor' or '24bit' → True
|
|
58
|
+
3. TERM_PROGRAM in known true color terminals → True
|
|
59
|
+
4. WT_SESSION set (Windows Terminal) → True
|
|
60
|
+
5. Otherwise → False (fallback to 16-color)
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
bool: True if terminal supports true color, False otherwise.
|
|
64
|
+
|
|
65
|
+
Example:
|
|
66
|
+
>>> if _supports_truecolor():
|
|
67
|
+
... # Use 24-bit RGB color codes
|
|
68
|
+
... color = "\\033[38;2;128;201;144m"
|
|
69
|
+
... else:
|
|
70
|
+
... # Use 16-color ANSI codes
|
|
71
|
+
... color = "\\033[92m"
|
|
72
|
+
"""
|
|
73
|
+
# Check COLORTERM for 'truecolor' or '24bit'
|
|
74
|
+
colorterm = _os.environ.get('COLORTERM', '')
|
|
75
|
+
if colorterm in ('truecolor', '24bit'):
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
# Check TERM for truecolor indicators
|
|
79
|
+
term = _os.environ.get('TERM', '')
|
|
80
|
+
if 'truecolor' in term or '24bit' in term:
|
|
81
|
+
return True
|
|
82
|
+
|
|
83
|
+
# Check TERM_PROGRAM for known true color terminals
|
|
84
|
+
term_program = _os.environ.get('TERM_PROGRAM', '')
|
|
85
|
+
if term_program in ('iTerm.app', 'Apple_Terminal', 'vscode', 'Hyper'):
|
|
86
|
+
return True
|
|
87
|
+
|
|
88
|
+
# Check WT_SESSION for Windows Terminal
|
|
89
|
+
if _os.environ.get('WT_SESSION'):
|
|
90
|
+
return True
|
|
91
|
+
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# Module-level constant for true color support detection
|
|
96
|
+
# Evaluated once at module load time
|
|
97
|
+
TRUECOLOR = _supports_truecolor()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class Color(Enum):
|
|
101
|
+
"""HCL color palette with true color support and 16-color fallback.
|
|
102
|
+
|
|
103
|
+
Uses a qualitative HCL palette with equal perceived brightness
|
|
104
|
+
for accessibility and visual harmony. True color (24-bit) is used
|
|
105
|
+
when supported, falling back to standard 16-color ANSI codes.
|
|
106
|
+
|
|
107
|
+
Reference: R12 §3.2 (12-enhancing_colors_v0.md)
|
|
108
|
+
Reference: R06 §3.1 (06-dependency_analysis_v0.md)
|
|
109
|
+
Reference: R03 §4 (03-mutation_output_specification_v0.md)
|
|
110
|
+
|
|
111
|
+
HCL Palette Values:
|
|
112
|
+
GREEN #80C990 → rgb(128, 201, 144)
|
|
113
|
+
RED #EFA6A2 → rgb(239, 166, 162)
|
|
114
|
+
YELLOW #C8C874 → rgb(200, 200, 116)
|
|
115
|
+
BLUE #A3B8EF → rgb(163, 184, 239)
|
|
116
|
+
MAGENTA #E6A3DC → rgb(230, 163, 220)
|
|
117
|
+
CYAN #50CACD → rgb(80, 202, 205)
|
|
118
|
+
GRAY #808080 → rgb(128, 128, 128)
|
|
119
|
+
AMBER #A69460 → rgb(166, 148, 96)
|
|
120
|
+
|
|
121
|
+
Color Semantics:
|
|
122
|
+
Green → Constructive (CREATE, ADD, CONFIGURE, INSTALL, INITIALIZE)
|
|
123
|
+
Blue → Recovery (RESTORE)
|
|
124
|
+
Red → Destructive (REMOVE, DELETE, CLEAN)
|
|
125
|
+
Yellow → Modification (SET, UPDATE)
|
|
126
|
+
Magenta → Transfer (SYNC)
|
|
127
|
+
Cyan → Informational (VALIDATE)
|
|
128
|
+
Gray → No-op (SKIP, EXISTS, UNCHANGED)
|
|
129
|
+
Amber → Entity highlighting (show commands)
|
|
130
|
+
|
|
131
|
+
Example:
|
|
132
|
+
>>> from hatch.cli.cli_utils import Color, _colors_enabled
|
|
133
|
+
>>> if _colors_enabled():
|
|
134
|
+
... print(f"{Color.GREEN.value}Success{Color.RESET.value}")
|
|
135
|
+
... else:
|
|
136
|
+
... print("Success")
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
# === Bright colors (execution results - past tense) ===
|
|
140
|
+
|
|
141
|
+
# Green #80C990 - CREATE, ADD, CONFIGURE, INSTALL, INITIALIZE
|
|
142
|
+
GREEN = "\033[38;2;128;201;144m" if TRUECOLOR else "\033[92m"
|
|
143
|
+
|
|
144
|
+
# Red #EFA6A2 - REMOVE, DELETE, CLEAN
|
|
145
|
+
RED = "\033[38;2;239;166;162m" if TRUECOLOR else "\033[91m"
|
|
146
|
+
|
|
147
|
+
# Yellow #C8C874 - SET, UPDATE
|
|
148
|
+
YELLOW = "\033[38;2;200;200;116m" if TRUECOLOR else "\033[93m"
|
|
149
|
+
|
|
150
|
+
# Blue #A3B8EF - RESTORE
|
|
151
|
+
BLUE = "\033[38;2;163;184;239m" if TRUECOLOR else "\033[94m"
|
|
152
|
+
|
|
153
|
+
# Magenta #E6A3DC - SYNC
|
|
154
|
+
MAGENTA = "\033[38;2;230;163;220m" if TRUECOLOR else "\033[95m"
|
|
155
|
+
|
|
156
|
+
# Cyan #50CACD - VALIDATE
|
|
157
|
+
CYAN = "\033[38;2;80;202;205m" if TRUECOLOR else "\033[96m"
|
|
158
|
+
|
|
159
|
+
# === Dim colors (confirmation prompts - present tense) ===
|
|
160
|
+
|
|
161
|
+
# Aquamarine #5ACCAF (green shifted)
|
|
162
|
+
GREEN_DIM = "\033[38;2;90;204;175m" if TRUECOLOR else "\033[2;32m"
|
|
163
|
+
|
|
164
|
+
# Orange #E0AF85 (red shifted)
|
|
165
|
+
RED_DIM = "\033[38;2;224;175;133m" if TRUECOLOR else "\033[2;31m"
|
|
166
|
+
|
|
167
|
+
# Amber #A69460 (yellow shifted)
|
|
168
|
+
YELLOW_DIM = "\033[38;2;166;148;96m" if TRUECOLOR else "\033[2;33m"
|
|
169
|
+
|
|
170
|
+
# Violet #CCACED (blue shifted)
|
|
171
|
+
BLUE_DIM = "\033[38;2;204;172;237m" if TRUECOLOR else "\033[2;34m"
|
|
172
|
+
|
|
173
|
+
# Rose #F2A1C2 (magenta shifted)
|
|
174
|
+
MAGENTA_DIM = "\033[38;2;242;161;194m" if TRUECOLOR else "\033[2;35m"
|
|
175
|
+
|
|
176
|
+
# Azure #74C3E4 (cyan shifted)
|
|
177
|
+
CYAN_DIM = "\033[38;2;116;195;228m" if TRUECOLOR else "\033[2;36m"
|
|
178
|
+
|
|
179
|
+
# === Utility colors ===
|
|
180
|
+
|
|
181
|
+
# Gray #808080 - SKIP, EXISTS, UNCHANGED
|
|
182
|
+
GRAY = "\033[38;2;128;128;128m" if TRUECOLOR else "\033[90m"
|
|
183
|
+
|
|
184
|
+
# Amber #A69460 - Entity name highlighting (NEW)
|
|
185
|
+
AMBER = "\033[38;2;166;148;96m" if TRUECOLOR else "\033[33m"
|
|
186
|
+
|
|
187
|
+
# Reset
|
|
188
|
+
RESET = "\033[0m"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _supports_unicode() -> bool:
|
|
192
|
+
"""Check if terminal supports UTF-8 for unicode symbols.
|
|
193
|
+
|
|
194
|
+
Used to determine whether to use ✓/✗ symbols or ASCII fallback (+/x)
|
|
195
|
+
in partial success reporting.
|
|
196
|
+
|
|
197
|
+
Reference: R13 §12.3 (13-error_message_formatting_v0.md)
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
bool: True if terminal supports UTF-8, False otherwise.
|
|
201
|
+
|
|
202
|
+
Example:
|
|
203
|
+
>>> if _supports_unicode():
|
|
204
|
+
... success_symbol = "✓"
|
|
205
|
+
... else:
|
|
206
|
+
... success_symbol = "+"
|
|
207
|
+
"""
|
|
208
|
+
import locale
|
|
209
|
+
encoding = locale.getpreferredencoding(False)
|
|
210
|
+
return encoding.lower() in ('utf-8', 'utf8')
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _colors_enabled() -> bool:
|
|
214
|
+
"""Check if color output should be enabled.
|
|
215
|
+
|
|
216
|
+
Colors are disabled when:
|
|
217
|
+
- NO_COLOR environment variable is set to a non-empty value
|
|
218
|
+
- stdout is not a TTY (e.g., piped output, CI environment)
|
|
219
|
+
|
|
220
|
+
Reference: R05 §3.4 (05-test_definition_v0.md)
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
bool: True if colors should be enabled, False otherwise.
|
|
224
|
+
|
|
225
|
+
Example:
|
|
226
|
+
>>> if _colors_enabled():
|
|
227
|
+
... print(f"{Color.GREEN.value}colored{Color.RESET.value}")
|
|
228
|
+
... else:
|
|
229
|
+
... print("plain")
|
|
230
|
+
"""
|
|
231
|
+
import os
|
|
232
|
+
import sys
|
|
233
|
+
|
|
234
|
+
# Check NO_COLOR environment variable (https://no-color.org/)
|
|
235
|
+
no_color = os.environ.get('NO_COLOR', '')
|
|
236
|
+
if no_color: # Any non-empty value disables colors
|
|
237
|
+
return False
|
|
238
|
+
|
|
239
|
+
# Check if stdout is a TTY
|
|
240
|
+
if not sys.stdout.isatty():
|
|
241
|
+
return False
|
|
242
|
+
|
|
243
|
+
return True
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def highlight(text: str) -> str:
|
|
247
|
+
"""Apply highlight formatting (bold + amber) to entity names.
|
|
248
|
+
|
|
249
|
+
Used in show commands to emphasize host and server names for
|
|
250
|
+
quick visual scanning of detailed output.
|
|
251
|
+
|
|
252
|
+
Reference: R12 §3.3 (12-enhancing_colors_v0.md)
|
|
253
|
+
Reference: R11 §3.2 (11-enhancing_show_command_v0.md)
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
text: The entity name to highlight
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
str: Text with bold + amber formatting if colors enabled,
|
|
260
|
+
otherwise plain text.
|
|
261
|
+
|
|
262
|
+
Example:
|
|
263
|
+
>>> print(f"MCP Host: {highlight('claude-desktop')}")
|
|
264
|
+
MCP Host: claude-desktop # (bold + amber in TTY)
|
|
265
|
+
"""
|
|
266
|
+
if _colors_enabled():
|
|
267
|
+
# Bold (\033[1m) + Amber color
|
|
268
|
+
return f"\033[1m{Color.AMBER.value}{text}{Color.RESET.value}"
|
|
269
|
+
return text
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class ConsequenceType(Enum):
|
|
273
|
+
"""Action types with dual-tense labels and semantic colors.
|
|
274
|
+
|
|
275
|
+
Each consequence type has:
|
|
276
|
+
- prompt_label: Present tense for confirmation prompts (e.g., "CREATE")
|
|
277
|
+
- result_label: Past tense for execution results (e.g., "CREATED")
|
|
278
|
+
- prompt_color: Dim color for prompts
|
|
279
|
+
- result_color: Bright color for results
|
|
280
|
+
|
|
281
|
+
Reference: R06 §3.2 (06-dependency_analysis_v0.md)
|
|
282
|
+
Reference: R03 §2 (03-mutation_output_specification_v0.md)
|
|
283
|
+
|
|
284
|
+
Categories:
|
|
285
|
+
Constructive (Green): CREATE, ADD, CONFIGURE, INSTALL, INITIALIZE
|
|
286
|
+
Recovery (Blue): RESTORE
|
|
287
|
+
Destructive (Red): REMOVE, DELETE, CLEAN
|
|
288
|
+
Modification (Yellow): SET, UPDATE
|
|
289
|
+
Transfer (Magenta): SYNC
|
|
290
|
+
Informational (Cyan): VALIDATE
|
|
291
|
+
No-op (Gray): SKIP, EXISTS, UNCHANGED
|
|
292
|
+
|
|
293
|
+
Example:
|
|
294
|
+
>>> ct = ConsequenceType.CREATE
|
|
295
|
+
>>> print(f"[{ct.prompt_label}]") # [CREATE]
|
|
296
|
+
>>> print(f"[{ct.result_label}]") # [CREATED]
|
|
297
|
+
"""
|
|
298
|
+
|
|
299
|
+
# Value format: (prompt_label, result_label, prompt_color, result_color)
|
|
300
|
+
|
|
301
|
+
# Constructive actions (Green)
|
|
302
|
+
CREATE = ("CREATE", "CREATED", Color.GREEN_DIM, Color.GREEN)
|
|
303
|
+
ADD = ("ADD", "ADDED", Color.GREEN_DIM, Color.GREEN)
|
|
304
|
+
CONFIGURE = ("CONFIGURE", "CONFIGURED", Color.GREEN_DIM, Color.GREEN)
|
|
305
|
+
INSTALL = ("INSTALL", "INSTALLED", Color.GREEN_DIM, Color.GREEN)
|
|
306
|
+
INITIALIZE = ("INITIALIZE", "INITIALIZED", Color.GREEN_DIM, Color.GREEN)
|
|
307
|
+
|
|
308
|
+
# Recovery actions (Blue)
|
|
309
|
+
RESTORE = ("RESTORE", "RESTORED", Color.BLUE_DIM, Color.BLUE)
|
|
310
|
+
|
|
311
|
+
# Destructive actions (Red)
|
|
312
|
+
REMOVE = ("REMOVE", "REMOVED", Color.RED_DIM, Color.RED)
|
|
313
|
+
DELETE = ("DELETE", "DELETED", Color.RED_DIM, Color.RED)
|
|
314
|
+
CLEAN = ("CLEAN", "CLEANED", Color.RED_DIM, Color.RED)
|
|
315
|
+
|
|
316
|
+
# Modification actions (Yellow)
|
|
317
|
+
SET = ("SET", "SET", Color.YELLOW_DIM, Color.YELLOW) # Irregular: no change
|
|
318
|
+
UPDATE = ("UPDATE", "UPDATED", Color.YELLOW_DIM, Color.YELLOW)
|
|
319
|
+
|
|
320
|
+
# Transfer actions (Magenta)
|
|
321
|
+
SYNC = ("SYNC", "SYNCED", Color.MAGENTA_DIM, Color.MAGENTA)
|
|
322
|
+
|
|
323
|
+
# Informational actions (Cyan)
|
|
324
|
+
VALIDATE = ("VALIDATE", "VALIDATED", Color.CYAN_DIM, Color.CYAN)
|
|
325
|
+
|
|
326
|
+
# No-op actions (Gray) - same color for prompt and result
|
|
327
|
+
SKIP = ("SKIP", "SKIPPED", Color.GRAY, Color.GRAY)
|
|
328
|
+
EXISTS = ("EXISTS", "EXISTS", Color.GRAY, Color.GRAY) # Irregular: no change
|
|
329
|
+
UNCHANGED = ("UNCHANGED", "UNCHANGED", Color.GRAY, Color.GRAY) # Irregular: no change
|
|
330
|
+
|
|
331
|
+
@property
|
|
332
|
+
def prompt_label(self) -> str:
|
|
333
|
+
"""Present tense label for confirmation prompts."""
|
|
334
|
+
return self.value[0]
|
|
335
|
+
|
|
336
|
+
@property
|
|
337
|
+
def result_label(self) -> str:
|
|
338
|
+
"""Past tense label for execution results."""
|
|
339
|
+
return self.value[1]
|
|
340
|
+
|
|
341
|
+
@property
|
|
342
|
+
def prompt_color(self) -> Color:
|
|
343
|
+
"""Dim color for confirmation prompts."""
|
|
344
|
+
return self.value[2]
|
|
345
|
+
|
|
346
|
+
@property
|
|
347
|
+
def result_color(self) -> Color:
|
|
348
|
+
"""Bright color for execution results."""
|
|
349
|
+
return self.value[3]
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# =============================================================================
|
|
353
|
+
# ValidationError Exception for Structured Error Reporting
|
|
354
|
+
# =============================================================================
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class ValidationError(Exception):
|
|
358
|
+
"""Validation error with structured context.
|
|
359
|
+
|
|
360
|
+
Provides structured error information for input validation failures,
|
|
361
|
+
including optional field name and suggestion for resolution.
|
|
362
|
+
|
|
363
|
+
Reference: R13 §4.2.2 (13-error_message_formatting_v0.md)
|
|
364
|
+
|
|
365
|
+
Attributes:
|
|
366
|
+
message: Human-readable error description
|
|
367
|
+
field: Optional field/argument name that caused the error
|
|
368
|
+
suggestion: Optional suggestion for resolving the error
|
|
369
|
+
|
|
370
|
+
Example:
|
|
371
|
+
>>> raise ValidationError(
|
|
372
|
+
... "Invalid host 'vsc'",
|
|
373
|
+
... field="--host",
|
|
374
|
+
... suggestion="Supported hosts: claude-desktop, vscode, cursor"
|
|
375
|
+
... )
|
|
376
|
+
"""
|
|
377
|
+
|
|
378
|
+
def __init__(
|
|
379
|
+
self,
|
|
380
|
+
message: str,
|
|
381
|
+
field: str = None,
|
|
382
|
+
suggestion: str = None
|
|
383
|
+
):
|
|
384
|
+
"""Initialize ValidationError.
|
|
385
|
+
|
|
386
|
+
Args:
|
|
387
|
+
message: Human-readable error description
|
|
388
|
+
field: Optional field/argument name that caused the error
|
|
389
|
+
suggestion: Optional suggestion for resolving the error
|
|
390
|
+
"""
|
|
391
|
+
self.message = message
|
|
392
|
+
self.field = field
|
|
393
|
+
self.suggestion = suggestion
|
|
394
|
+
super().__init__(message)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
from dataclasses import dataclass, field
|
|
398
|
+
from typing import List
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@dataclass
|
|
402
|
+
class Consequence:
|
|
403
|
+
"""Data model for a single consequence (resource or field level).
|
|
404
|
+
|
|
405
|
+
Consequences represent actions that will be or have been performed.
|
|
406
|
+
They can be nested to show resource-level actions with field-level details.
|
|
407
|
+
|
|
408
|
+
Reference: R06 §3.3 (06-dependency_analysis_v0.md)
|
|
409
|
+
Reference: R04 §5.1 (04-reporting_infrastructure_coexistence_v0.md)
|
|
410
|
+
|
|
411
|
+
Attributes:
|
|
412
|
+
type: The ConsequenceType indicating the action category
|
|
413
|
+
message: Human-readable description of the consequence
|
|
414
|
+
children: Nested consequences (e.g., field-level details under resource)
|
|
415
|
+
|
|
416
|
+
Invariants:
|
|
417
|
+
- children only populated for resource-level consequences
|
|
418
|
+
- field-level consequences have empty children list
|
|
419
|
+
- nesting limited to 2 levels (resource → field)
|
|
420
|
+
|
|
421
|
+
Example:
|
|
422
|
+
>>> parent = Consequence(
|
|
423
|
+
... type=ConsequenceType.CONFIGURE,
|
|
424
|
+
... message="Server 'weather' on 'claude-desktop'",
|
|
425
|
+
... children=[
|
|
426
|
+
... Consequence(ConsequenceType.UPDATE, "command: None → 'python'"),
|
|
427
|
+
... Consequence(ConsequenceType.SKIP, "timeout: unsupported"),
|
|
428
|
+
... ]
|
|
429
|
+
... )
|
|
430
|
+
"""
|
|
431
|
+
|
|
432
|
+
type: ConsequenceType
|
|
433
|
+
message: str
|
|
434
|
+
children: List["Consequence"] = field(default_factory=list)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
from typing import Optional, Tuple
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
class ResultReporter:
|
|
441
|
+
"""Unified rendering system for all CLI output.
|
|
442
|
+
|
|
443
|
+
Tracks consequences and renders them with tense-aware, color-coded output.
|
|
444
|
+
Present tense (dim colors) for confirmation prompts, past tense (bright colors)
|
|
445
|
+
for execution results.
|
|
446
|
+
|
|
447
|
+
Reference: R06 §3.4 (06-dependency_analysis_v0.md)
|
|
448
|
+
Reference: R04 §5.2 (04-reporting_infrastructure_coexistence_v0.md)
|
|
449
|
+
Reference: R01 §8.2 (01-cli_output_analysis_v2.md)
|
|
450
|
+
|
|
451
|
+
Attributes:
|
|
452
|
+
command_name: Display name for the command (e.g., "hatch mcp configure")
|
|
453
|
+
dry_run: If True, append "- DRY RUN" suffix to result labels
|
|
454
|
+
consequences: List of tracked consequences in order of addition
|
|
455
|
+
|
|
456
|
+
Invariants:
|
|
457
|
+
- consequences list is append-only
|
|
458
|
+
- report_prompt() and report_result() are idempotent
|
|
459
|
+
- Order of add() calls determines output order
|
|
460
|
+
|
|
461
|
+
Example:
|
|
462
|
+
>>> reporter = ResultReporter("hatch env create", dry_run=False)
|
|
463
|
+
>>> reporter.add(ConsequenceType.CREATE, "Environment 'dev'")
|
|
464
|
+
>>> reporter.add(ConsequenceType.CREATE, "Python environment (3.11)")
|
|
465
|
+
>>> prompt = reporter.report_prompt() # Present tense, dim colors
|
|
466
|
+
>>> # ... user confirms ...
|
|
467
|
+
>>> reporter.report_result() # Past tense, bright colors
|
|
468
|
+
"""
|
|
469
|
+
|
|
470
|
+
def __init__(self, command_name: str, dry_run: bool = False):
|
|
471
|
+
"""Initialize ResultReporter.
|
|
472
|
+
|
|
473
|
+
Args:
|
|
474
|
+
command_name: Display name for the command
|
|
475
|
+
dry_run: If True, results show "- DRY RUN" suffix
|
|
476
|
+
"""
|
|
477
|
+
self._command_name = command_name
|
|
478
|
+
self._dry_run = dry_run
|
|
479
|
+
self._consequences: List[Consequence] = []
|
|
480
|
+
|
|
481
|
+
@property
|
|
482
|
+
def command_name(self) -> str:
|
|
483
|
+
"""Display name for the command."""
|
|
484
|
+
return self._command_name
|
|
485
|
+
|
|
486
|
+
@property
|
|
487
|
+
def dry_run(self) -> bool:
|
|
488
|
+
"""Whether this is a dry-run preview."""
|
|
489
|
+
return self._dry_run
|
|
490
|
+
|
|
491
|
+
@property
|
|
492
|
+
def consequences(self) -> List[Consequence]:
|
|
493
|
+
"""List of tracked consequences in order of addition."""
|
|
494
|
+
return self._consequences
|
|
495
|
+
|
|
496
|
+
def add(
|
|
497
|
+
self,
|
|
498
|
+
consequence_type: ConsequenceType,
|
|
499
|
+
message: str,
|
|
500
|
+
children: Optional[List[Consequence]] = None
|
|
501
|
+
) -> None:
|
|
502
|
+
"""Add a consequence with optional nested children.
|
|
503
|
+
|
|
504
|
+
Args:
|
|
505
|
+
consequence_type: The type of action
|
|
506
|
+
message: Human-readable description
|
|
507
|
+
children: Optional nested consequences (e.g., field-level details)
|
|
508
|
+
|
|
509
|
+
Invariants:
|
|
510
|
+
- Order of add() calls determines output order
|
|
511
|
+
- Children inherit parent's tense during rendering
|
|
512
|
+
"""
|
|
513
|
+
consequence = Consequence(
|
|
514
|
+
type=consequence_type,
|
|
515
|
+
message=message,
|
|
516
|
+
children=children or []
|
|
517
|
+
)
|
|
518
|
+
self._consequences.append(consequence)
|
|
519
|
+
|
|
520
|
+
def add_from_conversion_report(self, report: "ConversionReport") -> None:
|
|
521
|
+
"""Convert ConversionReport field operations to nested consequences.
|
|
522
|
+
|
|
523
|
+
Maps ConversionReport data to the unified consequence model:
|
|
524
|
+
- report.operation → resource ConsequenceType
|
|
525
|
+
- field_op "UPDATED" → ConsequenceType.UPDATE
|
|
526
|
+
- field_op "UNSUPPORTED" → ConsequenceType.SKIP
|
|
527
|
+
- field_op "UNCHANGED" → ConsequenceType.UNCHANGED
|
|
528
|
+
|
|
529
|
+
Reference: R06 §3.5 (06-dependency_analysis_v0.md)
|
|
530
|
+
Reference: R04 §1.2 (04-reporting_infrastructure_coexistence_v0.md)
|
|
531
|
+
|
|
532
|
+
Args:
|
|
533
|
+
report: ConversionReport with field operations to convert
|
|
534
|
+
|
|
535
|
+
Invariants:
|
|
536
|
+
- All field operations become children of resource consequence
|
|
537
|
+
- UNSUPPORTED fields include "(unsupported by host)" suffix
|
|
538
|
+
"""
|
|
539
|
+
# Import here to avoid circular dependency
|
|
540
|
+
from hatch.mcp_host_config.reporting import ConversionReport
|
|
541
|
+
|
|
542
|
+
# Map report.operation to resource ConsequenceType
|
|
543
|
+
operation_map = {
|
|
544
|
+
"create": ConsequenceType.CONFIGURE,
|
|
545
|
+
"update": ConsequenceType.CONFIGURE,
|
|
546
|
+
"delete": ConsequenceType.REMOVE,
|
|
547
|
+
"migrate": ConsequenceType.CONFIGURE,
|
|
548
|
+
}
|
|
549
|
+
resource_type = operation_map.get(report.operation, ConsequenceType.CONFIGURE)
|
|
550
|
+
|
|
551
|
+
# Build resource message
|
|
552
|
+
resource_message = f"Server '{report.server_name}' on '{report.target_host.value}'"
|
|
553
|
+
|
|
554
|
+
# Map field operations to child consequences
|
|
555
|
+
field_op_map = {
|
|
556
|
+
"UPDATED": ConsequenceType.UPDATE,
|
|
557
|
+
"UNSUPPORTED": ConsequenceType.SKIP,
|
|
558
|
+
"UNCHANGED": ConsequenceType.UNCHANGED,
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
children = []
|
|
562
|
+
for field_op in report.field_operations:
|
|
563
|
+
child_type = field_op_map.get(field_op.operation, ConsequenceType.UPDATE)
|
|
564
|
+
|
|
565
|
+
# Format field message based on operation type
|
|
566
|
+
if field_op.operation == "UPDATED":
|
|
567
|
+
child_message = f"{field_op.field_name}: {repr(field_op.old_value)} → {repr(field_op.new_value)}"
|
|
568
|
+
elif field_op.operation == "UNSUPPORTED":
|
|
569
|
+
child_message = f"{field_op.field_name}: (unsupported by host)"
|
|
570
|
+
else: # UNCHANGED
|
|
571
|
+
child_message = f"{field_op.field_name}: {repr(field_op.new_value)}"
|
|
572
|
+
|
|
573
|
+
children.append(Consequence(type=child_type, message=child_message))
|
|
574
|
+
|
|
575
|
+
# Add the resource consequence with children
|
|
576
|
+
self.add(resource_type, resource_message, children=children)
|
|
577
|
+
|
|
578
|
+
def _format_consequence(
|
|
579
|
+
self,
|
|
580
|
+
consequence: Consequence,
|
|
581
|
+
use_result_tense: bool,
|
|
582
|
+
indent: int = 2
|
|
583
|
+
) -> str:
|
|
584
|
+
"""Format a single consequence with color and tense.
|
|
585
|
+
|
|
586
|
+
Args:
|
|
587
|
+
consequence: The consequence to format
|
|
588
|
+
use_result_tense: True for past tense (result), False for present (prompt)
|
|
589
|
+
indent: Number of spaces for indentation
|
|
590
|
+
|
|
591
|
+
Returns:
|
|
592
|
+
Formatted string with optional ANSI colors
|
|
593
|
+
"""
|
|
594
|
+
ct = consequence.type
|
|
595
|
+
label = ct.result_label if use_result_tense else ct.prompt_label
|
|
596
|
+
color = ct.result_color if use_result_tense else ct.prompt_color
|
|
597
|
+
|
|
598
|
+
# Add dry-run suffix for results
|
|
599
|
+
if use_result_tense and self._dry_run:
|
|
600
|
+
label = f"{label} - DRY RUN"
|
|
601
|
+
|
|
602
|
+
# Format with or without colors
|
|
603
|
+
indent_str = " " * indent
|
|
604
|
+
if _colors_enabled():
|
|
605
|
+
line = f"{indent_str}{color.value}[{label}]{Color.RESET.value} {consequence.message}"
|
|
606
|
+
else:
|
|
607
|
+
line = f"{indent_str}[{label}] {consequence.message}"
|
|
608
|
+
|
|
609
|
+
return line
|
|
610
|
+
|
|
611
|
+
def report_prompt(self) -> str:
|
|
612
|
+
"""Generate confirmation prompt (present tense, dim colors).
|
|
613
|
+
|
|
614
|
+
Output format:
|
|
615
|
+
{command_name}:
|
|
616
|
+
[VERB] resource message
|
|
617
|
+
[VERB] field message
|
|
618
|
+
[VERB] field message
|
|
619
|
+
|
|
620
|
+
Returns:
|
|
621
|
+
Formatted prompt string, empty string if no consequences.
|
|
622
|
+
|
|
623
|
+
Invariants:
|
|
624
|
+
- All consequences shown (including UNCHANGED, SKIP)
|
|
625
|
+
- Empty string if no consequences
|
|
626
|
+
"""
|
|
627
|
+
if not self._consequences:
|
|
628
|
+
return ""
|
|
629
|
+
|
|
630
|
+
lines = [f"{self._command_name}:"]
|
|
631
|
+
|
|
632
|
+
for consequence in self._consequences:
|
|
633
|
+
lines.append(self._format_consequence(consequence, use_result_tense=False))
|
|
634
|
+
for child in consequence.children:
|
|
635
|
+
lines.append(self._format_consequence(child, use_result_tense=False, indent=4))
|
|
636
|
+
|
|
637
|
+
return "\n".join(lines)
|
|
638
|
+
|
|
639
|
+
def report_result(self) -> None:
|
|
640
|
+
"""Print execution results (past tense, bright colors).
|
|
641
|
+
|
|
642
|
+
Output format:
|
|
643
|
+
[SUCCESS] summary (or [DRY RUN] for dry-run mode)
|
|
644
|
+
[VERB-ED] resource message
|
|
645
|
+
[VERB-ED] field message (only changed fields)
|
|
646
|
+
|
|
647
|
+
Invariants:
|
|
648
|
+
- UNCHANGED and SKIP fields may be omitted from result (noise reduction)
|
|
649
|
+
- Dry-run appends "- DRY RUN" suffix
|
|
650
|
+
- No output if consequences list is empty
|
|
651
|
+
"""
|
|
652
|
+
if not self._consequences:
|
|
653
|
+
return
|
|
654
|
+
|
|
655
|
+
# Print header
|
|
656
|
+
if self._dry_run:
|
|
657
|
+
if _colors_enabled():
|
|
658
|
+
print(f"{Color.CYAN.value}[DRY RUN]{Color.RESET.value} Preview of changes:")
|
|
659
|
+
else:
|
|
660
|
+
print("[DRY RUN] Preview of changes:")
|
|
661
|
+
else:
|
|
662
|
+
if _colors_enabled():
|
|
663
|
+
print(f"{Color.GREEN.value}[SUCCESS]{Color.RESET.value} Operation completed:")
|
|
664
|
+
else:
|
|
665
|
+
print("[SUCCESS] Operation completed:")
|
|
666
|
+
|
|
667
|
+
# Print consequences
|
|
668
|
+
for consequence in self._consequences:
|
|
669
|
+
print(self._format_consequence(consequence, use_result_tense=True))
|
|
670
|
+
for child in consequence.children:
|
|
671
|
+
# Optionally filter out UNCHANGED/SKIP in results for noise reduction
|
|
672
|
+
# For now, show all for transparency
|
|
673
|
+
print(self._format_consequence(child, use_result_tense=True, indent=4))
|
|
674
|
+
|
|
675
|
+
def report_error(self, summary: str, details: Optional[List[str]] = None) -> None:
|
|
676
|
+
"""Report execution failure with structured details.
|
|
677
|
+
|
|
678
|
+
Prints error message with [ERROR] prefix in bright red color (when colors enabled).
|
|
679
|
+
Details are indented with 2 spaces for visual hierarchy.
|
|
680
|
+
|
|
681
|
+
Reference: R13 §4.2.3 (13-error_message_formatting_v0.md)
|
|
682
|
+
|
|
683
|
+
Args:
|
|
684
|
+
summary: High-level error description
|
|
685
|
+
details: Optional list of detail lines to print below summary
|
|
686
|
+
|
|
687
|
+
Output format:
|
|
688
|
+
[ERROR] <summary>
|
|
689
|
+
<detail_line_1>
|
|
690
|
+
<detail_line_2>
|
|
691
|
+
|
|
692
|
+
Example:
|
|
693
|
+
>>> reporter = ResultReporter("hatch env create")
|
|
694
|
+
>>> reporter.report_error(
|
|
695
|
+
... "Failed to create environment 'dev'",
|
|
696
|
+
... details=["Python environment creation failed: conda not available"]
|
|
697
|
+
... )
|
|
698
|
+
[ERROR] Failed to create environment 'dev'
|
|
699
|
+
Python environment creation failed: conda not available
|
|
700
|
+
"""
|
|
701
|
+
if not summary:
|
|
702
|
+
return
|
|
703
|
+
|
|
704
|
+
# Print error header with color
|
|
705
|
+
if _colors_enabled():
|
|
706
|
+
print(f"{Color.RED.value}[ERROR]{Color.RESET.value} {summary}")
|
|
707
|
+
else:
|
|
708
|
+
print(f"[ERROR] {summary}")
|
|
709
|
+
|
|
710
|
+
# Print details with indentation
|
|
711
|
+
if details:
|
|
712
|
+
for detail in details:
|
|
713
|
+
print(f" {detail}")
|
|
714
|
+
|
|
715
|
+
def report_partial_success(
|
|
716
|
+
self,
|
|
717
|
+
summary: str,
|
|
718
|
+
successes: List[str],
|
|
719
|
+
failures: List[Tuple[str, str]]
|
|
720
|
+
) -> None:
|
|
721
|
+
"""Report mixed success/failure results with ✓/✗ symbols.
|
|
722
|
+
|
|
723
|
+
Prints warning message with [WARNING] prefix in bright yellow color.
|
|
724
|
+
Uses ✓/✗ symbols for success/failure items (with ASCII fallback).
|
|
725
|
+
Includes summary line showing success ratio.
|
|
726
|
+
|
|
727
|
+
Reference: R13 §4.2.3 (13-error_message_formatting_v0.md)
|
|
728
|
+
|
|
729
|
+
Args:
|
|
730
|
+
summary: High-level summary description
|
|
731
|
+
successes: List of successful item descriptions
|
|
732
|
+
failures: List of (item, reason) tuples for failed items
|
|
733
|
+
|
|
734
|
+
Output format:
|
|
735
|
+
[WARNING] <summary>
|
|
736
|
+
✓ <success_item>
|
|
737
|
+
✗ <failure_item>: <reason>
|
|
738
|
+
Summary: X/Y succeeded
|
|
739
|
+
|
|
740
|
+
Example:
|
|
741
|
+
>>> reporter = ResultReporter("hatch mcp sync")
|
|
742
|
+
>>> reporter.report_partial_success(
|
|
743
|
+
... "Partial synchronization",
|
|
744
|
+
... successes=["claude-desktop (backup: ~/.hatch/backups/...)"],
|
|
745
|
+
... failures=[("cursor", "Config file not found")]
|
|
746
|
+
... )
|
|
747
|
+
[WARNING] Partial synchronization
|
|
748
|
+
✓ claude-desktop (backup: ~/.hatch/backups/...)
|
|
749
|
+
✗ cursor: Config file not found
|
|
750
|
+
Summary: 1/2 succeeded
|
|
751
|
+
"""
|
|
752
|
+
# Determine symbols based on unicode support
|
|
753
|
+
success_symbol = "✓" if _supports_unicode() else "+"
|
|
754
|
+
failure_symbol = "✗" if _supports_unicode() else "x"
|
|
755
|
+
|
|
756
|
+
# Print warning header with color
|
|
757
|
+
if _colors_enabled():
|
|
758
|
+
print(f"{Color.YELLOW.value}[WARNING]{Color.RESET.value} {summary}")
|
|
759
|
+
else:
|
|
760
|
+
print(f"[WARNING] {summary}")
|
|
761
|
+
|
|
762
|
+
# Print success items
|
|
763
|
+
for item in successes:
|
|
764
|
+
if _colors_enabled():
|
|
765
|
+
print(f" {Color.GREEN.value}{success_symbol}{Color.RESET.value} {item}")
|
|
766
|
+
else:
|
|
767
|
+
print(f" {success_symbol} {item}")
|
|
768
|
+
|
|
769
|
+
# Print failure items
|
|
770
|
+
for item, reason in failures:
|
|
771
|
+
if _colors_enabled():
|
|
772
|
+
print(f" {Color.RED.value}{failure_symbol}{Color.RESET.value} {item}: {reason}")
|
|
773
|
+
else:
|
|
774
|
+
print(f" {failure_symbol} {item}: {reason}")
|
|
775
|
+
|
|
776
|
+
# Print summary line
|
|
777
|
+
total = len(successes) + len(failures)
|
|
778
|
+
succeeded = len(successes)
|
|
779
|
+
print(f" Summary: {succeeded}/{total} succeeded")
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
# =============================================================================
|
|
783
|
+
# Error Formatting Utilities
|
|
784
|
+
# =============================================================================
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def format_validation_error(error: "ValidationError") -> None:
|
|
788
|
+
"""Print formatted validation error with color.
|
|
789
|
+
|
|
790
|
+
Prints error message with [ERROR] prefix in bright red color.
|
|
791
|
+
Optionally includes field name and suggestion if provided.
|
|
792
|
+
|
|
793
|
+
Reference: R13 §4.3 (13-error_message_formatting_v0.md)
|
|
794
|
+
|
|
795
|
+
Args:
|
|
796
|
+
error: ValidationError instance with message, field, and suggestion
|
|
797
|
+
|
|
798
|
+
Output format:
|
|
799
|
+
[ERROR] <message>
|
|
800
|
+
Field: <field> (if provided)
|
|
801
|
+
Suggestion: <suggestion> (if provided)
|
|
802
|
+
|
|
803
|
+
Example:
|
|
804
|
+
>>> from hatch.cli.cli_utils import ValidationError, format_validation_error
|
|
805
|
+
>>> format_validation_error(ValidationError(
|
|
806
|
+
... "Invalid host 'vsc'",
|
|
807
|
+
... field="--host",
|
|
808
|
+
... suggestion="Supported hosts: claude-desktop, vscode, cursor"
|
|
809
|
+
... ))
|
|
810
|
+
[ERROR] Invalid host 'vsc'
|
|
811
|
+
Field: --host
|
|
812
|
+
Suggestion: Supported hosts: claude-desktop, vscode, cursor
|
|
813
|
+
"""
|
|
814
|
+
# Print error header with color
|
|
815
|
+
if _colors_enabled():
|
|
816
|
+
print(f"{Color.RED.value}[ERROR]{Color.RESET.value} {error.message}")
|
|
817
|
+
else:
|
|
818
|
+
print(f"[ERROR] {error.message}")
|
|
819
|
+
|
|
820
|
+
# Print field if provided
|
|
821
|
+
if error.field:
|
|
822
|
+
print(f" Field: {error.field}")
|
|
823
|
+
|
|
824
|
+
# Print suggestion if provided
|
|
825
|
+
if error.suggestion:
|
|
826
|
+
print(f" Suggestion: {error.suggestion}")
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def format_info(message: str) -> None:
|
|
830
|
+
"""Print formatted info message with color.
|
|
831
|
+
|
|
832
|
+
Prints message with [INFO] prefix in bright blue color.
|
|
833
|
+
Used for informational messages like "Operation cancelled".
|
|
834
|
+
|
|
835
|
+
Reference: R13-B §B.6.2 (13-error_message_formatting_appendix_b_v0.md)
|
|
836
|
+
|
|
837
|
+
Args:
|
|
838
|
+
message: Info message to display
|
|
839
|
+
|
|
840
|
+
Output format:
|
|
841
|
+
[INFO] <message>
|
|
842
|
+
|
|
843
|
+
Example:
|
|
844
|
+
>>> from hatch.cli.cli_utils import format_info
|
|
845
|
+
>>> format_info("Operation cancelled")
|
|
846
|
+
[INFO] Operation cancelled
|
|
847
|
+
"""
|
|
848
|
+
if _colors_enabled():
|
|
849
|
+
print(f"{Color.BLUE.value}[INFO]{Color.RESET.value} {message}")
|
|
850
|
+
else:
|
|
851
|
+
print(f"[INFO] {message}")
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def format_warning(message: str, suggestion: str = None) -> None:
|
|
855
|
+
"""Print formatted warning message with color.
|
|
856
|
+
|
|
857
|
+
Prints message with [WARNING] prefix in bright yellow color.
|
|
858
|
+
Used for non-fatal warnings that don't prevent operation completion.
|
|
859
|
+
|
|
860
|
+
Reference: R13-A §A.5 P3 (13-error_message_formatting_appendix_a_v0.md)
|
|
861
|
+
|
|
862
|
+
Args:
|
|
863
|
+
message: Warning message to display
|
|
864
|
+
suggestion: Optional suggestion for resolution
|
|
865
|
+
|
|
866
|
+
Output format:
|
|
867
|
+
[WARNING] <message>
|
|
868
|
+
Suggestion: <suggestion> (if provided)
|
|
869
|
+
|
|
870
|
+
Example:
|
|
871
|
+
>>> from hatch.cli.cli_utils import format_warning
|
|
872
|
+
>>> format_warning("Invalid header format 'foo'", suggestion="Expected KEY=VALUE")
|
|
873
|
+
[WARNING] Invalid header format 'foo'
|
|
874
|
+
Suggestion: Expected KEY=VALUE
|
|
875
|
+
"""
|
|
876
|
+
if _colors_enabled():
|
|
877
|
+
print(f"{Color.YELLOW.value}[WARNING]{Color.RESET.value} {message}")
|
|
878
|
+
else:
|
|
879
|
+
print(f"[WARNING] {message}")
|
|
880
|
+
|
|
881
|
+
if suggestion:
|
|
882
|
+
print(f" Suggestion: {suggestion}")
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
# =============================================================================
|
|
886
|
+
# TableFormatter Infrastructure for List Commands
|
|
887
|
+
# =============================================================================
|
|
888
|
+
|
|
889
|
+
from typing import Union, Literal
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
@dataclass
|
|
893
|
+
class ColumnDef:
|
|
894
|
+
"""Column definition for TableFormatter.
|
|
895
|
+
|
|
896
|
+
Reference: R06 §3.6 (06-dependency_analysis_v0.md)
|
|
897
|
+
Reference: R02 §5 (02-list_output_format_specification_v2.md)
|
|
898
|
+
|
|
899
|
+
Attributes:
|
|
900
|
+
name: Column header text
|
|
901
|
+
width: Fixed width (int) or "auto" for auto-calculation
|
|
902
|
+
align: Text alignment ("left", "right", "center")
|
|
903
|
+
|
|
904
|
+
Example:
|
|
905
|
+
>>> col = ColumnDef(name="Name", width=20, align="left")
|
|
906
|
+
>>> col_auto = ColumnDef(name="Count", width="auto", align="right")
|
|
907
|
+
"""
|
|
908
|
+
|
|
909
|
+
name: str
|
|
910
|
+
width: Union[int, Literal["auto"]]
|
|
911
|
+
align: Literal["left", "right", "center"] = "left"
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
class TableFormatter:
|
|
915
|
+
"""Aligned table output for list commands.
|
|
916
|
+
|
|
917
|
+
Renders data as aligned columns with headers and separator line.
|
|
918
|
+
Supports fixed and auto-calculated column widths.
|
|
919
|
+
|
|
920
|
+
Reference: R06 §3.6 (06-dependency_analysis_v0.md)
|
|
921
|
+
Reference: R02 §5 (02-list_output_format_specification_v2.md)
|
|
922
|
+
|
|
923
|
+
Attributes:
|
|
924
|
+
columns: List of column definitions
|
|
925
|
+
|
|
926
|
+
Example:
|
|
927
|
+
>>> columns = [
|
|
928
|
+
... ColumnDef(name="Name", width=20),
|
|
929
|
+
... ColumnDef(name="Status", width=10),
|
|
930
|
+
... ]
|
|
931
|
+
>>> formatter = TableFormatter(columns)
|
|
932
|
+
>>> formatter.add_row(["my-server", "active"])
|
|
933
|
+
>>> print(formatter.render())
|
|
934
|
+
Name Status
|
|
935
|
+
─────────────────────────────────
|
|
936
|
+
my-server active
|
|
937
|
+
"""
|
|
938
|
+
|
|
939
|
+
def __init__(self, columns: List[ColumnDef]):
|
|
940
|
+
"""Initialize TableFormatter with column definitions.
|
|
941
|
+
|
|
942
|
+
Args:
|
|
943
|
+
columns: List of ColumnDef specifying table structure
|
|
944
|
+
"""
|
|
945
|
+
self._columns = columns
|
|
946
|
+
self._rows: List[List[str]] = []
|
|
947
|
+
|
|
948
|
+
def add_row(self, values: List[str]) -> None:
|
|
949
|
+
"""Add a data row to the table.
|
|
950
|
+
|
|
951
|
+
Args:
|
|
952
|
+
values: List of string values, one per column
|
|
953
|
+
"""
|
|
954
|
+
self._rows.append(values)
|
|
955
|
+
|
|
956
|
+
def _calculate_widths(self) -> List[int]:
|
|
957
|
+
"""Calculate actual column widths, resolving 'auto' widths.
|
|
958
|
+
|
|
959
|
+
Returns:
|
|
960
|
+
List of integer widths for each column
|
|
961
|
+
"""
|
|
962
|
+
widths = []
|
|
963
|
+
for i, col in enumerate(self._columns):
|
|
964
|
+
if col.width == "auto":
|
|
965
|
+
# Calculate from header and all row values
|
|
966
|
+
max_width = len(col.name)
|
|
967
|
+
for row in self._rows:
|
|
968
|
+
if i < len(row):
|
|
969
|
+
max_width = max(max_width, len(row[i]))
|
|
970
|
+
widths.append(max_width)
|
|
971
|
+
else:
|
|
972
|
+
widths.append(col.width)
|
|
973
|
+
return widths
|
|
974
|
+
|
|
975
|
+
def _align_value(self, value: str, width: int, align: str) -> str:
|
|
976
|
+
"""Align a value within the specified width.
|
|
977
|
+
|
|
978
|
+
Args:
|
|
979
|
+
value: The string value to align
|
|
980
|
+
width: Target width
|
|
981
|
+
align: Alignment type ("left", "right", "center")
|
|
982
|
+
|
|
983
|
+
Returns:
|
|
984
|
+
Aligned string, truncated with ellipsis if too long
|
|
985
|
+
"""
|
|
986
|
+
# Truncate if too long
|
|
987
|
+
if len(value) > width:
|
|
988
|
+
if width > 1:
|
|
989
|
+
return value[:width - 1] + "…"
|
|
990
|
+
return value[:width]
|
|
991
|
+
|
|
992
|
+
# Apply alignment
|
|
993
|
+
if align == "right":
|
|
994
|
+
return value.rjust(width)
|
|
995
|
+
elif align == "center":
|
|
996
|
+
return value.center(width)
|
|
997
|
+
else: # left (default)
|
|
998
|
+
return value.ljust(width)
|
|
999
|
+
|
|
1000
|
+
def render(self) -> str:
|
|
1001
|
+
"""Render the table as a formatted string.
|
|
1002
|
+
|
|
1003
|
+
Returns:
|
|
1004
|
+
Multi-line string with headers, separator, and data rows
|
|
1005
|
+
"""
|
|
1006
|
+
widths = self._calculate_widths()
|
|
1007
|
+
lines = []
|
|
1008
|
+
|
|
1009
|
+
# Header row
|
|
1010
|
+
header_parts = []
|
|
1011
|
+
for i, col in enumerate(self._columns):
|
|
1012
|
+
header_parts.append(self._align_value(col.name, widths[i], col.align))
|
|
1013
|
+
lines.append(" " + " ".join(header_parts))
|
|
1014
|
+
|
|
1015
|
+
# Separator line
|
|
1016
|
+
total_width = sum(widths) + (len(widths) - 1) * 2 + 2 # columns + separators + indent
|
|
1017
|
+
lines.append(" " + "─" * (total_width - 2))
|
|
1018
|
+
|
|
1019
|
+
# Data rows
|
|
1020
|
+
for row in self._rows:
|
|
1021
|
+
row_parts = []
|
|
1022
|
+
for i, col in enumerate(self._columns):
|
|
1023
|
+
value = row[i] if i < len(row) else ""
|
|
1024
|
+
row_parts.append(self._align_value(value, widths[i], col.align))
|
|
1025
|
+
lines.append(" " + " ".join(row_parts))
|
|
1026
|
+
|
|
1027
|
+
return "\n".join(lines)
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
# Exit code constants for consistent CLI return values
|
|
1031
|
+
EXIT_SUCCESS = 0
|
|
1032
|
+
EXIT_ERROR = 1
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def get_hatch_version() -> str:
|
|
1036
|
+
"""Get Hatch version from package metadata.
|
|
1037
|
+
|
|
1038
|
+
Returns:
|
|
1039
|
+
str: Version string from package metadata, or 'unknown (development mode)'
|
|
1040
|
+
if package is not installed.
|
|
1041
|
+
"""
|
|
1042
|
+
try:
|
|
1043
|
+
return version("hatch")
|
|
1044
|
+
except PackageNotFoundError:
|
|
1045
|
+
return "unknown (development mode)"
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
import os
|
|
1049
|
+
import sys
|
|
1050
|
+
from typing import Optional
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
def request_confirmation(message: str, auto_approve: bool = False) -> bool:
|
|
1054
|
+
"""Request user confirmation with non-TTY support following Hatch patterns.
|
|
1055
|
+
|
|
1056
|
+
Args:
|
|
1057
|
+
message: The confirmation message to display
|
|
1058
|
+
auto_approve: If True, automatically approve without prompting
|
|
1059
|
+
|
|
1060
|
+
Returns:
|
|
1061
|
+
bool: True if confirmed, False otherwise
|
|
1062
|
+
"""
|
|
1063
|
+
# Check for auto-approve first
|
|
1064
|
+
if auto_approve or os.getenv("HATCH_AUTO_APPROVE", "").lower() in (
|
|
1065
|
+
"1",
|
|
1066
|
+
"true",
|
|
1067
|
+
"yes",
|
|
1068
|
+
):
|
|
1069
|
+
return True
|
|
1070
|
+
|
|
1071
|
+
# Interactive mode - request user input (works in both TTY and test environments)
|
|
1072
|
+
try:
|
|
1073
|
+
while True:
|
|
1074
|
+
response = input(f"{message} [y/N]: ").strip().lower()
|
|
1075
|
+
if response in ["y", "yes"]:
|
|
1076
|
+
return True
|
|
1077
|
+
elif response in ["n", "no", ""]:
|
|
1078
|
+
return False
|
|
1079
|
+
else:
|
|
1080
|
+
print("Please enter 'y' for yes or 'n' for no.")
|
|
1081
|
+
except (EOFError, KeyboardInterrupt):
|
|
1082
|
+
# Only auto-approve on EOF/interrupt if not in TTY (non-interactive environment)
|
|
1083
|
+
if not sys.stdin.isatty():
|
|
1084
|
+
return True
|
|
1085
|
+
return False
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
def parse_env_vars(env_list: Optional[list]) -> dict:
|
|
1089
|
+
"""Parse environment variables from command line format.
|
|
1090
|
+
|
|
1091
|
+
Args:
|
|
1092
|
+
env_list: List of strings in KEY=VALUE format
|
|
1093
|
+
|
|
1094
|
+
Returns:
|
|
1095
|
+
dict: Dictionary of environment variable key-value pairs
|
|
1096
|
+
"""
|
|
1097
|
+
if not env_list:
|
|
1098
|
+
return {}
|
|
1099
|
+
|
|
1100
|
+
env_dict = {}
|
|
1101
|
+
for env_var in env_list:
|
|
1102
|
+
if "=" not in env_var:
|
|
1103
|
+
format_warning(
|
|
1104
|
+
f"Invalid environment variable format '{env_var}'",
|
|
1105
|
+
suggestion="Expected KEY=VALUE"
|
|
1106
|
+
)
|
|
1107
|
+
continue
|
|
1108
|
+
key, value = env_var.split("=", 1)
|
|
1109
|
+
env_dict[key.strip()] = value.strip()
|
|
1110
|
+
|
|
1111
|
+
return env_dict
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def parse_header(header_list: Optional[list]) -> dict:
|
|
1115
|
+
"""Parse HTTP headers from command line format.
|
|
1116
|
+
|
|
1117
|
+
Args:
|
|
1118
|
+
header_list: List of strings in KEY=VALUE format
|
|
1119
|
+
|
|
1120
|
+
Returns:
|
|
1121
|
+
dict: Dictionary of header key-value pairs
|
|
1122
|
+
"""
|
|
1123
|
+
if not header_list:
|
|
1124
|
+
return {}
|
|
1125
|
+
|
|
1126
|
+
headers_dict = {}
|
|
1127
|
+
for header in header_list:
|
|
1128
|
+
if "=" not in header:
|
|
1129
|
+
format_warning(
|
|
1130
|
+
f"Invalid header format '{header}'",
|
|
1131
|
+
suggestion="Expected KEY=VALUE"
|
|
1132
|
+
)
|
|
1133
|
+
continue
|
|
1134
|
+
key, value = header.split("=", 1)
|
|
1135
|
+
headers_dict[key.strip()] = value.strip()
|
|
1136
|
+
|
|
1137
|
+
return headers_dict
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
def parse_input(input_list: Optional[list]) -> Optional[list]:
|
|
1141
|
+
"""Parse VS Code input variable definitions from command line format.
|
|
1142
|
+
|
|
1143
|
+
Format: type,id,description[,password=true]
|
|
1144
|
+
Example: promptString,api-key,GitHub Personal Access Token,password=true
|
|
1145
|
+
|
|
1146
|
+
Args:
|
|
1147
|
+
input_list: List of input definition strings
|
|
1148
|
+
|
|
1149
|
+
Returns:
|
|
1150
|
+
List of input variable definition dictionaries, or None if no inputs provided.
|
|
1151
|
+
"""
|
|
1152
|
+
if not input_list:
|
|
1153
|
+
return None
|
|
1154
|
+
|
|
1155
|
+
parsed_inputs = []
|
|
1156
|
+
for input_str in input_list:
|
|
1157
|
+
parts = [p.strip() for p in input_str.split(",")]
|
|
1158
|
+
if len(parts) < 3:
|
|
1159
|
+
format_warning(
|
|
1160
|
+
f"Invalid input format '{input_str}'",
|
|
1161
|
+
suggestion="Expected: type,id,description[,password=true]"
|
|
1162
|
+
)
|
|
1163
|
+
continue
|
|
1164
|
+
|
|
1165
|
+
input_def = {"type": parts[0], "id": parts[1], "description": parts[2]}
|
|
1166
|
+
|
|
1167
|
+
# Check for optional password flag
|
|
1168
|
+
if len(parts) > 3 and parts[3].lower() == "password=true":
|
|
1169
|
+
input_def["password"] = True
|
|
1170
|
+
|
|
1171
|
+
parsed_inputs.append(input_def)
|
|
1172
|
+
|
|
1173
|
+
return parsed_inputs if parsed_inputs else None
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
from typing import List
|
|
1177
|
+
|
|
1178
|
+
from hatch.mcp_host_config import MCPHostRegistry, MCPHostType
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
def parse_host_list(host_arg: str) -> List[str]:
|
|
1182
|
+
"""Parse comma-separated host list or 'all'.
|
|
1183
|
+
|
|
1184
|
+
Args:
|
|
1185
|
+
host_arg: Comma-separated host names or 'all' for all available hosts
|
|
1186
|
+
|
|
1187
|
+
Returns:
|
|
1188
|
+
List[str]: List of host name strings
|
|
1189
|
+
|
|
1190
|
+
Raises:
|
|
1191
|
+
ValueError: If an unknown host name is provided
|
|
1192
|
+
"""
|
|
1193
|
+
if not host_arg:
|
|
1194
|
+
return []
|
|
1195
|
+
|
|
1196
|
+
if host_arg.lower() == "all":
|
|
1197
|
+
available_hosts = MCPHostRegistry.detect_available_hosts()
|
|
1198
|
+
return [host.value for host in available_hosts]
|
|
1199
|
+
|
|
1200
|
+
hosts = []
|
|
1201
|
+
for host_str in host_arg.split(","):
|
|
1202
|
+
host_str = host_str.strip()
|
|
1203
|
+
try:
|
|
1204
|
+
host_type = MCPHostType(host_str)
|
|
1205
|
+
hosts.append(host_type.value)
|
|
1206
|
+
except ValueError:
|
|
1207
|
+
available = [h.value for h in MCPHostType]
|
|
1208
|
+
raise ValueError(f"Unknown host '{host_str}'. Available: {available}")
|
|
1209
|
+
|
|
1210
|
+
return hosts
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
import json
|
|
1214
|
+
from pathlib import Path
|
|
1215
|
+
|
|
1216
|
+
from hatch.environment_manager import HatchEnvironmentManager
|
|
1217
|
+
from hatch.mcp_host_config import MCPServerConfig
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
def get_package_mcp_server_config(
|
|
1221
|
+
env_manager: HatchEnvironmentManager, env_name: str, package_name: str
|
|
1222
|
+
) -> MCPServerConfig:
|
|
1223
|
+
"""Get MCP server configuration for a package using existing APIs.
|
|
1224
|
+
|
|
1225
|
+
Args:
|
|
1226
|
+
env_manager: The environment manager instance
|
|
1227
|
+
env_name: Name of the environment containing the package
|
|
1228
|
+
package_name: Name of the package to get config for
|
|
1229
|
+
|
|
1230
|
+
Returns:
|
|
1231
|
+
MCPServerConfig: Server configuration for the package
|
|
1232
|
+
|
|
1233
|
+
Raises:
|
|
1234
|
+
ValueError: If package not found, not a Hatch package, or has no MCP entry point
|
|
1235
|
+
"""
|
|
1236
|
+
try:
|
|
1237
|
+
# Get package info from environment
|
|
1238
|
+
packages = env_manager.list_packages(env_name)
|
|
1239
|
+
package_info = next(
|
|
1240
|
+
(pkg for pkg in packages if pkg["name"] == package_name), None
|
|
1241
|
+
)
|
|
1242
|
+
|
|
1243
|
+
if not package_info:
|
|
1244
|
+
raise ValueError(
|
|
1245
|
+
f"Package '{package_name}' not found in environment '{env_name}'"
|
|
1246
|
+
)
|
|
1247
|
+
|
|
1248
|
+
# Load package metadata using existing pattern from environment_manager.py:716-727
|
|
1249
|
+
package_path = Path(package_info["source"]["path"])
|
|
1250
|
+
metadata_path = package_path / "hatch_metadata.json"
|
|
1251
|
+
|
|
1252
|
+
if not metadata_path.exists():
|
|
1253
|
+
raise ValueError(
|
|
1254
|
+
f"Package '{package_name}' is not a Hatch package (no hatch_metadata.json)"
|
|
1255
|
+
)
|
|
1256
|
+
|
|
1257
|
+
with open(metadata_path, "r") as f:
|
|
1258
|
+
metadata = json.load(f)
|
|
1259
|
+
|
|
1260
|
+
# Use PackageService for schema-aware access
|
|
1261
|
+
from hatch_validator.package.package_service import PackageService
|
|
1262
|
+
|
|
1263
|
+
package_service = PackageService(metadata)
|
|
1264
|
+
|
|
1265
|
+
# Get the HatchMCP entry point (this handles both v1.2.0 and v1.2.1 schemas)
|
|
1266
|
+
mcp_entry_point = package_service.get_mcp_entry_point()
|
|
1267
|
+
if not mcp_entry_point:
|
|
1268
|
+
raise ValueError(
|
|
1269
|
+
f"Package '{package_name}' does not have a HatchMCP entry point"
|
|
1270
|
+
)
|
|
1271
|
+
|
|
1272
|
+
# Get environment-specific Python executable
|
|
1273
|
+
python_executable = env_manager.get_current_python_executable()
|
|
1274
|
+
if not python_executable:
|
|
1275
|
+
# Fallback to system Python if no environment-specific Python available
|
|
1276
|
+
python_executable = "python"
|
|
1277
|
+
|
|
1278
|
+
# Create server configuration
|
|
1279
|
+
server_path = str(package_path / mcp_entry_point)
|
|
1280
|
+
server_config = MCPServerConfig(
|
|
1281
|
+
name=package_name, command=python_executable, args=[server_path], env={}
|
|
1282
|
+
)
|
|
1283
|
+
|
|
1284
|
+
return server_config
|
|
1285
|
+
|
|
1286
|
+
except Exception as e:
|
|
1287
|
+
raise ValueError(
|
|
1288
|
+
f"Failed to get MCP server config for package '{package_name}': {e}"
|
|
1289
|
+
)
|