damage-control-dev 0.1.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.
Files changed (74) hide show
  1. damage_control/__init__.py +9 -0
  2. damage_control/__main__.py +7 -0
  3. damage_control/adapter.py +179 -0
  4. damage_control/audit.py +432 -0
  5. damage_control/breakglass.py +155 -0
  6. damage_control/claude_code.py +148 -0
  7. damage_control/cli.py +173 -0
  8. damage_control/engine.py +10656 -0
  9. damage_control/groups.py +114 -0
  10. damage_control/harness_config.py +205 -0
  11. damage_control/hook.py +54 -0
  12. damage_control/install.py +618 -0
  13. damage_control/packs.py +219 -0
  14. damage_control/policy.py +323 -0
  15. damage_control/profile.py +177 -0
  16. damage_control/rules/aws.json +85 -0
  17. damage_control/rules/aws.yaml +76 -0
  18. damage_control/rules/cloud-hosting.json +162 -0
  19. damage_control/rules/cloud-hosting.yaml +163 -0
  20. damage_control/rules/containers.json +67 -0
  21. damage_control/rules/containers.yaml +69 -0
  22. damage_control/rules/control-plane.json +7 -0
  23. damage_control/rules/control-plane.yaml +21 -0
  24. damage_control/rules/core.json +332 -0
  25. damage_control/rules/core.yaml +441 -0
  26. damage_control/rules/databases.json +117 -0
  27. damage_control/rules/databases.yaml +123 -0
  28. damage_control/rules/firebase.json +34 -0
  29. damage_control/rules/firebase.yaml +30 -0
  30. damage_control/rules/gcp.json +60 -0
  31. damage_control/rules/gcp.yaml +57 -0
  32. damage_control/rules/git.json +119 -0
  33. damage_control/rules/git.yaml +120 -0
  34. damage_control/rules/infrastructure.json +69 -0
  35. damage_control/rules/infrastructure.yaml +66 -0
  36. damage_control/rules/mcp-tools.json +11 -0
  37. damage_control/rules/mcp-tools.yaml +47 -0
  38. damage_control/rules/outbound.json +40 -0
  39. damage_control/rules/outbound.yaml +58 -0
  40. damage_control/rules/payloads.json +120 -0
  41. damage_control/rules/payloads.yaml +155 -0
  42. damage_control/rules/publish.json +46 -0
  43. damage_control/rules/publish.yaml +46 -0
  44. damage_control/rules/registry.json +10 -0
  45. damage_control/rules/registry.yaml +11 -0
  46. damage_control/rules/remote.json +31 -0
  47. damage_control/rules/remote.yaml +53 -0
  48. damage_control/rules/tmux.json +17 -0
  49. damage_control/rules/tmux.yaml +16 -0
  50. damage_control/ruleset.py +94 -0
  51. damage_control/tooldefs/aws.json +140 -0
  52. damage_control/tooldefs/aws.yaml +119 -0
  53. damage_control/tooldefs/docker.json +146 -0
  54. damage_control/tooldefs/docker.yaml +123 -0
  55. damage_control/tooldefs/gcp.json +186 -0
  56. damage_control/tooldefs/gcp.yaml +158 -0
  57. damage_control/tooldefs/gh.json +172 -0
  58. damage_control/tooldefs/gh.yaml +145 -0
  59. damage_control/tooldefs/git.json +166 -0
  60. damage_control/tooldefs/git.yaml +143 -0
  61. damage_control/tooldefs/kubectl.json +101 -0
  62. damage_control/tooldefs/kubectl.yaml +85 -0
  63. damage_control/tooldefs/npm.json +77 -0
  64. damage_control/tooldefs/npm.yaml +65 -0
  65. damage_control/tooldefs/terraform.json +68 -0
  66. damage_control/tooldefs/terraform.yaml +57 -0
  67. damage_control/tooldefs/uv.json +112 -0
  68. damage_control/tooldefs/uv.yaml +97 -0
  69. damage_control_dev-0.1.0.dist-info/METADATA +15 -0
  70. damage_control_dev-0.1.0.dist-info/RECORD +74 -0
  71. damage_control_dev-0.1.0.dist-info/WHEEL +4 -0
  72. damage_control_dev-0.1.0.dist-info/entry_points.txt +2 -0
  73. damage_control_dev-0.1.0.dist-info/licenses/LICENSE +201 -0
  74. damage_control_dev-0.1.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env python3
