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.
- jcli/__init__.py +1 -1
- jcli/cli.py +565 -51
- jcli/cli_helpers.py +5 -42
- jcli/skills/jcli/SKILL.md +15 -2
- jcli/skills/jcli/auth/SKILL.md +86 -0
- jcli/skills/jcli/build/SKILL.md +123 -0
- jcli/skills/jcli/credential/SKILL.md +14 -5
- jcli/skills/jcli/job/SKILL.md +20 -4
- jcli/skills/jcli/node/SKILL.md +4 -2
- jcli/skills/jcli/pipeline/SKILL.md +3 -3
- jcli/skills/jcli/plugin/SKILL.md +3 -0
- jcli/skills/jcli/system/SKILL.md +15 -2
- jcli/skills/jcli/view/SKILL.md +4 -3
- jcli/specs/_auth.yaml +15 -0
- jcli/{plugins/skills.py → specs/plugins/jcli_commands.py} +124 -63
- jcli/specs/plugins/jenkins_auth.py +75 -0
- jcli/specs/plugins/jenkins_methods.py +332 -0
- jcli/specs/plugins/jenkins_pipeline_validate.py +51 -0
- jcli/specs/plugins/jenkins_plugin.py +77 -0
- jcli/specs/plugins/jenkins_system.py +137 -0
- jcli/specs/resources/build.yaml +160 -0
- jcli/specs/resources/credential.yaml +97 -0
- jcli/specs/resources/job.yaml +184 -0
- jcli/specs/resources/node.yaml +69 -0
- jcli/specs/resources/pipeline.yaml +62 -0
- jcli/specs/resources/plugin.yaml +65 -0
- jcli/specs/resources/system.yaml +72 -0
- jcli/specs/resources/view.yaml +75 -0
- {python_jcli-0.1.0.dist-info → python_jcli-1.0.1.dist-info}/METADATA +57 -12
- python_jcli-1.0.1.dist-info/RECORD +40 -0
- {python_jcli-0.1.0.dist-info → python_jcli-1.0.1.dist-info}/WHEEL +1 -1
- jcli/plugins/__init__.py +0 -28
- jcli/plugins/build.py +0 -197
- jcli/plugins/config.py +0 -268
- jcli/plugins/credential.py +0 -210
- jcli/plugins/job.py +0 -425
- jcli/plugins/node.py +0 -134
- jcli/plugins/pipeline.py +0 -108
- jcli/plugins/plugin.py +0 -173
- jcli/plugins/system.py +0 -173
- jcli/plugins/view.py +0 -148
- jcli/sdk/build.py +0 -182
- jcli/sdk/credential.py +0 -143
- jcli/sdk/job.py +0 -191
- jcli/sdk/job_templates.py +0 -128
- jcli/sdk/node.py +0 -139
- jcli/sdk/pipeline.py +0 -121
- jcli/sdk/plugin.py +0 -155
- jcli/sdk/system.py +0 -202
- jcli/sdk/view.py +0 -94
- jcli/skills/jcli/config/SKILL.md +0 -81
- python_jcli-0.1.0.dist-info/RECORD +0 -44
- {python_jcli-0.1.0.dist-info → python_jcli-1.0.1.dist-info}/entry_points.txt +0 -0
- {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
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
@click.
|
|
28
|
-
def
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
register_commands(cli)
|
|
497
|
+
cli.add_command(auth)
|
|
51
498
|
|
|
52
499
|
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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__":
|