python-jcli 0.1.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 +1 -0
- jcli/__main__.py +3 -0
- jcli/cli.py +79 -0
- jcli/cli_helpers.py +58 -0
- jcli/plugins/__init__.py +28 -0
- jcli/plugins/build.py +197 -0
- jcli/plugins/config.py +268 -0
- jcli/plugins/credential.py +210 -0
- jcli/plugins/job.py +425 -0
- jcli/plugins/node.py +134 -0
- jcli/plugins/pipeline.py +108 -0
- jcli/plugins/plugin.py +173 -0
- jcli/plugins/skills.py +399 -0
- jcli/plugins/system.py +173 -0
- jcli/plugins/view.py +148 -0
- jcli/sdk/__init__.py +0 -0
- jcli/sdk/build.py +182 -0
- jcli/sdk/client.py +264 -0
- jcli/sdk/config.py +227 -0
- jcli/sdk/credential.py +143 -0
- jcli/sdk/exceptions.py +59 -0
- jcli/sdk/job.py +191 -0
- jcli/sdk/job_templates.py +128 -0
- jcli/sdk/node.py +139 -0
- jcli/sdk/output/__init__.py +3 -0
- jcli/sdk/output/formatter.py +131 -0
- jcli/sdk/pipeline.py +121 -0
- jcli/sdk/plugin.py +155 -0
- jcli/sdk/system.py +202 -0
- jcli/sdk/view.py +94 -0
- jcli/skills/jcli/SKILL.md +73 -0
- jcli/skills/jcli/config/SKILL.md +81 -0
- jcli/skills/jcli/credential/SKILL.md +47 -0
- jcli/skills/jcli/job/SKILL.md +55 -0
- jcli/skills/jcli/node/SKILL.md +51 -0
- jcli/skills/jcli/pipeline/SKILL.md +58 -0
- jcli/skills/jcli/plugin/SKILL.md +51 -0
- jcli/skills/jcli/system/SKILL.md +70 -0
- jcli/skills/jcli/view/SKILL.md +47 -0
- python_jcli-0.1.0.dist-info/METADATA +311 -0
- python_jcli-0.1.0.dist-info/RECORD +44 -0
- python_jcli-0.1.0.dist-info/WHEEL +5 -0
- python_jcli-0.1.0.dist-info/entry_points.txt +2 -0
- python_jcli-0.1.0.dist-info/top_level.txt +1 -0
jcli/plugins/skills.py
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""Jcli skills management commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from jcli.cli_helpers import get_formatter
|
|
12
|
+
|
|
13
|
+
# ------------------------------------------------------------------
|
|
14
|
+
# Constants
|
|
15
|
+
# ------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
# Bundled skills directory (ships with jcli package)
|
|
18
|
+
BUNDLED_SKILLS_DIR = Path(__file__).parent.parent / "skills" / "jcli"
|
|
19
|
+
|
|
20
|
+
# Default install directory for opencode
|
|
21
|
+
DEFAULT_INSTALL_DIR = Path.home() / ".config" / "opencode" / "skills"
|
|
22
|
+
|
|
23
|
+
# SKILL.md frontmatter pattern
|
|
24
|
+
FRONTMATTER_PATTERN = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ------------------------------------------------------------------
|
|
28
|
+
# Helpers
|
|
29
|
+
# ------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_skill_metadata(skill_dir: Path) -> dict[str, Any]:
|
|
33
|
+
"""Parse SKILL.md frontmatter to extract metadata.
|
|
34
|
+
|
|
35
|
+
Returns dict with keys: name, version, description, allowed_tools, path
|
|
36
|
+
"""
|
|
37
|
+
skill_md = skill_dir / "SKILL.md"
|
|
38
|
+
metadata: dict[str, Any] = {
|
|
39
|
+
"name": skill_dir.name,
|
|
40
|
+
"version": "",
|
|
41
|
+
"description": "",
|
|
42
|
+
"allowed_tools": [],
|
|
43
|
+
"path": str(skill_dir),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if not skill_md.exists():
|
|
47
|
+
return metadata
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
content = skill_md.read_text(encoding="utf-8")
|
|
51
|
+
except OSError:
|
|
52
|
+
return metadata
|
|
53
|
+
|
|
54
|
+
# Extract frontmatter
|
|
55
|
+
match = FRONTMATTER_PATTERN.match(content)
|
|
56
|
+
if not match:
|
|
57
|
+
return metadata
|
|
58
|
+
|
|
59
|
+
frontmatter = match.group(1)
|
|
60
|
+
|
|
61
|
+
# Simple YAML parsing without pyyaml dependency
|
|
62
|
+
for line in frontmatter.split("\n"):
|
|
63
|
+
line = line.strip()
|
|
64
|
+
if not line or line.startswith("#"):
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
if line.startswith("name:"):
|
|
68
|
+
metadata["name"] = line.split(":", 1)[1].strip().strip("'\"")
|
|
69
|
+
elif line.startswith("version:"):
|
|
70
|
+
metadata["version"] = line.split(":", 1)[1].strip().strip("'\"")
|
|
71
|
+
elif line.startswith("description:"):
|
|
72
|
+
desc = line.split(":", 1)[1].strip().strip("'\"")
|
|
73
|
+
metadata["description"] = desc
|
|
74
|
+
elif line.startswith("- ") and "allowed-tools" in frontmatter:
|
|
75
|
+
tool = line[2:].strip().strip("'\"")
|
|
76
|
+
metadata["allowed_tools"].append(tool)
|
|
77
|
+
|
|
78
|
+
return metadata
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_bundled_skills() -> list[dict[str, Any]]:
|
|
82
|
+
"""Get all bundled skills from the skills directory."""
|
|
83
|
+
skills = []
|
|
84
|
+
|
|
85
|
+
if not BUNDLED_SKILLS_DIR.exists():
|
|
86
|
+
return skills
|
|
87
|
+
|
|
88
|
+
for skill_dir in sorted(BUNDLED_SKILLS_DIR.iterdir()):
|
|
89
|
+
if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
|
|
90
|
+
metadata = parse_skill_metadata(skill_dir)
|
|
91
|
+
metadata["source"] = "bundled"
|
|
92
|
+
skills.append(metadata)
|
|
93
|
+
|
|
94
|
+
return skills
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_installed_skills(install_dir: Path | None = None) -> list[dict[str, Any]]:
|
|
98
|
+
"""Get all installed skills from the install directory."""
|
|
99
|
+
target_dir = install_dir or DEFAULT_INSTALL_DIR
|
|
100
|
+
skills = []
|
|
101
|
+
|
|
102
|
+
if not target_dir.exists():
|
|
103
|
+
return skills
|
|
104
|
+
|
|
105
|
+
for skill_dir in sorted(target_dir.iterdir()):
|
|
106
|
+
if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
|
|
107
|
+
metadata = parse_skill_metadata(skill_dir)
|
|
108
|
+
metadata["source"] = "installed"
|
|
109
|
+
# Check if it's a symlink to bundled
|
|
110
|
+
if skill_dir.is_symlink():
|
|
111
|
+
real_path = skill_dir.resolve()
|
|
112
|
+
if str(BUNDLED_SKILLS_DIR) in str(real_path):
|
|
113
|
+
metadata["source"] = "bundled (symlink)"
|
|
114
|
+
skills.append(metadata)
|
|
115
|
+
|
|
116
|
+
return skills
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def find_skill_dir(name: str) -> Path | None:
|
|
120
|
+
"""Find a bundled skill directory by name or frontmatter name."""
|
|
121
|
+
direct_path = BUNDLED_SKILLS_DIR / name
|
|
122
|
+
if direct_path.exists() and direct_path.is_dir():
|
|
123
|
+
return direct_path
|
|
124
|
+
|
|
125
|
+
for skill_dir in BUNDLED_SKILLS_DIR.iterdir():
|
|
126
|
+
if skill_dir.is_dir():
|
|
127
|
+
metadata = parse_skill_metadata(skill_dir)
|
|
128
|
+
if metadata["name"] == name:
|
|
129
|
+
return skill_dir
|
|
130
|
+
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def install_skill(
|
|
135
|
+
name: str,
|
|
136
|
+
install_dir: Path | None = None,
|
|
137
|
+
force: bool = False,
|
|
138
|
+
) -> bool:
|
|
139
|
+
"""Install a skill by creating a symlink.
|
|
140
|
+
|
|
141
|
+
Returns True if successful, False otherwise.
|
|
142
|
+
"""
|
|
143
|
+
target_dir = install_dir or DEFAULT_INSTALL_DIR
|
|
144
|
+
|
|
145
|
+
# Find the bundled skill
|
|
146
|
+
skill_source = find_skill_dir(name)
|
|
147
|
+
if not skill_source:
|
|
148
|
+
raise click.ClickException(f"Skill '{name}' not found in bundled skills.")
|
|
149
|
+
|
|
150
|
+
skill_dest = target_dir / name
|
|
151
|
+
|
|
152
|
+
# Check if already installed
|
|
153
|
+
if skill_dest.exists():
|
|
154
|
+
if force:
|
|
155
|
+
# Remove existing
|
|
156
|
+
if skill_dest.is_symlink():
|
|
157
|
+
skill_dest.unlink()
|
|
158
|
+
else:
|
|
159
|
+
raise click.ClickException(
|
|
160
|
+
f"Skill '{name}' already installed at {skill_dest}. "
|
|
161
|
+
"Use --force to overwrite."
|
|
162
|
+
)
|
|
163
|
+
else:
|
|
164
|
+
raise click.ClickException(
|
|
165
|
+
f"Skill '{name}' already installed at {skill_dest}. "
|
|
166
|
+
"Use --force to overwrite."
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# Create install directory if needed
|
|
170
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
171
|
+
|
|
172
|
+
# Create symlink
|
|
173
|
+
skill_dest.symlink_to(skill_source)
|
|
174
|
+
return True
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def uninstall_skill(name: str, install_dir: Path | None = None) -> bool:
|
|
178
|
+
"""Uninstall a skill by removing the symlink/directory.
|
|
179
|
+
|
|
180
|
+
Returns True if successful, False otherwise.
|
|
181
|
+
"""
|
|
182
|
+
target_dir = install_dir or DEFAULT_INSTALL_DIR
|
|
183
|
+
skill_path = target_dir / name
|
|
184
|
+
|
|
185
|
+
if not skill_path.exists():
|
|
186
|
+
raise click.ClickException(f"Skill '{name}' not found in {target_dir}.")
|
|
187
|
+
|
|
188
|
+
if skill_path.is_symlink():
|
|
189
|
+
skill_path.unlink()
|
|
190
|
+
else:
|
|
191
|
+
raise click.ClickException(
|
|
192
|
+
f"Skill '{name}' is not a symlink. "
|
|
193
|
+
"Manual removal required for non-symlinked skills."
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
return True
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ------------------------------------------------------------------
|
|
200
|
+
# Click group
|
|
201
|
+
# ------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@click.group("skills", help="Manage jcli skills (list, install, uninstall).")
|
|
205
|
+
def skills_group() -> None:
|
|
206
|
+
"""Skills management commands."""
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# ------------------------------------------------------------------
|
|
210
|
+
# list
|
|
211
|
+
# ------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@skills_group.command("list")
|
|
215
|
+
@click.option("--installed", "-i", is_flag=True, help="Show only installed skills.")
|
|
216
|
+
@click.option("--bundled", "-b", is_flag=True, help="Show only bundled skills.")
|
|
217
|
+
@click.option("--dir", "install_dir", type=click.Path(exists=False), help="Custom install directory.")
|
|
218
|
+
@click.pass_context
|
|
219
|
+
def list_cmd(ctx: click.Context, installed: bool, bundled: bool, install_dir: str | None) -> None:
|
|
220
|
+
"""List all available skills."""
|
|
221
|
+
fmt = get_formatter(ctx)
|
|
222
|
+
target_dir = Path(install_dir) if install_dir else DEFAULT_INSTALL_DIR
|
|
223
|
+
|
|
224
|
+
skills_to_show = []
|
|
225
|
+
|
|
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:
|
|
238
|
+
skills_to_show.append(skill)
|
|
239
|
+
|
|
240
|
+
if not skills_to_show:
|
|
241
|
+
fmt.print_info("No skills found.")
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
headers = ["Name", "Version", "Description", "Source"]
|
|
245
|
+
rows = [
|
|
246
|
+
[
|
|
247
|
+
s.get("name", ""),
|
|
248
|
+
s.get("version", "-"),
|
|
249
|
+
s.get("description", "")[:50] + ("..." if len(s.get("description", "")) > 50 else ""),
|
|
250
|
+
s.get("source", ""),
|
|
251
|
+
]
|
|
252
|
+
for s in skills_to_show
|
|
253
|
+
]
|
|
254
|
+
fmt.print_table(headers, rows, title="Jcli Skills")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ------------------------------------------------------------------
|
|
258
|
+
# install
|
|
259
|
+
# ------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@skills_group.command("install")
|
|
263
|
+
@click.argument("name", required=False)
|
|
264
|
+
@click.option("--all", "-a", "install_all", is_flag=True, help="Install all bundled skills.")
|
|
265
|
+
@click.option("--force", "-f", is_flag=True, help="Force install (overwrite existing).")
|
|
266
|
+
@click.option("--dir", "install_dir", type=click.Path(exists=False), help="Custom install directory.")
|
|
267
|
+
@click.pass_context
|
|
268
|
+
def install_cmd(ctx: click.Context, name: str | None, install_all: bool, force: bool, install_dir: str | None) -> None:
|
|
269
|
+
"""Install a skill.
|
|
270
|
+
|
|
271
|
+
NAME is the skill name to install (from bundled skills).
|
|
272
|
+
Use -a/--all to install all bundled skills.
|
|
273
|
+
"""
|
|
274
|
+
fmt = get_formatter(ctx)
|
|
275
|
+
target_dir = Path(install_dir) if install_dir else DEFAULT_INSTALL_DIR
|
|
276
|
+
|
|
277
|
+
if not name and not install_all:
|
|
278
|
+
raise click.UsageError("Must specify either NAME or -a/--all")
|
|
279
|
+
|
|
280
|
+
if install_all:
|
|
281
|
+
bundled = get_bundled_skills()
|
|
282
|
+
success_count = 0
|
|
283
|
+
skip_count = 0
|
|
284
|
+
fail_count = 0
|
|
285
|
+
|
|
286
|
+
for skill in bundled:
|
|
287
|
+
skill_name = skill["name"]
|
|
288
|
+
try:
|
|
289
|
+
install_skill(skill_name, target_dir, force)
|
|
290
|
+
fmt.print_success(f"Skill '{skill_name}' installed")
|
|
291
|
+
success_count += 1
|
|
292
|
+
except click.ClickException as exc:
|
|
293
|
+
if "already installed" in str(exc):
|
|
294
|
+
fmt.print_info(f"Skill '{skill_name}' already installed, skipping")
|
|
295
|
+
skip_count += 1
|
|
296
|
+
else:
|
|
297
|
+
fmt.print_error(f"Skill '{skill_name}': {exc}")
|
|
298
|
+
fail_count += 1
|
|
299
|
+
|
|
300
|
+
fmt.console.print(f"\n[bold]Summary:[/bold] {success_count} installed, {skip_count} skipped, {fail_count} failed")
|
|
301
|
+
return
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
install_skill(name, target_dir, force)
|
|
305
|
+
except click.ClickException:
|
|
306
|
+
raise
|
|
307
|
+
except Exception as exc:
|
|
308
|
+
fmt.print_error(f"Failed to install skill '{name}': {exc}")
|
|
309
|
+
raise SystemExit(1) from exc
|
|
310
|
+
|
|
311
|
+
fmt.print_success(f"Skill '{name}' installed to {target_dir / name}")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# ------------------------------------------------------------------
|
|
315
|
+
# uninstall
|
|
316
|
+
# ------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@skills_group.command("uninstall")
|
|
320
|
+
@click.argument("name")
|
|
321
|
+
@click.option("--dir", "install_dir", type=click.Path(exists=False), help="Custom install directory.")
|
|
322
|
+
@click.pass_context
|
|
323
|
+
def uninstall_cmd(ctx: click.Context, name: str, install_dir: str | None) -> None:
|
|
324
|
+
"""Uninstall a skill.
|
|
325
|
+
|
|
326
|
+
NAME is the skill name to uninstall.
|
|
327
|
+
"""
|
|
328
|
+
fmt = get_formatter(ctx)
|
|
329
|
+
target_dir = Path(install_dir) if install_dir else DEFAULT_INSTALL_DIR
|
|
330
|
+
|
|
331
|
+
try:
|
|
332
|
+
uninstall_skill(name, target_dir)
|
|
333
|
+
except click.ClickException:
|
|
334
|
+
raise
|
|
335
|
+
except Exception as exc:
|
|
336
|
+
fmt.print_error(f"Failed to uninstall skill '{name}': {exc}")
|
|
337
|
+
raise SystemExit(1) from exc
|
|
338
|
+
|
|
339
|
+
fmt.print_success(f"Skill '{name}' uninstalled from {target_dir}")
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# ------------------------------------------------------------------
|
|
343
|
+
# get
|
|
344
|
+
# ------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@skills_group.command("get")
|
|
348
|
+
@click.argument("name")
|
|
349
|
+
@click.option("--installed", "-i", is_flag=True, help="Get from installed skills.")
|
|
350
|
+
@click.option("--dir", "install_dir", type=click.Path(exists=False), help="Custom install directory.")
|
|
351
|
+
@click.pass_context
|
|
352
|
+
def get_cmd(ctx: click.Context, name: str, installed: bool, install_dir: str | None) -> None:
|
|
353
|
+
"""Show skill details and SKILL.md content."""
|
|
354
|
+
fmt = get_formatter(ctx)
|
|
355
|
+
target_dir = Path(install_dir) if install_dir else DEFAULT_INSTALL_DIR
|
|
356
|
+
|
|
357
|
+
if installed:
|
|
358
|
+
skill_dir = target_dir / name
|
|
359
|
+
else:
|
|
360
|
+
skill_dir = find_skill_dir(name)
|
|
361
|
+
|
|
362
|
+
if not skill_dir or not skill_dir.exists():
|
|
363
|
+
fmt.print_error(f"Skill '{name}' not found.")
|
|
364
|
+
raise SystemExit(1)
|
|
365
|
+
|
|
366
|
+
# Read and display SKILL.md
|
|
367
|
+
skill_md = skill_dir / "SKILL.md"
|
|
368
|
+
if not skill_md.exists():
|
|
369
|
+
fmt.print_error(f"Skill '{name}' does not have a SKILL.md file.")
|
|
370
|
+
raise SystemExit(1)
|
|
371
|
+
|
|
372
|
+
try:
|
|
373
|
+
content = skill_md.read_text(encoding="utf-8")
|
|
374
|
+
except OSError as exc:
|
|
375
|
+
fmt.print_error(f"Failed to read SKILL.md: {exc}")
|
|
376
|
+
raise SystemExit(1) from exc
|
|
377
|
+
|
|
378
|
+
# Print metadata
|
|
379
|
+
metadata = parse_skill_metadata(skill_dir)
|
|
380
|
+
fmt.console.print(f"\n[bold]Skill:[/bold] {metadata['name']}")
|
|
381
|
+
if metadata["version"]:
|
|
382
|
+
fmt.console.print(f"[bold]Version:[/bold] {metadata['version']}")
|
|
383
|
+
if metadata["description"]:
|
|
384
|
+
fmt.console.print(f"[bold]Description:[/bold] {metadata['description']}")
|
|
385
|
+
fmt.console.print(f"[bold]Path:[/bold] {metadata['path']}")
|
|
386
|
+
fmt.console.print("\n" + "=" * 60 + "\n")
|
|
387
|
+
|
|
388
|
+
# Print content
|
|
389
|
+
fmt.console.print(content)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
# ------------------------------------------------------------------
|
|
393
|
+
# Registration
|
|
394
|
+
# ------------------------------------------------------------------
|
|
395
|
+
|
|
396
|
+
|
|
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)
|
jcli/plugins/system.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Jenkins System management commands."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from jcli.cli_helpers import get_client, get_formatter
|
|
8
|
+
from jcli.sdk.client import JenkinsClient
|
|
9
|
+
from jcli.sdk.config import Config
|
|
10
|
+
from jcli.sdk.exceptions import JenkinsConfigError
|
|
11
|
+
from jcli.sdk.system import (
|
|
12
|
+
cancel_quiet_down,
|
|
13
|
+
generate_token,
|
|
14
|
+
get_system_info,
|
|
15
|
+
get_system_load,
|
|
16
|
+
list_users,
|
|
17
|
+
quiet_down,
|
|
18
|
+
run_script,
|
|
19
|
+
safe_restart,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@click.group("system", help="Jenkins system information and management.")
|
|
26
|
+
def system_group():
|
|
27
|
+
"""System management commands."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@system_group.command("info")
|
|
31
|
+
@click.pass_context
|
|
32
|
+
def system_info(ctx: click.Context):
|
|
33
|
+
"""Show Jenkins system information."""
|
|
34
|
+
formatter = get_formatter(ctx)
|
|
35
|
+
client = get_client(ctx)
|
|
36
|
+
|
|
37
|
+
info = get_system_info(client)
|
|
38
|
+
|
|
39
|
+
formatter.print_info(f"Jenkins {info['version']}")
|
|
40
|
+
formatter.print_info(f"Mode: {info['mode']}")
|
|
41
|
+
formatter.print_info(f"Quieting down: {info['quietingDown']}")
|
|
42
|
+
|
|
43
|
+
rows = [
|
|
44
|
+
("Executors", str(info["num_executors"])),
|
|
45
|
+
("Nodes", str(info["num_nodes"])),
|
|
46
|
+
("Jobs", str(info["num_jobs"])),
|
|
47
|
+
("Slave Agent Port", str(info["slaveAgentPort"])),
|
|
48
|
+
]
|
|
49
|
+
formatter.print_table(
|
|
50
|
+
headers=("Property", "Value"),
|
|
51
|
+
rows=rows,
|
|
52
|
+
title="System Overview",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@system_group.command("restart")
|
|
57
|
+
@click.pass_context
|
|
58
|
+
def system_restart(ctx: click.Context):
|
|
59
|
+
"""Trigger a safe restart of Jenkins."""
|
|
60
|
+
formatter = get_formatter(ctx)
|
|
61
|
+
client = get_client(ctx)
|
|
62
|
+
|
|
63
|
+
if safe_restart(client):
|
|
64
|
+
formatter.print_success("Safe restart initiated.")
|
|
65
|
+
else:
|
|
66
|
+
formatter.print_error("Failed to initiate safe restart.")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@system_group.command("quiet-down")
|
|
70
|
+
@click.option("--reason", "-r", default="", help="Reason for quiet-down.")
|
|
71
|
+
@click.pass_context
|
|
72
|
+
def system_quiet_down(ctx: click.Context, reason: str):
|
|
73
|
+
"""Put Jenkins into quiet-down mode."""
|
|
74
|
+
formatter = get_formatter(ctx)
|
|
75
|
+
client = get_client(ctx)
|
|
76
|
+
|
|
77
|
+
if quiet_down(client, reason=reason):
|
|
78
|
+
formatter.print_success("Jenkins is now in quiet-down mode.")
|
|
79
|
+
if reason:
|
|
80
|
+
formatter.print_info(f"Reason: {reason}")
|
|
81
|
+
else:
|
|
82
|
+
formatter.print_error("Failed to enter quiet-down mode.")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@system_group.command("cancel-quiet-down")
|
|
86
|
+
@click.pass_context
|
|
87
|
+
def system_cancel_quiet_down(ctx: click.Context):
|
|
88
|
+
"""Cancel quiet-down mode."""
|
|
89
|
+
formatter = get_formatter(ctx)
|
|
90
|
+
client = get_client(ctx)
|
|
91
|
+
|
|
92
|
+
if cancel_quiet_down(client):
|
|
93
|
+
formatter.print_success("Quiet-down mode cancelled.")
|
|
94
|
+
else:
|
|
95
|
+
formatter.print_error("Failed to cancel quiet-down mode.")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@system_group.command("load")
|
|
99
|
+
@click.pass_context
|
|
100
|
+
def system_load(ctx: click.Context):
|
|
101
|
+
"""Show current system load (queue length, executor usage)."""
|
|
102
|
+
formatter = get_formatter(ctx)
|
|
103
|
+
client = get_client(ctx)
|
|
104
|
+
|
|
105
|
+
load = get_system_load(client)
|
|
106
|
+
|
|
107
|
+
rows = [
|
|
108
|
+
("Queue Length", str(load["queue_length"])),
|
|
109
|
+
("Total Executors", str(load["total_executors"])),
|
|
110
|
+
("Busy Executors", str(load["busy_executors"])),
|
|
111
|
+
("Idle Executors", str(load["idle_executors"])),
|
|
112
|
+
]
|
|
113
|
+
formatter.print_table(
|
|
114
|
+
headers=("Metric", "Value"),
|
|
115
|
+
rows=rows,
|
|
116
|
+
title="System Load",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@system_group.command("script")
|
|
121
|
+
@click.argument("script")
|
|
122
|
+
@click.pass_context
|
|
123
|
+
def script_cmd(ctx: click.Context, script: str):
|
|
124
|
+
"""Execute a Groovy script on the Jenkins server.
|
|
125
|
+
|
|
126
|
+
SCRIPT is the Groovy script text to execute.
|
|
127
|
+
"""
|
|
128
|
+
client = get_client(ctx)
|
|
129
|
+
fmt = get_formatter(ctx)
|
|
130
|
+
try:
|
|
131
|
+
result = run_script(client, script)
|
|
132
|
+
print(result)
|
|
133
|
+
except Exception as exc:
|
|
134
|
+
fmt.print_error(str(exc))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@system_group.command("users")
|
|
138
|
+
@click.pass_context
|
|
139
|
+
def users_cmd(ctx: click.Context):
|
|
140
|
+
"""List all Jenkins users."""
|
|
141
|
+
client = get_client(ctx)
|
|
142
|
+
fmt = get_formatter(ctx)
|
|
143
|
+
try:
|
|
144
|
+
data = list_users(client)
|
|
145
|
+
users = data.get("users", [])
|
|
146
|
+
if not users:
|
|
147
|
+
fmt.print_info("No users found.")
|
|
148
|
+
return
|
|
149
|
+
user_ids = [u["user"]["fullName"] for u in users]
|
|
150
|
+
fmt.print_json(user_ids)
|
|
151
|
+
except Exception as exc:
|
|
152
|
+
fmt.print_error(str(exc))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@system_group.command("token")
|
|
156
|
+
@click.argument("username")
|
|
157
|
+
@click.option("--name", "-n", default="jcli-generated", help="Token name/label.")
|
|
158
|
+
@click.pass_context
|
|
159
|
+
def token_cmd(ctx: click.Context, username: str, name: str):
|
|
160
|
+
"""Generate a new API token for a user."""
|
|
161
|
+
client = get_client(ctx)
|
|
162
|
+
fmt = get_formatter(ctx)
|
|
163
|
+
try:
|
|
164
|
+
result = generate_token(client, username, token_name=name)
|
|
165
|
+
fmt.print_json(result)
|
|
166
|
+
fmt.print_success(f"Token generated for user '{username}'.")
|
|
167
|
+
except Exception as exc:
|
|
168
|
+
fmt.print_error(str(exc))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def register(parent_group):
|
|
172
|
+
"""Register the system subgroup under the parent Click group."""
|
|
173
|
+
parent_group.add_command(system_group)
|