spec-kitty-cli 0.12.1__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.
Files changed (242) hide show
  1. spec_kitty_cli-0.12.1.dist-info/METADATA +1767 -0
  2. spec_kitty_cli-0.12.1.dist-info/RECORD +242 -0
  3. spec_kitty_cli-0.12.1.dist-info/WHEEL +4 -0
  4. spec_kitty_cli-0.12.1.dist-info/entry_points.txt +2 -0
  5. spec_kitty_cli-0.12.1.dist-info/licenses/LICENSE +21 -0
  6. specify_cli/__init__.py +171 -0
  7. specify_cli/acceptance.py +627 -0
  8. specify_cli/agent_utils/README.md +157 -0
  9. specify_cli/agent_utils/__init__.py +9 -0
  10. specify_cli/agent_utils/status.py +356 -0
  11. specify_cli/cli/__init__.py +6 -0
  12. specify_cli/cli/commands/__init__.py +46 -0
  13. specify_cli/cli/commands/accept.py +189 -0
  14. specify_cli/cli/commands/agent/__init__.py +22 -0
  15. specify_cli/cli/commands/agent/config.py +382 -0
  16. specify_cli/cli/commands/agent/context.py +191 -0
  17. specify_cli/cli/commands/agent/feature.py +1057 -0
  18. specify_cli/cli/commands/agent/release.py +11 -0
  19. specify_cli/cli/commands/agent/tasks.py +1253 -0
  20. specify_cli/cli/commands/agent/workflow.py +801 -0
  21. specify_cli/cli/commands/context.py +246 -0
  22. specify_cli/cli/commands/dashboard.py +85 -0
  23. specify_cli/cli/commands/implement.py +973 -0
  24. specify_cli/cli/commands/init.py +827 -0
  25. specify_cli/cli/commands/init_help.py +62 -0
  26. specify_cli/cli/commands/merge.py +755 -0
  27. specify_cli/cli/commands/mission.py +240 -0
  28. specify_cli/cli/commands/ops.py +265 -0
  29. specify_cli/cli/commands/orchestrate.py +640 -0
  30. specify_cli/cli/commands/repair.py +175 -0
  31. specify_cli/cli/commands/research.py +165 -0
  32. specify_cli/cli/commands/sync.py +364 -0
  33. specify_cli/cli/commands/upgrade.py +249 -0
  34. specify_cli/cli/commands/validate_encoding.py +186 -0
  35. specify_cli/cli/commands/validate_tasks.py +186 -0
  36. specify_cli/cli/commands/verify.py +310 -0
  37. specify_cli/cli/helpers.py +123 -0
  38. specify_cli/cli/step_tracker.py +91 -0
  39. specify_cli/cli/ui.py +192 -0
  40. specify_cli/core/__init__.py +53 -0
  41. specify_cli/core/agent_context.py +311 -0
  42. specify_cli/core/config.py +96 -0
  43. specify_cli/core/context_validation.py +362 -0
  44. specify_cli/core/dependency_graph.py +351 -0
  45. specify_cli/core/git_ops.py +129 -0
  46. specify_cli/core/multi_parent_merge.py +323 -0
  47. specify_cli/core/paths.py +260 -0
  48. specify_cli/core/project_resolver.py +110 -0
  49. specify_cli/core/stale_detection.py +263 -0
  50. specify_cli/core/tool_checker.py +79 -0
  51. specify_cli/core/utils.py +43 -0
  52. specify_cli/core/vcs/__init__.py +114 -0
  53. specify_cli/core/vcs/detection.py +341 -0
  54. specify_cli/core/vcs/exceptions.py +85 -0
  55. specify_cli/core/vcs/git.py +1304 -0
  56. specify_cli/core/vcs/jujutsu.py +1208 -0
  57. specify_cli/core/vcs/protocol.py +285 -0
  58. specify_cli/core/vcs/types.py +249 -0
  59. specify_cli/core/version_checker.py +261 -0
  60. specify_cli/core/worktree.py +506 -0
  61. specify_cli/dashboard/__init__.py +28 -0
  62. specify_cli/dashboard/diagnostics.py +204 -0
  63. specify_cli/dashboard/handlers/__init__.py +17 -0
  64. specify_cli/dashboard/handlers/api.py +143 -0
  65. specify_cli/dashboard/handlers/base.py +65 -0
  66. specify_cli/dashboard/handlers/features.py +390 -0
  67. specify_cli/dashboard/handlers/router.py +81 -0
  68. specify_cli/dashboard/handlers/static.py +50 -0
  69. specify_cli/dashboard/lifecycle.py +541 -0
  70. specify_cli/dashboard/scanner.py +437 -0
  71. specify_cli/dashboard/server.py +123 -0
  72. specify_cli/dashboard/static/dashboard/dashboard.css +722 -0
  73. specify_cli/dashboard/static/dashboard/dashboard.js +1424 -0
  74. specify_cli/dashboard/static/spec-kitty.png +0 -0
  75. specify_cli/dashboard/templates/__init__.py +36 -0
  76. specify_cli/dashboard/templates/index.html +258 -0
  77. specify_cli/doc_generators.py +621 -0
  78. specify_cli/doc_state.py +408 -0
  79. specify_cli/frontmatter.py +384 -0
  80. specify_cli/gap_analysis.py +915 -0
  81. specify_cli/gitignore_manager.py +300 -0
  82. specify_cli/guards.py +145 -0
  83. specify_cli/legacy_detector.py +83 -0
  84. specify_cli/manifest.py +286 -0
  85. specify_cli/merge/__init__.py +63 -0
  86. specify_cli/merge/executor.py +653 -0
  87. specify_cli/merge/forecast.py +215 -0
  88. specify_cli/merge/ordering.py +126 -0
  89. specify_cli/merge/preflight.py +230 -0
  90. specify_cli/merge/state.py +185 -0
  91. specify_cli/merge/status_resolver.py +354 -0
  92. specify_cli/mission.py +654 -0
  93. specify_cli/missions/documentation/command-templates/implement.md +309 -0
  94. specify_cli/missions/documentation/command-templates/plan.md +275 -0
  95. specify_cli/missions/documentation/command-templates/review.md +344 -0
  96. specify_cli/missions/documentation/command-templates/specify.md +206 -0
  97. specify_cli/missions/documentation/command-templates/tasks.md +189 -0
  98. specify_cli/missions/documentation/mission.yaml +113 -0
  99. specify_cli/missions/documentation/templates/divio/explanation-template.md +192 -0
  100. specify_cli/missions/documentation/templates/divio/howto-template.md +168 -0
  101. specify_cli/missions/documentation/templates/divio/reference-template.md +179 -0
  102. specify_cli/missions/documentation/templates/divio/tutorial-template.md +146 -0
  103. specify_cli/missions/documentation/templates/generators/jsdoc.json.template +18 -0
  104. specify_cli/missions/documentation/templates/generators/sphinx-conf.py.template +36 -0
  105. specify_cli/missions/documentation/templates/plan-template.md +269 -0
  106. specify_cli/missions/documentation/templates/release-template.md +222 -0
  107. specify_cli/missions/documentation/templates/spec-template.md +172 -0
  108. specify_cli/missions/documentation/templates/task-prompt-template.md +140 -0
  109. specify_cli/missions/documentation/templates/tasks-template.md +159 -0
  110. specify_cli/missions/research/command-templates/merge.md +388 -0
  111. specify_cli/missions/research/command-templates/plan.md +125 -0
  112. specify_cli/missions/research/command-templates/review.md +144 -0
  113. specify_cli/missions/research/command-templates/tasks.md +225 -0
  114. specify_cli/missions/research/mission.yaml +115 -0
  115. specify_cli/missions/research/templates/data-model-template.md +33 -0
  116. specify_cli/missions/research/templates/plan-template.md +161 -0
  117. specify_cli/missions/research/templates/research/evidence-log.csv +18 -0
  118. specify_cli/missions/research/templates/research/source-register.csv +18 -0
  119. specify_cli/missions/research/templates/research-template.md +35 -0
  120. specify_cli/missions/research/templates/spec-template.md +64 -0
  121. specify_cli/missions/research/templates/task-prompt-template.md +148 -0
  122. specify_cli/missions/research/templates/tasks-template.md +114 -0
  123. specify_cli/missions/software-dev/command-templates/accept.md +75 -0
  124. specify_cli/missions/software-dev/command-templates/analyze.md +183 -0
  125. specify_cli/missions/software-dev/command-templates/checklist.md +286 -0
  126. specify_cli/missions/software-dev/command-templates/clarify.md +157 -0
  127. specify_cli/missions/software-dev/command-templates/constitution.md +432 -0
  128. specify_cli/missions/software-dev/command-templates/dashboard.md +101 -0
  129. specify_cli/missions/software-dev/command-templates/implement.md +41 -0
  130. specify_cli/missions/software-dev/command-templates/merge.md +383 -0
  131. specify_cli/missions/software-dev/command-templates/plan.md +171 -0
  132. specify_cli/missions/software-dev/command-templates/review.md +32 -0
  133. specify_cli/missions/software-dev/command-templates/specify.md +321 -0
  134. specify_cli/missions/software-dev/command-templates/tasks.md +566 -0
  135. specify_cli/missions/software-dev/mission.yaml +100 -0
  136. specify_cli/missions/software-dev/templates/plan-template.md +132 -0
  137. specify_cli/missions/software-dev/templates/spec-template.md +116 -0
  138. specify_cli/missions/software-dev/templates/task-prompt-template.md +140 -0
  139. specify_cli/missions/software-dev/templates/tasks-template.md +159 -0
  140. specify_cli/orchestrator/__init__.py +75 -0
  141. specify_cli/orchestrator/agent_config.py +224 -0
  142. specify_cli/orchestrator/agents/__init__.py +170 -0
  143. specify_cli/orchestrator/agents/augment.py +112 -0
  144. specify_cli/orchestrator/agents/base.py +243 -0
  145. specify_cli/orchestrator/agents/claude.py +112 -0
  146. specify_cli/orchestrator/agents/codex.py +106 -0
  147. specify_cli/orchestrator/agents/copilot.py +137 -0
  148. specify_cli/orchestrator/agents/cursor.py +139 -0
  149. specify_cli/orchestrator/agents/gemini.py +115 -0
  150. specify_cli/orchestrator/agents/kilocode.py +94 -0
  151. specify_cli/orchestrator/agents/opencode.py +132 -0
  152. specify_cli/orchestrator/agents/qwen.py +96 -0
  153. specify_cli/orchestrator/config.py +455 -0
  154. specify_cli/orchestrator/executor.py +642 -0
  155. specify_cli/orchestrator/integration.py +1230 -0
  156. specify_cli/orchestrator/monitor.py +898 -0
  157. specify_cli/orchestrator/scheduler.py +832 -0
  158. specify_cli/orchestrator/state.py +508 -0
  159. specify_cli/orchestrator/testing/__init__.py +122 -0
  160. specify_cli/orchestrator/testing/availability.py +346 -0
  161. specify_cli/orchestrator/testing/fixtures.py +684 -0
  162. specify_cli/orchestrator/testing/paths.py +218 -0
  163. specify_cli/plan_validation.py +107 -0
  164. specify_cli/scripts/debug-dashboard-scan.py +61 -0
  165. specify_cli/scripts/tasks/acceptance_support.py +695 -0
  166. specify_cli/scripts/tasks/task_helpers.py +506 -0
  167. specify_cli/scripts/tasks/tasks_cli.py +848 -0
  168. specify_cli/scripts/validate_encoding.py +180 -0
  169. specify_cli/task_metadata_validation.py +274 -0
  170. specify_cli/tasks_support.py +447 -0
  171. specify_cli/template/__init__.py +47 -0
  172. specify_cli/template/asset_generator.py +206 -0
  173. specify_cli/template/github_client.py +334 -0
  174. specify_cli/template/manager.py +193 -0
  175. specify_cli/template/renderer.py +99 -0
  176. specify_cli/templates/AGENTS.md +190 -0
  177. specify_cli/templates/POWERSHELL_SYNTAX.md +229 -0
  178. specify_cli/templates/agent-file-template.md +35 -0
  179. specify_cli/templates/checklist-template.md +42 -0
  180. specify_cli/templates/claudeignore-template +58 -0
  181. specify_cli/templates/command-templates/accept.md +141 -0
  182. specify_cli/templates/command-templates/analyze.md +253 -0
  183. specify_cli/templates/command-templates/checklist.md +352 -0
  184. specify_cli/templates/command-templates/clarify.md +224 -0
  185. specify_cli/templates/command-templates/constitution.md +432 -0
  186. specify_cli/templates/command-templates/dashboard.md +175 -0
  187. specify_cli/templates/command-templates/implement.md +190 -0
  188. specify_cli/templates/command-templates/merge.md +374 -0
  189. specify_cli/templates/command-templates/plan.md +171 -0
  190. specify_cli/templates/command-templates/research.md +88 -0
  191. specify_cli/templates/command-templates/review.md +510 -0
  192. specify_cli/templates/command-templates/specify.md +321 -0
  193. specify_cli/templates/command-templates/status.md +92 -0
  194. specify_cli/templates/command-templates/tasks.md +199 -0
  195. specify_cli/templates/git-hooks/pre-commit +22 -0
  196. specify_cli/templates/git-hooks/pre-commit-agent-check +37 -0
  197. specify_cli/templates/git-hooks/pre-commit-encoding-check +142 -0
  198. specify_cli/templates/plan-template.md +108 -0
  199. specify_cli/templates/spec-template.md +118 -0
  200. specify_cli/templates/task-prompt-template.md +165 -0
  201. specify_cli/templates/tasks-template.md +161 -0
  202. specify_cli/templates/vscode-settings.json +13 -0
  203. specify_cli/text_sanitization.py +225 -0
  204. specify_cli/upgrade/__init__.py +18 -0
  205. specify_cli/upgrade/detector.py +239 -0
  206. specify_cli/upgrade/metadata.py +182 -0
  207. specify_cli/upgrade/migrations/__init__.py +65 -0
  208. specify_cli/upgrade/migrations/base.py +80 -0
  209. specify_cli/upgrade/migrations/m_0_10_0_python_only.py +359 -0
  210. specify_cli/upgrade/migrations/m_0_10_12_constitution_cleanup.py +99 -0
  211. specify_cli/upgrade/migrations/m_0_10_14_update_implement_slash_command.py +176 -0
  212. specify_cli/upgrade/migrations/m_0_10_1_populate_slash_commands.py +174 -0
  213. specify_cli/upgrade/migrations/m_0_10_2_update_slash_commands.py +172 -0
  214. specify_cli/upgrade/migrations/m_0_10_6_workflow_simplification.py +174 -0
  215. specify_cli/upgrade/migrations/m_0_10_8_fix_memory_structure.py +252 -0
  216. specify_cli/upgrade/migrations/m_0_10_9_repair_templates.py +168 -0
  217. specify_cli/upgrade/migrations/m_0_11_0_workspace_per_wp.py +182 -0
  218. specify_cli/upgrade/migrations/m_0_11_1_improved_workflow_templates.py +173 -0
  219. specify_cli/upgrade/migrations/m_0_11_1_update_implement_slash_command.py +160 -0
  220. specify_cli/upgrade/migrations/m_0_11_2_improved_workflow_templates.py +173 -0
  221. specify_cli/upgrade/migrations/m_0_11_3_workflow_agent_flag.py +114 -0
  222. specify_cli/upgrade/migrations/m_0_12_0_documentation_mission.py +155 -0
  223. specify_cli/upgrade/migrations/m_0_12_1_remove_kitty_specs_from_gitignore.py +183 -0
  224. specify_cli/upgrade/migrations/m_0_2_0_specify_to_kittify.py +80 -0
  225. specify_cli/upgrade/migrations/m_0_4_8_gitignore_agents.py +118 -0
  226. specify_cli/upgrade/migrations/m_0_5_0_encoding_hooks.py +141 -0
  227. specify_cli/upgrade/migrations/m_0_6_5_commands_rename.py +169 -0
  228. specify_cli/upgrade/migrations/m_0_6_7_ensure_missions.py +228 -0
  229. specify_cli/upgrade/migrations/m_0_7_2_worktree_commands_dedup.py +89 -0
  230. specify_cli/upgrade/migrations/m_0_7_3_update_scripts.py +114 -0
  231. specify_cli/upgrade/migrations/m_0_8_0_remove_active_mission.py +82 -0
  232. specify_cli/upgrade/migrations/m_0_8_0_worktree_agents_symlink.py +148 -0
  233. specify_cli/upgrade/migrations/m_0_9_0_frontmatter_only_lanes.py +346 -0
  234. specify_cli/upgrade/migrations/m_0_9_1_complete_lane_migration.py +656 -0
  235. specify_cli/upgrade/migrations/m_0_9_2_research_mission_templates.py +221 -0
  236. specify_cli/upgrade/registry.py +121 -0
  237. specify_cli/upgrade/runner.py +284 -0
  238. specify_cli/validators/__init__.py +14 -0
  239. specify_cli/validators/paths.py +154 -0
  240. specify_cli/validators/research.py +428 -0
  241. specify_cli/verify_enhanced.py +270 -0
  242. specify_cli/workspace_context.py +224 -0
