specly 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.
@@ -0,0 +1,44 @@
1
+ # Verification
2
+
3
+ > **Done checklist.** Validate the implementation against requirements, design, and tasks.
4
+ > Run this after implementation and before marking tasks as complete.
5
+
6
+ ## 1. Requirements Traceability
7
+
8
+ For each user story in `requirements.md`:
9
+
10
+ - [ ] All EARS criteria pass (WHEN / IF...THEN / WHILE / WHERE / AS SOON AS)
11
+ - [ ] Ambiguity checklist is resolved
12
+ - [ ] Edge cases are handled (empty state, error state, boundary values)
13
+
14
+ ## 2. Design Compliance
15
+
16
+ - [ ] Architecture matches `design.md` decisions
17
+ - [ ] Data models match the defined schema
18
+ - [ ] Error handling covers all documented scenarios
19
+ - [ ] Security controls are in place (auth, secrets, data protection)
20
+ - [ ] Tests from design.md pass
21
+
22
+ ## 3. Code Quality
23
+
24
+ - [ ] No secrets, API keys, or credentials in the code
25
+ - [ ] No debug code, console.log, or TODO left behind
26
+ - [ ] Linter / formatter passes
27
+ - [ ] Diff reviewed and approved
28
+
29
+ ## 4. Task Completion
30
+
31
+ - [ ] Every task in `tasks.md` is checked and verified
32
+ - [ ] Commits are atomic and follow the project convention
33
+ - [ ] No task was marked done without verification
34
+
35
+ ## 5. Final Sign-Off
36
+
37
+ - [ ] Build / compilation passes
38
+ - [ ] Tests pass (`npm test` or equivalent)
39
+ - [ ] If applicable: CI pipeline is green
40
+ - [ ] `steering.md` is up to date with any new conventions
41
+
42
+ ---
43
+
44
+ **Rule:** If any box is unchecked, do not close the feature. Fix it first.
specly_cli/cli.py ADDED
@@ -0,0 +1,269 @@
1
+ """Specly CLI implementation in Python."""
2
+
3
+ import argparse
4
+ import os
5
+ import re
6
+ import shutil
7
+ import sys
8
+ from typing import List, Optional
9
+
10
+ from specly_cli.contract import (
11
+ compute_sha256,
12
+ load_contract,
13
+ resolve_artifacts,
14
+ ContractError,
15
+ )
16
+ from specly_cli.layout import AGENT_KEYS, AGENT_LABELS, detect_agent, resolve_layout, LayoutError
17
+
18
+
19
+ def print_help() -> None:
20
+ agents = ", ".join(AGENT_KEYS)
21
+ print(f"""
22
+ specly — Spec-Driven Development scaffolding (Python CLI)
23
+
24
+ Usage:
25
+ specly [options]
26
+ specly init [options]
27
+ specly doctor
28
+ specly plan <feature>
29
+ specly verify <feature>
30
+
31
+ Commands:
32
+ init Create the SDD files (default)
33
+ doctor Check runtime, agent, and template readiness without changing files
34
+ plan <feature> Show proposed local writes and effects without executing them
35
+ verify <feature> Inspect local artifacts and report observed workflow evidence
36
+
37
+ Options:
38
+ -a, --agent <name> Target agent ({agents})
39
+ -n, --feature <name> Feature name (default: starter)
40
+ -f, --force Overwrite existing files
41
+ -l, --list List supported agents
42
+ -h, --help Show this help
43
+
44
+ Note:
45
+ run and recover are not supported in the Python CLI.
46
+ """)
47
+
48
+
49
+ def print_agents() -> None:
50
+ print("\nSupported agents:\n")
51
+ for key in AGENT_KEYS:
52
+ print(f" {key.ljust(14)} {AGENT_LABELS.get(key, key)}")
53
+ print("\nWithout --agent, the CLI auto-detects from project folders.")
54
+
55
+
56
+ def run_doctor(root: str) -> int:
57
+ contract = load_contract()
58
+ agent = detect_agent(root)
59
+ py_ver = sys.version_info
60
+ runtime_ready = py_ver.major == 3 and py_ver.minor >= 10
61
+
62
+ artifacts = contract.get("artifacts", [])
63
+ missing_templates = []
64
+ for item in artifacts:
65
+ from specly_cli.contract import get_packaged_template_path
66
+ tpl_path = get_packaged_template_path(item["template"])
67
+ if not os.path.exists(tpl_path) or compute_sha256(tpl_path) != item["sha256"]:
68
+ missing_templates.append(item["id"])
69
+
70
+ template_ready = len(missing_templates) == 0
71
+
72
+ print("Specly doctor — read-only readiness check (Python)\n")
73
+ print(f"Runtime: {'ready' if runtime_ready else 'unsupported'} (Python {sys.version.split()[0]}; requires >=3.10)")
74
+ print(f"Agent: ready ({agent}{' fallback' if agent == 'generic' else ' detected'})")
75
+ print(f"Templates: {'ready' if template_ready else 'incomplete'} ({len(artifacts)} packaged templates verified)")
76
+ if missing_templates:
77
+ print(f"Missing: {', '.join(missing_templates)}")
78
+
79
+ return 0 if (runtime_ready and template_ready) else 1
80
+
81
+
82
+ def run_plan(feature: str, root: str, agent: Optional[str] = None) -> int:
83
+ contract = load_contract()
84
+ if not agent:
85
+ agent = detect_agent(root)
86
+ layout = resolve_layout(agent, feature, root, layouts=contract.get("layouts"))
87
+ artifacts = resolve_artifacts(contract, agent, feature, root)
88
+
89
+ print(f"Specly plan — {feature} (read-only; nothing is executed)\n")
90
+ print(f"Agent: {layout['label']} ({layout['agent']})")
91
+ print(f"Feature specs: {layout['relativeSpecsDir']}/\n")
92
+ print("Proposed local file writes:")
93
+ for art in artifacts:
94
+ target = art["target_path"]
95
+ rel = art["relative_target"]
96
+ state = "preserve existing file" if os.path.exists(target) else "create if init is separately approved"
97
+ print(f" {state}: {rel} ({art['file']})")
98
+
99
+ print("\nDependency changes: none.")
100
+ print("External commands: none.")
101
+ print("Permissions: this plan reads local paths only; a later init would need write access to the listed missing paths.")
102
+ print("Network, credentials, and IDE launch: none.")
103
+ return 0
104
+
105
+
106
+ def checkbox_state(content: str) -> str:
107
+ matches = re.findall(r"^\s*- \[([ xX])\]", content, flags=re.MULTILINE)
108
+ if not matches:
109
+ return "unavailable"
110
+ return "passed" if all(m.lower() == "x" for m in matches) else "failed"
111
+
112
+
113
+ def run_verify(feature: str, root: str, agent: Optional[str] = None) -> int:
114
+ contract = load_contract()
115
+ if not agent:
116
+ agent = detect_agent(root)
117
+ layout = resolve_layout(agent, feature, root, layouts=contract.get("layouts"))
118
+ artifacts = resolve_artifacts(contract, agent, feature, root)
119
+
120
+ checks = []
121
+ tasks_content = ""
122
+ verification_content = ""
123
+
124
+ for art in artifacts:
125
+ target = art["target_path"]
126
+ rel = art["relative_target"]
127
+ if os.path.exists(target):
128
+ try:
129
+ with open(target, "r", encoding="utf-8") as f:
130
+ content = f.read()
131
+ checks.append({"label": f"{art['file']} exists and is readable", "status": "passed"})
132
+ if art["file"] == "tasks.md":
133
+ tasks_content = content
134
+ elif art["file"] == "verification.md":
135
+ verification_content = content
136
+ except Exception:
137
+ checks.append({"label": f"{art['file']} exists and is readable", "status": "unavailable"})
138
+ else:
139
+ checks.append({"label": f"{art['file']} exists and is readable", "status": "failed"})
140
+
141
+ checks.append({
142
+ "label": "tasks.md checklist is complete",
143
+ "status": checkbox_state(tasks_content) if tasks_content else "unavailable",
144
+ })
145
+ checks.append({
146
+ "label": "verification.md checklist is complete",
147
+ "status": checkbox_state(verification_content) if verification_content else "unavailable",
148
+ })
149
+ checks.append({"label": "requirements/design acceptance evidence", "status": "unavailable"})
150
+ checks.append({"label": "External test commands", "status": "skipped"})
151
+
152
+ counts = {s: sum(1 for c in checks if c["status"] == s) for s in ["passed", "failed", "skipped", "unavailable"]}
153
+
154
+ print(f"Specly verify — {feature} (read-only; no commands run)\n")
155
+ print(f"Agent: {layout['label']} ({layout['agent']})")
156
+ for check in checks:
157
+ print(f" {check['status'].upper().ljust(11)} {check['label']}")
158
+ print(
159
+ f"\nSummary: {counts['passed']} passed, {counts['failed']} failed, {counts['skipped']} skipped, {counts['unavailable']} unavailable."
160
+ )
161
+ print("Progress is read from durable tasks.md checkboxes. This report is not persisted; acceptance evidence requires project-specific checks.")
162
+ return 0
163
+
164
+
165
+ def run_init(agent: Optional[str], feature: str, force: bool, root: str) -> int:
166
+ contract = load_contract()
167
+ if not agent:
168
+ agent = detect_agent(root)
169
+ layout = resolve_layout(agent, feature, root, layouts=contract.get("layouts"))
170
+ artifacts = resolve_artifacts(contract, agent, feature, root)
171
+
172
+ print(f"\n🤖 Agent: {layout['label']} ({layout['agent']})")
173
+ print(f"📂 Feature: {layout['feature']}")
174
+ print(f"📁 Specs: {layout['relativeSpecsDir']}/\n")
175
+
176
+ created = 0
177
+ for art in artifacts:
178
+ target = art["target_path"]
179
+ template = art["template_path"]
180
+ rel = art["relative_target"]
181
+
182
+ os.makedirs(os.path.dirname(target), exist_ok=True)
183
+
184
+ if os.path.exists(target) and not force:
185
+ existing_sha = compute_sha256(target)
186
+ if existing_sha == art["sha256"]:
187
+ print(f"⚠️ Already exists, skipping: {rel}")
188
+ else:
189
+ print(f"⚠️ File differs from template, skipping without --force: {rel}")
190
+ continue
191
+
192
+ shutil.copyfile(template, target)
193
+ print(f"✅ Created: {rel}")
194
+ created += 1
195
+
196
+ print("\n🚀 SDD initialized.")
197
+ print(f" Start with: {layout['relativeSpecsDir']}/requirements.md")
198
+ if not layout["steeringInSpecs"]:
199
+ print(f" Context: {os.path.relpath(layout['steeringDir'], root)}/steering.md")
200
+ print(f" Guide: {os.path.relpath(layout['agentDir'], root)}/how-to-use.md")
201
+ return 0
202
+
203
+
204
+ def main(argv: Optional[List[str]] = None, root: Optional[str] = None) -> int:
205
+ if argv is None:
206
+ argv = sys.argv[1:]
207
+ if root is None:
208
+ root = os.getcwd()
209
+
210
+ if not argv:
211
+ command = "init"
212
+ cmd_args = []
213
+ elif argv[0] in ["init", "doctor", "plan", "verify", "run", "recover"]:
214
+ command = argv[0]
215
+ cmd_args = argv[1:]
216
+ elif argv[0].startswith("-"):
217
+ command = "init"
218
+ cmd_args = argv
219
+ else:
220
+ # Check for unknown command
221
+ print(f"Error: Unknown command '{argv[0]}'", file=sys.stderr)
222
+ print_help()
223
+ sys.exit(1)
224
+
225
+ if command in ["run", "recover"]:
226
+ print(
227
+ f"Error: 'specly {command}' is not supported in the Python CLI distribution.\n"
228
+ "Action execution and recovery are currently provided by the Node CLI (npx specly).",
229
+ file=sys.stderr,
230
+ )
231
+ sys.exit(1)
232
+
233
+ if command == "doctor":
234
+ if "--help" in cmd_args or "-h" in cmd_args:
235
+ print("Usage: specly doctor\n\nChecks runtime, agent, and template readiness without writing files.")
236
+ return 0
237
+ return run_doctor(root)
238
+
239
+ if command == "plan":
240
+ if not cmd_args or cmd_args[0].startswith("-"):
241
+ print("Usage: specly plan <feature>", file=sys.stderr)
242
+ sys.exit(1)
243
+ feature = cmd_args[0]
244
+ return run_plan(feature, root)
245
+
246
+ if command == "verify":
247
+ if not cmd_args or cmd_args[0].startswith("-"):
248
+ print("Usage: specly verify <feature>", file=sys.stderr)
249
+ sys.exit(1)
250
+ feature = cmd_args[0]
251
+ return run_verify(feature, root)
252
+
253
+ # init command parsing
254
+ parser = argparse.ArgumentParser(add_help=False)
255
+ parser.add_argument("-a", "--agent", dest="agent", default=None)
256
+ parser.add_argument("-n", "--feature", "--name", dest="feature", default="starter")
257
+ parser.add_argument("-f", "--force", dest="force", action="store_true")
258
+ parser.add_argument("-l", "--list", dest="list", action="store_true")
259
+ parser.add_argument("-h", "--help", dest="help", action="store_true")
260
+
261
+ parsed, remaining = parser.parse_known_args(cmd_args)
262
+ if parsed.help:
263
+ print_help()
264
+ return 0
265
+ if parsed.list:
266
+ print_agents()
267
+ return 0
268
+
269
+ return run_init(parsed.agent, parsed.feature, parsed.force, root)
specly_cli/contract.py ADDED
@@ -0,0 +1,100 @@
1
+ """Contract loading, validation, and artifact resolution."""
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ from typing import Any, Dict, List
7
+
8
+ from specly_cli.layout import resolve_layout
9
+
10
+ ASSETS_DIR = os.path.join(os.path.dirname(__file__), "assets")
11
+ DEFAULT_CONTRACT_PATH = os.path.join(ASSETS_DIR, "artifact-contract-v1.json")
12
+
13
+
14
+ class ContractError(Exception):
15
+ """Raised when artifact contract validation or template fingerprint fails."""
16
+ pass
17
+
18
+
19
+ def load_contract(path: str = DEFAULT_CONTRACT_PATH) -> Dict[str, Any]:
20
+ """Load contract JSON file."""
21
+ if not os.path.exists(path):
22
+ raise ContractError(f"Contract file not found: {path}")
23
+ with open(path, "r", encoding="utf-8") as f:
24
+ data = json.load(f)
25
+ validate_contract(data)
26
+ return data
27
+
28
+
29
+ def validate_contract(data: Dict[str, Any]) -> None:
30
+ """Validate contract name, schema version, and structure."""
31
+ if data.get("contract") != "specly-generated-artifacts":
32
+ raise ContractError(f"Unsupported contract name: {data.get('contract')}")
33
+ if data.get("schemaVersion") != 1:
34
+ raise ContractError(f"Unsupported contract schema version: {data.get('schemaVersion')}")
35
+ if "artifacts" not in data or "layouts" not in data:
36
+ raise ContractError("Invalid contract: missing 'artifacts' or 'layouts' sections")
37
+
38
+
39
+ def get_packaged_template_path(template_name: str) -> str:
40
+ """Resolve packaged template path within assets directory."""
41
+ # Maps src/templates/foo.md -> assets/templates/foo.md
42
+ # Maps how-to-use.md -> assets/how-to-use.md
43
+ basename = os.path.basename(template_name)
44
+ if "templates" in template_name:
45
+ path = os.path.join(ASSETS_DIR, "templates", basename)
46
+ else:
47
+ path = os.path.join(ASSETS_DIR, basename)
48
+ return path
49
+
50
+
51
+ def compute_sha256(file_path: str) -> str:
52
+ """Calculate SHA-256 hex digest of file."""
53
+ h = hashlib.sha256()
54
+ with open(file_path, "rb") as f:
55
+ while chunk := f.read(8192):
56
+ h.update(chunk)
57
+ return h.hexdigest()
58
+
59
+
60
+ def resolve_artifacts(contract: Dict[str, Any], agent: str, feature: str, root: str) -> List[Dict[str, Any]]:
61
+ """Resolve and verify all artifacts for an agent layout and feature."""
62
+ validate_contract(contract)
63
+ layout = resolve_layout(agent, feature, root, layouts=contract.get("layouts"))
64
+ artifacts_result = []
65
+
66
+ for item in contract.get("artifacts", []):
67
+ artifact_id = item["id"]
68
+ dir_key = item["directory"]
69
+ file_name = item["file"]
70
+ expected_sha = item["sha256"]
71
+ template_ref = item["template"]
72
+
73
+ dir_path = layout.get(dir_key)
74
+ if not dir_path:
75
+ raise ContractError(f"Layout does not define directory key '{dir_key}' for artifact '{artifact_id}'")
76
+
77
+ target_file = os.path.join(dir_path, file_name)
78
+ template_file = get_packaged_template_path(template_ref)
79
+
80
+ if not os.path.exists(template_file):
81
+ raise ContractError(f"Packaged template missing for {artifact_id}: {template_file}")
82
+
83
+ actual_sha = compute_sha256(template_file)
84
+ if actual_sha != expected_sha:
85
+ raise ContractError(
86
+ f"Packaged template fingerprint mismatch for {artifact_id}: expected {expected_sha}, got {actual_sha}"
87
+ )
88
+
89
+ artifacts_result.append({
90
+ "id": artifact_id,
91
+ "role": item.get("role", ""),
92
+ "file": file_name,
93
+ "target_path": target_file,
94
+ "relative_target": os.path.relpath(target_file, root),
95
+ "template_path": template_file,
96
+ "sha256": expected_sha,
97
+ "ownership": item.get("ownership", "user"),
98
+ })
99
+
100
+ return artifacts_result
specly_cli/layout.py ADDED
@@ -0,0 +1,106 @@
1
+ """Layout detection and resolution for supported agent configurations."""
2
+
3
+ import os
4
+ import re
5
+
6
+ AGENT_KEYS = ["generic", "cursor", "kiro", "vscode", "antigravity", "windsurf", "claude"]
7
+
8
+ AGENT_LABELS = {
9
+ "generic": "Generic",
10
+ "cursor": "Cursor",
11
+ "kiro": "Kiro",
12
+ "vscode": "VS Code / Copilot",
13
+ "antigravity": "Antigravity",
14
+ "windsurf": "Windsurf",
15
+ "claude": "Claude Code",
16
+ }
17
+
18
+ FEATURE_REGEX = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]*$")
19
+
20
+
21
+ class LayoutError(Exception):
22
+ """Raised when layout resolution or safety checks fail."""
23
+ pass
24
+
25
+
26
+ def detect_agent(root: str) -> str:
27
+ """Detect agent from existing project markers in root."""
28
+ if os.path.exists(os.path.join(root, ".cursor")):
29
+ return "cursor"
30
+ if os.path.exists(os.path.join(root, ".kiro")):
31
+ return "kiro"
32
+ if os.path.exists(os.path.join(root, ".vscode")) or os.path.exists(
33
+ os.path.join(root, ".github", "copilot-instructions.md")
34
+ ):
35
+ return "vscode"
36
+ if os.path.exists(os.path.join(root, ".agent")) or os.path.exists(
37
+ os.path.join(root, ".antigravity")
38
+ ):
39
+ return "antigravity"
40
+ if os.path.exists(os.path.join(root, ".windsurf")):
41
+ return "windsurf"
42
+ if os.path.exists(os.path.join(root, "CLAUDE.md")) or os.path.exists(
43
+ os.path.join(root, ".claude")
44
+ ):
45
+ return "claude"
46
+ return "generic"
47
+
48
+
49
+ def resolve_layout(agent: str, feature: str, root: str, layouts: dict = None) -> dict:
50
+ """Resolve and validate layout paths for an agent and feature."""
51
+ if not feature or not FEATURE_REGEX.match(feature) or ".." in feature or "/" in feature or "\\" in feature:
52
+ raise LayoutError(f"Invalid or unsafe feature name: {feature!r}")
53
+
54
+ if not agent:
55
+ agent = detect_agent(root)
56
+ elif agent not in AGENT_KEYS:
57
+ raise LayoutError(f"Unknown agent: {agent!r}")
58
+
59
+ # Default layout mappings conforming to artifact-contract-v1
60
+ layout_defs = layouts or {
61
+ "generic": {"specsDir": "specs", "steeringDir": "specs", "agentDir": "specs"},
62
+ "cursor": {"specsDir": ".cursor/specs/{feature}", "steeringDir": ".cursor", "agentDir": ".cursor"},
63
+ "kiro": {"specsDir": ".kiro/specs/{feature}", "steeringDir": ".kiro", "agentDir": ".kiro"},
64
+ "vscode": {"specsDir": ".vscode/specs/{feature}", "steeringDir": ".vscode", "agentDir": ".vscode"},
65
+ "antigravity": {"specsDir": ".agent/specs/{feature}", "steeringDir": ".agent", "agentDir": ".agent"},
66
+ "windsurf": {"specsDir": ".windsurf/specs/{feature}", "steeringDir": ".windsurf", "agentDir": ".windsurf"},
67
+ "claude": {"specsDir": ".claude/specs/{feature}", "steeringDir": ".claude", "agentDir": ".claude"},
68
+ }
69
+
70
+ if agent not in layout_defs:
71
+ raise LayoutError(f"Layout definition missing for agent: {agent}")
72
+
73
+ conf = layout_defs[agent]
74
+ real_root = os.path.realpath(root)
75
+
76
+ resolved = {
77
+ "agent": agent,
78
+ "label": AGENT_LABELS.get(agent, agent.title()),
79
+ "feature": feature,
80
+ "specsDir": os.path.join(root, conf["specsDir"].replace("{feature}", feature)),
81
+ "steeringDir": os.path.join(root, conf["steeringDir"]),
82
+ "agentDir": os.path.join(root, conf["agentDir"]),
83
+ "relativeSpecsDir": conf["specsDir"].replace("{feature}", feature),
84
+ "steeringInSpecs": conf["steeringDir"] == conf["specsDir"].replace("{feature}", feature),
85
+ }
86
+
87
+ # Safety checks: ensure resolved paths are inside root and no symlink escapes root
88
+ for key in ["specsDir", "steeringDir", "agentDir"]:
89
+ target_path = resolved[key]
90
+ rel = os.path.relpath(target_path, root)
91
+ if rel.startswith("..") or os.path.isabs(rel):
92
+ raise LayoutError(f"Path escape detected in layout for {key}: {target_path}")
93
+
94
+ # Check existing parent or target if it's a symlink pointing outside root
95
+ curr = target_path
96
+ while curr and curr != root:
97
+ if os.path.islink(curr):
98
+ link_dest = os.path.realpath(curr)
99
+ if not (link_dest == real_root or link_dest.startswith(real_root + os.sep)):
100
+ raise LayoutError(f"Symlink escape detected: {curr} points to {link_dest}")
101
+ parent = os.path.dirname(curr)
102
+ if parent == curr:
103
+ break
104
+ curr = parent
105
+
106
+ return resolved
@@ -0,0 +1 @@
1
+ """Optional adapters for Specly CLI."""
@@ -0,0 +1,160 @@
1
+ """Optional Google AX cluster adapter for offline diagnostics and effect preview."""
2
+
3
+ from dataclasses import dataclass, field
4
+ import datetime
5
+ import hashlib
6
+ import json
7
+ import shutil
8
+ from typing import Any, Callable, Dict, List, Optional
9
+
10
+ AX_COMPATIBILITY_STATUS = "unverified"
11
+ AX_DOCUMENTED_EVIDENCE = (
12
+ "Google AX (github.com/google/ax) prerelease. Local prerequisites: kubectl, ko, go, container registry."
13
+ )
14
+ REQUIRED_PREREQUISITES = ["kubectl", "ko", "go"]
15
+
16
+
17
+ class AxAdapterError(Exception):
18
+ """Raised when AX validation, prerequisite checks, or approval matching fails."""
19
+ pass
20
+
21
+
22
+ @dataclass
23
+ class AxPrerequisiteResult:
24
+ prerequisites: Dict[str, bool]
25
+ missing: List[str]
26
+ ready: bool
27
+ actionable_diagnostic: str
28
+ compatibility_status: str = AX_COMPATIBILITY_STATUS
29
+
30
+
31
+ @dataclass
32
+ class AxPreview:
33
+ cluster_context: str
34
+ namespace: str
35
+ operation: str
36
+ resource_targets: List[str]
37
+ proposed_effects: List[str]
38
+ compatibility_evidence: str
39
+ approval_digest: str
40
+ created_at: str
41
+ is_mutation: bool
42
+
43
+
44
+ def check_prerequisites(binaries_checker: Optional[Callable[[str], bool]] = None) -> AxPrerequisiteResult:
45
+ """Check local required binaries without cluster probing or credential inspection."""
46
+ checker = binaries_checker or (lambda name: shutil.which(name) is not None)
47
+ results = {}
48
+ missing = []
49
+
50
+ for name in REQUIRED_PREREQUISITES:
51
+ found = bool(checker(name))
52
+ results[name] = found
53
+ if not found:
54
+ missing.append(name)
55
+
56
+ ready = len(missing) == 0
57
+ if ready:
58
+ diag = "All local AX prerequisites detected (kubectl, ko, go). Cluster status remains unverified."
59
+ else:
60
+ missing_str = ", ".join(missing)
61
+ diag = (
62
+ f"Actionable prerequisite blockers: missing [{missing_str}]. "
63
+ "Install required tooling before attempting AX operations."
64
+ )
65
+
66
+ return AxPrerequisiteResult(
67
+ prerequisites=results,
68
+ missing=missing,
69
+ ready=ready,
70
+ actionable_diagnostic=diag,
71
+ compatibility_status=AX_COMPATIBILITY_STATUS,
72
+ )
73
+
74
+
75
+ def compute_preview_digest(
76
+ cluster_context: str,
77
+ namespace: str,
78
+ operation: str,
79
+ resource_targets: List[str],
80
+ proposed_effects: List[str],
81
+ compatibility_evidence: str,
82
+ ) -> str:
83
+ payload = {
84
+ "cluster_context": cluster_context,
85
+ "namespace": namespace,
86
+ "operation": operation,
87
+ "resource_targets": sorted(resource_targets),
88
+ "proposed_effects": sorted(proposed_effects),
89
+ "compatibility_evidence": compatibility_evidence,
90
+ }
91
+ return hashlib.sha256(json.dumps(payload, sort_keys=True).encode("utf-8")).hexdigest()
92
+
93
+
94
+ def generate_ax_preview(
95
+ cluster_context: str,
96
+ namespace: str,
97
+ operation: str,
98
+ resource_targets: List[str],
99
+ proposed_effects: List[str],
100
+ compatibility_evidence: str = AX_DOCUMENTED_EVIDENCE,
101
+ ) -> AxPreview:
102
+ """Generate read-only immutable effect preview and approval digest."""
103
+ digest = compute_preview_digest(
104
+ cluster_context=cluster_context,
105
+ namespace=namespace,
106
+ operation=operation,
107
+ resource_targets=resource_targets,
108
+ proposed_effects=proposed_effects,
109
+ compatibility_evidence=compatibility_evidence,
110
+ )
111
+ is_mutation = operation.lower() in {"apply", "create", "delete", "update", "patch"}
112
+ created_at = datetime.datetime.now(datetime.timezone.utc).isoformat()
113
+
114
+ return AxPreview(
115
+ cluster_context=cluster_context,
116
+ namespace=namespace,
117
+ operation=operation,
118
+ resource_targets=resource_targets,
119
+ proposed_effects=proposed_effects,
120
+ compatibility_evidence=compatibility_evidence,
121
+ approval_digest=digest,
122
+ created_at=created_at,
123
+ is_mutation=is_mutation,
124
+ )
125
+
126
+
127
+ def submit_ax_operation(
128
+ preview: AxPreview,
129
+ approval_digest: str,
130
+ destination_auth: Dict[str, str],
131
+ submit_fn: Optional[Callable[[AxPreview], Dict[str, Any]]] = None,
132
+ ) -> Dict[str, Any]:
133
+ """Submit an AX cluster operation after explicit approval bound to the preview."""
134
+ if not approval_digest:
135
+ raise AxAdapterError("Approval digest required: cluster submission requires affirmative approval.")
136
+
137
+ if approval_digest != preview.approval_digest:
138
+ raise AxAdapterError(
139
+ f"Approval digest mismatch: expected {preview.approval_digest}, got {approval_digest}. "
140
+ "A fresh preview and approval are required."
141
+ )
142
+
143
+ required_auth_keys = {"destination", "operation", "credential_session"}
144
+ if not required_auth_keys.issubset(destination_auth.keys()) or not all(
145
+ destination_auth[k] for k in required_auth_keys
146
+ ):
147
+ raise AxAdapterError(
148
+ f"Missing explicit authorization: must provide destination, operation, and credential_session. "
149
+ f"Provided keys: {list(destination_auth.keys())}"
150
+ )
151
+
152
+ if submit_fn:
153
+ return submit_fn(preview)
154
+
155
+ # Offline / unverified default: do not contact real cluster
156
+ return {
157
+ "status": "dry_run_recorded",
158
+ "digest": preview.approval_digest,
159
+ "message": "Operation approved and recorded. Real cluster submission requires external provider session.",
160
+ }