python-jcli 0.1.0__py3-none-any.whl → 1.0.1__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.
Files changed (54) hide show
  1. jcli/__init__.py +1 -1
  2. jcli/cli.py +565 -51
  3. jcli/cli_helpers.py +5 -42
  4. jcli/skills/jcli/SKILL.md +15 -2
  5. jcli/skills/jcli/auth/SKILL.md +86 -0
  6. jcli/skills/jcli/build/SKILL.md +123 -0
  7. jcli/skills/jcli/credential/SKILL.md +14 -5
  8. jcli/skills/jcli/job/SKILL.md +20 -4
  9. jcli/skills/jcli/node/SKILL.md +4 -2
  10. jcli/skills/jcli/pipeline/SKILL.md +3 -3
  11. jcli/skills/jcli/plugin/SKILL.md +3 -0
  12. jcli/skills/jcli/system/SKILL.md +15 -2
  13. jcli/skills/jcli/view/SKILL.md +4 -3
  14. jcli/specs/_auth.yaml +15 -0
  15. jcli/{plugins/skills.py → specs/plugins/jcli_commands.py} +124 -63
  16. jcli/specs/plugins/jenkins_auth.py +75 -0
  17. jcli/specs/plugins/jenkins_methods.py +332 -0
  18. jcli/specs/plugins/jenkins_pipeline_validate.py +51 -0
  19. jcli/specs/plugins/jenkins_plugin.py +77 -0
  20. jcli/specs/plugins/jenkins_system.py +137 -0
  21. jcli/specs/resources/build.yaml +160 -0
  22. jcli/specs/resources/credential.yaml +97 -0
  23. jcli/specs/resources/job.yaml +184 -0
  24. jcli/specs/resources/node.yaml +69 -0
  25. jcli/specs/resources/pipeline.yaml +62 -0
  26. jcli/specs/resources/plugin.yaml +65 -0
  27. jcli/specs/resources/system.yaml +72 -0
  28. jcli/specs/resources/view.yaml +75 -0
  29. {python_jcli-0.1.0.dist-info → python_jcli-1.0.1.dist-info}/METADATA +57 -12
  30. python_jcli-1.0.1.dist-info/RECORD +40 -0
  31. {python_jcli-0.1.0.dist-info → python_jcli-1.0.1.dist-info}/WHEEL +1 -1
  32. jcli/plugins/__init__.py +0 -28
  33. jcli/plugins/build.py +0 -197
  34. jcli/plugins/config.py +0 -268
  35. jcli/plugins/credential.py +0 -210
  36. jcli/plugins/job.py +0 -425
  37. jcli/plugins/node.py +0 -134
  38. jcli/plugins/pipeline.py +0 -108
  39. jcli/plugins/plugin.py +0 -173
  40. jcli/plugins/system.py +0 -173
  41. jcli/plugins/view.py +0 -148
  42. jcli/sdk/build.py +0 -182
  43. jcli/sdk/credential.py +0 -143
  44. jcli/sdk/job.py +0 -191
  45. jcli/sdk/job_templates.py +0 -128
  46. jcli/sdk/node.py +0 -139
  47. jcli/sdk/pipeline.py +0 -121
  48. jcli/sdk/plugin.py +0 -155
  49. jcli/sdk/system.py +0 -202
  50. jcli/sdk/view.py +0 -94
  51. jcli/skills/jcli/config/SKILL.md +0 -81
  52. python_jcli-0.1.0.dist-info/RECORD +0 -44
  53. {python_jcli-0.1.0.dist-info → python_jcli-1.0.1.dist-info}/entry_points.txt +0 -0
  54. {python_jcli-0.1.0.dist-info → python_jcli-1.0.1.dist-info}/top_level.txt +0 -0
