python-jcli 1.0.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 (44) hide show
  1. jcli/__init__.py +1 -1
  2. jcli/cli.py +30 -10
  3. jcli/cli_helpers.py +5 -42
  4. jcli/skills/jcli/build/SKILL.md +123 -0
  5. jcli/specs/_auth.yaml +15 -0
  6. jcli/{plugins/skills.py → specs/plugins/jcli_commands.py} +64 -46
  7. jcli/specs/plugins/jenkins_auth.py +75 -0
  8. jcli/specs/plugins/jenkins_methods.py +332 -0
  9. jcli/specs/plugins/jenkins_pipeline_validate.py +51 -0
  10. jcli/specs/plugins/jenkins_plugin.py +77 -0
  11. jcli/specs/plugins/jenkins_system.py +137 -0
  12. jcli/specs/resources/build.yaml +160 -0
  13. jcli/specs/resources/credential.yaml +97 -0
  14. jcli/specs/resources/job.yaml +184 -0
  15. jcli/specs/resources/node.yaml +69 -0
  16. jcli/specs/resources/pipeline.yaml +62 -0
  17. jcli/specs/resources/plugin.yaml +65 -0
  18. jcli/specs/resources/system.yaml +72 -0
  19. jcli/specs/resources/view.yaml +75 -0
  20. {python_jcli-1.0.0.dist-info → python_jcli-1.0.1.dist-info}/METADATA +10 -9
  21. python_jcli-1.0.1.dist-info/RECORD +40 -0
  22. jcli/plugins/__init__.py +0 -28
  23. jcli/plugins/build.py +0 -197
  24. jcli/plugins/config.py +0 -268
  25. jcli/plugins/credential.py +0 -210
  26. jcli/plugins/job.py +0 -425
  27. jcli/plugins/node.py +0 -134
  28. jcli/plugins/pipeline.py +0 -108
  29. jcli/plugins/plugin.py +0 -173
  30. jcli/plugins/system.py +0 -173
  31. jcli/plugins/view.py +0 -148
  32. jcli/sdk/build.py +0 -182
  33. jcli/sdk/credential.py +0 -143
  34. jcli/sdk/job.py +0 -191
  35. jcli/sdk/job_templates.py +0 -128
  36. jcli/sdk/node.py +0 -139
  37. jcli/sdk/pipeline.py +0 -121
  38. jcli/sdk/plugin.py +0 -155
  39. jcli/sdk/system.py +0 -202
  40. jcli/sdk/view.py +0 -94
  41. python_jcli-1.0.0.dist-info/RECORD +0 -44
  42. {python_jcli-1.0.0.dist-info → python_jcli-1.0.1.dist-info}/WHEEL +0 -0
  43. {python_jcli-1.0.0.dist-info → python_jcli-1.0.1.dist-info}/entry_points.txt +0 -0
  44. {python_jcli-1.0.0.dist-info → python_jcli-1.0.1.dist-info}/top_level.txt +0 -0
jcli/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "1.0.0"
1
+ __version__ = "1.0.1"
jcli/cli.py CHANGED
@@ -48,21 +48,14 @@ SPEC_DIR_ENV = "JCLI_SPEC_DIR"
48
48
 
49
49
 
50
50
  def _default_spec_dir() -> Path:
51
- """Locate the cliyard YAML spec directory.
51
+ """Locate the cliyard YAML spec directory (ships inside the package).
52
52
 
