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.
- specly-0.1.0.dist-info/METADATA +72 -0
- specly-0.1.0.dist-info/RECORD +20 -0
- specly-0.1.0.dist-info/WHEEL +5 -0
- specly-0.1.0.dist-info/entry_points.txt +2 -0
- specly-0.1.0.dist-info/top_level.txt +1 -0
- specly_cli/__init__.py +3 -0
- specly_cli/__main__.py +7 -0
- specly_cli/assets/artifact-contract-v1.json +76 -0
- specly_cli/assets/how-to-use.md +226 -0
- specly_cli/assets/templates/design.md +51 -0
- specly_cli/assets/templates/requirements.md +49 -0
- specly_cli/assets/templates/steering.md +91 -0
- specly_cli/assets/templates/tasks.md +40 -0
- specly_cli/assets/templates/verification.md +44 -0
- specly_cli/cli.py +269 -0
- specly_cli/contract.py +100 -0
- specly_cli/layout.py +106 -0
- specly_cli/optional/__init__.py +1 -0
- specly_cli/optional/ax_adapter.py +160 -0
- specly_cli/optional/strands_adapter.py +147 -0
|
@@ -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
|
+
}
|