fix: render script command hints with active agent separator (#2649)

* fix script command hints for agent separators

* Address command hint review feedback

* chore: remove whitespace-only PR churn

* test: fix PowerShell command hint invocation

* fix: preserve hyphens in script command hints

* fix: render managed script command hints
This commit is contained in:
Pascal THUET
2026-06-03 23:24:13 +02:00
committed by GitHub
parent 67fecd357a
commit 4028c50af8
8 changed files with 441 additions and 14 deletions

View File

@@ -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

View File

@@ -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() {

View File

@@ -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

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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,
*,
@@ -388,6 +420,7 @@ def install_shared_infra(
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,

View File

@@ -13,8 +13,10 @@ from tests.conftest import requires_bash
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(
@@ -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
# ===========================================================================
@@ -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