jcli/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.1.0"
1
+ __version__ = "1.0.1"
jcli/cli.py CHANGED
@@ -1,78 +1,592 @@
1
- """Jenkins CLI - Command line interface for managing Jenkins."""
1
+ """Jenkins CLI - Command line interface for managing Jenkins.
2
+
3
+ jcli 2.0 wrapper: the Click CLI is generated from the cliyard YAML specs
4
+ (``specs/``) and this module layers the global options on top:
5
+
6
+ * ``-f/--format`` — table / json / yaml / csv (default ``table``)
7
+ * ``-p/--profile`` — pick a profile from ``~/.jcli/config.yaml``
8
+ * ``-d/--debug`` — enable DEBUG logging on stderr
9
+ * ``-s/--server`` — handled by cliyard's runner pre-extraction
10
+ (:func:`cliyard.runtime.runner.extract_server_override`); it is *not*
11
+ redefined here to avoid conflicting with cliyard.
12
+
13
+ The global ``-f`` is injected into each subcommand callback only when the
14
+ subcommand did not explicitly set its own ``--format`` on the command line
15
+ (cliyard's ``_make_callback`` pops ``format`` from kwargs, so a plain default
16
+ would shadow the global override).
17
+ """
18
+
19
+ from __future__ import annotations
2
20
 
3
21
  import logging
22
+ import os
4
23
  import sys
24
+ from pathlib import Path
25
+ from typing import Any, Callable
5
26
 
6
27
  import click
28
+ from click.core import ParameterSource
29
+
30
+ from cliyard.runtime import create_cli
31
+ from cliyard.runtime.runner import extract_server_override
7
32
 
8
33
  from jcli import __version__
9
- from jcli.plugins import register_commands
10
34
 
11
35
  logger = logging.getLogger("jcli")
12
36
 
