harbor-aiops 0.2.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.
@@ -0,0 +1,8 @@
1
+ """Harbor AIops: governed container registry operations for AI agents."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("harbor-aiops")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.0.0+unknown"
@@ -0,0 +1,9 @@
1
+ """CLI package for harbor-aiops.
2
+
3
+ Re-exports ``app`` so the pyproject entry point
4
+ ``harbor-aiops = "harbor_aiops.cli:app"`` works unchanged.
5
+ """
6
+
7
+ from harbor_aiops.cli._root import app
8
+
9
+ __all__ = ["app"]
@@ -0,0 +1,122 @@
1
+ """Shared helpers for harbor-aiops CLI sub-modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ from collections.abc import Callable
7
+ from typing import Annotated, Any
8
+
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.markup import escape
12
+
13
+ console = Console()
14
+
15
+ # ─── Shared Option types ───────────────────────────────────────────────────
16
+
17
+ TargetOption = Annotated[
18
+ str | None, typer.Option("--target", "-t", help="Target name from config")
19
+ ]
20
+ DryRunOption = Annotated[
21
+ bool, typer.Option("--dry-run", help="Print the API call without executing")
22
+ ]
23
+
24
+
25
+ def _cli_error_types() -> tuple[type[BaseException], ...]:
26
+ """Exceptions translated to a one-line teaching error instead of a traceback.
27
+
28
+ ``BudgetExceeded`` (and the retained ``PolicyDenied`` type) are raised by
29
+ ``@governed_tool`` OUTSIDE the tool body, so ``tool_errors`` never sees them
30
+ and they never arrive as an ``{"error": ...}`` dict. Their message is the
31
+ teaching text (e.g. which budget was hit) — without them here such a refusal
32
+ reaches the CLI as a traceback instead.
33
+ """
34
+ from harbor_aiops.connection import HarborApiError
35
+ from harbor_aiops.governance import BudgetExceeded, PolicyDenied
36
+ from harbor_aiops.secretstore import SecretStoreError
37
+
38
+ return (HarborApiError, PolicyDenied, BudgetExceeded, SecretStoreError, KeyError, OSError,
39
+ ValueError)
40
+
41
+
42
+ def cli_errors(fn: Callable) -> Callable:
43
+ """Translate known exceptions into one red line + exit code 1."""
44
+
45
+ @functools.wraps(fn)
46
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
47
+ try:
48
+ return fn(*args, **kwargs)
49
+ except (typer.Exit, typer.Abort):
50
+ raise
51
+ except _cli_error_types() as e:
52
+ message = str(e)
53
+ if isinstance(e, KeyError):
54
+ message = f"Missing required key or environment variable: {message}"
55
+ # Upstream text can contain "[/bold]"; unescaped, rich raises on it and the
56
+ # real error is replaced by a MarkupError traceback.
57
+ console.print(f"[red]Error: {escape(message)}[/]")
58
+ raise typer.Exit(1) from e
59
+
60
+ return wrapper
61
+
62
+
63
+ #: Exit status for a write whose outcome could not be determined — kept distinct
64
+ #: from 0 (confirmed) and 1 (failed) so a script can tell all three apart.
65
+ EXIT_UNDETERMINED = 2
66
+
67
+
68
+ def governed(result: Any) -> dict:
69
+ """Return a governed tool's result, or print its error and exit 1.
70
+
71
+ The ``harbor_aiops.mcp_server.tools`` twins never raise: ``@tool_errors`` flattens every
72
+ failure — an unreachable service, a refused login, a bad argument — into
73
+ ``{"error": ...}``. A CLI command that drops that on the floor prints an empty
74
+ table and exits 0 as if the service had nothing to report. Route every governed
75
+ call through here.
76
+ """
77
+ # ``outcomeUnknown`` is judged BEFORE ``error``, matching the harness: a
78
+ # write whose response was lost carries BOTH keys, and it is audited
79
+ # `unknown` precisely because it may have taken effect. Reporting that as a
80
+ # plain failure would tell a script the change did not happen and invite the
81
+ # double-apply the payload's own note warns about.
82
+ if isinstance(result, dict) and result.get("outcomeUnknown"):
83
+ console.print(f"[yellow]Outcome undetermined: {escape(str(result.get('note') or ''))}[/]")
84
+ raise typer.Exit(EXIT_UNDETERMINED)
85
+ if isinstance(result, dict) and result.get("error"):
86
+ console.print(f"[red]Error: {escape(str(result['error']))}[/]")
87
+ raise typer.Exit(1)
88
+ return result if isinstance(result, dict) else {}
89
+
90
+
91
+ def dry_run_print(*, operation: str, api_call: str, parameters: dict | None = None) -> None:
92
+ """Print a dry-run preview of the API call that would be made."""
93
+ console.print("\n[bold magenta][DRY-RUN] No changes will be made.[/]")
94
+ console.print(f"[magenta] Operation: {operation}[/]")
95
+ console.print(f"[magenta] API Call: {api_call}[/]")
96
+ for k, v in (parameters or {}).items():
97
+ console.print(f"[magenta] Param: {k} = {v}[/]")
98
+ console.print("[magenta] Run without --dry-run to execute.[/]\n")
99
+
100
+
101
+ def double_confirm(action: str, resource: str) -> None:
102
+ """Require two confirmations for a destructive operation."""
103
+ console.print(f"[bold yellow]⚠️ About to: {action} '{resource}'[/]")
104
+ typer.confirm(f"Confirm 1/2: {action} '{resource}'?", abort=True)
105
+ typer.confirm(
106
+ f"Confirm 2/2: really {action} '{resource}'? This may be irreversible.",
107
+ abort=True,
108
+ )
109
+
110
+
111
+ def print_truncation_note(result: dict, noun: str) -> None:
112
+ """Print a visible warning when a listing envelope was capped.
113
+
114
+ The JSON already carries ``truncated``, but a human scrolling a long dump
115
+ will not spot it — and neither will a model summarising the terminal. Say
116
+ it in words, on the last line, where it cannot be missed.
117
+ """
118
+ if isinstance(result, dict) and result.get("truncated"):
119
+ console.print(
120
+ f"[yellow]… showing {result.get('returned')} of more {noun} — "
121
+ f"truncated, re-run with a higher --limit[/]"
122
+ )
@@ -0,0 +1,446 @@
1
+ """Harbor AIops command line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from typing import Annotated
8
+
9
+ import typer
10
+
11
+ from harbor_aiops.cli._common import (
12
+ cli_errors,
13
+ console,
14
+ double_confirm,
15
+ governed,
16
+ print_truncation_note,
17
+ )
18
+ from harbor_aiops.cli.secret import secret_app
19
+ from harbor_aiops.cli.undo import undo_app
20
+ from harbor_aiops.governance.paths import ops_home
21
+
22
+ app = typer.Typer(
23
+ name="harbor-aiops",
24
+ no_args_is_help=True,
25
+ help="Audited Harbor registry diagnosis and maintenance visibility.",
26
+ )
27
+ app.add_typer(secret_app, name="secret")
28
+ app.add_typer(undo_app, name="undo")
29
+
30
+ Target = Annotated[str | None, typer.Option("--target", "-t", help="Configured Harbor target")]
31
+ Limit = Annotated[int, typer.Option("--limit", help="Max records, 1–500")]
32
+
33
+
34
+ def _print(result: dict, noun: str = "records") -> None:
35
+ result = governed(result)
36
+ console.print_json(json.dumps(result))
37
+ print_truncation_note(result, noun)
38
+
39
+
40
+ @app.command("statistics")
41
+ @cli_errors
42
+ def statistics(target: Target = None) -> None:
43
+ """Show registry-wide project and repository counts."""
44
+ from harbor_aiops.mcp_server.tools.registry import harbor_statistics
45
+ _print(harbor_statistics(target=target))
46
+
47
+
48
+ @app.command("project-members")
49
+ @cli_errors
50
+ def project_members(project: str, limit: Limit = 50, target: Target = None) -> None:
51
+ """List members of a Harbor project."""
52
+ from harbor_aiops.mcp_server.tools.registry import harbor_project_members
53
+ _print(harbor_project_members(project=project, limit=limit, target=target), "members")
54
+
55
+
56
+ @app.command("immutable-rules")
57
+ @cli_errors
58
+ def immutable_rules(project: str, limit: Limit = 50, target: Target = None) -> None:
59
+ """List tag immutability rules for a project."""
60
+ from harbor_aiops.mcp_server.tools.registry import harbor_immutable_rules
61
+ _print(harbor_immutable_rules(project=project, limit=limit, target=target), "rules")
62
+
63
+
64
+ @app.command("robot-accounts")
65
+ @cli_errors
66
+ def robot_accounts(limit: Limit = 50, target: Target = None) -> None:
67
+ """List robot accounts without exposing their secrets."""
68
+ from harbor_aiops.mcp_server.tools.registry import harbor_robot_accounts
69
+ _print(harbor_robot_accounts(limit=limit, target=target), "robot accounts")
70
+
71
+
72
+ @app.command("labels")
73
+ @cli_errors
74
+ def labels(limit: Limit = 50, target: Target = None) -> None:
75
+ """List Harbor labels."""
76
+ from harbor_aiops.mcp_server.tools.registry import harbor_labels
77
+ _print(harbor_labels(limit=limit, target=target), "labels")
78
+
79
+
80
+ @app.command("scan-all-metrics")
81
+ @cli_errors
82
+ def scan_all_metrics(target: Target = None) -> None:
83
+ """Show latest registry-wide scan metrics."""
84
+ from harbor_aiops.mcp_server.tools.registry import harbor_scan_all_metrics
85
+ _print(harbor_scan_all_metrics(target=target))
86
+
87
+
88
+ @app.command("scheduled-scan-metrics")
89
+ @cli_errors
90
+ def scheduled_scan_metrics(target: Target = None) -> None:
91
+ """Show latest scheduled scan metrics."""
92
+ from harbor_aiops.mcp_server.tools.registry import harbor_scheduled_scan_metrics
93
+ _print(harbor_scheduled_scan_metrics(target=target))
94
+
95
+
96
+ @app.command("scan-log")
97
+ @cli_errors
98
+ def scan_log(project: str, repository: str, reference: str, report_id: str,
99
+ target: Target = None) -> None:
100
+ """Show a bounded artifact scan report log."""
101
+ from harbor_aiops.mcp_server.tools.registry import harbor_scan_log
102
+ _print(harbor_scan_log(project=project, repository=repository, reference=reference,
103
+ report_id=report_id, target=target))
104
+
105
+
106
+ @app.command("projects")
107
+ @cli_errors
108
+ def projects(limit: Limit = 50, target: Target = None) -> None:
109
+ """List Harbor projects."""
110
+ from harbor_aiops.mcp_server.tools.registry import harbor_projects
111
+ _print(harbor_projects(limit=limit, target=target), "projects")
112
+
113
+
114
+ @app.command("project")
115
+ @cli_errors
116
+ def project(name: str, target: Target = None) -> None:
117
+ """Show one Harbor project."""
118
+ from harbor_aiops.mcp_server.tools.registry import harbor_project
119
+ _print(harbor_project(name=name, target=target))
120
+
121
+
122
+ @app.command("project-summary")
123
+ @cli_errors
124
+ def project_summary(name: str, target: Target = None) -> None:
125
+ """Show Harbor's project usage summary."""
126
+ from harbor_aiops.mcp_server.tools.registry import harbor_project_summary
127
+ _print(harbor_project_summary(name=name, target=target))
128
+
129
+
130
+ @app.command("repositories")
131
+ @cli_errors
132
+ def repositories(project: str, limit: Limit = 50, target: Target = None) -> None:
133
+ """List repositories in a project."""
134
+ from harbor_aiops.mcp_server.tools.registry import harbor_repositories
135
+ _print(harbor_repositories(project=project, limit=limit, target=target), "repositories")
136
+
137
+
138
+ @app.command("artifacts")
139
+ @cli_errors
140
+ def artifacts(project: str, repository: str, limit: Limit = 50, target: Target = None) -> None:
141
+ """List artifacts in a repository."""
142
+ from harbor_aiops.mcp_server.tools.registry import harbor_artifacts
143
+ _print(harbor_artifacts(project=project, repository=repository, limit=limit,
144
+ target=target), "artifacts")
145
+
146
+
147
+ @app.command("artifact")
148
+ @cli_errors
149
+ def artifact(project: str, repository: str, reference: str, target: Target = None) -> None:
150
+ """Show one artifact by digest or tag."""
151
+ from harbor_aiops.mcp_server.tools.registry import harbor_artifact
152
+ _print(harbor_artifact(project=project, repository=repository, reference=reference,
153
+ target=target))
154
+
155
+
156
+ @app.command("tags")
157
+ @cli_errors
158
+ def tags(project: str, repository: str, reference: str, limit: Limit = 50,
159
+ target: Target = None) -> None:
160
+ """List artifact tags."""
161
+ from harbor_aiops.mcp_server.tools.registry import harbor_tags
162
+ _print(harbor_tags(project=project, repository=repository, reference=reference,
163
+ limit=limit, target=target), "tags")
164
+
165
+
166
+ @app.command("vulnerabilities")
167
+ @cli_errors
168
+ def vulnerabilities(project: str, repository: str, reference: str,
169
+ target: Target = None) -> None:
170
+ """Show an artifact's stored vulnerability report."""
171
+ from harbor_aiops.mcp_server.tools.registry import harbor_vulnerabilities
172
+ _print(harbor_vulnerabilities(project=project, repository=repository,
173
+ reference=reference, target=target))
174
+
175
+
176
+ @app.command("replication-policies")
177
+ @cli_errors
178
+ def replication_policies(limit: Limit = 50, target: Target = None) -> None:
179
+ """List replication policies."""
180
+ from harbor_aiops.mcp_server.tools.registry import harbor_replication_policies
181
+ _print(harbor_replication_policies(limit=limit, target=target), "replication policies")
182
+
183
+
184
+ @app.command("replication-policy")
185
+ @cli_errors
186
+ def replication_policy(policy_id: int, target: Target = None) -> None:
187
+ """Show a replication policy."""
188
+ from harbor_aiops.mcp_server.tools.registry import harbor_replication_policy
189
+ _print(harbor_replication_policy(policy_id=policy_id, target=target))
190
+
191
+
192
+ @app.command("replication-execution")
193
+ @cli_errors
194
+ def replication_execution(execution_id: int, target: Target = None) -> None:
195
+ """Show one replication execution."""
196
+ from harbor_aiops.mcp_server.tools.registry import harbor_replication_execution
197
+ _print(harbor_replication_execution(execution_id=execution_id, target=target))
198
+
199
+
200
+ @app.command("retention-tasks")
201
+ @cli_errors
202
+ def retention_tasks(policy_id: int, execution_id: int, limit: Limit = 50,
203
+ target: Target = None) -> None:
204
+ """List tasks of a retention execution."""
205
+ from harbor_aiops.mcp_server.tools.registry import harbor_retention_tasks
206
+ _print(harbor_retention_tasks(policy_id=policy_id, execution_id=execution_id,
207
+ limit=limit, target=target), "retention tasks")
208
+
209
+
210
+ @app.command("quotas")
211
+ @cli_errors
212
+ def quotas(limit: Limit = 50, target: Target = None) -> None:
213
+ """List Harbor quotas."""
214
+ from harbor_aiops.mcp_server.tools.registry import harbor_quotas
215
+ _print(harbor_quotas(limit=limit, target=target), "quotas")
216
+
217
+
218
+ @app.command("project-audit-logs")
219
+ @cli_errors
220
+ def project_audit_logs(project: str, limit: Limit = 50, target: Target = None) -> None:
221
+ """List Harbor audit logs for a project."""
222
+ from harbor_aiops.mcp_server.tools.registry import harbor_project_audit_logs
223
+ _print(harbor_project_audit_logs(project=project, limit=limit, target=target), "audit logs")
224
+
225
+
226
+ @app.command("scan-artifact")
227
+ @cli_errors
228
+ def scan_artifact(project: str, repository: str, reference: str,
229
+ dry_run: bool = False, target: Target = None) -> None:
230
+ """Request a Harbor artifact vulnerability scan."""
231
+ from harbor_aiops.mcp_server.tools.writes import harbor_scan_artifact
232
+ if not dry_run:
233
+ double_confirm("scan artifact", f"{project}/{repository}:{reference}")
234
+ _print(harbor_scan_artifact(project=project, repository=repository,
235
+ reference=reference, dry_run=dry_run, target=target))
236
+
237
+
238
+ @app.command("create-tag")
239
+ @cli_errors
240
+ def create_tag(project: str, repository: str, reference: str, tag: str,
241
+ dry_run: bool = False, target: Target = None) -> None:
242
+ """Create and verify a Harbor artifact tag."""
243
+ from harbor_aiops.mcp_server.tools.writes import harbor_create_tag
244
+ if not dry_run:
245
+ double_confirm("create tag", f"{project}/{repository}:{tag}")
246
+ _print(harbor_create_tag(project=project, repository=repository, reference=reference,
247
+ tag=tag, dry_run=dry_run, target=target))
248
+
249
+
250
+ @app.command("delete-tag")
251
+ @cli_errors
252
+ def delete_tag(project: str, repository: str, reference: str, tag: str,
253
+ dry_run: bool = False, target: Target = None) -> None:
254
+ """Delete one mutable Harbor tag after exact target checks."""
255
+ from harbor_aiops.mcp_server.tools.writes import harbor_delete_tag
256
+ if not dry_run:
257
+ double_confirm("delete tag", f"{project}/{repository}:{tag}")
258
+ _print(harbor_delete_tag(project=project, repository=repository, reference=reference,
259
+ tag=tag, dry_run=dry_run, target=target))
260
+
261
+
262
+ @app.command("gc-execution")
263
+ @cli_errors
264
+ def gc_execution(gc_id: int, target: Target = None) -> None:
265
+ """Show one GC execution."""
266
+ from harbor_aiops.mcp_server.tools.registry import harbor_gc_execution
267
+ _print(harbor_gc_execution(gc_id=gc_id, target=target))
268
+
269
+
270
+ @app.command("gc-log")
271
+ @cli_errors
272
+ def gc_log(gc_id: int, target: Target = None) -> None:
273
+ """Show a bounded GC execution log."""
274
+ from harbor_aiops.mcp_server.tools.registry import harbor_gc_log
275
+ _print(harbor_gc_log(gc_id=gc_id, target=target))
276
+
277
+
278
+ @app.command("replication-task-log")
279
+ @cli_errors
280
+ def replication_task_log(execution_id: int, task_id: int, target: Target = None) -> None:
281
+ """Show a bounded replication task log."""
282
+ from harbor_aiops.mcp_server.tools.registry import harbor_replication_task_log
283
+ _print(harbor_replication_task_log(execution_id=execution_id, task_id=task_id,
284
+ target=target))
285
+
286
+
287
+ @app.command("retention-task-log")
288
+ @cli_errors
289
+ def retention_task_log(policy_id: int, execution_id: int, task_id: int,
290
+ target: Target = None) -> None:
291
+ """Show a bounded retention task log."""
292
+ from harbor_aiops.mcp_server.tools.registry import harbor_retention_task_log
293
+ _print(harbor_retention_task_log(policy_id=policy_id, execution_id=execution_id,
294
+ task_id=task_id, target=target))
295
+
296
+
297
+ @app.command("init")
298
+ @cli_errors
299
+ def init() -> None:
300
+ """Add a Harbor target and store its password encrypted."""
301
+ import getpass
302
+ from pathlib import Path
303
+
304
+ import yaml
305
+
306
+ from harbor_aiops.config import TargetConfig
307
+ from harbor_aiops.secretstore import SecretStore, resolve_master_password
308
+
309
+ name = typer.prompt("Target name", default="harbor")
310
+ host = typer.prompt("Harbor hostname")
311
+ username = typer.prompt("Harbor username or robot account")
312
+ ca_file = typer.prompt("Custom CA PEM path (optional)", default="")
313
+ chosen = TargetConfig(name=name, host=host, username=username, ca_file=ca_file or None)
314
+ path = Path(ops_home()) / "config.yaml"
315
+ path.parent.mkdir(parents=True, exist_ok=True)
316
+ if path.exists():
317
+ raise ValueError(f"Config already exists at {path}; edit it to add a target.")
318
+ master = resolve_master_password(confirm_if_new=True)
319
+ password = getpass.getpass("Harbor password (hidden): ")
320
+ SecretStore.unlock(master).set(name, password)
321
+ path.write_text(
322
+ yaml.safe_dump(
323
+ {
324
+ "targets": [
325
+ {
326
+ "name": chosen.name,
327
+ "host": chosen.host,
328
+ "username": chosen.username,
329
+ "scheme": chosen.scheme,
330
+ "port": chosen.port,
331
+ "verify_ssl": chosen.verify_ssl,
332
+ "ca_file": chosen.ca_file,
333
+ "timeout": chosen.timeout,
334
+ }
335
+ ]
336
+ }
337
+ ),
338
+ encoding="utf-8",
339
+ )
340
+ path.chmod(0o600)
341
+ console.print(f"[green]Configured '{name}' in {path}.[/]")
342
+
343
+
344
+ @app.command("health")
345
+ @cli_errors
346
+ def health(target: Target = None) -> None:
347
+ """Show Harbor component health."""
348
+ from harbor_aiops.mcp_server.tools.registry import harbor_health
349
+
350
+ _print(harbor_health(target=target))
351
+
352
+
353
+ @app.command("volumes")
354
+ @cli_errors
355
+ def volumes(target: Target = None) -> None:
356
+ """Show Harbor storage volume usage."""
357
+ from harbor_aiops.mcp_server.tools.registry import harbor_volumes
358
+
359
+ _print(harbor_volumes(target=target))
360
+
361
+
362
+ @app.command("gc")
363
+ @cli_errors
364
+ def gc(limit: Limit = 50, target: Target = None) -> None:
365
+ """List garbage-collection executions."""
366
+ from harbor_aiops.mcp_server.tools.registry import harbor_gc_history
367
+
368
+ _print(harbor_gc_history(limit=limit, target=target), "GC executions")
369
+
370
+
371
+ @app.command("replication")
372
+ @cli_errors
373
+ def replication(limit: Limit = 50, status: str | None = None, target: Target = None) -> None:
374
+ """List replication executions."""
375
+ from harbor_aiops.mcp_server.tools.registry import harbor_replication_executions
376
+
377
+ _print(
378
+ harbor_replication_executions(limit=limit, status=status, target=target),
379
+ "replication executions",
380
+ )
381
+
382
+
383
+ @app.command("replication-tasks")
384
+ @cli_errors
385
+ def replication_tasks(execution_id: int, limit: Limit = 50, target: Target = None) -> None:
386
+ """List tasks in one replication execution."""
387
+ from harbor_aiops.mcp_server.tools.registry import harbor_replication_tasks
388
+
389
+ _print(
390
+ harbor_replication_tasks(execution_id=execution_id, limit=limit, target=target),
391
+ "replication tasks",
392
+ )
393
+
394
+
395
+ @app.command("retention")
396
+ @cli_errors
397
+ def retention(policy_id: int, target: Target = None) -> None:
398
+ """Show a retention policy by ID."""
399
+ from harbor_aiops.mcp_server.tools.registry import harbor_retention_policy
400
+
401
+ _print(harbor_retention_policy(policy_id=policy_id, target=target))
402
+
403
+
404
+ @app.command("retention-executions")
405
+ @cli_errors
406
+ def retention_executions(policy_id: int, limit: Limit = 50, target: Target = None) -> None:
407
+ """List executions of a retention policy."""
408
+ from harbor_aiops.mcp_server.tools.registry import harbor_retention_executions
409
+
410
+ _print(
411
+ harbor_retention_executions(policy_id=policy_id, limit=limit, target=target),
412
+ "retention executions",
413
+ )
414
+
415
+
416
+ @app.command("diagnose")
417
+ @cli_errors
418
+ def diagnose(limit: Limit = 20, target: Target = None) -> None:
419
+ """Correlate health, volume, GC and replication observations."""
420
+ from harbor_aiops.mcp_server.tools.registry import harbor_diagnose
421
+
422
+ _print(harbor_diagnose(limit=limit, target=target))
423
+
424
+
425
+ @app.command("doctor")
426
+ @cli_errors
427
+ def doctor(target: Target = None) -> None:
428
+ """Test target configuration and Harbor health endpoint."""
429
+ from harbor_aiops.mcp_server.tools.registry import harbor_health
430
+
431
+ _print(harbor_health(target=target))
432
+
433
+
434
+ @app.command("mcp")
435
+ @cli_errors
436
+ def mcp_cmd() -> None:
437
+ """Start the MCP server over stdio."""
438
+ if sys.version_info < (3, 11):
439
+ raise typer.Exit(2)
440
+ from harbor_aiops.mcp_server.server import main
441
+
442
+ main()
443
+
444
+
445
+ if __name__ == "__main__":
446
+ app()
@@ -0,0 +1,106 @@
1
+ """``harbor-aiops secret`` — manage the encrypted credential store.
2
+
3
+ Secrets (Harbor account passwords) are stored in ``~/.harbor-aiops/secrets.enc``
4
+ (Fernet, key derived from a master password via scrypt). Nothing here ever
5
+ prints a secret value.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import getpass
11
+ from typing import Annotated
12
+
13
+ import typer
14
+
15
+ from harbor_aiops.cli._common import cli_errors, console
16
+ from harbor_aiops.config import SECRET_ENV_PREFIX, SECRET_ENV_SUFFIX
17
+ from harbor_aiops.secretstore import (
18
+ SECRETS_FILE,
19
+ SecretStore,
20
+ check_permissions,
21
+ migrate_legacy_env,
22
+ resolve_master_password,
23
+ )
24
+
25
+ secret_app = typer.Typer(
26
+ name="secret",
27
+ help="Manage the encrypted credential store (secrets.enc).",
28
+ no_args_is_help=True,
29
+ )
30
+
31
+ NameArg = Annotated[str, typer.Argument(help="Target name the password belongs to")]
32
+
33
+
34
+ @secret_app.command("set")
35
+ @cli_errors
36
+ def secret_set(
37
+ name: NameArg,
38
+ value: Annotated[
39
+ str | None,
40
+ typer.Option("--value", help="Password (omit to be prompted, hidden)"),
41
+ ] = None,
42
+ ) -> None:
43
+ """Store (or replace) the password for a target — value is read hidden."""
44
+ password = resolve_master_password(confirm_if_new=True)
45
+ if value is None:
46
+ value = getpass.getpass(f"Password for '{name}' (hidden): ")
47
+ else:
48
+ console.print("[yellow]! --value leaves the password in shell history and the "
49
+ "process list; omit it to be prompted instead.[/]")
50
+ store = SecretStore.unlock(password)
51
+ store.set(name, value)
52
+ console.print(f"[green]✓ Stored encrypted password for '{name}' in {SECRETS_FILE}[/]")
53
+
54
+
55
+ @secret_app.command("list")
56
+ @cli_errors
57
+ def secret_list() -> None:
58
+ """List target names that have a stored password (values never shown)."""
59
+ store = SecretStore.unlock()
60
+ names = store.names()
61
+ if not names:
62
+ console.print("[yellow]No secrets stored yet. Add one: harbor-aiops secret set <name>[/]")
63
+ return
64
+ console.print("[bold]Stored secrets:[/]")
65
+ for n in names:
66
+ console.print(f" • {n}")
67
+ warning = check_permissions()
68
+ if warning:
69
+ console.print(f"[yellow]! {warning}[/]")
70
+
71
+
72
+ @secret_app.command("rm")
73
+ @cli_errors
74
+ def secret_rm(name: NameArg) -> None:
75
+ """Delete a stored password."""
76
+ store = SecretStore.unlock()
77
+ store.delete(name)
78
+ console.print(f"[green]✓ Deleted password for '{name}'[/]")
79
+
80
+
81
+ @secret_app.command("migrate")
82
+ @cli_errors
83
+ def secret_migrate() -> None:
84
+ """Import passwords from a legacy plaintext .env into the encrypted store."""
85
+ password = resolve_master_password(confirm_if_new=True)
86
+ imported = migrate_legacy_env(SECRET_ENV_PREFIX, SECRET_ENV_SUFFIX, password)
87
+ if not imported:
88
+ console.print("[yellow]Nothing to migrate (no legacy .env secrets found).[/]")
89
+ return
90
+ console.print(f"[green]✓ Imported {len(imported)} secret(s): {', '.join(imported)}[/]")
91
+ console.print("[dim]The old .env was renamed to .env.migrated — delete it once verified.[/]")
92
+
93
+
94
+ @secret_app.command("rotate-password")
95
+ @cli_errors
96
+ def secret_rotate_password() -> None:
97
+ """Re-encrypt the whole store under a new master password."""
98
+ console.print("[bold]Unlock with the current master password:[/]")
99
+ store = SecretStore.unlock()
100
+ new_pw = getpass.getpass("New master password: ")
101
+ confirm = getpass.getpass("Confirm new master password: ")
102
+ if new_pw != confirm:
103
+ console.print("[red]Passwords did not match. Aborted.[/]")
104
+ raise typer.Exit(1)
105
+ store.with_password(new_pw)
106
+ console.print("[green]✓ Master password rotated. Update HARBOR_AIOPS_MASTER_PASSWORD.[/]")