python-jcli 0.1.0__py3-none-any.whl → 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. jcli/__init__.py +1 -1
  2. jcli/cli.py +565 -51
  3. jcli/cli_helpers.py +5 -42
  4. jcli/skills/jcli/SKILL.md +15 -2
  5. jcli/skills/jcli/auth/SKILL.md +86 -0
  6. jcli/skills/jcli/build/SKILL.md +123 -0
  7. jcli/skills/jcli/credential/SKILL.md +14 -5
  8. jcli/skills/jcli/job/SKILL.md +20 -4
  9. jcli/skills/jcli/node/SKILL.md +4 -2
  10. jcli/skills/jcli/pipeline/SKILL.md +3 -3
  11. jcli/skills/jcli/plugin/SKILL.md +3 -0
  12. jcli/skills/jcli/system/SKILL.md +15 -2
  13. jcli/skills/jcli/view/SKILL.md +4 -3
  14. jcli/specs/_auth.yaml +15 -0
  15. jcli/{plugins/skills.py → specs/plugins/jcli_commands.py} +124 -63
  16. jcli/specs/plugins/jenkins_auth.py +75 -0
  17. jcli/specs/plugins/jenkins_methods.py +332 -0
  18. jcli/specs/plugins/jenkins_pipeline_validate.py +51 -0
  19. jcli/specs/plugins/jenkins_plugin.py +77 -0
  20. jcli/specs/plugins/jenkins_system.py +137 -0
  21. jcli/specs/resources/build.yaml +160 -0
  22. jcli/specs/resources/credential.yaml +97 -0
  23. jcli/specs/resources/job.yaml +184 -0
  24. jcli/specs/resources/node.yaml +69 -0
  25. jcli/specs/resources/pipeline.yaml +62 -0
  26. jcli/specs/resources/plugin.yaml +65 -0
  27. jcli/specs/resources/system.yaml +72 -0
  28. jcli/specs/resources/view.yaml +75 -0
  29. {python_jcli-0.1.0.dist-info → python_jcli-1.0.1.dist-info}/METADATA +57 -12
  30. python_jcli-1.0.1.dist-info/RECORD +40 -0
  31. {python_jcli-0.1.0.dist-info → python_jcli-1.0.1.dist-info}/WHEEL +1 -1
  32. jcli/plugins/__init__.py +0 -28
  33. jcli/plugins/build.py +0 -197
  34. jcli/plugins/config.py +0 -268
  35. jcli/plugins/credential.py +0 -210
  36. jcli/plugins/job.py +0 -425
  37. jcli/plugins/node.py +0 -134
  38. jcli/plugins/pipeline.py +0 -108
  39. jcli/plugins/plugin.py +0 -173
  40. jcli/plugins/system.py +0 -173
  41. jcli/plugins/view.py +0 -148
  42. jcli/sdk/build.py +0 -182
  43. jcli/sdk/credential.py +0 -143
  44. jcli/sdk/job.py +0 -191
  45. jcli/sdk/job_templates.py +0 -128
  46. jcli/sdk/node.py +0 -139
  47. jcli/sdk/pipeline.py +0 -121
  48. jcli/sdk/plugin.py +0 -155
  49. jcli/sdk/system.py +0 -202
  50. jcli/sdk/view.py +0 -94
  51. jcli/skills/jcli/config/SKILL.md +0 -81
  52. python_jcli-0.1.0.dist-info/RECORD +0 -44
  53. {python_jcli-0.1.0.dist-info → python_jcli-1.0.1.dist-info}/entry_points.txt +0 -0
  54. {python_jcli-0.1.0.dist-info → python_jcli-1.0.1.dist-info}/top_level.txt +0 -0
@@ -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
 
@@ -59,9 +70,12 @@ def parse_skill_metadata(skill_dir: Path) -> dict[str, Any]:
59
70
  frontmatter = match.group(1)
60
71
 
61
72
  # Simple YAML parsing without pyyaml dependency
62
- for line in frontmatter.split("\n"):
63
- line = line.strip()
73
+ lines = frontmatter.split("\n")
74
+ i = 0
75
+ while i < len(lines):
76
+ line = lines[i].strip()
64
77
  if not line or line.startswith("#"):
78
+ i += 1
65
79
  continue
66
80
 
