python-jcli 0.1.0__py3-none-any.whl → 1.0.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.
jcli/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.1.0"
1
+ __version__ = "1.0.0"
jcli/cli.py CHANGED
@@ -1,78 +1,572 @@
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")
13
40
 
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,
42
- )
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)
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.
52
+
53
+ Priority: ``$JCLI_SPEC_DIR`` > package-local ``specs/`` > repo-root
54
+ ``specs/`` (editable/dev layout).
55
+ """
56
+ env = os.environ.get(SPEC_DIR_ENV)
57
+ if env:
58
+ return Path(env)
59
+ pkg_specs = Path(__file__).resolve().parent / "specs"
60
+ if pkg_specs.is_dir():
61
+ return pkg_specs
62
+ repo_specs = Path(__file__).resolve().parent.parent / "specs"
63
+ if repo_specs.is_dir():
64
+ return repo_specs
65
+ return pkg_specs
66
+
67
+
68
+ def extract_profile_override(argv: list[str]) -> str | None:
69
+ """Read a ``--profile``/``-p`` value from *argv* without removing it.
70
+
71
+ The top-level ``-p/--profile`` Click option parses the flag normally; this
72
+ only previews the value so the base_url can be resolved before the CLI tree
73
+ is built. ``--profile X``, ``--profile=X``, ``-p X`` and ``-p=X`` forms
74
+ are supported.
75
+ """
76
+ profile: str | None = None
77
+ i = 0
78
+ while i < len(argv):
79
+ arg = argv[i]
80
+ if arg in ("--profile", "-p") and i + 1 < len(argv) and not argv[i + 1].startswith("-"):
81
+ profile = argv[i + 1]
82
+ i += 2
83
+ continue
84
+ if arg.startswith("--profile="):
85
+ profile = arg.split("=", 1)[1]
86
+ i += 1
87
+ continue
88
+ if arg.startswith("-p=") and len(arg) > 3:
89
+ profile = arg[3:]
90
+ i += 1
91
+ continue
92
+ i += 1
93
+ return profile
94
+
95
+
96
+ def _read_profile_url(profile_name: str | None) -> str | None:
97
+ """Read a profile URL from ``~/.jcli/config.yaml`` (read-only, no creation)."""
98
+ import yaml as _yaml
99
+
100
+ from jcli.sdk.config import DEFAULT_CONFIG_FILE, ENV_PROFILE
101
+
102
+ try:
103
+ if not DEFAULT_CONFIG_FILE.exists():
104
+ return None
105
+ cfg = _yaml.safe_load(DEFAULT_CONFIG_FILE.read_text(encoding="utf-8"))
106
+ except Exception:
107
+ return None
108
+ if not isinstance(cfg, dict):
109
+ return None
110
+ name = profile_name or os.environ.get(ENV_PROFILE) or cfg.get("active_profile") or "default"
111
+ profiles = cfg.get("profiles")
112
+ if not isinstance(profiles, dict):
113
+ return None
114
+ profile = profiles.get(name)
115
+ if not isinstance(profile, dict):
116
+ return None
117
+ url = profile.get("url")
118
+ return url if isinstance(url, str) and url.strip() else None
119
+
120
+
121
+ def resolve_base_url(server_override: str | None, profile_name: str | None) -> str | None:
122
+ """Resolve the base_url handed to ``create_cli(base_url_override=...)``.
123
+
124
+ Precedence: ``-s/--server`` > ``JCLI_URL`` env > profile ``url`` from
125
+ ``~/.jcli/config.yaml``. ``None`` lets cliyard fall back to the spec's
126
+ ``server.base_url``.
127
+ """
128
+ if server_override:
129
+ return server_override
130
+ env_url = os.environ.get("JCLI_URL")
131
+ if env_url:
132
+ return env_url
133
+ return _read_profile_url(profile_name)
134
+
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # Global options & format injection
138
+ # ---------------------------------------------------------------------------
139
+
140
+
141
+ def add_global_options(cli: click.Group) -> None:
142
+ """Attach global options (``-f/--format``, ``-p/--profile``, ``-d/--debug``).
143
+
144
+ Values are stored in ``ctx.obj`` so wrapped subcommand callbacks can read
145
+ them. ``-s/--server`` is intentionally *not* added here — cliyard's
146
+ ``create_cli`` already registers it on the top-level group (with a root
147
+ callback that stores the override into ``ctx.obj["server"]``); that
148
+ behavior is preserved by the combined callback below.
149
+
150
+ Note: ``Group.callback`` is a plain attribute in Click 8.4 (the
151
+ ``@cli.callback`` decorator form is gone), so the callback and its options
152
+ are assigned explicitly.
153
+ """
154
+ cli.params.extend(
155
+ [
156
+ click.Option(
157
+ ["-f", "--format", "output_format"],
158
+ default="table",
159
+ type=click.Choice(GLOBAL_FORMATS),
160
+ help="Output format.",
161
+ show_default=True,
162
+ ),
163
+ click.Option(["-p", "--profile"], default=None, help="Configuration profile name."),
164
+ click.Option(["-d", "--debug"], is_flag=True, default=False, help="Enable debug output."),
165
+ ]
166
+ )
167
+
168
+ @click.pass_context
169
+ def _global_options(
170
+ ctx: click.Context,
171
+ output_format: str,
172
+ profile: str | None,
173
+ debug: bool,
174
+ **extra: Any,
175
+ ) -> None:
176
+ ctx.ensure_object(dict)
177
+
178
+ # Preserve cliyard's root-callback behavior: the native --server/-s
179
+ # option (registered by create_cli) is stored for subcommands.
180
+ server = extra.get("server")
181
+ if server:
182
+ ctx.obj["server"] = server
183
+
184
+ ctx.obj["format"] = output_format
185
+ ctx.obj["profile"] = profile
186
+ ctx.obj["debug"] = debug
187
+
188
+ if debug:
189
+ logging.basicConfig(
190
+ level=logging.DEBUG,
191
+ format="%(asctime)s %(name)s %(levelname)s %(message)s",
192
+ stream=sys.stderr,
193
+ force=True,
194
+ )
195
+ logger.debug("Debug logging enabled")
196
+ else:
197
+ logging.basicConfig(level=logging.WARNING, stream=sys.stderr, force=True)
198
+
199
+ cli.callback = _global_options
200
+
201
+
202
+ def _make_format_injector(callback: Callable[..., Any]) -> Callable[..., Any]:
203
+ """Wrap a subcommand callback so the global ``-f/--format`` is applied.
204
+
205
+ cliyard's ``_make_callback`` pops ``format`` from kwargs and falls back to
206
+ the method's own default — a default therefore shadows a global override.
207
+ We inspect the parameter source and only inject the global format when the
208
+ subcommand did not explicitly pass ``--format`` on the command line.
209
+
210
+ cliyard's callbacks also swallow every exception and print the message to
211
+ the console without re-raising, so connection failures / API errors would
212
+ otherwise exit 0. The wrapped callback captures stdout, recognises the
213
+ ``Error:``/``错误:`` markers and re-emits them on stderr with a non-zero
214
+ exit code.
215
+
216
+ Note: ``ctx.exit(1)`` (not ``return 1``) is required — Click >= 8.4
217
+ ignores a plain integer return value from the top-level callback and
218
+ always exits with ``ctx.exit_code`` (default 0).
219
+ """
220
+ import io
221
+
222
+ def wrapped(**kwargs: Any) -> Any:
223
+ ctx = click.get_current_context()
224
+ root = ctx.find_root()
225
+ global_format = (root.obj or {}).get("format")
226
+ if global_format:
227
+ try:
228
+ source = ctx.get_parameter_source("format")
229
+ except Exception:
230
+ source = ParameterSource.DEFAULT
231
+ if source in (None, ParameterSource.DEFAULT, ParameterSource.DEFAULT_MAP):
232
+ kwargs["format"] = global_format
233
+
234
+ buffer = io.StringIO()
235
+ real_stdout = sys.stdout
236
+ sys.stdout = buffer
237
+ try:
238
+ result = callback(**kwargs)
239
+ finally:
240
+ sys.stdout = real_stdout
241
+
242
+ output = buffer.getvalue()
243
+ if output.startswith(("错误:", "Error:")):
244
+ click.echo(output, err=True, nl=False)
245
+ ctx.exit(1)
246
+ if output:
247
+ click.echo(output, nl=False)
248
+ return result
249
+
250
+ wrapped.__name__ = getattr(callback, "__name__", "callback")
251
+ wrapped.__doc__ = getattr(callback, "__doc__", None)
252
+ return wrapped
47
253
 
48
254
 
49
- # Register all plugin subcommands
50
- register_commands(cli)
255
+ def wrap_subcommand_callbacks(command: click.Command) -> None:
256
+ """Recursively wrap leaf-command callbacks to inherit the global format."""
257
+ if isinstance(command, click.Group):
258
+ for sub in command.commands.values():
259
+ wrap_subcommand_callbacks(sub)
260
+ return
261
+ if any(getattr(p, "name", None) == "format" for p in command.params):
262
+ command.callback = _make_format_injector(command.callback)
51
263
 
52
264
 
53
- @click.group()
54
- def completion():
55
- """Shell completion support for jcli."""
56
- pass
265
+ def add_completion_command(cli: click.Group) -> None:
266
+ """Attach the ``completion show`` command (kept from jcli v1)."""
57
267
 
268
+ @click.group()
269
+ def completion() -> None:
270
+ """Shell completion support for jcli."""
58
271
 
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
272
+ @completion.command()
273
+ @click.argument("shell", type=click.Choice(["bash", "zsh", "fish"]))
274
+ def show(shell: str) -> None:
275
+ """Output shell completion script for the specified shell."""
276
+ from click.shell_completion import BashComplete, FishComplete, ZshComplete
64
277
 
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)
278
+ shell_cls = {"bash": BashComplete, "zsh": ZshComplete, "fish": FishComplete}
279
+ complete = shell_cls[shell](cli, {}, "jcli", "_JCLI_COMPLETE")
280
+ click.echo(complete.source(), nl=False)
68
281
 
282
+ cli.add_command(completion)
69
283
 
70
- cli.add_command(completion)
71
284
 
285
+ def set_group_help(cli: click.Group) -> None:
286
+ """Propagate each command's ``short_help`` to its ``help`` text.
72
287
 
