@@ -314,6 +314,38 @@ def main() -> None:
314314 s_restore .add_argument ("--backup-id" , default = None ,
315315 help = "Timestamp of specific backup to restore from" )
316316
317+ # ── v0.11.0: key management ────────────────────────────────
318+ s_key = sub .add_parser ("key" , help = "Key management (wrap/unwrap)" )
319+ key_sub = s_key .add_subparsers (dest = "key_cmd" , required = True )
320+
321+ s_key_wrap = key_sub .add_parser ("wrap" , help = "Wrap key with passphrase (Tier 0 -> Tier 1)" )
322+ s_key_wrap .add_argument ("--dir" , default = "." , help = "Project root directory" )
323+
324+ s_key_unwrap = key_sub .add_parser ("unwrap" , help = "Unwrap key back to raw (Tier 1 -> Tier 0)" )
325+ s_key_unwrap .add_argument ("--dir" , default = "." , help = "Project root directory" )
326+
327+ # ── v0.11.0: backup management ──────────────────────────────
328+ s_backup = sub .add_parser ("backup" , help = "Backup management (migrate, prune)" )
329+ backup_sub = s_backup .add_subparsers (dest = "backup_cmd" , required = True )
330+
331+ s_backup_migrate = backup_sub .add_parser ("migrate" , help = "Encrypt legacy plaintext backups" )
332+ s_backup_migrate .add_argument ("--dir" , default = "." , help = "Project root directory" )
333+ s_backup_migrate .add_argument ("--dry-run" , action = "store_true" ,
334+ help = "Preview only (default behavior)" )
335+ s_backup_migrate .add_argument ("--quarantine" , action = "store_true" ,
336+ help = "Move legacy dirs to quarantine instead of deleting" )
337+
338+ s_backup_prune = backup_sub .add_parser ("prune" , help = "Remove old backups" )
339+ s_backup_prune .add_argument ("--dir" , default = "." , help = "Project root directory" )
340+ s_backup_prune .add_argument ("--ttl" , default = "30d" ,
341+ help = "TTL threshold (e.g. 30d, 24h, 90m) — default: 30d" )
342+ s_backup_prune .add_argument ("--keep-last" , type = int , default = 10 ,
343+ help = "Always keep N newest backups (default: 10)" )
344+ s_backup_prune .add_argument ("--apply" , action = "store_true" ,
345+ help = "Actually delete (dry-run without this flag)" )
346+ s_backup_prune .add_argument ("--include-legacy" , action = "store_true" ,
347+ help = "Also prune legacy plaintext directories" )
348+
317349 # ── serve (FastMCP) ────────────────────────────────────────
318350 s_serve = sub .add_parser ("serve" , help = "Start the MCP server (FastMCP)" )
319351 s_serve .add_argument ("--policy" , default = None , help = "Path to YAML policy file" )
@@ -645,6 +677,88 @@ def main() -> None:
645677 force = args .force , backup_id = args .backup_id )
646678 return
647679
680+ if args .cmd == "key" :
681+ from .storage import wrap_keyfile , unwrap_keyfile , _project_slug , _key_path , _detect_key_format
682+
683+ if args .key_cmd == "wrap" :
684+ _validate_dir_path (args .dir , "directory" )
685+ try :
686+ path = wrap_keyfile (args .dir )
687+ print (f"Key wrapped: { path } " , file = sys .stderr )
688+ print (" Format: CLOAKKEY1 (passphrase-wrapped via scrypt)" , file = sys .stderr )
689+ except (RuntimeError , FileNotFoundError ) as e :
690+ print (f"Error: { e } " , file = sys .stderr )
691+ sys .exit (1 )
692+ return
693+
694+ elif args .key_cmd == "unwrap" :
695+ _validate_dir_path (args .dir , "directory" )
696+ try :
697+ path = unwrap_keyfile (args .dir )
698+ print (f"Key unwrapped: { path } " , file = sys .stderr )
699+ print (" Format: raw Fernet key (Tier 0)" , file = sys .stderr )
700+ except (RuntimeError , FileNotFoundError ) as e :
701+ print (f"Error: { e } " , file = sys .stderr )
702+ sys .exit (1 )
703+ except Exception as e :
704+ print (f"Error: wrong passphrase or corrupt key file: { e } " , file = sys .stderr )
705+ sys .exit (1 )
706+ return
707+
708+ if args .cmd == "backup" :
709+ from .dirpack import migrate_all_legacy_backups , prune_backups
710+
711+ if args .backup_cmd == "migrate" :
712+ _validate_dir_path (args .dir , "directory" )
713+ dry_run = getattr (args , 'dry_run' , True )
714+ quarantine = getattr (args , 'quarantine' , False )
715+ results = migrate_all_legacy_backups (
716+ args .dir , dry_run = dry_run , quarantine = quarantine
717+ )
718+ if not results :
719+ print ("No legacy plaintext backups found." , file = sys .stderr )
720+ return
721+ for r in results :
722+ status = r ["status" ]
723+ size_kb = r .get ("size" , 0 ) // 1024
724+ print (f" { r ['timestamp' ]} [{ status } ] { size_kb } KB" , file = sys .stderr )
725+ migrated = sum (1 for r in results if r ["status" ] == "migrated" )
726+ failed = sum (1 for r in results if r ["status" ] == "failed" )
727+ would = sum (1 for r in results if r ["status" ] == "would_migrate" )
728+ if dry_run :
729+ print (f"\n [DRY RUN] Would migrate { would } backup(s)." , file = sys .stderr )
730+ print ("Run without --dry-run to execute." , file = sys .stderr )
731+ else :
732+ print (f"\n Migrated: { migrated } , Failed: { failed } " , file = sys .stderr )
733+ return
734+
735+ elif args .backup_cmd == "prune" :
736+ _validate_dir_path (args .dir , "directory" )
737+ try :
738+ result = prune_backups (
739+ args .dir ,
740+ ttl = args .ttl ,
741+ keep_last = args .keep_last ,
742+ apply = args .apply ,
743+ include_legacy = args .include_legacy ,
744+ )
745+ except ValueError as e :
746+ print (f"Error: { e } " , file = sys .stderr )
747+ sys .exit (1 )
748+ for d in result ["details" ]:
749+ size_kb = d .get ("size" , 0 ) // 1024
750+ print (f" { d ['timestamp' ]} [{ d ['format' ]} ] { size_kb } KB -> { d ['action' ]} " ,
751+ file = sys .stderr )
752+ freed_kb = result ["freed_bytes" ] // 1024
753+ if args .apply :
754+ print (f"\n Pruned: { result ['pruned' ]} , Kept: { result ['kept' ]} , "
755+ f"Freed: { freed_kb } KB" , file = sys .stderr )
756+ else :
757+ print (f"\n [DRY RUN] Would prune: { result ['pruned' ]} , Keep: { result ['kept' ]} " ,
758+ file = sys .stderr )
759+ print ("Add --apply to execute." , file = sys .stderr )
760+ return
761+
648762 if args .cmd == "serve" :
649763 try :
650764 from .fastmcp_server import create_server , build_parser as mcp_parser
0 commit comments