agent-governance 1.0.4__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.
- agent_governance/__init__.py +49 -0
- agent_governance/agentctl.py +945 -0
- agent_governance/contracts/__init__.py +0 -0
- agent_governance/contracts/output_packet.schema.json +88 -0
- agent_governance/contracts/task_packet.schema.json +74 -0
- agent_governance/init.py +1049 -0
- agent_governance/update_check.py +310 -0
- agent_governance-1.0.4.dist-info/METADATA +133 -0
- agent_governance-1.0.4.dist-info/RECORD +12 -0
- agent_governance-1.0.4.dist-info/WHEEL +5 -0
- agent_governance-1.0.4.dist-info/entry_points.txt +2 -0
- agent_governance-1.0.4.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,945 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from importlib import resources
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
from jsonschema import validate, ValidationError
|
|
16
|
+
|
|
17
|
+
from importlib import metadata
|
|
18
|
+
|
|
19
|
+
from packaging.version import InvalidVersion, Version
|
|
20
|
+
|
|
21
|
+
from agent_governance import PACKAGE_NAME, get_version
|
|
22
|
+
from agent_governance.init import (
|
|
23
|
+
ParseError,
|
|
24
|
+
check_agents_md,
|
|
25
|
+
parse_agents_policy,
|
|
26
|
+
render_error_report,
|
|
27
|
+
run_init,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
CANONICAL_ROLES = [
|
|
31
|
+
{"name": "triage", "purpose": "Clarify scope, risks, and next steps."},
|
|
32
|
+
{"name": "testing", "purpose": "Add/adjust tests and verify coverage."},
|
|
33
|
+
{"name": "bugfix", "purpose": "Fix defects with minimal scope."},
|
|
34
|
+
{"name": "refactor", "purpose": "Improve structure without behavior changes."},
|
|
35
|
+
{"name": "docs", "purpose": "Update documentation and usage notes."},
|
|
36
|
+
{"name": "data_contract", "purpose": "Define or validate data contracts."},
|
|
37
|
+
{"name": "perf", "purpose": "Profile and improve performance."},
|
|
38
|
+
{"name": "security", "purpose": "Harden security and address threats."},
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
DEFAULT_MIN_TOOL_VERSION = "1.0.4"
|
|
42
|
+
from agent_governance.update_check import maybe_check, resolve_mode
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def resolve_repo_root() -> Path:
|
|
46
|
+
env_root = os.environ.get("AGENT_GOVERNANCE_ROOT")
|
|
47
|
+
if env_root:
|
|
48
|
+
return Path(env_root).resolve()
|
|
49
|
+
start = Path.cwd().resolve()
|
|
50
|
+
for parent in [start, *start.parents]:
|
|
51
|
+
git_marker = parent / ".git"
|
|
52
|
+
if git_marker.exists():
|
|
53
|
+
return parent
|
|
54
|
+
return start
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
ROOT = resolve_repo_root()
|
|
58
|
+
CONTRACTS = ROOT / "agents" / "contracts"
|
|
59
|
+
TEMPLATES = CONTRACTS / "templates"
|
|
60
|
+
REPORTS_TASKS = ROOT / "reports" / "tasks"
|
|
61
|
+
REPORTS_GATES = ROOT / "reports" / "gates"
|
|
62
|
+
LOGS_AGENTS = ROOT / "logs" / "agents"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _load_agents_policy(strict: bool) -> dict[str, object] | None:
|
|
66
|
+
path = ROOT / "AGENTS.md"
|
|
67
|
+
if not path.exists():
|
|
68
|
+
return None
|
|
69
|
+
try:
|
|
70
|
+
policy, _block = parse_agents_policy(path)
|
|
71
|
+
except ParseError:
|
|
72
|
+
if strict:
|
|
73
|
+
raise
|
|
74
|
+
return None
|
|
75
|
+
version = policy.get("policy_schema_version")
|
|
76
|
+
if not isinstance(version, int):
|
|
77
|
+
if strict:
|
|
78
|
+
raise ParseError(path, "policy_schema_version must be an integer")
|
|
79
|
+
return None
|
|
80
|
+
if version < 1 or version > 1:
|
|
81
|
+
if strict:
|
|
82
|
+
raise ParseError(path, f"unsupported policy_schema_version: {version}")
|
|
83
|
+
return None
|
|
84
|
+
return policy
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _detect_installation() -> tuple[str, bool, str]:
|
|
88
|
+
version = get_version()
|
|
89
|
+
if version == "unknown":
|
|
90
|
+
return version, False, "unknown_version"
|
|
91
|
+
try:
|
|
92
|
+
dist = metadata.distribution(PACKAGE_NAME)
|
|
93
|
+
except metadata.PackageNotFoundError:
|
|
94
|
+
return version, False, "no_metadata"
|
|
95
|
+
direct_url = dist.read_text("direct_url.json")
|
|
96
|
+
if direct_url:
|
|
97
|
+
try:
|
|
98
|
+
data = json.loads(direct_url)
|
|
99
|
+
except json.JSONDecodeError:
|
|
100
|
+
return version, False, "direct_url_invalid"
|
|
101
|
+
dir_info = data.get("dir_info", {}) if isinstance(data, dict) else {}
|
|
102
|
+
if isinstance(dir_info, dict) and dir_info.get("editable") is True:
|
|
103
|
+
return version, False, "editable"
|
|
104
|
+
url = data.get("url")
|
|
105
|
+
if isinstance(url, str) and url.startswith("file:"):
|
|
106
|
+
return version, False, "file_url"
|
|
107
|
+
return version, True, "pinned"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _enforce_tool_policy() -> int:
|
|
111
|
+
strict = os.environ.get("CI", "").lower() == "true"
|
|
112
|
+
policy = _load_agents_policy(strict=strict)
|
|
113
|
+
if not policy:
|
|
114
|
+
return 0
|
|
115
|
+
|
|
116
|
+
require_pinned = policy.get("require_pinned_tool")
|
|
117
|
+
min_version = policy.get("min_tool_version")
|
|
118
|
+
max_version = policy.get("max_tool_version")
|
|
119
|
+
|
|
120
|
+
if require_pinned is not None and not isinstance(require_pinned, bool):
|
|
121
|
+
raise ParseError(ROOT / "AGENTS.md", "require_pinned_tool must be boolean")
|
|
122
|
+
if min_version is not None and not isinstance(min_version, str):
|
|
123
|
+
raise ParseError(ROOT / "AGENTS.md", "min_tool_version must be a string")
|
|
124
|
+
if max_version is not None and not isinstance(max_version, str):
|
|
125
|
+
raise ParseError(ROOT / "AGENTS.md", "max_tool_version must be a string")
|
|
126
|
+
|
|
127
|
+
installed_version, pinned, pin_reason = _detect_installation()
|
|
128
|
+
|
|
129
|
+
if require_pinned and not pinned:
|
|
130
|
+
print(
|
|
131
|
+
f"policy requires pinned agent-governance install: detected {pin_reason}",
|
|
132
|
+
file=sys.stderr,
|
|
133
|
+
)
|
|
134
|
+
return 2
|
|
135
|
+
|
|
136
|
+
if min_version or max_version:
|
|
137
|
+
try:
|
|
138
|
+
current = Version(installed_version)
|
|
139
|
+
except InvalidVersion as exc:
|
|
140
|
+
raise ParseError(ROOT / "AGENTS.md", f"invalid installed version: {exc}")
|
|
141
|
+
if min_version:
|
|
142
|
+
try:
|
|
143
|
+
minimum = Version(min_version)
|
|
144
|
+
except InvalidVersion as exc:
|
|
145
|
+
raise ParseError(
|
|
146
|
+
ROOT / "AGENTS.md", f"invalid min_tool_version: {exc}"
|
|
147
|
+
)
|
|
148
|
+
if current < minimum:
|
|
149
|
+
print(
|
|
150
|
+
f"installed version {current} < min_tool_version {minimum}",
|
|
151
|
+
file=sys.stderr,
|
|
152
|
+
)
|
|
153
|
+
return 2
|
|
154
|
+
if max_version:
|
|
155
|
+
try:
|
|
156
|
+
maximum = Version(max_version)
|
|
157
|
+
except InvalidVersion as exc:
|
|
158
|
+
raise ParseError(
|
|
159
|
+
ROOT / "AGENTS.md", f"invalid max_tool_version: {exc}"
|
|
160
|
+
)
|
|
161
|
+
if current > maximum:
|
|
162
|
+
print(
|
|
163
|
+
f"installed version {current} > max_tool_version {maximum}",
|
|
164
|
+
file=sys.stderr,
|
|
165
|
+
)
|
|
166
|
+
return 2
|
|
167
|
+
|
|
168
|
+
return 0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _render_policy_block(
|
|
172
|
+
allowed_roles: list[str],
|
|
173
|
+
min_tool_version: str | None,
|
|
174
|
+
max_tool_version: str | None,
|
|
175
|
+
require_pinned_tool: bool | None,
|
|
176
|
+
) -> str:
|
|
177
|
+
lines = [
|
|
178
|
+
"policy_schema_version: 1",
|
|
179
|
+
f"min_tool_version: {min_tool_version or DEFAULT_MIN_TOOL_VERSION}",
|
|
180
|
+
]
|
|
181
|
+
if max_tool_version:
|
|
182
|
+
lines.append(f"max_tool_version: {max_tool_version}")
|
|
183
|
+
if require_pinned_tool is not None:
|
|
184
|
+
flag = "true" if require_pinned_tool else "false"
|
|
185
|
+
lines.append(f"require_pinned_tool: {flag}")
|
|
186
|
+
lines.append("allowed_roles:")
|
|
187
|
+
for role in sorted(allowed_roles):
|
|
188
|
+
lines.append(f" - {role}")
|
|
189
|
+
return "\n".join(lines + [""])
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _find_policy_block(lines: list[str]) -> tuple[int, int] | None:
|
|
193
|
+
fenced_starts = [idx for idx, line in enumerate(lines) if line.strip().startswith("```yaml")]
|
|
194
|
+
if fenced_starts:
|
|
195
|
+
if len(fenced_starts) > 1:
|
|
196
|
+
raise ParseError(ROOT / "AGENTS.md", "multiple policy blocks found")
|
|
197
|
+
start = fenced_starts[0]
|
|
198
|
+
end = None
|
|
199
|
+
for idx in range(start + 1, len(lines)):
|
|
200
|
+
if lines[idx].strip() == "```":
|
|
201
|
+
end = idx
|
|
202
|
+
break
|
|
203
|
+
if end is None:
|
|
204
|
+
raise ParseError(ROOT / "AGENTS.md", "unterminated policy block")
|
|
205
|
+
for idx, line in enumerate(lines):
|
|
206
|
+
if start <= idx <= end:
|
|
207
|
+
continue
|
|
208
|
+
if line.lstrip().startswith("policy_schema_version:"):
|
|
209
|
+
raise ParseError(ROOT / "AGENTS.md", "multiple policy blocks found")
|
|
210
|
+
return start, end
|
|
211
|
+
|
|
212
|
+
starts = [
|
|
213
|
+
idx
|
|
214
|
+
for idx, line in enumerate(lines)
|
|
215
|
+
if line.lstrip().startswith("policy_schema_version:")
|
|
216
|
+
]
|
|
217
|
+
if not starts:
|
|
218
|
+
return None
|
|
219
|
+
if len(starts) > 1:
|
|
220
|
+
raise ParseError(ROOT / "AGENTS.md", "multiple policy blocks found")
|
|
221
|
+
start = starts[0]
|
|
222
|
+
start_indent = len(lines[start]) - len(lines[start].lstrip())
|
|
223
|
+
end = start
|
|
224
|
+
for idx in range(start + 1, len(lines)):
|
|
225
|
+
line = lines[idx]
|
|
226
|
+
if not line.strip():
|
|
227
|
+
break
|
|
228
|
+
if re.match(r"^#{1,6}\\s", line.lstrip()):
|
|
229
|
+
break
|
|
230
|
+
line_indent = len(line) - len(line.lstrip())
|
|
231
|
+
if line_indent < start_indent:
|
|
232
|
+
break
|
|
233
|
+
end = idx
|
|
234
|
+
return start, end
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _parse_allowed_roles(raw: str) -> list[str]:
|
|
238
|
+
roles = [item.strip() for item in raw.split(",") if item.strip()]
|
|
239
|
+
if not roles:
|
|
240
|
+
raise ParseError(ROOT / "AGENTS.md", "no roles selected")
|
|
241
|
+
known = {entry["name"] for entry in CANONICAL_ROLES}
|
|
242
|
+
unknown = sorted(set(roles) - known)
|
|
243
|
+
if unknown:
|
|
244
|
+
raise ParseError(
|
|
245
|
+
ROOT / "AGENTS.md",
|
|
246
|
+
f"unknown roles: {', '.join(unknown)}",
|
|
247
|
+
)
|
|
248
|
+
return roles
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _interactive_role_selection() -> list[str]:
|
|
252
|
+
if not sys.stdin.isatty():
|
|
253
|
+
raise ParseError(
|
|
254
|
+
ROOT / "AGENTS.md",
|
|
255
|
+
"interactive selection requires a TTY; use --allow",
|
|
256
|
+
)
|
|
257
|
+
print("Available roles:")
|
|
258
|
+
for idx, entry in enumerate(CANONICAL_ROLES, start=1):
|
|
259
|
+
print(f"{idx}. {entry['name']} — {entry['purpose']}")
|
|
260
|
+
selection = input("Select roles (comma-separated numbers or 'all'): ").strip()
|
|
261
|
+
if not selection:
|
|
262
|
+
raise ParseError(ROOT / "AGENTS.md", "no roles selected")
|
|
263
|
+
if selection.lower() == "all":
|
|
264
|
+
return [entry["name"] for entry in CANONICAL_ROLES]
|
|
265
|
+
choices = []
|
|
266
|
+
for item in selection.split(","):
|
|
267
|
+
item = item.strip()
|
|
268
|
+
if not item:
|
|
269
|
+
continue
|
|
270
|
+
if not item.isdigit():
|
|
271
|
+
raise ParseError(ROOT / "AGENTS.md", f"invalid selection: {item}")
|
|
272
|
+
choices.append(int(item))
|
|
273
|
+
roles = []
|
|
274
|
+
for idx in choices:
|
|
275
|
+
if idx < 1 or idx > len(CANONICAL_ROLES):
|
|
276
|
+
raise ParseError(ROOT / "AGENTS.md", f"invalid selection: {idx}")
|
|
277
|
+
roles.append(CANONICAL_ROLES[idx - 1]["name"])
|
|
278
|
+
if not roles:
|
|
279
|
+
raise ParseError(ROOT / "AGENTS.md", "no roles selected")
|
|
280
|
+
return roles
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
REQUIRED_SECTIONS = [
|
|
284
|
+
(
|
|
285
|
+
"## agent init behavior",
|
|
286
|
+
[
|
|
287
|
+
"- init is evidence-only (no LLM)",
|
|
288
|
+
"- ignore: .venv/, node_modules/, .git/",
|
|
289
|
+
"- prefer python3 over python when present",
|
|
290
|
+
],
|
|
291
|
+
),
|
|
292
|
+
("## Notes", ["- Add human context here."]),
|
|
293
|
+
]
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _render_required_sections() -> list[str]:
|
|
297
|
+
lines: list[str] = []
|
|
298
|
+
for heading, items in REQUIRED_SECTIONS:
|
|
299
|
+
if lines:
|
|
300
|
+
lines.append("")
|
|
301
|
+
lines.append(heading)
|
|
302
|
+
lines.extend(items)
|
|
303
|
+
return lines
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def cmd_bootstrap(
|
|
307
|
+
allow: str | None,
|
|
308
|
+
write: bool,
|
|
309
|
+
min_tool_version: str | None,
|
|
310
|
+
max_tool_version: str | None,
|
|
311
|
+
require_pinned_tool: bool | None,
|
|
312
|
+
) -> int:
|
|
313
|
+
try:
|
|
314
|
+
allowed_roles = _parse_allowed_roles(allow) if allow else _interactive_role_selection()
|
|
315
|
+
except ParseError as exc:
|
|
316
|
+
report = render_error_report(ROOT, exc.path, exc.message)
|
|
317
|
+
print(report, end="")
|
|
318
|
+
return 2
|
|
319
|
+
|
|
320
|
+
block = _render_policy_block(
|
|
321
|
+
allowed_roles,
|
|
322
|
+
min_tool_version,
|
|
323
|
+
max_tool_version,
|
|
324
|
+
require_pinned_tool,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
target = ROOT / "AGENTS.md"
|
|
328
|
+
base_lines = block.strip().splitlines()
|
|
329
|
+
base_lines.append("")
|
|
330
|
+
base_lines.extend(_render_required_sections())
|
|
331
|
+
if target.exists():
|
|
332
|
+
lines = target.read_text().splitlines()
|
|
333
|
+
try:
|
|
334
|
+
_find_policy_block(lines)
|
|
335
|
+
except ParseError as exc:
|
|
336
|
+
report = render_error_report(ROOT, exc.path, exc.message)
|
|
337
|
+
print(report, end="")
|
|
338
|
+
return 2
|
|
339
|
+
content = "\n".join(base_lines) + "\n"
|
|
340
|
+
if not write:
|
|
341
|
+
print(content)
|
|
342
|
+
return 0
|
|
343
|
+
target.write_text(content)
|
|
344
|
+
return 0
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def load_schema(name: str) -> dict:
|
|
348
|
+
try:
|
|
349
|
+
schema_path = resources.files("agent_governance.contracts").joinpath(name)
|
|
350
|
+
with schema_path.open("r", encoding="utf-8") as f:
|
|
351
|
+
return json.load(f)
|
|
352
|
+
except FileNotFoundError:
|
|
353
|
+
pass
|
|
354
|
+
if os.environ.get("AGENT_GOVERNANCE_SCHEMA_OVERRIDE") == "1":
|
|
355
|
+
with open(CONTRACTS / name, "r", encoding="utf-8") as f:
|
|
356
|
+
return json.load(f)
|
|
357
|
+
raise FileNotFoundError(f"schema not found: {name}")
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def load_yaml(path):
|
|
361
|
+
with open(path, "r") as f:
|
|
362
|
+
return yaml.safe_load(f)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def write_yaml(path, data):
|
|
366
|
+
with open(path, "w") as f:
|
|
367
|
+
yaml.safe_dump(data, f, sort_keys=False)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def ensure_dir(path):
|
|
371
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def slugify(value):
|
|
375
|
+
slug = re.sub(r"[^a-zA-Z0-9]+", "-", value).strip("-").lower()
|
|
376
|
+
return slug
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def tool_version() -> str:
|
|
380
|
+
return get_version()
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def get_repo_context():
|
|
384
|
+
try:
|
|
385
|
+
branch = (
|
|
386
|
+
subprocess.check_output(
|
|
387
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
388
|
+
cwd=ROOT,
|
|
389
|
+
stderr=subprocess.DEVNULL,
|
|
390
|
+
)
|
|
391
|
+
.decode()
|
|
392
|
+
.strip()
|
|
393
|
+
)
|
|
394
|
+
commit = subprocess.check_output(
|
|
395
|
+
["git", "rev-parse", "HEAD"],
|
|
396
|
+
cwd=ROOT,
|
|
397
|
+
stderr=subprocess.DEVNULL,
|
|
398
|
+
)
|
|
399
|
+
commit = commit.decode().strip()
|
|
400
|
+
except Exception:
|
|
401
|
+
branch = "unknown"
|
|
402
|
+
commit = "unknown"
|
|
403
|
+
return branch, commit
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def load_repo_profile():
|
|
407
|
+
profile_path = ROOT / "agents" / "repo_profile.yaml"
|
|
408
|
+
if not profile_path.exists():
|
|
409
|
+
raise FileNotFoundError("missing agents/repo_profile.yaml")
|
|
410
|
+
return load_yaml(profile_path)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def load_repo_policy_update_check() -> str | None:
|
|
414
|
+
profile_path = ROOT / "agents" / "repo_profile.yaml"
|
|
415
|
+
if not profile_path.exists():
|
|
416
|
+
return None
|
|
417
|
+
profile = load_yaml(profile_path) or {}
|
|
418
|
+
update_check = profile.get("update_check")
|
|
419
|
+
if isinstance(update_check, str):
|
|
420
|
+
return update_check
|
|
421
|
+
return None
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def validate_packet(packet_path, schema_name):
|
|
425
|
+
schema = load_schema(schema_name)
|
|
426
|
+
data = load_yaml(packet_path)
|
|
427
|
+
validate(instance=data, schema=schema)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def cmd_validate(kind, file_path):
|
|
431
|
+
if kind == "task":
|
|
432
|
+
schema = "task_packet.schema.json"
|
|
433
|
+
elif kind == "output":
|
|
434
|
+
schema = "output_packet.schema.json"
|
|
435
|
+
else:
|
|
436
|
+
raise ValueError("kind must be 'task' or 'output'")
|
|
437
|
+
|
|
438
|
+
try:
|
|
439
|
+
validate_packet(file_path, schema)
|
|
440
|
+
print(f"{kind} packet valid: {file_path}")
|
|
441
|
+
except ValidationError as e:
|
|
442
|
+
print(f"{kind} packet INVALID:")
|
|
443
|
+
print(e.message)
|
|
444
|
+
raise SystemExit(2)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def load_task_template():
|
|
448
|
+
template_path = TEMPLATES / "task.yaml"
|
|
449
|
+
if template_path.exists():
|
|
450
|
+
return load_yaml(template_path)
|
|
451
|
+
return {
|
|
452
|
+
"id": "",
|
|
453
|
+
"title": "",
|
|
454
|
+
"role": "",
|
|
455
|
+
"goal": "TBD",
|
|
456
|
+
"repo_context": {"branch": "unknown", "commit": "unknown", "paths": []},
|
|
457
|
+
"inputs": [],
|
|
458
|
+
"constraints": {"allowed_write_paths": [], "forbidden_actions": []},
|
|
459
|
+
"deliverables": ["diff", "logs", "commands"],
|
|
460
|
+
"stop_conditions": ["TBD"],
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def cmd_new_task(role, title):
|
|
465
|
+
ensure_dir(REPORTS_TASKS)
|
|
466
|
+
now = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
|
467
|
+
slug = slugify(title)
|
|
468
|
+
task_id = f"{now}_{slug}" if slug else now
|
|
469
|
+
|
|
470
|
+
task = load_task_template()
|
|
471
|
+
task["id"] = task_id
|
|
472
|
+
task["title"] = title
|
|
473
|
+
task["role"] = role
|
|
474
|
+
|
|
475
|
+
repo_context = task.get("repo_context") or {}
|
|
476
|
+
branch, commit = get_repo_context()
|
|
477
|
+
repo_context.setdefault("branch", branch)
|
|
478
|
+
repo_context.setdefault("commit", commit)
|
|
479
|
+
task["repo_context"] = repo_context
|
|
480
|
+
|
|
481
|
+
output_path = REPORTS_TASKS / f"{task_id}.task.yaml"
|
|
482
|
+
write_yaml(output_path, task)
|
|
483
|
+
print(str(output_path))
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def cmd_log(run_id, task_file, append_file):
|
|
487
|
+
ensure_dir(LOGS_AGENTS)
|
|
488
|
+
output_path = LOGS_AGENTS / f"{run_id}.log"
|
|
489
|
+
timestamp = datetime.utcnow().isoformat() + "Z"
|
|
490
|
+
task_packet = load_yaml(task_file) or {}
|
|
491
|
+
task_id = task_packet.get("id", "unknown")
|
|
492
|
+
role = task_packet.get("role", "unknown")
|
|
493
|
+
|
|
494
|
+
with open(output_path, "a") as out:
|
|
495
|
+
out.write("---\n")
|
|
496
|
+
out.write(f"task_id: {task_id}\n")
|
|
497
|
+
out.write(f"role: {role}\n")
|
|
498
|
+
out.write(f"run_id: {run_id}\n")
|
|
499
|
+
out.write(f"timestamp: {timestamp}\n")
|
|
500
|
+
out.write("---\n")
|
|
501
|
+
with open(append_file, "r") as src:
|
|
502
|
+
content = src.read()
|
|
503
|
+
out.write(content)
|
|
504
|
+
if content and not content.endswith("\n"):
|
|
505
|
+
out.write("\n")
|
|
506
|
+
|
|
507
|
+
print(str(output_path))
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def run_gate_command(name, command, run_dir):
|
|
511
|
+
stdout_path = run_dir / f"{name}.stdout.log"
|
|
512
|
+
stderr_path = run_dir / f"{name}.stderr.log"
|
|
513
|
+
completed = subprocess.run(
|
|
514
|
+
command,
|
|
515
|
+
cwd=ROOT,
|
|
516
|
+
shell=True,
|
|
517
|
+
text=True,
|
|
518
|
+
capture_output=True,
|
|
519
|
+
)
|
|
520
|
+
stdout_path.write_text(completed.stdout)
|
|
521
|
+
stderr_path.write_text(completed.stderr)
|
|
522
|
+
return {
|
|
523
|
+
"name": name,
|
|
524
|
+
"command": command,
|
|
525
|
+
"returncode": completed.returncode,
|
|
526
|
+
"stdout": str(stdout_path),
|
|
527
|
+
"stderr": str(stderr_path),
|
|
528
|
+
"stdout_content": completed.stdout,
|
|
529
|
+
"stderr_content": completed.stderr,
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def cmd_gate_pr():
|
|
534
|
+
profile = load_repo_profile()
|
|
535
|
+
commands = profile.get("commands", {})
|
|
536
|
+
policies = profile.get("policies") or {}
|
|
537
|
+
require_tests = bool(policies.get("require_tests", True))
|
|
538
|
+
test_glob = policies.get("python_test_glob", "tests/test_*.py")
|
|
539
|
+
required = ["test", "lint", "typecheck", "format"]
|
|
540
|
+
missing = [name for name in required if not commands.get(name)]
|
|
541
|
+
if missing:
|
|
542
|
+
raise SystemExit(f"missing commands in repo_profile.yaml: {', '.join(missing)}")
|
|
543
|
+
|
|
544
|
+
ensure_dir(REPORTS_GATES)
|
|
545
|
+
now = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
|
546
|
+
run_dir = REPORTS_GATES / now
|
|
547
|
+
ensure_dir(run_dir)
|
|
548
|
+
|
|
549
|
+
results = []
|
|
550
|
+
test_files = []
|
|
551
|
+
if require_tests and test_glob:
|
|
552
|
+
test_files = list(ROOT.glob(test_glob))
|
|
553
|
+
for name in required:
|
|
554
|
+
result = run_gate_command(name, commands[name], run_dir)
|
|
555
|
+
if name == "test":
|
|
556
|
+
reasons = []
|
|
557
|
+
if require_tests and test_glob and not test_files:
|
|
558
|
+
reasons.append(
|
|
559
|
+
f"require_tests true: no test files matched python_test_glob={test_glob}"
|
|
560
|
+
)
|
|
561
|
+
combined_output = f"{result['stdout_content']}\n{result['stderr_content']}"
|
|
562
|
+
if require_tests and re.search(r"collected\\s+0\\s+items", combined_output):
|
|
563
|
+
reasons.append("require_tests true: pytest collected 0 items")
|
|
564
|
+
if reasons:
|
|
565
|
+
result["returncode"] = 1
|
|
566
|
+
result["reason"] = "; ".join(reasons)
|
|
567
|
+
results.append(result)
|
|
568
|
+
|
|
569
|
+
report_path = REPORTS_GATES / f"{now}.md"
|
|
570
|
+
failed = [r for r in results if r["returncode"] != 0]
|
|
571
|
+
|
|
572
|
+
lines = []
|
|
573
|
+
lines.append(f"# Gate Report {now}\n")
|
|
574
|
+
for result in results:
|
|
575
|
+
status = "PASS" if result["returncode"] == 0 else "FAIL"
|
|
576
|
+
lines.append(f"## {result['name']} — {status}")
|
|
577
|
+
lines.append(f"- command: `{result['command']}`")
|
|
578
|
+
lines.append(f"- stdout: {result['stdout']}")
|
|
579
|
+
lines.append(f"- stderr: {result['stderr']}\n")
|
|
580
|
+
if result.get("reason"):
|
|
581
|
+
lines.append(f"- reason: {result['reason']}\n")
|
|
582
|
+
summary = "PASS" if not failed else "FAIL"
|
|
583
|
+
lines.append(f"## Summary — {summary}")
|
|
584
|
+
lines.append(f"- total: {len(results)}")
|
|
585
|
+
lines.append(f"- failed: {len(failed)}")
|
|
586
|
+
report_path.write_text("\n".join(lines) + "\n")
|
|
587
|
+
|
|
588
|
+
if failed:
|
|
589
|
+
raise SystemExit(1)
|
|
590
|
+
print(str(report_path))
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _load_init_overlay(root: Path) -> dict[str, object] | None:
|
|
594
|
+
overlay_path = root / ".agents" / "generated" / "AGENTS.repo.overlay.yaml"
|
|
595
|
+
if not overlay_path.exists():
|
|
596
|
+
return None
|
|
597
|
+
try:
|
|
598
|
+
return load_yaml(overlay_path) or {}
|
|
599
|
+
except Exception:
|
|
600
|
+
return None
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _print_gate_plan(overlay: dict[str, object] | None) -> None:
|
|
604
|
+
print("## Gate plan")
|
|
605
|
+
if not overlay:
|
|
606
|
+
print("- init overlay not found; no plan available")
|
|
607
|
+
print("")
|
|
608
|
+
return
|
|
609
|
+
verify = overlay.get("verify_commands", [])
|
|
610
|
+
risk_paths = overlay.get("risk_paths", [])
|
|
611
|
+
if not verify:
|
|
612
|
+
print("- verify_commands: none")
|
|
613
|
+
else:
|
|
614
|
+
print("- verify_commands:")
|
|
615
|
+
for item in verify:
|
|
616
|
+
cmd = " ".join(item.get("command", []))
|
|
617
|
+
cwd = item.get("cwd", ".")
|
|
618
|
+
print(f" - {cmd} (cwd: {cwd})")
|
|
619
|
+
if risk_paths:
|
|
620
|
+
print("- risk_paths:")
|
|
621
|
+
for path in risk_paths:
|
|
622
|
+
print(f" - {path}")
|
|
623
|
+
print("")
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _check_tools_available(overlay: dict[str, object] | None) -> None:
|
|
627
|
+
print("## Tool availability")
|
|
628
|
+
if not overlay:
|
|
629
|
+
print("- init overlay missing; tool checks skipped")
|
|
630
|
+
print("")
|
|
631
|
+
return
|
|
632
|
+
verify = overlay.get("verify_commands", [])
|
|
633
|
+
if not verify:
|
|
634
|
+
print("- no verify commands to check")
|
|
635
|
+
print("")
|
|
636
|
+
return
|
|
637
|
+
for item in verify:
|
|
638
|
+
command = item.get("command", [])
|
|
639
|
+
if not command:
|
|
640
|
+
print("- <empty command>: skipped")
|
|
641
|
+
continue
|
|
642
|
+
tool = command[0]
|
|
643
|
+
available = shutil.which(tool) is not None
|
|
644
|
+
status = "ok" if available else "missing"
|
|
645
|
+
print(f"- {tool}: {status}")
|
|
646
|
+
print("")
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def cmd_gate_pr_dry_run() -> int:
|
|
650
|
+
print("# Gate Dry Run")
|
|
651
|
+
print("")
|
|
652
|
+
try:
|
|
653
|
+
agents_status, _template = check_agents_md(
|
|
654
|
+
ROOT, signals=None, facts=None, strict=True
|
|
655
|
+
)
|
|
656
|
+
except Exception as exc:
|
|
657
|
+
if hasattr(exc, "path") and hasattr(exc, "message"):
|
|
658
|
+
report = render_error_report(ROOT, exc.path, exc.message)
|
|
659
|
+
print(report, end="")
|
|
660
|
+
return 2
|
|
661
|
+
print(f"internal error: {exc}", file=sys.stderr)
|
|
662
|
+
return 1
|
|
663
|
+
|
|
664
|
+
print("## AGENTS.md status")
|
|
665
|
+
print(f"- status: {agents_status['status']}")
|
|
666
|
+
for detail in agents_status.get("details", []):
|
|
667
|
+
print(f"- {detail}")
|
|
668
|
+
print("")
|
|
669
|
+
|
|
670
|
+
overlay = _load_init_overlay(ROOT)
|
|
671
|
+
_print_gate_plan(overlay)
|
|
672
|
+
_check_tools_available(overlay)
|
|
673
|
+
return 0
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def cmd_ops() -> int:
|
|
677
|
+
lines = [
|
|
678
|
+
"# Agent Governance Ops Contract",
|
|
679
|
+
"",
|
|
680
|
+
"## Recommended install method",
|
|
681
|
+
"- Use a pinned version: agent-governance==<version>",
|
|
682
|
+
"- Prefer pipx for global CLI use; use a venv for project-local installs",
|
|
683
|
+
"",
|
|
684
|
+
"## Pinning rule",
|
|
685
|
+
"- Always install with an explicit version pin",
|
|
686
|
+
"- CI must install the pinned version and run gate checks with it",
|
|
687
|
+
"",
|
|
688
|
+
"## Rollback one-liner",
|
|
689
|
+
"- pipx: pipx install agent-governance==<prev_version> --force",
|
|
690
|
+
"- venv: pip install --force-reinstall agent-governance==<prev_version>",
|
|
691
|
+
"",
|
|
692
|
+
"## Verify version",
|
|
693
|
+
"- agentctl --version",
|
|
694
|
+
"",
|
|
695
|
+
"## Install commands",
|
|
696
|
+
"- pipx: pipx install agent-governance==<version>",
|
|
697
|
+
"- venv: pip install agent-governance==<version>",
|
|
698
|
+
"",
|
|
699
|
+
"## CI",
|
|
700
|
+
"- Install pinned version (pipx or venv) before running gate checks",
|
|
701
|
+
"- Run: agentctl gate pr",
|
|
702
|
+
"",
|
|
703
|
+
]
|
|
704
|
+
print("\n".join(lines))
|
|
705
|
+
return 0
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def build_parser():
|
|
709
|
+
parser = argparse.ArgumentParser(
|
|
710
|
+
prog="agentctl",
|
|
711
|
+
description=(
|
|
712
|
+
"Agent governance CLI (init, gate, validate, ops guidance). "
|
|
713
|
+
"Policy enforcement is driven by AGENTS.md."
|
|
714
|
+
),
|
|
715
|
+
)
|
|
716
|
+
parser.add_argument(
|
|
717
|
+
"--version",
|
|
718
|
+
action="version",
|
|
719
|
+
version=f"%(prog)s {tool_version()}",
|
|
720
|
+
)
|
|
721
|
+
parser.add_argument(
|
|
722
|
+
"--update-check",
|
|
723
|
+
choices=["auto", "on", "off", "verbose"],
|
|
724
|
+
default="auto",
|
|
725
|
+
help="Enable update checks (auto|on|off|verbose)",
|
|
726
|
+
)
|
|
727
|
+
subparsers = parser.add_subparsers(dest="cmd", required=True)
|
|
728
|
+
|
|
729
|
+
new_task = subparsers.add_parser("new-task", help="Create a task packet")
|
|
730
|
+
new_task.add_argument("--role", required=True, help="Role for the task")
|
|
731
|
+
new_task.add_argument("--title", required=True, help="Task title")
|
|
732
|
+
|
|
733
|
+
validate_cmd = subparsers.add_parser("validate", help="Validate a packet")
|
|
734
|
+
validate_cmd.add_argument("kind", choices=["task", "output"])
|
|
735
|
+
validate_cmd.add_argument("file")
|
|
736
|
+
|
|
737
|
+
log_cmd = subparsers.add_parser("log", help="Append to agent run log")
|
|
738
|
+
log_cmd.add_argument("--run-id", required=True)
|
|
739
|
+
log_cmd.add_argument("--task", required=True, help="Task packet file")
|
|
740
|
+
log_cmd.add_argument("--append", required=True, help="Text file to append")
|
|
741
|
+
|
|
742
|
+
gate_cmd = subparsers.add_parser(
|
|
743
|
+
"gate",
|
|
744
|
+
help="Run repo gates",
|
|
745
|
+
description=(
|
|
746
|
+
"Run PR gate checks using agents/repo_profile.yaml.\n"
|
|
747
|
+
"Use --dry-run to validate AGENTS.md and print a plan without a profile."
|
|
748
|
+
),
|
|
749
|
+
formatter_class=argparse.RawTextHelpFormatter,
|
|
750
|
+
)
|
|
751
|
+
gate_cmd.add_argument("kind", choices=["pr"])
|
|
752
|
+
gate_cmd.add_argument(
|
|
753
|
+
"--dry-run",
|
|
754
|
+
action="store_true",
|
|
755
|
+
default=False,
|
|
756
|
+
help="Validate AGENTS.md and show planned gates without repo_profile.yaml",
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
subparsers.add_parser("ops", help="Print install/pin/rollback guidance")
|
|
760
|
+
subparsers.add_parser("doctor", help="Alias for ops guidance")
|
|
761
|
+
|
|
762
|
+
bootstrap_cmd = subparsers.add_parser(
|
|
763
|
+
"bootstrap",
|
|
764
|
+
help="Author AGENTS.md policy block from canonical roles",
|
|
765
|
+
)
|
|
766
|
+
bootstrap_cmd.add_argument(
|
|
767
|
+
"--allow",
|
|
768
|
+
help="Comma-separated role names (non-interactive)",
|
|
769
|
+
)
|
|
770
|
+
bootstrap_cmd.add_argument(
|
|
771
|
+
"--write",
|
|
772
|
+
action="store_true",
|
|
773
|
+
default=False,
|
|
774
|
+
help="Write AGENTS.md (default is preview only)",
|
|
775
|
+
)
|
|
776
|
+
bootstrap_cmd.add_argument("--min-tool-version")
|
|
777
|
+
bootstrap_cmd.add_argument("--max-tool-version")
|
|
778
|
+
bootstrap_cmd.add_argument(
|
|
779
|
+
"--require-pinned-tool",
|
|
780
|
+
action="store_true",
|
|
781
|
+
default=None,
|
|
782
|
+
help="Require pinned (non-editable) installs",
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
init_description = (
|
|
786
|
+
"Deterministic repo introspection (no LLM, evidence-only).\n"
|
|
787
|
+
"When --write is set, writes:\n"
|
|
788
|
+
" - .agents/generated/AGENTS.repo.overlay.yaml\n"
|
|
789
|
+
" - .agents/generated/init_report.md\n"
|
|
790
|
+
" - .agents/generated/init_facts.json\n"
|
|
791
|
+
"Default out dir: .agents/generated\n"
|
|
792
|
+
"If .gitignore exists, append one line for the out dir. Otherwise no change.\n"
|
|
793
|
+
"Policy enforcement uses AGENTS.md (min/max tool version, pinned installs).\n"
|
|
794
|
+
"Exit codes:\n"
|
|
795
|
+
" 0 success (including dry-run)\n"
|
|
796
|
+
" nonzero parse errors or write failures\n"
|
|
797
|
+
"\n"
|
|
798
|
+
"Examples:\n"
|
|
799
|
+
" agentctl init\n"
|
|
800
|
+
" agentctl init --write --out-dir .agents/custom"
|
|
801
|
+
)
|
|
802
|
+
init_cmd = subparsers.add_parser(
|
|
803
|
+
"init",
|
|
804
|
+
help="Generate a repo-specific policy overlay",
|
|
805
|
+
description=init_description,
|
|
806
|
+
formatter_class=argparse.RawTextHelpFormatter,
|
|
807
|
+
)
|
|
808
|
+
init_cmd.add_argument("--write", action="store_true", default=False)
|
|
809
|
+
init_cmd.add_argument("--out-dir", default=".agents/generated")
|
|
810
|
+
init_cmd.add_argument("--force", action="store_true", default=False)
|
|
811
|
+
init_cmd.add_argument(
|
|
812
|
+
"--print-agents-template",
|
|
813
|
+
action="store_true",
|
|
814
|
+
default=False,
|
|
815
|
+
help="Print AGENTS.md starter template when missing",
|
|
816
|
+
)
|
|
817
|
+
init_cmd.add_argument(
|
|
818
|
+
"--strict",
|
|
819
|
+
action="store_true",
|
|
820
|
+
default=None,
|
|
821
|
+
help="Fail on invalid AGENTS.md (default in CI)",
|
|
822
|
+
)
|
|
823
|
+
init_cmd.add_argument(
|
|
824
|
+
"--no-strict",
|
|
825
|
+
action="store_true",
|
|
826
|
+
default=None,
|
|
827
|
+
help="Warn on invalid AGENTS.md",
|
|
828
|
+
)
|
|
829
|
+
|
|
830
|
+
return parser
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def main():
|
|
834
|
+
parser = build_parser()
|
|
835
|
+
args = parser.parse_args()
|
|
836
|
+
env = dict(os.environ)
|
|
837
|
+
env["AGENT_GOVERNANCE_ROOT"] = str(ROOT)
|
|
838
|
+
|
|
839
|
+
if args.cmd == "new-task":
|
|
840
|
+
mode = resolve_mode(args.update_check, env)
|
|
841
|
+
policy = load_repo_policy_update_check()
|
|
842
|
+
try:
|
|
843
|
+
maybe_check(ROOT, mode, env, policy)
|
|
844
|
+
except Exception:
|
|
845
|
+
if mode == "verbose":
|
|
846
|
+
print("update check failed", file=sys.stderr)
|
|
847
|
+
cmd_new_task(args.role, args.title)
|
|
848
|
+
elif args.cmd == "validate":
|
|
849
|
+
mode = resolve_mode(args.update_check, env)
|
|
850
|
+
policy = load_repo_policy_update_check()
|
|
851
|
+
try:
|
|
852
|
+
maybe_check(ROOT, mode, env, policy)
|
|
853
|
+
except Exception:
|
|
854
|
+
if mode == "verbose":
|
|
855
|
+
print("update check failed", file=sys.stderr)
|
|
856
|
+
cmd_validate(args.kind, args.file)
|
|
857
|
+
elif args.cmd == "log":
|
|
858
|
+
mode = resolve_mode(args.update_check, env)
|
|
859
|
+
policy = load_repo_policy_update_check()
|
|
860
|
+
try:
|
|
861
|
+
maybe_check(ROOT, mode, env, policy)
|
|
862
|
+
except Exception:
|
|
863
|
+
if mode == "verbose":
|
|
864
|
+
print("update check failed", file=sys.stderr)
|
|
865
|
+
cmd_log(args.run_id, args.task, args.append)
|
|
866
|
+
elif args.cmd == "gate":
|
|
867
|
+
if args.kind != "pr":
|
|
868
|
+
raise SystemExit("only 'pr' is supported")
|
|
869
|
+
if args.dry_run:
|
|
870
|
+
raise SystemExit(cmd_gate_pr_dry_run())
|
|
871
|
+
mode = resolve_mode(args.update_check, env)
|
|
872
|
+
policy = load_repo_policy_update_check()
|
|
873
|
+
try:
|
|
874
|
+
maybe_check(ROOT, mode, env, policy)
|
|
875
|
+
except Exception:
|
|
876
|
+
if mode == "verbose":
|
|
877
|
+
print("update check failed", file=sys.stderr)
|
|
878
|
+
try:
|
|
879
|
+
policy_code = _enforce_tool_policy()
|
|
880
|
+
except ParseError as exc:
|
|
881
|
+
report = render_error_report(ROOT, exc.path, exc.message)
|
|
882
|
+
print(report, end="")
|
|
883
|
+
raise SystemExit(2)
|
|
884
|
+
if policy_code != 0:
|
|
885
|
+
raise SystemExit(policy_code)
|
|
886
|
+
cmd_gate_pr()
|
|
887
|
+
elif args.cmd == "init":
|
|
888
|
+
mode = resolve_mode(args.update_check, env)
|
|
889
|
+
policy = load_repo_policy_update_check()
|
|
890
|
+
try:
|
|
891
|
+
maybe_check(ROOT, mode, env, policy)
|
|
892
|
+
except Exception:
|
|
893
|
+
if mode == "verbose":
|
|
894
|
+
print("update check failed", file=sys.stderr)
|
|
895
|
+
try:
|
|
896
|
+
policy_code = _enforce_tool_policy()
|
|
897
|
+
except ParseError as exc:
|
|
898
|
+
report = render_error_report(ROOT, exc.path, exc.message)
|
|
899
|
+
print(report, end="")
|
|
900
|
+
raise SystemExit(2)
|
|
901
|
+
if policy_code != 0:
|
|
902
|
+
raise SystemExit(policy_code)
|
|
903
|
+
strict = False
|
|
904
|
+
if os.environ.get("CI", "").lower() == "true":
|
|
905
|
+
strict = True
|
|
906
|
+
if args.strict is True:
|
|
907
|
+
strict = True
|
|
908
|
+
if args.no_strict is True:
|
|
909
|
+
strict = False
|
|
910
|
+
code = run_init(
|
|
911
|
+
Path.cwd(),
|
|
912
|
+
write=args.write,
|
|
913
|
+
out_dir=args.out_dir,
|
|
914
|
+
force=args.force,
|
|
915
|
+
print_agents_template=args.print_agents_template,
|
|
916
|
+
strict=strict,
|
|
917
|
+
)
|
|
918
|
+
raise SystemExit(code)
|
|
919
|
+
elif args.cmd in ["ops", "doctor"]:
|
|
920
|
+
try:
|
|
921
|
+
policy_code = _enforce_tool_policy()
|
|
922
|
+
except ParseError as exc:
|
|
923
|
+
report = render_error_report(ROOT, exc.path, exc.message)
|
|
924
|
+
print(report, end="")
|
|
925
|
+
raise SystemExit(2)
|
|
926
|
+
if policy_code != 0:
|
|
927
|
+
raise SystemExit(policy_code)
|
|
928
|
+
raise SystemExit(cmd_ops())
|
|
929
|
+
elif args.cmd == "bootstrap":
|
|
930
|
+
raise SystemExit(
|
|
931
|
+
cmd_bootstrap(
|
|
932
|
+
args.allow,
|
|
933
|
+
args.write,
|
|
934
|
+
args.min_tool_version,
|
|
935
|
+
args.max_tool_version,
|
|
936
|
+
args.require_pinned_tool,
|
|
937
|
+
)
|
|
938
|
+
)
|
|
939
|
+
else:
|
|
940
|
+
parser.print_help()
|
|
941
|
+
raise SystemExit(1)
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
if __name__ == "__main__":
|
|
945
|
+
main()
|