37
+ #: Formats accepted by the global ``-f/--format`` option (superset of
38
+ #: cliyard's built-ins, adding ``yaml``).
39
+ GLOBAL_FORMATS = ("table", "json", "yaml", "csv")
40
+
41
+ #: Env var to override the cliyard spec directory (mainly for tests/dev).
42
+ SPEC_DIR_ENV = "JCLI_SPEC_DIR"
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Spec directory & base_url resolution
47
+ # ---------------------------------------------------------------------------
48
+
49
+
50
+ def _default_spec_dir() -> Path:
51
+ """Locate the cliyard YAML spec directory (ships inside the package).
52
+
53
+ Priority: ``$JCLI_SPEC_DIR`` env var > package-local ``jcli/specs/``.
54
+ """
55
+ env = os.environ.get(SPEC_DIR_ENV)
56
+ if env:
57
+ return Path(env)
58
+ return Path(__file__).resolve().parent / "specs"
59
+
60
+
61
+ def extract_profile_override(argv: list[str]) -> str | None:
62
+ """Read a ``--profile``/``-p`` value from *argv* without removing it.
63
+
64
+ The top-level ``-p/--profile`` Click option parses the flag normally; this
65
+ only previews the value so the base_url can be resolved before the CLI tree
66
+ is built. ``--profile X``, ``--profile=X``, ``-p X`` and ``-p=X`` forms
67
+ are supported.
68
+ """
69
+ profile: str | None = None
70
+ i = 0
71
+ while i < len(argv):
72
+ arg = argv[i]
73
+ if arg in ("--profile", "-p") and i + 1 < len(argv) and not argv[i + 1].startswith("-"):
74
+ profile = argv[i + 1]
75
+ i += 2
76
+ continue
77
+ if arg.startswith("--profile="):
78
+ profile = arg.split("=", 1)[1]
79
+ i += 1
80
+ continue
81
+ if arg.startswith("-p=") and len(arg) > 3:
82
+ profile = arg[3:]
83
+ i += 1
84
+ continue
85
+ i += 1
86
+ return profile
87
+
88
+
89
+ def _read_profile_url(profile_name: str | None) -> str | None:
90
+ """Read a profile URL from ``~/.jcli/config.yaml`` (read-only, no creation)."""
91
+ import yaml as _yaml
92
+
93
+ from jcli.sdk.config import DEFAULT_CONFIG_FILE, ENV_PROFILE
94
+
95
+ try:
96
+ if not DEFAULT_CONFIG_FILE.exists():
97
+ return None
98
+ cfg = _yaml.safe_load(DEFAULT_CONFIG_FILE.read_text(encoding="utf-8"))
99
+ except Exception:
100
+ return None
101
+ if not isinstance(cfg, dict):
102
+ return None
103
+ name = profile_name or os.environ.get(ENV_PROFILE) or cfg.get("active_profile") or "default"
104
+ profiles = cfg.get("profiles")
105
+ if not isinstance(profiles, dict):
106
+ return None
107
+ profile = profiles.get(name)
108
+ if not isinstance(profile, dict):
109
+ return None
110
+ url = profile.get("url")
111
+ return url if isinstance(url, str) and url.strip() else None
112
+
113
+
114
+ def resolve_base_url(server_override: str | None, profile_name: str | None) -> str | None:
115
+ """Resolve the base_url handed to ``create_cli(base_url_override=...)``.
116
+
117
+ Precedence: ``-s/--server`` > ``JCLI_URL`` env > profile ``url`` from
118
+ ``~/.jcli/config.yaml``. ``None`` lets cliyard fall back to the spec's
119
+ ``server.base_url``.
120
+ """
121
+ if server_override:
122
+ return server_override
123
+ env_url = os.environ.get("JCLI_URL")
124
+ if env_url:
125
+ return env_url
126
+ return _read_profile_url(profile_name)
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # Global options & format injection
131
+ # ---------------------------------------------------------------------------
132
+
133
+
134
+ def add_global_options(cli: click.Group) -> None:
135
+ """Attach global options (``-f/--format``, ``-p/--profile``, ``-d/--debug``).
136
+
137
+ Values are stored in ``ctx.obj`` so wrapped subcommand callbacks can read
138
+ them. ``-s/--server`` is intentionally *not* added here — cliyard's
139
+ ``create_cli`` already registers it on the top-level group (with a root
140
+ callback that stores the override into ``ctx.obj["server"]``); that
141
+ behavior is preserved by the combined callback below.
142
+
143
+ Note: ``Group.callback`` is a plain attribute in Click 8.4 (the
144
+ ``@cli.callback`` decorator form is gone), so the callback and its options
145
+ are assigned explicitly.
146
+ """
147
+ cli.params.extend(
148
+ [
149
+ click.Option(
150
+ ["-f", "--format", "output_format"],
151
+ default="table",
152
+ type=click.Choice(GLOBAL_FORMATS),
153
+ help="Output format.",
154
+ show_default=True,
155
+ ),
156
+ click.Option(["-p", "--profile"], default=None, help="Configuration profile name."),
157
+ click.Option(["-d", "--debug"], is_flag=True, default=False, help="Enable debug output."),
158
+ ]
159
+ )
160
+
161
+ @click.pass_context
162
+ def _global_options(
163
+ ctx: click.Context,
164
+ output_format: str,
165
+ profile: str | None,
166
+ debug: bool,
167
+ **extra: Any,
168
+ ) -> None:
169
+ ctx.ensure_object(dict)
170
+
171
+ # Preserve cliyard's root-callback behavior: the native --server/-s
172
+ # option (registered by create_cli) is stored for subcommands.
173
+ server = extra.get("server")
174
+ if server:
175
+ ctx.obj["server"] = server
176
+
177
+ ctx.obj["format"] = output_format
178
+ ctx.obj["profile"] = profile
179
+ ctx.obj["debug"] = debug
180
+
181
+ if debug:
182
+ logging.basicConfig(
183
+ level=logging.DEBUG,
184
+ format="%(asctime)s %(name)s %(levelname)s %(message)s",
185
+ stream=sys.stderr,
186
+ force=True,
187
+ )
188
+ logger.debug("Debug logging enabled")
189
+ else:
190
+ logging.basicConfig(level=logging.WARNING, stream=sys.stderr, force=True)
191
+
192
+ cli.callback = _global_options
193
+
194
+
195
+ def _make_format_injector(callback: Callable[..., Any]) -> Callable[..., Any]:
196
+ """Wrap a subcommand callback so the global ``-f/--format`` is applied.
197
+
198
+ cliyard's ``_make_callback`` pops ``format`` from kwargs and falls back to
199
+ the method's own default — a default therefore shadows a global override.
200
+ We inspect the parameter source and only inject the global format when the
201
+ subcommand did not explicitly pass ``--format`` on the command line.
202
+
203
+ cliyard's callbacks also swallow every exception and print the message to
204
+ the console without re-raising, so connection failures / API errors would
205
+ otherwise exit 0. The wrapped callback captures stdout, recognises the
206
+ ``Error:``/``错误:`` markers and re-emits them on stderr with a non-zero
207
+ exit code.
208
+
209
+ Commands carrying a ``--follow`` flag (live log tailing) bypass the
210
+ stdout capture so the plugin can stream output in real time.
211
+
212
+ Note: ``ctx.exit(1)`` (not ``return 1``) is required — Click >= 8.4
213
+ ignores a plain integer return value from the top-level callback and
214
+ always exits with ``ctx.exit_code`` (default 0).
215
+ """
216
+ import io
217
+
218
+ def wrapped(**kwargs: Any) -> Any:
219
+ ctx = click.get_current_context()
220
+ root = ctx.find_root()
221
+ global_format = (root.obj or {}).get("format")
222
+ if global_format:
223
+ try:
224
+ source = ctx.get_parameter_source("format")
225
+ except Exception:
226
+ source = ParameterSource.DEFAULT
227
+ if source in (None, ParameterSource.DEFAULT, ParameterSource.DEFAULT_MAP):
228
+ kwargs["format"] = global_format
229
+
230
+ if kwargs.get("follow"):
231
+ return callback(**kwargs)
232
+
233
+ buffer = io.StringIO()
234
+ real_stdout = sys.stdout
235
+ sys.stdout = buffer
236
+ try:
237
+ result = callback(**kwargs)
238
+ finally:
239
+ sys.stdout = real_stdout
240
+
241
+ output = buffer.getvalue()
242
+ if output.startswith(("错误:", "Error:")):
243
+ click.echo(output, err=True, nl=False)
244
+ ctx.exit(1)
245
+ if output:
246
+ click.echo(output, nl=False)
247
+ return result
248
+
249
+ wrapped.__name__ = getattr(callback, "__name__", "callback")
250
+ wrapped.__doc__ = getattr(callback, "__doc__", None)
251
+ return wrapped
252
+
13
253
 
