diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index ec7af8876..3817be41c 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -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) diff --git a/src/specify_cli/core/__init__.py b/src/specify_cli/core/__init__.py new file mode 100644 index 000000000..09958cdc8 --- /dev/null +++ b/src/specify_cli/core/__init__.py @@ -0,0 +1 @@ +# Core utilities for specify-cli. diff --git a/src/specify_cli/core/question_transformer.py b/src/specify_cli/core/question_transformer.py new file mode 100644 index 000000000..3895dea4e --- /dev/null +++ b/src/specify_cli/core/question_transformer.py @@ -0,0 +1,137 @@ +"""Question block transformer for Claude Code integration.""" + +from __future__ import annotations + +import json +import re + +_FENCE_RE = re.compile( + r"\s*\n(.*?)\n\s*", + 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) diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 31972c4b0..d9734735c 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -7,6 +7,7 @@ import yaml +from ...core.question_transformer import transform_question_block from ..base import SkillsIntegration from ..manifest import IntegrationManifest @@ -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") diff --git a/templates/commands/checklist.md b/templates/commands/checklist.md index 533046566..98747155f 100644 --- a/templates/commands/checklist.md +++ b/templates/commands/checklist.md @@ -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: + + + | Option | Candidate | Why It Matters | + |--------|-----------|----------------| + | A | | | + | B | | | + + - 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." diff --git a/templates/commands/clarify.md b/templates/commands/clarify.md index d6d6bbe91..bbf0c4e50 100644 --- a/templates/commands/clarify.md +++ b/templates/commands/clarify.md @@ -145,12 +145,14 @@ Execution steps: - Format as: `**Recommended:** Option [X] - ` - Then render all options as a Markdown table: + | Option | Description | |--------|-------------| | A |