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.
@@ -0,0 +1,8 @@
1
+ """cli-core-yo: Reusable CLI kernel for unified command-line interfaces."""
2
+
3
+ try:
4
+ from cli_core_yo._version import __version__
5
+ except ImportError:
6
+ __version__ = "0.0.0.dev0"
7
+
8
+ __all__ = ["__version__"]
@@ -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()