diff --git a/scripts/bash/check-prerequisites.sh b/scripts/bash/check-prerequisites.sh index 88a5559460..1ae495f085 100644 --- a/scripts/bash/check-prerequisites.sh +++ b/scripts/bash/check-prerequisites.sh @@ -115,20 +115,20 @@ fi # Validate required directories and files if [[ ! -d "$FEATURE_DIR" ]]; then echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2 - echo "Run /speckit.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.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.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 03141e4462..07dbf00a90 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -307,6 +307,84 @@ 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}" + 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() { @@ -642,4 +720,3 @@ except Exception: printf '%s' "$content" return 0 } - diff --git a/scripts/bash/setup-tasks.sh b/scripts/bash/setup-tasks.sh index 3f6a40b12d..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.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.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 91667e9ef1..e5e818eef2 100644 --- a/scripts/powershell/check-prerequisites.ps1 +++ b/scripts/powershell/check-prerequisites.ps1 @@ -88,20 +88,23 @@ if ($PathsOnly) { # 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.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.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.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 ffc6d73b3c..c75a773ed4 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 { @@ -640,4 +692,4 @@ except Exception: } return $content -} \ No newline at end of file +} diff --git a/scripts/powershell/setup-tasks.ps1 b/scripts/powershell/setup-tasks.ps1 index e00ae7a02f..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.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.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/tests/test_setup_tasks.py b/tests/test_setup_tasks.py index f2e10d8b0f..df93d87015 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]: @@ -69,8 +92,40 @@ def _clean_env() -> dict[str, str]: if key.startswith("SPECIFY_"): env.pop(key) 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", + '$common = $args[0]; $commandName = $args[1]; . $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( @@ -349,8 +404,124 @@ def test_setup_tasks_bash_missing_template_errors(tasks_repo: Path) -> None: 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_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 +584,9 @@ 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 # =========================================================================== @@ -512,8 +681,77 @@ def test_setup_tasks_ps_missing_template_errors(tasks_repo: Path) -> None: assert result.returncode != 0 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_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 +819,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