14
- @click.group()
15
- @click.version_option(version=__version__, prog_name="jcli")
16
- @click.option(
17
- "-f",
18
- "--format",
19
- "output_format",
20
- default="table",
21
- type=click.Choice(["table", "json", "yaml"]),
22
- help="Output format.",
23
- )
24
- @click.option("-p", "--profile", default=None, help="Configuration profile name.")
25
- @click.option("-s", "--server", default=None, help="Jenkins server URL.")
26
- @click.option("-d", "--debug", is_flag=True, default=False, help="Enable debug output.")
27
- @click.pass_context
28
- def cli(ctx, output_format, profile, server, debug):
29
- """Jenkins CLI - Manage Jenkins from the command line."""
30
- ctx.ensure_object(dict)
31
- ctx.obj["format"] = output_format
32
- ctx.obj["profile"] = profile
33
- ctx.obj["server"] = server
34
- ctx.obj["debug"] = debug
35
-
36
- # Configure debug logging when --debug / -d is set
37
- if debug:
38
- logging.basicConfig(
39
- level=logging.DEBUG,
40
- format="%(asctime)s %(name)s %(levelname)s %(message)s",
41
- stream=sys.stderr,
254
+ def wrap_subcommand_callbacks(command: click.Command) -> None:
255
+ """Recursively wrap leaf-command callbacks to inherit the global format."""
256
+ if isinstance(command, click.Group):
257
+ for sub in command.commands.values():
258
+ wrap_subcommand_callbacks(sub)
259
+ return
260
+ if any(getattr(p, "name", None) == "format" for p in command.params):
261
+ command.callback = _make_format_injector(command.callback)
262
+
263
+
264
+ def add_completion_command(cli: click.Group) -> None:
265
+ """Attach the ``completion show`` command (kept from jcli v1)."""
266
+
267
+ @click.group()
268
+ def completion() -> None:
269
+ """Shell completion support for jcli."""
270
+
271
+ @completion.command()
272
+ @click.argument("shell", type=click.Choice(["bash", "zsh", "fish"]))
273
+ def show(shell: str) -> None:
274
+ """Output shell completion script for the specified shell."""
275
+ from click.shell_completion import BashComplete, FishComplete, ZshComplete
276
+
277
+ shell_cls = {"bash": BashComplete, "zsh": ZshComplete, "fish": FishComplete}
278
+ complete = shell_cls[shell](cli, {}, "jcli", "_JCLI_COMPLETE")
279
+ click.echo(complete.source(), nl=False)
280
+
281
+ cli.add_command(completion)
282
+
283
+
284
+ def set_group_help(cli: click.Group) -> None:
285
+ """Propagate each command's ``short_help`` to its ``help`` text.
286
+
287
+ cliyard only sets ``short_help`` (from the YAML ``description``), so
288
+ ``jcli job --help`` / ``jcli plugin list --help`` would otherwise render
289
+ without the description line. jcli v1 printed it, so keep that
290
+ behaviour: the top-level help lists commands via ``short_help``
291
+ (unchanged), while per-command ``--help`` pages show the description.
292
+ """
293
+ stack = list(cli.commands.values())
294
+ while stack:
295
+ command = stack.pop()
296
+ if isinstance(command, click.Group):
297
+ stack.extend(command.commands.values())
298
+ if command.short_help and not command.help:
299
+ command.help = command.short_help
300
+
301
+
302
+ # ---------------------------------------------------------------------------
303
+ # CLI construction & entry point
304
+ # ---------------------------------------------------------------------------
305
+
306
+ # Commands belonging to jcli. cliyard auto-registers ``auth`` (built-in)
307
+ # and any global plugins found in ~/.cliyard/plugins/ (other projects), so
308
+ # only whitelisted top-level commands are kept on the jcli command surface.
309
+ _ALLOWED_TOP_LEVEL_COMMANDS = {
310
+ "job", "build", "node", "plugin", "credential", "pipeline", "view",
311
+ "system", "skills", "completion", "auth",
312
+ }
313
+
314
+
315
+ def _prune_non_jcli_commands(cli: click.Group) -> None:
316
+ """Drop top-level commands that are not part of jcli."""
317
+ for name in list(cli.commands.keys()):
318
+ if name not in _ALLOWED_TOP_LEVEL_COMMANDS:
319
+ cli.commands.pop(name)
320
+
321
+
322
+ def _mask_token(token: str) -> str:
323
+ """Mask an API token for display (``abcd****wxyz``)."""
324
+ if len(token) > 8:
325
+ return f"{token[:4]}****{token[-4:]}"
326
+ return "****" if token else "-"
327
+
328
+
329
+ def _add_jcli_auth_commands(cli: click.Group) -> None:
330
+ """Register the jcli-native ``auth`` command group.
331
+
332
+ Replaces cliyard's built-in ``auth``, which writes ``~/.cliyard/
333
+ credentials.yaml`` and is unrelated to jcli's actual authentication
334
+ source. This group reads/writes ``~/.jcli/config.yaml`` through the
335
+ same :class:`jcli.sdk.config.Config` class as ``jcli config``, so the
336
+ two command families share a single configuration source.
337
+ """
338
+ import copy
339
+
340
+ from jcli.sdk.config import (
341
+ DEFAULT_CONFIG_TEMPLATE,
342
+ DEFAULT_PROFILE_NAME,
343
+ ProfileNotFoundError,
344
+ Config,
345
+ )
346
+
347
+ @click.group()
348
+ def auth() -> None:
349
+ """Manage Jenkins authentication profiles."""
350
+
351
+ def _get_config() -> Config:
352
+ return Config().load()
353
+
354
+ @auth.command("add")
355
+ @click.option("-n", "--name", default=DEFAULT_PROFILE_NAME, help="Profile name.")
356
+ @click.option("-u", "--username", required=True, help="Jenkins username.")
357
+ @click.option(
358
+ "-p",
359
+ "--password",
360
+ "api_token",
361
+ required=True,
362
+ help="Jenkins API token (Jenkins Basic Auth = username:token).",
363
+ )
364
+ @click.option("-e", "--endpoint", "url", required=True, help="Jenkins server URL.")
365
+ @click.option("--default", "set_default", is_flag=True, help="Set as the active profile.")
366
+ def auth_add(name: str, username: str, api_token: str, url: str, set_default: bool) -> None:
367
+ """Add (or update) an authentication profile in ~/.jcli/config.yaml."""
368
+ cfg = _get_config()
369
+ has_custom = any(p != DEFAULT_PROFILE_NAME for p in cfg.list_profiles())
370
+ cfg.add_profile(name=name, url=url, username=username, api_token=api_token)
371
+ if set_default or not has_custom:
372
+ cfg.set_active_profile(name)
373
+ click.echo(f"Profile '{name}' added ({'active' if cfg.get_active_profile_name() == name else 'inactive'}).")
374
+
375
+ @auth.command("status")
376
+ @click.pass_context
377
+ def auth_status(ctx: click.Context) -> None:
378
+ """List configured authentication profiles (tokens masked)."""
379
+ from jcli.sdk.output.formatter import get_formatter
380
+
381
+ fmt = get_formatter(ctx.find_root())
382
+ cfg = _get_config()
383
+ profiles = cfg.list_profiles()
384
+ active_name = cfg.get_active_profile_name()
385
+
386
+ if not profiles:
387
+ fmt.print_info("No profiles configured. Run 'jcli auth add' to create one.")
388
+ return
389
+
390
+ headers = ["Name", "URL", "Username", "Token", "Active"]
391
+ rows = []
392
+ for name, data in profiles.items():
393
+ rows.append(
394
+ [
395
+ name,
396
+ data.get("url", ""),
397
+ data.get("username", ""),
398
+ _mask_token(data.get("api_token", "")),
399
+ "✓" if name == active_name else "",
400
+ ]
401
+ )
402
+ fmt.print_table(headers, rows, title="Profiles")
403
+
404
+ def _auth_use(profile: str) -> None:
405
+ cfg = _get_config()
406
+ try:
407
+ cfg.set_active_profile(profile)
408
+ except ProfileNotFoundError:
409
+ click.echo(f"Error: Profile '{profile}' not found", err=True)
410
+ available = ", ".join(cfg.list_profiles())
411
+ if available:
412
+ click.echo(f"Available profiles: {available}", err=True)
413
+ raise click.exceptions.Exit(1)
414
+ click.echo(f"Active profile set to '{profile}'")
415
+
416
+ @auth.command("use")
417
+ @click.argument("profile")
418
+ def auth_use(profile: str) -> None:
419
+ """Switch the active authentication profile."""
420
+ _auth_use(profile)
421
+
422
+ @auth.command("switch", hidden=True)
423
+ @click.argument("profile")
424
+ def auth_switch(profile: str) -> None:
425
+ """Alias of ``auth use``."""
426
+ _auth_use(profile)
427
+
428
+ @auth.command("rm")
429
+ @click.argument("name", required=False)
430
+ @click.option("--all", "clear_all", is_flag=True, help="Remove all profiles and reset to the default template.")
431
+ def auth_rm(name: str | None, clear_all: bool) -> None:
432
+ """Remove an authentication profile (or all with --all)."""
433
+ cfg = _get_config()
434
+ if name:
435
+ try:
436
+ cfg.remove_profile(name)
437
+ except ProfileNotFoundError:
438
+ click.echo(f"Error: Profile '{name}' not found", err=True)
439
+ raise click.exceptions.Exit(1)
440
+ click.echo(f"Profile '{name}' removed")
441
+ elif clear_all:
442
+ cfg._data = copy.deepcopy(DEFAULT_CONFIG_TEMPLATE)
443
+ cfg.save()
444
+ click.echo("All profiles removed, config reset to the default template.")
445
+ else:
446
+ active = cfg.get_active_profile_name()
447
+ click.echo(f"Active profile: {active} (use 'jcli auth rm NAME' or 'jcli auth rm --all')")
448
+
449
+ @auth.command("set")
450
+ @click.argument("name")
451
+ @click.argument("field", type=click.Choice(["url", "username", "api_token", "description"]))
452
+ @click.argument("value")
453
+ def auth_set(name: str, field: str, value: str) -> None:
454
+ """Set a configuration field (url/username/api_token/description) for a profile."""
455
+ cfg = _get_config()
456
+ try:
457
+ data = cfg.get_profile(name)
458
+ except ProfileNotFoundError:
459
+ click.echo(f"Error: Profile '{name}' not found", err=True)
460
+ raise click.exceptions.Exit(1)
461
+ data[field] = value
462
+ cfg.add_profile(
463
+ name=name,
464
+ url=data.get("url", ""),
465
+ username=data.get("username", ""),
466
+ api_token=data.get("api_token", ""),
467
+ description=data.get("description", ""),
42
468
  )
43
- logger.debug("Debug logging enabled")
44
- else:
45
- # Ensure WARNING+ level when not in debug mode
46
- logging.basicConfig(level=logging.WARNING, stream=sys.stderr)
469
+ click.echo(f"Updated profile '{name}': {field} = {value}")
47
470
 
471
+ @auth.command("show")
472
+ @click.argument("name", required=False)
473
+ def auth_show(name: str | None) -> None:
474
+ """Show profile details (default: active profile, token masked)."""
475
+ cfg = _get_config()
476
+ if name is None:
477
+ name = cfg.get_active_profile_name()
478
+ try:
479
+ data = cfg.get_profile(name)
480
+ except ProfileNotFoundError:
481
+ click.echo(f"Error: Profile '{name}' not found", err=True)
482
+ raise click.exceptions.Exit(1)
483
+ active_name = cfg.get_active_profile_name()
484
+ is_active = " (active)" if name == active_name else ""
485
+ click.echo(f"Profile: {name}{is_active}")
486
+ fields = [
487
+ ("url", "URL"),
488
+ ("username", "Username"),
489
+ ("api_token", "API Token"),
490
+ ("description", "Description"),
491
+ ]
492
+ for key, label in fields:
493
+ value = data.get(key, "")
494
+ display = _mask_token(value) if key == "api_token" else value
495
+ click.echo(f" {label}: {display}")
48
496
 
49
- # Register all plugin subcommands
50
- register_commands(cli)
497
+ cli.add_command(auth)
51
498
 
52
499
 
53
- @click.group()
54
- def completion():
55
- """Shell completion support for jcli."""
56
- pass
500
+ def _add_tail_short_options(cli: click.Group) -> None:
501
+ """Give ``build log`` tail-style short flags (``-f``/``-n``).
57
502
 
503
+ cliyard generates long-only options (``--follow``/``--lines``); add the
504
+ conventional ``tail`` short aliases on the ``build log`` command.
505
+ """
506
+ build = cli.commands.get("build")
507
+ if not isinstance(build, click.Group):
508
+ return
509
+ log = build.commands.get("log")
510
+ if not log:
511
+ return
512
+ for p in log.params:
513
+ if isinstance(p, click.Option):
514
+ if p.name == "follow" and "-f" not in p.opts:
515
+ p.opts = ("-f", "--follow")
516
+ elif p.name == "lines" and "-n" not in p.opts:
517
+ p.opts = ("-n", "--lines")
58
518
 
59
- @completion.command()
60
- @click.argument("shell", type=click.Choice(["bash", "zsh", "fish"]))
61
- def show(shell):
62
- """Output shell completion script for the specified shell."""
63
- from click.shell_completion import BashComplete, ZshComplete, FishComplete
64
519
 
65
- shell_cls = {"bash": BashComplete, "zsh": ZshComplete, "fish": FishComplete}
66
- complete = shell_cls[shell](cli, {}, "jcli", "_JCLI_COMPLETE")
67
- click.echo(complete.source(), nl=False)
520
+ def _build_cli(spec_dir: Path, server: str | None, profile: str | None) -> click.Group:
521
+ """Create the cliyard CLI and apply the jcli wrapper layer."""
522
+ base_url = resolve_base_url(server, profile)
523
+ cli = create_cli(str(spec_dir), version=__version__, base_url_override=base_url)
524
+ cli.commands.pop("auth", None)
525
+ _add_jcli_auth_commands(cli)
526
+ add_global_options(cli)
527
+ add_completion_command(cli)
528
+ wrap_subcommand_callbacks(cli)
529
+ set_group_help(cli)
530
+ _prune_non_jcli_commands(cli)
531
+ _add_tail_short_options(cli)
532
+ return cli
68
533
 
69
534
 
70
- cli.add_command(completion)
535
+ def create_jcli_cli(argv: list[str] | None = None) -> click.Group:
536
+ """Build the jcli Click CLI from the YAML specs (wrapper included).
537
+
538
+ With *argv* left ``None`` the real ``sys.argv`` is used and
539
+ ``-s/--server`` is stripped from it (cliyard's runner pre-extraction).
540
+ Tests pass an explicit argv to avoid touching ``sys.argv``.
541
+ """
542
+ if argv is None:
543
+ argv = sys.argv[1:]
544
+ cleaned, server = extract_server_override(argv)
545
+ if cleaned != argv:
546
+ sys.argv = [sys.argv[0]] + cleaned
547
+ profile = extract_profile_override(cleaned)
548
+ else:
549
+ cleaned, server = extract_server_override(list(argv))
550
+ profile = extract_profile_override(argv)
551
+ return _build_cli(_default_spec_dir(), server, profile)
71
552
 
72
553
 
73
- def main():
554
+ def main() -> None:
74
555
  """CLI entry point."""
75
- cli()
556
+ try:
557
+ cli = create_jcli_cli()
558
+ except Exception as exc:
559
+ click.echo(f"Error: {exc}", err=True)
560
+ sys.exit(1)
561
+
562
+ try:
563
+ code = cli(standalone_mode=False)
564
+ sys.exit(code if code is not None else 0)
565
+ except SystemExit as e:
566
+ sys.exit(int(e.code) if e.code is not None else 0)
567
+ except click.exceptions.ClickException as exc:
568
+ click.echo(exc.format_message(), err=True)
569
+ sys.exit(exc.exit_code)
570
+ except Exception as exc:
571
+ click.echo(f"Error: {exc}", err=True)
572
+ sys.exit(1)
573
+
574
+
575
+ class _LazyCLI:
576
+ """Lazy proxy so ``from jcli.cli import cli`` keeps working (tests/scripts).
577
+
578
+ Each access rebuilds the CLI from the current specs, so no stale state is
579
+ cached. The real entry point is :func:`main` (console script target).
580
+ """
581
+
582
+ def __call__(self, *args: Any, **kwargs: Any) -> Any:
583
+ return create_jcli_cli(sys.argv[1:])(*args, **kwargs)
584
+
585
+ def __getattr__(self, name: str) -> Any:
586
+ return getattr(create_jcli_cli(sys.argv[1:]), name)
587
+
588
+
589
+ cli = _LazyCLI()
76
590
 
77
591
 
78
592
  if __name__ == "__main__":