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 ADDED
@@ -0,0 +1 @@
1
+ __version__ = "2.7.0"
sdd/__main__.py ADDED
@@ -0,0 +1,30 @@
1
+ import click
2
+ from sdd import __version__
3
+ from sdd.commands.init import init_command
4
+ from sdd.commands.upgrade import upgrade_command
5
+ from sdd.commands.config import config_command
6
+ from sdd.commands.jira import jira_command
7
+ from sdd.commands.confluence import confluence_command
8
+ from sdd.commands.review import review_command
9
+ from sdd.commands.cr import cr_command
10
+ from sdd.commands.pr import pr_command
11
+
12
+
13
+ @click.group()
14
+ @click.version_option(__version__, prog_name="sdd")
15
+ def cli():
16
+ """SDD Framework CLI — Spec-Driven Development"""
17
+
18
+
19
+ cli.add_command(init_command, name="init")
20
+ cli.add_command(upgrade_command, name="upgrade")
21
+ cli.add_command(config_command, name="config")
22
+ cli.add_command(jira_command, name="jira")
23
+ cli.add_command(confluence_command, name="confluence")
24
+ cli.add_command(review_command, name="review")
25
+ cli.add_command(cr_command, name="cr")
26
+ cli.add_command(pr_command, name="pr")
27
+
28
+
29
+ if __name__ == "__main__":
30
+ cli()
File without changes
sdd/commands/config.py ADDED
@@ -0,0 +1,268 @@
1
+ from __future__ import annotations
2
+ from pathlib import Path
3
+ import yaml
4
+ import click
5
+ import requests
6
+ from rich.console import Console
7
+
8
+ from sdd.utils.atlassian_auth import load_profile, build_session, save_config, CONFIG_PATH
9
+ from sdd.utils.jira_client import JiraClient
10
+ from sdd.utils.confluence_client import ConfluenceClient
11
+
12
+ console = Console()
13
+
14
+
15
+ @click.group()
16
+ def config_command():
17
+ """Configure Atlassian credentials and project integration."""
18
+
19
+
20
+ @config_command.command("init")
21
+ def config_init():
22
+ """Interactively create ~/.sdd/config.yml and .specify/integrations.yml."""
23
+ import questionary
24
+
25
+ console.print()
26
+ console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
27
+ console.print(" [bold cyan]SDD Config[/bold cyan] — Atlassian setup")
28
+ console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
29
+ console.print()
30
+
31
+ profile_name = questionary.text("Profile name:", default="default").ask()
32
+ base_url = questionary.text(
33
+ "Atlassian base URL:", default="https://myco.atlassian.net"
34
+ ).ask()
35
+ auth_mode = questionary.select(
36
+ "Auth mode:",
37
+ choices=[
38
+ questionary.Choice(
39
+ "basic — Cloud (email + API token)", value="basic"),
40
+ questionary.Choice(
41
+ "pat — Server/DC (Personal Access Token)", value="pat"),
42
+ questionary.Choice(
43
+ "oauth2 — Cloud CI/CD (OAuth 2.0 Bearer token)", value="oauth2"),
44
+ ],
45
+ ).ask()
46
+
47
+ profile: dict = {"auth_mode": auth_mode, "base_url": base_url}
48
+
49
+ if auth_mode == "basic":
50
+ email = questionary.text("Your Atlassian account email:").ask()
51
+ api_token_env = questionary.text(
52
+ "Name of env var holding your API token:", default="JIRA_API_TOKEN"
53
+ ).ask()
54
+ profile["email"] = email
55
+ profile["api_token_env"] = api_token_env
56
+ console.print(f"\n [dim]Export [cyan]{api_token_env}[/cyan] before running sdd commands.[/dim]")
57
+
58
+ elif auth_mode == "pat":
59
+ pat_env = questionary.text(
60
+ "Name of env var holding your PAT:", default="JIRA_PAT"
61
+ ).ask()
62
+ profile["pat_env"] = pat_env
63
+ console.print(f"\n [dim]Export [cyan]{pat_env}[/cyan] before running sdd commands.[/dim]")
64
+
65
+ elif auth_mode == "oauth2":
66
+ access_token_env = questionary.text(
67
+ "Name of env var holding your OAuth2 access token:",
68
+ default="JIRA_ACCESS_TOKEN",
69
+ ).ask()
70
+ profile["access_token_env"] = access_token_env
71
+ console.print(f"\n [dim]Export [cyan]{access_token_env}[/cyan] before running sdd commands.[/dim]")
72
+
73
+ # Merge into existing config
74
+ if CONFIG_PATH.exists():
75
+ existing = yaml.safe_load(CONFIG_PATH.read_text()) or {}
76
+ else:
77
+ existing = {"version": "1", "profiles": {}}
78
+
79
+ existing.setdefault("profiles", {})[profile_name] = profile
80
+ existing.setdefault("default_profile", profile_name)
81
+
82
+ save_config(existing)
83
+ console.print(f"\n [green]✓[/green] Profile [cyan]{profile_name}[/cyan] saved → {CONFIG_PATH}")
84
+
85
+ # Optionally scaffold .specify/integrations.yml
86
+ if questionary.confirm(
87
+ "\n Set up .specify/integrations.yml for this project?", default=True
88
+ ).ask():
89
+ _scaffold_integrations(profile_name)
90
+
91
+ console.print()
92
+ console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
93
+ console.print(
94
+ " [bold green]Config complete![/bold green] "
95
+ "Run [cyan]sdd config test[/cyan] to verify."
96
+ )
97
+ console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
98
+ console.print()
99
+
100
+
101
+ def _scaffold_integrations(profile_name: str) -> None:
102
+ import questionary
103
+ from sdd.utils.manifest import read_manifest
104
+
105
+ dest = Path(".specify/integrations.yml")
106
+ if dest.exists():
107
+ if not questionary.confirm(
108
+ f" {dest} already exists — overwrite?", default=False
109
+ ).ask():
110
+ return
111
+
112
+ project_key = questionary.text("Jira project key (e.g. MYPROJ):").ask()
113
+ space_key = questionary.text("Confluence space key (e.g. ENG):").ask()
114
+ parent_page_id = questionary.text(
115
+ "Confluence parent page ID (blank = root):", default=""
116
+ ).ask().strip()
117
+
118
+ manifest = read_manifest() or {}
119
+ project_name = (manifest.get("project") or {}).get("name", "{project}")
120
+
121
+ dest.write_text(
122
+ _integrations_template(profile_name, project_key, space_key,
123
+ parent_page_id, project_name)
124
+ )
125
+ console.print(f" [green]✓[/green] {dest} created")
126
+ console.print(
127
+ " [dim]Edit [cyan]custom_fields[/cyan] to match your Jira instance. "
128
+ "Run [cyan]sdd config fields[/cyan] to discover IDs.[/dim]"
129
+ )
130
+
131
+
132
+ @config_command.command("test")
133
+ @click.option("--profile", default=None, help="Profile name from ~/.sdd/config.yml")
134
+ def config_test(profile):
135
+ """Ping Jira and Confluence to verify credentials."""
136
+ console.print()
137
+ try:
138
+ prof = load_profile(profile)
139
+ session = build_session(prof)
140
+ except Exception as e:
141
+ console.print(f" [red]✗ Config error: {e}[/red]")
142
+ raise SystemExit(1)
143
+
144
+ try:
145
+ me = JiraClient(session, prof.base_url).get_myself()
146
+ name = me.get("displayName") or me.get("emailAddress", "?")
147
+ console.print(f" [green]✓[/green] Jira — connected as [cyan]{name}[/cyan]")
148
+ except requests.HTTPError as e:
149
+ console.print(f" [red]✗ Jira — HTTP {e.response.status_code}: "
150
+ f"{e.response.text[:120]}[/red]")
151
+
152
+ try:
153
+ me = ConfluenceClient(session, prof.base_url).get_myself()
154
+ name = me.get("displayName") or me.get("username", "?")
155
+ console.print(f" [green]✓[/green] Confluence — connected as [cyan]{name}[/cyan]")
156
+ except requests.HTTPError as e:
157
+ console.print(f" [red]✗ Confluence — HTTP {e.response.status_code}: "
158
+ f"{e.response.text[:120]}[/red]")
159
+
160
+ console.print()
161
+
162
+
163
+ @config_command.command("fields")
164
+ @click.option("--profile", default=None)
165
+ @click.option("--project", default=None, help="Jira project key")
166
+ def config_fields(profile, project):
167
+ """List Jira custom fields to help fill integrations.yml custom_fields."""
168
+ try:
169
+ prof = load_profile(profile)
170
+ session = build_session(prof)
171
+ except Exception as e:
172
+ console.print(f" [red]✗ {e}[/red]")
173
+ raise SystemExit(1)
174
+
175
+ if project is None:
176
+ try:
177
+ from sdd.utils.integrations import load_integrations
178
+ cfg = load_integrations()
179
+ project = cfg.jira.project_key if cfg.jira else None
180
+ except FileNotFoundError:
181
+ pass
182
+ if not project:
183
+ console.print(
184
+ " [red]✗ No project key. Use --project KEY or set it in "
185
+ ".specify/integrations.yml[/red]"
186
+ )
187
+ raise SystemExit(1)
188
+
189
+ fields = JiraClient(session, prof.base_url).get_fields()
190
+ custom = sorted(
191
+ [f for f in fields if f.get("custom")],
192
+ key=lambda f: f.get("name", ""),
193
+ )
194
+
195
+ console.print(f"\n [bold]Custom fields ({len(custom)} found):[/bold]\n")
196
+ console.print(f" {'ID':<30} {'Name':<40} Type")
197
+ console.print(f" {'─'*30} {'─'*40} {'─'*20}")
198
+ for f in custom:
199
+ ftype = (f.get("schema") or {}).get("type", "")
200
+ console.print(
201
+ f" [cyan]{f['id']:<30}[/cyan] {f.get('name',''):<40} [dim]{ftype}[/dim]"
202
+ )
203
+ console.print()
204
+
205
+
206
+ def _integrations_template(profile: str, project_key: str, space_key: str,
207
+ parent_page_id: str, project_name: str) -> str:
208
+ parent_line = (
209
+ f' parent_page_id: "{parent_page_id}"'
210
+ if parent_page_id
211
+ else ' # parent_page_id: "123456" # optional'
212
+ )
213
+ return f"""\
214
+ # SDD Integrations — project-level config (no secrets here)
215
+ # Credentials live in ~/.sdd/config.yml as env var names
216
+ profile: {profile}
217
+
218
+ jira:
219
+ project_key: {project_key}
220
+
221
+ # Jira issue type names — Feature → Story → Task
222
+ # Use "Epic" instead of "Feature" if your project has no Feature type
223
+ issue_hierarchy:
224
+ feature: Feature
225
+ story: Story
226
+ task: Task
227
+
228
+ # Parent link field:
229
+ # "parent" — Next-gen / team-managed projects
230
+ # "customfield_10014" — Classic projects (Epic Link)
231
+ parent_field: parent
232
+
233
+ base_fields:
234
+ priority_map:
235
+ must-have: High
236
+ should-have: Medium
237
+ could-have: Low
238
+ wont-have: Lowest
239
+ labels: [sdd-generated]
240
+ # fix_version: v1.0 # optional
241
+
242
+ # Custom field IDs — run "sdd config fields" to discover yours
243
+ custom_fields:
244
+ story_points: customfield_10016 # almost universal on Jira Cloud
245
+ # acceptance_criteria: customfield_10021
246
+ # team: customfield_10100
247
+
248
+ confluence:
249
+ space_key: {space_key}
250
+ {parent_line}
251
+
252
+ # Page title templates — {{project}} replaced with project name from manifest
253
+ # design applies in unified plan_mode; arch/hld/adr in separate plan_mode
254
+ page_map:
255
+ brd: "{project_name} — Business Requirements"
256
+ use-cases: "{project_name} — Use Cases"
257
+ srd: "{project_name} — System Requirements"
258
+ design: "{project_name} — Design"
259
+ arch: "{project_name} — Architecture Overview"
260
+ hld: "{project_name} — High-Level Design"
261
+ adr: "{project_name} — Architecture Decisions"
262
+ lld: "{project_name} — Low-Level Design"
263
+ runbook: "{project_name} — Runbook"
264
+
265
+ # For the Jira review workflow (sdd review submit/check/apply), add a
266
+ # document_reviews: section — see .specify/integrations.yml.example for the
267
+ # full plan_mode-aware reference.
268
+ """
@@ -0,0 +1,376 @@
1
+ from __future__ import annotations
2
+ import json
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.confluence_client import ConfluenceClient
10
+ from sdd.utils.md_to_cf import md_to_storage
11
+ from sdd.utils.cf_to_md import cf_to_md
12
+ from sdd.utils.manifest import read_manifest
13
+
14
+ console = Console()
15
+
16
+ _DRAFTS_FILE = Path(".specify") / ".confluence-drafts.json"
17
+
18
+ _CONTEXT_PAGE_TITLE = "{project} — Context: {feature}"
19
+
20
+
21
+ def _load_drafts() -> dict:
22
+ if _DRAFTS_FILE.exists():
23
+ return json.loads(_DRAFTS_FILE.read_text())
24
+ return {}
25
+
26
+
27
+ def _save_drafts(drafts: dict) -> None:
28
+ _DRAFTS_FILE.parent.mkdir(parents=True, exist_ok=True)
29
+ _DRAFTS_FILE.write_text(json.dumps(drafts, indent=2))
30
+
31
+
32
+ def _resolve_doc_path(doc: str, feature: str) -> Path:
33
+ """Return local file path for a doc key."""
34
+ if doc == "context":
35
+ return Path(".specify") / "contexts" / f"{feature}.md"
36
+ return Path(".specify") / "features" / feature / f"{doc}.md"
37
+
38
+
39
+ def _resolve_page_title(doc: str, project_name: str, feature: str,
40
+ page_map: dict) -> str:
41
+ if doc == "context":
42
+ return _CONTEXT_PAGE_TITLE.replace("{project}", project_name).replace("{feature}", feature)
43
+ template = page_map.get(doc, f"{{project}} — {doc.upper()}")
44
+ return template.replace("{project}", project_name)
45
+
46
+
47
+ @click.group()
48
+ def confluence_command():
49
+ """Push SDD documents to Confluence."""
50
+
51
+
52
+ @confluence_command.command("push")
53
+ @click.option("--profile", default=None)
54
+ @click.option("--feature", default=None, help="Feature name (default: from manifest.yml)")
55
+ @click.option("--doc", default=None,
56
+ help="Push a single doc only (e.g. hld, brd, arch, runbook)")
57
+ @click.option("--dry-run", is_flag=True, help="Print page titles without calling the API")
58
+ def confluence_push(profile, feature, doc, dry_run):
59
+ """Publish SDD documents to Confluence pages (create or update)."""
60
+ console.print()
61
+ console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
62
+ label = " [bold cyan]SDD → Confluence[/bold cyan]"
63
+ if dry_run:
64
+ label += " [yellow](dry run)[/yellow]"
65
+ console.print(label)
66
+ console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
67
+ console.print()
68
+
69
+ try:
70
+ cfg = load_integrations()
71
+ except FileNotFoundError as e:
72
+ console.print(f" [red]✗ {e}[/red]")
73
+ raise SystemExit(1)
74
+
75
+ if not cfg.confluence:
76
+ console.print(
77
+ " [red]✗ No confluence: section in .specify/integrations.yml[/red]"
78
+ )
79
+ raise SystemExit(1)
80
+
81
+ cf_cfg = cfg.confluence
82
+ manifest = read_manifest() or {}
83
+ proj = manifest.get("project") or {}
84
+ project_name = proj.get("name", "Project")
85
+ feature_name = feature or proj.get("feature", "")
86
+
87
+ features_dir = Path(".specify") / "features" / feature_name
88
+ if not features_dir.exists():
89
+ console.print(f" [red]✗ Feature directory not found: {features_dir}[/red]")
90
+ raise SystemExit(1)
91
+
92
+ # Resolve which docs to push
93
+ page_map = cf_cfg.page_map
94
+ keys_to_try = [doc] if doc else list(page_map.keys())
95
+
96
+ available: list[tuple[str, Path, str]] = []
97
+ for key in keys_to_try:
98
+ md_path = features_dir / f"{key}.md"
99
+ if not md_path.exists():
100
+ console.print(f" [dim]·[/dim] {key}.md not found — skipped")
101
+ continue
102
+ title = page_map.get(key, f"{project_name} — {key.upper()}")
103
+ title = title.replace("{project}", project_name)
104
+ available.append((key, md_path, title))
105
+
106
+ if not available:
107
+ console.print(" [yellow] No documents found to push.[/yellow]")
108
+ console.print()
109
+ return
110
+
111
+ console.print(f" Space : [cyan]{cf_cfg.space_key}[/cyan]")
112
+ console.print(f" Parent : [cyan]{cf_cfg.parent_page_id or 'root'}[/cyan]")
113
+ console.print(f" Docs : [cyan]{len(available)}[/cyan]")
114
+ console.print()
115
+
116
+ if dry_run:
117
+ for key, md_path, title in available:
118
+ console.print(f" [dim]would push[/dim] [cyan]{title}[/cyan] ← {md_path}")
119
+ console.print()
120
+ return
121
+
122
+ try:
123
+ prof = load_profile(profile or cfg.profile)
124
+ session = build_session(prof)
125
+ except Exception as e:
126
+ console.print(f" [red]✗ Auth error: {e}[/red]")
127
+ raise SystemExit(1)
128
+
129
+ client = ConfluenceClient(session, prof.base_url)
130
+
131
+ for key, md_path, title in available:
132
+ body = md_to_storage(md_path.read_text())
133
+ try:
134
+ page, created = client.upsert_page(
135
+ cf_cfg.space_key, title, body, cf_cfg.parent_page_id
136
+ )
137
+ action = "[green]created[/green]" if created else "[dim]updated[/dim]"
138
+ console.print(f" {action} [cyan]{title}[/cyan]")
139
+ if created:
140
+ web_ui = page.get("_links", {}).get("webui", "")
141
+ if web_ui:
142
+ console.print(f" {prof.base_url}/wiki{web_ui}")
143
+ except Exception as e:
144
+ console.print(f" [red]✗ {title} — {e}[/red]")
145
+
146
+ console.print()
147
+ console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
148
+ console.print(" [bold green]Confluence push complete![/bold green]")
149
+ console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
150
+ console.print()
151
+
152
+
153
+ @confluence_command.command("draft")
154
+ @click.option("--doc", required=True,
155
+ help="Document type: context, brd, uc, srd, design, lld, security, ...")
156
+ @click.option("--profile", default=None)
157
+ @click.option("--feature", default=None, help="Feature name (default: from manifest.yml)")
158
+ @click.option("--dry-run", is_flag=True, help="Print title and path without calling the API")
159
+ def confluence_draft(doc, profile, feature, dry_run):
160
+ """Push a draft SDD document to Confluence and print the edit URL.
161
+
162
+ The page URL is printed so the user can open it, fill in any
163
+ [MISSING] sections or questions, then run `sdd confluence pull`
164
+ to fetch the updated version back.
165
+ """
166
+ console.print()
167
+ console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
168
+ label = " [bold cyan]SDD → Confluence Draft[/bold cyan]"
169
+ if dry_run:
170
+ label += " [yellow](dry run)[/yellow]"
171
+ console.print(label)
172
+ console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
173
+ console.print()
174
+
175
+ try:
176
+ cfg = load_integrations()
177
+ except FileNotFoundError as e:
178
+ console.print(f" [red]✗ {e}[/red]")
179
+ raise SystemExit(1)
180
+
181
+ if not cfg.confluence:
182
+ console.print(" [red]✗ No confluence: section in .specify/integrations.yml[/red]")
183
+ raise SystemExit(1)
184
+
185
+ cf_cfg = cfg.confluence
186
+ manifest = read_manifest() or {}
187
+ proj = manifest.get("project") or {}
188
+ project_name = proj.get("name", "Project")
189
+ feature_name = feature or proj.get("feature", "")
190
+
191
+ doc_path = _resolve_doc_path(doc, feature_name)
192
+ if not doc_path.exists():
193
+ console.print(f" [red]✗ File not found: {doc_path}[/red]")
194
+ console.print(" [dim]Generate the document first, then run this command.[/dim]")
195
+ raise SystemExit(1)
196
+
197
+ title = _resolve_page_title(doc, project_name, feature_name, cf_cfg.page_map)
198
+
199
+ console.print(f" Doc : [cyan]{doc}[/cyan] ({doc_path})")
200
+ console.print(f" Title : [cyan]{title}[/cyan]")
201
+ console.print(f" Space : [cyan]{cf_cfg.space_key}[/cyan]")
202
+ console.print()
203
+
204
+ if dry_run:
205
+ console.print(" [dim]would push draft page to Confluence[/dim]")
206
+ console.print()
207
+ return
208
+
209
+ try:
210
+ prof = load_profile(profile or cfg.profile)
211
+ session = build_session(prof)
212
+ except Exception as e:
213
+ console.print(f" [red]✗ Auth error: {e}[/red]")
214
+ raise SystemExit(1)
215
+
216
+ client = ConfluenceClient(session, prof.base_url)
217
+ body = md_to_storage(doc_path.read_text())
218
+
219
+ try:
220
+ page, created = client.upsert_page(
221
+ cf_cfg.space_key, title, body, cf_cfg.parent_page_id
222
+ )
223
+ except Exception as e:
224
+ console.print(f" [red]✗ Confluence error: {e}[/red]")
225
+ raise SystemExit(1)
226
+
227
+ page_id = page.get("id", "")
228
+ web_ui = page.get("_links", {}).get("webui", "")
229
+ edit_url = f"{prof.base_url}/wiki{web_ui}" if web_ui else ""
230
+
231
+ # Persist page_id so `sdd confluence pull` can find it later
232
+ drafts = _load_drafts()
233
+ drafts[doc] = {"page_id": page_id, "title": title}
234
+ _save_drafts(drafts)
235
+
236
+ action = "[green]created[/green]" if created else "[dim]updated[/dim]"
237
+ console.print(f" {action} [bold]{title}[/bold]")
238
+ if edit_url:
239
+ console.print(f" URL : [underline cyan]{edit_url}[/underline cyan]")
240
+ console.print()
241
+ console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
242
+ console.print(" [bold green]Draft pushed![/bold green] Open the URL above, fill in any")
243
+ console.print(" [MISSING] sections or answer the questions, then run:")
244
+ console.print()
245
+ console.print(f" [bold]sdd confluence pull --doc {doc}[/bold]")
246
+ console.print()
247
+ console.print(" to pull your edits back into the local file.")
248
+ console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
249
+ console.print()
250
+
251
+
252
+ @confluence_command.command("pull")
253
+ @click.option("--doc", required=True,
254
+ help="Document type: context, brd, uc, srd, design, lld, security, ...")
255
+ @click.option("--profile", default=None)
256
+ @click.option("--feature", default=None, help="Feature name (default: from manifest.yml)")
257
+ @click.option("--page-id", default=None, help="Confluence page ID (overrides saved value)")
258
+ def confluence_pull(doc, profile, feature, page_id):
259
+ """Pull the latest Confluence page content back to the local SDD file.
260
+
261
+ Run this after editing the draft page in Confluence to pull your
262
+ changes back so the AI can continue the workflow from the updated doc.
263
+ """
264
+ console.print()
265
+ console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
266
+ console.print(" [bold cyan]Confluence → SDD Pull[/bold cyan]")
267
+ console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
268
+ console.print()
269
+
270
+ try:
271
+ cfg = load_integrations()
272
+ except FileNotFoundError as e:
273
+ console.print(f" [red]✗ {e}[/red]")
274
+ raise SystemExit(1)
275
+
276
+ if not cfg.confluence:
277
+ console.print(" [red]✗ No confluence: section in .specify/integrations.yml[/red]")
278
+ raise SystemExit(1)
279
+
280
+ manifest = read_manifest() or {}
281
+ proj = manifest.get("project") or {}
282
+ feature_name = feature or proj.get("feature", "")
283
+
284
+ # Resolve page ID: explicit flag > saved drafts file
285
+ resolved_page_id = page_id
286
+ if not resolved_page_id:
287
+ drafts = _load_drafts()
288
+ entry = drafts.get(doc)
289
+ if entry:
290
+ resolved_page_id = entry.get("page_id")
291
+ if not resolved_page_id:
292
+ console.print(
293
+ f" [red]✗ No page ID for '{doc}'.[/red]\n"
294
+ " Run [bold]sdd confluence draft --doc {doc}[/bold] first, "
295
+ "or pass [bold]--page-id[/bold] directly."
296
+ )
297
+ raise SystemExit(1)
298
+
299
+ try:
300
+ prof = load_profile(profile or cfg.profile)
301
+ session = build_session(prof)
302
+ except Exception as e:
303
+ console.print(f" [red]✗ Auth error: {e}[/red]")
304
+ raise SystemExit(1)
305
+
306
+ client = ConfluenceClient(session, prof.base_url)
307
+
308
+ console.print(f" Fetching page [cyan]{resolved_page_id}[/cyan] from Confluence...")
309
+ try:
310
+ page = client.get_page_with_body(resolved_page_id)
311
+ except Exception as e:
312
+ console.print(f" [red]✗ Confluence error: {e}[/red]")
313
+ raise SystemExit(1)
314
+
315
+ storage_body = (
316
+ page.get("body", {}).get("storage", {}).get("value", "")
317
+ )
318
+ if not storage_body:
319
+ console.print(" [red]✗ Page body is empty.[/red]")
320
+ raise SystemExit(1)
321
+
322
+ markdown = cf_to_md(storage_body)
323
+
324
+ # Fetch comments (footer + inline) and append as a section the AI can read
325
+ console.print(" Fetching comments...")
326
+ try:
327
+ footer_comments = client.get_page_comments(resolved_page_id)
328
+ inline_comments = client.get_inline_comments(resolved_page_id)
329
+ all_comments = footer_comments + inline_comments
330
+ except Exception:
331
+ all_comments = []
332
+
333
+ if all_comments:
334
+ lines = [
335
+ "",
336
+ "---",
337
+ "",
338
+ "## Confluence Comments",
339
+ "",
340
+ "> These comments were added in Confluence by reviewers or stakeholders.",
341
+ "> The AI will incorporate them into the document — do not edit this section manually.",
342
+ "",
343
+ ]
344
+ for i, c in enumerate(all_comments, 1):
345
+ kind = "inline" if c["type"] == "inline" else "comment"
346
+ lines.append(f"### {kind.capitalize()} {i} — {c['author']} ({c['created']})")
347
+ lines.append("")
348
+ lines.append(c["text"])
349
+ lines.append("")
350
+ markdown = markdown + "\n" + "\n".join(lines)
351
+
352
+ doc_path = _resolve_doc_path(doc, feature_name)
353
+ doc_path.parent.mkdir(parents=True, exist_ok=True)
354
+
355
+ old_text = doc_path.read_text() if doc_path.exists() else ""
356
+ doc_path.write_text(markdown + "\n")
357
+
358
+ body_lines = len(cf_to_md(storage_body).splitlines())
359
+ comment_count = len(all_comments)
360
+ console.print(f" [green]✓[/green] Saved to [bold]{doc_path}[/bold]")
361
+ console.print(f" Body : {body_lines} lines")
362
+ if comment_count:
363
+ console.print(f" Comments : [yellow]{comment_count}[/yellow] (included for AI review)")
364
+ console.print()
365
+ console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
366
+ console.print(" [bold green]Pull complete![/bold green] The local file is now up to date.")
367
+ if comment_count:
368
+ console.print(f" [yellow]{comment_count} Confluence comment(s)[/yellow] included —")
369
+ console.print(" tell the AI 'done' and it will incorporate them.")
370
+ if doc == "context":
371
+ console.print(" Say [bold]'done'[/bold] in chat — the AI will read the updates")
372
+ console.print(" and comments, then continue.")
373
+ else:
374
+ console.print(f" Say [bold]'done'[/bold] in chat to resume the SDD workflow.")
375
+ console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
376
+ console.print()