@@ -0,0 +1,334 @@
1
+ """GitHub template download and extraction helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import tempfile
8
+ import zipfile
9
+ from pathlib import Path
10
+ from typing import Tuple
11
+
12
+ import httpx
13
+ from rich.console import Console
14
+ from rich.panel import Panel
15
+ from rich.progress import Progress, SpinnerColumn, TextColumn
16
+
17
+ import ssl
18
+ import truststore
19
+
20
+ from specify_cli.cli import StepTracker
21
+
22
+
23
+ class GitHubClientError(RuntimeError):
24
+ """Raised when GitHub template operations fail."""
25
+
26
+
27
+ SSL_CONTEXT = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
28
+ DEFAULT_CONSOLE = Console()
29
+
30
+
31
+ def build_http_client(*, skip_tls: bool = False) -> httpx.Client:
32
+ """Create a default httpx client honoring TLS verification flags."""
33
+ verify = SSL_CONTEXT if not skip_tls else False
34
+ return httpx.Client(verify=verify)
35
+
36
+
37
+ def _github_token(cli_token: str | None = None) -> str | None:
38
+ """Return sanitized GitHub token (CLI argument takes precedence)."""
39
+ token = (cli_token or os.getenv("GH_TOKEN") or os.getenv("GITHUB_TOKEN") or "").strip()
40
+ return token or None
41
+
42
+
43
+ def _github_auth_headers(cli_token: str | None = None) -> dict[str, str]:
44
+ """Return Authorization header dict only when a non-empty token exists."""
45
+ token = _github_token(cli_token)
46
+ return {"Authorization": f"Bearer {token}"} if token else {}
47
+
48
+
49
+ def parse_repo_slug(slug: str) -> tuple[str, str]:
50
+ """Return (owner, repo) tuple for strings like 'owner/name'."""
51
+ parts = slug.strip().split("/")
52
+ if len(parts) != 2 or not all(parts):
53
+ raise ValueError(f"Invalid GitHub repo slug '{slug}'. Expected format owner/name")
54
+ return parts[0], parts[1]
55
+
56
+
57
+ def download_template_from_github(
58
+ repo_owner: str,
59
+ repo_name: str,
60
+ ai_assistant: str,
61
+ download_dir: Path,
62
+ *,
63
+ script_type: str = "sh",
64
+ verbose: bool = True,
65
+ show_progress: bool = True,
66
+ client: httpx.Client | None = None,
67
+ debug: bool = False,
68
+ github_token: str | None = None,
69
+ console: Console | None = None,
70
+ ) -> Tuple[Path, dict]:
71
+ """Download the release asset for the requested AI assistant."""
72
+ console = console or DEFAULT_CONSOLE
73
+ client = client or build_http_client()
74
+
75
+ if verbose:
76
+ console.print("[cyan]Fetching latest release information...[/cyan]")
77
+ api_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/latest"
78
+
79
+ try:
80
+ response = client.get(
81
+ api_url,
82
+ timeout=30,
83
+ follow_redirects=True,
84
+ headers=_github_auth_headers(github_token),
85
+ )
86
+ status = response.status_code
87
+ if status != 200:
88
+ msg = f"GitHub API returned {status} for {api_url}"
89
+ if debug:
90
+ msg += f"\nResponse headers: {response.headers}\nBody (truncated 500): {response.text[:500]}"
91
+ raise GitHubClientError(msg)
92
+ try:
93
+ release_data = response.json()
94
+ except ValueError as exc:
95
+ raise GitHubClientError(
96
+ f"Failed to parse release JSON: {exc}\nRaw (truncated 400): {response.text[:400]}"
97
+ ) from exc
98
+ except GitHubClientError:
99
+ raise
100
+ except Exception as exc:
101
+ console.print("[red]Error fetching release information[/red]")
102
+ console.print(Panel(str(exc), title="Fetch Error", border_style="red"))
103
+ raise GitHubClientError(str(exc)) from exc
104
+
105
+ assets = release_data.get("assets", [])
106
+ pattern = f"spec-kitty-template-{ai_assistant}-{script_type}"
107
+ matching_assets = [
108
+ asset for asset in assets if pattern in asset.get("name", "") and asset.get("name", "").endswith(".zip")
109
+ ]
110
+ asset = matching_assets[0] if matching_assets else None
111
+ if asset is None:
112
+ asset_names = [a.get("name", "?") for a in assets]
113
+ console.print(
114
+ f"[red]No matching release asset found[/red] for [bold]{ai_assistant}[/bold] "
115
+ f"(expected pattern: [bold]{pattern}[/bold])"
116
+ )
117
+ console.print(Panel("\n".join(asset_names) or "(no assets)", title="Available Assets", border_style="yellow"))
118
+ raise GitHubClientError("No matching release asset found")
119
+
120
+ download_url = asset["browser_download_url"]
121
+ filename = asset["name"]
122
+ file_size = asset["size"]
123
+
124
+ if verbose:
125
+ console.print(f"[cyan]Found template:[/cyan] {filename}")
126
+ console.print(f"[cyan]Size:[/cyan] {file_size:,} bytes")
127
+ console.print(f"[cyan]Release:[/cyan] {release_data['tag_name']}")
128
+
129
+ zip_path = download_dir / filename
130
+ if verbose:
131
+ console.print("[cyan]Downloading template...[/cyan]")
132
+
133
+ try:
134
+ with client.stream(
135
+ "GET",
136
+ download_url,
137
+ timeout=60,
138
+ follow_redirects=True,
139
+ headers=_github_auth_headers(github_token),
140
+ ) as response:
141
+ if response.status_code != 200:
142
+ body_sample = response.text[:400]
143
+ raise GitHubClientError(
144
+ f"Download failed with {response.status_code}\\nHeaders: {response.headers}\\nBody (truncated): {body_sample}"
145
+ )
146
+ total_size = int(response.headers.get("content-length", 0))
147
+ with open(zip_path, "wb") as fh:
148
+ if total_size == 0 or not show_progress:
149
+ for chunk in response.iter_bytes(chunk_size=8192):
150
+ fh.write(chunk)
151
+ else:
152
+ with Progress(
153
+ SpinnerColumn(),
154
+ TextColumn("[progress.description]{task.description}"),
155
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
156
+ console=console,
157
+ ) as progress:
158
+ task = progress.add_task("Downloading...", total=total_size)
159
+ downloaded = 0
160
+ for chunk in response.iter_bytes(chunk_size=8192):
161
+ fh.write(chunk)
162
+ downloaded += len(chunk)
163
+ progress.update(task, completed=downloaded)
164
+ except GitHubClientError:
165
+ if zip_path.exists():
166
+ zip_path.unlink()
167
+ raise
168
+ except Exception as exc:
169
+ if zip_path.exists():
170
+ zip_path.unlink()
171
+ console.print("[red]Error downloading template[/red]")
172
+ console.print(Panel(str(exc), title="Download Error", border_style="red"))
173
+ raise GitHubClientError(str(exc)) from exc
174
+
175
+ if verbose:
176
+ console.print(f"Downloaded: {filename}")
177
+ metadata = {
178
+ "filename": filename,
179
+ "size": file_size,
180
+ "release": release_data["tag_name"],
181
+ "asset_url": download_url,
182
+ }
183
+ return zip_path, metadata
184
+
185
+
186
+ def download_and_extract_template(
187
+ project_path: Path,
188
+ ai_assistant: str,
189
+ script_type: str,
190
+ is_current_dir: bool = False,
191
+ *,
192
+ verbose: bool = True,
193
+ tracker: StepTracker | None = None,
194
+ tracker_prefix: str | None = None,
195
+ allow_existing: bool = False,
196
+ client: httpx.Client | None = None,
197
+ debug: bool = False,
198
+ github_token: str | None = None,
199
+ repo_owner: str = "spec-kitty",
200
+ repo_name: str = "spec-kitty",
201
+ console: Console | None = None,
202
+ ) -> Path:
203
+ """Download the latest release and extract it to create a new project."""
204
+ console = console or DEFAULT_CONSOLE
205
+ current_dir = Path.cwd()
206
+
207
+ def tk(step: str) -> str:
208
+ return f"{tracker_prefix}-{step}" if tracker_prefix else step
209
+
210
+ if tracker:
211
+ tracker.start(tk("fetch"), "contacting GitHub API")
212
+ try:
213
+ zip_path, meta = download_template_from_github(
214
+ repo_owner,
215
+ repo_name,
216
+ ai_assistant,
217
+ current_dir,
218
+ script_type=script_type,
219
+ verbose=verbose and tracker is None,
220
+ show_progress=(tracker is None),
221
+ client=client,
222
+ debug=debug,
223
+ github_token=github_token,
224
+ console=console,
225
+ )
226
+ if tracker:
227
+ tracker.complete(tk("fetch"), f"release {meta['release']} ({meta['size']:,} bytes)")
228
+ tracker.add(tk("download"), "Download template")
229
+ tracker.complete(tk("download"), meta["filename"])
230
+ except GitHubClientError:
231
+ if tracker:
232
+ tracker.error(tk("fetch"), "failed")
233
+ raise
234
+
235
+ if tracker:
236
+ tracker.add(tk("extract"), "Extract template")
237
+ tracker.start(tk("extract"))
238
+ elif verbose:
239
+ console.print("Extracting template...")
240
+
241
+ temp_dir: Path | None = None
242
+ try:
243
+ if not is_current_dir:
244
+ project_path.mkdir(parents=True, exist_ok=True)
245
+
246
+ with zipfile.ZipFile(zip_path, "r") as zip_ref:
247
+ names = zip_ref.namelist()
248
+ if tracker:
249
+ tracker.start(tk("zip-list"))
250
+ tracker.complete(tk("zip-list"), f"{len(names)} entries")
251
+ elif verbose:
252
+ console.print(f"[cyan]ZIP contains {len(names)} items[/cyan]")
253
+
254
+ temp_dir = Path(tempfile.mkdtemp())
255
+ zip_ref.extractall(temp_dir)
256
+
257
+ extracted_items = list(temp_dir.iterdir())
258
+ if tracker:
259
+ tracker.start(tk("extracted-summary"))
260
+ tracker.complete(tk("extracted-summary"), f"temp {len(extracted_items)} items")
261
+ elif verbose:
262
+ console.print(f"[cyan]Extracted {len(extracted_items)} items to temp location[/cyan]")
263
+
264
+ source_dir = extracted_items[0] if len(extracted_items) == 1 and extracted_items[0].is_dir() else temp_dir
265
+ if source_dir is not temp_dir:
266
+ if tracker:
267
+ tracker.add(tk("flatten"), "Flatten nested directory")
268
+ tracker.complete(tk("flatten"))
269
+ elif verbose:
270
+ console.print("[cyan]Found nested directory structure[/cyan]")
271
+
272
+ if is_current_dir or allow_existing:
273
+ _merge_tree(source_dir, project_path, console, verbose and not tracker)
274
+ else:
275
+ # For new project directories, we need to move the contents not the directory itself
276
+ # Create the project_path first if it doesn't exist
277
+ project_path.mkdir(parents=True, exist_ok=True)
278
+ # Move each item from source_dir into project_path
279
+ for item in source_dir.iterdir():
280
+ dest_item = project_path / item.name
281
+ shutil.move(str(item), str(dest_item))
282
+ except Exception as exc:
283
+ if tracker:
284
+ tracker.error(tk("extract"), str(exc))
285
+ else:
286
+ console.print("[red]Error extracting template[/red]")
287
+ console.print(Panel(str(exc), title="Extraction Error", border_style="red"))
288
+ if not is_current_dir and project_path.exists():
289
+ shutil.rmtree(project_path)
290
+ raise GitHubClientError(str(exc)) from exc
291
+ finally:
292
+ if temp_dir and temp_dir.exists():
293
+ shutil.rmtree(temp_dir, ignore_errors=True)
294
+ if tracker:
295
+ tracker.add(tk("cleanup"), "Remove temporary archive")
296
+ if zip_path.exists():
297
+ zip_path.unlink()
298
+ if tracker:
299
+ tracker.complete(tk("cleanup"), meta["filename"])
300
+ elif verbose:
301
+ console.print(f"Cleaned up: {zip_path.name}")
302
+ elif tracker:
303
+ tracker.complete(tk("cleanup"), "skipped")
304
+
305
+ if tracker:
306
+ tracker.complete(tk("extract"), "done")
307
+ elif verbose:
308
+ console.print(f"[cyan]Template files {'merged' if is_current_dir else 'extracted'}[/cyan]")
309
+
310
+ return project_path
311
+
312
+
313
+
314
+ def _merge_tree(source_dir: Path, dest_dir: Path, console: Console, verbose: bool) -> None:
315
+ """Merge directory contents from source into destination."""
316
+ for item in source_dir.iterdir():
317
+ dest_path = dest_dir / item.name
318
+ if item.is_dir():
319
+ shutil.copytree(item, dest_path, dirs_exist_ok=True)
320
+ else:
321
+ if dest_path.exists() and verbose:
322
+ console.print(f"[yellow]Overwriting file:[/yellow] {item.name}")
323
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
324
+ shutil.copy2(item, dest_path)
325
+
326
+
327
+ __all__ = [
328
+ "GitHubClientError",
329
+ "SSL_CONTEXT",
330
+ "build_http_client",
331
+ "download_and_extract_template",
332
+ "download_template_from_github",
333
+ "parse_repo_slug",
334
+ ]
@@ -0,0 +1,193 @@
1
+ """Template discovery and copy helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ from importlib.resources import files
8
+ from pathlib import Path
9
+
10
+ from rich.console import Console
11
+
12
+ console = Console()
13
+
14
+
15
+ def get_local_repo_root(override_path: str | None = None) -> Path | None:
16
+ """Return repository root when running from a local checkout, else None.
17
+
18
+ Args:
19
+ override_path: Optional override path (e.g., from --template-root flag)
20
+
21
+ Returns:
22
+ Path to repository root containing src/specify_cli/templates/command-templates, or None
23
+ """
24
+ # Check override path first (from --template-root flag)
25
+ if override_path:
26
+ override = Path(override_path).expanduser().resolve()
27
+ if (override / "src" / "specify_cli" / "templates" / "command-templates").exists():
28
+ return override
29
+ # Legacy fallback for old template structure
30
+ if (override / ".kittify" / "templates" / "command-templates").exists():
31
+ return override
32
+ console.print(
33
+ f"[yellow]--template-root set to {override}, but src/specify_cli/templates/command-templates not found there. Ignoring.[/yellow]"
34
+ )
35
+
36
+ # Check environment variable
37
+ env_root = os.environ.get("SPEC_KITTY_TEMPLATE_ROOT")
38
+ if env_root:
39
+ root_path = Path(env_root).expanduser().resolve()
40
+ if (root_path / "src" / "specify_cli" / "templates" / "command-templates").exists():
41
+ return root_path
42
+ # Legacy fallback for old template structure
43
+ if (root_path / ".kittify" / "templates" / "command-templates").exists():
44
+ return root_path
45
+ console.print(
46
+ f"[yellow]SPEC_KITTY_TEMPLATE_ROOT set to {root_path}, but src/specify_cli/templates/command-templates not found there. Ignoring.[/yellow]"
47
+ )
48
+
49
+ # Check package location
50
+ candidate = Path(__file__).resolve().parents[2]
51
+ if (candidate / "src" / "specify_cli" / "templates" / "command-templates").exists():
52
+ return candidate
53
+ # Legacy fallback for old template structure
54
+ if (candidate / ".kittify" / "templates" / "command-templates").exists():
55
+ return candidate
56
+ return None
57
+
58
+
59
+ def copy_specify_base_from_local(repo_root: Path, project_path: Path, script_type: str) -> Path:
60
+ """Copy the embedded .kittify assets from a local repository checkout."""
61
+ specify_root = project_path / ".kittify"
62
+ specify_root.mkdir(parents=True, exist_ok=True)
63
+
64
+ # Copy from .kittify/memory/ for consistency with other .kittify paths
65
+ memory_src = repo_root / ".kittify" / "memory"
66
+ if memory_src.exists():
67
+ memory_dest = specify_root / "memory"
68
+ if memory_dest.exists():
69
+ shutil.rmtree(memory_dest)
70
+ shutil.copytree(memory_src, memory_dest)
71
+
72
+ # Copy from src/specify_cli/scripts/ (not root /scripts/)
73
+ # The src/specify_cli/scripts/ directory has the full implementation including
74
+ # worktree symlink code for shared constitution
75
+ scripts_src = repo_root / "src" / "specify_cli" / "scripts"
76
+ if scripts_src.exists():
77
+ scripts_dest = specify_root / "scripts"
78
+ if scripts_dest.exists():
79
+ shutil.rmtree(scripts_dest)
80
+ scripts_dest.mkdir(parents=True, exist_ok=True)
81
+ variant = "bash" if script_type == "sh" else "powershell"
82
+ variant_src = scripts_src / variant
83
+ if variant_src.exists():
84
+ shutil.copytree(variant_src, scripts_dest / variant)
85
+ tasks_src = scripts_src / "tasks"
86
+ if tasks_src.exists():
87
+ shutil.copytree(tasks_src, scripts_dest / "tasks")
88
+ for item in scripts_src.iterdir():
89
+ if item.is_file():
90
+ shutil.copy2(item, scripts_dest / item.name)
91
+
92
+ # Copy from src/specify_cli/templates/ (not root /templates/)
93
+ # The src/specify_cli/templates/ directory contains:
94
+ # - command-templates/ (agent command templates)
95
+ # - git-hooks/ (pre-commit hooks)
96
+ # - claudeignore-template
97
+ # - AGENTS.md
98
+ templates_src = repo_root / "src" / "specify_cli" / "templates"
99
+ if templates_src.exists():
100
+ templates_dest = specify_root / "templates"
101
+ if templates_dest.exists():
102
+ shutil.rmtree(templates_dest)
103
+ shutil.copytree(templates_src, templates_dest)
104
+ agents_template = templates_src / "AGENTS.md"
105
+ if agents_template.exists():
106
+ shutil.copy2(agents_template, specify_root / "AGENTS.md")
107
+
108
+ missions_src = repo_root / "src" / "specify_cli" / "missions"
109
+ if missions_src.exists():
110
+ missions_dest = specify_root / "missions"
111
+ if missions_dest.exists():
112
+ shutil.rmtree(missions_dest)
113
+ shutil.copytree(missions_src, missions_dest)
114
+
115
+ # NOTE: Templates are copied temporarily for agent command generation
116
+ # They will be cleaned up after all commands are generated (see init.py)
117
+ return specify_root / "templates" / "command-templates"
118
+
119
+
120
+ def copy_package_tree(resource, dest: Path) -> None:
121
+ """Recursively copy an importlib.resources directory tree."""
122
+ if dest.exists():
123
+ shutil.rmtree(dest)
124
+ dest.mkdir(parents=True, exist_ok=True)
125
+ for child in resource.iterdir():
126
+ target = dest / child.name
127
+ if child.is_dir():
128
+ copy_package_tree(child, target)
129
+ else:
130
+ with child.open("rb") as src, open(target, "wb") as dst:
131
+ shutil.copyfileobj(src, dst)
132
+
133
+
134
+ def copy_specify_base_from_package(project_path: Path, script_type: str) -> Path:
135
+ """Copy the packaged .kittify assets that ship with the CLI."""
136
+ data_root = files("specify_cli")
137
+ specify_root = project_path / ".kittify"
138
+ specify_root.mkdir(parents=True, exist_ok=True)
139
+
140
+ memory_resource = data_root.joinpath("memory")
141
+ if memory_resource.exists():
142
+ copy_package_tree(memory_resource, specify_root / "memory")
143
+
144
+ scripts_resource = data_root.joinpath("scripts")
145
+ if scripts_resource.exists():
146
+ scripts_dest = specify_root / "scripts"
147
+ if scripts_dest.exists():
148
+ shutil.rmtree(scripts_dest)
149
+ scripts_dest.mkdir(parents=True, exist_ok=True)
150
+ variant_name = "bash" if script_type == "sh" else "powershell"
151
+ variant_resource = scripts_resource.joinpath(variant_name)
152
+ if variant_resource.exists():
153
+ copy_package_tree(variant_resource, scripts_dest / variant_name)
154
+ tasks_resource = scripts_resource.joinpath("tasks")
155
+ if tasks_resource.exists():
156
+ copy_package_tree(tasks_resource, scripts_dest / "tasks")
157
+ for resource_file in scripts_resource.iterdir():
158
+ if resource_file.is_file():
159
+ with resource_file.open("rb") as src, open(
160
+ scripts_dest / resource_file.name, "wb"
161
+ ) as dst:
162
+ shutil.copyfileobj(src, dst)
163
+
164
+ templates_resource = data_root.joinpath("templates")
165
+ if templates_resource.exists():
166
+ templates_dest = specify_root / "templates"
167
+ copy_package_tree(templates_resource, templates_dest)
168
+ agents_template = templates_resource.joinpath("AGENTS.md")
169
+ if agents_template.exists():
170
+ with agents_template.open("rb") as src, open(
171
+ specify_root / "AGENTS.md", "wb"
172
+ ) as dst:
173
+ shutil.copyfileobj(src, dst)
174
+
175
+ missions_resource_candidates = [
176
+ data_root.joinpath("missions"), # Primary location per pyproject.toml
177
+ data_root.joinpath(".kittify", "missions"), # Legacy fallback
178
+ data_root.joinpath("template_data", "missions"), # Legacy fallback
179
+ ]
180
+ for missions_resource in missions_resource_candidates:
181
+ if missions_resource.exists():
182
+ copy_package_tree(missions_resource, specify_root / "missions")
183
+ break
184
+
185
+ return specify_root / "templates" / "command-templates"
186
+
187
+
188
+ __all__ = [
189
+ "copy_package_tree",
190
+ "copy_specify_base_from_local",
191
+ "copy_specify_base_from_package",
192
+ "get_local_repo_root",
193
+ ]
@@ -0,0 +1,99 @@
1
+ """Template rendering helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Callable, Mapping
7
+ from pathlib import Path
8
+ from typing import Any, Dict
9
+
10
+ import yaml
11
+
12
+ DEFAULT_PATH_PATTERNS: dict[str, str] = {
13
+ r"(?<!\.kittify/)scripts/": ".kittify/scripts/",
14
+ r"(?<!\.kittify/)templates/": ".kittify/templates/",
15
+ r"(?<!\.kittify/)memory/": ".kittify/memory/",
16
+ }
17
+
18
+ VariablesResolver = Mapping[str, str] | Callable[[dict[str, Any]], Mapping[str, str]]
19
+
20
+
21
+ def parse_frontmatter(content: str) -> tuple[dict[str, Any], str, str]:
22
+ """Parse frontmatter from markdown content.
23
+
24
+ Returns a tuple of (metadata, body, raw_frontmatter_text). If no frontmatter
25
+ is present the metadata dict is empty and the raw text is an empty string.
26
+ """
27
+ normalized = content.replace("\r", "")
28
+ if not normalized.startswith("---\n"):
29
+ return {}, normalized, ""
30
+
31
+ closing_index = normalized.find("\n---", 4)
32
+ if closing_index == -1:
33
+ return {}, normalized, ""
34
+
35
+ frontmatter_text = normalized[4:closing_index]
36
+ body_start = closing_index + len("\n---")
37
+ if body_start < len(normalized) and normalized[body_start] == "\n":
38
+ body_start += 1
39
+ body = normalized[body_start:]
40
+
41
+ try:
42
+ metadata = yaml.safe_load(frontmatter_text) or {}
43
+ if not isinstance(metadata, dict):
44
+ metadata = {}
45
+ except yaml.YAMLError:
46
+ metadata = {}
47
+
48
+ return metadata, body, frontmatter_text
49
+
50
+
51
+ def rewrite_paths(content: str, replacements: Mapping[str, str] | None = None) -> str:
52
+ """Rewrite template paths so generated files point to .kittify assets."""
53
+ patterns = replacements or DEFAULT_PATH_PATTERNS
54
+ rewritten = content
55
+ for pattern, replacement in patterns.items():
56
+ rewritten = re.sub(pattern, replacement, rewritten)
57
+ return rewritten
58
+
59
+
60
+ def render_template(
61
+ template_path: Path,
62
+ variables: VariablesResolver | None = None,
63
+ ) -> tuple[dict[str, Any], str, str]:
64
+ """Render a template by applying frontmatter parsing and substitutions."""
65
+ text = template_path.read_text(encoding="utf-8-sig").replace("\r", "")
66
+ metadata, body, raw_frontmatter = parse_frontmatter(text)
67
+ replacements = _resolve_variables(variables, metadata)
68
+ rendered = _apply_variables(body, replacements)
69
+ rendered = rewrite_paths(rendered)
70
+ if not rendered.endswith("\n"):
71
+ rendered += "\n"
72
+ return metadata, rendered, raw_frontmatter
73
+
74
+
75
+ def _resolve_variables(
76
+ variables: VariablesResolver | None, metadata: Dict[str, Any]
77
+ ) -> Mapping[str, str]:
78
+ if variables is None:
79
+ return {}
80
+ if callable(variables):
81
+ resolved = variables(metadata) or {}
82
+ else:
83
+ resolved = variables
84
+ return resolved
85
+
86
+
87
+ def _apply_variables(content: str, variables: Mapping[str, str]) -> str:
88
+ rendered = content
89
+ for placeholder, value in variables.items():
90
+ rendered = rendered.replace(placeholder, value)
91
+ return rendered
92
+
93
+
94
+ __all__ = [
95
+ "DEFAULT_PATH_PATTERNS",
96
+ "parse_frontmatter",
97
+ "render_template",
98
+ "rewrite_paths",
99
+ ]