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,447 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared utilities for manipulating Spec Kitty task prompts.
|
|
3
|
+
|
|
4
|
+
DEPRECATED: This module is deprecated as of v0.10.0.
|
|
5
|
+
Use `spec-kitty agent tasks` commands instead.
|
|
6
|
+
|
|
7
|
+
This file will be removed in the next release.
|
|
8
|
+
See: src/specify_cli/cli/commands/agent/tasks.py
|
|
9
|
+
|
|
10
|
+
Migration Guide:
|
|
11
|
+
- tasks_cli.py update → spec-kitty agent tasks move-task
|
|
12
|
+
- For all other operations, use the new agent tasks commands
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
import subprocess
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Dict, List, Optional, Tuple
|
|
24
|
+
|
|
25
|
+
from specify_cli.legacy_detector import is_legacy_format
|
|
26
|
+
|
|
27
|
+
# IMPORTANT: Keep in sync with scripts/tasks/task_helpers.py
|
|
28
|
+
LANES: Tuple[str, ...] = ("planned", "doing", "for_review", "done")
|
|
29
|
+
TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TaskCliError(RuntimeError):
|
|
33
|
+
"""Raised when task operations cannot be completed safely."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def find_repo_root(start: Optional[Path] = None) -> Path:
|
|
37
|
+
"""Find the MAIN repository root, even when inside a worktree.
|
|
38
|
+
|
|
39
|
+
This function correctly handles git worktrees by detecting when .git is a
|
|
40
|
+
file (worktree pointer) vs a directory (main repo), and following the
|
|
41
|
+
pointer back to the main repository.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
start: Starting directory for search (defaults to cwd)
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Path to the main repository root
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
TaskCliError: If repository root cannot be found
|
|
51
|
+
"""
|
|
52
|
+
current = (start or Path.cwd()).resolve()
|
|
53
|
+
|
|
54
|
+
for candidate in [current, *current.parents]:
|
|
55
|
+
git_path = candidate / ".git"
|
|
56
|
+
|
|
57
|
+
if git_path.is_file():
|
|
58
|
+
# This is a worktree! The .git file contains a pointer to the main repo.
|
|
59
|
+
# Format: "gitdir: /path/to/main/.git/worktrees/worktree-name"
|
|
60
|
+
try:
|
|
61
|
+
content = git_path.read_text().strip()
|
|
62
|
+
if content.startswith("gitdir:"):
|
|
63
|
+
gitdir = Path(content.split(":", 1)[1].strip())
|
|
64
|
+
# Navigate: .git/worktrees/name -> .git -> main repo root
|
|
65
|
+
# gitdir points to .git/worktrees/xxx, so .parent.parent is .git
|
|
66
|
+
main_git_dir = gitdir.parent.parent
|
|
67
|
+
main_repo = main_git_dir.parent
|
|
68
|
+
if main_repo.exists():
|
|
69
|
+
return main_repo
|
|
70
|
+
except (OSError, ValueError):
|
|
71
|
+
# If we can't read or parse the .git file, continue searching
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
elif git_path.is_dir():
|
|
75
|
+
# This is the main repo (or a regular git repo)
|
|
76
|
+
return candidate
|
|
77
|
+
|
|
78
|
+
# Also check for .kittify marker (fallback for non-git scenarios)
|
|
79
|
+
if (candidate / ".kittify").exists():
|
|
80
|
+
return candidate
|
|
81
|
+
|
|
82
|
+
raise TaskCliError("Unable to locate repository root (missing .git or .kittify).")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def run_git(args: List[str], cwd: Path, check: bool = True) -> subprocess.CompletedProcess:
|
|
86
|
+
"""Run a git command inside the repository."""
|
|
87
|
+
try:
|
|
88
|
+
return subprocess.run(
|
|
89
|
+
["git", *args],
|
|
90
|
+
cwd=str(cwd),
|
|
91
|
+
check=check,
|
|
92
|
+
text=True,
|
|
93
|
+
capture_output=True,
|
|
94
|
+
)
|
|
95
|
+
except FileNotFoundError as exc:
|
|
96
|
+
raise TaskCliError("git is not available on PATH.") from exc
|
|
97
|
+
except subprocess.CalledProcessError as exc:
|
|
98
|
+
if check:
|
|
99
|
+
message = exc.stderr.strip() or exc.stdout.strip() or "Unknown git error"
|
|
100
|
+
raise TaskCliError(message)
|
|
101
|
+
return exc
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def ensure_lane(value: str) -> str:
|
|
105
|
+
lane = value.strip().lower()
|
|
106
|
+
if lane not in LANES:
|
|
107
|
+
raise TaskCliError(f"Invalid lane '{value}'. Expected one of {', '.join(LANES)}.")
|
|
108
|
+
return lane
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def now_utc() -> str:
|
|
112
|
+
return datetime.now(timezone.utc).strftime(TIMESTAMP_FORMAT)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def git_status_lines(repo_root: Path) -> List[str]:
|
|
116
|
+
result = run_git(["status", "--porcelain"], cwd=repo_root, check=True)
|
|
117
|
+
return [line for line in result.stdout.splitlines() if line.strip()]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def normalize_note(note: Optional[str], target_lane: str) -> str:
|
|
121
|
+
default = f"Moved to {target_lane}"
|
|
122
|
+
cleaned = (note or default).strip()
|
|
123
|
+
return cleaned or default
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def detect_conflicting_wp_status(
|
|
127
|
+
status_lines: List[str], feature: str, old_path: Path, new_path: Path
|
|
128
|
+
) -> List[str]:
|
|
129
|
+
"""Return staged work-package entries unrelated to the requested move."""
|
|
130
|
+
prefix = f"kitty-specs/{feature}/tasks/"
|
|
131
|
+
allowed = {
|
|
132
|
+
str(old_path).lstrip("./"),
|
|
133
|
+
str(new_path).lstrip("./"),
|
|
134
|
+
}
|
|
135
|
+
conflicts = []
|
|
136
|
+
for line in status_lines:
|
|
137
|
+
path = line[3:] if len(line) > 3 else ""
|
|
138
|
+
if not path.startswith(prefix):
|
|
139
|
+
continue
|
|
140
|
+
clean = path.strip()
|
|
141
|
+
if clean not in allowed:
|
|
142
|
+
conflicts.append(line)
|
|
143
|
+
return conflicts
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def match_frontmatter_line(frontmatter: str, key: str) -> Optional[re.Match]:
|
|
147
|
+
pattern = re.compile(
|
|
148
|
+
rf"^({re.escape(key)}:\s*)(\".*?\"|'.*?'|[^#\n]*)(.*)$",
|
|
149
|
+
flags=re.MULTILINE,
|
|
150
|
+
)
|
|
151
|
+
return pattern.search(frontmatter)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def extract_scalar(frontmatter: str, key: str) -> Optional[str]:
|
|
155
|
+
match = match_frontmatter_line(frontmatter, key)
|
|
156
|
+
if not match:
|
|
157
|
+
return None
|
|
158
|
+
raw_value = match.group(2).strip()
|
|
159
|
+
if raw_value.startswith('"') and raw_value.endswith('"'):
|
|
160
|
+
return raw_value[1:-1]
|
|
161
|
+
if raw_value.startswith("'") and raw_value.endswith("'"):
|
|
162
|
+
return raw_value[1:-1]
|
|
163
|
+
return raw_value.strip() or None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def set_scalar(frontmatter: str, key: str, value: str) -> str:
|
|
167
|
+
"""Replace or insert a scalar value while preserving trailing comments."""
|
|
168
|
+
match = match_frontmatter_line(frontmatter, key)
|
|
169
|
+
replacement_line = f'{key}: "{value}"'
|
|
170
|
+
if match:
|
|
171
|
+
prefix = match.group(1)
|
|
172
|
+
comment = match.group(3)
|
|
173
|
+
comment_suffix = f"{comment}" if comment else ""
|
|
174
|
+
return (
|
|
175
|
+
frontmatter[: match.start()]
|
|
176
|
+
+ f'{prefix}"{value}"{comment_suffix}'
|
|
177
|
+
+ frontmatter[match.end() :]
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
insertion = f"{replacement_line}\n"
|
|
181
|
+
history_match = re.search(r"^\s*history:\s*$", frontmatter, flags=re.MULTILINE)
|
|
182
|
+
if history_match:
|
|
183
|
+
idx = history_match.start()
|
|
184
|
+
return frontmatter[:idx] + insertion + frontmatter[idx:]
|
|
185
|
+
|
|
186
|
+
if frontmatter and not frontmatter.endswith("\n"):
|
|
187
|
+
frontmatter += "\n"
|
|
188
|
+
return frontmatter + insertion
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def split_frontmatter(text: str) -> Tuple[str, str, str]:
|
|
192
|
+
"""Return (frontmatter, body, padding) while preserving spacing after frontmatter."""
|
|
193
|
+
normalized = text.replace("\r\n", "\n")
|
|
194
|
+
if not normalized.startswith("---\n"):
|
|
195
|
+
return "", normalized, ""
|
|
196
|
+
|
|
197
|
+
closing_idx = normalized.find("\n---", 4)
|
|
198
|
+
if closing_idx == -1:
|
|
199
|
+
return "", normalized, ""
|
|
200
|
+
|
|
201
|
+
front = normalized[4:closing_idx]
|
|
202
|
+
tail = normalized[closing_idx + 4 :]
|
|
203
|
+
padding = ""
|
|
204
|
+
while tail.startswith("\n"):
|
|
205
|
+
padding += "\n"
|
|
206
|
+
tail = tail[1:]
|
|
207
|
+
return front, tail, padding
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def build_document(frontmatter: str, body: str, padding: str) -> str:
|
|
211
|
+
frontmatter = frontmatter.rstrip("\n")
|
|
212
|
+
doc = f"---\n{frontmatter}\n---"
|
|
213
|
+
if padding or body:
|
|
214
|
+
doc += padding or "\n"
|
|
215
|
+
doc += body
|
|
216
|
+
if not doc.endswith("\n"):
|
|
217
|
+
doc += "\n"
|
|
218
|
+
return doc
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def append_activity_log(body: str, entry: str) -> str:
|
|
222
|
+
header = "## Activity Log"
|
|
223
|
+
if header not in body:
|
|
224
|
+
block = f"{header}\n\n{entry}\n"
|
|
225
|
+
if body and not body.endswith("\n\n"):
|
|
226
|
+
return body.rstrip() + "\n\n" + block
|
|
227
|
+
return body + "\n" + block if body else block
|
|
228
|
+
|
|
229
|
+
pattern = re.compile(r"(## Activity Log.*?)(?=\n## |\Z)", flags=re.DOTALL)
|
|
230
|
+
match = pattern.search(body)
|
|
231
|
+
if not match:
|
|
232
|
+
return body + ("\n" if not body.endswith("\n") else "") + entry + "\n"
|
|
233
|
+
|
|
234
|
+
section = match.group(1).rstrip()
|
|
235
|
+
if not section.endswith("\n"):
|
|
236
|
+
section += "\n"
|
|
237
|
+
section += f"{entry}\n"
|
|
238
|
+
return body[: match.start(1)] + section + body[match.end(1) :]
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def activity_entries(body: str) -> List[Dict[str, str]]:
|
|
242
|
+
pattern = re.compile(
|
|
243
|
+
r"^\s*-\s*"
|
|
244
|
+
r"(?P<timestamp>[0-9T:-]+Z)\s*[–-]\s*"
|
|
245
|
+
r"(?P<agent>[^–-]+?)\s*[–-]\s*"
|
|
246
|
+
r"(?:shell_pid=(?P<shell>[^–-]*?)\s*[–-]\s*)?"
|
|
247
|
+
r"lane=(?P<lane>[a-z_]+)\s*[–-]\s*"
|
|
248
|
+
r"(?P<note>.*)$",
|
|
249
|
+
flags=re.MULTILINE,
|
|
250
|
+
)
|
|
251
|
+
entries: List[Dict[str, str]] = []
|
|
252
|
+
for match in pattern.finditer(body):
|
|
253
|
+
entries.append(
|
|
254
|
+
{
|
|
255
|
+
"timestamp": match.group("timestamp").strip(),
|
|
256
|
+
"agent": match.group("agent").strip(),
|
|
257
|
+
"lane": match.group("lane").strip(),
|
|
258
|
+
"note": match.group("note").strip(),
|
|
259
|
+
"shell_pid": (match.group("shell") or "").strip(),
|
|
260
|
+
}
|
|
261
|
+
)
|
|
262
|
+
return entries
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@dataclass
|
|
266
|
+
class WorkPackage:
|
|
267
|
+
feature: str
|
|
268
|
+
path: Path
|
|
269
|
+
current_lane: str
|
|
270
|
+
relative_subpath: Path
|
|
271
|
+
frontmatter: str
|
|
272
|
+
body: str
|
|
273
|
+
padding: str
|
|
274
|
+
|
|
275
|
+
@property
|
|
276
|
+
def work_package_id(self) -> Optional[str]:
|
|
277
|
+
return extract_scalar(self.frontmatter, "work_package_id")
|
|
278
|
+
|
|
279
|
+
@property
|
|
280
|
+
def title(self) -> Optional[str]:
|
|
281
|
+
return extract_scalar(self.frontmatter, "title")
|
|
282
|
+
|
|
283
|
+
@property
|
|
284
|
+
def assignee(self) -> Optional[str]:
|
|
285
|
+
return extract_scalar(self.frontmatter, "assignee")
|
|
286
|
+
|
|
287
|
+
@property
|
|
288
|
+
def agent(self) -> Optional[str]:
|
|
289
|
+
return extract_scalar(self.frontmatter, "agent")
|
|
290
|
+
|
|
291
|
+
@property
|
|
292
|
+
def shell_pid(self) -> Optional[str]:
|
|
293
|
+
return extract_scalar(self.frontmatter, "shell_pid")
|
|
294
|
+
|
|
295
|
+
@property
|
|
296
|
+
def lane(self) -> Optional[str]:
|
|
297
|
+
return extract_scalar(self.frontmatter, "lane")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def locate_work_package(repo_root: Path, feature: str, wp_id: str) -> WorkPackage:
|
|
301
|
+
"""Locate a work package by ID, supporting both legacy and new formats.
|
|
302
|
+
|
|
303
|
+
Always uses main repo's kitty-specs/ regardless of current directory.
|
|
304
|
+
Worktrees should not contain kitty-specs/ (excluded via sparse checkout).
|
|
305
|
+
|
|
306
|
+
Legacy format: WP files in tasks/{lane}/ subdirectories
|
|
307
|
+
New format: WP files in flat tasks/ directory with lane in frontmatter
|
|
308
|
+
"""
|
|
309
|
+
from specify_cli.core.paths import get_main_repo_root
|
|
310
|
+
|
|
311
|
+
# Always use main repo's kitty-specs - it's the source of truth
|
|
312
|
+
# This fixes the bug where worktree's stale kitty-specs/ would be used
|
|
313
|
+
main_root = get_main_repo_root(repo_root)
|
|
314
|
+
feature_path = main_root / "kitty-specs" / feature
|
|
315
|
+
|
|
316
|
+
tasks_root = feature_path / "tasks"
|
|
317
|
+
if not tasks_root.exists():
|
|
318
|
+
raise TaskCliError(f"Feature '{feature}' has no tasks directory at {tasks_root}.")
|
|
319
|
+
|
|
320
|
+
# Use exact WP ID matching with word boundary to avoid WP04 matching WP04b
|
|
321
|
+
# Matches: WP04.md, WP04-something.md, WP04_something.md
|
|
322
|
+
# Does NOT match: WP04b.md, WP04b-something.md
|
|
323
|
+
wp_pattern = re.compile(rf"^{re.escape(wp_id)}(?:[-_.]|\.md$)")
|
|
324
|
+
|
|
325
|
+
use_legacy = is_legacy_format(feature_path)
|
|
326
|
+
candidates = []
|
|
327
|
+
|
|
328
|
+
if use_legacy:
|
|
329
|
+
# Legacy format: search lane subdirectories
|
|
330
|
+
for lane_dir in tasks_root.iterdir():
|
|
331
|
+
if not lane_dir.is_dir():
|
|
332
|
+
continue
|
|
333
|
+
lane = lane_dir.name
|
|
334
|
+
for path in lane_dir.rglob("*.md"):
|
|
335
|
+
if wp_pattern.match(path.name):
|
|
336
|
+
candidates.append((lane, path, lane_dir))
|
|
337
|
+
else:
|
|
338
|
+
# New format: search flat tasks/ directory
|
|
339
|
+
for path in tasks_root.glob("*.md"):
|
|
340
|
+
if path.name.lower() == "readme.md":
|
|
341
|
+
continue
|
|
342
|
+
if wp_pattern.match(path.name):
|
|
343
|
+
# Get lane from frontmatter
|
|
344
|
+
lane = get_lane_from_frontmatter(path, warn_on_missing=False)
|
|
345
|
+
candidates.append((lane, path, tasks_root))
|
|
346
|
+
|
|
347
|
+
if not candidates:
|
|
348
|
+
raise TaskCliError(f"Work package '{wp_id}' not found under kitty-specs/{feature}/tasks.")
|
|
349
|
+
if len(candidates) > 1:
|
|
350
|
+
joined = "\n".join(str(item[1].relative_to(repo_root)) for item in candidates)
|
|
351
|
+
raise TaskCliError(
|
|
352
|
+
f"Multiple files matched '{wp_id}'. Refine the ID or clean duplicates:\n{joined}"
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
lane, path, base_dir = candidates[0]
|
|
356
|
+
text = path.read_text(encoding="utf-8-sig")
|
|
357
|
+
front, body, padding = split_frontmatter(text)
|
|
358
|
+
relative = path.relative_to(base_dir)
|
|
359
|
+
return WorkPackage(
|
|
360
|
+
feature=feature,
|
|
361
|
+
path=path,
|
|
362
|
+
current_lane=lane,
|
|
363
|
+
relative_subpath=relative,
|
|
364
|
+
frontmatter=front,
|
|
365
|
+
body=body,
|
|
366
|
+
padding=padding,
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def load_meta(meta_path: Path) -> Dict:
|
|
371
|
+
if not meta_path.exists():
|
|
372
|
+
raise TaskCliError(f"Meta file not found at {meta_path}")
|
|
373
|
+
return json.loads(meta_path.read_text(encoding="utf-8-sig"))
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def get_lane_from_frontmatter(wp_path: Path, warn_on_missing: bool = True) -> str:
|
|
377
|
+
"""Extract lane from WP file frontmatter.
|
|
378
|
+
|
|
379
|
+
This is the authoritative way to determine a work package's lane
|
|
380
|
+
in the frontmatter-only lane system.
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
wp_path: Path to the work package markdown file
|
|
384
|
+
warn_on_missing: If True, print warning when lane field is missing
|
|
385
|
+
|
|
386
|
+
Returns:
|
|
387
|
+
Lane value (planned, doing, for_review, done)
|
|
388
|
+
|
|
389
|
+
Raises:
|
|
390
|
+
ValueError: If lane value is not in LANES
|
|
391
|
+
"""
|
|
392
|
+
content = wp_path.read_text(encoding="utf-8-sig")
|
|
393
|
+
frontmatter, _, _ = split_frontmatter(content)
|
|
394
|
+
|
|
395
|
+
lane = extract_scalar(frontmatter, "lane")
|
|
396
|
+
|
|
397
|
+
if lane is None:
|
|
398
|
+
if warn_on_missing:
|
|
399
|
+
# Import here to avoid circular dependency issues
|
|
400
|
+
try:
|
|
401
|
+
from rich.console import Console
|
|
402
|
+
console = Console(stderr=True)
|
|
403
|
+
console.print(
|
|
404
|
+
f"[yellow]Warning: {wp_path.name} missing lane field, "
|
|
405
|
+
f"defaulting to 'planned'[/yellow]"
|
|
406
|
+
)
|
|
407
|
+
except ImportError:
|
|
408
|
+
import sys
|
|
409
|
+
print(
|
|
410
|
+
f"Warning: {wp_path.name} missing lane field, defaulting to 'planned'",
|
|
411
|
+
file=sys.stderr
|
|
412
|
+
)
|
|
413
|
+
return "planned"
|
|
414
|
+
|
|
415
|
+
if lane not in LANES:
|
|
416
|
+
raise ValueError(
|
|
417
|
+
f"Invalid lane '{lane}' in {wp_path.name}. "
|
|
418
|
+
f"Valid lanes: {', '.join(LANES)}"
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
return lane
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
__all__ = [
|
|
425
|
+
"LANES",
|
|
426
|
+
"TIMESTAMP_FORMAT",
|
|
427
|
+
"TaskCliError",
|
|
428
|
+
"WorkPackage",
|
|
429
|
+
"append_activity_log",
|
|
430
|
+
"activity_entries",
|
|
431
|
+
"build_document",
|
|
432
|
+
"detect_conflicting_wp_status",
|
|
433
|
+
"ensure_lane",
|
|
434
|
+
"extract_scalar",
|
|
435
|
+
"find_repo_root",
|
|
436
|
+
"get_lane_from_frontmatter",
|
|
437
|
+
"git_status_lines",
|
|
438
|
+
"is_legacy_format",
|
|
439
|
+
"load_meta",
|
|
440
|
+
"locate_work_package",
|
|
441
|
+
"match_frontmatter_line",
|
|
442
|
+
"normalize_note",
|
|
443
|
+
"now_utc",
|
|
444
|
+
"run_git",
|
|
445
|
+
"set_scalar",
|
|
446
|
+
"split_frontmatter",
|
|
447
|
+
]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Template management for spec-kitty."""
|
|
2
|
+
|
|
3
|
+
from .manager import (
|
|
4
|
+
copy_package_tree,
|
|
5
|
+
copy_specify_base_from_local,
|
|
6
|
+
copy_specify_base_from_package,
|
|
7
|
+
get_local_repo_root,
|
|
8
|
+
)
|
|
9
|
+
from .renderer import (
|
|
10
|
+
DEFAULT_PATH_PATTERNS,
|
|
11
|
+
parse_frontmatter,
|
|
12
|
+
render_template,
|
|
13
|
+
rewrite_paths,
|
|
14
|
+
)
|
|
15
|
+
from .asset_generator import (
|
|
16
|
+
generate_agent_assets,
|
|
17
|
+
prepare_command_templates,
|
|
18
|
+
render_command_template,
|
|
19
|
+
)
|
|
20
|
+
from .github_client import (
|
|
21
|
+
GitHubClientError,
|
|
22
|
+
SSL_CONTEXT,
|
|
23
|
+
build_http_client,
|
|
24
|
+
download_and_extract_template,
|
|
25
|
+
download_template_from_github,
|
|
26
|
+
parse_repo_slug,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"GitHubClientError",
|
|
31
|
+
"SSL_CONTEXT",
|
|
32
|
+
"build_http_client",
|
|
33
|
+
"copy_package_tree",
|
|
34
|
+
"copy_specify_base_from_local",
|
|
35
|
+
"copy_specify_base_from_package",
|
|
36
|
+
"DEFAULT_PATH_PATTERNS",
|
|
37
|
+
"download_and_extract_template",
|
|
38
|
+
"download_template_from_github",
|
|
39
|
+
"generate_agent_assets",
|
|
40
|
+
"get_local_repo_root",
|
|
41
|
+
"parse_frontmatter",
|
|
42
|
+
"parse_repo_slug",
|
|
43
|
+
"prepare_command_templates",
|
|
44
|
+
"render_command_template",
|
|
45
|
+
"render_template",
|
|
46
|
+
"rewrite_paths",
|
|
47
|
+
]
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Agent-specific asset rendering helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Dict, Mapping
|
|
9
|
+
|
|
10
|
+
from specify_cli.core.config import AGENT_COMMAND_CONFIG
|
|
11
|
+
from specify_cli.template.renderer import render_template, rewrite_paths
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def prepare_command_templates(
|
|
15
|
+
base_templates_dir: Path,
|
|
16
|
+
mission_templates_dir: Path | None,
|
|
17
|
+
) -> Path:
|
|
18
|
+
"""Prepare command templates with mission overrides applied.
|
|
19
|
+
|
|
20
|
+
Returns a directory containing base templates, with any mission templates
|
|
21
|
+
overlaid to enhance/override the central command set.
|
|
22
|
+
"""
|
|
23
|
+
if not mission_templates_dir or not mission_templates_dir.exists():
|
|
24
|
+
return base_templates_dir
|
|
25
|
+
|
|
26
|
+
merged_dir = base_templates_dir.parent / f".merged-{mission_templates_dir.parent.name}"
|
|
27
|
+
if merged_dir.exists():
|
|
28
|
+
shutil.rmtree(merged_dir)
|
|
29
|
+
|
|
30
|
+
shutil.copytree(base_templates_dir, merged_dir)
|
|
31
|
+
for template_path in mission_templates_dir.glob("*.md"):
|
|
32
|
+
shutil.copy2(template_path, merged_dir / template_path.name)
|
|
33
|
+
|
|
34
|
+
return merged_dir
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def generate_agent_assets(command_templates_dir: Path, project_path: Path, agent_key: str, script_type: str) -> None:
|
|
38
|
+
"""Render every command template for the selected agent."""
|
|
39
|
+
config = AGENT_COMMAND_CONFIG[agent_key]
|
|
40
|
+
output_dir = project_path / config["dir"]
|
|
41
|
+
if output_dir.exists():
|
|
42
|
+
shutil.rmtree(output_dir)
|
|
43
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
|
|
45
|
+
if not command_templates_dir.exists():
|
|
46
|
+
_raise_template_discovery_error(command_templates_dir)
|
|
47
|
+
|
|
48
|
+
for template_path in sorted(command_templates_dir.glob("*.md")):
|
|
49
|
+
rendered = render_command_template(
|
|
50
|
+
template_path,
|
|
51
|
+
script_type,
|
|
52
|
+
agent_key,
|
|
53
|
+
config["arg_format"],
|
|
54
|
+
config["ext"],
|
|
55
|
+
)
|
|
56
|
+
ext = config["ext"]
|
|
57
|
+
stem = template_path.stem
|
|
58
|
+
if agent_key == "codex":
|
|
59
|
+
stem = stem.replace("-", "_")
|
|
60
|
+
filename = f"spec-kitty.{stem}.{ext}" if ext else f"spec-kitty.{stem}"
|
|
61
|
+
(output_dir / filename).write_text(rendered, encoding="utf-8")
|
|
62
|
+
|
|
63
|
+
if agent_key == "copilot":
|
|
64
|
+
vscode_settings = command_templates_dir.parent / "vscode-settings.json"
|
|
65
|
+
if vscode_settings.exists():
|
|
66
|
+
vscode_dest = project_path / ".vscode"
|
|
67
|
+
vscode_dest.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
shutil.copy2(vscode_settings, vscode_dest / "settings.json")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def render_command_template(
|
|
72
|
+
template_path: Path,
|
|
73
|
+
script_type: str,
|
|
74
|
+
agent_key: str,
|
|
75
|
+
arg_format: str,
|
|
76
|
+
extension: str,
|
|
77
|
+
) -> str:
|
|
78
|
+
"""Render a single command template for an agent."""
|
|
79
|
+
|
|
80
|
+
def build_variables(metadata: Dict[str, object]) -> Mapping[str, str]:
|
|
81
|
+
scripts = metadata.get("scripts") or {}
|
|
82
|
+
agent_scripts = metadata.get("agent_scripts") or {}
|
|
83
|
+
if not isinstance(scripts, dict):
|
|
84
|
+
scripts = {}
|
|
85
|
+
if not isinstance(agent_scripts, dict):
|
|
86
|
+
agent_scripts = {}
|
|
87
|
+
script_command = scripts.get(
|
|
88
|
+
script_type, f"(Missing script command for {script_type})"
|
|
89
|
+
)
|
|
90
|
+
agent_script_command = agent_scripts.get(script_type)
|
|
91
|
+
return {
|
|
92
|
+
"{SCRIPT}": script_command,
|
|
93
|
+
"{AGENT_SCRIPT}": agent_script_command or "",
|
|
94
|
+
"{ARGS}": arg_format,
|
|
95
|
+
"__AGENT__": agent_key,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
metadata, rendered_body, raw_frontmatter = render_template(
|
|
99
|
+
template_path, variables=build_variables
|
|
100
|
+
)
|
|
101
|
+
description = str(metadata.get("description", "")).strip()
|
|
102
|
+
|
|
103
|
+
frontmatter_clean = _filter_frontmatter(raw_frontmatter)
|
|
104
|
+
if frontmatter_clean:
|
|
105
|
+
frontmatter_clean = rewrite_paths(frontmatter_clean)
|
|
106
|
+
|
|
107
|
+
if extension == "toml":
|
|
108
|
+
# Convert Markdown variable syntax to TOML/Gemini variable syntax
|
|
109
|
+
# Gemini CLI uses {{args}} instead of $ARGUMENTS
|
|
110
|
+
# See: https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/custom-commands.md
|
|
111
|
+
rendered_body = _convert_markdown_syntax_to_format(rendered_body, "toml")
|
|
112
|
+
|
|
113
|
+
description_value = description
|
|
114
|
+
if description_value.startswith('"') and description_value.endswith('"'):
|
|
115
|
+
description_value = description_value[1:-1]
|
|
116
|
+
description_value = description_value.replace('"', '\\"')
|
|
117
|
+
body_text = rendered_body
|
|
118
|
+
if not body_text.endswith("\n"):
|
|
119
|
+
body_text += "\n"
|
|
120
|
+
return f'description = "{description_value}"\n\nprompt = """\n{body_text}"""\n'
|
|
121
|
+
|
|
122
|
+
if frontmatter_clean:
|
|
123
|
+
result = f"---\n{frontmatter_clean}\n---\n\n{rendered_body}"
|
|
124
|
+
else:
|
|
125
|
+
result = rendered_body
|
|
126
|
+
return result if result.endswith("\n") else result + "\n"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _convert_markdown_syntax_to_format(content: str, target_format: str) -> str:
|
|
130
|
+
"""Convert Markdown variable syntax to target format syntax.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
content: Rendered template content in Markdown syntax
|
|
134
|
+
target_format: Target format (e.g., "toml" for Gemini)
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Content with variable syntax converted to target format
|
|
138
|
+
|
|
139
|
+
Conversion rules:
|
|
140
|
+
- Markdown (Claude/Codex): $ARGUMENTS, $AGENT_SCRIPT
|
|
141
|
+
- TOML (Gemini): {{args}} (per https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/custom-commands.md)
|
|
142
|
+
"""
|
|
143
|
+
if target_format == "toml":
|
|
144
|
+
# Convert Claude/Codex Markdown variable syntax to Gemini TOML syntax
|
|
145
|
+
# $ARGUMENTS → {{args}}
|
|
146
|
+
content = content.replace("$ARGUMENTS", "{{args}}")
|
|
147
|
+
return content
|
|
148
|
+
|
|
149
|
+
# For other formats, return unchanged
|
|
150
|
+
return content
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _filter_frontmatter(frontmatter_text: str) -> str:
|
|
154
|
+
filtered_lines: list[str] = []
|
|
155
|
+
skipping_block = False
|
|
156
|
+
for line in frontmatter_text.splitlines():
|
|
157
|
+
stripped = line.strip()
|
|
158
|
+
if skipping_block:
|
|
159
|
+
if line.startswith((" ", "\t")):
|
|
160
|
+
continue
|
|
161
|
+
skipping_block = False
|
|
162
|
+
if stripped in {"scripts:", "agent_scripts:"}:
|
|
163
|
+
skipping_block = True
|
|
164
|
+
continue
|
|
165
|
+
filtered_lines.append(line)
|
|
166
|
+
return "\n".join(filtered_lines)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _raise_template_discovery_error(commands_dir: Path) -> None:
|
|
170
|
+
"""Raise an informative error about template discovery failure."""
|
|
171
|
+
env_root = os.environ.get("SPEC_KITTY_TEMPLATE_ROOT")
|
|
172
|
+
remote_repo = os.environ.get("SPECIFY_TEMPLATE_REPO")
|
|
173
|
+
|
|
174
|
+
error_msg = (
|
|
175
|
+
"Templates could not be found in any of the expected locations:\n\n"
|
|
176
|
+
"Checked paths (in order):\n"
|
|
177
|
+
f" ✗ Packaged resources (bundled with CLI)\n"
|
|
178
|
+
f" ✗ Environment variable SPEC_KITTY_TEMPLATE_ROOT" +
|
|
179
|
+
(f" = {env_root}" if env_root else " (not set)") + "\n" +
|
|
180
|
+
f" ✗ Remote repository SPECIFY_TEMPLATE_REPO" +
|
|
181
|
+
(f" = {remote_repo}" if remote_repo else " (not configured)") + "\n\n"
|
|
182
|
+
"To fix this, try one of these approaches:\n\n"
|
|
183
|
+
"1. Reinstall from PyPI (recommended for end users):\n"
|
|
184
|
+
" pip install --upgrade spec-kitty-cli\n\n"
|
|
185
|
+
"2. Use --template-root flag (for development):\n"
|
|
186
|
+
" spec-kitty init . --template-root=/path/to/spec-kitty\n\n"
|
|
187
|
+
"3. Set environment variable (for development):\n"
|
|
188
|
+
" export SPEC_KITTY_TEMPLATE_ROOT=/path/to/spec-kitty\n"
|
|
189
|
+
" spec-kitty init .\n\n"
|
|
190
|
+
"4. Configure remote repository:\n"
|
|
191
|
+
" export SPECIFY_TEMPLATE_REPO=owner/repo\n"
|
|
192
|
+
" spec-kitty init .\n\n"
|
|
193
|
+
"For development installs from source, use:\n"
|
|
194
|
+
" export SPEC_KITTY_TEMPLATE_ROOT=$(git rev-parse --show-toplevel)\n"
|
|
195
|
+
" spec-kitty init . --ai=claude"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
raise FileNotFoundError(error_msg)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
__all__ = [
|
|
202
|
+
"generate_agent_assets",
|
|
203
|
+
"prepare_command_templates",
|
|
204
|
+
"render_command_template",
|
|
205
|
+
"_convert_markdown_syntax_to_format",
|
|
206
|
+
]
|