|
2 | 2 | import argparse |
3 | 3 | import os |
4 | 4 | import sys |
5 | | -from typing import Tuple |
| 5 | +from typing import Any, Dict, List, Tuple |
6 | 6 |
|
7 | 7 | from .normalizer import normalize |
8 | 8 | from .policy import Policy |
@@ -80,6 +80,98 @@ def sanitize_text(text: str, policy: Policy, dry_run: bool = False) -> Tuple[str |
80 | 80 | ) |
81 | 81 | return ("".join(out) if not dry_run else text, blocked) |
82 | 82 |
|
| 83 | +def _print_status(status: Dict[str, Any]) -> None: |
| 84 | + """Print human-readable status report to stderr.""" |
| 85 | + print("=" * 50, file=sys.stderr) |
| 86 | + print("CloakMCP Status", file=sys.stderr) |
| 87 | + print("=" * 50, file=sys.stderr) |
| 88 | + |
| 89 | + # Session |
| 90 | + active = status.get("session_active", False) |
| 91 | + print(f"\nSession: {'ACTIVE' if active else 'INACTIVE'}", file=sys.stderr) |
| 92 | + if active and status.get("session"): |
| 93 | + session = status["session"] |
| 94 | + print(f" Policy: {session.get('policy', 'N/A')}", file=sys.stderr) |
| 95 | + print(f" Prefix: {session.get('prefix', 'N/A')}", file=sys.stderr) |
| 96 | + if session.get("backup_path"): |
| 97 | + print(f" Backup: {session['backup_path']}", file=sys.stderr) |
| 98 | + |
| 99 | + # Manifest |
| 100 | + manifest = status.get("manifest") |
| 101 | + if manifest: |
| 102 | + print(f"\nManifest: {manifest.get('total_files', 0)} files " |
| 103 | + f"(ts: {manifest.get('timestamp', 'N/A')})", file=sys.stderr) |
| 104 | + else: |
| 105 | + print("\nManifest: none", file=sys.stderr) |
| 106 | + |
| 107 | + # Delta |
| 108 | + delta = status.get("delta") |
| 109 | + if delta: |
| 110 | + new_files = delta.get("new_files", []) |
| 111 | + deleted_files = delta.get("deleted_files", []) |
| 112 | + changed_files = delta.get("changed_files", []) |
| 113 | + unchanged = delta.get("unchanged_count", 0) |
| 114 | + print(f"\nDelta: {len(new_files)} new, {len(deleted_files)} deleted, " |
| 115 | + f"{len(changed_files)} changed, {unchanged} unchanged", file=sys.stderr) |
| 116 | + for label, files in [("New", new_files), ("Deleted", deleted_files), |
| 117 | + ("Changed", changed_files)]: |
| 118 | + for f in files[:10]: |
| 119 | + print(f" [{label}] {f}", file=sys.stderr) |
| 120 | + if len(files) > 10: |
| 121 | + print(f" ... and {len(files) - 10} more", file=sys.stderr) |
| 122 | + |
| 123 | + # Vault |
| 124 | + vault = status.get("vault") |
| 125 | + if vault: |
| 126 | + print(f"\nVault: {vault.get('total_secrets', 0)} secrets, " |
| 127 | + f"{vault.get('unique_tags', 0)} unique tags", file=sys.stderr) |
| 128 | + print(f" Path: {vault.get('vault_path', 'N/A')}", file=sys.stderr) |
| 129 | + else: |
| 130 | + print("\nVault: unavailable", file=sys.stderr) |
| 131 | + |
| 132 | + # Tag residue |
| 133 | + residue = status.get("tag_residue") |
| 134 | + if residue: |
| 135 | + print(f"\nTag residue: {residue.get('tags_found', 0)} found, " |
| 136 | + f"{residue.get('tags_resolved', 0)} resolved, " |
| 137 | + f"{residue.get('tags_unresolvable', 0)} unresolvable", file=sys.stderr) |
| 138 | + unresolvable_files = residue.get("unresolvable_files", []) |
| 139 | + for rel_path, count in unresolvable_files[:5]: |
| 140 | + print(f" {rel_path}: {count} tag(s)", file=sys.stderr) |
| 141 | + if len(unresolvable_files) > 5: |
| 142 | + print(f" ... and {len(unresolvable_files) - 5} more files", |
| 143 | + file=sys.stderr) |
| 144 | + |
| 145 | + # Backups |
| 146 | + backups = status.get("backups") |
| 147 | + if backups: |
| 148 | + print(f"\nBackups: {len(backups)} available", file=sys.stderr) |
| 149 | + for b in backups[:5]: |
| 150 | + print(f" {b['timestamp']} ({b['file_count']} files)", file=sys.stderr) |
| 151 | + if len(backups) > 5: |
| 152 | + print(f" ... and {len(backups) - 5} more", file=sys.stderr) |
| 153 | + else: |
| 154 | + print("\nBackups: none", file=sys.stderr) |
| 155 | + |
| 156 | + # Legacy warning |
| 157 | + legacy = status.get("legacy_warning") |
| 158 | + if legacy: |
| 159 | + print(f"\n{legacy}", file=sys.stderr) |
| 160 | + |
| 161 | + # Recent audit |
| 162 | + audit = status.get("recent_audit") |
| 163 | + if audit: |
| 164 | + print(f"\nRecent audit ({len(audit)} events):", file=sys.stderr) |
| 165 | + for evt in audit: |
| 166 | + ts = evt.get("ts", "?") |
| 167 | + event_type = evt.get("event", "?") |
| 168 | + print(f" [{ts}] {event_type}", file=sys.stderr) |
| 169 | + else: |
| 170 | + print("\nAudit: no events", file=sys.stderr) |
| 171 | + |
| 172 | + print("", file=sys.stderr) |
| 173 | + |
| 174 | + |
83 | 175 | def main() -> None: |
84 | 176 | p = argparse.ArgumentParser(prog="cloak", description="Micro-Cleanse Preprocessor (local secret-removal)") |
85 | 177 | sub = p.add_subparsers(dest="cmd", required=True) |
@@ -183,6 +275,24 @@ def main() -> None: |
183 | 275 | # ── scripts-path ───────────────────────────────────────────── |
184 | 276 | sub.add_parser("scripts-path", help="Print path to bundled installer scripts") |
185 | 277 |
|
| 278 | + # ── v0.8.0: session status ───────────────────────────────────── |
| 279 | + s_status = sub.add_parser("status", help="Show session status and diagnostics") |
| 280 | + s_status.add_argument("--dir", default=".", help="Project root directory") |
| 281 | + s_status.add_argument("--json", action="store_true", dest="json_output", |
| 282 | + help="Output as JSON (machine-readable)") |
| 283 | + s_status.add_argument("--audit-lines", type=int, default=10, |
| 284 | + help="Number of recent audit events to show (default: 10)") |
| 285 | + |
| 286 | + # ── v0.8.0: restore ─────────────────────────────────────────── |
| 287 | + s_restore = sub.add_parser("restore", help="Restore secrets (vault-based or from backup)") |
| 288 | + s_restore.add_argument("--dir", default=".", help="Project root directory") |
| 289 | + s_restore.add_argument("--from-backup", action="store_true", |
| 290 | + help="Restore from external backup instead of vault") |
| 291 | + s_restore.add_argument("--force", action="store_true", |
| 292 | + help="Execute destructive backup restore (required with --from-backup)") |
| 293 | + s_restore.add_argument("--backup-id", default=None, |
| 294 | + help="Timestamp of specific backup to restore from") |
| 295 | + |
186 | 296 | # ── Dispatch ──────────────────────────────────────────────── |
187 | 297 |
|
188 | 298 | args = p.parse_args() |
@@ -377,6 +487,25 @@ def main() -> None: |
377 | 487 | handle_recover(project_dir=args.dir) |
378 | 488 | return |
379 | 489 |
|
| 490 | + if args.cmd == "status": |
| 491 | + from .hooks import handle_status |
| 492 | + _validate_dir_path(args.dir, "directory") |
| 493 | + result = handle_status(project_dir=args.dir, json_output=args.json_output, |
| 494 | + audit_lines=args.audit_lines) |
| 495 | + if args.json_output: |
| 496 | + import json as _json |
| 497 | + print(_json.dumps(result, indent=2, default=str)) |
| 498 | + else: |
| 499 | + _print_status(result) |
| 500 | + return |
| 501 | + |
| 502 | + if args.cmd == "restore": |
| 503 | + from .hooks import handle_restore |
| 504 | + _validate_dir_path(args.dir, "directory") |
| 505 | + handle_restore(project_dir=args.dir, from_backup=args.from_backup, |
| 506 | + force=args.force, backup_id=args.backup_id) |
| 507 | + return |
| 508 | + |
380 | 509 | if args.cmd == "verify": |
381 | 510 | from .dirpack import verify_unpack |
382 | 511 | _validate_dir_path(args.dir, "directory") |
|
0 commit comments