73
- def main():
288
+ cliyard only sets ``short_help`` (from the YAML ``description``), so
289
+ ``jcli job --help`` / ``jcli plugin list --help`` would otherwise render
290
+ without the description line. jcli v1 printed it, so keep that
291
+ behaviour: the top-level help lists commands via ``short_help``
292
+ (unchanged), while per-command ``--help`` pages show the description.
293
+ """
294
+ stack = list(cli.commands.values())
295
+ while stack:
296
+ command = stack.pop()
297
+ if isinstance(command, click.Group):
298
+ stack.extend(command.commands.values())
299
+ if command.short_help and not command.help:
300
+ command.help = command.short_help
301
+
302
+
303
+ # ---------------------------------------------------------------------------
304
+ # CLI construction & entry point
305
+ # ---------------------------------------------------------------------------
306
+
307
+ # Commands belonging to jcli. cliyard auto-registers ``auth`` (built-in)
308
+ # and any global plugins found in ~/.cliyard/plugins/ (other projects), so
309
+ # only whitelisted top-level commands are kept on the jcli command surface.
310
+ _ALLOWED_TOP_LEVEL_COMMANDS = {
311
+ "job", "build", "node", "plugin", "credential", "pipeline", "view",
312
+ "system", "skills", "completion", "auth",
313
+ }
314
+
315
+
316
+ def _prune_non_jcli_commands(cli: click.Group) -> None:
317
+ """Drop top-level commands that are not part of jcli."""
318
+ for name in list(cli.commands.keys()):
319
+ if name not in _ALLOWED_TOP_LEVEL_COMMANDS:
320
+ cli.commands.pop(name)
321
+
322
+
323
+ def _mask_token(token: str) -> str:
324
+ """Mask an API token for display (``abcd****wxyz``)."""
325
+ if len(token) > 8:
326
+ return f"{token[:4]}****{token[-4:]}"
327
+ return "****" if token else "-"
328
+
329
+
330
+ def _add_jcli_auth_commands(cli: click.Group) -> None:
331
+ """Register the jcli-native ``auth`` command group.
332
+
333
+ Replaces cliyard's built-in ``auth``, which writes ``~/.cliyard/
334
+ credentials.yaml`` and is unrelated to jcli's actual authentication
335
+ source. This group reads/writes ``~/.jcli/config.yaml`` through the
336
+ same :class:`jcli.sdk.config.Config` class as ``jcli config``, so the
337
+ two command families share a single configuration source.
338
+ """
339
+ import copy
340
+
341
+ from jcli.sdk.config import (
342
+ DEFAULT_CONFIG_TEMPLATE,
343
+ DEFAULT_PROFILE_NAME,
344
+ ProfileNotFoundError,
345
+ Config,
346
+ )
347
+
348
+ @click.group()
349
+ def auth() -> None:
350
+ """Manage Jenkins authentication profiles."""
351
+
352
+ def _get_config() -> Config:
353
+ return Config().load()
354
+
355
+ @auth.command("add")
356
+ @click.option("-n", "--name", default=DEFAULT_PROFILE_NAME, help="Profile name.")
357
+ @click.option("-u", "--username", required=True, help="Jenkins username.")
358
+ @click.option(
359
+ "-p",
360
+ "--password",
361
+ "api_token",
362
+ required=True,
363
+ help="Jenkins API token (Jenkins Basic Auth = username:token).",
364
+ )
365
+ @click.option("-e", "--endpoint", "url", required=True, help="Jenkins server URL.")
366
+ @click.option("--default", "set_default", is_flag=True, help="Set as the active profile.")
367
+ def auth_add(name: str, username: str, api_token: str, url: str, set_default: bool) -> None:
368
+ """Add (or update) an authentication profile in ~/.jcli/config.yaml."""
369
+ cfg = _get_config()
370
+ has_custom = any(p != DEFAULT_PROFILE_NAME for p in cfg.list_profiles())
371
+ cfg.add_profile(name=name, url=url, username=username, api_token=api_token)
372
+ if set_default or not has_custom:
373
+ cfg.set_active_profile(name)
374
+ click.echo(f"Profile '{name}' added ({'active' if cfg.get_active_profile_name() == name else 'inactive'}).")
375
+
376
+ @auth.command("status")
377
+ @click.pass_context
378
+ def auth_status(ctx: click.Context) -> None:
379
+ """List configured authentication profiles (tokens masked)."""
380
+ from jcli.sdk.output.formatter import get_formatter
381
+
382
+ fmt = get_formatter(ctx.find_root())
383
+ cfg = _get_config()
384
+ profiles = cfg.list_profiles()
385
+ active_name = cfg.get_active_profile_name()
386
+
387
+ if not profiles:
388
+ fmt.print_info("No profiles configured. Run 'jcli auth add' to create one.")
389
+ return
390
+
391
+ headers = ["Name", "URL", "Username", "Token", "Active"]
392
+ rows = []
393
+ for name, data in profiles.items():
394
+ rows.append(
395
+ [
396
+ name,
397
+ data.get("url", ""),
398
+ data.get("username", ""),
399
+ _mask_token(data.get("api_token", "")),
400
+ "✓" if name == active_name else "",
401
+ ]
402
+ )
403
+ fmt.print_table(headers, rows, title="Profiles")
404
+
405
+ def _auth_use(profile: str) -> None:
406
+ cfg = _get_config()
407
+ try:
408
+ cfg.set_active_profile(profile)
409
+ except ProfileNotFoundError:
410
+ click.echo(f"Error: Profile '{profile}' not found", err=True)
411
+ available = ", ".join(cfg.list_profiles())
412
+ if available:
413
+ click.echo(f"Available profiles: {available}", err=True)
414
+ raise click.exceptions.Exit(1)
415
+ click.echo(f"Active profile set to '{profile}'")
416
+
417
+ @auth.command("use")
418
+ @click.argument("profile")
419
+ def auth_use(profile: str) -> None:
420
+ """Switch the active authentication profile."""
421
+ _auth_use(profile)
422
+
423
+ @auth.command("switch", hidden=True)
424
+ @click.argument("profile")
425
+ def auth_switch(profile: str) -> None:
426
+ """Alias of ``auth use``."""
427
+ _auth_use(profile)
428
+
429
+ @auth.command("rm")
430
+ @click.argument("name", required=False)
431
+ @click.option("--all", "clear_all", is_flag=True, help="Remove all profiles and reset to the default template.")
432
+ def auth_rm(name: str | None, clear_all: bool) -> None:
433
+ """Remove an authentication profile (or all with --all)."""
434
+ cfg = _get_config()
435
+ if name:
436
+ try:
437
+ cfg.remove_profile(name)
438
+ except ProfileNotFoundError:
439
+ click.echo(f"Error: Profile '{name}' not found", err=True)
440
+ raise click.exceptions.Exit(1)
441
+ click.echo(f"Profile '{name}' removed")
442
+ elif clear_all:
443
+ cfg._data = copy.deepcopy(DEFAULT_CONFIG_TEMPLATE)
444
+ cfg.save()
445
+ click.echo("All profiles removed, config reset to the default template.")
446
+ else:
447
+ active = cfg.get_active_profile_name()
448
+ click.echo(f"Active profile: {active} (use 'jcli auth rm NAME' or 'jcli auth rm --all')")
449
+
450
+ @auth.command("set")
451
+ @click.argument("name")
452
+ @click.argument("field", type=click.Choice(["url", "username", "api_token", "description"]))
453
+ @click.argument("value")
454
+ def auth_set(name: str, field: str, value: str) -> None:
455
+ """Set a configuration field (url/username/api_token/description) for a profile."""
456
+ cfg = _get_config()
457
+ try:
458
+ data = cfg.get_profile(name)
459
+ except ProfileNotFoundError:
460
+ click.echo(f"Error: Profile '{name}' not found", err=True)
461
+ raise click.exceptions.Exit(1)
462
+ data[field] = value
463
+ cfg.add_profile(
464
+ name=name,
465
+ url=data.get("url", ""),
466
+ username=data.get("username", ""),
467
+ api_token=data.get("api_token", ""),
468
+ description=data.get("description", ""),
469
+ )
470
+ click.echo(f"Updated profile '{name}': {field} = {value}")
471
+
472
+ @auth.command("show")
473
+ @click.argument("name", required=False)
474
+ def auth_show(name: str | None) -> None:
475
+ """Show profile details (default: active profile, token masked)."""
476
+ cfg = _get_config()
477
+ if name is None:
478
+ name = cfg.get_active_profile_name()
479
+ try:
480
+ data = cfg.get_profile(name)
481
+ except ProfileNotFoundError:
482
+ click.echo(f"Error: Profile '{name}' not found", err=True)
483
+ raise click.exceptions.Exit(1)
484
+ active_name = cfg.get_active_profile_name()
485
+ is_active = " (active)" if name == active_name else ""
486
+ click.echo(f"Profile: {name}{is_active}")
487
+ fields = [
488
+ ("url", "URL"),
489
+ ("username", "Username"),
490
+ ("api_token", "API Token"),
491
+ ("description", "Description"),
492
+ ]
493
+ for key, label in fields:
494
+ value = data.get(key, "")
495
+ display = _mask_token(value) if key == "api_token" else value
496
+ click.echo(f" {label}: {display}")
497
+
498
+ cli.add_command(auth)
499
+
500
+
501
+ def _build_cli(spec_dir: Path, server: str | None, profile: str | None) -> click.Group:
502
+ """Create the cliyard CLI and apply the jcli wrapper layer."""
503
+ base_url = resolve_base_url(server, profile)
504
+ cli = create_cli(str(spec_dir), version=__version__, base_url_override=base_url)
505
+ cli.commands.pop("auth", None)
506
+ _add_jcli_auth_commands(cli)
507
+ add_global_options(cli)
508
+ add_completion_command(cli)
509
+ wrap_subcommand_callbacks(cli)
510
+ set_group_help(cli)
511
+ _prune_non_jcli_commands(cli)
512
+ return cli
513
+
514
+
515
+ def create_jcli_cli(argv: list[str] | None = None) -> click.Group:
516
+ """Build the jcli Click CLI from the YAML specs (wrapper included).
517
+
518
+ With *argv* left ``None`` the real ``sys.argv`` is used and
519
+ ``-s/--server`` is stripped from it (cliyard's runner pre-extraction).
520
+ Tests pass an explicit argv to avoid touching ``sys.argv``.
521
+ """
522
+ if argv is None:
523
+ argv = sys.argv[1:]
524
+ cleaned, server = extract_server_override(argv)
525
+ if cleaned != argv:
526
+ sys.argv = [sys.argv[0]] + cleaned
527
+ profile = extract_profile_override(cleaned)
528
+ else:
529
+ cleaned, server = extract_server_override(list(argv))
530
+ profile = extract_profile_override(argv)
531
+ return _build_cli(_default_spec_dir(), server, profile)
532
+
533
+
534
+ def main() -> None:
74
535
  """CLI entry point."""
75
- cli()
536
+ try:
537
+ cli = create_jcli_cli()
538
+ except Exception as exc:
539
+ click.echo(f"Error: {exc}", err=True)
540
+ sys.exit(1)
541
+
542
+ try:
543
+ code = cli(standalone_mode=False)
544
+ sys.exit(code if code is not None else 0)
545
+ except SystemExit as e:
546
+ sys.exit(int(e.code) if e.code is not None else 0)
547
+ except click.exceptions.ClickException as exc:
548
+ click.echo(exc.format_message(), err=True)
549
+ sys.exit(exc.exit_code)
550
+ except Exception as exc:
551
+ click.echo(f"Error: {exc}", err=True)
552
+ sys.exit(1)
553
+
554
+
555
+ class _LazyCLI:
556
+ """Lazy proxy so ``from jcli.cli import cli`` keeps working (tests/scripts).
557
+
558
+ Each access rebuilds the CLI from the current specs, so no stale state is
559
+ cached. The real entry point is :func:`main` (console script target).
560
+ """
561
+
562
+ def __call__(self, *args: Any, **kwargs: Any) -> Any:
563
+ return create_jcli_cli(sys.argv[1:])(*args, **kwargs)
564
+
565
+ def __getattr__(self, name: str) -> Any:
566
+ return getattr(create_jcli_cli(sys.argv[1:]), name)
567
+
568
+
569
+ cli = _LazyCLI()
76
570
 
77
571
 
78
572
  if __name__ == "__main__":
jcli/plugins/skills.py CHANGED
@@ -59,9 +59,12 @@ def parse_skill_metadata(skill_dir: Path) -> dict[str, Any]:
59
59
  frontmatter = match.group(1)
60
60
 
61
61
  # Simple YAML parsing without pyyaml dependency
62
- for line in frontmatter.split("\n"):
63
- line = line.strip()
62
+ lines = frontmatter.split("\n")
63
+ i = 0
64
+ while i < len(lines):
65
+ line = lines[i].strip()
64
66
  if not line or line.startswith("#"):
67
+ i += 1
65
68
  continue
66
69
 
67
70
  if line.startswith("name:"):
@@ -70,14 +73,45 @@ def parse_skill_metadata(skill_dir: Path) -> dict[str, Any]:
70
73
  metadata["version"] = line.split(":", 1)[1].strip().strip("'\"")
71
74
  elif line.startswith("description:"):
72
75
  desc = line.split(":", 1)[1].strip().strip("'\"")
76
+ if desc in ("|", ">", ""):
77
+ # YAML block scalar: collect following indented lines
78
+ parts = []
79
+ j = i + 1
80
+ while j < len(lines) and (
81
+ lines[j].startswith(" ") or lines[j].startswith("\t")
82
+ ):
83
+ parts.append(lines[j].strip())
84
+ j += 1
85
+ if parts:
86
+ desc = " ".join(parts)
87
+ i = j - 1 # continue after the block scalar
73
88
  metadata["description"] = desc
74
89
  elif line.startswith("- ") and "allowed-tools" in frontmatter:
75
90
  tool = line[2:].strip().strip("'\"")
76
91
  metadata["allowed_tools"].append(tool)
92
+ i += 1
77
93
 
78
94
  return metadata
79
95
 
80
96
 
97
+ def _is_jcli_skill(metadata: dict[str, Any]) -> bool:
98
+ """Return True if the skill belongs to jcli.
99
+
100
+ A skill is considered jcli's own when:
101
+ - it is a symlink into the bundled skills directory
102
+ (``source == "bundled (symlink)"``), or
103
+ - its name starts with the ``jcli-`` prefix, or
104
+ - its path contains a ``jcli`` path segment (e.g. inside BUNDLED_SKILLS_DIR).
105
+ """
106
+ if metadata.get("source") == "bundled (symlink)":
107
+ return True
108
+ if str(metadata.get("name", "")).startswith("jcli-"):
109
+ return True
110
+ if "jcli" in Path(str(metadata.get("path", ""))).parts:
111
+ return True
112
+ return False
113
+
114
+
81
115
  def get_bundled_skills() -> list[dict[str, Any]]:
82
116
  """Get all bundled skills from the skills directory."""
83
117
  skills = []
@@ -149,8 +183,8 @@ def install_skill(
149
183
 
150
184
  skill_dest = target_dir / name
151
185
 
152
- # Check if already installed
153
- if skill_dest.exists():
186
+ # Path.exists() is False for a broken symlink; is_symlink() catches it
187
+ if skill_dest.exists() or skill_dest.is_symlink():
154
188
  if force:
155
189
  # Remove existing
156
190
  if skill_dest.is_symlink():
@@ -182,7 +216,8 @@ def uninstall_skill(name: str, install_dir: Path | None = None) -> bool:
182
216
  target_dir = install_dir or DEFAULT_INSTALL_DIR
183
217
  skill_path = target_dir / name
184
218
 
185
- if not skill_path.exists():
219
+ # Path.exists() is False for a broken symlink; is_symlink() catches it
220
+ if not skill_path.exists() and not skill_path.is_symlink():
186
221
  raise click.ClickException(f"Skill '{name}' not found in {target_dir}.")
187
222
 
188
223
  if skill_path.is_symlink():
@@ -223,19 +258,27 @@ def list_cmd(ctx: click.Context, installed: bool, bundled: bool, install_dir: st
223
258
 
224
259
  skills_to_show = []
225
260
 
226
- if not installed:
227
- # Show bundled skills
228
- bundled_skills = get_bundled_skills()
229
- skills_to_show.extend(bundled_skills)
230
-
231
- if not bundled:
232
- # Show installed skills
233
- installed_skills = get_installed_skills(target_dir)
234
- # Avoid duplicates if both bundled and installed
235
- existing_names = {s["name"] for s in skills_to_show}
236
- for skill in installed_skills:
237
- if skill["name"] not in existing_names:
261
+ if installed and bundled:
262
+ # Both flags explicitly given: bundled + jcli installed (dedup by name)
263
+ seen: set[str] = set()
264
+ for skill in get_bundled_skills():
265
+ skills_to_show.append(skill)
266
+ seen.add(skill["name"])
267
+ for skill in get_installed_skills(target_dir):
268
+ if _is_jcli_skill(skill) and skill["name"] not in seen:
238
269
  skills_to_show.append(skill)
270
+ seen.add(skill["name"])
271
+ elif installed:
272
+ # Only jcli installed skills
273
+ skills_to_show.extend(
274
+ s for s in get_installed_skills(target_dir) if _is_jcli_skill(s)
275
+ )
276
+ elif bundled:
277
+ # Only bundled skills
278
+ skills_to_show.extend(get_bundled_skills())
279
+ else:
280
+ # Default: only bundled (jcli's own skills)
281
+ skills_to_show.extend(get_bundled_skills())
239
282
 
240
283
  if not skills_to_show:
241
284
  fmt.print_info("No skills found.")
jcli/skills/jcli/SKILL.md CHANGED
@@ -30,7 +30,7 @@ jcli -f yaml job list # YAML 输出
30
30
 
31
31
  | 技能 | 说明 |
32
32
  |------|------|
33
- | [jcli-config](config/SKILL.md) | 配置管理 - Jenkins 连接配置、多实例管理 |
33
+ | [jcli-auth](auth/SKILL.md) | 认证配置管理 - Jenkins 连接 profile 添加、切换、查看、删除 |
34
34
  | [jcli-job](job/SKILL.md) | Job 管理 - Job 创建、删除、复制、启用/禁用 |
35
35
  | [jcli-build](build/SKILL.md) | 构建管理 - 构建触发、查看、停止、队列 |
36
36
  | [jcli-node](node/SKILL.md) | 节点管理 - Jenkins Agent 节点管理 |
@@ -40,6 +40,19 @@ jcli -f yaml job list # YAML 输出
40
40
  | [jcli-view](view/SKILL.md) | 视图管理 - 视图创建、删除、查看 |
41
41
  | [jcli-system](system/SKILL.md) | 系统管理 - 系统信息、重启、安静模式 |
42
42
 
43
+ ## 认证配置(auth)
44
+
45
+ ```bash
46
+ jcli auth add -n <name> -u <user> -p <token> -e <url> # 添加配置(token 即 Jenkins API Token)
47
+ jcli auth status # 查看所有配置(token 掩码)
48
+ jcli auth use <name> # 切换 active profile
49
+ jcli auth set <name> <field> <value> # 修改字段(url/username/api_token/description)
50
+ jcli auth show [name] # 查看单个配置详情
51
+ jcli auth rm <name> # 删除配置
52
+ ```
53
+
54
+ 详细用法见 [jcli-auth](auth/SKILL.md)。
55
+
43
56
  ## Skills 命令
44
57
 
45
58
  ```bash
@@ -70,4 +83,4 @@ jcli completion show fish # Fish 补全脚本
70
83
 
71
84
  - 源码: `/data/git-project/jcli`
72
85
  - 配置: `~/.jcli/config.yaml`
73
- - 技能文档: `/data/git-project/jcli/skills/jcli/`
86
+ - 技能文档: `/data/git-project/jcli/jcli/skills/jcli/`
@@ -0,0 +1,86 @@
1
+ ---
2
+ name: jcli-auth
3
+ version: 1.0.0
4
+ description: |
5
+ jcli 认证配置管理 - 管理 Jenkins 连接 profile 的添加、切换、查看、删除等操作。
6
+ allowed-tools:
7
+ - Bash
8
+ - Read
9
+ ---
10
+
11
+ # jcli-auth
12
+
13
+ 管理 jcli 的认证配置 profile,支持多 Jenkins 实例。
14
+
15
+ ## 前提条件
16
+
17
+ - Python >= 3.10
18
+ - Jenkins 2.x 或更高版本
19
+ - 已安装 jcli:`pip install -e /data/git-project/jcli`
20
+
21
+ ## 配置文件位置
22
+
23
+ `~/.jcli/config.yaml`
24
+
25
+ ## 配置结构
26
+
27
+ ```yaml
28
+ active_profile: default
29
+ profiles:
30
+ default:
31
+ url: https://jenkins.example.com
32
+ username: admin
33
+ api_token: your-api-token-here
34
+ description: Default Jenkins instance
35
+ ```
36
+
37
+ ## 命令参考
38
+
39
+ ```bash
40
+ jcli auth add -n <name> -u <user> -p <token> -e <url> # 添加(或更新)配置
41
+ jcli auth add -n <name> ... --default # 添加并设为 active profile
42
+ jcli auth status # 列出所有配置(token 掩码)
43
+ jcli auth use <name> # 切换 active profile
44
+ jcli auth set <name> <field> <value> # 修改字段(url/username/api_token/description)
45
+ jcli auth show [name] # 查看单个配置详情(默认 active)
46
+ jcli auth rm <name> # 删除配置
47
+ jcli auth rm --all # 删除所有配置(重置为模板)
48
+ ```
49
+
50
+ ## 常见用例
51
+
52
+ ### 添加新 profile
53
+
54
+ ```bash
55
+ jcli auth add -n dev -u admin -p your-api-token -e https://jenkins-dev.example.com
56
+ ```
57
+
58
+ ### 修改配置
59
+
60
+ ```bash
61
+ jcli auth set dev api_token your-token
62
+ jcli auth set dev description "Development Jenkins"
63
+ ```
64
+
65
+ ### 切换 profile
66
+
67
+ ```bash
68
+ jcli auth use dev
69
+ ```
70
+
71
+ ### 查看配置
72
+
73
+ ```bash
74
+ jcli auth status
75
+ jcli auth show
76
+ jcli auth show dev
77
+ ```
78
+
79
+ ## 环境变量覆盖
80
+
81
+ | 变量 | 覆盖字段 |
82
+ |------|---------|
83
+ | `JCLI_URL` | url |
84
+ | `JCLI_USERNAME` | username |
85
+ | `JCLI_API_TOKEN` | api_token |
86
+ | `JCLI_PROFILE` | active_profile |
@@ -21,10 +21,12 @@ allowed-tools:
21
21
  ## 命令参考
22
22
 
23
23
  ```bash
24
- jcli credential list # 列出凭据
25
- jcli credential get <id> # 查看凭据详情
26
- jcli credential create <id> -f config.xml # 从 XML 创建凭据
27
- jcli credential delete <id> # 删除凭据
24
+ jcli credential list [store] [domain] # 列出凭据(默认 store=system, domain=_)
25
+ jcli credential list --depth 2 # 递归列出凭据
26
+ jcli credential get <id> [store] # 查看凭据详情
27
+ jcli credential create <config.xml> [store] [domain] # 从 XML 文件创建凭据
28
+ jcli credential update <config.xml> <id> [store] # 更新凭据
29
+ jcli credential delete <id> [store] [domain] # 删除凭据
28
30
  ```
29
31
 
30
32
  ## 常见用例
@@ -43,5 +45,12 @@ jcli credential get my-credential-id
43
45
 
44
46
  ```bash
45
47
  # 从 XML 配置文件创建凭据
46
- jcli credential create my-credential-id -f credential-config.xml
48
+ jcli credential create credential-config.xml
49
+ ```
50
+
51
+ ### 更新凭据
52
+
53
+ ```bash
54
+ # 从 XML 文件更新已有凭据
55
+ jcli credential update credential-config.xml my-credential-id
47
56
  ```
@@ -23,13 +23,16 @@ allowed-tools:
23
23
  ```bash
24
24
  jcli job list # 列出所有 Job
25
25
  jcli job get <name> # 查看 Job 详情
26
- jcli job create <name> -f config.xml # 从 XML 创建 Job
27
- jcli job delete <name> # 删除 Job(需确认)
28
- jcli job delete <name> --yes # 删除 Job(跳过确认)
26
+ jcli job create <name> --config-file config.xml # 从 XML 创建 Job
27
+ jcli job delete <name> # 删除 Job
29
28
  jcli job copy <from> <to> # 复制 Job
29
+ jcli job rename <old> <new> # 重命名 Job
30
30
  jcli job enable <name> # 启用 Job
31
31
  jcli job disable <name> # 禁用 Job
32
32
  jcli job config <name> # 查看 Job XML 配置
33
+ jcli job update <config.xml> <name> # 用 XML 更新 Job 配置
34
+ jcli job create-folder <name> # 创建文件夹
35
+ jcli job delete-folder <name> # 删除文件夹(含内容)
33
36
  ```
34
37
 
35
38
  ## 常见用例
@@ -51,5 +54,18 @@ done
51
54
  ### 从 XML 创建 Job
52
55
 
53
56
  ```bash
54
- jcli job create my-new-job -f job-config.xml
57
+ jcli job create my-new-job --config-file job-config.xml
58
+ ```
59
+
60
+ ### 参数化创建 Job
61
+
62
+ ```bash
63
+ # 从 git 仓库创建 freestyle 构建
64
+ jcli job create my-freestyle --job-type freestyle --git-url git@github.com:org/repo.git --git-branch main
65
+
66
+ # 创建 Pipeline 构建(使用仓库根目录的 Jenkinsfile)
67
+ jcli job create my-pipeline --job-type pipeline --git-url git@github.com:org/repo.git --script "pipeline { agent any; stages { stage('Build') { steps { echo 'hi' } } } }"
68
+
69
+ # 定时构建
70
+ jcli job create my-scheduled --job-type freestyle --git-url git@github.com:org/repo.git --cron "H/15 * * * *"
55
71
  ```
@@ -23,8 +23,10 @@ allowed-tools:
23
23
  ```bash
24
24
  jcli node list # 列出所有节点
25
25
  jcli node get <name> # 查看节点详情
26
+ jcli node create <name> # 创建节点(JNLP 启动器)
27
+ jcli node create <name> --num-executors 2 --remote-fs /data/jenkins --labels linux # 指定参数创建
26
28
  jcli node delete <name> # 删除节点
27
- jcli node toggle <name> --message "维护" # 节点离线
29
+ jcli node toggle <name> --offline-message "维护" # 节点离线(带原因)
28
30
  jcli node toggle <name> # 节点上线
29
31
  ```
30
32
 
@@ -34,7 +36,7 @@ jcli node toggle <name> # 节点上线
34
36
 
35
37
  ```bash
36
38
  # 节点离线(带维护原因)
37
- jcli node toggle agent-01 --message "系统升级维护"
39
+ jcli node toggle agent-01 --offline-message "系统升级维护"
38
40
 
39
41
  # 节点恢复上线
40
42
  jcli node toggle agent-01
@@ -23,7 +23,7 @@ allowed-tools:
23
23
  ```bash
24
24
  jcli pipeline stages <job> <build> # 查看阶段信息
25
25
  jcli pipeline log <job> <build> <node-id> # 查看步骤日志
26
- jcli pipeline validate -f Jenkinsfile # 验证 Jenkinsfile
26
+ jcli pipeline validate <Jenkinsfile> # 验证 Jenkinsfile(位置参数,文件路径)
27
27
  jcli pipeline pending <job> <build> # 查看待处理输入
28
28
  ```
29
29
 
@@ -46,8 +46,8 @@ jcli pipeline log my-job 42 15
46
46
  ### 验证 Jenkinsfile
47
47
 
48
48
  ```bash
49
- # 验证 Jenkinsfile 语法
50
- jcli pipeline validate -f Jenkinsfile
49
+ # 验证 Jenkinsfile 语法(传入文件路径)
50
+ jcli pipeline validate Jenkinsfile
51
51
  ```
52
52
 
53
53
  ### 查看待处理输入
@@ -25,7 +25,10 @@ jcli plugin list # 列出已安装插件
25
25
  jcli plugin get <name> # 查看插件详情
26
26
  jcli plugin install <name> # 安装插件
27
27
  jcli plugin install git@4.15.0 # 安装指定版本
28
+ jcli plugin install <name1> <name2> # 一次安装多个插件
28
29
  jcli plugin uninstall <name> # 卸载插件
30
+ jcli plugin restart <name> # 重启插件
31
+ jcli plugin check-updates # 检查插件更新
29
32
  ```
30
33
 
31
34
  ## 常见用例
@@ -27,7 +27,10 @@ jcli system restart # 安全重启 Jenkins
27
27
  jcli system quiet-down # 进入安静模式
28
28
  jcli system quiet-down --reason "维护" # 带原因的安静模式
29
29
  jcli system cancel-quiet-down # 取消安静模式
30
- jcli system script "println('hello')" # 执行 Groovy 脚本
30
+ jcli system script <script> # 执行 Groovy 脚本(位置参数)
31
+ jcli system users # 列出所有 Jenkins 用户
32
+ jcli system token <username> # 为用户生成 API token
33
+ jcli system token <username> --token-name <name> # 指定 token 名称
31
34
  ```
32
35
 
33
36
  ## 常见用例
@@ -62,9 +65,19 @@ jcli system restart
62
65
  ### 执行 Groovy 脚本
63
66
 
64
67
  ```bash
65
- # 执行简单的 Groovy 脚本
68
+ # 执行简单的 Groovy 脚本(SCRIPT 为位置参数)
66
69
  jcli system script "println('Hello from jcli')"
67
70
 
68
71
  # 获取所有节点信息
69
72
  jcli system script "Jenkins.instance.computers.each { println it.name }"
70
73
  ```
74
+
75
+ ### 用户与 Token
76
+
77
+ ```bash
78
+ # 列出所有用户
79
+ jcli system users
80
+
81
+ # 为指定用户生成 API token
82
+ jcli system token admin
83
+ ```
@@ -23,7 +23,8 @@ allowed-tools:
23
23
  ```bash
24
24
  jcli view list # 列出所有视图
25
25
  jcli view get <name> # 查看视图详情
26
- jcli view create <name> -f config.xml # 从 XML 创建视图
26
+ jcli view create <config.xml> <name> # 从 XML 文件创建视图(XML 在前,名称在后)
27
+ jcli view update <config.xml> <name> # 用 XML 更新视图配置
27
28
  jcli view delete <name> # 删除视图
28
29
  ```
29
30
 
@@ -42,6 +43,6 @@ jcli view get my-view
42
43
  ### 创建视图
43
44
 
44
45
  ```bash
45
- # 从 XML 配置文件创建视图
46
- jcli view create my-view -f view-config.xml
46
+ # 从 XML 配置文件创建视图(XML 文件路径在前,视图名在后)
47
+ jcli view create view-config.xml my-view
47
48
  ```
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-jcli
3
- Version: 0.1.0
3
+ Version: 1.0.0
4
4
  Summary: Python Jenkins CLI tool
5
5
  License: MIT
6
6
  Requires-Python: >=3.10
@@ -9,6 +9,7 @@ Requires-Dist: click>=8.1.0
9
9
  Requires-Dist: requests~=2.31.0
10
10
  Requires-Dist: pyyaml~=6.0.1
11
11
  Requires-Dist: rich>=13.7.1
12
+ Requires-Dist: cliyard>=0.6.0
12
13
  Provides-Extra: dev
13
14
  Requires-Dist: pytest>=7.0; extra == "dev"
14
15
  Requires-Dist: pytest-cov>=4.0; extra == "dev"
@@ -23,10 +24,37 @@ A command-line tool for managing Jenkins servers. jcli wraps the Jenkins REST AP
23
24
  ## Features
24
25
 
25
26
  - **8 command modules** covering jobs, builds, nodes, plugins, credentials, pipelines, views, and system management
27
+ - **Spec-driven CLI**: commands are declared as cliyard YAML specs in `specs/` and generated at runtime, no hand-written Click groups
26
28
  - **Multiple output formats**: human-readable tables (default, via Rich), JSON, and YAML
27
29
  - **Multi-profile support** via `~/.jcli/config.yaml` with environment variable overrides
28
30
  - **Automatic CSRF (Crumb) handling** for Jenkins servers with CSRF protection enabled
29
- - **Built with Click and Rich** for a polished terminal experience
31
+ - **Built on cliyard and Rich** for a polished, maintainable terminal experience
32
+
33
+ ## Architecture
34
+
35
+ jcli 2.0 is driven by [cliyard](https://pypi.org/project/cliyard/) YAML specs instead of hand-written Click command groups. At startup, `jcli/cli.py` locates the spec directory (`JCLI_SPEC_DIR` env var > package-local `specs/` > repo-root `specs/`) and calls `cliyard.runtime.create_cli()` to build the whole command tree. Commands stay declarative: adding or changing a command is an edit to a YAML file, not Python plumbing.
36
+
37
+ ```
38
+ specs/
39
+ _auth.yaml Server auth chain (basic auth + crumb, as cliyard plugins)
40
+ resources/ One YAML spec per Jenkins domain
41
+ job.yaml job list/get/create/config/copy/enable/disable/delete
42
+ build.yaml build list/get/trigger/replay/log/stop/queue
43
+ node.yaml node list/get/delete/toggle
44
+ plugin.yaml plugin list/get/install/uninstall
45
+ credential.yaml credential list/get/create/delete
46
+ pipeline.yaml pipeline stages/log/pending/validate
47
+ view.yaml view list/get/create/delete
48
+ system.yaml system info/load/quiet-down/restart/script
49
+ plugins/ Python method plugins for non-trivial logic
50
+ jenkins_auth.py Basic + crumb authentication
51
+ jenkins_methods.py Build trigger/replay, node create
52
+ jenkins_pipeline_validate.py Jenkinsfile validation
53
+ jenkins_plugin.py Plugin install/uninstall/update-center
54
+ jenkins_system.py Groovy script, quiet-down, restart
55
+ ```
56
+
57
+ A resource spec maps a command name to either a plain HTTP call (method, path, params, output mapping) or a `plugin:` reference. The auth chain in `_auth.yaml` injects `Authorization` and crumb headers into every request.
30
58
 
31
59
  ## Requirements
32
60
 
@@ -147,6 +175,21 @@ Every command accepts these global flags before the subcommand:
147
175
  -s, --server TEXT Jenkins server URL
148
176
  ```
149
177
 
178
+ ### auth — Manage authentication profiles
179
+
180
+ Profiles are stored in `~/.jcli/config.yaml` (token shown masked).
181
+
182
+ ```
183
+ jcli auth add -n NAME -u USER -p TOKEN -e URL Add (or update) a profile
184
+ jcli auth add -n NAME ... --default ... and make it active
185
+ jcli auth status List profiles (tokens masked)
186
+ jcli auth use NAME Switch the active profile
187
+ jcli auth rm NAME Remove a profile
188
+ jcli auth rm --all Remove all profiles (reset template)
189
+ jcli auth set NAME FIELD VALUE Set url/username/api_token/description
190
+ jcli auth show [NAME] Show a profile's details (default: active)
191
+ ```
192
+
150
193
  ### job — Manage Jenkins jobs
151
194
 
152
195
  ```
@@ -166,6 +209,9 @@ jcli job delete JOB_NAME Delete a job
166
209
  jcli build list JOB_NAME List recent builds for a job
167
210
  jcli build get JOB_NAME BUILD_NUMBER Show details for a specific build
168
211
  jcli build trigger JOB_NAME Trigger a new build
212
+ jcli build rebuild JOB_NAME BUILD_NUMBER Rebuild reusing previous parameters
213
+ jcli build rebuild JOB_NAME BUILD_NUMBER --set KEY=VAL ... and override values (repeatable)
214
+ jcli build replay JOB_NAME BUILD_NUMBER JENKINSFILE Replay a build with a custom Jenkinsfile
169
215
  jcli build log JOB_NAME BUILD_NUMBER Show console log for a build
170
216
  jcli build stop JOB_NAME BUILD_NUMBER Stop a running build
171
217
  jcli build queue Show the current build queue
@@ -268,23 +314,20 @@ pytest --cov=jcli --cov-report=term-missing
268
314
 
269
315
  ```
270
316
  jcli/
271
- cli.py Main CLI entry point (Click group)
317
+ cli.py Entry point: builds the Click CLI from cliyard YAML specs
318
+ cli_helpers.py Global option injection and profile/format plumbing
272
319
  __init__.py Version
273
- plugins/ Command modules (one per Jenkins domain)
274
- job.py
275
- build.py
276
- node.py
277
- plugin.py
278
- credential.py
279
- pipeline.py
280
- view.py
281
- system.py
320
+ plugins/ Legacy hand-written command modules (kept for SDK compatibility)
282
321
  sdk/ Shared libraries
283
322
  client.py Jenkins REST API client (HTTP, auth, crumb)
284
323
  config.py Configuration management (YAML, env vars)
285
324
  output/
286
325
  formatter.py Table/JSON/YAML output formatting
287
326
  exceptions.py Typed exceptions
327
+ specs/ cliyard YAML specs (command definitions)
328
+ _auth.yaml Auth chain: basic + crumb plugins
329
+ resources/ One YAML per Jenkins domain (job, build, node, ...)
330
+ plugins/ Python method plugins for non-trivial logic
288
331
  tests/ pytest test suite
289
332
  ```
290
333
 
@@ -293,6 +336,7 @@ tests/ pytest test suite
293
336
  | Package | Purpose |
294
337
  |-----------------|-----------------------------|
295
338
  | click >= 8.1.0 | CLI framework |
339
+ | cliyard >= 0.6.0 | Spec-driven CLI engine (YAML command generation) |
296
340
  | requests ~= 2.31 | HTTP client |
297
341
  | pyyaml ~= 6.0.1 | YAML config parsing |
298
342
  | rich >= 13.7.1 | Terminal formatting (tables) |
@@ -1,6 +1,6 @@
1
- jcli/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
1
+ jcli/__init__.py,sha256=J-j-u0itpEFT6irdmWmixQqYMadNl1X91TxUmoiLHMI,22
2
2
  jcli/__main__.py,sha256=ARL8XYr45T4pIf0gnyCCbK8uljcn1LF7TKcFapxHt_k,34
3
- jcli/cli.py,sha256=Ep6OFwyx4eVdbMbVcXrebPboMQuXarkYuxTILTst3eM,2135
3
+ jcli/cli.py,sha256=cg45uwYAW2IsdilPffCBSWK7RE2ragnKJ2xiYp9_m94,21175
4
4
  jcli/cli_helpers.py,sha256=8GsvEwmaqS8p9Rpx7d_b3_i_Pwag8zsJNNbunxgNXS0,1831
5
5
  jcli/plugins/__init__.py,sha256=I6vTpfYO3gIwHoNu5V-sOIdpT_g-bAy4RXR3TW3-k1U,1054
6
6
  jcli/plugins/build.py,sha256=60F0UPMFy_eZznBeAIM0XwCcQ3pAuJJjacGaCXxOlcI,6032
@@ -10,7 +10,7 @@ jcli/plugins/job.py,sha256=dq5jbAn8ZjtYNqJlo7u-0iORubMdZMB2sj4GVK01xoU,12872
10
10
  jcli/plugins/node.py,sha256=nwUDYvev9R_BOoYHVmNvNp3vhrmZAXVFN2z_-F_iO7I,4776
11
11
  jcli/plugins/pipeline.py,sha256=vTtLk0tPiMSwy2CsCEmtQMhbt9baFoxAqGCSyHtOmOc,3049
12
12
  jcli/plugins/plugin.py,sha256=0LMaPjr5jpZokOLENt0rDi_Klk4JYcw8uUzZUceDYvk,4713
13
- jcli/plugins/skills.py,sha256=WAJJN9_r9kgS2FCjoZeeQZhTrU9ZF0zUu3wpLwniHs4,13177
13
+ jcli/plugins/skills.py,sha256=5Nw3kPLbx82-8ackdXVLnIL09jziv53jhMZ2sCqYihk,14921
14
14
  jcli/plugins/system.py,sha256=2ojwBOsaiuNKmuiTLvU6GpLeSI3-kccy1QjpHuX_8vE,4938
15
15
  jcli/plugins/view.py,sha256=eH7pCe7Tw1rC4kVBZDxHqVd3ZrXYLlVxNGPjWvhgw8Q,4475
16
16
  jcli/sdk/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -28,17 +28,17 @@ jcli/sdk/system.py,sha256=L5PHlrjs-5_PFFOQ9IBA-vMwgQyqq33TfJidTcs0VSQ,6022
28
28
  jcli/sdk/view.py,sha256=5Poo50l3eY9ivef3hs0CyL-K39qDk39XQuX7gEHHRlA,2300
29
29
  jcli/sdk/output/__init__.py,sha256=Rt0F77oq24tqHvTDV4HyWKAjDtOyPOh9m7ucFy7B77s,70
30
30
  jcli/sdk/output/formatter.py,sha256=OUjQxeD-2iSNncuuRaLa01qLi899ux3F39KNkc8ik2Q,3876
31
- jcli/skills/jcli/SKILL.md,sha256=DM471dAnNguIzQKHMEBG3u6YafEzh9j3MesR8facQNQ,2384
32
- jcli/skills/jcli/config/SKILL.md,sha256=AHGfoitHRNPI0PyfKNrmOAG-k55RQOUacrDPfHiVDrI,1728
33
- jcli/skills/jcli/credential/SKILL.md,sha256=vOo2HWoVwv1IeQqALe7nOhwZ-qp4uLabanB4L8y14h8,943
34
- jcli/skills/jcli/job/SKILL.md,sha256=x8ytgCuHbwG1sspwLhzsd06AW4nw5M42ioHFqJi8yd8,1322
35
- jcli/skills/jcli/node/SKILL.md,sha256=E_r4aYjIr213qiTS6KGz08tV08hdsc8tlIzKZucrsts,1034
36
- jcli/skills/jcli/pipeline/SKILL.md,sha256=0cffcYTy-FzlqffosR-adSR7zne3Yfq08TEn1Yd6eCY,1177
37
- jcli/skills/jcli/plugin/SKILL.md,sha256=ybzQacz2-k0H9sTm4CwAEG40unT-Ndx1ngFrSv19KwA,986
38
- jcli/skills/jcli/system/SKILL.md,sha256=d2EkJSQnjaZFQ2MHWniHPxpHLGtZ9iDVy3jC2zev4zw,1538
39
- jcli/skills/jcli/view/SKILL.md,sha256=e-huIcHxr-A3tKFUkjQIV6mT2O9RKlSmEJ6HJ9NylyI,895
40
- python_jcli-0.1.0.dist-info/METADATA,sha256=1FUFhHyJ7BY_SC8xws-JjdaXFSBXsuQellAgHE90OWk,9276
41
- python_jcli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
42
- python_jcli-0.1.0.dist-info/entry_points.txt,sha256=g-Gb8voGH3Ru4NEoCUMJXJiMZMapdAfYi6Xt1phyFi4,39
43
- python_jcli-0.1.0.dist-info/top_level.txt,sha256=WFqrbWSEfhvQfbN2m-_Wi5KKCwv1_23LhzPUqo7AdQc,5
44
- python_jcli-0.1.0.dist-info/RECORD,,
31
+ jcli/skills/jcli/SKILL.md,sha256=X9xHxRvNa4F3hUN8bhckBXAJZx2MWY58hGqqd0y158M,3040
32
+ jcli/skills/jcli/auth/SKILL.md,sha256=4_DLD51SwKTXh2Xi0UiQbqG4uXw7qIKv2SOOopS2Qn0,1996
33
+ jcli/skills/jcli/credential/SKILL.md,sha256=3VsVdGalsIySvGfFO79IdL0b5OjF7kJBS9_4P56Wt2A,1244
34
+ jcli/skills/jcli/job/SKILL.md,sha256=bmGyiyLXS2n395bA31gehcMRe094SUBq-FzUlUDvpQM,2066
35
+ jcli/skills/jcli/node/SKILL.md,sha256=k4qi6ApspdIlBcxb0v2ItPrPwT7d9AnFq4N-vGhGcqY,1247
36
+ jcli/skills/jcli/pipeline/SKILL.md,sha256=kUwnxBCUx53zi7efVgtJg9ainO-YuX3griaCqoUyzxU,1231
37
+ jcli/skills/jcli/plugin/SKILL.md,sha256=--1t5LekIHAtnCWUnsQ9BKkPIgAQ7n-p7_QGd8H2jbI,1178
38
+ jcli/skills/jcli/system/SKILL.md,sha256=SHdcLbaJyifjt4KJlmdcHdYLsFGwpF-6lqiWdYpw3co,1932
39
+ jcli/skills/jcli/view/SKILL.md,sha256=f2QZEzvFocIlR7Yh0RKnp5y4sijwUJU_1fxiW7gKJcA,1047
40
+ python_jcli-1.0.0.dist-info/METADATA,sha256=9A7W8m365rFW8_iTXqwPA9VFDE-YUmNvdLR-7BKH7qs,12497
41
+ python_jcli-1.0.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
42
+ python_jcli-1.0.0.dist-info/entry_points.txt,sha256=g-Gb8voGH3Ru4NEoCUMJXJiMZMapdAfYi6Xt1phyFi4,39
43
+ python_jcli-1.0.0.dist-info/top_level.txt,sha256=WFqrbWSEfhvQfbN2m-_Wi5KKCwv1_23LhzPUqo7AdQc,5
44
+ python_jcli-1.0.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (82.0.1)
2
+ Generator: setuptools (83.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,81 +0,0 @@
1
- ---
2
- name: jcli-config
3
- version: 1.0.0
4
- description: |
5
- jcli 配置管理 - 管理 Jenkins 连接配置,支持多实例管理。
6
- allowed-tools:
7
- - Bash
8
- - Read
9
- ---
10
-
11
- # jcli-config
12
-
13
- 管理 jcli 的连接配置,支持多 Jenkins 实例。
14
-
15
- ## 前提条件
16
-
17
- - Python >= 3.10
18
- - Jenkins 2.x 或更高版本
19
- - 已安装 jcli:`pip install -e /data/git-project/jcli`
20
-
21
- ## 初始化配置
22
-
23
- ```bash
24
- jcli config init
25
- ```
26
-
27
- ## 配置文件位置
28
-
29
- `~/.jcli/config.yaml`
30
-
31
- ## 配置结构
32
-
33
- ```yaml
34
- active_profile: default
35
- profiles:
36
- default:
37
- url: https://jenkins.example.com
38
- username: admin
39
- api_token: your-api-token-here
40
- description: Default Jenkins instance
41
- ```
42
-
43
- ## 多实例管理
44
-
45
- ```bash
46
- # 添加新 profile
47
- jcli config add dev --url https://jenkins-dev.example.com --username admin
48
-
49
- # 修改配置
50
- jcli config set dev api_token your-token
51
- jcli config set dev description "Development Jenkins"
52
-
53
- # 切换 profile
54
- jcli config use dev
55
-
56
- # 查看配置
57
- jcli config show
58
- jcli config list
59
- ```
60
-
61
- ## 环境变量覆盖
62
-
63
- | 变量 | 覆盖字段 |
64
- |------|---------|
65
- | `JCLI_URL` | url |
66
- | `JCLI_USERNAME` | username |
67
- | `JCLI_API_TOKEN` | api_token |
68
- | `JCLI_PROFILE` | active_profile |
69
-
70
- ## 命令参考
71
-
72
- ```bash
73
- jcli config init # 初始化配置文件
74
- jcli config show # 查看当前配置
75
- jcli config show --profile dev # 查看指定 profile
76
- jcli config list # 列出所有 profiles
77
- jcli config add <name> --url URL # 添加新 profile
78
- jcli config set <name> <field> <value> # 修改配置
79
- jcli config use <name> # 切换 active profile
80
- jcli config delete <name> # 删除 profile
81
- ```