specfact-cli 0.4.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.
Potentially problematic release.
This version of specfact-cli might be problematic. Click here for more details.
- specfact_cli/__init__.py +14 -0
- specfact_cli/agents/__init__.py +23 -0
- specfact_cli/agents/analyze_agent.py +392 -0
- specfact_cli/agents/base.py +95 -0
- specfact_cli/agents/plan_agent.py +202 -0
- specfact_cli/agents/registry.py +176 -0
- specfact_cli/agents/sync_agent.py +133 -0
- specfact_cli/analyzers/__init__.py +10 -0
- specfact_cli/analyzers/code_analyzer.py +775 -0
- specfact_cli/cli.py +397 -0
- specfact_cli/commands/__init__.py +7 -0
- specfact_cli/commands/enforce.py +87 -0
- specfact_cli/commands/import_cmd.py +355 -0
- specfact_cli/commands/init.py +119 -0
- specfact_cli/commands/plan.py +1090 -0
- specfact_cli/commands/repro.py +172 -0
- specfact_cli/commands/sync.py +408 -0
- specfact_cli/common/__init__.py +24 -0
- specfact_cli/common/logger_setup.py +673 -0
- specfact_cli/common/logging_utils.py +41 -0
- specfact_cli/common/text_utils.py +52 -0
- specfact_cli/common/utils.py +48 -0
- specfact_cli/comparators/__init__.py +10 -0
- specfact_cli/comparators/plan_comparator.py +391 -0
- specfact_cli/generators/__init__.py +13 -0
- specfact_cli/generators/plan_generator.py +105 -0
- specfact_cli/generators/protocol_generator.py +115 -0
- specfact_cli/generators/report_generator.py +200 -0
- specfact_cli/generators/workflow_generator.py +111 -0
- specfact_cli/importers/__init__.py +6 -0
- specfact_cli/importers/speckit_converter.py +773 -0
- specfact_cli/importers/speckit_scanner.py +704 -0
- specfact_cli/models/__init__.py +32 -0
- specfact_cli/models/deviation.py +105 -0
- specfact_cli/models/enforcement.py +150 -0
- specfact_cli/models/plan.py +97 -0
- specfact_cli/models/protocol.py +28 -0
- specfact_cli/modes/__init__.py +18 -0
- specfact_cli/modes/detector.py +126 -0
- specfact_cli/modes/router.py +153 -0
- specfact_cli/sync/__init__.py +11 -0
- specfact_cli/sync/repository_sync.py +279 -0
- specfact_cli/sync/speckit_sync.py +388 -0
- specfact_cli/utils/__init__.py +57 -0
- specfact_cli/utils/console.py +69 -0
- specfact_cli/utils/feature_keys.py +213 -0
- specfact_cli/utils/git.py +241 -0
- specfact_cli/utils/ide_setup.py +381 -0
- specfact_cli/utils/prompts.py +179 -0
- specfact_cli/utils/structure.py +496 -0
- specfact_cli/utils/yaml_utils.py +200 -0
- specfact_cli/validators/__init__.py +19 -0
- specfact_cli/validators/fsm.py +260 -0
- specfact_cli/validators/repro_checker.py +320 -0
- specfact_cli/validators/schema.py +200 -0
- specfact_cli-0.4.0.dist-info/METADATA +332 -0
- specfact_cli-0.4.0.dist-info/RECORD +60 -0
- specfact_cli-0.4.0.dist-info/WHEEL +4 -0
- specfact_cli-0.4.0.dist-info/entry_points.txt +2 -0
- specfact_cli-0.4.0.dist-info/licenses/LICENSE.md +55 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""
|
|
2
|
+
IDE Setup Utilities - Detect IDE and copy prompt templates to IDE-specific locations.
|
|
3
|
+
|
|
4
|
+
This module provides utilities for detecting IDE type, processing prompt templates,
|
|
5
|
+
and copying them to IDE-specific locations for slash command integration.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
from beartype import beartype
|
|
17
|
+
from icontract import ensure, require
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
|
|
20
|
+
console = Console()
|
|
21
|
+
|
|
22
|
+
# IDE configuration map (from Spec-Kit)
|
|
23
|
+
IDE_CONFIG: dict[str, dict[str, str | bool | None]] = {
|
|
24
|
+
"claude": {
|
|
25
|
+
"name": "Claude Code",
|
|
26
|
+
"folder": ".claude/commands/",
|
|
27
|
+
"format": "md",
|
|
28
|
+
"settings_file": None,
|
|
29
|
+
},
|
|
30
|
+
"copilot": {
|
|
31
|
+
"name": "GitHub Copilot",
|
|
32
|
+
"folder": ".github/prompts/",
|
|
33
|
+
"format": "prompt.md",
|
|
34
|
+
"settings_file": ".vscode/settings.json",
|
|
35
|
+
},
|
|
36
|
+
"vscode": {
|
|
37
|
+
"name": "VS Code",
|
|
38
|
+
"folder": ".github/prompts/",
|
|
39
|
+
"format": "prompt.md",
|
|
40
|
+
"settings_file": ".vscode/settings.json",
|
|
41
|
+
},
|
|
42
|
+
"cursor": {
|
|
43
|
+
"name": "Cursor",
|
|
44
|
+
"folder": ".cursor/commands/",
|
|
45
|
+
"format": "md",
|
|
46
|
+
"settings_file": None,
|
|
47
|
+
},
|
|
48
|
+
"gemini": {
|
|
49
|
+
"name": "Gemini CLI",
|
|
50
|
+
"folder": ".gemini/commands/",
|
|
51
|
+
"format": "toml",
|
|
52
|
+
"settings_file": None,
|
|
53
|
+
},
|
|
54
|
+
"qwen": {
|
|
55
|
+
"name": "Qwen Code",
|
|
56
|
+
"folder": ".qwen/commands/",
|
|
57
|
+
"format": "toml",
|
|
58
|
+
"settings_file": None,
|
|
59
|
+
},
|
|
60
|
+
"opencode": {
|
|
61
|
+
"name": "opencode",
|
|
62
|
+
"folder": ".opencode/command/",
|
|
63
|
+
"format": "md",
|
|
64
|
+
"settings_file": None,
|
|
65
|
+
},
|
|
66
|
+
"windsurf": {
|
|
67
|
+
"name": "Windsurf",
|
|
68
|
+
"folder": ".windsurf/workflows/",
|
|
69
|
+
"format": "md",
|
|
70
|
+
"settings_file": None,
|
|
71
|
+
},
|
|
72
|
+
"kilocode": {
|
|
73
|
+
"name": "Kilo Code",
|
|
74
|
+
"folder": ".kilocode/workflows/",
|
|
75
|
+
"format": "md",
|
|
76
|
+
"settings_file": None,
|
|
77
|
+
},
|
|
78
|
+
"auggie": {
|
|
79
|
+
"name": "Auggie CLI",
|
|
80
|
+
"folder": ".augment/commands/",
|
|
81
|
+
"format": "md",
|
|
82
|
+
"settings_file": None,
|
|
83
|
+
},
|
|
84
|
+
"roo": {
|
|
85
|
+
"name": "Roo Code",
|
|
86
|
+
"folder": ".roo/commands/",
|
|
87
|
+
"format": "md",
|
|
88
|
+
"settings_file": None,
|
|
89
|
+
},
|
|
90
|
+
"codebuddy": {
|
|
91
|
+
"name": "CodeBuddy",
|
|
92
|
+
"folder": ".codebuddy/commands/",
|
|
93
|
+
"format": "md",
|
|
94
|
+
"settings_file": None,
|
|
95
|
+
},
|
|
96
|
+
"amp": {
|
|
97
|
+
"name": "Amp",
|
|
98
|
+
"folder": ".agents/commands/",
|
|
99
|
+
"format": "md",
|
|
100
|
+
"settings_file": None,
|
|
101
|
+
},
|
|
102
|
+
"q": {
|
|
103
|
+
"name": "Amazon Q Developer",
|
|
104
|
+
"folder": ".amazonq/prompts/",
|
|
105
|
+
"format": "md",
|
|
106
|
+
"settings_file": None,
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
# Commands available in SpecFact
|
|
111
|
+
SPECFACT_COMMANDS = [
|
|
112
|
+
"specfact-import-from-code",
|
|
113
|
+
"specfact-plan-init",
|
|
114
|
+
"specfact-plan-select",
|
|
115
|
+
"specfact-plan-promote",
|
|
116
|
+
"specfact-plan-compare",
|
|
117
|
+
"specfact-sync",
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@beartype
|
|
122
|
+
@require(lambda ide: ide in IDE_CONFIG or ide == "auto", "IDE must be valid or 'auto'")
|
|
123
|
+
def detect_ide(ide: str = "auto") -> str:
|
|
124
|
+
"""
|
|
125
|
+
Detect IDE type from environment or use provided value.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
ide: IDE identifier or "auto" for auto-detection
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
IDE identifier (e.g., "cursor", "vscode", "copilot")
|
|
132
|
+
|
|
133
|
+
Examples:
|
|
134
|
+
>>> detect_ide("cursor")
|
|
135
|
+
'cursor'
|
|
136
|
+
>>> detect_ide("auto") # Auto-detect from environment
|
|
137
|
+
'vscode'
|
|
138
|
+
"""
|
|
139
|
+
if ide != "auto":
|
|
140
|
+
return ide
|
|
141
|
+
|
|
142
|
+
# Auto-detect from environment variables
|
|
143
|
+
# Check Cursor FIRST (before VS Code) since Cursor sets VSCODE_* variables too
|
|
144
|
+
# Cursor-specific variables take priority
|
|
145
|
+
# Cursor sets: CURSOR_AGENT, CURSOR_TRACE_ID, CURSOR_PID, CURSOR_INJECTION, CHROME_DESKTOP=cursor.desktop
|
|
146
|
+
if (
|
|
147
|
+
os.environ.get("CURSOR_AGENT")
|
|
148
|
+
or os.environ.get("CURSOR_TRACE_ID")
|
|
149
|
+
or os.environ.get("CURSOR_PID")
|
|
150
|
+
or os.environ.get("CURSOR_INJECTION")
|
|
151
|
+
or os.environ.get("CHROME_DESKTOP") == "cursor.desktop"
|
|
152
|
+
):
|
|
153
|
+
return "cursor"
|
|
154
|
+
# VS Code / Copilot
|
|
155
|
+
if os.environ.get("VSCODE_PID") or os.environ.get("VSCODE_INJECTION"):
|
|
156
|
+
return "vscode"
|
|
157
|
+
# Claude Code
|
|
158
|
+
if os.environ.get("CLAUDE_PID"):
|
|
159
|
+
return "claude"
|
|
160
|
+
# Default to VS Code if no detection
|
|
161
|
+
return "vscode"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@beartype
|
|
165
|
+
@require(lambda template_path: template_path.exists(), "Template path must exist")
|
|
166
|
+
@require(lambda template_path: template_path.is_file(), "Template path must be a file")
|
|
167
|
+
@ensure(
|
|
168
|
+
lambda result: isinstance(result, dict) and "description" in result and "content" in result,
|
|
169
|
+
"Result must be dict with description and content",
|
|
170
|
+
)
|
|
171
|
+
def read_template(template_path: Path) -> dict[str, str]:
|
|
172
|
+
"""
|
|
173
|
+
Read prompt template and extract YAML frontmatter and content.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
template_path: Path to template file (.md)
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
Dict with "description" (from frontmatter) and "content" (markdown body)
|
|
180
|
+
|
|
181
|
+
Examples:
|
|
182
|
+
>>> template = read_template(Path("resources/prompts/specfact-import-from-code.md"))
|
|
183
|
+
>>> "description" in template
|
|
184
|
+
True
|
|
185
|
+
>>> "content" in template
|
|
186
|
+
True
|
|
187
|
+
"""
|
|
188
|
+
content = template_path.read_text(encoding="utf-8")
|
|
189
|
+
|
|
190
|
+
# Extract YAML frontmatter
|
|
191
|
+
frontmatter_match = re.match(r"^---\s*\n(.*?)\n---\s*\n(.*)$", content, re.DOTALL)
|
|
192
|
+
if frontmatter_match:
|
|
193
|
+
frontmatter_str = frontmatter_match.group(1)
|
|
194
|
+
body = frontmatter_match.group(2)
|
|
195
|
+
frontmatter = yaml.safe_load(frontmatter_str) or {}
|
|
196
|
+
description = frontmatter.get("description", "")
|
|
197
|
+
else:
|
|
198
|
+
# No frontmatter, use entire content as body
|
|
199
|
+
description = ""
|
|
200
|
+
body = content
|
|
201
|
+
|
|
202
|
+
return {"description": description, "content": body}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@beartype
|
|
206
|
+
@require(lambda content: isinstance(content, str), "Content must be string")
|
|
207
|
+
@require(lambda format_type: format_type in ("md", "toml", "prompt.md"), "Format must be md, toml, or prompt.md")
|
|
208
|
+
def process_template(content: str, description: str, format_type: Literal["md", "toml", "prompt.md"]) -> str:
|
|
209
|
+
"""
|
|
210
|
+
Process template content for specific IDE format.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
content: Template markdown content
|
|
214
|
+
description: Template description (from frontmatter)
|
|
215
|
+
format_type: Target format (md, toml, or prompt.md)
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
Processed template content for target format
|
|
219
|
+
|
|
220
|
+
Examples:
|
|
221
|
+
>>> process_template("# Title\n$ARGUMENTS", "Test", "md")
|
|
222
|
+
'# Title\n$ARGUMENTS'
|
|
223
|
+
>>> result = process_template("# Title\n$ARGUMENTS", "Test", "toml")
|
|
224
|
+
>>> "description" in result and "prompt" in result
|
|
225
|
+
True
|
|
226
|
+
"""
|
|
227
|
+
# Replace placeholders based on format
|
|
228
|
+
if format_type == "toml":
|
|
229
|
+
# TOML format: Replace $ARGUMENTS with {{args}}, escape backslashes
|
|
230
|
+
processed = content.replace("$ARGUMENTS", "{{args}}")
|
|
231
|
+
processed = processed.replace("\\", "\\\\")
|
|
232
|
+
# Wrap in TOML structure
|
|
233
|
+
return f'description = "{description}"\n\nprompt = """\n{processed}\n"""'
|
|
234
|
+
if format_type == "prompt.md":
|
|
235
|
+
# VS Code/Copilot format: Keep $ARGUMENTS, add .prompt.md extension
|
|
236
|
+
return content
|
|
237
|
+
# Markdown format: Keep $ARGUMENTS as-is
|
|
238
|
+
return content
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@beartype
|
|
242
|
+
@require(lambda repo_path: repo_path.exists(), "Repo path must exist")
|
|
243
|
+
@require(lambda repo_path: repo_path.is_dir(), "Repo path must be a directory")
|
|
244
|
+
@require(lambda ide: ide in IDE_CONFIG, "IDE must be valid")
|
|
245
|
+
@ensure(
|
|
246
|
+
lambda result: isinstance(result, tuple)
|
|
247
|
+
and len(result) == 2
|
|
248
|
+
and (result[1] is None or (isinstance(result[1], Path) and result[1].exists())),
|
|
249
|
+
"Settings file path must exist if returned",
|
|
250
|
+
)
|
|
251
|
+
def copy_templates_to_ide(
|
|
252
|
+
repo_path: Path, ide: str, templates_dir: Path, force: bool = False
|
|
253
|
+
) -> tuple[list[Path], Path | None]:
|
|
254
|
+
"""
|
|
255
|
+
Copy prompt templates to IDE-specific locations.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
repo_path: Repository root path
|
|
259
|
+
ide: IDE identifier
|
|
260
|
+
templates_dir: Directory containing prompt templates
|
|
261
|
+
force: Overwrite existing files
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
Tuple of (copied_file_paths, settings_file_path or None)
|
|
265
|
+
|
|
266
|
+
Examples:
|
|
267
|
+
>>> copied, settings = copy_templates_to_ide(Path("."), "cursor", Path("resources/prompts"))
|
|
268
|
+
>>> len(copied) > 0
|
|
269
|
+
True
|
|
270
|
+
"""
|
|
271
|
+
config = IDE_CONFIG[ide]
|
|
272
|
+
ide_folder = str(config["folder"])
|
|
273
|
+
format_type = str(config["format"])
|
|
274
|
+
settings_file = config.get("settings_file")
|
|
275
|
+
if settings_file is not None and not isinstance(settings_file, str):
|
|
276
|
+
settings_file = None
|
|
277
|
+
|
|
278
|
+
# Create IDE directory
|
|
279
|
+
ide_dir = repo_path / ide_folder
|
|
280
|
+
ide_dir.mkdir(parents=True, exist_ok=True)
|
|
281
|
+
|
|
282
|
+
copied_files = []
|
|
283
|
+
|
|
284
|
+
# Copy each template
|
|
285
|
+
for command in SPECFACT_COMMANDS:
|
|
286
|
+
template_path = templates_dir / f"{command}.md"
|
|
287
|
+
if not template_path.exists():
|
|
288
|
+
console.print(f"[yellow]Warning:[/yellow] Template not found: {template_path}")
|
|
289
|
+
continue
|
|
290
|
+
|
|
291
|
+
# Read and process template
|
|
292
|
+
template_data = read_template(template_path)
|
|
293
|
+
processed_content = process_template(template_data["content"], template_data["description"], format_type) # type: ignore[arg-type]
|
|
294
|
+
|
|
295
|
+
# Determine output filename
|
|
296
|
+
if format_type == "prompt.md":
|
|
297
|
+
output_filename = f"{command}.prompt.md"
|
|
298
|
+
elif format_type == "toml":
|
|
299
|
+
output_filename = f"{command}.toml"
|
|
300
|
+
else:
|
|
301
|
+
output_filename = f"{command}.md"
|
|
302
|
+
|
|
303
|
+
output_path = ide_dir / output_filename
|
|
304
|
+
|
|
305
|
+
# Check if file exists
|
|
306
|
+
if output_path.exists() and not force:
|
|
307
|
+
console.print(f"[yellow]Skipping:[/yellow] {output_path} (already exists, use --force to overwrite)")
|
|
308
|
+
continue
|
|
309
|
+
|
|
310
|
+
# Write processed template
|
|
311
|
+
output_path.write_text(processed_content, encoding="utf-8")
|
|
312
|
+
copied_files.append(output_path)
|
|
313
|
+
console.print(f"[green]Copied:[/green] {output_path}")
|
|
314
|
+
|
|
315
|
+
# Handle VS Code settings if needed
|
|
316
|
+
settings_path = None
|
|
317
|
+
if settings_file and isinstance(settings_file, str):
|
|
318
|
+
settings_path = create_vscode_settings(repo_path, settings_file)
|
|
319
|
+
|
|
320
|
+
return (copied_files, settings_path)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@beartype
|
|
324
|
+
@require(lambda repo_path: repo_path.exists(), "Repo path must exist")
|
|
325
|
+
@require(lambda repo_path: repo_path.is_dir(), "Repo path must be a directory")
|
|
326
|
+
@ensure(lambda result: result is None or result.exists(), "Settings file must exist if returned")
|
|
327
|
+
def create_vscode_settings(repo_path: Path, settings_file: str) -> Path | None:
|
|
328
|
+
"""
|
|
329
|
+
Create or merge VS Code settings.json with prompt file recommendations.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
repo_path: Repository root path
|
|
333
|
+
settings_file: Settings file path (e.g., ".vscode/settings.json")
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
Path to settings file, or None if not VS Code/Copilot
|
|
337
|
+
|
|
338
|
+
Examples:
|
|
339
|
+
>>> settings = create_vscode_settings(Path("."), ".vscode/settings.json")
|
|
340
|
+
>>> settings is not None
|
|
341
|
+
True
|
|
342
|
+
"""
|
|
343
|
+
import json
|
|
344
|
+
|
|
345
|
+
settings_path = repo_path / settings_file
|
|
346
|
+
settings_dir = settings_path.parent
|
|
347
|
+
settings_dir.mkdir(parents=True, exist_ok=True)
|
|
348
|
+
|
|
349
|
+
# Generate prompt file recommendations
|
|
350
|
+
prompt_files = [f".github/prompts/{cmd}.prompt.md" for cmd in SPECFACT_COMMANDS]
|
|
351
|
+
|
|
352
|
+
# Load existing settings or create new
|
|
353
|
+
if settings_path.exists():
|
|
354
|
+
try:
|
|
355
|
+
with open(settings_path, encoding="utf-8") as f:
|
|
356
|
+
existing_settings = json.load(f)
|
|
357
|
+
except (json.JSONDecodeError, FileNotFoundError):
|
|
358
|
+
existing_settings = {}
|
|
359
|
+
else:
|
|
360
|
+
existing_settings = {}
|
|
361
|
+
|
|
362
|
+
# Merge chat.promptFilesRecommendations
|
|
363
|
+
if "chat" not in existing_settings:
|
|
364
|
+
existing_settings["chat"] = {}
|
|
365
|
+
|
|
366
|
+
existing_recommendations = existing_settings["chat"].get("promptFilesRecommendations", [])
|
|
367
|
+
merged_recommendations = list(set(existing_recommendations + prompt_files))
|
|
368
|
+
existing_settings["chat"]["promptFilesRecommendations"] = merged_recommendations
|
|
369
|
+
|
|
370
|
+
# Write merged settings
|
|
371
|
+
with open(settings_path, "w", encoding="utf-8") as f:
|
|
372
|
+
json.dump(existing_settings, f, indent=4)
|
|
373
|
+
f.write("\n")
|
|
374
|
+
|
|
375
|
+
# Ensure file exists before returning (satisfies contract)
|
|
376
|
+
if not settings_path.exists():
|
|
377
|
+
console.print(f"[yellow]Warning:[/yellow] Settings file not created: {settings_path}")
|
|
378
|
+
return None
|
|
379
|
+
|
|
380
|
+
console.print(f"[green]Updated:[/green] {settings_path}")
|
|
381
|
+
return settings_path
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Interactive prompt utilities for CLI commands."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from beartype import beartype
|
|
6
|
+
from icontract import ensure, require
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.prompt import Confirm, Prompt
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@beartype
|
|
15
|
+
@require(lambda message: isinstance(message, str) and len(message) > 0, "Message must be non-empty string")
|
|
16
|
+
@require(lambda default: default is None or isinstance(default, str), "Default must be None or string")
|
|
17
|
+
@ensure(lambda result: isinstance(result, str), "Must return string")
|
|
18
|
+
def prompt_text(message: str, default: str | None = None, required: bool = True) -> str:
|
|
19
|
+
"""
|
|
20
|
+
Prompt user for text input.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
message: Prompt message
|
|
24
|
+
default: Default value
|
|
25
|
+
required: Whether input is required
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
User input string
|
|
29
|
+
"""
|
|
30
|
+
while True:
|
|
31
|
+
result = Prompt.ask(message, default=default if default else "")
|
|
32
|
+
if result or not required:
|
|
33
|
+
return result
|
|
34
|
+
console.print("[yellow]This field is required[/yellow]")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@beartype
|
|
38
|
+
@require(lambda message: isinstance(message, str) and len(message) > 0, "Message must be non-empty string")
|
|
39
|
+
@require(lambda separator: isinstance(separator, str) and len(separator) > 0, "Separator must be non-empty string")
|
|
40
|
+
@ensure(lambda result: isinstance(result, list), "Must return list")
|
|
41
|
+
@ensure(lambda result: all(isinstance(item, str) for item in result), "All items must be strings")
|
|
42
|
+
def prompt_list(message: str, separator: str = ",") -> list[str]:
|
|
43
|
+
"""
|
|
44
|
+
Prompt user for comma-separated list input.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
message: Prompt message
|
|
48
|
+
separator: List item separator
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
List of strings
|
|
52
|
+
"""
|
|
53
|
+
result = Prompt.ask(f"{message} (comma-separated)")
|
|
54
|
+
if not result:
|
|
55
|
+
return []
|
|
56
|
+
return [item.strip() for item in result.split(separator) if item.strip()]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@beartype
|
|
60
|
+
@require(lambda message: isinstance(message, str) and len(message) > 0, "Message must be non-empty string")
|
|
61
|
+
@ensure(lambda result: isinstance(result, dict), "Must return dictionary")
|
|
62
|
+
def prompt_dict(message: str) -> dict[str, Any]:
|
|
63
|
+
"""
|
|
64
|
+
Prompt user for key:value pairs.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
message: Prompt message
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Dictionary of key-value pairs
|
|
71
|
+
"""
|
|
72
|
+
console.print(f"\n[bold]{message}[/bold]")
|
|
73
|
+
console.print("Enter key:value pairs (one per line, empty line to finish)")
|
|
74
|
+
|
|
75
|
+
result = {}
|
|
76
|
+
while True:
|
|
77
|
+
line = Prompt.ask(" ", default="")
|
|
78
|
+
if not line:
|
|
79
|
+
break
|
|
80
|
+
|
|
81
|
+
if ":" not in line:
|
|
82
|
+
console.print("[yellow]Format should be key:value[/yellow]")
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
key, value = line.split(":", 1)
|
|
86
|
+
key = key.strip()
|
|
87
|
+
value = value.strip()
|
|
88
|
+
|
|
89
|
+
# Try to convert to number if possible
|
|
90
|
+
try:
|
|
91
|
+
if "." in value:
|
|
92
|
+
result[key] = float(value)
|
|
93
|
+
else:
|
|
94
|
+
result[key] = int(value)
|
|
95
|
+
except ValueError:
|
|
96
|
+
result[key] = value
|
|
97
|
+
|
|
98
|
+
return result
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@beartype
|
|
102
|
+
@require(lambda message: isinstance(message, str) and len(message) > 0, "Message must be non-empty string")
|
|
103
|
+
@ensure(lambda result: isinstance(result, bool), "Must return boolean")
|
|
104
|
+
def prompt_confirm(message: str, default: bool = False) -> bool:
|
|
105
|
+
"""
|
|
106
|
+
Prompt user for yes/no confirmation.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
message: Prompt message
|
|
110
|
+
default: Default value
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
True if confirmed, False otherwise
|
|
114
|
+
"""
|
|
115
|
+
return Confirm.ask(message, default=default)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@beartype
|
|
119
|
+
@require(lambda title: isinstance(title, str) and len(title) > 0, "Title must be non-empty string")
|
|
120
|
+
@require(lambda data: isinstance(data, dict), "Data must be dictionary")
|
|
121
|
+
def display_summary(title: str, data: dict[str, Any]) -> None:
|
|
122
|
+
"""
|
|
123
|
+
Display a summary table.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
title: Table title
|
|
127
|
+
data: Data to display
|
|
128
|
+
"""
|
|
129
|
+
table = Table(title=title, show_header=True, header_style="bold cyan")
|
|
130
|
+
table.add_column("Field", style="cyan")
|
|
131
|
+
table.add_column("Value", style="green")
|
|
132
|
+
|
|
133
|
+
for key, value in data.items():
|
|
134
|
+
if isinstance(value, list):
|
|
135
|
+
value_str = ", ".join(str(v) for v in value)
|
|
136
|
+
elif isinstance(value, dict):
|
|
137
|
+
value_str = ", ".join(f"{k}={v}" for k, v in value.items())
|
|
138
|
+
else:
|
|
139
|
+
value_str = str(value)
|
|
140
|
+
table.add_row(key, value_str)
|
|
141
|
+
|
|
142
|
+
console.print(table)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@beartype
|
|
146
|
+
@require(lambda message: isinstance(message, str) and len(message) > 0, "Message must be non-empty string")
|
|
147
|
+
def print_success(message: str) -> None:
|
|
148
|
+
"""Print success message."""
|
|
149
|
+
console.print(f"[bold green]✅ {message}[/bold green]")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@beartype
|
|
153
|
+
@require(lambda message: isinstance(message, str) and len(message) > 0, "Message must be non-empty string")
|
|
154
|
+
def print_error(message: str) -> None:
|
|
155
|
+
"""Print error message."""
|
|
156
|
+
console.print(f"[bold red]❌ {message}[/bold red]")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@beartype
|
|
160
|
+
@require(lambda message: isinstance(message, str) and len(message) > 0, "Message must be non-empty string")
|
|
161
|
+
def print_warning(message: str) -> None:
|
|
162
|
+
"""Print warning message."""
|
|
163
|
+
console.print(f"[bold yellow]⚠️ {message}[/bold yellow]")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@beartype
|
|
167
|
+
@require(lambda message: isinstance(message, str) and len(message) > 0, "Message must be non-empty string")
|
|
168
|
+
def print_info(message: str) -> None:
|
|
169
|
+
"""Print info message."""
|
|
170
|
+
console.print(f"[bold blue]ℹ️ {message}[/bold blue]")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@beartype
|
|
174
|
+
@require(lambda title: isinstance(title, str) and len(title) > 0, "Title must be non-empty string")
|
|
175
|
+
def print_section(title: str) -> None:
|
|
176
|
+
"""Print section header."""
|
|
177
|
+
console.print(f"\n[bold cyan]{'=' * 60}[/bold cyan]")
|
|
178
|
+
console.print(f"[bold cyan]{title}[/bold cyan]")
|
|
179
|
+
console.print(f"[bold cyan]{'=' * 60}[/bold cyan]\n")
|