mirror of
https://github.com/cexll/myclaude.git
synced 2026-02-05 02:30:26 +08:00
feat: add sparv module and interactive plugin manager
- Add sparv module to config.json (SPARV workflow v1.1) - Disable essentials module by default - Add --status to show installation status of all modules - Add --uninstall to remove installed modules - Add interactive management mode (install/uninstall via menu) - Add filesystem-based installation detection - Support both module numbers and names in selection - Merge install status instead of overwriting Generated with SWE-Agent.ai Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
This commit is contained in:
14
config.json
14
config.json
@@ -93,7 +93,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"essentials": {
|
"essentials": {
|
||||||
"enabled": true,
|
"enabled": false,
|
||||||
"description": "Core development commands and utilities",
|
"description": "Core development commands and utilities",
|
||||||
"operations": [
|
"operations": [
|
||||||
{
|
{
|
||||||
@@ -156,6 +156,18 @@
|
|||||||
"description": "Install develop agent prompt"
|
"description": "Install develop agent prompt"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"sparv": {
|
||||||
|
"enabled": false,
|
||||||
|
"description": "SPARV workflow (Specify→Plan→Act→Review→Vault) with 10-point gate",
|
||||||
|
"operations": [
|
||||||
|
{
|
||||||
|
"type": "copy_dir",
|
||||||
|
"source": "skills/sparv",
|
||||||
|
"target": "skills/sparv",
|
||||||
|
"description": "Install sparv skill with all scripts and hooks"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
398
install.py
398
install.py
@@ -46,7 +46,7 @@ def parse_args(argv: Optional[Iterable[str]] = None) -> argparse.Namespace:
|
|||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--module",
|
"--module",
|
||||||
help="Comma-separated modules to install, or 'all' for all enabled",
|
help="Comma-separated modules to install/uninstall, or 'all'",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--config",
|
"--config",
|
||||||
@@ -58,6 +58,16 @@ def parse_args(argv: Optional[Iterable[str]] = None) -> argparse.Namespace:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="List available modules and exit",
|
help="List available modules and exit",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--status",
|
||||||
|
action="store_true",
|
||||||
|
help="Show installation status of all modules",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--uninstall",
|
||||||
|
action="store_true",
|
||||||
|
help="Uninstall specified modules",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--force",
|
"--force",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
@@ -166,22 +176,93 @@ def resolve_paths(config: Dict[str, Any], args: argparse.Namespace) -> Dict[str,
|
|||||||
|
|
||||||
def list_modules(config: Dict[str, Any]) -> None:
|
def list_modules(config: Dict[str, Any]) -> None:
|
||||||
print("Available Modules:")
|
print("Available Modules:")
|
||||||
print(f"{'Name':<15} {'Default':<8} Description")
|
print(f"{'#':<3} {'Name':<15} {'Default':<8} Description")
|
||||||
print("-" * 60)
|
print("-" * 65)
|
||||||
for name, cfg in config.get("modules", {}).items():
|
for idx, (name, cfg) in enumerate(config.get("modules", {}).items(), 1):
|
||||||
default = "✓" if cfg.get("enabled", False) else "✗"
|
default = "✓" if cfg.get("enabled", False) else "✗"
|
||||||
desc = cfg.get("description", "")
|
desc = cfg.get("description", "")
|
||||||
print(f"{name:<15} {default:<8} {desc}")
|
print(f"{idx:<3} {name:<15} {default:<8} {desc}")
|
||||||
print("\n✓ = installed by default when no --module specified")
|
print("\n✓ = installed by default when no --module specified")
|
||||||
|
|
||||||
|
|
||||||
|
def load_installed_status(ctx: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Load installed modules status from status file."""
|
||||||
|
status_path = Path(ctx["status_file"])
|
||||||
|
if status_path.exists():
|
||||||
|
try:
|
||||||
|
return _load_json(status_path)
|
||||||
|
except (ValueError, FileNotFoundError):
|
||||||
|
return {"modules": {}}
|
||||||
|
return {"modules": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def check_module_installed(name: str, cfg: Dict[str, Any], ctx: Dict[str, Any]) -> bool:
|
||||||
|
"""Check if a module is installed by verifying its files exist."""
|
||||||
|
install_dir = ctx["install_dir"]
|
||||||
|
|
||||||
|
for op in cfg.get("operations", []):
|
||||||
|
op_type = op.get("type")
|
||||||
|
if op_type in ("copy_dir", "copy_file"):
|
||||||
|
target = (install_dir / op["target"]).expanduser().resolve()
|
||||||
|
if target.exists():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_installed_modules(config: Dict[str, Any], ctx: Dict[str, Any]) -> Dict[str, bool]:
|
||||||
|
"""Get installation status of all modules by checking files."""
|
||||||
|
result = {}
|
||||||
|
modules = config.get("modules", {})
|
||||||
|
|
||||||
|
# First check status file
|
||||||
|
status = load_installed_status(ctx)
|
||||||
|
status_modules = status.get("modules", {})
|
||||||
|
|
||||||
|
for name, cfg in modules.items():
|
||||||
|
# Check both status file and filesystem
|
||||||
|
in_status = name in status_modules
|
||||||
|
files_exist = check_module_installed(name, cfg, ctx)
|
||||||
|
result[name] = in_status or files_exist
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def list_modules_with_status(config: Dict[str, Any], ctx: Dict[str, Any]) -> None:
|
||||||
|
"""List modules with installation status."""
|
||||||
|
installed_status = get_installed_modules(config, ctx)
|
||||||
|
status_data = load_installed_status(ctx)
|
||||||
|
status_modules = status_data.get("modules", {})
|
||||||
|
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("Module Status")
|
||||||
|
print("=" * 70)
|
||||||
|
print(f"{'#':<3} {'Name':<15} {'Status':<15} {'Installed At':<20} Description")
|
||||||
|
print("-" * 70)
|
||||||
|
|
||||||
|
for idx, (name, cfg) in enumerate(config.get("modules", {}).items(), 1):
|
||||||
|
desc = cfg.get("description", "")[:25]
|
||||||
|
if installed_status.get(name, False):
|
||||||
|
status = "✅ Installed"
|
||||||
|
installed_at = status_modules.get(name, {}).get("installed_at", "")[:16]
|
||||||
|
else:
|
||||||
|
status = "⬚ Not installed"
|
||||||
|
installed_at = ""
|
||||||
|
print(f"{idx:<3} {name:<15} {status:<15} {installed_at:<20} {desc}")
|
||||||
|
|
||||||
|
total = len(config.get("modules", {}))
|
||||||
|
installed_count = sum(1 for v in installed_status.values() if v)
|
||||||
|
print(f"\nTotal: {installed_count}/{total} modules installed")
|
||||||
|
print(f"Install dir: {ctx['install_dir']}")
|
||||||
|
|
||||||
|
|
||||||
def select_modules(config: Dict[str, Any], module_arg: Optional[str]) -> Dict[str, Any]:
|
def select_modules(config: Dict[str, Any], module_arg: Optional[str]) -> Dict[str, Any]:
|
||||||
modules = config.get("modules", {})
|
modules = config.get("modules", {})
|
||||||
if not module_arg:
|
if not module_arg:
|
||||||
return {k: v for k, v in modules.items() if v.get("enabled", False)}
|
# No --module specified: show interactive selection
|
||||||
|
return interactive_select_modules(config)
|
||||||
|
|
||||||
if module_arg.strip().lower() == "all":
|
if module_arg.strip().lower() == "all":
|
||||||
return {k: v for k, v in modules.items() if v.get("enabled", False)}
|
return dict(modules.items())
|
||||||
|
|
||||||
selected: Dict[str, Any] = {}
|
selected: Dict[str, Any] = {}
|
||||||
for name in (part.strip() for part in module_arg.split(",")):
|
for name in (part.strip() for part in module_arg.split(",")):
|
||||||
@@ -193,6 +274,256 @@ def select_modules(config: Dict[str, Any], module_arg: Optional[str]) -> Dict[st
|
|||||||
return selected
|
return selected
|
||||||
|
|
||||||
|
|
||||||
|
def interactive_select_modules(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Interactive module selection when no --module is specified."""
|
||||||
|
modules = config.get("modules", {})
|
||||||
|
module_names = list(modules.keys())
|
||||||
|
|
||||||
|
print("\n" + "=" * 65)
|
||||||
|
print("Welcome to Claude Plugin Installer")
|
||||||
|
print("=" * 65)
|
||||||
|
print("\nNo modules specified. Please select modules to install:\n")
|
||||||
|
|
||||||
|
list_modules(config)
|
||||||
|
|
||||||
|
print("\nEnter module numbers or names (comma-separated), or:")
|
||||||
|
print(" 'all' - Install all modules")
|
||||||
|
print(" 'q' - Quit without installing")
|
||||||
|
print()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
user_input = input("Select modules: ").strip()
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
print("\nInstallation cancelled.")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if not user_input:
|
||||||
|
print("No input. Please enter module numbers, names, 'all', or 'q'.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if user_input.lower() == "q":
|
||||||
|
print("Installation cancelled.")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if user_input.lower() == "all":
|
||||||
|
print(f"\nSelected all {len(modules)} modules.")
|
||||||
|
return dict(modules.items())
|
||||||
|
|
||||||
|
# Parse selection
|
||||||
|
selected: Dict[str, Any] = {}
|
||||||
|
parts = [p.strip() for p in user_input.replace(" ", ",").split(",") if p.strip()]
|
||||||
|
|
||||||
|
try:
|
||||||
|
for part in parts:
|
||||||
|
# Try as number first
|
||||||
|
if part.isdigit():
|
||||||
|
idx = int(part) - 1
|
||||||
|
if 0 <= idx < len(module_names):
|
||||||
|
name = module_names[idx]
|
||||||
|
selected[name] = modules[name]
|
||||||
|
else:
|
||||||
|
print(f"Invalid number: {part}. Valid range: 1-{len(module_names)}")
|
||||||
|
selected = {}
|
||||||
|
break
|
||||||
|
# Try as name
|
||||||
|
elif part in modules:
|
||||||
|
selected[part] = modules[part]
|
||||||
|
else:
|
||||||
|
print(f"Module not found: '{part}'")
|
||||||
|
selected = {}
|
||||||
|
break
|
||||||
|
|
||||||
|
if selected:
|
||||||
|
names = ", ".join(selected.keys())
|
||||||
|
print(f"\nSelected {len(selected)} module(s): {names}")
|
||||||
|
return selected
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
print("Invalid input. Please try again.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
def uninstall_module(name: str, cfg: Dict[str, Any], ctx: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Uninstall a module by removing its files."""
|
||||||
|
result: Dict[str, Any] = {
|
||||||
|
"module": name,
|
||||||
|
"status": "success",
|
||||||
|
"uninstalled_at": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
install_dir = ctx["install_dir"]
|
||||||
|
removed_paths = []
|
||||||
|
|
||||||
|
for op in cfg.get("operations", []):
|
||||||
|
op_type = op.get("type")
|
||||||
|
try:
|
||||||
|
if op_type in ("copy_dir", "copy_file"):
|
||||||
|
target = (install_dir / op["target"]).expanduser().resolve()
|
||||||
|
if target.exists():
|
||||||
|
if target.is_dir():
|
||||||
|
shutil.rmtree(target)
|
||||||
|
else:
|
||||||
|
target.unlink()
|
||||||
|
removed_paths.append(str(target))
|
||||||
|
write_log({"level": "INFO", "message": f"Removed: {target}"}, ctx)
|
||||||
|
# merge_dir and merge_json are harder to uninstall cleanly, skip
|
||||||
|
except Exception as exc:
|
||||||
|
write_log({"level": "WARNING", "message": f"Failed to remove {op.get('target', 'unknown')}: {exc}"}, ctx)
|
||||||
|
|
||||||
|
result["removed_paths"] = removed_paths
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def update_status_after_uninstall(uninstalled_modules: List[str], ctx: Dict[str, Any]) -> None:
|
||||||
|
"""Remove uninstalled modules from status file."""
|
||||||
|
status = load_installed_status(ctx)
|
||||||
|
modules = status.get("modules", {})
|
||||||
|
|
||||||
|
for name in uninstalled_modules:
|
||||||
|
if name in modules:
|
||||||
|
del modules[name]
|
||||||
|
|
||||||
|
status["modules"] = modules
|
||||||
|
status["updated_at"] = datetime.now().isoformat()
|
||||||
|
|
||||||
|
status_path = Path(ctx["status_file"])
|
||||||
|
with status_path.open("w", encoding="utf-8") as fh:
|
||||||
|
json.dump(status, fh, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def interactive_manage(config: Dict[str, Any], ctx: Dict[str, Any]) -> int:
|
||||||
|
"""Interactive module management menu."""
|
||||||
|
while True:
|
||||||
|
installed_status = get_installed_modules(config, ctx)
|
||||||
|
modules = config.get("modules", {})
|
||||||
|
module_names = list(modules.keys())
|
||||||
|
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("Claude Plugin Manager")
|
||||||
|
print("=" * 70)
|
||||||
|
print(f"{'#':<3} {'Name':<15} {'Status':<15} Description")
|
||||||
|
print("-" * 70)
|
||||||
|
|
||||||
|
for idx, (name, cfg) in enumerate(modules.items(), 1):
|
||||||
|
desc = cfg.get("description", "")[:30]
|
||||||
|
if installed_status.get(name, False):
|
||||||
|
status = "✅ Installed"
|
||||||
|
else:
|
||||||
|
status = "⬚ Not installed"
|
||||||
|
print(f"{idx:<3} {name:<15} {status:<15} {desc}")
|
||||||
|
|
||||||
|
total = len(modules)
|
||||||
|
installed_count = sum(1 for v in installed_status.values() if v)
|
||||||
|
print(f"\nInstalled: {installed_count}/{total} | Dir: {ctx['install_dir']}")
|
||||||
|
|
||||||
|
print("\nCommands:")
|
||||||
|
print(" i <num/name> - Install module(s)")
|
||||||
|
print(" u <num/name> - Uninstall module(s)")
|
||||||
|
print(" q - Quit")
|
||||||
|
print()
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_input = input("Enter command: ").strip()
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
print("\nExiting.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if not user_input:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if user_input.lower() == "q":
|
||||||
|
print("Goodbye!")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
parts = user_input.split(maxsplit=1)
|
||||||
|
cmd = parts[0].lower()
|
||||||
|
args = parts[1] if len(parts) > 1 else ""
|
||||||
|
|
||||||
|
if cmd == "i":
|
||||||
|
# Install
|
||||||
|
selected = _parse_module_selection(args, modules, module_names)
|
||||||
|
if selected:
|
||||||
|
# Filter out already installed
|
||||||
|
to_install = {k: v for k, v in selected.items() if not installed_status.get(k, False)}
|
||||||
|
if not to_install:
|
||||||
|
print("All selected modules are already installed.")
|
||||||
|
continue
|
||||||
|
print(f"\nInstalling: {', '.join(to_install.keys())}")
|
||||||
|
results = []
|
||||||
|
for name, cfg in to_install.items():
|
||||||
|
try:
|
||||||
|
results.append(execute_module(name, cfg, ctx))
|
||||||
|
print(f" ✓ {name} installed")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f" ✗ {name} failed: {exc}")
|
||||||
|
# Update status
|
||||||
|
current_status = load_installed_status(ctx)
|
||||||
|
for r in results:
|
||||||
|
if r.get("status") == "success":
|
||||||
|
current_status.setdefault("modules", {})[r["module"]] = r
|
||||||
|
current_status["updated_at"] = datetime.now().isoformat()
|
||||||
|
with Path(ctx["status_file"]).open("w", encoding="utf-8") as fh:
|
||||||
|
json.dump(current_status, fh, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
elif cmd == "u":
|
||||||
|
# Uninstall
|
||||||
|
selected = _parse_module_selection(args, modules, module_names)
|
||||||
|
if selected:
|
||||||
|
# Filter to only installed ones
|
||||||
|
to_uninstall = {k: v for k, v in selected.items() if installed_status.get(k, False)}
|
||||||
|
if not to_uninstall:
|
||||||
|
print("None of the selected modules are installed.")
|
||||||
|
continue
|
||||||
|
print(f"\nUninstalling: {', '.join(to_uninstall.keys())}")
|
||||||
|
confirm = input("Confirm? (y/N): ").strip().lower()
|
||||||
|
if confirm != "y":
|
||||||
|
print("Cancelled.")
|
||||||
|
continue
|
||||||
|
for name, cfg in to_uninstall.items():
|
||||||
|
try:
|
||||||
|
uninstall_module(name, cfg, ctx)
|
||||||
|
print(f" ✓ {name} uninstalled")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f" ✗ {name} failed: {exc}")
|
||||||
|
update_status_after_uninstall(list(to_uninstall.keys()), ctx)
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f"Unknown command: {cmd}. Use 'i', 'u', or 'q'.")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_module_selection(
|
||||||
|
args: str, modules: Dict[str, Any], module_names: List[str]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Parse module selection from user input."""
|
||||||
|
if not args:
|
||||||
|
print("Please specify module number(s) or name(s).")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
if args.lower() == "all":
|
||||||
|
return dict(modules.items())
|
||||||
|
|
||||||
|
selected: Dict[str, Any] = {}
|
||||||
|
parts = [p.strip() for p in args.replace(",", " ").split() if p.strip()]
|
||||||
|
|
||||||
|
for part in parts:
|
||||||
|
if part.isdigit():
|
||||||
|
idx = int(part) - 1
|
||||||
|
if 0 <= idx < len(module_names):
|
||||||
|
name = module_names[idx]
|
||||||
|
selected[name] = modules[name]
|
||||||
|
else:
|
||||||
|
print(f"Invalid number: {part}")
|
||||||
|
return {}
|
||||||
|
elif part in modules:
|
||||||
|
selected[part] = modules[part]
|
||||||
|
else:
|
||||||
|
print(f"Module not found: '{part}'")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
return selected
|
||||||
|
|
||||||
|
|
||||||
def ensure_install_dir(path: Path) -> None:
|
def ensure_install_dir(path: Path) -> None:
|
||||||
path = Path(path)
|
path = Path(path)
|
||||||
if path.exists() and not path.is_dir():
|
if path.exists() and not path.is_dir():
|
||||||
@@ -529,10 +860,54 @@ def main(argv: Optional[Iterable[str]] = None) -> int:
|
|||||||
|
|
||||||
ctx = resolve_paths(config, args)
|
ctx = resolve_paths(config, args)
|
||||||
|
|
||||||
|
# Handle --list-modules
|
||||||
if getattr(args, "list_modules", False):
|
if getattr(args, "list_modules", False):
|
||||||
list_modules(config)
|
list_modules(config)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
# Handle --status
|
||||||
|
if getattr(args, "status", False):
|
||||||
|
list_modules_with_status(config, ctx)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Handle --uninstall
|
||||||
|
if getattr(args, "uninstall", False):
|
||||||
|
if not args.module:
|
||||||
|
print("Error: --uninstall requires --module to specify which modules to uninstall")
|
||||||
|
return 1
|
||||||
|
modules = config.get("modules", {})
|
||||||
|
installed = load_installed_status(ctx)
|
||||||
|
installed_modules = installed.get("modules", {})
|
||||||
|
|
||||||
|
selected = select_modules(config, args.module)
|
||||||
|
to_uninstall = {k: v for k, v in selected.items() if k in installed_modules}
|
||||||
|
|
||||||
|
if not to_uninstall:
|
||||||
|
print("None of the specified modules are installed.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print(f"Uninstalling {len(to_uninstall)} module(s): {', '.join(to_uninstall.keys())}")
|
||||||
|
for name, cfg in to_uninstall.items():
|
||||||
|
try:
|
||||||
|
uninstall_module(name, cfg, ctx)
|
||||||
|
print(f" ✓ {name} uninstalled")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f" ✗ {name} failed: {exc}", file=sys.stderr)
|
||||||
|
|
||||||
|
update_status_after_uninstall(list(to_uninstall.keys()), ctx)
|
||||||
|
print(f"\n✓ Uninstall complete")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# No --module specified: enter interactive management mode
|
||||||
|
if not args.module:
|
||||||
|
try:
|
||||||
|
ensure_install_dir(ctx["install_dir"])
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"Failed to prepare install dir: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
return interactive_manage(config, ctx)
|
||||||
|
|
||||||
|
# Install specified modules
|
||||||
modules = select_modules(config, args.module)
|
modules = select_modules(config, args.module)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -568,7 +943,14 @@ def main(argv: Optional[Iterable[str]] = None) -> int:
|
|||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
write_status(results, ctx)
|
# Merge with existing status
|
||||||
|
current_status = load_installed_status(ctx)
|
||||||
|
for r in results:
|
||||||
|
if r.get("status") == "success":
|
||||||
|
current_status.setdefault("modules", {})[r["module"]] = r
|
||||||
|
current_status["updated_at"] = datetime.now().isoformat()
|
||||||
|
with Path(ctx["status_file"]).open("w", encoding="utf-8") as fh:
|
||||||
|
json.dump(current_status, fh, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
# Summary
|
# Summary
|
||||||
success = sum(1 for r in results if r.get("status") == "success")
|
success = sum(1 for r in results if r.get("status") == "success")
|
||||||
|
|||||||
Reference in New Issue
Block a user