53
- Priority: ``$JCLI_SPEC_DIR`` > package-local ``specs/`` > repo-root
54
- ``specs/`` (editable/dev layout).
53
+ Priority: ``$JCLI_SPEC_DIR`` env var > package-local ``jcli/specs/``.
55
54
  """
56
55
  env = os.environ.get(SPEC_DIR_ENV)
57
56
  if env:
58
57
  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
58
+ return Path(__file__).resolve().parent / "specs"
66
59
 
67
60
 
68
61
  def extract_profile_override(argv: list[str]) -> str | None:
@@ -213,6 +206,9 @@ def _make_format_injector(callback: Callable[..., Any]) -> Callable[..., Any]:
213
206
  ``Error:``/``错误:`` markers and re-emits them on stderr with a non-zero
214
207
  exit code.
215
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
+
216
212
  Note: ``ctx.exit(1)`` (not ``return 1``) is required — Click >= 8.4
217
213
  ignores a plain integer return value from the top-level callback and
218
214
  always exits with ``ctx.exit_code`` (default 0).
@@ -231,6 +227,9 @@ def _make_format_injector(callback: Callable[..., Any]) -> Callable[..., Any]:
231
227
  if source in (None, ParameterSource.DEFAULT, ParameterSource.DEFAULT_MAP):
232
228
  kwargs["format"] = global_format
233
229
 
230
+ if kwargs.get("follow"):
231
+ return callback(**kwargs)
232
+
234
233
  buffer = io.StringIO()
235
234
  real_stdout = sys.stdout
236
235
  sys.stdout = buffer
@@ -498,6 +497,26 @@ def _add_jcli_auth_commands(cli: click.Group) -> None:
498
497
  cli.add_command(auth)
499
498
 
500
499
 
500
+ def _add_tail_short_options(cli: click.Group) -> None:
501
+ """Give ``build log`` tail-style short flags (``-f``/``-n``).
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")
518
+
519
+
501
520
  def _build_cli(spec_dir: Path, server: str | None, profile: str | None) -> click.Group:
502
521
  """Create the cliyard CLI and apply the jcli wrapper layer."""
503
522
  base_url = resolve_base_url(server, profile)
@@ -509,6 +528,7 @@ def _build_cli(spec_dir: Path, server: str | None, profile: str | None) -> click
509
528
  wrap_subcommand_callbacks(cli)
510
529
  set_group_help(cli)
511
530
  _prune_non_jcli_commands(cli)
531
+ _add_tail_short_options(cli)
512
532
  return cli
513
533
 
514
534
 
jcli/cli_helpers.py CHANGED
@@ -1,54 +1,17 @@
1
- """Centralized CLI helpers for creating JenkinsClient and OutputFormatter.
1
+ """Centralized CLI helpers.
2
2
 
