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 ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
jcli/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from jcli.cli import main
2
+
3
+ main()
jcli/cli.py ADDED
@@ -0,0 +1,79 @@
1
+ """Jenkins CLI - Command line interface for managing Jenkins."""
2
+
3
+ import logging
4
+ import sys
5
+
6
+ import click
7
+
8
+ from jcli import __version__
9
+ from jcli.plugins import register_commands
10
+
11
+ logger = logging.getLogger("jcli")
12
+
13
+
14
+ @click.group()
15
+ @click.version_option(version=__version__, prog_name="jcli")
16
+ @click.option(
17
+ "-f",
18
+ "--format",
19
+ "output_format",
20
+ default="table",
21
+ type=click.Choice(["table", "json", "yaml"]),
22
+ help="Output format.",
23
+ )
24
+ @click.option("-p", "--profile", default=None, help="Configuration profile name.")
25
+ @click.option("-s", "--server", default=None, help="Jenkins server URL.")
26
+ @click.option("-d", "--debug", is_flag=True, default=False, help="Enable debug output.")
27
+ @click.pass_context
28
+ def cli(ctx, output_format, profile, server, debug):
29
+ """Jenkins CLI - Manage Jenkins from the command line."""
30
+ ctx.ensure_object(dict)
31
+ ctx.obj["format"] = output_format
32
+ ctx.obj["profile"] = profile
33
+ ctx.obj["server"] = server
34
+ ctx.obj["debug"] = debug
35
+
36
+ # Configure debug logging when --debug / -d is set
37
+ if debug:
38
+ logging.basicConfig(
39
+ level=logging.DEBUG,
40
+ format="%(asctime)s %(name)s %(levelname)s %(message)s",
41
+ stream=sys.stderr,
42
+ )
43
+ logger.debug("Debug logging enabled")
44
+ else:
45
+ # Ensure WARNING+ level when not in debug mode
46
+ logging.basicConfig(level=logging.WARNING, stream=sys.stderr)
47
+
48
+
49
+ # Register all plugin subcommands
50
+ register_commands(cli)
51
+
52
+
53
+ @click.group()
54
+ def completion():
55
+ """Shell completion support for jcli."""
56
+ pass
57
+
58
+
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
+
65
+ shell_cls = {"bash": BashComplete, "zsh": ZshComplete, "fish": FishComplete}
66
+ complete = shell_cls[shell](cli, {}, "jcli", "_JCLI_COMPLETE")
67
+ click.echo(complete.source(), nl=False)
68
+
69
+
70
+ cli.add_command(completion)
71
+
72
+
73
+ def main():
74
+ """CLI entry point."""
75
+ cli()
76
+
77
+
78
+ if __name__ == "__main__":
79
+ main()
jcli/cli_helpers.py ADDED
@@ -0,0 +1,58 @@
1
+ """Centralized CLI helpers for creating JenkinsClient and OutputFormatter.
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.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+
12
+ import click
13
+
14
+ from jcli.sdk.client import JenkinsClient
15
+ from jcli.sdk.config import Config
16
+ from jcli.sdk.output import OutputFormatter
17
+
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
+
53
+ def get_formatter(ctx: click.Context) -> OutputFormatter:
54
+ """Return an ``OutputFormatter`` matching the ``--format`` flag."""
55
+ obj = ctx.obj or {}
56
+ fmt = obj.get("format", "table")
57
+
58
+ return OutputFormatter(format_type=fmt)
@@ -0,0 +1,28 @@
1
+ """Jenkins CLI plugins - register all subcommands."""
2
+
3
+ import click
4
+
5
+ from jcli.plugins.build import register as register_build
6
+ from jcli.plugins.config import register as register_config
7
+ from jcli.plugins.credential import register as register_credential
8
+ from jcli.plugins.job import register as register_job
9
+ from jcli.plugins.node import register as register_node
10
+ from jcli.plugins.pipeline import register as register_pipeline
11
+ from jcli.plugins.plugin import register as register_plugin
12
+ from jcli.plugins.skills import register as register_skills
13
+ from jcli.plugins.system import register as register_system
14
+ from jcli.plugins.view import register as register_view
15
+
16
+
17
+ def register_commands(group: click.Group) -> None:
18
+ """Register all plugin subcommands under the main CLI group."""
19
+ register_build(group)
20
+ register_config(group)
21
+ register_credential(group)
22
+ register_job(group)
23
+ register_node(group)
24
+ register_pipeline(group)
25
+ register_plugin(group)
26
+ register_skills(group)
27
+ register_system(group)
28
+ register_view(group)
jcli/plugins/build.py ADDED
@@ -0,0 +1,197 @@
1
+ """Jenkins Build management commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from jcli.cli_helpers import get_client, get_formatter
8
+ from jcli.sdk.build import (
9
+ cancel_queue_item,
10
+ get_build,
11
+ get_build_artifacts,
12
+ get_build_log,
13
+ get_builds,
14
+ get_queue,
15
+ stop_build,
16
+ trigger_build,
17
+ )
18
+ from jcli.sdk.exceptions import JenkinsError
19
+
20
+
21
+ def _parse_params(raw_params: tuple[str, ...]) -> dict[str, str]:
22
+ """Parse ``KEY=VAL`` strings into a dict."""
23
+ params: dict[str, str] = {}
24
+ for item in raw_params:
25
+ if "=" not in item:
26
+ raise click.BadParameter(f"Invalid parameter format: {item} (expected KEY=VAL)")
27
+ key, _, value = item.partition("=")
28
+ params[key.strip()] = value.strip()
29
+ return params
30
+
31
+
32
+ # ------------------------------------------------------------------
33
+ # Build group
34
+ # ------------------------------------------------------------------
35
+
36
+
37
+ @click.group("build", help="Manage Jenkins builds.")
38
+ def build_group():
39
+ """Build management commands."""
40
+
41
+
42
+ @build_group.command("trigger")
43
+ @click.argument("job_name")
44
+ @click.option(
45
+ "--params", "-p",
46
+ multiple=True,
47
+ help="Build parameters as KEY=VAL pairs. Repeatable.",
48
+ )
49
+ @click.pass_context
50
+ def trigger(ctx: click.Context, job_name: str, params: tuple[str, ...]):
51
+ """Trigger a new build for JOB_NAME.
52
+
53
+ Use --params/-p KEY=VAL to pass build parameters (repeat for multiple).
54
+ """
55
+ client = get_client(ctx)
56
+ fmt = get_formatter(ctx)
57
+ try:
58
+ param_dict = _parse_params(params) if params else None
59
+ result = trigger_build(client, job_name, param_dict)
60
+ fmt.print_success(f"Build triggered for {job_name}")
61
+ fmt.print_json(result)
62
+ except JenkinsError as exc:
63
+ fmt.print_error(str(exc))
64
+ raise SystemExit(1)
65
+
66
+
67
+ @build_group.command("list")
68
+ @click.argument("job_name")
69
+ @click.option("--limit", "-n", default=10, type=int, help="Maximum builds to show.")
70
+ @click.pass_context
71
+ def list_builds(ctx: click.Context, job_name: str, limit: int):
72
+ """List recent builds for JOB_NAME."""
73
+ client = get_client(ctx)
74
+ fmt = get_formatter(ctx)
75
+ try:
76
+ builds = get_builds(client, job_name, limit=limit)
77
+ if not builds:
78
+ fmt.print_info(f"No builds found for {job_name}")
79
+ return
80
+
81
+ headers = ["Number", "Result", "Timestamp", "Duration", "Building"]
82
+ rows = [
83
+ [
84
+ b.get("number", ""),
85
+ str(b.get("result") or "IN PROGRESS"),
86
+ str(b.get("timestamp", "")),
87
+ str(b.get("duration", "")),
88
+ str(b.get("building", False)),
89
+ ]
90
+ for b in builds
91
+ ]
92
+ fmt.print_table(headers, rows, title=f"Builds for {job_name}")
93
+ except JenkinsError as exc:
94
+ fmt.print_error(str(exc))
95
+ raise SystemExit(1)
96
+
97
+
98
+ @build_group.command("get")
99
+ @click.argument("job_name")
100
+ @click.argument("number", type=int)
101
+ @click.pass_context
102
+ def get_build_cmd(ctx: click.Context, job_name: str, number: int):
103
+ """Show details for a specific build."""
104
+ client = get_client(ctx)
105
+ fmt = get_formatter(ctx)
106
+ try:
107
+ build = get_build(client, job_name, number)
108
+ fmt.print_json(build)
109
+ except JenkinsError as exc:
110
+ fmt.print_error(str(exc))
111
+ raise SystemExit(1)
112
+
113
+
114
+ @build_group.command("log")
115
+ @click.argument("job_name")
116
+ @click.argument("number", type=int)
117
+ @click.pass_context
118
+ def log_cmd(ctx: click.Context, job_name: str, number: int):
119
+ """Show console log for a build."""
120
+ client = get_client(ctx)
121
+ fmt = get_formatter(ctx)
122
+ try:
123
+ log_text = get_build_log(client, job_name, number)
124
+ fmt.console.print(log_text)
125
+ except JenkinsError as exc:
126
+ fmt.print_error(str(exc))
127
+ raise SystemExit(1)
128
+
129
+
130
+ @build_group.command("stop")
131
+ @click.argument("job_name")
132
+ @click.argument("number", type=int)
133
+ @click.pass_context
134
+ def stop_cmd(ctx: click.Context, job_name: str, number: int):
135
+ """Stop a running build."""
136
+ client = get_client(ctx)
137
+ fmt = get_formatter(ctx)
138
+ try:
139
+ stop_build(client, job_name, number)
140
+ fmt.print_success(f"Build {job_name}#{number} stopped")
141
+ except JenkinsError as exc:
142
+ fmt.print_error(str(exc))
143
+ raise SystemExit(1)
144
+
145
+
146
+ @build_group.command("queue")
147
+ @click.pass_context
148
+ def queue_cmd(ctx: click.Context):
149
+ """Show the current Jenkins build queue."""
150
+ client = get_client(ctx)
151
+ fmt = get_formatter(ctx)
152
+ try:
153
+ items = get_queue(client)
154
+ if not items:
155
+ fmt.print_info("Build queue is empty")
156
+ return
157
+
158
+ headers = ["ID", "Task", "Why", "In Queue Since"]
159
+ rows = [
160
+ [
161
+ str(item.get("id", "")),
162
+ str(item.get("task", {}).get("name", "")),
163
+ str(item.get("why", "")),
164
+ str(item.get("inQueueSince", "")),
165
+ ]
166
+ for item in items
167
+ ]
168
+ fmt.print_table(headers, rows, title="Build Queue")
169
+ except JenkinsError as exc:
170
+ fmt.print_error(str(exc))
171
+ raise SystemExit(1)
172
+
173
+
174
+ @build_group.command("artifacts")
175
+ @click.argument("job_name")
176
+ @click.argument("build_number")
177
+ @click.pass_context
178
+ def artifacts_cmd(ctx: click.Context, job_name: str, build_number: str):
179
+ """List artifacts for a build."""
180
+ client = get_client(ctx)
181
+ fmt = get_formatter(ctx)
182
+ try:
183
+ data = get_build_artifacts(client, job_name, build_number)
184
+ artifacts = data.get("artifacts", [])
185
+ if not artifacts:
186
+ fmt.print_info("No artifacts found.")
187
+ return
188
+ headers = ["File Name", "Relative Path"]
189
+ rows = [[a["fileName"], a["relativePath"]] for a in artifacts]
190
+ fmt.print_table(headers, rows, title=f"Artifacts: {job_name} #{build_number}")
191
+ except Exception as exc:
192
+ fmt.print_error(str(exc))
193
+
194
+
195
+ def register(parent_group: click.Group):
196
+ """Register the build subgroup under the parent Click group."""
197
+ parent_group.add_command(build_group)
jcli/plugins/config.py ADDED
@@ -0,0 +1,268 @@
1
+ """Configuration management CLI commands for jcli."""
2
+
3
+ import click
4
+
5
+ from jcli.sdk.config import Config, ProfileNotFoundError
6
+ from jcli.sdk.output.formatter import OutputFormatter
7
+
8
+
9
+ def _get_config(ctx: click.Context) -> Config:
10
+ """Get or create Config from Click context."""
11
+ if "config" not in ctx.obj:
12
+ ctx.obj["config"] = Config().load()
13
+ return ctx.obj["config"]
14
+
15
+
16
+ def _get_formatter(ctx: click.Context) -> OutputFormatter:
17
+ """Get or create OutputFormatter from Click context."""
18
+ if "formatter" not in ctx.obj:
19
+ fmt = ctx.obj.get("format", "table")
20
+ ctx.obj["formatter"] = OutputFormatter(fmt)
21
+ return ctx.obj["formatter"]
22
+
23
+
24
+ @click.group()
25
+ def config_group():
26
+ """Manage jcli configuration."""
27
+ pass
28
+
29
+
30
+ @config_group.command("init")
31
+ @click.option("--force", is_flag=True, help="Overwrite existing config file")
32
+ @click.pass_context
33
+ def config_init(ctx: click.Context, force: bool):
34
+ """Initialize configuration file with default template."""
35
+ cfg = _get_config(ctx)
36
+ fmt = _get_formatter(ctx)
37
+
38
+ if cfg.config_path.exists() and not force:
39
+ fmt.print_error(f"Config file already exists: {cfg.config_path}")
40
+ fmt.print_info("Use --force to overwrite")
41
+ return
42
+
43
+ # Create fresh config with template
44
+ cfg = Config()
45
+ cfg.load()
46
+ cfg.save()
47
+
48
+ fmt.print_success(f"Config file created: {cfg.config_path}")
49
+ fmt.print_info("Edit it with your Jenkins server details:")
50
+ fmt.print_info(f" url: https://jenkins.example.com")
51
+ fmt.print_info(f" username: admin")
52
+ fmt.print_info(f" api_token: your-api-token-here")
53
+
54
+
55
+ @config_group.command("show")
56
+ @click.option("--profile", "-p", help="Show specific profile (default: active)")
57
+ @click.pass_context
58
+ def config_show(ctx: click.Context, profile: str | None):
59
+ """Show configuration details."""
60
+ cfg = _get_config(ctx)
61
+ fmt = _get_formatter(ctx)
62
+
63
+ if profile:
64
+ try:
65
+ data = cfg.get_profile(profile)
66
+ except ProfileNotFoundError:
67
+ fmt.print_error(f"Profile '{profile}' not found")
68
+ return
69
+ active_name = cfg.get_active_profile_name()
70
+ is_active = " (active)" if profile == active_name else ""
71
+ fmt.print_info(f"Profile: {profile}{is_active}")
72
+ _show_profile(fmt, data)
73
+ else:
74
+ active_name = cfg.get_active_profile_name()
75
+ fmt.print_info(f"Config file: {cfg.config_path}")
76
+ fmt.print_info(f"Active profile: {active_name}")
77
+ fmt.print_info("")
78
+ try:
79
+ data = cfg.get_active_profile()
80
+ _show_profile(fmt, data)
81
+ except ProfileNotFoundError:
82
+ fmt.print_error(f"Active profile '{active_name}' not found")
83
+
84
+
85
+ def _show_profile(fmt: OutputFormatter, data: dict):
86
+ """Display profile fields."""
87
+ fields = [
88
+ ("url", "URL"),
89
+ ("username", "Username"),
90
+ ("api_token", "API Token"),
91
+ ("description", "Description"),
92
+ ]
93
+ for key, label in fields:
94
+ value = data.get(key, "")
95
+ # Mask token for display
96
+ if key == "api_token" and value and len(value) > 8:
97
+ display = value[:4] + "****" + value[-4:]
98
+ else:
99
+ display = value
100
+ fmt.print_info(f" {label}: {display}")
101
+
102
+
103
+ @config_group.command("list")
104
+ @click.pass_context
105
+ def config_list(ctx: click.Context):
106
+ """List all configured profiles."""
107
+ cfg = _get_config(ctx)
108
+ fmt = _get_formatter(ctx)
109
+
110
+ profiles = cfg.list_profiles()
111
+ active_name = cfg.get_active_profile_name()
112
+
113
+ if not profiles:
114
+ fmt.print_info("No profiles configured. Run 'jcli config init' to create one.")
115
+ return
116
+
117
+ headers = ["Name", "URL", "Username", "Active"]
118
+ rows = []
119
+ for name, data in profiles.items():
120
+ active_mark = "✓" if name == active_name else ""
121
+ rows.append([
122
+ name,
123
+ data.get("url", ""),
124
+ data.get("username", ""),
125
+ active_mark,
126
+ ])
127
+
128
+ fmt.print_table(headers, rows, title="Profiles")
129
+
130
+
131
+ @config_group.command("set")
132
+ @click.argument("profile")
133
+ @click.argument("field", type=click.Choice(["url", "username", "api_token", "description"]))
134
+ @click.argument("value")
135
+ @click.pass_context
136
+ def config_set(ctx: click.Context, profile: str, field: str, value: str):
137
+ """Set a configuration field for a profile.
138
+
139
+ Examples:
140
+ jcli config set dev url https://jenkins-dev.example.com
141
+ jcli config set dev username admin
142
+ jcli config set dev api_token abc123
143
+ jcli config set dev description "Development Jenkins"
144
+ """
145
+ cfg = _get_config(ctx)
146
+ fmt = _get_formatter(ctx)
147
+
148
+ try:
149
+ data = cfg.get_profile(profile)
150
+ except ProfileNotFoundError:
151
+ fmt.print_error(f"Profile '{profile}' not found")
152
+ fmt.print_info(f"Create it first with: jcli config add {profile}")
153
+ return
154
+
155
+ # Update the field
156
+ data[field] = value
157
+ cfg.add_profile(
158
+ name=profile,
159
+ url=data.get("url", ""),
160
+ username=data.get("username", ""),
161
+ api_token=data.get("api_token", ""),
162
+ description=data.get("description", ""),
163
+ )
164
+
165
+ fmt.print_success(f"Updated profile '{profile}': {field} = {value}")
166
+
167
+
168
+ @config_group.command("add")
169
+ @click.argument("profile")
170
+ @click.option("--url", "-u", default="", help="Jenkins server URL")
171
+ @click.option("--username", default="", help="Jenkins username")
172
+ @click.option("--api-token", default="", help="Jenkins API token")
173
+ @click.option("--description", "-d", default="", help="Profile description")
174
+ @click.pass_context
175
+ def config_add(
176
+ ctx: click.Context,
177
+ profile: str,
178
+ url: str,
179
+ username: str,
180
+ api_token: str,
181
+ description: str,
182
+ ):
183
+ """Add a new configuration profile.
184
+
185
+ Examples:
186
+ jcli config add dev --url https://jenkins-dev.example.com --username admin
187
+ jcli config add prod -u https://jenkins-prod.example.com -d "Production"
188
+ """
189
+ cfg = _get_config(ctx)
190
+ fmt = _get_formatter(ctx)
191
+
192
+ # Check if profile already exists
193
+ profiles = cfg.list_profiles()
194
+ if profile in profiles:
195
+ fmt.print_error(f"Profile '{profile}' already exists")
196
+ fmt.print_info(f"Use 'jcli config set {profile} <field> <value>' to update")
197
+ return
198
+
199
+ cfg.add_profile(
200
+ name=profile,
201
+ url=url or "https://jenkins.example.com",
202
+ username=username or "admin",
203
+ api_token=api_token or "",
204
+ description=description or f"{profile} Jenkins instance",
205
+ )
206
+
207
+ fmt.print_success(f"Profile '{profile}' added")
208
+ fmt.print_info("Configure it with:")
209
+ fmt.print_info(f" jcli config set {profile} url https://your-jenkins.com")
210
+ fmt.print_info(f" jcli config set {profile} api_token your-token")
211
+
212
+
213
+ @config_group.command("delete")
214
+ @click.argument("profile")
215
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
216
+ @click.pass_context
217
+ def config_delete(ctx: click.Context, profile: str, yes: bool):
218
+ """Delete a configuration profile.
219
+
220
+ Examples:
221
+ jcli config delete dev
222
+ jcli config delete old-server --yes
223
+ """
224
+ cfg = _get_config(ctx)
225
+ fmt = _get_formatter(ctx)
226
+
227
+ profiles = cfg.list_profiles()
228
+ if profile not in profiles:
229
+ fmt.print_error(f"Profile '{profile}' not found")
230
+ return
231
+
232
+ if not yes:
233
+ if not click.confirm(f"Delete profile '{profile}'?"):
234
+ fmt.print_info("Cancelled")
235
+ return
236
+
237
+ cfg.remove_profile(profile)
238
+ fmt.print_success(f"Profile '{profile}' deleted")
239
+
240
+
241
+ @config_group.command("use")
242
+ @click.argument("profile")
243
+ @click.pass_context
244
+ def config_use(ctx: click.Context, profile: str):
245
+ """Switch active profile.
246
+
247
+ Examples:
248
+ jcli config use prod
249
+ jcli config use dev
250
+ """
251
+ cfg = _get_config(ctx)
252
+ fmt = _get_formatter(ctx)
253
+
254
+ try:
255
+ cfg.set_active_profile(profile)
256
+ except ProfileNotFoundError:
257
+ fmt.print_error(f"Profile '{profile}' not found")
258
+ profiles = cfg.list_profiles()
259
+ if profiles:
260
+ fmt.print_info(f"Available profiles: {', '.join(profiles.keys())}")
261
+ return
262
+
263
+ fmt.print_success(f"Active profile set to '{profile}'")
264
+
265
+
266
+ def register(group: click.Group) -> None:
267
+ """Register config commands with the main CLI group."""
268
+ group.add_command(config_group, "config")