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/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)