3
- All plugin modules should import ``get_client`` and ``get_formatter`` from here
4
- instead of implementing their own versions. This ensures consistent behaviour
5
- for ``--profile``, ``--server``, and ``--format`` across every subcommand.
3
+ v2 only needs the output-formatter helper (used by the cliyard command
4
+ plugins, e.g. ``jcli/specs/plugins/jcli_commands.py``). The v1 ``get_client``
5
+ helper was removed together with the legacy hand-written command modules
6
+ (``jcli/plugins/``).
6
7
  """
7
8
 
8
9
  from __future__ import annotations
9
10
 
10
- import logging
11
-
12
11
  import click
13
12
 
14
- from jcli.sdk.client import JenkinsClient
15
- from jcli.sdk.config import Config
16
13
  from jcli.sdk.output import OutputFormatter
17
14
 
18
- logger = logging.getLogger(__name__)
19
-
20
-
21
- def get_client(ctx: click.Context) -> JenkinsClient:
22
- """Return a cached ``JenkinsClient`` from the Click context.
23
-
24
- Resolution order:
25
- 1. Re-use ``ctx.obj["client"]`` if already present (tests / caching).
26
- 2. Load the config profile selected by ``--profile`` / ``$JCLI_PROFILE``.
27
- 3. Override the profile URL with ``--server`` when provided.
28
- """
29
- obj = ctx.obj or {}
30
-
31
- # Allow pre-created client (used by tests and internal callers)
32
- if "client" in obj:
33
- return obj["client"]
34
-
35
- config = Config()
36
- config.load()
37
-
38
- profile_name = obj.get("profile") or config.get_active_profile_name()
39
- profile = config.get_profile(profile_name)
40
-
41
- url = obj.get("server") or profile["url"]
42
- username = profile["username"]
43
- token = profile["api_token"]
44
-
45
- client = JenkinsClient(base_url=url, username=username, token=token)
46
- logger.debug("Created JenkinsClient for %s (profile=%s)", url, profile_name)
47
-
48
- # Cache for subsequent calls within the same invocation
49
- obj["client"] = client
50
- return client
51
-
52
15
 
53
16
  def get_formatter(ctx: click.Context) -> OutputFormatter:
54
17
  """Return an ``OutputFormatter`` matching the ``--format`` flag."""
@@ -0,0 +1,123 @@
1
+ ---
2
+ name: jcli-build
3
+ version: 1.0.0
4
+ description: |
5
+ jcli 构建管理 - 管理 Jenkins 构建的触发、查看、停止等操作。
6
+ allowed-tools:
7
+ - Bash
8
+ - Read
9
+ ---
10
+
11
+ # jcli-build
12
+
13
+ 管理 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 build list <job> # 列出构建历史
25
+ jcli build list <job> --limit 20 # 列出最近 20 次构建
26
+ jcli build get <job> <number> # 查看构建详情
27
+ jcli build log <job> <number> # 查看完整控制台日志
28
+ jcli build log <job> <number> -n 50 # 只显示末尾 50 行(tail -n)
29
+ jcli build log <job> <number> -f # 实时跟随(tail -f):先打印全部历史,再滚动新增,构建结束自动退出
30
+ jcli build log <job> <number> -f -n 50 # 显示末尾 50 行后实时跟随
31
+ jcli build artifacts <job> <number> # 查看构建产物
32
+ jcli build trigger <job> # 触发构建
33
+ jcli build trigger <job> --params BRANCH=main # 带参数触发构建
34
+ jcli build trigger <job> --params KEY1=VAL1 --params KEY2=VAL2 # 多个参数
35
+ jcli build rebuild <job> <number> # 重建(复用上次构建参数)
36
+ jcli build rebuild <job> <number> --set KEY=VAL # 重建并覆盖参数(可重复)
37
+ jcli build replay <job> <number> <jenkinsfile> # 回放(使用修改后的 Jenkinsfile)
38
+ jcli build stop <job> <number> # 停止构建
39
+ jcli build queue # 查看构建队列
40
+ ```
41
+
42
+ ## 常见用例
43
+
44
+ ### 查看构建失败原因
45
+
46
+ ```bash
47
+ # 1. 查看最近构建
48
+ jcli build list my-job
49
+
50
+ # 2. 查看失败构建的日志
51
+ jcli build log my-job 42
52
+ ```
53
+
54
+ ### 批量触发构建
55
+
56
+ ```bash
57
+ for job in job1 job2 job3; do
58
+ jcli build trigger $job
59
+ done
60
+ ```
61
+
62
+ ### 监控构建状态
63
+
64
+ ```bash
65
+ # 查看队列
66
+ jcli build queue
67
+
68
+ # 实时查看最新构建状态
69
+ watch -n 5 "jcli build list my-job --limit 1"
70
+
71
+ # 实时跟随构建日志(-f,构建结束自动退出)
72
+ jcli build log my-job 42 -f
73
+ ```
74
+
75
+ ### 带参数触发构建
76
+
77
+ ```bash
78
+ # 单个参数
79
+ jcli build trigger my-job --params BRANCH=develop
80
+
81
+ # 多个参数
82
+ jcli build trigger my-job --params BRANCH=develop --params DEPLOY=true --params ENV=staging
83
+ ```
84
+
85
+ ### 实时查看构建日志(tail 风格)
86
+
87
+ `jcli build log` 沿用 `tail` 命令的参数语义:
88
+
89
+ | 命令 | 效果 |
90
+ |------|------|
91
+ | `jcli build log <job> <n>` | 输出完整日志 |
92
+ | `jcli build log <job> <n> -n 50` | 只显示末尾 50 行 |
93
+ | `jcli build log <job> <n> -f` | 实时跟随:先输出全部历史,再滚动显示新日志 |
94
+ | `jcli build log <job> <n> -f -n 50` | 先看末尾 50 行,再实时跟随 |
95
+
96
+ - `-f` / `--follow`:实时输出,新日志逐行滚动(tail -f)
97
+ - `-n N` / `--lines N`:只显示末尾 N 行,默认全部
98
+ - 跟随模式在**构建结束后自动退出**,也可随时 `Ctrl+C` 中断
99
+
100
+ ```bash
101
+ # 构建失败排查:看末尾日志定位错误
102
+ jcli build log my-job 42 -n 100
103
+
104
+ # 实时观察正在构建的日志
105
+ jcli build log my-job 42 -f
106
+
107
+ # 触发构建后直接跟随
108
+ jcli build trigger my-job
109
+ jcli build log my-job 43 -f
110
+ ```
111
+
112
+ ### 重建与回放
113
+
114
+ ```bash
115
+ # 重建:复用上次构建的参数
116
+ jcli build rebuild my-job 42
117
+
118
+ # 重建并覆盖部分参数(--set 可重复)
119
+ jcli build rebuild my-job 42 --set DEPLOY=false
120
+
121
+ # 回放:使用修改后的 Jenkinsfile 文件重新运行 Pipeline
122
+ jcli build replay my-job 42 Jenkinsfile
123
+ ```
jcli/specs/_auth.yaml ADDED
@@ -0,0 +1,15 @@
1
+ name: jcli
2
+ version: "1.0.1"
3
+ description: Jenkins CLI tool
4
+ output:
5
+ default: table
6
+ server:
7
+ base_url: http://localhost:8090
8
+ auth:
9
+ steps:
10
+ - name: basic
11
+ type: plugin:jenkins_basic
12
+ config: {}
13
+ - name: crumb
14
+ type: plugin:jenkins_crumb
15
+ config: {}
@@ -1,4 +1,14 @@
1
- """Jcli skills management commands."""
1
+ """Top-level command plugins for jcli 2.0: skills, completion.
2
+
3
+ Migrated verbatim from jcli v1:
4
+
5
+ * ``skills`` — jcli/plugins/skills.py
6
+ * ``completion`` — jcli/cli.py ``add_completion_command``
7
+
8
+ Each group is registered as a cliyard command plugin
9
+ (``@register_command``), so ``create_cli`` attaches it to the top-level
10
+ Click group. Command semantics and output formats are unchanged from v1.
11
+ """
2
12
 
3
13
  from __future__ import annotations
4
14
 
@@ -8,14 +18,20 @@ from typing import Any
8
18
 
9
19
  import click
10
20
 
21
+ from cliyard.plugin import register_command
22
+
11
23
  from jcli.cli_helpers import get_formatter
12
24
 
13
- # ------------------------------------------------------------------
14
- # Constants
15
- # ------------------------------------------------------------------
16
25
 
17
- # Bundled skills directory (ships with jcli package)
18
- BUNDLED_SKILLS_DIR = Path(__file__).parent.parent / "skills" / "jcli"
26
+ # =====================================================================
27
+ # Skills commands (verbatim from jcli/plugins/skills.py)
28
+ # =====================================================================
29
+
30
+ # Bundled skills directory (ships with jcli package):
31
+ # <repo-root>/jcli/skills/jcli/* — this plugin lives in <repo-root>/jcli/specs/plugins/
32
+ BUNDLED_SKILLS_DIR = (
33
+ Path(__file__).resolve().parent.parent.parent.parent / "jcli" / "skills" / "jcli"
34
+ )
19
35
 
20
36
  # Default install directory for opencode
21
37
  DEFAULT_INSTALL_DIR = Path.home() / ".config" / "opencode" / "skills"
@@ -24,11 +40,6 @@ DEFAULT_INSTALL_DIR = Path.home() / ".config" / "opencode" / "skills"
24
40
  FRONTMATTER_PATTERN = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
25
41
 
26
42
 
27
- # ------------------------------------------------------------------
28
- # Helpers
29
- # ------------------------------------------------------------------
30
-
31
-
32
43
  def parse_skill_metadata(skill_dir: Path) -> dict[str, Any]:
33
44
  """Parse SKILL.md frontmatter to extract metadata.