67
81
  if line.startswith("name:"):
@@ -70,14 +84,45 @@ def parse_skill_metadata(skill_dir: Path) -> dict[str, Any]:
70
84
  metadata["version"] = line.split(":", 1)[1].strip().strip("'\"")
71
85
  elif line.startswith("description:"):
72
86
  desc = line.split(":", 1)[1].strip().strip("'\"")
87
+ if desc in ("|", ">", ""):
88
+ # YAML block scalar: collect following indented lines
89
+ parts = []
90
+ j = i + 1
91
+ while j < len(lines) and (
92
+ lines[j].startswith(" ") or lines[j].startswith("\t")
93
+ ):
94
+ parts.append(lines[j].strip())
95
+ j += 1
96
+ if parts:
97
+ desc = " ".join(parts)
98
+ i = j - 1 # continue after the block scalar
73
99
  metadata["description"] = desc
74
100
  elif line.startswith("- ") and "allowed-tools" in frontmatter:
75
101
  tool = line[2:].strip().strip("'\"")
76
102
  metadata["allowed_tools"].append(tool)
103
+ i += 1
77
104
 
78
105
  return metadata
79
106
 
80
107
 
108
+ def _is_jcli_skill(metadata: dict[str, Any]) -> bool:
109
+ """Return True if the skill belongs to jcli.
110
+
111
+ A skill is considered jcli's own when:
112
+ - it is a symlink into the bundled skills directory
113
+ (``source == "bundled (symlink)"``), or
114
+ - its name starts with the ``jcli-`` prefix, or
115
+ - its path contains a ``jcli`` path segment (e.g. inside BUNDLED_SKILLS_DIR).
116
+ """
117
+ if metadata.get("source") == "bundled (symlink)":
118
+ return True
119
+ if str(metadata.get("name", "")).startswith("jcli-"):
120
+ return True
121
+ if "jcli" in Path(str(metadata.get("path", ""))).parts:
122
+ return True
123
+ return False
124
+
125
+
81
126
  def get_bundled_skills() -> list[dict[str, Any]]:
82
127
  """Get all bundled skills from the skills directory."""
83
128
  skills = []
