cal-docs-client 1.0.0b1__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.
cal_docs_client/cli.py ADDED
@@ -0,0 +1,518 @@
1
+ # ────────────────────────────────────────────────────────────────────────────────────────
2
+ # cli.py
3
+ # ──────
4
+ #
5
+ # Command-line interface for cal-docs-client.
6
+ #
7
+ # (c) 2026 Cyber Assessment Labs — MIT License; see LICENSE in the project root.
8
+ #
9
+ # Authors
10
+ # ───────
11
+ # bena (via Claude)
12
+ #
13
+ # Version History
14
+ # ───────────────
15
+ # Feb 2026 - Created
16
+ # ────────────────────────────────────────────────────────────────────────────────────────
17
+
18
+ # ────────────────────────────────────────────────────────────────────────────────────────
19
+ # Imports
20
+ # ────────────────────────────────────────────────────────────────────────────────────────
21
+
22
+ import json
23
+ import os
24
+ import sys
25
+ import traceback
26
+ from collections.abc import Callable
27
+ from pathlib import Path
28
+ from typing import Any
29
+ from typing import cast
30
+ from .argbuilder import ArgsParser
31
+ from .argbuilder import Namespace
32
+ from .client import ClientError
33
+ from .client import DocsClient
34
+ from .common import bold
35
+ from .common import cyan
36
+ from .common import green
37
+ from .common import red
38
+ from .common import set_colours_enabled
39
+ from .common import yellow
40
+ from .version import VERSION_STR
41
+
42
+ # ────────────────────────────────────────────────────────────────────────────────────────
43
+ # Constants
44
+ # ────────────────────────────────────────────────────────────────────────────────────────
45
+
46
+ CONFIG_DIR = Path.home() / ".config" / "cal-docs-client"
47
+ DEFAULT_CONFIG_PATH = CONFIG_DIR / "config.json"
48
+
49
+ # ────────────────────────────────────────────────────────────────────────────────────────
50
+ # Config Loading
51
+ # ────────────────────────────────────────────────────────────────────────────────────────
52
+
53
+
54
+ # ---
55
+ def load_config(config_path: Path | None) -> dict[str, Any]:
56
+ """Load configuration from a JSON file.
57
+
58
+ Args:
59
+ config_path: Path to config file, or None to use default.
60
+
61
+ Returns:
62
+ Configuration dict, or empty dict if not found.
63
+
64
+ Config file format:
65
+ {
66
+ "server": "https://docs.example.com",
67
+ "token": "your-api-token",
68
+ "no_colour": false
69
+ }
70
+ """
71
+ # Determine which config file to use
72
+ if config_path:
73
+ path = config_path
74
+ else:
75
+ path = DEFAULT_CONFIG_PATH
76
+
77
+ if not path.exists():
78
+ return {}
79
+
80
+ try:
81
+ with path.open() as f:
82
+ data = json.load(f)
83
+ if not isinstance(data, dict):
84
+ print(
85
+ yellow(
86
+ f"Warning: Config file {path} is not a JSON object, ignoring"
87
+ ),
88
+ file=sys.stderr,
89
+ )
90
+ return {}
91
+ return cast("dict[str, Any]", data)
92
+ except json.JSONDecodeError as e:
93
+ print(
94
+ yellow(f"Warning: Invalid JSON in config file {path}: {e}"),
95
+ file=sys.stderr,
96
+ )
97
+ return {}
98
+ except OSError as e:
99
+ print(
100
+ yellow(f"Warning: Could not read config file {path}: {e}"),
101
+ file=sys.stderr,
102
+ )
103
+ return {}
104
+
105
+
106
+ # ────────────────────────────────────────────────────────────────────────────────────────
107
+ # Argument Parser
108
+ # ────────────────────────────────────────────────────────────────────────────────────────
109
+
110
+
111
+ # ---
112
+ def create_parser() -> ArgsParser:
113
+ """Create the main argument parser with subcommands."""
114
+ parser = ArgsParser(
115
+ prog="cal-docs-client",
116
+ description="CLI client for cal-docs-server documentation API",
117
+ version=f"cal-docs-client {VERSION_STR}",
118
+ )
119
+
120
+ # =========== Common Options ===========
121
+
122
+ common = parser.create_common_collection()
123
+ common_group = common.add_group("Common Options")
124
+ common_group.add_argument(
125
+ "-s",
126
+ "--server",
127
+ metavar="URL",
128
+ help="Server URL (or set CAL_DOCS_SERVER env var, or config file)",
129
+ )
130
+ common_group.add_argument(
131
+ "-c",
132
+ "--config",
133
+ metavar="FILE",
134
+ type=Path,
135
+ help=f"Config file path (default: {DEFAULT_CONFIG_PATH})",
136
+ )
137
+ common_group.add_argument(
138
+ "--no-colour",
139
+ "--no-color",
140
+ action="store_true",
141
+ dest="no_colour",
142
+ help="Disable coloured output",
143
+ )
144
+ common_group.add_argument(
145
+ "-v",
146
+ "--verbose",
147
+ action="store_true",
148
+ help="Verbose output",
149
+ )
150
+
151
+ # =========== Version Command ===========
152
+
153
+ parser.add_command(
154
+ "version",
155
+ help="Show server and client version",
156
+ description="Show version information for both the server and this client.",
157
+ )
158
+
159
+ # =========== Projects Command ===========
160
+
161
+ projects_cmd = parser.add_command(
162
+ "projects",
163
+ help="List documentation projects",
164
+ description="List all documentation projects on the server.",
165
+ )
166
+ projects_cmd.add_argument(
167
+ "-q",
168
+ "--search",
169
+ metavar="TERM",
170
+ help="Filter projects by name",
171
+ )
172
+ projects_cmd.add_argument(
173
+ "-j",
174
+ "--json",
175
+ action="store_true",
176
+ help="Output as JSON",
177
+ )
178
+
179
+ # =========== Download Command ===========
180
+
181
+ download_cmd = parser.add_command(
182
+ "download",
183
+ help="Download documentation",
184
+ description="Download a documentation package as a zip file.",
185
+ )
186
+ download_cmd.add_argument(
187
+ "project",
188
+ help="Project name",
189
+ )
190
+ download_cmd.add_argument(
191
+ "doc_version",
192
+ nargs="?",
193
+ default="latest",
194
+ metavar="VERSION",
195
+ help="Version to download (default: latest)",
196
+ )
197
+ download_cmd.add_argument(
198
+ "-o",
199
+ "--output",
200
+ metavar="FILE",
201
+ help="Output filename (default: {project}-{version}-docs.zip)",
202
+ )
203
+
204
+ # =========== Upload Command ===========
205
+
206
+ upload_cmd = parser.add_command(
207
+ "upload",
208
+ help="Upload documentation",
209
+ description=(
210
+ "Upload a documentation package to the server.\nRequires authentication via"
211
+ " --token, CAL_DOCS_TOKEN env var, or config file.\nFilename must match:"
212
+ " {project}-{version}[-docs].zip"
213
+ ),
214
+ )
215
+ upload_cmd.add_argument(
216
+ "file",
217
+ help="Zip file to upload",
218
+ )
219
+ upload_cmd.add_argument(
220
+ "-t",
221
+ "--token",
222
+ metavar="TOKEN",
223
+ help="Auth token (or set CAL_DOCS_TOKEN env var, or config file)",
224
+ )
225
+
226
+ # =========== Help Command ===========
227
+
228
+ parser.add_command(
229
+ "help",
230
+ help="Show server API help",
231
+ description="Fetch and display the server's API help documentation.",
232
+ )
233
+
234
+ # =========== Spec Command ===========
235
+
236
+ spec_cmd = parser.add_command(
237
+ "spec",
238
+ help="Show OpenAPI specification",
239
+ description="Fetch and display the server's OpenAPI specification.",
240
+ )
241
+ spec_cmd.add_argument(
242
+ "-o",
243
+ "--output",
244
+ metavar="FILE",
245
+ help="Save specification to file",
246
+ )
247
+
248
+ return parser
249
+
250
+
251
+ # ────────────────────────────────────────────────────────────────────────────────────────
252
+ # Command Handlers
253
+ # ────────────────────────────────────────────────────────────────────────────────────────
254
+
255
+
256
+ # ---
257
+ def cmd_version(client: DocsClient, _args: Namespace) -> int:
258
+ """Handle the 'version' command."""
259
+ client.verify_server()
260
+
261
+ print(
262
+ f"{bold('Server:')} cal-docs-server {client.server_version} (API"
263
+ f" v{client.api_version})"
264
+ )
265
+ print(f"{bold('Client:')} cal-docs-client {VERSION_STR}")
266
+ return 0
267
+
268
+
269
+ # ---
270
+ def cmd_projects(client: DocsClient, args: Namespace) -> int:
271
+ """Handle the 'projects' command."""
272
+ client.verify_server()
273
+
274
+ data = client.get_projects(args.search)
275
+
276
+ if args.json:
277
+ print(json.dumps(data, indent=2))
278
+ return 0
279
+
280
+ projects = data.get("projects", [])
281
+ count = data.get("count", len(projects))
282
+
283
+ if args.search:
284
+ print(
285
+ f"{bold('Projects')} matching '{args.search}' on {cyan(client.server_url)}"
286
+ f" ({count} found):\n"
287
+ )
288
+ else:
289
+ print(f"{bold('Projects')} on {cyan(client.server_url)} ({count} found):\n")
290
+
291
+ if not projects:
292
+ print(yellow(" No projects found."))
293
+ return 0
294
+
295
+ for proj in projects:
296
+ name = proj.get("name", proj.get("directory_name", "unknown"))
297
+ desc = proj.get("description", "")
298
+ versions = proj.get("versions", [])
299
+ latest = proj.get("latest_version_dir")
300
+
301
+ print(f" {bold(name)}")
302
+ if desc:
303
+ print(f" {desc}")
304
+
305
+ if versions:
306
+ version_strs = [v.get("version", "?") for v in versions[:5]]
307
+ if len(versions) > 5:
308
+ version_strs.append(f"... ({len(versions)} total)")
309
+ print(f" Versions: {', '.join(version_strs)}")
310
+
311
+ if latest:
312
+ print(
313
+ " Latest:"
314
+ f" {green(latest.rsplit('-', 1)[-1] if '-' in latest else latest)}"
315
+ )
316
+
317
+ print()
318
+
319
+ return 0
320
+
321
+
322
+ # ---
323
+ def cmd_download(client: DocsClient, args: Namespace) -> int:
324
+ """Handle the 'download' command."""
325
+ client.verify_server()
326
+
327
+ project = args.project
328
+ version = args.doc_version
329
+
330
+ # Determine output filename
331
+ if args.output:
332
+ output_path = Path(args.output)
333
+ else:
334
+ output_path = Path(f"{project}-{version}-docs.zip")
335
+
336
+ print(f"Downloading {cyan(project)} version {cyan(version)}...")
337
+
338
+ try:
339
+ data = client.download(project, version)
340
+ except ClientError as e:
341
+ print(red(f"Error: {e}"), file=sys.stderr)
342
+ return 1
343
+
344
+ output_path.write_bytes(data)
345
+ print(f"Saved to {green(str(output_path))} ({len(data):,} bytes)")
346
+ return 0
347
+
348
+
349
+ # ---
350
+ def cmd_upload(client: DocsClient, args: Namespace) -> int:
351
+ """Handle the 'upload' command."""
352
+ client.verify_server()
353
+
354
+ file_path = Path(args.file)
355
+
356
+ if not file_path.exists():
357
+ print(red(f"Error: File not found: {file_path}"), file=sys.stderr)
358
+ return 1
359
+
360
+ if not file_path.suffix.lower() == ".zip":
361
+ print(red("Error: File must be a .zip file"), file=sys.stderr)
362
+ return 1
363
+
364
+ if not client.token:
365
+ print(
366
+ red(
367
+ "Error: Authentication required. Set --token, CAL_DOCS_TOKEN env var,"
368
+ " or add token to config file."
369
+ ),
370
+ file=sys.stderr,
371
+ )
372
+ return 1
373
+
374
+ filename = file_path.name
375
+ data = file_path.read_bytes()
376
+
377
+ print(f"Uploading {cyan(filename)} ({len(data):,} bytes)...")
378
+
379
+ try:
380
+ result = client.upload(filename, data)
381
+ except ClientError as e:
382
+ print(red(f"Error: {e}"), file=sys.stderr)
383
+ return 1
384
+
385
+ if result.get("success"):
386
+ print(green("Upload successful!"))
387
+ print(f" Project: {result.get('project', 'unknown')}")
388
+ print(f" Version: {result.get('version', 'unknown')}")
389
+ print(f" URL: {result.get('url', 'N/A')}")
390
+ print(f" Files: {result.get('files_extracted', 'N/A')}")
391
+ else:
392
+ print(
393
+ red(f"Upload failed: {result.get('message', 'Unknown error')}"),
394
+ file=sys.stderr,
395
+ )
396
+ return 1
397
+
398
+ return 0
399
+
400
+
401
+ # ---
402
+ def cmd_help(client: DocsClient, _args: Namespace) -> int:
403
+ """Handle the 'help' command (server API help)."""
404
+ client.verify_server()
405
+
406
+ help_text = client.get_help()
407
+ print(help_text)
408
+ return 0
409
+
410
+
411
+ # ---
412
+ def cmd_spec(client: DocsClient, args: Namespace) -> int:
413
+ """Handle the 'spec' command."""
414
+ client.verify_server()
415
+
416
+ spec = client.get_spec()
417
+ output = json.dumps(spec, indent=2)
418
+
419
+ if args.output:
420
+ Path(args.output).write_text(output)
421
+ print(f"Specification saved to {green(args.output)}")
422
+ else:
423
+ print(output)
424
+
425
+ return 0
426
+
427
+
428
+ # ────────────────────────────────────────────────────────────────────────────────────────
429
+ # Main Entry Point
430
+ # ────────────────────────────────────────────────────────────────────────────────────────
431
+
432
+ CommandHandler = Callable[[DocsClient, Namespace], int]
433
+
434
+ COMMANDS: dict[str, CommandHandler] = {
435
+ "version": cmd_version,
436
+ "projects": cmd_projects,
437
+ "download": cmd_download,
438
+ "upload": cmd_upload,
439
+ "help": cmd_help,
440
+ "spec": cmd_spec,
441
+ }
442
+
443
+
444
+ # ---
445
+ def main(argv: list[str] | None = None) -> int:
446
+ """Main entry point."""
447
+ if argv is None:
448
+ argv = sys.argv[1:]
449
+
450
+ try:
451
+ return _main_inner(argv)
452
+ except KeyboardInterrupt:
453
+ print()
454
+ print("---- Manually Terminated ----")
455
+ print()
456
+ return 1
457
+ except SystemExit as e:
458
+ return e.code if isinstance(e.code, int) else 1
459
+ except BaseException as e:
460
+ print("-" * 77, file=sys.stderr)
461
+ print("UNHANDLED EXCEPTION OCCURRED!!", file=sys.stderr)
462
+ print(file=sys.stderr)
463
+ print(traceback.format_exc(), file=sys.stderr)
464
+ print(f"EXCEPTION: {type(e).__name__}: {e}", file=sys.stderr)
465
+ print("-" * 77, file=sys.stderr)
466
+ return 1
467
+
468
+
469
+ # ---
470
+ def _main_inner(argv: list[str]) -> int:
471
+ """Inner main function that may raise exceptions."""
472
+ parser = create_parser()
473
+ args = parser.parse(argv)
474
+
475
+ # Load config file (CLI arg > default path)
476
+ config = load_config(args.config)
477
+
478
+ # Handle colour settings: CLI > config > default (auto)
479
+ if args.no_colour:
480
+ set_colours_enabled(False)
481
+ elif config.get("no_colour"):
482
+ set_colours_enabled(False)
483
+
484
+ # If no command, show help
485
+ if not args.command:
486
+ return 0 # ArgsParser already shows help
487
+
488
+ # Get server URL: CLI > env > config
489
+ server_url = (
490
+ args.server or os.environ.get("CAL_DOCS_SERVER") or config.get("server")
491
+ )
492
+ if not server_url:
493
+ print(
494
+ red(
495
+ "Error: Server URL required. Use --server, set CAL_DOCS_SERVER env var,"
496
+ " or add to config file."
497
+ ),
498
+ file=sys.stderr,
499
+ )
500
+ return 1
501
+
502
+ # Get token: CLI > env > config
503
+ token = args.token or os.environ.get("CAL_DOCS_TOKEN") or config.get("token")
504
+
505
+ # Create client
506
+ client = DocsClient(server_url, token)
507
+
508
+ # Get command handler
509
+ handler = COMMANDS.get(args.command)
510
+ if not handler:
511
+ print(red(f"Error: Unknown command: {args.command}"), file=sys.stderr)
512
+ return 1
513
+
514
+ try:
515
+ return handler(client, args)
516
+ except ClientError as e:
517
+ print(red(f"Error: {e}"), file=sys.stderr)
518
+ return 1