cli-core-yo 0.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_core_yo/__init__.py +8 -0
- cli_core_yo/_version.py +1 -0
- cli_core_yo/app.py +416 -0
- cli_core_yo/errors.py +61 -0
- cli_core_yo/output.py +123 -0
- cli_core_yo/plugins.py +97 -0
- cli_core_yo/py.typed +0 -0
- cli_core_yo/registry.py +211 -0
- cli_core_yo/runtime.py +52 -0
- cli_core_yo/spec.py +77 -0
- cli_core_yo/xdg.py +87 -0
- cli_core_yo-0.2.0.dist-info/METADATA +189 -0
- cli_core_yo-0.2.0.dist-info/RECORD +16 -0
- cli_core_yo-0.2.0.dist-info/WHEEL +5 -0
- cli_core_yo-0.2.0.dist-info/licenses/LICENSE +21 -0
- cli_core_yo-0.2.0.dist-info/top_level.txt +1 -0
cli_core_yo/__init__.py
ADDED
cli_core_yo/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
cli_core_yo/app.py
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"""App factory and built-in commands (§3.3–3.5, §4.6, §4.7).
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
create_app(spec) -> typer.Typer
|
|
5
|
+
run(spec, argv) -> int
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib.resources
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import traceback
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
|
|
18
|
+
import typer
|
|
19
|
+
|
|
20
|
+
from cli_core_yo import output
|
|
21
|
+
from cli_core_yo.errors import CliCoreYoError
|
|
22
|
+
from cli_core_yo.plugins import load_plugins
|
|
23
|
+
from cli_core_yo.registry import CommandRegistry
|
|
24
|
+
from cli_core_yo.runtime import _reset, initialize
|
|
25
|
+
from cli_core_yo.spec import CliSpec, ConfigSpec, EnvSpec
|
|
26
|
+
from cli_core_yo.xdg import XdgPaths, resolve_paths
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def create_app(spec: CliSpec) -> typer.Typer:
|
|
30
|
+
"""Create a fully-constructed Typer app from a CliSpec.
|
|
31
|
+
|
|
32
|
+
Sequence (§3.5):
|
|
33
|
+
1. Validate spec
|
|
34
|
+
2. Construct root Typer app
|
|
35
|
+
3. Initialize RuntimeContext
|
|
36
|
+
4. Register built-in commands (version, info)
|
|
37
|
+
5. Conditionally register config group
|
|
38
|
+
6. Conditionally register env group
|
|
39
|
+
7. Load plugins
|
|
40
|
+
8. Freeze registry
|
|
41
|
+
9. Apply registry to app
|
|
42
|
+
"""
|
|
43
|
+
_validate_spec(spec)
|
|
44
|
+
|
|
45
|
+
app = typer.Typer(
|
|
46
|
+
name=spec.prog_name,
|
|
47
|
+
help=spec.root_help,
|
|
48
|
+
add_completion=True,
|
|
49
|
+
no_args_is_help=True,
|
|
50
|
+
rich_markup_mode="rich",
|
|
51
|
+
context_settings={"help_option_names": ["--help"]},
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Resolve XDG paths and initialize runtime
|
|
55
|
+
xdg_paths = resolve_paths(spec.xdg)
|
|
56
|
+
|
|
57
|
+
# Build reserved names from enabled optional groups
|
|
58
|
+
reserved: set[str] = set()
|
|
59
|
+
if spec.config is not None:
|
|
60
|
+
reserved.add("config")
|
|
61
|
+
if spec.env is not None:
|
|
62
|
+
reserved.add("env")
|
|
63
|
+
|
|
64
|
+
registry = CommandRegistry(reserved_names=frozenset(reserved))
|
|
65
|
+
|
|
66
|
+
# Register built-in commands
|
|
67
|
+
_register_version(registry, spec)
|
|
68
|
+
_register_info(registry, spec, xdg_paths)
|
|
69
|
+
|
|
70
|
+
# Register optional built-in groups
|
|
71
|
+
if spec.config is not None:
|
|
72
|
+
_register_config_group(registry, spec.config, xdg_paths)
|
|
73
|
+
if spec.env is not None:
|
|
74
|
+
_register_env_group(registry, spec.env, xdg_paths)
|
|
75
|
+
|
|
76
|
+
# Load plugins (explicit first, then entry-points)
|
|
77
|
+
load_plugins(registry, spec)
|
|
78
|
+
|
|
79
|
+
# Freeze and apply
|
|
80
|
+
registry.freeze()
|
|
81
|
+
registry.apply(app)
|
|
82
|
+
|
|
83
|
+
# Store registry on app for run() to access
|
|
84
|
+
app._cli_core_yo_registry = registry # type: ignore[attr-defined]
|
|
85
|
+
app._cli_core_yo_spec = spec # type: ignore[attr-defined]
|
|
86
|
+
app._cli_core_yo_xdg_paths = xdg_paths # type: ignore[attr-defined]
|
|
87
|
+
|
|
88
|
+
return app
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def run(spec: CliSpec, argv: list[str] | None = None) -> int:
|
|
92
|
+
"""Execute the CLI and return an exit code. MUST NOT call sys.exit()."""
|
|
93
|
+
_reset() # ensure clean context for this invocation
|
|
94
|
+
|
|
95
|
+
# Determine debug mode from environment (§6.6)
|
|
96
|
+
debug = os.environ.get("CLI_CORE_YO_DEBUG") == "1"
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
app = create_app(spec)
|
|
100
|
+
xdg_paths = app._cli_core_yo_xdg_paths # type: ignore[attr-defined]
|
|
101
|
+
|
|
102
|
+
# Determine json_mode from argv before Typer parses
|
|
103
|
+
args = argv if argv is not None else sys.argv[1:]
|
|
104
|
+
json_mode = "--json" in args or "-j" in args
|
|
105
|
+
|
|
106
|
+
initialize(spec, xdg_paths, json_mode=json_mode, debug=debug)
|
|
107
|
+
|
|
108
|
+
app(args, standalone_mode=False)
|
|
109
|
+
return 0
|
|
110
|
+
except SystemExit as exc:
|
|
111
|
+
return exc.code if isinstance(exc.code, int) else 0
|
|
112
|
+
except CliCoreYoError as exc:
|
|
113
|
+
if debug:
|
|
114
|
+
traceback.print_exc(file=sys.stderr)
|
|
115
|
+
output.error(str(exc))
|
|
116
|
+
return exc.exit_code
|
|
117
|
+
except Exception as exc:
|
|
118
|
+
if debug:
|
|
119
|
+
traceback.print_exc(file=sys.stderr)
|
|
120
|
+
output.error(f"Unexpected error: {exc}")
|
|
121
|
+
return 1
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ── Validation ───────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _validate_spec(spec: CliSpec) -> None:
|
|
128
|
+
"""Validate CliSpec required fields (§3.5 step 1)."""
|
|
129
|
+
from cli_core_yo.errors import SpecValidationError
|
|
130
|
+
from cli_core_yo.spec import NAME_RE
|
|
131
|
+
|
|
132
|
+
if not spec.prog_name:
|
|
133
|
+
raise SpecValidationError("prog_name must not be empty")
|
|
134
|
+
if not NAME_RE.match(spec.prog_name):
|
|
135
|
+
raise SpecValidationError(f"prog_name '{spec.prog_name}' is not a valid name")
|
|
136
|
+
if not spec.app_display_name:
|
|
137
|
+
raise SpecValidationError("app_display_name must not be empty")
|
|
138
|
+
if not spec.dist_name:
|
|
139
|
+
raise SpecValidationError("dist_name must not be empty")
|
|
140
|
+
if not spec.root_help:
|
|
141
|
+
raise SpecValidationError("root_help must not be empty")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ── Built-in: version ────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _register_version(registry: CommandRegistry, spec: CliSpec) -> None:
|
|
148
|
+
"""Register the 'version' built-in command (§2.5)."""
|
|
149
|
+
|
|
150
|
+
def _version_callback(
|
|
151
|
+
json: bool = typer.Option(False, "--json", "-j", help="Output as JSON."),
|
|
152
|
+
) -> None:
|
|
153
|
+
version = _get_dist_version(spec.dist_name)
|
|
154
|
+
if json:
|
|
155
|
+
output.emit_json({"app": spec.app_display_name, "version": version})
|
|
156
|
+
else:
|
|
157
|
+
output.print_text(f"{spec.app_display_name} [cyan]{version}[/cyan]")
|
|
158
|
+
|
|
159
|
+
registry._reserved.discard("version")
|
|
160
|
+
registry.add_command(None, "version", _version_callback, help_text="Show version.", order=0)
|
|
161
|
+
registry._reserved.add("version")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ── Built-in: info ───────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _register_info(registry: CommandRegistry, spec: CliSpec, xdg_paths: XdgPaths) -> None:
|
|
168
|
+
"""Register the 'info' built-in command (§2.5, §6.3)."""
|
|
169
|
+
|
|
170
|
+
def _info_callback(
|
|
171
|
+
json: bool = typer.Option(False, "--json", "-j", help="Output as JSON."),
|
|
172
|
+
) -> None:
|
|
173
|
+
version = _get_dist_version(spec.dist_name)
|
|
174
|
+
core_version = _get_dist_version("cli-core-yo")
|
|
175
|
+
|
|
176
|
+
rows: list[tuple[str, str]] = [
|
|
177
|
+
("Version", version),
|
|
178
|
+
("Python", sys.version.split()[0]),
|
|
179
|
+
("Config Dir", str(xdg_paths.config)),
|
|
180
|
+
("Data Dir", str(xdg_paths.data)),
|
|
181
|
+
("State Dir", str(xdg_paths.state)),
|
|
182
|
+
("Cache Dir", str(xdg_paths.cache)),
|
|
183
|
+
("CLI Core", core_version),
|
|
184
|
+
]
|
|
185
|
+
|
|
186
|
+
# Extension hooks (§6.3)
|
|
187
|
+
for hook in spec.info_hooks:
|
|
188
|
+
rows.extend(hook())
|
|
189
|
+
|
|
190
|
+
if json:
|
|
191
|
+
output.emit_json({k: v for k, v in rows})
|
|
192
|
+
else:
|
|
193
|
+
output.heading(f"{spec.app_display_name} Info")
|
|
194
|
+
max_key = max(len(k) for k, _ in rows)
|
|
195
|
+
for key, val in rows:
|
|
196
|
+
output.print_text(f" {key:<{max_key}} {val}")
|
|
197
|
+
|
|
198
|
+
registry._reserved.discard("info")
|
|
199
|
+
registry.add_command(None, "info", _info_callback, help_text="Show system info.", order=1)
|
|
200
|
+
registry._reserved.add("info")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ── Built-in: config group ───────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _register_config_group(
|
|
207
|
+
registry: CommandRegistry, config_spec: ConfigSpec, xdg_paths: XdgPaths
|
|
208
|
+
) -> None:
|
|
209
|
+
"""Register built-in config subcommands (§4.6)."""
|
|
210
|
+
config_path = xdg_paths.config / config_spec.primary_filename
|
|
211
|
+
|
|
212
|
+
registry._reserved.discard("config")
|
|
213
|
+
registry.add_group("config", help_text="Configuration management.")
|
|
214
|
+
registry._reserved.add("config")
|
|
215
|
+
|
|
216
|
+
# config path
|
|
217
|
+
def _config_path_callback() -> None:
|
|
218
|
+
output.print_text(str(config_path))
|
|
219
|
+
|
|
220
|
+
registry.add_command(
|
|
221
|
+
"config",
|
|
222
|
+
"path",
|
|
223
|
+
_config_path_callback,
|
|
224
|
+
help_text="Show config file path.",
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# config init
|
|
228
|
+
def _config_init_callback(
|
|
229
|
+
force: bool = typer.Option(False, "--force", help="Overwrite existing file."),
|
|
230
|
+
) -> None:
|
|
231
|
+
if config_path.exists() and not force:
|
|
232
|
+
output.error(f"Config file already exists: {config_path}")
|
|
233
|
+
output.detail("Use --force to overwrite.")
|
|
234
|
+
raise SystemExit(1)
|
|
235
|
+
template = _resolve_template(config_spec)
|
|
236
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
237
|
+
config_path.write_bytes(template)
|
|
238
|
+
output.success(f"Config file created: {config_path}")
|
|
239
|
+
|
|
240
|
+
registry.add_command(
|
|
241
|
+
"config",
|
|
242
|
+
"init",
|
|
243
|
+
_config_init_callback,
|
|
244
|
+
help_text="Create config from template.",
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
# config show
|
|
248
|
+
def _config_show_callback() -> None:
|
|
249
|
+
if not config_path.exists():
|
|
250
|
+
output.error(f"Config file not found: {config_path}")
|
|
251
|
+
raise SystemExit(1)
|
|
252
|
+
sys.stdout.write(config_path.read_text(encoding="utf-8"))
|
|
253
|
+
|
|
254
|
+
registry.add_command(
|
|
255
|
+
"config",
|
|
256
|
+
"show",
|
|
257
|
+
_config_show_callback,
|
|
258
|
+
help_text="Show config file contents.",
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
# config validate
|
|
262
|
+
def _config_validate_callback() -> None:
|
|
263
|
+
if config_spec.validator is None:
|
|
264
|
+
output.success("No validator configured — config is accepted.")
|
|
265
|
+
return
|
|
266
|
+
if not config_path.exists():
|
|
267
|
+
output.error(f"Config file not found: {config_path}")
|
|
268
|
+
raise SystemExit(1)
|
|
269
|
+
content = config_path.read_text(encoding="utf-8")
|
|
270
|
+
errors = config_spec.validator(content)
|
|
271
|
+
if errors:
|
|
272
|
+
output.error("Config validation failed:")
|
|
273
|
+
for err in errors:
|
|
274
|
+
output.bullet(err)
|
|
275
|
+
raise SystemExit(1)
|
|
276
|
+
output.success("Config is valid.")
|
|
277
|
+
|
|
278
|
+
registry.add_command(
|
|
279
|
+
"config",
|
|
280
|
+
"validate",
|
|
281
|
+
_config_validate_callback,
|
|
282
|
+
help_text="Validate config file.",
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# config edit
|
|
286
|
+
def _config_edit_callback() -> None:
|
|
287
|
+
if not sys.stdin.isatty():
|
|
288
|
+
output.error("Cannot edit config: not an interactive terminal.")
|
|
289
|
+
raise SystemExit(1)
|
|
290
|
+
if not config_path.exists():
|
|
291
|
+
output.error(f"Config file not found: {config_path}")
|
|
292
|
+
raise SystemExit(1)
|
|
293
|
+
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or "vi"
|
|
294
|
+
result = subprocess.run([editor, str(config_path)])
|
|
295
|
+
if result.returncode != 0:
|
|
296
|
+
output.error(f"Editor exited with code {result.returncode}")
|
|
297
|
+
raise SystemExit(1)
|
|
298
|
+
|
|
299
|
+
registry.add_command(
|
|
300
|
+
"config",
|
|
301
|
+
"edit",
|
|
302
|
+
_config_edit_callback,
|
|
303
|
+
help_text="Edit config in editor.",
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# config reset
|
|
307
|
+
def _config_reset_callback(
|
|
308
|
+
yes: bool = typer.Option(False, "--yes", help="Skip confirmation."),
|
|
309
|
+
) -> None:
|
|
310
|
+
if config_path.exists():
|
|
311
|
+
if not yes:
|
|
312
|
+
confirm = typer.confirm(
|
|
313
|
+
"Reset config to template? This will overwrite current config."
|
|
314
|
+
)
|
|
315
|
+
if not confirm:
|
|
316
|
+
output.action("Aborted.")
|
|
317
|
+
raise SystemExit(0)
|
|
318
|
+
# Backup
|
|
319
|
+
ts = datetime.now(tz=timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
320
|
+
backup = config_path.with_suffix(f".{ts}.bak")
|
|
321
|
+
shutil.copy2(str(config_path), str(backup))
|
|
322
|
+
output.detail(f"Backup: {backup}")
|
|
323
|
+
template = _resolve_template(config_spec)
|
|
324
|
+
config_path.write_bytes(template)
|
|
325
|
+
output.success(f"Config reset to template: {config_path}")
|
|
326
|
+
|
|
327
|
+
registry.add_command(
|
|
328
|
+
"config",
|
|
329
|
+
"reset",
|
|
330
|
+
_config_reset_callback,
|
|
331
|
+
help_text="Reset config to template.",
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# ── Built-in: env group ──────────────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _register_env_group(registry: CommandRegistry, env_spec: EnvSpec, xdg_paths: XdgPaths) -> None:
|
|
339
|
+
"""Register built-in env subcommands (§4.7)."""
|
|
340
|
+
|
|
341
|
+
registry._reserved.discard("env")
|
|
342
|
+
registry.add_group("env", help_text="Environment management.")
|
|
343
|
+
registry._reserved.add("env")
|
|
344
|
+
|
|
345
|
+
# env status
|
|
346
|
+
def _env_status_callback() -> None:
|
|
347
|
+
active_val = os.environ.get(env_spec.active_env_var, "")
|
|
348
|
+
is_active = bool(active_val)
|
|
349
|
+
project_root = os.environ.get(env_spec.project_root_env_var, "")
|
|
350
|
+
|
|
351
|
+
if is_active:
|
|
352
|
+
output.success("Environment is [bold]active[/bold]")
|
|
353
|
+
else:
|
|
354
|
+
output.warning("Environment is [bold]not active[/bold]")
|
|
355
|
+
|
|
356
|
+
output.detail(f"Active env var: {env_spec.active_env_var}={active_val or '(unset)'}")
|
|
357
|
+
output.detail(f"Project root: {project_root or '(unset)'}")
|
|
358
|
+
output.detail(f"Python path: {sys.executable}")
|
|
359
|
+
output.detail(f"Config dir: {xdg_paths.config}")
|
|
360
|
+
|
|
361
|
+
registry.add_command(
|
|
362
|
+
"env",
|
|
363
|
+
"status",
|
|
364
|
+
_env_status_callback,
|
|
365
|
+
help_text="Show environment status.",
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
# env activate
|
|
369
|
+
def _env_activate_callback() -> None:
|
|
370
|
+
output.print_text(f"source {env_spec.activate_script_name}")
|
|
371
|
+
|
|
372
|
+
registry.add_command(
|
|
373
|
+
"env",
|
|
374
|
+
"activate",
|
|
375
|
+
_env_activate_callback,
|
|
376
|
+
help_text="Print activation command.",
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
# env deactivate
|
|
380
|
+
def _env_deactivate_callback() -> None:
|
|
381
|
+
output.print_text(f"source {env_spec.deactivate_script_name}")
|
|
382
|
+
|
|
383
|
+
registry.add_command(
|
|
384
|
+
"env", "deactivate", _env_deactivate_callback, help_text="Print deactivation command."
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
# env reset
|
|
388
|
+
def _env_reset_callback() -> None:
|
|
389
|
+
output.print_text(f"source {env_spec.deactivate_script_name}")
|
|
390
|
+
output.print_text(f"source {env_spec.activate_script_name}")
|
|
391
|
+
|
|
392
|
+
registry.add_command("env", "reset", _env_reset_callback, help_text="Print reset commands.")
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# ── Helpers ──────────────────────────────────────────────────────────────────
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _get_dist_version(dist_name: str) -> str:
|
|
399
|
+
"""Get installed version of a distribution package."""
|
|
400
|
+
try:
|
|
401
|
+
from importlib.metadata import version
|
|
402
|
+
|
|
403
|
+
return version(dist_name)
|
|
404
|
+
except Exception:
|
|
405
|
+
return "unknown"
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _resolve_template(config_spec: ConfigSpec) -> bytes:
|
|
409
|
+
"""Resolve template content from bytes or resource."""
|
|
410
|
+
if config_spec.template_bytes is not None:
|
|
411
|
+
return config_spec.template_bytes
|
|
412
|
+
if config_spec.template_resource is not None:
|
|
413
|
+
pkg, resource_name = config_spec.template_resource
|
|
414
|
+
ref = importlib.resources.files(pkg).joinpath(resource_name)
|
|
415
|
+
return ref.read_bytes()
|
|
416
|
+
raise ValueError("ConfigSpec has no template source")
|
cli_core_yo/errors.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Framework exceptions and exit-code mapping for cli-core-yo."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CliCoreYoError(Exception):
|
|
7
|
+
"""Base exception for all cli-core-yo framework errors."""
|
|
8
|
+
|
|
9
|
+
exit_code: int = 1
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ContextNotInitializedError(CliCoreYoError):
|
|
13
|
+
"""Raised when get_context() is called before runtime initialization."""
|
|
14
|
+
|
|
15
|
+
exit_code: int = 1
|
|
16
|
+
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
super().__init__("RuntimeContext has not been initialized.")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RegistryFrozenError(CliCoreYoError):
|
|
22
|
+
"""Raised when a registration is attempted after the registry is frozen."""
|
|
23
|
+
|
|
24
|
+
exit_code: int = 1
|
|
25
|
+
|
|
26
|
+
def __init__(self, action: str = "register") -> None:
|
|
27
|
+
super().__init__(f"Cannot {action}: command registry is frozen.")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RegistryConflictError(CliCoreYoError):
|
|
31
|
+
"""Raised when a command name collision is detected."""
|
|
32
|
+
|
|
33
|
+
exit_code: int = 1
|
|
34
|
+
|
|
35
|
+
def __init__(self, path: str, detail: str = "") -> None:
|
|
36
|
+
msg = f"Registration conflict at '{path}'"
|
|
37
|
+
if detail:
|
|
38
|
+
msg += f": {detail}"
|
|
39
|
+
super().__init__(msg)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class PluginLoadError(CliCoreYoError):
|
|
43
|
+
"""Raised when a plugin fails to import or raises during registration."""
|
|
44
|
+
|
|
45
|
+
exit_code: int = 1
|
|
46
|
+
|
|
47
|
+
def __init__(self, plugin_name: str, reason: str = "") -> None:
|
|
48
|
+
msg = f"Failed to load plugin '{plugin_name}'"
|
|
49
|
+
if reason:
|
|
50
|
+
msg += f": {reason}"
|
|
51
|
+
super().__init__(msg)
|
|
52
|
+
self.plugin_name = plugin_name
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SpecValidationError(CliCoreYoError):
|
|
56
|
+
"""Raised when CliSpec validation fails."""
|
|
57
|
+
|
|
58
|
+
exit_code: int = 1
|
|
59
|
+
|
|
60
|
+
def __init__(self, detail: str) -> None:
|
|
61
|
+
super().__init__(f"Invalid CliSpec: {detail}")
|
cli_core_yo/output.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""UX output primitives and JSON emitter (§6.1, §6.2, §2.8).
|
|
2
|
+
|
|
3
|
+
All human output goes through Rich Console.
|
|
4
|
+
JSON output bypasses Rich entirely.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
|
|
16
|
+
# Module-level console — lazy-initialized to respect NO_COLOR at call time.
|
|
17
|
+
_console: Console | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _get_console() -> Console:
|
|
21
|
+
"""Return a shared Console instance, respecting NO_COLOR."""
|
|
22
|
+
global _console
|
|
23
|
+
if _console is None:
|
|
24
|
+
no_color = "NO_COLOR" in os.environ
|
|
25
|
+
_console = Console(
|
|
26
|
+
highlight=False,
|
|
27
|
+
no_color=no_color,
|
|
28
|
+
stderr=False,
|
|
29
|
+
)
|
|
30
|
+
return _console
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _reset_console() -> None:
|
|
34
|
+
"""Reset the console (test-only)."""
|
|
35
|
+
global _console
|
|
36
|
+
_console = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _is_json_mode() -> bool:
|
|
40
|
+
"""Check if the current invocation is in JSON mode."""
|
|
41
|
+
try:
|
|
42
|
+
from cli_core_yo.runtime import get_context
|
|
43
|
+
|
|
44
|
+
return get_context().json_mode
|
|
45
|
+
except Exception:
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ── Human output primitives (§6.2) ──────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def heading(title: str) -> None:
|
|
53
|
+
"""Print a section heading: blank line, bold cyan title, blank line."""
|
|
54
|
+
if _is_json_mode():
|
|
55
|
+
return
|
|
56
|
+
con = _get_console()
|
|
57
|
+
con.print()
|
|
58
|
+
con.print(f"[bold cyan]{title}[/bold cyan]")
|
|
59
|
+
con.print()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def success(msg: str) -> None:
|
|
63
|
+
"""Print a success line: ✓ prefix in green."""
|
|
64
|
+
if _is_json_mode():
|
|
65
|
+
return
|
|
66
|
+
_get_console().print(f"[green]✓[/green] {msg}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def warning(msg: str) -> None:
|
|
70
|
+
"""Print a warning line: ⚠ prefix in yellow."""
|
|
71
|
+
if _is_json_mode():
|
|
72
|
+
return
|
|
73
|
+
_get_console().print(f"[yellow]⚠[/yellow] {msg}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def error(msg: str) -> None:
|
|
77
|
+
"""Print an error line: ✗ prefix in red."""
|
|
78
|
+
if _is_json_mode():
|
|
79
|
+
return
|
|
80
|
+
_get_console().print(f"[red]✗[/red] {msg}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def action(msg: str) -> None:
|
|
84
|
+
"""Print an action line: → prefix in cyan."""
|
|
85
|
+
if _is_json_mode():
|
|
86
|
+
return
|
|
87
|
+
_get_console().print(f"[cyan]→[/cyan] {msg}")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def detail(msg: str) -> None:
|
|
91
|
+
"""Print an indented detail line (3-space indent)."""
|
|
92
|
+
if _is_json_mode():
|
|
93
|
+
return
|
|
94
|
+
_get_console().print(f" {msg}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def bullet(msg: str) -> None:
|
|
98
|
+
"""Print a bullet detail line (3-space indent + •)."""
|
|
99
|
+
if _is_json_mode():
|
|
100
|
+
return
|
|
101
|
+
_get_console().print(f" • {msg}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def print_text(msg: str) -> None:
|
|
105
|
+
"""Print arbitrary text through the console (respects NO_COLOR)."""
|
|
106
|
+
if _is_json_mode():
|
|
107
|
+
return
|
|
108
|
+
_get_console().print(msg)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ── JSON emitter (§2.8) ─────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def emit_json(data: Any) -> None:
|
|
115
|
+
"""Write deterministic JSON to stdout, bypassing Rich.
|
|
116
|
+
|
|
117
|
+
- indent=2, sort_keys=True, ensure_ascii=False
|
|
118
|
+
- Trailing newline
|
|
119
|
+
- No ANSI codes
|
|
120
|
+
"""
|
|
121
|
+
text = json.dumps(data, indent=2, sort_keys=True, ensure_ascii=False)
|
|
122
|
+
sys.stdout.write(text + "\n")
|
|
123
|
+
sys.stdout.flush()
|