@@ -149,8 +194,8 @@ def install_skill(
149
194
 
150
195
  skill_dest = target_dir / name
151
196
 
152
- # Check if already installed
153
- if skill_dest.exists():
197
+ # Path.exists() is False for a broken symlink; is_symlink() catches it
198
+ if skill_dest.exists() or skill_dest.is_symlink():
154
199
  if force:
155
200
  # Remove existing
156
201
  if skill_dest.is_symlink():
@@ -182,7 +227,8 @@ def uninstall_skill(name: str, install_dir: Path | None = None) -> bool:
182
227
  target_dir = install_dir or DEFAULT_INSTALL_DIR
183
228
  skill_path = target_dir / name
184
229
 
185
- if not skill_path.exists():
230
+ # Path.exists() is False for a broken symlink; is_symlink() catches it
231
+ if not skill_path.exists() and not skill_path.is_symlink():
186
232
  raise click.ClickException(f"Skill '{name}' not found in {target_dir}.")
187
233
 
188
234
  if skill_path.is_symlink():
@@ -196,46 +242,44 @@ def uninstall_skill(name: str, install_dir: Path | None = None) -> bool:
196
242
  return True
197
243
 
198
244
 
199
- # ------------------------------------------------------------------
200
- # Click group
201
- # ------------------------------------------------------------------
202
-
203
-
204
245
  @click.group("skills", help="Manage jcli skills (list, install, uninstall).")
205
246
  def skills_group() -> None:
206
247
  """Skills management commands."""
207
248
 
208
249
 
209
- # ------------------------------------------------------------------
210
- # list
211
- # ------------------------------------------------------------------
212
-
213
-
214
250
  @skills_group.command("list")
215
251
  @click.option("--installed", "-i", is_flag=True, help="Show only installed skills.")
216
252
  @click.option("--bundled", "-b", is_flag=True, help="Show only bundled skills.")
217
253
  @click.option("--dir", "install_dir", type=click.Path(exists=False), help="Custom install directory.")
218
254
  @click.pass_context
219
- 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:
220
256
  """List all available skills."""
221
257
  fmt = get_formatter(ctx)
222
258
  target_dir = Path(install_dir) if install_dir else DEFAULT_INSTALL_DIR
223
259
 
224
260
  skills_to_show = []
225
261
 
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:
262
+ if installed and bundled:
263
+ # Both flags explicitly given: bundled + jcli installed (dedup by name)
264
+ seen: set[str] = set()
265
+ for skill in get_bundled_skills():
266
+ skills_to_show.append(skill)
267
+ seen.add(skill["name"])
268
+ for skill in get_installed_skills(target_dir):
269
+ if _is_jcli_skill(skill) and skill["name"] not in seen:
238
270
  skills_to_show.append(skill)
271
+ seen.add(skill["name"])
272
+ elif installed:
273
+ # Only jcli installed skills
274
+ skills_to_show.extend(
275
+ s for s in get_installed_skills(target_dir) if _is_jcli_skill(s)
276
+ )
277
+ elif bundled:
278
+ # Only bundled skills
279
+ skills_to_show.extend(get_bundled_skills())
280
+ else:
281
+ # Default: only bundled (jcli's own skills)
282
+ skills_to_show.extend(get_bundled_skills())
239
283
 
240
284
  if not skills_to_show:
241
285
  fmt.print_info("No skills found.")
@@ -254,18 +298,13 @@ def list_cmd(ctx: click.Context, installed: bool, bundled: bool, install_dir: st
254
298
  fmt.print_table(headers, rows, title="Jcli Skills")
255
299
 
256
300
 
257
- # ------------------------------------------------------------------
258
- # install
259
- # ------------------------------------------------------------------
260
-
261
-
262
301
  @skills_group.command("install")
263
302
  @click.argument("name", required=False)
264
303
  @click.option("--all", "-a", "install_all", is_flag=True, help="Install all bundled skills.")
265
304
  @click.option("--force", "-f", is_flag=True, help="Force install (overwrite existing).")
266
305
  @click.option("--dir", "install_dir", type=click.Path(exists=False), help="Custom install directory.")
267
306
  @click.pass_context
268
- 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:
269
308
  """Install a skill.
270
309
 
271
310
  NAME is the skill name to install (from bundled skills).
@@ -311,16 +350,11 @@ def install_cmd(ctx: click.Context, name: str | None, install_all: bool, force:
311
350
  fmt.print_success(f"Skill '{name}' installed to {target_dir / name}")
312
351
 
313
352
 
314
- # ------------------------------------------------------------------
315
- # uninstall
316
- # ------------------------------------------------------------------
317
-
318
-
319
353
  @skills_group.command("uninstall")
320
354
  @click.argument("name")
321
355
  @click.option("--dir", "install_dir", type=click.Path(exists=False), help="Custom install directory.")
322
356
  @click.pass_context
323
- 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:
324
358
  """Uninstall a skill.
325
359
 
326
360
  NAME is the skill name to uninstall.
@@ -339,17 +373,12 @@ def uninstall_cmd(ctx: click.Context, name: str, install_dir: str | None) -> Non
339
373
  fmt.print_success(f"Skill '{name}' uninstalled from {target_dir}")
340
374
 
341
375
 
342
- # ------------------------------------------------------------------
343
- # get
344
- # ------------------------------------------------------------------
345
-
346
-
347
376
  @skills_group.command("get")
348
377
  @click.argument("name")
349
378
  @click.option("--installed", "-i", is_flag=True, help="Get from installed skills.")
350
379
  @click.option("--dir", "install_dir", type=click.Path(exists=False), help="Custom install directory.")
351
380
  @click.pass_context
352
- 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:
353
382
  """Show skill details and SKILL.md content."""
354
383
  fmt = get_formatter(ctx)
355
384
  target_dir = Path(install_dir) if install_dir else DEFAULT_INSTALL_DIR
@@ -389,11 +418,43 @@ def get_cmd(ctx: click.Context, name: str, installed: bool, install_dir: str | N
389
418
  fmt.console.print(content)
390
419
 
391
420
 
392
- # ------------------------------------------------------------------
393
- # Registration
394
- # ------------------------------------------------------------------
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)
395
455
 
396
456
 
397
- def register(parent_group: click.Group) -> None:
398
- """Register the skills subgroup under the parent Click group."""
399
- 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}