Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 35 additions & 10 deletions src/specify_cli/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -310,31 +310,56 @@ def resolve_skill_placeholders(agent_name: str, frontmatter: dict, body: str, pr
init_opts = {}

script_variant = init_opts.get("script")
fallback_order: list[str] = []
if script_variant not in {"sh", "ps"}:
fallback_order = []
# Build fallback order: prefer the variant present in BOTH scripts and
# agent_scripts so that {SCRIPT} and {AGENT_SCRIPT} resolve consistently.
# On Windows the OS default is "ps", but if only "sh" is available in
# agent_scripts we must still resolve both placeholders from "sh".
default_variant = "ps" if platform.system().lower().startswith("win") else "sh"
secondary_variant = "sh" if default_variant == "ps" else "ps"

if default_variant in scripts or default_variant in agent_scripts:
fallback_order.append(default_variant)
if secondary_variant in scripts or secondary_variant in agent_scripts:
fallback_order.append(secondary_variant)

for key in scripts:
if key not in fallback_order:
fallback_order.append(key)
for key in agent_scripts:
# Prefer a variant that satisfies both scripts AND agent_scripts.
both_variants = set(scripts) & set(agent_scripts)
if both_variants:
for v in (default_variant, secondary_variant):
if v in both_variants:
fallback_order.append(v)
for v in sorted(both_variants):
if v not in fallback_order:
fallback_order.append(v)

# Then add remaining variants from scripts / agent_scripts.
for v in (default_variant, secondary_variant):
if v not in fallback_order and (v in scripts or v in agent_scripts):
fallback_order.append(v)
for key in list(scripts) + list(agent_scripts):
if key not in fallback_order:
fallback_order.append(key)

script_variant = fallback_order[0] if fallback_order else None

# Resolve script_command: try script_variant first, then walk fallback_order.
# This ensures sh-only extensions work on Windows (where default is "ps").
script_command = scripts.get(script_variant) if script_variant else None
if not script_command:
for _variant in fallback_order:
candidate = scripts.get(_variant)
if candidate:
script_command = candidate
break
if script_command:
script_command = script_command.replace("{ARGS}", "$ARGUMENTS")
body = body.replace("{SCRIPT}", script_command)

# Resolve agent_script_command: same cross-platform fallback.
agent_script_command = agent_scripts.get(script_variant) if script_variant else None
if not agent_script_command:
for _variant in fallback_order:
candidate = agent_scripts.get(_variant)
if candidate:
agent_script_command = candidate
break
if agent_script_command:
agent_script_command = agent_script_command.replace("{ARGS}", "$ARGUMENTS")
body = body.replace("{AGENT_SCRIPT}", agent_script_command)
Expand Down
1 change: 1 addition & 0 deletions src/specify_cli/core/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Core utilities for specify-cli.
137 changes: 137 additions & 0 deletions src/specify_cli/core/question_transformer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"""Question block transformer for Claude Code integration."""

from __future__ import annotations

import json
import re

_FENCE_RE = re.compile(
r"<!-- speckit:question-render:begin -->\s*\n(.*?)\n\s*<!-- speckit:question-render:end -->",
re.DOTALL,
)
_SEPARATOR_RE = re.compile(r"^\|[-| :]+\|$")

# Markers that promote an option to the top of the list.
_RECOMMENDED_RE = re.compile(r"\bRecommended\b\s*[\u2014\-]", re.IGNORECASE)


def _parse_table_rows(block: str) -> list[list[str]]:
"""Return data rows from a Markdown table, skipping header and separator.

Handles leading indentation (as found in clarify.md / checklist.md).
Rows with pipe characters inside cell values are not supported by
standard Markdown tables, so no special handling is needed.
"""
rows: list[list[str]] = []
header_seen = False
separator_seen = False

for line in block.splitlines():
stripped = line.strip()
if not stripped.startswith("|"):
continue
if not header_seen:
header_seen = True
continue
if not separator_seen:
if _SEPARATOR_RE.match(stripped):
separator_seen = True
continue
cells = [c.strip() for c in stripped.split("|")[1:-1]]
if cells:
rows.append(cells)

return rows


def parse_clarify(block: str) -> list[dict]:
"""Parse clarify.md schema: | Option | Description |

- Rows matching ``Recommended —`` / ``Recommended -`` (case-insensitive)
are placed first.
- Duplicate labels are deduplicated (first occurrence wins).
"""
options: list[dict] = []
recommended: dict | None = None
seen_labels: set[str] = set()

for cells in _parse_table_rows(block):
if len(cells) < 2:
continue
label = cells[0]
description = cells[1]
if label in seen_labels:
continue
seen_labels.add(label)
entry = {"label": label, "description": description}
if _RECOMMENDED_RE.search(description):
if recommended is None:
recommended = entry
else:
options.append(entry)

if recommended:
options.insert(0, recommended)

return options


def parse_checklist(block: str) -> list[dict]:
"""Parse checklist.md schema: | Option | Candidate | Why It Matters |

Candidate → label, Why It Matters → description.
Duplicate labels are deduplicated (first occurrence wins).
"""
options: list[dict] = []
seen_labels: set[str] = set()

for cells in _parse_table_rows(block):
if len(cells) < 3:
continue
label = cells[1]
description = cells[2]
if label in seen_labels:
continue
seen_labels.add(label)
options.append({"label": label, "description": description})

return options


def _build_payload(options: list[dict]) -> str:
"""Serialise options into a validated AskUserQuestion JSON code block."""
# Append "Other" only if not already present.
if not any(o["label"].lower() == "other" for o in options):
options = options + [
{
"label": "Other",
"description": "Provide my own short answer (\u226410 words)",
}
]

payload: dict = {
"question": "Please select an option:",
"multiSelect": False,
"options": options,
}

# Validate round-trip before returning — raises ValueError on bad data.
raw = json.dumps(payload, ensure_ascii=False, indent=2)
json.loads(raw) # round-trip check
return f"```json\n{raw}\n```"


def transform_question_block(content: str) -> str:
"""Replace fenced question blocks with AskUserQuestion JSON payloads.

Content without markers is returned byte-identical — safe for all
non-Claude integrations.
"""

def _replace(match: re.Match) -> str:
block = match.group(1)
is_checklist = "| Candidate |" in block or "|Candidate|" in block
options = parse_checklist(block) if is_checklist else parse_clarify(block)
return _build_payload(options)

return _FENCE_RE.sub(_replace, content)
6 changes: 5 additions & 1 deletion src/specify_cli/integrations/claude/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import yaml

from ...core.question_transformer import transform_question_block
from ..base import SkillsIntegration
from ..manifest import IntegrationManifest

Expand Down Expand Up @@ -173,8 +174,11 @@ def setup(
content_bytes = path.read_bytes()
content = content_bytes.decode("utf-8")

# Transform question blocks if present
updated = transform_question_block(content)

# Inject user-invocable: true (Claude skills are accessible via /command)
updated = self._inject_frontmatter_flag(content, "user-invocable")
updated = self._inject_frontmatter_flag(updated, "user-invocable")

# Inject disable-model-invocation: true (Claude skills run only when invoked)
updated = self._inject_frontmatter_flag(updated, "disable-model-invocation")
Expand Down
10 changes: 9 additions & 1 deletion templates/commands/checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,15 @@ You **MUST** consider the user input before proceeding (if not empty).
- Scenario class gap (e.g., "No recovery flows detected—are rollback / partial failure paths in scope?")

Question formatting rules:
- If presenting options, generate a compact table with columns: Option | Candidate | Why It Matters
- If presenting options, generate a compact table with columns:

<!-- speckit:question-render:begin -->
| Option | Candidate | Why It Matters |
|--------|-----------|----------------|
| A | <Candidate A> | <Why it matters> |
| B | <Candidate B> | <Why it matters> |
<!-- speckit:question-render:end -->

- Limit to A–E options maximum; omit table if a free-form answer is clearer
- Never ask the user to restate what they already said
- Avoid speculative categories (no hallucination). If uncertain, ask explicitly: "Confirm whether X belongs in scope."
Expand Down
2 changes: 2 additions & 0 deletions templates/commands/clarify.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,14 @@ Execution steps:
- Format as: `**Recommended:** Option [X] - <reasoning>`
- Then render all options as a Markdown table:

<!-- speckit:question-render:begin -->
| Option | Description |
|--------|-------------|
| A | <Option A description> |
| B | <Option B description> |
| C | <Option C description> (add D/E as needed up to 5) |
| Short | Provide a different short answer (<=5 words) (Include only if free-form alternative is appropriate) |
<!-- speckit:question-render:end -->

- After the table, add: `You can reply with the option letter (e.g., "A"), accept the recommendation by saying "yes" or "recommended", or provide your own short answer.`
- For short‑answer style (no meaningful discrete options):
Expand Down
61 changes: 61 additions & 0 deletions tests/integrations/test_integration_claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -400,3 +400,64 @@ def test_inject_argument_hint_skips_if_already_present(self):
lines = result.splitlines()
hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:"))
assert hint_count == 1


class TestClaudeQuestionRenderTransform:
"""Verify question-render markers are transformed in generated Claude skills."""

def _get_skill(self, tmp_path, name: str) -> str:
i = get_integration("claude")
m = IntegrationManifest("claude", tmp_path)
i.setup(tmp_path, m, script_type="sh")
skill = tmp_path / ".claude" / "skills" / f"speckit-{name}" / "SKILL.md"
assert skill.exists(), f"speckit-{name}/SKILL.md not found"
return skill.read_text(encoding="utf-8")

def test_clarify_skill_has_no_raw_markers(self, tmp_path):
content = self._get_skill(tmp_path, "clarify")
assert "speckit:question-render" not in content

def test_clarify_skill_contains_json_block(self, tmp_path):
content = self._get_skill(tmp_path, "clarify")
assert "```json" in content

def test_clarify_skill_json_is_valid(self, tmp_path):
content = self._get_skill(tmp_path, "clarify")
json_str = content.split("```json\n")[1].split("\n```")[0]
parsed = json.loads(json_str)
assert parsed["multiSelect"] is False
assert isinstance(parsed["options"], list)
assert len(parsed["options"]) >= 2 # at least one real option + Other

def test_clarify_skill_json_has_other_option(self, tmp_path):
content = self._get_skill(tmp_path, "clarify")
json_str = content.split("```json\n")[1].split("\n```")[0]
parsed = json.loads(json_str)
labels = [o["label"] for o in parsed["options"]]
assert "Other" in labels
assert labels[-1] == "Other"

def test_checklist_skill_has_no_raw_markers(self, tmp_path):
content = self._get_skill(tmp_path, "checklist")
assert "speckit:question-render" not in content

def test_checklist_skill_contains_json_block(self, tmp_path):
content = self._get_skill(tmp_path, "checklist")
assert "```json" in content

def test_checklist_skill_json_is_valid(self, tmp_path):
content = self._get_skill(tmp_path, "checklist")
json_str = content.split("```json\n")[1].split("\n```")[0]
parsed = json.loads(json_str)
assert parsed["multiSelect"] is False
assert isinstance(parsed["options"], list)

def test_non_question_skills_unchanged(self, tmp_path):
"""Skills without markers must not contain JSON question payloads."""
for name in ("plan", "specify", "implement", "tasks"):
content = self._get_skill(tmp_path, name)
assert "speckit:question-render" not in content
# These skills should not have a question JSON block
assert '"multiSelect"' not in content, (
f"speckit-{name} unexpectedly contains question JSON"
)
Loading