34
45
 
@@ -231,27 +242,17 @@ def uninstall_skill(name: str, install_dir: Path | None = None) -> bool:
231
242
  return True
232
243
 
233
244
 
234
- # ------------------------------------------------------------------
235
- # Click group
236
- # ------------------------------------------------------------------
237
-
238
-
239
245
  @click.group("skills", help="Manage jcli skills (list, install, uninstall).")
240
246
  def skills_group() -> None:
241
247
  """Skills management commands."""
242
248
 
243
249
 
244
- # ------------------------------------------------------------------
245
- # list
246
- # ------------------------------------------------------------------
247
-
248
-
249
250
  @skills_group.command("list")
250
251
  @click.option("--installed", "-i", is_flag=True, help="Show only installed skills.")
251
252
  @click.option("--bundled", "-b", is_flag=True, help="Show only bundled skills.")
252
253
  @click.option("--dir", "install_dir", type=click.Path(exists=False), help="Custom install directory.")
253
254
  @click.pass_context
254
- def list_cmd(ctx: click.Context, installed: bool, bundled: bool, install_dir: str | None) -> None:
255
+ def skills_list_cmd(ctx: click.Context, installed: bool, bundled: bool, install_dir: str | None) -> None:
255
256
  """List all available skills."""
256
257
  fmt = get_formatter(ctx)
