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.
- spec_kitty_cli-0.12.1.dist-info/METADATA +1767 -0
- spec_kitty_cli-0.12.1.dist-info/RECORD +242 -0
- spec_kitty_cli-0.12.1.dist-info/WHEEL +4 -0
- spec_kitty_cli-0.12.1.dist-info/entry_points.txt +2 -0
- spec_kitty_cli-0.12.1.dist-info/licenses/LICENSE +21 -0
- specify_cli/__init__.py +171 -0
- specify_cli/acceptance.py +627 -0
- specify_cli/agent_utils/README.md +157 -0
- specify_cli/agent_utils/__init__.py +9 -0
- specify_cli/agent_utils/status.py +356 -0
- specify_cli/cli/__init__.py +6 -0
- specify_cli/cli/commands/__init__.py +46 -0
- specify_cli/cli/commands/accept.py +189 -0
- specify_cli/cli/commands/agent/__init__.py +22 -0
- specify_cli/cli/commands/agent/config.py +382 -0
- specify_cli/cli/commands/agent/context.py +191 -0
- specify_cli/cli/commands/agent/feature.py +1057 -0
- specify_cli/cli/commands/agent/release.py +11 -0
- specify_cli/cli/commands/agent/tasks.py +1253 -0
- specify_cli/cli/commands/agent/workflow.py +801 -0
- specify_cli/cli/commands/context.py +246 -0
- specify_cli/cli/commands/dashboard.py +85 -0
- specify_cli/cli/commands/implement.py +973 -0
- specify_cli/cli/commands/init.py +827 -0
- specify_cli/cli/commands/init_help.py +62 -0
- specify_cli/cli/commands/merge.py +755 -0
- specify_cli/cli/commands/mission.py +240 -0
- specify_cli/cli/commands/ops.py +265 -0
- specify_cli/cli/commands/orchestrate.py +640 -0
- specify_cli/cli/commands/repair.py +175 -0
- specify_cli/cli/commands/research.py +165 -0
- specify_cli/cli/commands/sync.py +364 -0
- specify_cli/cli/commands/upgrade.py +249 -0
- specify_cli/cli/commands/validate_encoding.py +186 -0
- specify_cli/cli/commands/validate_tasks.py +186 -0
- specify_cli/cli/commands/verify.py +310 -0
- specify_cli/cli/helpers.py +123 -0
- specify_cli/cli/step_tracker.py +91 -0
- specify_cli/cli/ui.py +192 -0
- specify_cli/core/__init__.py +53 -0
- specify_cli/core/agent_context.py +311 -0
- specify_cli/core/config.py +96 -0
- specify_cli/core/context_validation.py +362 -0
- specify_cli/core/dependency_graph.py +351 -0
- specify_cli/core/git_ops.py +129 -0
- specify_cli/core/multi_parent_merge.py +323 -0
- specify_cli/core/paths.py +260 -0
- specify_cli/core/project_resolver.py +110 -0
- specify_cli/core/stale_detection.py +263 -0
- specify_cli/core/tool_checker.py +79 -0
- specify_cli/core/utils.py +43 -0
- specify_cli/core/vcs/__init__.py +114 -0
- specify_cli/core/vcs/detection.py +341 -0
- specify_cli/core/vcs/exceptions.py +85 -0
- specify_cli/core/vcs/git.py +1304 -0
- specify_cli/core/vcs/jujutsu.py +1208 -0
- specify_cli/core/vcs/protocol.py +285 -0
- specify_cli/core/vcs/types.py +249 -0
- specify_cli/core/version_checker.py +261 -0
- specify_cli/core/worktree.py +506 -0
- specify_cli/dashboard/__init__.py +28 -0
- specify_cli/dashboard/diagnostics.py +204 -0
- specify_cli/dashboard/handlers/__init__.py +17 -0
- specify_cli/dashboard/handlers/api.py +143 -0
- specify_cli/dashboard/handlers/base.py +65 -0
- specify_cli/dashboard/handlers/features.py +390 -0
- specify_cli/dashboard/handlers/router.py +81 -0
- specify_cli/dashboard/handlers/static.py +50 -0
- specify_cli/dashboard/lifecycle.py +541 -0
- specify_cli/dashboard/scanner.py +437 -0
- specify_cli/dashboard/server.py +123 -0
- specify_cli/dashboard/static/dashboard/dashboard.css +722 -0
- specify_cli/dashboard/static/dashboard/dashboard.js +1424 -0
- specify_cli/dashboard/static/spec-kitty.png +0 -0
- specify_cli/dashboard/templates/__init__.py +36 -0
- specify_cli/dashboard/templates/index.html +258 -0
- specify_cli/doc_generators.py +621 -0
- specify_cli/doc_state.py +408 -0
- specify_cli/frontmatter.py +384 -0
- specify_cli/gap_analysis.py +915 -0
- specify_cli/gitignore_manager.py +300 -0
- specify_cli/guards.py +145 -0
- specify_cli/legacy_detector.py +83 -0
- specify_cli/manifest.py +286 -0
- specify_cli/merge/__init__.py +63 -0
- specify_cli/merge/executor.py +653 -0
- specify_cli/merge/forecast.py +215 -0
- specify_cli/merge/ordering.py +126 -0
- specify_cli/merge/preflight.py +230 -0
- specify_cli/merge/state.py +185 -0
- specify_cli/merge/status_resolver.py +354 -0
- specify_cli/mission.py +654 -0
- specify_cli/missions/documentation/command-templates/implement.md +309 -0
- specify_cli/missions/documentation/command-templates/plan.md +275 -0
- specify_cli/missions/documentation/command-templates/review.md +344 -0
- specify_cli/missions/documentation/command-templates/specify.md +206 -0
- specify_cli/missions/documentation/command-templates/tasks.md +189 -0
- specify_cli/missions/documentation/mission.yaml +113 -0
- specify_cli/missions/documentation/templates/divio/explanation-template.md +192 -0
- specify_cli/missions/documentation/templates/divio/howto-template.md +168 -0
- specify_cli/missions/documentation/templates/divio/reference-template.md +179 -0
- specify_cli/missions/documentation/templates/divio/tutorial-template.md +146 -0
- specify_cli/missions/documentation/templates/generators/jsdoc.json.template +18 -0
- specify_cli/missions/documentation/templates/generators/sphinx-conf.py.template +36 -0
- specify_cli/missions/documentation/templates/plan-template.md +269 -0
- specify_cli/missions/documentation/templates/release-template.md +222 -0
- specify_cli/missions/documentation/templates/spec-template.md +172 -0
- specify_cli/missions/documentation/templates/task-prompt-template.md +140 -0
- specify_cli/missions/documentation/templates/tasks-template.md +159 -0
- specify_cli/missions/research/command-templates/merge.md +388 -0
- specify_cli/missions/research/command-templates/plan.md +125 -0
- specify_cli/missions/research/command-templates/review.md +144 -0
- specify_cli/missions/research/command-templates/tasks.md +225 -0
- specify_cli/missions/research/mission.yaml +115 -0
- specify_cli/missions/research/templates/data-model-template.md +33 -0
- specify_cli/missions/research/templates/plan-template.md +161 -0
- specify_cli/missions/research/templates/research/evidence-log.csv +18 -0
- specify_cli/missions/research/templates/research/source-register.csv +18 -0
- specify_cli/missions/research/templates/research-template.md +35 -0
- specify_cli/missions/research/templates/spec-template.md +64 -0
- specify_cli/missions/research/templates/task-prompt-template.md +148 -0
- specify_cli/missions/research/templates/tasks-template.md +114 -0
- specify_cli/missions/software-dev/command-templates/accept.md +75 -0
- specify_cli/missions/software-dev/command-templates/analyze.md +183 -0
- specify_cli/missions/software-dev/command-templates/checklist.md +286 -0
- specify_cli/missions/software-dev/command-templates/clarify.md +157 -0
- specify_cli/missions/software-dev/command-templates/constitution.md +432 -0
- specify_cli/missions/software-dev/command-templates/dashboard.md +101 -0
- specify_cli/missions/software-dev/command-templates/implement.md +41 -0
- specify_cli/missions/software-dev/command-templates/merge.md +383 -0
- specify_cli/missions/software-dev/command-templates/plan.md +171 -0
- specify_cli/missions/software-dev/command-templates/review.md +32 -0
- specify_cli/missions/software-dev/command-templates/specify.md +321 -0
- specify_cli/missions/software-dev/command-templates/tasks.md +566 -0
- specify_cli/missions/software-dev/mission.yaml +100 -0
- specify_cli/missions/software-dev/templates/plan-template.md +132 -0
- specify_cli/missions/software-dev/templates/spec-template.md +116 -0
- specify_cli/missions/software-dev/templates/task-prompt-template.md +140 -0
- specify_cli/missions/software-dev/templates/tasks-template.md +159 -0
- specify_cli/orchestrator/__init__.py +75 -0
- specify_cli/orchestrator/agent_config.py +224 -0
- specify_cli/orchestrator/agents/__init__.py +170 -0
- specify_cli/orchestrator/agents/augment.py +112 -0
- specify_cli/orchestrator/agents/base.py +243 -0
- specify_cli/orchestrator/agents/claude.py +112 -0
- specify_cli/orchestrator/agents/codex.py +106 -0
- specify_cli/orchestrator/agents/copilot.py +137 -0
- specify_cli/orchestrator/agents/cursor.py +139 -0
- specify_cli/orchestrator/agents/gemini.py +115 -0
- specify_cli/orchestrator/agents/kilocode.py +94 -0
- specify_cli/orchestrator/agents/opencode.py +132 -0
- specify_cli/orchestrator/agents/qwen.py +96 -0
- specify_cli/orchestrator/config.py +455 -0
- specify_cli/orchestrator/executor.py +642 -0
- specify_cli/orchestrator/integration.py +1230 -0
- specify_cli/orchestrator/monitor.py +898 -0
- specify_cli/orchestrator/scheduler.py +832 -0
- specify_cli/orchestrator/state.py +508 -0
- specify_cli/orchestrator/testing/__init__.py +122 -0
- specify_cli/orchestrator/testing/availability.py +346 -0
- specify_cli/orchestrator/testing/fixtures.py +684 -0
- specify_cli/orchestrator/testing/paths.py +218 -0
- specify_cli/plan_validation.py +107 -0
- specify_cli/scripts/debug-dashboard-scan.py +61 -0
- specify_cli/scripts/tasks/acceptance_support.py +695 -0
- specify_cli/scripts/tasks/task_helpers.py +506 -0
- specify_cli/scripts/tasks/tasks_cli.py +848 -0
- specify_cli/scripts/validate_encoding.py +180 -0
- specify_cli/task_metadata_validation.py +274 -0
- specify_cli/tasks_support.py +447 -0
- specify_cli/template/__init__.py +47 -0
- specify_cli/template/asset_generator.py +206 -0
- specify_cli/template/github_client.py +334 -0
- specify_cli/template/manager.py +193 -0
- specify_cli/template/renderer.py +99 -0
- specify_cli/templates/AGENTS.md +190 -0
- specify_cli/templates/POWERSHELL_SYNTAX.md +229 -0
- specify_cli/templates/agent-file-template.md +35 -0
- specify_cli/templates/checklist-template.md +42 -0
- specify_cli/templates/claudeignore-template +58 -0
- specify_cli/templates/command-templates/accept.md +141 -0
- specify_cli/templates/command-templates/analyze.md +253 -0
- specify_cli/templates/command-templates/checklist.md +352 -0
- specify_cli/templates/command-templates/clarify.md +224 -0
- specify_cli/templates/command-templates/constitution.md +432 -0
- specify_cli/templates/command-templates/dashboard.md +175 -0
- specify_cli/templates/command-templates/implement.md +190 -0
- specify_cli/templates/command-templates/merge.md +374 -0
- specify_cli/templates/command-templates/plan.md +171 -0
- specify_cli/templates/command-templates/research.md +88 -0
- specify_cli/templates/command-templates/review.md +510 -0
- specify_cli/templates/command-templates/specify.md +321 -0
- specify_cli/templates/command-templates/status.md +92 -0
- specify_cli/templates/command-templates/tasks.md +199 -0
- specify_cli/templates/git-hooks/pre-commit +22 -0
- specify_cli/templates/git-hooks/pre-commit-agent-check +37 -0
- specify_cli/templates/git-hooks/pre-commit-encoding-check +142 -0
- specify_cli/templates/plan-template.md +108 -0
- specify_cli/templates/spec-template.md +118 -0
- specify_cli/templates/task-prompt-template.md +165 -0
- specify_cli/templates/tasks-template.md +161 -0
- specify_cli/templates/vscode-settings.json +13 -0
- specify_cli/text_sanitization.py +225 -0
- specify_cli/upgrade/__init__.py +18 -0
- specify_cli/upgrade/detector.py +239 -0
- specify_cli/upgrade/metadata.py +182 -0
- specify_cli/upgrade/migrations/__init__.py +65 -0
- specify_cli/upgrade/migrations/base.py +80 -0
- specify_cli/upgrade/migrations/m_0_10_0_python_only.py +359 -0
- specify_cli/upgrade/migrations/m_0_10_12_constitution_cleanup.py +99 -0
- specify_cli/upgrade/migrations/m_0_10_14_update_implement_slash_command.py +176 -0
- specify_cli/upgrade/migrations/m_0_10_1_populate_slash_commands.py +174 -0
- specify_cli/upgrade/migrations/m_0_10_2_update_slash_commands.py +172 -0
- specify_cli/upgrade/migrations/m_0_10_6_workflow_simplification.py +174 -0
- specify_cli/upgrade/migrations/m_0_10_8_fix_memory_structure.py +252 -0
- specify_cli/upgrade/migrations/m_0_10_9_repair_templates.py +168 -0
- specify_cli/upgrade/migrations/m_0_11_0_workspace_per_wp.py +182 -0
- specify_cli/upgrade/migrations/m_0_11_1_improved_workflow_templates.py +173 -0
- specify_cli/upgrade/migrations/m_0_11_1_update_implement_slash_command.py +160 -0
- specify_cli/upgrade/migrations/m_0_11_2_improved_workflow_templates.py +173 -0
- specify_cli/upgrade/migrations/m_0_11_3_workflow_agent_flag.py +114 -0
- specify_cli/upgrade/migrations/m_0_12_0_documentation_mission.py +155 -0
- specify_cli/upgrade/migrations/m_0_12_1_remove_kitty_specs_from_gitignore.py +183 -0
- specify_cli/upgrade/migrations/m_0_2_0_specify_to_kittify.py +80 -0
- specify_cli/upgrade/migrations/m_0_4_8_gitignore_agents.py +118 -0
- specify_cli/upgrade/migrations/m_0_5_0_encoding_hooks.py +141 -0
- specify_cli/upgrade/migrations/m_0_6_5_commands_rename.py +169 -0
- specify_cli/upgrade/migrations/m_0_6_7_ensure_missions.py +228 -0
- specify_cli/upgrade/migrations/m_0_7_2_worktree_commands_dedup.py +89 -0
- specify_cli/upgrade/migrations/m_0_7_3_update_scripts.py +114 -0
- specify_cli/upgrade/migrations/m_0_8_0_remove_active_mission.py +82 -0
- specify_cli/upgrade/migrations/m_0_8_0_worktree_agents_symlink.py +148 -0
- specify_cli/upgrade/migrations/m_0_9_0_frontmatter_only_lanes.py +346 -0
- specify_cli/upgrade/migrations/m_0_9_1_complete_lane_migration.py +656 -0
- specify_cli/upgrade/migrations/m_0_9_2_research_mission_templates.py +221 -0
- specify_cli/upgrade/registry.py +121 -0
- specify_cli/upgrade/runner.py +284 -0
- specify_cli/validators/__init__.py +14 -0
- specify_cli/validators/paths.py +154 -0
- specify_cli/validators/research.py +428 -0
- specify_cli/verify_enhanced.py +270 -0
- 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
|
+
]
|