sddflow 2.7.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.
- sdd/__init__.py +1 -0
- sdd/__main__.py +30 -0
- sdd/commands/__init__.py +0 -0
- sdd/commands/config.py +268 -0
- sdd/commands/confluence.py +376 -0
- sdd/commands/cr.py +257 -0
- sdd/commands/init.py +279 -0
- sdd/commands/jira.py +262 -0
- sdd/commands/pr.py +308 -0
- sdd/commands/review.py +608 -0
- sdd/commands/upgrade.py +100 -0
- sdd/utils/__init__.py +0 -0
- sdd/utils/atlassian_auth.py +108 -0
- sdd/utils/cf_to_md.py +81 -0
- sdd/utils/confluence_client.py +170 -0
- sdd/utils/detect.py +97 -0
- sdd/utils/git_host.py +657 -0
- sdd/utils/integrations.py +165 -0
- sdd/utils/jira_client.py +86 -0
- sdd/utils/manifest.py +44 -0
- sdd/utils/md_to_cf.py +139 -0
- sdd/utils/scaffold.py +85 -0
- sdd/utils/sdd_parser.py +194 -0
- sdd/utils/validate.py +37 -0
- sddflow-2.7.0.dist-info/METADATA +639 -0
- sddflow-2.7.0.dist-info/RECORD +28 -0
- sddflow-2.7.0.dist-info/WHEEL +4 -0
- sddflow-2.7.0.dist-info/entry_points.txt +2 -0
sdd/commands/cr.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import re
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import click
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
|
|
7
|
+
from sdd.utils.atlassian_auth import load_profile, build_session
|
|
8
|
+
from sdd.utils.integrations import load_integrations
|
|
9
|
+
from sdd.utils.jira_client import JiraClient
|
|
10
|
+
from sdd.utils.confluence_client import ConfluenceClient
|
|
11
|
+
from sdd.utils.md_to_cf import md_to_storage
|
|
12
|
+
from sdd.utils.manifest import read_manifest
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _cr_path(feature: str, cr_id: str) -> Path:
|
|
18
|
+
return Path(".specify") / "features" / feature / "changesets" / f"{cr_id}.md"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _extract_cr_summary(text: str) -> str:
|
|
22
|
+
"""Pull the one-line description from the CR record (§1 Change Description)."""
|
|
23
|
+
for line in text.splitlines():
|
|
24
|
+
line = line.strip()
|
|
25
|
+
if line and not line.startswith("#") and not line.startswith("|") and len(line) > 10:
|
|
26
|
+
return line[:120]
|
|
27
|
+
return "Change Request"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@click.group()
|
|
31
|
+
def cr_command():
|
|
32
|
+
"""Change Request lifecycle — submit, check, status."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@cr_command.command("submit")
|
|
36
|
+
@click.option("--cr", required=True,
|
|
37
|
+
help="CR identifier, e.g. CR-001")
|
|
38
|
+
@click.option("--profile", default=None)
|
|
39
|
+
@click.option("--feature", default=None,
|
|
40
|
+
help="Feature name (default: from manifest.yml)")
|
|
41
|
+
@click.option("--reviewer", default=None,
|
|
42
|
+
help="Jira accountId of the reviewer (overrides integrations.yml cr_reviewer)")
|
|
43
|
+
@click.option("--dry-run", is_flag=True)
|
|
44
|
+
def cr_submit(cr, profile, feature, reviewer, dry_run):
|
|
45
|
+
"""Push a CR record to Confluence and create a Jira review task.
|
|
46
|
+
|
|
47
|
+
Run this automatically after /change saves the changeset record.
|
|
48
|
+
Stakeholders review and comment in Confluence; the Jira task tracks
|
|
49
|
+
formal approval exactly like sdd review submit does for spec docs.
|
|
50
|
+
"""
|
|
51
|
+
cr_id = cr.upper()
|
|
52
|
+
console.print()
|
|
53
|
+
console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
|
|
54
|
+
label = f" [bold cyan]CR Submit[/bold cyan] — {cr_id}"
|
|
55
|
+
if dry_run:
|
|
56
|
+
label += " [yellow](dry run)[/yellow]"
|
|
57
|
+
console.print(label)
|
|
58
|
+
console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
|
|
59
|
+
console.print()
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
cfg = load_integrations()
|
|
63
|
+
except FileNotFoundError as e:
|
|
64
|
+
console.print(f" [red]✗ {e}[/red]")
|
|
65
|
+
raise SystemExit(1)
|
|
66
|
+
|
|
67
|
+
manifest = read_manifest() or {}
|
|
68
|
+
proj = manifest.get("project") or {}
|
|
69
|
+
project_name = proj.get("name", "Project")
|
|
70
|
+
feature_name = feature or proj.get("feature", "")
|
|
71
|
+
|
|
72
|
+
cr_file = _cr_path(feature_name, cr_id)
|
|
73
|
+
if not cr_file.exists():
|
|
74
|
+
console.print(f" [red]✗ CR file not found: {cr_file}[/red]")
|
|
75
|
+
console.print(" Run /change first to generate the changeset record.")
|
|
76
|
+
raise SystemExit(1)
|
|
77
|
+
|
|
78
|
+
cr_text = cr_file.read_text()
|
|
79
|
+
cr_summary = _extract_cr_summary(cr_text)
|
|
80
|
+
page_title = f"{project_name} — {cr_id}: {cr_summary}"[:200]
|
|
81
|
+
|
|
82
|
+
console.print(f" CR file : [cyan]{cr_file}[/cyan]")
|
|
83
|
+
console.print(f" Title : [cyan]{page_title}[/cyan]")
|
|
84
|
+
|
|
85
|
+
if dry_run:
|
|
86
|
+
console.print()
|
|
87
|
+
console.print(" [dim]would push to Confluence + create Jira review task[/dim]")
|
|
88
|
+
console.print()
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
prof = load_profile(profile or cfg.profile)
|
|
93
|
+
session = build_session(prof)
|
|
94
|
+
except Exception as e:
|
|
95
|
+
console.print(f" [red]✗ Auth error: {e}[/red]")
|
|
96
|
+
raise SystemExit(1)
|
|
97
|
+
|
|
98
|
+
page_url = ""
|
|
99
|
+
|
|
100
|
+
# ── Push CR record to Confluence ─────────────────────────────────────────
|
|
101
|
+
if cfg.confluence:
|
|
102
|
+
cf_client = ConfluenceClient(session, prof.base_url)
|
|
103
|
+
body_html = md_to_storage(cr_text)
|
|
104
|
+
try:
|
|
105
|
+
page, created = cf_client.upsert_page(
|
|
106
|
+
cfg.confluence.space_key,
|
|
107
|
+
page_title,
|
|
108
|
+
body_html,
|
|
109
|
+
cfg.confluence.parent_page_id,
|
|
110
|
+
)
|
|
111
|
+
action = "[green]created[/green]" if created else "[dim]updated[/dim]"
|
|
112
|
+
web_ui = page.get("_links", {}).get("webui", "")
|
|
113
|
+
page_url = f"{prof.base_url}/wiki{web_ui}" if web_ui else ""
|
|
114
|
+
console.print(f" {action} Confluence: [cyan]{page_title}[/cyan]")
|
|
115
|
+
if page_url:
|
|
116
|
+
console.print(f" [underline cyan]{page_url}[/underline cyan]")
|
|
117
|
+
except Exception as e:
|
|
118
|
+
console.print(f" [yellow]⚠ Confluence error: {e} — continuing to Jira[/yellow]")
|
|
119
|
+
else:
|
|
120
|
+
console.print(" [dim]·[/dim] Confluence not configured — skipping page push")
|
|
121
|
+
|
|
122
|
+
# ── Create / update Jira review task ─────────────────────────────────────
|
|
123
|
+
if cfg.jira:
|
|
124
|
+
jira_client = JiraClient(session, prof.base_url)
|
|
125
|
+
idempotency_label = f"sdd-cr:{cr_id.lower()}"
|
|
126
|
+
existing = jira_client.find_by_label(cfg.jira.project_key, idempotency_label)
|
|
127
|
+
|
|
128
|
+
reviewer_id = (
|
|
129
|
+
reviewer
|
|
130
|
+
or getattr(cfg, "cr_reviewer", None)
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
desc_text = (
|
|
134
|
+
f"Please review Change Request {cr_id}.\n\n"
|
|
135
|
+
f"Summary: {cr_summary}\n\n"
|
|
136
|
+
+ (f"Confluence: {page_url}\n\n" if page_url else "")
|
|
137
|
+
+ f"To APPROVE: set task to Done and comment 'Approved'.\n"
|
|
138
|
+
f"To REQUEST CHANGES: add comments and leave the task open."
|
|
139
|
+
)
|
|
140
|
+
fields: dict = {
|
|
141
|
+
"project": {"key": cfg.jira.project_key},
|
|
142
|
+
"issuetype": {"name": cfg.jira.issue_hierarchy.get("task", "Task")},
|
|
143
|
+
"summary": f"Review: {project_name} — {cr_id}",
|
|
144
|
+
"labels": ["sdd-cr", idempotency_label],
|
|
145
|
+
"description": {
|
|
146
|
+
"type": "doc", "version": 1,
|
|
147
|
+
"content": [{"type": "paragraph", "content": [
|
|
148
|
+
{"type": "text", "text": desc_text}
|
|
149
|
+
]}],
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
if reviewer_id:
|
|
153
|
+
fields["assignee"] = {"accountId": reviewer_id}
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
if existing:
|
|
157
|
+
jira_client.update_issue(existing["key"], fields)
|
|
158
|
+
task_key = existing["key"]
|
|
159
|
+
console.print(f" [dim]·[/dim] Jira task updated: [cyan]{task_key}[/cyan]")
|
|
160
|
+
else:
|
|
161
|
+
result = jira_client.create_issue(fields)
|
|
162
|
+
task_key = result["key"]
|
|
163
|
+
console.print(f" [green]✓[/green] Jira task created: [cyan]{task_key}[/cyan]")
|
|
164
|
+
except Exception as e:
|
|
165
|
+
console.print(f" [yellow]⚠ Jira error: {e}[/yellow]")
|
|
166
|
+
else:
|
|
167
|
+
console.print(" [dim]·[/dim] Jira not configured — skipping review task")
|
|
168
|
+
|
|
169
|
+
console.print()
|
|
170
|
+
console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
|
|
171
|
+
console.print(f" [bold green]{cr_id} submitted![/bold green]")
|
|
172
|
+
console.print(" Stakeholders can review and comment in Confluence.")
|
|
173
|
+
console.print(" Reviewer approves in Jira when ready.")
|
|
174
|
+
console.print(f" Check status: [cyan]sdd cr check --cr {cr_id}[/cyan]")
|
|
175
|
+
console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
|
|
176
|
+
console.print()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@cr_command.command("check")
|
|
180
|
+
@click.option("--cr", required=True)
|
|
181
|
+
@click.option("--profile", default=None)
|
|
182
|
+
def cr_check(cr, profile):
|
|
183
|
+
"""Check approval status of a CR in Jira.
|
|
184
|
+
Exit codes: 0=approved 1=needs-revision 2=pending 3=not-submitted.
|
|
185
|
+
"""
|
|
186
|
+
cr_id = cr.upper()
|
|
187
|
+
console.print()
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
cfg = load_integrations()
|
|
191
|
+
prof = load_profile(profile or cfg.profile)
|
|
192
|
+
session = build_session(prof)
|
|
193
|
+
except Exception as e:
|
|
194
|
+
console.print(f" [red]✗ {e}[/red]")
|
|
195
|
+
raise SystemExit(1)
|
|
196
|
+
|
|
197
|
+
if not cfg.jira:
|
|
198
|
+
console.print(" [red]✗ No jira: section in integrations.yml[/red]")
|
|
199
|
+
raise SystemExit(1)
|
|
200
|
+
|
|
201
|
+
client = JiraClient(session, prof.base_url)
|
|
202
|
+
issue = client.find_by_label(cfg.jira.project_key, f"sdd-cr:{cr_id.lower()}")
|
|
203
|
+
|
|
204
|
+
if not issue:
|
|
205
|
+
console.print(f" [dim]· {cr_id} — NOT SUBMITTED[/dim]")
|
|
206
|
+
console.print(f" Run [cyan]sdd cr submit --cr {cr_id}[/cyan] first.")
|
|
207
|
+
console.print()
|
|
208
|
+
raise SystemExit(3)
|
|
209
|
+
|
|
210
|
+
jira_status = issue.get("fields", {}).get("status", {}).get("name", "")
|
|
211
|
+
comments = client.get_comments(issue["key"])
|
|
212
|
+
|
|
213
|
+
approved_statuses = cfg.approved_statuses
|
|
214
|
+
approved_keywords = cfg.approved_keywords
|
|
215
|
+
|
|
216
|
+
if jira_status in approved_statuses:
|
|
217
|
+
console.print(f" [green]✓ {cr_id} — APPROVED[/green] [dim](Jira status: {jira_status})[/dim]")
|
|
218
|
+
console.print()
|
|
219
|
+
raise SystemExit(0)
|
|
220
|
+
|
|
221
|
+
for c in comments:
|
|
222
|
+
body = c.get("body", "")
|
|
223
|
+
text = body if isinstance(body, str) else " ".join(
|
|
224
|
+
n.get("text", "") for n in _walk_adf(body)
|
|
225
|
+
)
|
|
226
|
+
if any(kw in text.lower() for kw in approved_keywords):
|
|
227
|
+
console.print(f" [green]✓ {cr_id} — APPROVED[/green] [dim](via comment keyword)[/dim]")
|
|
228
|
+
console.print()
|
|
229
|
+
raise SystemExit(0)
|
|
230
|
+
|
|
231
|
+
if comments:
|
|
232
|
+
console.print(f" [yellow]✗ {cr_id} — NEEDS REVISION[/yellow]")
|
|
233
|
+
console.print()
|
|
234
|
+
console.print(" [bold]Review comments:[/bold]")
|
|
235
|
+
for c in comments:
|
|
236
|
+
author = (c.get("author") or {}).get("displayName", "?")
|
|
237
|
+
body = c.get("body", "")
|
|
238
|
+
text = body if isinstance(body, str) else " ".join(
|
|
239
|
+
n.get("text", "") for n in _walk_adf(body)
|
|
240
|
+
)
|
|
241
|
+
console.print(f" [cyan]{author}[/cyan]: {text.strip()[:300]}")
|
|
242
|
+
console.print()
|
|
243
|
+
raise SystemExit(1)
|
|
244
|
+
|
|
245
|
+
console.print(f" [dim]⏳ {cr_id} — PENDING[/dim] Waiting for reviewer.")
|
|
246
|
+
console.print()
|
|
247
|
+
raise SystemExit(2)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _walk_adf(node: dict) -> list[dict]:
|
|
251
|
+
"""Flatten ADF doc into text nodes."""
|
|
252
|
+
out: list[dict] = []
|
|
253
|
+
if node.get("type") == "text":
|
|
254
|
+
out.append(node)
|
|
255
|
+
for child in node.get("content", []):
|
|
256
|
+
out.extend(_walk_adf(child))
|
|
257
|
+
return out
|
sdd/commands/init.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import click
|
|
3
|
+
import questionary
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
|
|
6
|
+
from sdd.utils.detect import detect_project_type, PROJECT_TYPES
|
|
7
|
+
from sdd.utils.validate import validate_name, assert_valid_name
|
|
8
|
+
from sdd.utils.manifest import patch_manifest, MANIFEST_PATH, SDD_VERSION
|
|
9
|
+
from sdd.utils.scaffold import (
|
|
10
|
+
recommended_pack, scaffold_pack,
|
|
11
|
+
PACK_DESCRIPTIONS, ALL_PACKS, TYPE_TO_PACK,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
AI_TOOLS = [
|
|
15
|
+
questionary.Choice("Claude Code — type /specify", value="claude-code"),
|
|
16
|
+
questionary.Choice("GitHub Copilot — type /specify", value="copilot"),
|
|
17
|
+
questionary.Choice("Cursor — chat: Read and follow the prompt file", value="cursor"),
|
|
18
|
+
questionary.Choice("Windsurf — chat: Run specify", value="windsurf"),
|
|
19
|
+
questionary.Choice("Other / not sure", value="other"),
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
_AI_TOOL_NEXT_STEP: dict[str, str] = {
|
|
23
|
+
"claude-code": "Open this folder in Claude Code and type: [bold]/specify[/bold]",
|
|
24
|
+
"copilot": "Open in VS Code with Copilot Chat and type: [bold]/specify[/bold]",
|
|
25
|
+
"cursor": "In Cursor chat, type:\n [bold]Read and follow .github/prompts/specify.prompt.md exactly[/bold]",
|
|
26
|
+
"windsurf": "In Windsurf chat, type: [bold]Run specify[/bold]",
|
|
27
|
+
"other": "Copy [cyan].github/prompts/specify.prompt.md[/cyan] and paste into your AI tool",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
console = Console()
|
|
31
|
+
|
|
32
|
+
_BANNER = f"""
|
|
33
|
+
[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]
|
|
34
|
+
[bold cyan]SDD Framework[/bold cyan] [dim]v{SDD_VERSION}[/dim]
|
|
35
|
+
[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]"""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@click.command()
|
|
39
|
+
@click.option("-p", "--project", "project_name", default=None, help="Project name")
|
|
40
|
+
@click.option("-f", "--feature", "feature_name", default=None, help="First feature name")
|
|
41
|
+
@click.option("-s", "--scope", default=None, help="pilot | mvp | full")
|
|
42
|
+
@click.option("-t", "--type", "project_type", default=None,
|
|
43
|
+
help="Project type (auto-detected if omitted)")
|
|
44
|
+
@click.option("--pack", default=None,
|
|
45
|
+
help=f"Pack to scaffold: {', '.join(ALL_PACKS)}")
|
|
46
|
+
def init_command(project_name, feature_name, scope, project_type, pack):
|
|
47
|
+
"""Initialize an SDD pack in the current project directory."""
|
|
48
|
+
console.print(_BANNER)
|
|
49
|
+
|
|
50
|
+
# ── Scaffold mode: no pack present yet ───────────────────────────────────
|
|
51
|
+
if not Path(MANIFEST_PATH).exists():
|
|
52
|
+
_scaffold_mode(project_type, pack)
|
|
53
|
+
|
|
54
|
+
# ── Fill mode: manifest.yml already exists ────────────────────────────────
|
|
55
|
+
# (also reached after scaffold_mode copies the pack)
|
|
56
|
+
|
|
57
|
+
# ── Project type (needed for manifest even in fill mode) ─────────────────
|
|
58
|
+
if not project_type:
|
|
59
|
+
console.print("[dim] Detecting project type...[/dim] ", end="")
|
|
60
|
+
detected = detect_project_type(".")
|
|
61
|
+
if detected:
|
|
62
|
+
console.print(f"[green]{detected}[/green]")
|
|
63
|
+
confirmed = questionary.confirm(
|
|
64
|
+
f" Use detected type '{detected}'?", default=True
|
|
65
|
+
).ask()
|
|
66
|
+
project_type = detected if confirmed else None
|
|
67
|
+
|
|
68
|
+
if not project_type:
|
|
69
|
+
project_type = questionary.select(
|
|
70
|
+
" Project type:",
|
|
71
|
+
choices=PROJECT_TYPES,
|
|
72
|
+
).ask()
|
|
73
|
+
|
|
74
|
+
# ── Interactive prompts ───────────────────────────────────────────────────
|
|
75
|
+
if not project_name:
|
|
76
|
+
project_name = questionary.text(
|
|
77
|
+
"Project name:",
|
|
78
|
+
validate=lambda v: validate_name(v, "Project name") or True,
|
|
79
|
+
).ask()
|
|
80
|
+
|
|
81
|
+
if not feature_name:
|
|
82
|
+
feature_name = questionary.text(
|
|
83
|
+
"First feature name:",
|
|
84
|
+
validate=lambda v: validate_name(v, "Feature name") or True,
|
|
85
|
+
).ask()
|
|
86
|
+
|
|
87
|
+
if not scope:
|
|
88
|
+
scope = questionary.select(
|
|
89
|
+
"Scope:",
|
|
90
|
+
choices=[
|
|
91
|
+
questionary.Choice("pilot — quick prototype, minimal docs", value="pilot"),
|
|
92
|
+
questionary.Choice("mvp — production-ready (+ api-spec, data-model, LLD, ADR)", value="mvp"),
|
|
93
|
+
questionary.Choice("full — enterprise (+ resilience, investigation, security-design)", value="full"),
|
|
94
|
+
],
|
|
95
|
+
).ask()
|
|
96
|
+
|
|
97
|
+
ai_tool = questionary.select(
|
|
98
|
+
"Which AI tool will you use?",
|
|
99
|
+
choices=AI_TOOLS,
|
|
100
|
+
).ask()
|
|
101
|
+
|
|
102
|
+
# Validate CLI-supplied values (questionary validates interactive ones)
|
|
103
|
+
assert_valid_name(project_name, "Project name")
|
|
104
|
+
assert_valid_name(feature_name, "Feature name")
|
|
105
|
+
|
|
106
|
+
console.print()
|
|
107
|
+
console.print(" Setting up:")
|
|
108
|
+
console.print(f" Project : [cyan]{project_name}[/cyan]")
|
|
109
|
+
console.print(f" Type : [cyan]{project_type}[/cyan]")
|
|
110
|
+
console.print(f" Feature : [cyan]{feature_name}[/cyan]")
|
|
111
|
+
console.print(f" Scope : [cyan]{scope}[/cyan]")
|
|
112
|
+
console.print(f" AI tool : [cyan]{ai_tool}[/cyan]")
|
|
113
|
+
console.print()
|
|
114
|
+
|
|
115
|
+
# ── Update manifest.yml via PyYAML (no string injection possible) ─────────
|
|
116
|
+
patch_manifest({
|
|
117
|
+
"project": {
|
|
118
|
+
"name": project_name,
|
|
119
|
+
"scope": scope,
|
|
120
|
+
"feature": feature_name,
|
|
121
|
+
"context_file": f"{feature_name}.md",
|
|
122
|
+
},
|
|
123
|
+
"project_type": project_type,
|
|
124
|
+
"sdd_version": SDD_VERSION,
|
|
125
|
+
"ai_tool": ai_tool,
|
|
126
|
+
})
|
|
127
|
+
console.print(f" [green]✓[/green] {MANIFEST_PATH} filled")
|
|
128
|
+
|
|
129
|
+
# ── Create context file ───────────────────────────────────────────────────
|
|
130
|
+
context_path = Path(".specify") / "contexts" / f"{feature_name}.md"
|
|
131
|
+
context_path.parent.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
|
|
133
|
+
if not context_path.exists():
|
|
134
|
+
context_path.write_text(_context_template(feature_name, project_name))
|
|
135
|
+
console.print(f" [green]✓[/green] {context_path} created")
|
|
136
|
+
else:
|
|
137
|
+
console.print(f" [dim]·[/dim] {context_path} already exists — skipped")
|
|
138
|
+
|
|
139
|
+
# ── Create feature output directory ──────────────────────────────────────
|
|
140
|
+
feature_dir = Path(".specify") / "features" / feature_name
|
|
141
|
+
feature_dir.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
console.print(f" [green]✓[/green] {feature_dir}/ ready")
|
|
143
|
+
|
|
144
|
+
# ── Done ──────────────────────────────────────────────────────────────────
|
|
145
|
+
next_step = _AI_TOOL_NEXT_STEP.get(ai_tool, _AI_TOOL_NEXT_STEP["other"])
|
|
146
|
+
console.print()
|
|
147
|
+
console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
|
|
148
|
+
console.print(f" [bold green]Setup complete![/bold green] Next steps:")
|
|
149
|
+
console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
|
|
150
|
+
console.print()
|
|
151
|
+
console.print(f" 1. Edit [cyan]{context_path}[/cyan]")
|
|
152
|
+
console.print(" Fill in: What it does, actors, key flows, tech stack, NFRs")
|
|
153
|
+
console.print(" (or run /create-context to build it interactively)")
|
|
154
|
+
console.print()
|
|
155
|
+
console.print(f" 2. {next_step}")
|
|
156
|
+
console.print()
|
|
157
|
+
console.print(" See QUICKSTART.md for the full walkthrough.")
|
|
158
|
+
console.print()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _scaffold_mode(project_type: str | None, pack_override: str | None) -> None:
|
|
162
|
+
"""
|
|
163
|
+
No SDD pack found — detect project type, recommend a pack, copy it here.
|
|
164
|
+
Exits (raises SystemExit) only on error; on success the manifest will now
|
|
165
|
+
exist and init_command continues normally.
|
|
166
|
+
"""
|
|
167
|
+
console.print(" [yellow]No SDD pack found in this directory.[/yellow]")
|
|
168
|
+
console.print()
|
|
169
|
+
|
|
170
|
+
# ── Detect or accept supplied project type ────────────────────────────────
|
|
171
|
+
if not project_type:
|
|
172
|
+
console.print("[dim] Detecting project type...[/dim] ", end="")
|
|
173
|
+
project_type = detect_project_type(".")
|
|
174
|
+
if project_type:
|
|
175
|
+
console.print(f"[green]{project_type}[/green]")
|
|
176
|
+
else:
|
|
177
|
+
console.print("[yellow]not detected[/yellow]")
|
|
178
|
+
|
|
179
|
+
# ── Determine recommended pack ────────────────────────────────────────────
|
|
180
|
+
rec = pack_override or recommended_pack(project_type)
|
|
181
|
+
|
|
182
|
+
# ── Ask user which pack to scaffold ──────────────────────────────────────
|
|
183
|
+
console.print()
|
|
184
|
+
|
|
185
|
+
if pack_override:
|
|
186
|
+
chosen_pack = pack_override
|
|
187
|
+
console.print(f" Pack: [cyan]{chosen_pack}[/cyan] (from --pack flag)")
|
|
188
|
+
else:
|
|
189
|
+
choices = _build_pack_choices(project_type, rec)
|
|
190
|
+
chosen_pack = questionary.select(
|
|
191
|
+
"Which pack would you like to scaffold?",
|
|
192
|
+
choices=choices,
|
|
193
|
+
).ask()
|
|
194
|
+
|
|
195
|
+
if chosen_pack == "__all__":
|
|
196
|
+
chosen_pack = questionary.select(
|
|
197
|
+
"Select pack:",
|
|
198
|
+
choices=[
|
|
199
|
+
questionary.Choice(
|
|
200
|
+
f"{p} — {PACK_DESCRIPTIONS[p]}",
|
|
201
|
+
value=p,
|
|
202
|
+
)
|
|
203
|
+
for p in ALL_PACKS
|
|
204
|
+
],
|
|
205
|
+
).ask()
|
|
206
|
+
|
|
207
|
+
if not chosen_pack:
|
|
208
|
+
console.print("[red]Cancelled.[/red]")
|
|
209
|
+
raise SystemExit(1)
|
|
210
|
+
|
|
211
|
+
# ── Copy pack files ───────────────────────────────────────────────────────
|
|
212
|
+
console.print()
|
|
213
|
+
console.print(f" Scaffolding [cyan]{chosen_pack}[/cyan]...")
|
|
214
|
+
try:
|
|
215
|
+
n = scaffold_pack(chosen_pack, dest=".")
|
|
216
|
+
except RuntimeError as e:
|
|
217
|
+
console.print(f"[red]✗ {e}[/red]")
|
|
218
|
+
raise SystemExit(1)
|
|
219
|
+
|
|
220
|
+
console.print(f" [green]✓[/green] {chosen_pack} scaffolded ({n} files copied)")
|
|
221
|
+
console.print()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _build_pack_choices(project_type: str | None, rec: str) -> list:
|
|
225
|
+
"""Build the questionary choices list for pack selection."""
|
|
226
|
+
choices = []
|
|
227
|
+
|
|
228
|
+
if rec != "sdd-universal":
|
|
229
|
+
choices.append(questionary.Choice(
|
|
230
|
+
f"{rec} ({PACK_DESCRIPTIONS[rec]}) ← recommended for {project_type}",
|
|
231
|
+
value=rec,
|
|
232
|
+
))
|
|
233
|
+
choices.append(questionary.Choice(
|
|
234
|
+
f"sdd-universal ({PACK_DESCRIPTIONS['sdd-universal']})",
|
|
235
|
+
value="sdd-universal",
|
|
236
|
+
))
|
|
237
|
+
else:
|
|
238
|
+
label = f"for {project_type}" if project_type else "when type is unclear"
|
|
239
|
+
choices.append(questionary.Choice(
|
|
240
|
+
f"sdd-universal ({PACK_DESCRIPTIONS['sdd-universal']}) ← recommended {label}",
|
|
241
|
+
value="sdd-universal",
|
|
242
|
+
))
|
|
243
|
+
|
|
244
|
+
choices.append(questionary.Choice("Choose from all packs…", value="__all__"))
|
|
245
|
+
return choices
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _context_template(feature_name: str, project_name: str) -> str:
|
|
249
|
+
return f"""# Context: {feature_name}
|
|
250
|
+
# Project: {project_name}
|
|
251
|
+
# Fill this file, then run /specify (or /create-context to build it interactively).
|
|
252
|
+
|
|
253
|
+
## What This Does
|
|
254
|
+
{{describe the feature in 2-3 sentences}}
|
|
255
|
+
|
|
256
|
+
## Actors
|
|
257
|
+
{{who triggers or benefits from this feature?}}
|
|
258
|
+
|
|
259
|
+
## Key Flows
|
|
260
|
+
{{describe 2-3 main user journeys}}
|
|
261
|
+
|
|
262
|
+
## Integrations
|
|
263
|
+
{{list any external systems, APIs, or databases}}
|
|
264
|
+
|
|
265
|
+
## Business Rules
|
|
266
|
+
{{any constraints, validation rules, or compliance requirements}}
|
|
267
|
+
|
|
268
|
+
## Tech Stack
|
|
269
|
+
{{language, framework, database, cache, CI/CD — fill what you know}}
|
|
270
|
+
|
|
271
|
+
## Non-Functional Requirements
|
|
272
|
+
{{performance targets, availability, security level}}
|
|
273
|
+
|
|
274
|
+
## Out of Scope
|
|
275
|
+
{{explicitly list what this feature does NOT cover}}
|
|
276
|
+
|
|
277
|
+
## Open Questions
|
|
278
|
+
{{anything unclear that needs a decision}}
|
|
279
|
+
"""
|