2
+ """Break-glass for the damage-control hooks: works when the venv they run from does not.
3
+
4
+ The hooks run ``<venv python> -I -m damage_control.hook <kind>`` with ``"onFailure": "block"``,
5
+ so a venv that is gone (``uv tool install --force`` died halfway, a managed Python was
6
+ uninstalled) makes Claude Code refuse EVERY tool call, including the ones you would use to
7
+ repair it. This file is the way out. It is standard library only and ``init`` copies it to
8
+ ``<config dir>/bin/damage-control-breakglass``. Run it with the system ``python3`` from an
9
+ ordinary terminal (not through Claude Code):
10
+
11
+ python3 damage-control-breakglass status [--settings PATH]
12
+ python3 damage-control-breakglass unregister [--settings PATH]
13
+
14
+ ``unregister`` removes exactly the damage-control entries (the file is backed up first,
15
+ written atomically, and an unparseable file is refused, never rewritten). With them gone
16
+ Claude Code stops calling the hooks, so **damage control is OFF** until you repair the
17
+ install and run ``damage-control-dev init``.
18
+
19
+ It is a human's tool: the hook guards ``~/.claude/settings.json``, so a policed agent
20
+ cannot rewrite it.
21
+ """
22
+
23
+ import json
24
+ import os
25
+ import shutil
26
+ import subprocess
27
+ import sys
28
+ import tempfile
29
+ from pathlib import Path
30
+
31
+ HOOK_MODULE = "damage_control.hook"
32
+ BACKUP_SUFFIX = ".breakglass.bak"
33
+
34
+
35
+ def default_settings() -> Path:
36
+ return Path.home() / ".claude" / "settings.json"
37
+
38
+
39
+ def is_ours(hook) -> bool:
40
+ args = hook.get("args") if isinstance(hook, dict) else None
41
+ return isinstance(args, list) and HOOK_MODULE in args
42
+
43
+
44
+ def load(path: Path):
45
+ try:
46
+ raw = path.read_bytes()
47
+ except FileNotFoundError:
48
+ return None, {}
49
+ parsed = json.loads(raw.decode("utf-8-sig")) # ValueError propagates: an unparseable file is refused
50
+ if not isinstance(parsed, dict):
51
+ raise ValueError(f"{path} is not a JSON object")
52
+ return raw, parsed
53
+
54
+
55
+ def owned(settings) -> list:
56
+ hooks = settings.get("hooks")
57
+ pre = hooks.get("PreToolUse") if isinstance(hooks, dict) else None
58
+ found = []
59
+ for entry in pre if isinstance(pre, list) else []:
60
+ inner = entry.get("hooks") if isinstance(entry, dict) else None
61
+ for hook in inner if isinstance(inner, list) else []:
62
+ if is_ours(hook):
63
+ found.append((entry.get("matcher"), hook))
64
+ return found
65
+
66
+
67
+ def runs(interpreter: str) -> bool:
68
+ try:
69
+ return subprocess.run([interpreter, "-I", "-c", "import damage_control"],
70
+ capture_output=True, timeout=20).returncode == 0
71
+ except (OSError, subprocess.SubprocessError):
72
+ return False
73
+
74
+
75
+ def cmd_status(path: Path) -> int:
76
+ try:
77
+ _, settings = load(path)
78
+ except (ValueError, OSError) as e:
79
+ print(f"{path}: cannot read it ({e}); nothing to report")
80
+ return 1
81
+ entries = owned(settings)
82
+ if not entries:
83
+ print(f"No damage-control hooks are registered in {path}.")
84
+ return 0
85
+ print(f"{len(entries)} damage-control hook entries in {path}")
86
+ code = 0
87
+ for interpreter in sorted({h.get("command", "") for _, h in entries}):
88
+ ok = runs(interpreter)
89
+ print(f" interpreter {interpreter}: {'ok' if ok else 'DEAD — every tool call is being blocked'}")
90
+ code |= 0 if ok else 1
91
+ if code:
92
+ print("Fix: run `unregister`, repair the install, then `damage-control-dev init`.")
93
+ return code
94
+
95
+
96
+ def cmd_unregister(path: Path) -> int:
97
+ try:
98
+ raw, settings = load(path)
99
+ except (ValueError, OSError) as e:
100
+ print(f"REFUSED: {path} cannot be read as JSON ({e}). Edit it by hand: delete the "
101
+ f"PreToolUse entries whose command runs `-m {HOOK_MODULE}`.")
102
+ return 1
103
+ if not owned(settings):
104
+ print("Nothing to remove.")
105
+ return 0
106
+ kept = []
107
+ for entry in settings["hooks"]["PreToolUse"]:
108
+ inner = entry.get("hooks") if isinstance(entry, dict) else None
109
+ if isinstance(inner, list):
110
+ remaining = [h for h in inner if not is_ours(h)]
111
+ if len(remaining) != len(inner):
112
+ if not remaining:
113
+ continue
114
+ entry = dict(entry, hooks=remaining)
115
+ kept.append(entry)
116
+ settings["hooks"]["PreToolUse"] = kept
117
+ backup = path.with_name(path.name + BACKUP_SUFFIX)
118
+ if raw is not None and not backup.exists():
119
+ shutil.copy2(path, backup)
120
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
121
+ try:
122
+ with os.fdopen(fd, "w") as f:
123
+ f.write(json.dumps(settings, indent=2))
124
+ f.flush()
125
+ os.fsync(f.fileno())
126
+ shutil.copymode(path, tmp)
127
+ os.replace(tmp, path)
128
+ finally:
129
+ if os.path.exists(tmp):
130
+ os.unlink(tmp)
131
+ print(f"Removed the damage-control hooks from {path} (backup: {backup.name}).")
132
+ print("DAMAGE CONTROL IS NOW OFF. Repair the install, then re-register:")
133
+ print(" damage-control-dev init")
134
+ return 0
135
+
136
+
137
+ def main(argv) -> int:
138
+ args = list(argv)
139
+ path = default_settings()
140
+ if "--settings" in args:
141
+ i = args.index("--settings")
142
+ if i + 1 >= len(args):
143
+ print(__doc__)
144
+ return 2
145
+ path = Path(args[i + 1])
146
+ del args[i:i + 2]
147
+ commands = {"status": cmd_status, "unregister": cmd_unregister}
148
+ if len(args) != 1 or args[0] not in commands:
149
+ print(__doc__)
150
+ return 2
151
+ return commands[args[0]](path)
152
+
153
+
154
+ if __name__ == "__main__":
155
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,148 @@
1
+ """Claude Code as an install target (#1209 M5): where its settings live, what a registration is.
2
+
3
+ A registration is the exec form Claude Code runs without a shell:
4
+ ``{"command": <absolute interpreter>, "args": ["-I", "-m", "damage_control.hook", kind, ...],
5
+ "timeout": N, "onFailure": "block"}``. Our entries are recognized by the package module name
6
+ in ``args`` and by nothing else.
7
+
8
+ Standard library only.
9
+ """
10
+
11
+ import os
12
+ import re
13
+ import subprocess
14
+ import sys
15
+ from pathlib import Path
16
+ from typing import Any, Dict, List, NamedTuple, Optional, Tuple
17
+
18
+ HOOK_MODULE = "damage_control.hook"
19
+ HARNESS = "claude-code"
20
+
21
+ # ``onFailure: "block"`` and exec-form ``args`` need Claude Code >= this. Below it a hook crash,
22
+ # timeout or missing interpreter lets the tool call RUN, so registering anyway is a weaker
23
+ # config than the one asked for.
24
+ ONFAILURE_MIN_VERSION = (2, 1, 295)
25
+
26
+ # matcher -> hook kind. Several matchers share a kind (Read/Grep/Glob), PowerShell is a
27
+ # separate tool name with Bash's input shape.
28
+ MATCHERS: Dict[str, str] = {
29
+ "Bash": "bash",
30
+ "PowerShell": "bash",
31
+ "Edit": "edit",
32
+ "NotebookEdit": "edit",
33
+ "Write": "write",
34
+ "Read": "read",
35
+ "Grep": "read",
36
+ "Glob": "read",
37
+ "mcp__.*": "mcp",
38
+ }
39
+
40
+ SCOPES = ("user", "project", "managed")
41
+
42
+
43
+ def settings_path(scope: str, cwd: Optional[Path] = None) -> Path:
44
+ """The settings file ``scope`` registers into."""
45
+ if scope == "user":
46
+ return Path.home() / ".claude" / "settings.json"
47
+ if scope == "project":
48
+ return (cwd or Path.cwd()) / ".claude" / "settings.json"
49
+ if scope == "managed":
50
+ base = (Path("/Library/Application Support/ClaudeCode") if sys.platform == "darwin"
51
+ else Path("/etc/claude-code"))
52
+ return base / "managed-settings.d" / "50-damage-control.json"
53
+ raise ValueError(f"unknown scope {scope!r}; expected one of {SCOPES}")
54
+
55
+
56
+ def parse_version(text: str) -> Optional[Tuple[int, ...]]:
57
+ m = re.match(r"\s*(\d+)\.(\d+)\.(\d+)", text or "")
58
+ return tuple(int(g) for g in m.groups()) if m else None
59
+
60
+
61
+ def detect_version() -> Optional[Tuple[int, ...]]:
62
+ """The installed Claude Code version, or None when ``claude`` is absent or unreadable."""
63
+ try:
64
+ out = subprocess.run(["claude", "--version"], capture_output=True, text=True, timeout=15).stdout
65
+ except (OSError, subprocess.SubprocessError):
66
+ return None
67
+ return parse_version(out)
68
+
69
+
70
+ def version_text(version: Optional[Tuple[int, ...]]) -> str:
71
+ return ".".join(map(str, version)) if version else "unknown"
72
+
73
+
74
+ def supports_fail_closed(version: Optional[Tuple[int, ...]]) -> Optional[bool]:
75
+ """True/False once the version is known; None when it could not be read."""
76
+ return None if version is None else version >= ONFAILURE_MIN_VERSION
77
+
78
+
79
+ def hook_args(kind: str, profile: Optional[str] = None) -> List[str]:
80
+ args = ["-I", "-m", HOOK_MODULE, kind]
81
+ if profile:
82
+ args += ["--profile", profile]
83
+ return args
84
+
85
+
86
+ def is_ours(hook: Any) -> bool:
87
+ """A hook dict that runs this package's hook module."""
88
+ args = hook.get("args") if isinstance(hook, dict) else None
89
+ return isinstance(args, list) and HOOK_MODULE in args
90
+
91
+
92
+ def profile_of(hook: Dict[str, Any]) -> Optional[str]:
93
+ """The ``--profile`` path in one of our hook dicts, if any."""
94
+ args = hook.get("args") or []
95
+ return args[args.index("--profile") + 1] if "--profile" in args and args.index("--profile") + 1 < len(args) else None
96
+
97
+
98
+ def kind_of(hook: Dict[str, Any]) -> Optional[str]:
99
+ args = hook.get("args") or []
100
+ i = args.index(HOOK_MODULE) + 1 if HOOK_MODULE in args else len(args)
101
+ return args[i] if i < len(args) else None
102
+
103
+
104
+ class Owned(NamedTuple):
105
+ matcher: Any
106
+ hook: Dict[str, Any]
107
+
108
+
109
+ def pre_tool_use(settings: Dict[str, Any]) -> Optional[List[Any]]:
110
+ hooks = settings.get("hooks")
111
+ pre = hooks.get("PreToolUse") if isinstance(hooks, dict) else None
112
+ return pre if isinstance(pre, list) else None
113
+
114
+
115
+ def owned_entries(settings: Dict[str, Any]) -> List[Owned]:
116
+ """Every hook of ours in ``settings``, with the matcher it sits under. Malformed entries are skipped."""
117
+ found: List[Owned] = []
118
+ for entry in pre_tool_use(settings) or []:
119
+ inner = entry.get("hooks") if isinstance(entry, dict) else None
120
+ if isinstance(inner, list):
121
+ found.extend(Owned(entry.get("matcher"), h) for h in inner if is_ours(h))
122
+ return found
123
+
124
+
125
+ def foreign_entry_count(settings: Dict[str, Any]) -> int:
126
+ """PreToolUse hooks that are not ours: the user's own, left strictly alone."""
127
+ count = 0
128
+ for entry in pre_tool_use(settings) or []:
129
+ inner = entry.get("hooks") if isinstance(entry, dict) else None
130
+ count += sum(1 for h in inner if not is_ours(h)) if isinstance(inner, list) else 0
131
+ return count
132
+
133
+
134
+ def wanted_hook(interpreter: str, kind: str, timeout: int, fail_closed: bool,
135
+ profile: Optional[str] = None) -> Dict[str, Any]:
136
+ hook: Dict[str, Any] = {"type": "command", "command": interpreter,
137
+ "args": hook_args(kind, profile), "timeout": timeout}
138
+ if fail_closed:
139
+ hook["onFailure"] = "block"
140
+ return hook
141
+
142
+
143
+ def selftest_command(interpreter: str, kind: str, profile: Optional[str] = None) -> List[str]:
144
+ return [interpreter, *hook_args(kind, profile)]
145
+
146
+
147
+ def absolute_interpreter() -> str:
148
+ return os.path.abspath(sys.executable)
damage_control/cli.py ADDED
@@ -0,0 +1,173 @@
1
+ """``damage-control-dev``: install, inspect and operate the Claude Code hooks (#1209 M5).
2
+
3
+ damage-control-dev init [--scope user|project|managed] [--dry-run] [--refresh] [--fail-open]
4
+ damage-control-dev status [--json] # read-only drift report
5
+ damage-control-dev doctor [--fix] [--json]
6
+ damage-control-dev uninstall [--purge]
7
+ damage-control-dev check "<command>" # what would the hook decide?
8
+ damage-control-dev rules # the enforced rule ids
9
+ damage-control-dev packs|groups list|enable|disable [name]
10
+
11
+ Exit status: 0 = fine, 1 = a refusal or a finding marked ``[!!]``, 2 = bad usage.
12
+ """
13
+
14
+ import argparse
15
+ import json
16
+ import sys
17
+ from pathlib import Path
18
+ from typing import List, Optional
19
+
20
+ from damage_control import __version__, engine, install, packs
21
+ from damage_control import claude_code as cc
22
+ from damage_control import harness_config as hc
23
+ from damage_control.profile import ProfileError, load_profile
24
+
25
+
26
+ def _common(parser: argparse.ArgumentParser) -> None:
27
+ parser.add_argument("--harness", default=cc.HARNESS, choices=[cc.HARNESS], help="the agent harness (Claude Code only)")
28
+ parser.add_argument("--scope", default="user", choices=cc.SCOPES, help="which settings file to manage (default: user)")
29
+ parser.add_argument("--settings", metavar="PATH", help="manage this settings file instead of the scope's")
30
+ parser.add_argument("--profile", metavar="PATH", help="a host profile manifest the hooks load (for host installers)")
31
+
32
+
33
+ def build_parser() -> argparse.ArgumentParser:
34
+ parser = argparse.ArgumentParser(prog="damage-control-dev", description=__doc__.split("\n\n")[0])
35
+ parser.add_argument("--version", action="version", version=f"damage-control-dev {__version__}")
36
+ sub = parser.add_subparsers(dest="command", required=True)
37
+
38
+ p = sub.add_parser("init", help="register the hooks, prove they run, record the install")
39
+ _common(p)
40
+ p.add_argument("--dry-run", action="store_true", help="show the diff and write nothing")
41
+ p.add_argument("--refresh", action="store_true", help="print and acknowledge the ruleset diff (loosening is marked !!)")
42
+ p.add_argument("--force", action="store_true", help="override the downgrade refusal")
43
+ p.add_argument("--fail-open", action="store_true", help="register without onFailure=block (recorded in the policy)")
44
+ p.add_argument("--allow-dev-source", action="store_true", help="allow an editable/vcs/uvx install to write machine-global state")
45
+ p.add_argument("--apply", action="store_true", help="managed scope only: actually write (otherwise a dry run)")
46
+
47
+ p = sub.add_parser("status", help="read-only drift report")
48
+ _common(p)
49
+ p.add_argument("--json", action="store_true")
50
+
51
+ p = sub.add_parser("doctor", help="status plus self-test, harness version and repair")
52
+ _common(p)
53
+ p.add_argument("--json", action="store_true")
54
+ p.add_argument("--fix", action="store_true", help="re-run init to repair the registration")
55
+ p.add_argument("--allow-dev-source", action="store_true")
56
+
57
+ p = sub.add_parser("uninstall", help="remove exactly our entries")
58
+ _common(p)
59
+ p.add_argument("--purge", action="store_true", help="also delete the policy, overlays and install state")
60
+
61
+ p = sub.add_parser("check", help="what would the hooks decide for a shell command?")
62
+ p.add_argument("--profile", metavar="PATH")
63
+ p.add_argument("text")
64
+ p.add_argument("--json", action="store_true")
65
+
66
+ p = sub.add_parser("rules", help="list the enforced rule ids")
67
+ p.add_argument("--profile", metavar="PATH")
68
+ p.add_argument("--json", action="store_true")
69
+
70
+ for noun in ("packs", "groups"):
71
+ p = sub.add_parser(noun, help=f"list, enable or disable {noun} (enable/disable: human terminal only)")
72
+ p.add_argument("--profile", metavar="PATH")
73
+ p.add_argument("action", choices=["list", "enable", "disable"])
74
+ p.add_argument("name", nargs="?")
75
+ return parser
76
+
77
+
78
+ def _load_profile(path: Optional[str]) -> None:
79
+ if path:
80
+ engine.install_profile(load_profile(Path(path)))
81
+
82
+
83
+ def _print_findings(findings: List[install.Finding], as_json: bool) -> int:
84
+ code = install.exit_code(findings)
85
+ if as_json:
86
+ print(json.dumps({"ok": code == 0, "version": __version__, "profile": engine.PROFILE.name,
87
+ "findings": [f._asdict() for f in findings]}, indent=2))
88
+ else:
89
+ for f in findings:
90
+ print(f.line())
91
+ return code
92
+
93
+
94
+ def _cmd_check(args: argparse.Namespace) -> int:
95
+ result = engine.check_command(args.text, engine.load_effective_config(), cwd=str(Path.cwd()))
96
+ if args.json:
97
+ print(json.dumps(result, indent=2, default=str))
98
+ else:
99
+ print(f"{result.get('decision')}: {result.get('reason') or 'no rule matched'}"
100
+ + (f" [{result['id']}]" if result.get("id") else ""))
101
+ return 0
102
+
103
+
104
+ def _cmd_rules(args: argparse.Namespace) -> int:
105
+ config = engine.load_effective_config()
106
+ rows = [{"id": r["id"], "tier": "ask" if r.get("ask") else "block", "source": r.get("source")}
107
+ for r in config["bashToolPatterns"]]
108
+ if args.json:
109
+ print(json.dumps(rows, indent=2))
110
+ else:
111
+ for r in rows:
112
+ print(f"{r['tier']:5} {r['id']} ({r['source']})")
113
+ return 0
114
+
115
+
116
+ def _cmd_pack_group(args: argparse.Namespace) -> int:
117
+ kind = "pack" if args.command == "packs" else "group"
118
+ policy = engine.policy_file_path()
119
+ if args.action == "list":
120
+ for row in packs.overview(policy, *engine.overlay_dirs()):
121
+ if row["kind"] == kind:
122
+ print(f"{'on ' if row['enabled'] else 'off'} {row['name']:16} {row['description']}")
123
+ return 0
124
+ if not sys.stdin.isatty():
125
+ print(f"REFUSED: changing damage-control {kind}s is a policy change; run it from your own terminal.", file=sys.stderr)
126
+ return 1
127
+ try:
128
+ packs.set_enabled(policy, kind, args.name or "", args.action == "enable")
129
+ except packs.MembershipError as e:
130
+ print(f"REFUSED: {e}", file=sys.stderr)
131
+ return 1
132
+ print(f"{args.name}: {'enabled' if args.action == 'enable' else 'disabled'}")
133
+ return 0
134
+
135
+
136
+ def run(args: argparse.Namespace) -> int:
137
+ _load_profile(getattr(args, "profile", None))
138
+ profile = getattr(args, "profile", None)
139
+ command = args.command
140
+ if command == "init":
141
+ return install.init(scope=args.scope, settings=args.settings, dry_run=args.dry_run, refresh=args.refresh,
142
+ force=args.force, fail_open=args.fail_open, allow_dev_source=args.allow_dev_source,
143
+ apply=args.apply, profile=profile)
144
+ if command == "status":
145
+ return _print_findings(install.inspect(args.scope, args.settings, profile_hint=profile), args.json)
146
+ if command == "doctor":
147
+ findings = install.inspect(args.scope, args.settings, deep=True, profile_hint=profile)
148
+ code = _print_findings(findings, args.json)
149
+ if args.fix and code:
150
+ print("--fix: re-running init to repair the registration", file=sys.stderr)
151
+ return install.init(scope=args.scope, settings=args.settings, allow_dev_source=args.allow_dev_source,
152
+ profile=profile, force=False)
153
+ return code
154
+ if command == "uninstall":
155
+ return install.uninstall(args.scope, args.settings, purge=args.purge)
156
+ if command == "check":
157
+ return _cmd_check(args)
158
+ if command == "rules":
159
+ return _cmd_rules(args)
160
+ return _cmd_pack_group(args)
161
+
162
+
163
+ def main(argv: Optional[List[str]] = None) -> int:
164
+ args = build_parser().parse_args(argv)
165
+ try:
166
+ return run(args)
167
+ except (install.InstallError, hc.ConfigError, ProfileError) as e:
168
+ print(f"REFUSED: {e}", file=sys.stderr)
169
+ return 1
170
+
171
+
172
+ if __name__ == "__main__":
173
+ sys.exit(main())