diff --git a/scripts/bash/check-prerequisites.sh b/scripts/bash/check-prerequisites.sh index 7d6d3644fd..95e7344d80 100644 --- a/scripts/bash/check-prerequisites.sh +++ b/scripts/bash/check-prerequisites.sh @@ -117,20 +117,20 @@ check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1 # Validate required directories and files if [[ ! -d "$FEATURE_DIR" ]]; then echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2 - echo "Run __SPECKIT_COMMAND_SPECIFY__ first to create the feature structure." >&2 + echo "Run $(format_speckit_command specify "$REPO_ROOT") first to create the feature structure." >&2 exit 1 fi if [[ ! -f "$IMPL_PLAN" ]]; then echo "ERROR: plan.md not found in $FEATURE_DIR" >&2 - echo "Run __SPECKIT_COMMAND_PLAN__ first to create the implementation plan." >&2 + echo "Run $(format_speckit_command plan "$REPO_ROOT") first to create the implementation plan." >&2 exit 1 fi # Check for tasks.md if required if $REQUIRE_TASKS && [[ ! -f "$TASKS" ]]; then echo "ERROR: tasks.md not found in $FEATURE_DIR" >&2 - echo "Run __SPECKIT_COMMAND_TASKS__ first to create the task list." >&2 + echo "Run $(format_speckit_command tasks "$REPO_ROOT") first to create the task list." >&2 exit 1 fi diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index e8da27e238..9d7dd21edf 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -307,6 +307,83 @@ has_jq() { command -v jq >/dev/null 2>&1 } +get_invoke_separator() { + local repo_root="${1:-$(get_repo_root)}" + if [[ "${_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT:-}" == "$repo_root" && -n "${_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE:-}" ]]; then + printf '%s\n' "$_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE" + return 0 + fi + + local integration_json="$repo_root/.specify/integration.json" + local separator="." + local parsed_with_jq=0 + + if [[ -f "$integration_json" ]]; then + if command -v jq >/dev/null 2>&1; then + local jq_separator + if jq_separator=$(jq -r '(.default_integration // .integration // "") as $k | if $k == "" then "." else (.integration_settings[$k].invoke_separator // ".") end' "$integration_json" 2>/dev/null); then + parsed_with_jq=1 + case "$jq_separator" in + "."|"-") separator="$jq_separator" ;; + esac + fi + fi + + if [[ "$parsed_with_jq" -eq 0 ]] && command -v python3 >/dev/null 2>&1; then + if separator=$(python3 - "$integration_json" <<'PY' 2>/dev/null +import json +import sys + +try: + with open(sys.argv[1], encoding="utf-8") as fh: + state = json.load(fh) + key = state.get("default_integration") or state.get("integration") or "" + settings = state.get("integration_settings") + separator = "." + if isinstance(key, str) and isinstance(settings, dict): + entry = settings.get(key) + if isinstance(entry, dict) and entry.get("invoke_separator") in {".", "-"}: + separator = entry["invoke_separator"] + print(separator) +except Exception: + print(".") +PY +); then + case "$separator" in + "."|"-") ;; + *) separator="." ;; + esac + else + separator="." + fi + fi + fi + + _SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root" + _SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE="$separator" + printf '%s\n' "$separator" +} + +format_speckit_command() { + local command_name="$1" + local repo_root="${2:-$(get_repo_root)}" + local separator + if [[ "${_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT:-}" == "$repo_root" && -n "${_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE:-}" ]]; then + separator="$_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE" + else + separator=$(get_invoke_separator "$repo_root") + _SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root" + _SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE="$separator" + fi + + command_name="${command_name#/}" + command_name="${command_name#speckit.}" + command_name="${command_name#speckit-}" + command_name="${command_name//./$separator}" + + printf '/speckit%s%s\n' "$separator" "$command_name" +} + # Escape a string for safe embedding in a JSON value (fallback when jq is unavailable). # Handles backslash, double-quote, and JSON-required control character escapes (RFC 8259). json_escape() { diff --git a/scripts/bash/setup-tasks.sh b/scripts/bash/setup-tasks.sh index ec0420e78a..73bc095b48 100644 --- a/scripts/bash/setup-tasks.sh +++ b/scripts/bash/setup-tasks.sh @@ -35,13 +35,13 @@ fi if [[ ! -f "$IMPL_PLAN" ]]; then echo "ERROR: plan.md not found in $FEATURE_DIR" >&2 - echo "Run __SPECKIT_COMMAND_PLAN__ first to create the implementation plan." >&2 + echo "Run $(format_speckit_command plan "$REPO_ROOT") first to create the implementation plan." >&2 exit 1 fi if [[ ! -f "$FEATURE_SPEC" ]]; then echo "ERROR: spec.md not found in $FEATURE_DIR" >&2 - echo "Run __SPECKIT_COMMAND_SPECIFY__ first to create the feature structure." >&2 + echo "Run $(format_speckit_command specify "$REPO_ROOT") first to create the feature structure." >&2 exit 1 fi diff --git a/scripts/powershell/check-prerequisites.ps1 b/scripts/powershell/check-prerequisites.ps1 index ea0832ebc5..bcf3aa46c4 100644 --- a/scripts/powershell/check-prerequisites.ps1 +++ b/scripts/powershell/check-prerequisites.ps1 @@ -89,20 +89,23 @@ if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit:$paths.HAS_GI # Validate required directories and files if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) { Write-Output "ERROR: Feature directory not found: $($paths.FEATURE_DIR)" - Write-Output "Run __SPECKIT_COMMAND_SPECIFY__ first to create the feature structure." + $specifyCommand = Format-SpecKitCommand -CommandName 'specify' -RepoRoot $paths.REPO_ROOT + Write-Output "Run $specifyCommand first to create the feature structure." exit 1 } if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) { Write-Output "ERROR: plan.md not found in $($paths.FEATURE_DIR)" - Write-Output "Run __SPECKIT_COMMAND_PLAN__ first to create the implementation plan." + $planCommand = Format-SpecKitCommand -CommandName 'plan' -RepoRoot $paths.REPO_ROOT + Write-Output "Run $planCommand first to create the implementation plan." exit 1 } # Check for tasks.md if required if ($RequireTasks -and -not (Test-Path $paths.TASKS -PathType Leaf)) { Write-Output "ERROR: tasks.md not found in $($paths.FEATURE_DIR)" - Write-Output "Run __SPECKIT_COMMAND_TASKS__ first to create the task list." + $tasksCommand = Format-SpecKitCommand -CommandName 'tasks' -RepoRoot $paths.REPO_ROOT + Write-Output "Run $tasksCommand first to create the task list." exit 1 } diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index 75655d68e4..42ffdf1390 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -355,6 +355,58 @@ function Test-DirHasFiles { } } +function Get-InvokeSeparator { + param([string]$RepoRoot = (Get-RepoRoot)) + + if ($null -eq $script:SpecKitInvokeSeparatorCache) { + $script:SpecKitInvokeSeparatorCache = @{} + } + if ($script:SpecKitInvokeSeparatorCache.ContainsKey($RepoRoot)) { + return $script:SpecKitInvokeSeparatorCache[$RepoRoot] + } + + $separator = '.' + $integrationJson = Join-Path $RepoRoot '.specify/integration.json' + if (Test-Path -LiteralPath $integrationJson -PathType Leaf) { + try { + $state = Get-Content -LiteralPath $integrationJson -Raw | ConvertFrom-Json + $key = if ($state.default_integration) { [string]$state.default_integration } elseif ($state.integration) { [string]$state.integration } else { '' } + if ($key -and $state.integration_settings) { + $settingProperty = $state.integration_settings.PSObject.Properties[$key] + if ($settingProperty) { + $setting = $settingProperty.Value + if ($setting -and ($setting.invoke_separator -eq '.' -or $setting.invoke_separator -eq '-')) { + $separator = [string]$setting.invoke_separator + } + } + } + } catch { + $separator = '.' + } + } + + $script:SpecKitInvokeSeparatorCache[$RepoRoot] = $separator + return $separator +} + +function Format-SpecKitCommand { + param( + [Parameter(Mandatory = $true)][string]$CommandName, + [string]$RepoRoot = (Get-RepoRoot) + ) + + $separator = Get-InvokeSeparator -RepoRoot $RepoRoot + $name = $CommandName.TrimStart('/') + if ($name.StartsWith('speckit.')) { + $name = $name.Substring(8) + } elseif ($name.StartsWith('speckit-')) { + $name = $name.Substring(8) + } + $name = $name -replace '\.', $separator + + return "/speckit$separator$name" +} + # Find a usable Python 3 executable (python3, python, or py -3). # Returns the command/arguments as an array, or $null if none found. function Get-Python3Command { diff --git a/scripts/powershell/setup-tasks.ps1 b/scripts/powershell/setup-tasks.ps1 index 80593e2809..41de629685 100644 --- a/scripts/powershell/setup-tasks.ps1 +++ b/scripts/powershell/setup-tasks.ps1 @@ -28,13 +28,15 @@ if (-not (Test-FeatureJsonMatchesFeatureDir -RepoRoot $paths.REPO_ROOT -ActiveFe if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) { [Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)") - [Console]::Error.WriteLine("Run __SPECKIT_COMMAND_PLAN__ first to create the implementation plan.") + $planCommand = Format-SpecKitCommand -CommandName 'plan' -RepoRoot $paths.REPO_ROOT + [Console]::Error.WriteLine("Run $planCommand first to create the implementation plan.") exit 1 } if (-not (Test-Path $paths.FEATURE_SPEC -PathType Leaf)) { [Console]::Error.WriteLine("ERROR: spec.md not found in $($paths.FEATURE_DIR)") - [Console]::Error.WriteLine("Run __SPECKIT_COMMAND_SPECIFY__ first to create the feature structure.") + $specifyCommand = Format-SpecKitCommand -CommandName 'specify' -RepoRoot $paths.REPO_ROOT + [Console]::Error.WriteLine("Run $specifyCommand first to create the feature structure.") exit 1 } diff --git a/src/specify_cli/shared_infra.py b/src/specify_cli/shared_infra.py index f57f5722e3..2e0b1fcf2a 100644 --- a/src/specify_cli/shared_infra.py +++ b/src/specify_cli/shared_infra.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import re import tempfile from pathlib import Path from typing import Any @@ -194,6 +195,37 @@ def _write_shared_bytes( temp_path.unlink() +_BASH_FORMAT_COMMAND_RE = re.compile( + r"\$\(\s*format_speckit_command\s+(['\"]?)([A-Za-z0-9_.-]+)\1(?:\s+[^)]*)?\)" +) +_POWERSHELL_FORMAT_COMMAND_RE = re.compile( + r"Format-SpecKitCommand\s+-CommandName\s+(['\"])([A-Za-z0-9_.-]+)\1(?:\s+-RepoRoot\s+[^\r\n]+)?" +) + + +def _format_speckit_command(command_name: str, separator: str) -> str: + name = command_name.strip().lstrip("/") + if name.startswith("speckit."): + name = name[len("speckit.") :] + elif name.startswith("speckit-"): + name = name[len("speckit-") :] + name = name.replace(".", separator) + return f"/speckit{separator}{name}" + + +def _resolve_dynamic_command_refs(content: str, separator: str) -> str: + """Render script runtime command helpers for managed shared infra copies.""" + + content = _BASH_FORMAT_COMMAND_RE.sub( + lambda match: _format_speckit_command(match.group(2), separator), + content, + ) + return _POWERSHELL_FORMAT_COMMAND_RE.sub( + lambda match: f"'{_format_speckit_command(match.group(2), separator)}'", + content, + ) + + def refresh_shared_templates( project_path: Path, *, @@ -371,6 +403,7 @@ def _ensure_or_bucket_dir(directory: Path) -> bool: continue content = src_path.read_text(encoding="utf-8") content = IntegrationBase.resolve_command_refs(content, invoke_separator) + content = _resolve_dynamic_command_refs(content, invoke_separator) planned_copies.append( ( dst_path, diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index f40adb7ae9..7719e0b7ec 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -3,7 +3,6 @@ import json import os -import pytest from typer.testing import CliRunner from specify_cli import app diff --git a/tests/test_setup_tasks.py b/tests/test_setup_tasks.py index f2e10d8b0f..961124d3a9 100644 --- a/tests/test_setup_tasks.py +++ b/tests/test_setup_tasks.py @@ -13,8 +13,10 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh" SETUP_TASKS_SH = PROJECT_ROOT / "scripts" / "bash" / "setup-tasks.sh" +CHECK_PREREQ_SH = PROJECT_ROOT / "scripts" / "bash" / "check-prerequisites.sh" COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1" SETUP_TASKS_PS = PROJECT_ROOT / "scripts" / "powershell" / "setup-tasks.ps1" +CHECK_PREREQ_PS = PROJECT_ROOT / "scripts" / "powershell" / "check-prerequisites.ps1" TASKS_TEMPLATE = PROJECT_ROOT / "templates" / "tasks-template.md" HAS_PWSH = shutil.which("pwsh") is not None @@ -30,6 +32,7 @@ def _install_bash_scripts(repo: Path) -> None: d.mkdir(parents=True, exist_ok=True) shutil.copy(COMMON_SH, d / "common.sh") shutil.copy(SETUP_TASKS_SH, d / "setup-tasks.sh") + shutil.copy(CHECK_PREREQ_SH, d / "check-prerequisites.sh") def _install_ps_scripts(repo: Path) -> None: @@ -37,6 +40,7 @@ def _install_ps_scripts(repo: Path) -> None: d.mkdir(parents=True, exist_ok=True) shutil.copy(COMMON_PS, d / "common.ps1") shutil.copy(SETUP_TASKS_PS, d / "setup-tasks.ps1") + shutil.copy(CHECK_PREREQ_PS, d / "check-prerequisites.ps1") def _install_core_tasks_template(repo: Path) -> None: @@ -57,6 +61,25 @@ def _minimal_feature(repo: Path) -> Path: (feat / "spec.md").write_text("# spec\n", encoding="utf-8") (feat / "plan.md").write_text("# plan\n", encoding="utf-8") return feat + + +def _write_integration_state(repo: Path, integration: str = "claude", separator: str = "-") -> None: + specify_dir = repo / ".specify" + specify_dir.mkdir(parents=True, exist_ok=True) + state = { + "integration": integration, + "default_integration": integration, + "installed_integrations": [integration], + "integration_settings": { + integration: { + "invoke_separator": separator, + }, + }, + } + (specify_dir / "integration.json").write_text( + json.dumps(state), + encoding="utf-8", + ) def _clean_env() -> dict[str, str]: @@ -71,6 +94,38 @@ def _clean_env() -> dict[str, str]: return env +def _run_bash_format_command(repo: Path, command_name: str) -> subprocess.CompletedProcess: + script = repo / ".specify" / "scripts" / "bash" / "common.sh" + return subprocess.run( + ["bash", "-c", 'source "$1"; format_speckit_command "$2" "$PWD"', "bash", str(script), command_name], + cwd=repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + +def _run_powershell_format_command(repo: Path, command_name: str) -> subprocess.CompletedProcess: + script = repo / ".specify" / "scripts" / "powershell" / "common.ps1" + exe = "pwsh" if HAS_PWSH else _POWERSHELL + return subprocess.run( + [ + exe, + "-NoProfile", + "-Command", + '& { param($common, $commandName) . $common; Format-SpecKitCommand -CommandName $commandName -RepoRoot (Get-Location).Path }', + str(script), + command_name, + ], + cwd=repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + def _git_init(repo: Path) -> None: subprocess.run(["git", "init", "-q"], cwd=repo, check=True) subprocess.run( @@ -123,7 +178,7 @@ def test_setup_tasks_bash_core_template_resolved(tasks_repo: Path) -> None: setup-tasks.sh --json should exit 0 and return an absolute, existing TASKS_TEMPLATE path pointing to the core template. """ - feat = _minimal_feature(tasks_repo) + _minimal_feature(tasks_repo) script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh" result = subprocess.run( @@ -150,7 +205,7 @@ def test_setup_tasks_bash_override_wins(tasks_repo: Path) -> None: When an override exists at .specify/templates/overrides/tasks-template.md, setup-tasks.sh --json must return the override path, not the core path. """ - feat = _minimal_feature(tasks_repo) + _minimal_feature(tasks_repo) # Create the override overrides_dir = tasks_repo / ".specify" / "templates" / "overrides" @@ -187,7 +242,7 @@ def test_setup_tasks_bash_extension_wins_over_core(tasks_repo: Path) -> None: When an extension template exists, setup-tasks.sh --json must resolve tasks-template.md from the extension before falling back to the core path. """ - feat = _minimal_feature(tasks_repo) + _minimal_feature(tasks_repo) # FIX: real extension layout is .specify/extensions//templates/.md extension_dir = ( @@ -225,7 +280,7 @@ def test_setup_tasks_bash_preset_wins_over_extension(tasks_repo: Path) -> None: When both preset and extension templates exist, setup-tasks.sh --json must resolve the preset path because presets outrank extensions. """ - feat = _minimal_feature(tasks_repo) + _minimal_feature(tasks_repo) # FIX: real extension layout is .specify/extensions//templates/.md extension_dir = ( @@ -269,7 +324,7 @@ def test_setup_tasks_bash_preset_priority_order(tasks_repo: Path) -> None: When two presets both provide tasks-template.md, the one listed first in .specify/presets/.registry wins. """ - feat = _minimal_feature(tasks_repo) + _minimal_feature(tasks_repo) # resolve_template reads .specify/presets/.registry as a JSON object with a # "presets" map where each entry has a numeric "priority" (lower = higher @@ -329,7 +384,7 @@ def test_setup_tasks_bash_missing_template_errors(tasks_repo: Path) -> None: When tasks-template.md is absent from all locations, setup-tasks.sh must exit non-zero and print a helpful ERROR message to stderr. """ - feat = _minimal_feature(tasks_repo) + _minimal_feature(tasks_repo) # Remove the core template so no template exists anywhere core = tasks_repo / ".specify" / "templates" / "tasks-template.md" @@ -345,12 +400,138 @@ def test_setup_tasks_bash_missing_template_errors(tasks_repo: Path) -> None: check=False, env=_clean_env(), ) - + assert result.returncode != 0 assert "ERROR" in result.stderr assert "tasks-template" in result.stderr +@requires_bash +def test_bash_command_hint_defaults_to_dot_without_integration_json(tasks_repo: Path) -> None: + integration_json = tasks_repo / ".specify" / "integration.json" + if integration_json.exists(): + integration_json.unlink() + + result = _run_bash_format_command(tasks_repo, "plan") + + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "/speckit.plan" + + +@requires_bash +def test_bash_command_hint_rejects_invalid_invoke_separator(tasks_repo: Path) -> None: + _write_integration_state(tasks_repo, "claude", "/") + + result = _run_bash_format_command(tasks_repo, "plan") + + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "/speckit.plan" + + +@requires_bash +def test_bash_command_hint_normalizes_mixed_separators(tasks_repo: Path) -> None: + _write_integration_state(tasks_repo, "copilot", ".") + + result = _run_bash_format_command(tasks_repo, "/speckit-git.commit") + + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "/speckit.git.commit" + + _write_integration_state(tasks_repo, "claude", "-") + + result = _run_bash_format_command(tasks_repo, "speckit.git-commit") + + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "/speckit-git-commit" + + +@requires_bash +def test_bash_command_hint_preserves_hyphens_inside_segments(tasks_repo: Path) -> None: + _write_integration_state(tasks_repo, "copilot", ".") + + result = _run_bash_format_command(tasks_repo, "speckit.jira.sync-status") + + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "/speckit.jira.sync-status" + + +@requires_bash +def test_bash_command_hint_caches_invoke_separator_per_process(tasks_repo: Path) -> None: + _write_integration_state(tasks_repo, "claude", "-") + script = tasks_repo / ".specify" / "scripts" / "bash" / "common.sh" + dot_state = { + "integration": "copilot", + "default_integration": "copilot", + "installed_integrations": ["copilot"], + "integration_settings": {"copilot": {"invoke_separator": "."}}, + } + + result = subprocess.run( + [ + "bash", + "-c", + 'source "$1"; format_speckit_command plan "$PWD"; printf "%s" "$2" > .specify/integration.json; format_speckit_command tasks "$PWD"', + "bash", + str(script), + json.dumps(dot_state), + ], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode == 0, result.stderr + assert result.stdout.splitlines() == ["/speckit-plan", "/speckit-tasks"] + + +@requires_bash +def test_setup_tasks_bash_uses_invoke_separator_in_plan_hint(tasks_repo: Path) -> None: + _write_integration_state(tasks_repo, "claude", "-") + feat = tasks_repo / "specs" / "001-my-feature" + feat.mkdir(parents=True, exist_ok=True) + (feat / "spec.md").write_text("# spec\n", encoding="utf-8") + + script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh" + + result = subprocess.run( + ["bash", str(script), "--json"], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode != 0 + assert "Run /speckit-plan first" in result.stderr + assert "/speckit.plan" not in result.stderr + + +@requires_bash +def test_check_prerequisites_bash_uses_invoke_separator_in_tasks_hint( + tasks_repo: Path, +) -> None: + _write_integration_state(tasks_repo, "claude", "-") + _minimal_feature(tasks_repo) + + script = tasks_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" + + result = subprocess.run( + ["bash", str(script), "--require-tasks"], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode != 0 + assert "Run /speckit-tasks first" in result.stderr + assert "/speckit.tasks" not in result.stderr + + @requires_bash def test_setup_tasks_bash_passes_custom_branch_when_feature_json_valid( tasks_repo: Path, @@ -413,11 +594,10 @@ def test_setup_tasks_bash_fails_custom_branch_without_feature_json( check=False, env=_clean_env(), ) - + assert result.returncode != 0 assert "Not on a feature branch" in result.stderr - # =========================================================================== # POWERSHELL TESTS # =========================================================================== @@ -429,7 +609,7 @@ def test_setup_tasks_ps_core_template_resolved(tasks_repo: Path) -> None: setup-tasks.ps1 -Json should exit 0 and return an absolute, existing TASKS_TEMPLATE path. """ - feat = _minimal_feature(tasks_repo) + _minimal_feature(tasks_repo) script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1" exe = "pwsh" if HAS_PWSH else _POWERSHELL @@ -457,7 +637,7 @@ def test_setup_tasks_ps_override_wins(tasks_repo: Path) -> None: When an override exists at .specify/templates/overrides/tasks-template.md, setup-tasks.ps1 -Json must return the override path, not the core path. """ - feat = _minimal_feature(tasks_repo) + _minimal_feature(tasks_repo) overrides_dir = tasks_repo / ".specify" / "templates" / "overrides" overrides_dir.mkdir(parents=True, exist_ok=True) @@ -493,7 +673,7 @@ def test_setup_tasks_ps_missing_template_errors(tasks_repo: Path) -> None: When tasks-template.md is absent from all locations, setup-tasks.ps1 must exit non-zero and write a helpful error to stderr. """ - feat = _minimal_feature(tasks_repo) + _minimal_feature(tasks_repo) core = tasks_repo / ".specify" / "templates" / "tasks-template.md" core.unlink() @@ -514,6 +694,87 @@ def test_setup_tasks_ps_missing_template_errors(tasks_repo: Path) -> None: assert "tasks-template" in result.stderr.lower() or "tasks-template" in result.stdout.lower() +@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") +def test_powershell_command_hint_normalizes_mixed_separators( + tasks_repo: Path, +) -> None: + _write_integration_state(tasks_repo, "copilot", ".") + + result = _run_powershell_format_command(tasks_repo, "/speckit-git.commit") + + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "/speckit.git.commit" + + _write_integration_state(tasks_repo, "claude", "-") + + result = _run_powershell_format_command(tasks_repo, "speckit.git-commit") + + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "/speckit-git-commit" + + +@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") +def test_powershell_command_hint_preserves_hyphens_inside_segments( + tasks_repo: Path, +) -> None: + _write_integration_state(tasks_repo, "copilot", ".") + + result = _run_powershell_format_command(tasks_repo, "speckit.jira.sync-status") + + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "/speckit.jira.sync-status" + + +@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") +def test_setup_tasks_ps_uses_invoke_separator_in_plan_hint(tasks_repo: Path) -> None: + _write_integration_state(tasks_repo, "claude", "-") + feat = tasks_repo / "specs" / "001-my-feature" + feat.mkdir(parents=True, exist_ok=True) + (feat / "spec.md").write_text("# spec\n", encoding="utf-8") + + script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1" + exe = "pwsh" if HAS_PWSH else _POWERSHELL + + result = subprocess.run( + [exe, "-NoProfile", "-File", str(script), "-Json"], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + output = result.stderr + result.stdout + assert result.returncode != 0 + assert "Run /speckit-plan first" in output + assert "/speckit.plan" not in output + + +@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") +def test_check_prerequisites_ps_uses_invoke_separator_in_tasks_hint( + tasks_repo: Path, +) -> None: + _write_integration_state(tasks_repo, "claude", "-") + _minimal_feature(tasks_repo) + + script = tasks_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1" + exe = "pwsh" if HAS_PWSH else _POWERSHELL + + result = subprocess.run( + [exe, "-NoProfile", "-File", str(script), "-RequireTasks"], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + output = result.stderr + result.stdout + assert result.returncode != 0 + assert "Run /speckit-tasks first" in output + assert "/speckit.tasks" not in output + + @pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") def test_setup_tasks_ps_passes_custom_branch_when_feature_json_valid( tasks_repo: Path, @@ -581,4 +842,3 @@ def test_setup_tasks_ps_fails_custom_branch_without_feature_json( assert result.returncode != 0 assert "Not on a feature branch" in result.stderr - \ No newline at end of file