257
258
  target_dir = Path(install_dir) if install_dir else DEFAULT_INSTALL_DIR
@@ -297,18 +298,13 @@ def list_cmd(ctx: click.Context, installed: bool, bundled: bool, install_dir: st
297
298
  fmt.print_table(headers, rows, title="Jcli Skills")
298
299
 
299
300
 
300
- # ------------------------------------------------------------------
301
- # install
302
- # ------------------------------------------------------------------
303
-
304
-
305
301
  @skills_group.command("install")
306
302
  @click.argument("name", required=False)
307
303
  @click.option("--all", "-a", "install_all", is_flag=True, help="Install all bundled skills.")
308
304
  @click.option("--force", "-f", is_flag=True, help="Force install (overwrite existing).")
309
305
  @click.option("--dir", "install_dir", type=click.Path(exists=False), help="Custom install directory.")
310
306
  @click.pass_context
311
- def install_cmd(ctx: click.Context, name: str | None, install_all: bool, force: bool, install_dir: str | None) -> None:
307
+ def skills_install_cmd(ctx: click.Context, name: str | None, install_all: bool, force: bool, install_dir: str | None) -> None:
312
308
  """Install a skill.
313
309
 
314
310
  NAME is the skill name to install (from bundled skills).
@@ -354,16 +350,11 @@ def install_cmd(ctx: click.Context, name: str | None, install_all: bool, force:
354
350
  fmt.print_success(f"Skill '{name}' installed to {target_dir / name}")
355
351
 
356
352
 
357
- # ------------------------------------------------------------------
358
- # uninstall
359
- # ------------------------------------------------------------------
360
-
361
-
362
353
  @skills_group.command("uninstall")
363
354
  @click.argument("name")
364
355
  @click.option("--dir", "install_dir", type=click.Path(exists=False), help="Custom install directory.")
365
356
  @click.pass_context
366
- def uninstall_cmd(ctx: click.Context, name: str, install_dir: str | None) -> None:
357
+ def skills_uninstall_cmd(ctx: click.Context, name: str, install_dir: str | None) -> None:
367
358
  """Uninstall a skill.
368
359
 
369
360
  NAME is the skill name to uninstall.
@@ -382,17 +373,12 @@ def uninstall_cmd(ctx: click.Context, name: str, install_dir: str | None) -> Non
382
373
  fmt.print_success(f"Skill '{name}' uninstalled from {target_dir}")
383
374
 
384
375
 
385
- # ------------------------------------------------------------------
386
- # get
387
- # ------------------------------------------------------------------
388
-
389
-
390
376
  @skills_group.command("get")
391
377
  @click.argument("name")
392
378
  @click.option("--installed", "-i", is_flag=True, help="Get from installed skills.")
393
379
  @click.option("--dir", "install_dir", type=click.Path(exists=False), help="Custom install directory.")
394
380
  @click.pass_context
395
- def get_cmd(ctx: click.Context, name: str, installed: bool, install_dir: str | None) -> None:
381
+ def skills_get_cmd(ctx: click.Context, name: str, installed: bool, install_dir: str | None) -> None:
396
382
  """Show skill details and SKILL.md content."""
397
383
  fmt = get_formatter(ctx)
398
384
  target_dir = Path(install_dir) if install_dir else DEFAULT_INSTALL_DIR
@@ -432,11 +418,43 @@ def get_cmd(ctx: click.Context, name: str, installed: bool, install_dir: str | N
432
418
  fmt.console.print(content)
433
419
 
434
420
 
435
- # ------------------------------------------------------------------
436
- # Registration
437
- # ------------------------------------------------------------------
421
+ # =====================================================================
422
+ # Completion command (verbatim from jcli/cli.py add_completion_command)
423
+ # =====================================================================
424
+
425
+
426
+ def build_completion_group(cli: click.Group) -> click.Group:
427
+ """Build the ``completion show`` group attached to *cli*."""
428
+ from click.shell_completion import BashComplete, FishComplete, ZshComplete
429
+
430
+ @click.group()
431
+ def completion() -> None:
432
+ """Shell completion support for jcli."""
433
+
434
+ @completion.command()
435
+ @click.argument("shell", type=click.Choice(["bash", "zsh", "fish"]))
436
+ def show(shell: str) -> None:
437
+ """Output shell completion script for the specified shell."""
438
+ shell_cls = {"bash": BashComplete, "zsh": ZshComplete, "fish": FishComplete}
439
+ complete = shell_cls[shell](cli, {}, "jcli", "_JCLI_COMPLETE")
440
+ click.echo(complete.source(), nl=False)
441
+
442
+ cli.add_command(completion)
443
+ return completion
444
+
445
+
446
+ # =====================================================================
447
+ # cliyard command plugin registration
448
+ # =====================================================================
449
+
450
+
451
+ @register_command("skills")
452
+ def _register_skills(cli: click.Group, ctx: Any) -> None:
453
+ """Attach the skills command group to the top-level CLI."""
454
+ cli.add_command(skills_group)
438
455
 
439
456
 
440
- def register(parent_group: click.Group) -> None:
441
- """Register the skills subgroup under the parent Click group."""
442
- parent_group.add_command(skills_group)
457
+ @register_command("completion")
458
+ def _register_completion(cli: click.Group, ctx: Any) -> None:
459
+ """Attach the completion command group to the top-level CLI."""
460
+ build_completion_group(cli)
@@ -0,0 +1,75 @@
1
+ """Jenkins auth steps: Basic Auth + Crumb (CSRF)."""
2
+ import base64
3
+ import os
4
+
5
+ import requests
6
+
7
+ from cliyard.plugin import register_auth_step
8
+
9
+
10
+ def _config_path() -> str:
11
+ """Resolve the jcli config file path at call time.
12
+
13
+ Prefers ``jcli.sdk.config.DEFAULT_CONFIG_FILE`` (so tests can point it
14
+ elsewhere via monkeypatch) and falls back to ``~/.jcli/config.yaml``
15
+ when the SDK is not importable.
16
+ """
17
+ try:
18
+ from jcli.sdk.config import DEFAULT_CONFIG_FILE
19
+
20
+ return str(DEFAULT_CONFIG_FILE)
21
+ except Exception:
22
+ return os.path.expanduser("~/.jcli/config.yaml")
23
+
24
+
25
+ def _load_profile():
26
+ import yaml
27
+ config_path = _config_path()
28
+ if not os.path.exists(config_path):
29
+ raise RuntimeError(f"config not found: {config_path}")
30
+ with open(config_path, "r", encoding="utf-8") as f:
31
+ cfg = yaml.safe_load(f) or {}
32
+ name = os.environ.get("JCLI_PROFILE") or cfg.get("active_profile", "default")
33
+ profile = dict(cfg.get("profiles", {}).get(name, {}))
34
+ for env_key, field in (("JCLI_URL", "url"), ("JCLI_USERNAME", "username"), ("JCLI_API_TOKEN", "api_token")):
35
+ if os.environ.get(env_key):
36
+ profile[field] = os.environ[env_key]
37
+ return profile
38
+
39
+
40
+ @register_auth_step("jenkins_basic")
41
+ class JenkinsBasicAuth:
42
+ """Set Basic Auth header from ~/.jcli/config.yaml profile."""
43
+
44
+ def execute(self, auth_state, config, http_client):
45
+ profile = _load_profile()
46
+ username = profile.get("username", "")
47
+ token = profile.get("api_token", "")
48
+ if not username or not token:
49
+ raise RuntimeError("missing username/api_token in profile")
50
+ raw = f"{username}:{token}".encode()
51
+ http_client.default_headers["Authorization"] = "Basic " + base64.b64encode(raw).decode()
52
+ auth_state["username"] = username
53
+ return {"username": username}
54
+
55
+
56
+ @register_auth_step("jenkins_crumb")
57
+ class JenkinsCrumb:
58
+ """Fetch Jenkins crumb and inject as header. Tolerate 404 (CSRF disabled)."""
59
+
60
+ def execute(self, auth_state, config, http_client):
61
+ url = f"{http_client.base_url}/crumbIssuer/api/json"
62
+ try:
63
+ resp = http_client._session.get(url, headers=http_client.default_headers, timeout=10)
64
+ except requests.RequestException:
65
+ return {}
66
+ if resp.status_code == 404:
67
+ return {} # CSRF disabled
68
+ if resp.status_code != 200:
69
+ raise RuntimeError(f"crumb fetch failed: {resp.status_code}")
70
+ data = resp.json()
71
+ field = data.get("crumbRequestField")
72
+ value = data.get("crumb")
73
+ if field and value:
74
+ http_client.default_headers[field] = value
75
+ return {"crumb": value}