From d3725b498ac94b264b7e4663ed4991ff76e1d2d1 Mon Sep 17 00:00:00 2001 From: root <1647273252@qq.com> Date: Tue, 19 May 2026 12:46:18 +0800 Subject: [PATCH] fix shared script command refs for integration separators --- scripts/bash/check-prerequisites.sh | 6 +- scripts/bash/common.sh | 5 +- scripts/bash/setup-tasks.sh | 4 +- scripts/powershell/check-prerequisites.ps1 | 6 +- scripts/powershell/common.ps1 | 6 +- scripts/powershell/setup-tasks.ps1 | 4 +- src/specify_cli/__init__.py | 22 ++-- src/specify_cli/shared_infra.py | 11 +- tests/integrations/test_cli.py | 70 ++++++++++- .../test_integration_subcommand.py | 110 ++++++++++++++---- 10 files changed, 192 insertions(+), 52 deletions(-) diff --git a/scripts/bash/check-prerequisites.sh b/scripts/bash/check-prerequisites.sh index 88a5559460..a051a58aa6 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 __SPECKIT_COMMAND_SPECIFY__ 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 __SPECKIT_COMMAND_PLAN__ 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 __SPECKIT_COMMAND_TASKS__ first to create the task list." >&2 exit 1 fi diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 03141e4462..e8da27e238 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -186,7 +186,7 @@ read_feature_json_feature_directory() { } # Returns 0 when .specify/feature.json lists feature_directory that exists as a directory -# and matches the resolved active FEATURE_DIR (so /speckit.plan can skip git branch pattern checks). +# and matches the resolved active FEATURE_DIR (so __SPECKIT_COMMAND_PLAN__ can skip git branch pattern checks). # Delegates parsing to read_feature_json_feature_directory, which is safe under `set -e`. feature_json_matches_feature_dir() { local repo_root="$1" @@ -262,7 +262,7 @@ get_feature_paths() { # Resolve feature directory. Priority: # 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override) - # 2. .specify/feature.json "feature_directory" key (persisted by /speckit.specify) + # 2. .specify/feature.json "feature_directory" key (persisted by __SPECKIT_COMMAND_SPECIFY__) # 3. Branch-name-based prefix lookup (legacy fallback) local feature_dir if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then @@ -642,4 +642,3 @@ except Exception: printf '%s' "$content" return 0 } - diff --git a/scripts/bash/setup-tasks.sh b/scripts/bash/setup-tasks.sh index 3f6a40b12d..ec0420e78a 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 __SPECKIT_COMMAND_PLAN__ 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 __SPECKIT_COMMAND_SPECIFY__ 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..726a0a5b52 100644 --- a/scripts/powershell/check-prerequisites.ps1 +++ b/scripts/powershell/check-prerequisites.ps1 @@ -88,20 +88,20 @@ 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." + Write-Output "Run __SPECKIT_COMMAND_SPECIFY__ 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." + Write-Output "Run __SPECKIT_COMMAND_PLAN__ 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." + Write-Output "Run __SPECKIT_COMMAND_TASKS__ first to create the task list." exit 1 } diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index ffc6d73b3c..b9619105ed 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -165,7 +165,7 @@ function Test-FeatureBranch { } # True when .specify/feature.json pins an existing feature directory that matches the -# active FEATURE_DIR from Get-FeaturePathsEnv (so /speckit.plan can skip git branch pattern checks). +# active FEATURE_DIR from Get-FeaturePathsEnv (so __SPECKIT_COMMAND_PLAN__ can skip git branch pattern checks). function Test-FeatureJsonMatchesFeatureDir { param( [Parameter(Mandatory = $true)][string]$RepoRoot, @@ -288,7 +288,7 @@ function Get-FeaturePathsEnv { # Resolve feature directory. Priority: # 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override) - # 2. .specify/feature.json "feature_directory" key (persisted by /speckit.specify) + # 2. .specify/feature.json "feature_directory" key (persisted by __SPECKIT_COMMAND_SPECIFY__) # 3. Branch-name-based prefix lookup (same as scripts/bash/common.sh) $featureJson = Join-Path $repoRoot '.specify/feature.json' if ($env:SPECIFY_FEATURE_DIRECTORY) { @@ -640,4 +640,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..80593e2809 100644 --- a/scripts/powershell/setup-tasks.ps1 +++ b/scripts/powershell/setup-tasks.ps1 @@ -28,13 +28,13 @@ 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.") + [Console]::Error.WriteLine("Run __SPECKIT_COMMAND_PLAN__ 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.") + [Console]::Error.WriteLine("Run __SPECKIT_COMMAND_SPECIFY__ first to create the feature structure.") exit 1 } diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 41fb994726..d16c98a8ab 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -235,9 +235,9 @@ def _install_shared_infra( ``bash`` when *script_type* is ``"sh"`` and ``powershell`` when it is ``"ps"``. Tracks all installed files in ``speckit.manifest.json``. - Page templates are processed to resolve ``__SPECKIT_COMMAND___`` - placeholders using *invoke_separator* (``"."`` for markdown agents, - ``"-"`` for skills agents). + Shared scripts and page templates are processed to resolve + ``__SPECKIT_COMMAND___`` placeholders using *invoke_separator* + (``"."`` for markdown agents, ``"-"`` for skills agents). Overwrite policy: @@ -1439,16 +1439,18 @@ def _set_default_integration( if refresh_templates: try: - _refresh_shared_templates( + _install_shared_infra( project_root, + resolved_script, invoke_separator=_invoke_separator_for_integration( integration, {"integration_settings": settings}, key, parsed_options ), force=refresh_templates_force, + refresh_managed=True, ) except (ValueError, OSError) as exc: raise _SharedTemplateRefreshError( - f"Failed to refresh shared templates for '{key}': {exc}" + f"Failed to refresh shared infrastructure for '{key}': {exc}" ) from exc _write_integration_json(project_root, key, installed_keys, settings) @@ -1787,7 +1789,7 @@ def _update_init_options_for_integration( @integration_app.command("use") def integration_use( key: str = typer.Argument(help="Installed integration key to make the default"), - force: bool = typer.Option(False, "--force", help="Overwrite managed shared templates while changing the default"), + force: bool = typer.Option(False, "--force", help="Overwrite managed shared infrastructure while changing the default"), ): """Set the default integration without uninstalling other integrations.""" from .integrations import get_integration @@ -1984,7 +1986,7 @@ def integration_switch( ) console.print( f"\n[green]✓[/green] Default integration remains [bold]{target}[/bold]; " - "managed shared templates refreshed." + "managed shared infrastructure refreshed." ) raise typer.Exit(0) console.print(f"[yellow]Integration '{target}' is already the default integration. Nothing to switch.[/yellow]") @@ -2324,16 +2326,18 @@ def integration_upgrade( ) if installed_key == key: try: - _refresh_shared_templates( + _install_shared_infra( project_root, + selected_script, invoke_separator=_invoke_separator_for_integration( integration, {"integration_settings": settings}, key, parsed_options ), force=force, + refresh_managed=True, ) except (ValueError, OSError) as exc: raise _SharedTemplateRefreshError( - f"Failed to refresh shared templates for '{key}': {exc}" + f"Failed to refresh shared infrastructure for '{key}': {exc}" ) from exc new_manifest.save() _write_integration_json(project_root, installed_key, installed_keys, settings) diff --git a/src/specify_cli/shared_infra.py b/src/specify_cli/shared_infra.py index 35bf02e644..8f4d73c6f6 100644 --- a/src/specify_cli/shared_infra.py +++ b/src/specify_cli/shared_infra.py @@ -363,7 +363,16 @@ def _ensure_or_bucket_dir(directory: Path) -> bool: if not _ensure_or_bucket_dir(dst_path.parent): continue - planned_copies.append((dst_path, rel, src_path.read_bytes(), src_path.stat().st_mode & 0o777)) + content = src_path.read_text(encoding="utf-8") + content = IntegrationBase.resolve_command_refs(content, invoke_separator) + planned_copies.append( + ( + dst_path, + rel, + content.encode("utf-8"), + src_path.stat().st_mode & 0o777, + ) + ) templates_src = shared_templates_source(core_pack=core_pack, repo_root=repo_root) if templates_src.is_dir(): diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index ed0e824539..2cc14ce7b5 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -866,7 +866,23 @@ def test_git_extension_commands_registered(self, tmp_path): class TestSharedInfraCommandRefs: - """Verify _install_shared_infra resolves __SPECKIT_COMMAND_*__ in page templates.""" + """Verify _install_shared_infra resolves __SPECKIT_COMMAND_*__ in shared infra.""" + + @staticmethod + def _combined_script_content(project, script_type): + script_dir = "bash" if script_type == "sh" else "powershell" + suffix = "sh" if script_type == "sh" else "ps1" + names = [ + f"check-prerequisites.{suffix}", + f"common.{suffix}", + f"setup-tasks.{suffix}", + ] + return "\n".join( + (project / ".specify" / "scripts" / script_dir / name).read_text( + encoding="utf-8" + ) + for name in names + ) def test_dot_separator_in_page_templates(self, tmp_path): """Markdown agents get /speckit. in page templates.""" @@ -911,6 +927,46 @@ def test_hyphen_separator_in_page_templates(self, tmp_path): assert "__SPECKIT_COMMAND_" not in content assert "/speckit-tasks" in content + @pytest.mark.parametrize("script_type", ["sh", "ps"]) + def test_dot_separator_in_shared_scripts(self, tmp_path, script_type): + """Markdown agents get /speckit. in shared script hints.""" + from specify_cli import _install_shared_infra + + project = tmp_path / f"dot-script-{script_type}" + project.mkdir() + (project / ".specify").mkdir() + + _install_shared_infra(project, script_type, invoke_separator=".") + + content = self._combined_script_content(project, script_type) + assert "__SPECKIT_COMMAND_" not in content + assert "/speckit.specify" in content + assert "/speckit.plan" in content + assert "/speckit.tasks" in content + assert "/speckit-specify" not in content + assert "/speckit-plan" not in content + assert "/speckit-tasks" not in content + + @pytest.mark.parametrize("script_type", ["sh", "ps"]) + def test_hyphen_separator_in_shared_scripts(self, tmp_path, script_type): + """Skills agents get /speckit- in shared script hints.""" + from specify_cli import _install_shared_infra + + project = tmp_path / f"hyphen-script-{script_type}" + project.mkdir() + (project / ".specify").mkdir() + + _install_shared_infra(project, script_type, invoke_separator="-") + + content = self._combined_script_content(project, script_type) + assert "__SPECKIT_COMMAND_" not in content + assert "/speckit-specify" in content + assert "/speckit-plan" in content + assert "/speckit-tasks" in content + assert "/speckit.specify" not in content + assert "/speckit.plan" not in content + assert "/speckit.tasks" not in content + def test_full_init_claude_resolves_page_templates(self, tmp_path): """Full CLI init with Claude (skills agent) produces hyphen refs in page templates.""" from typer.testing import CliRunner @@ -938,6 +994,10 @@ def test_full_init_claude_resolves_page_templates(self, tmp_path): assert "/speckit-plan" in content, "Claude (skills) should use /speckit-plan" assert "__SPECKIT_COMMAND_" not in content + script_content = self._combined_script_content(project, "sh") + assert "/speckit-specify" in script_content + assert "/speckit.specify" not in script_content + def test_full_init_copilot_resolves_page_templates(self, tmp_path): """Full CLI init with Copilot (markdown agent) produces dot refs in page templates.""" from typer.testing import CliRunner @@ -965,6 +1025,10 @@ def test_full_init_copilot_resolves_page_templates(self, tmp_path): assert "/speckit.plan" in content, "Copilot (markdown) should use /speckit.plan" assert "__SPECKIT_COMMAND_" not in content + script_content = self._combined_script_content(project, "sh") + assert "/speckit.specify" in script_content + assert "/speckit-specify" not in script_content + def test_full_init_copilot_skills_resolves_page_templates(self, tmp_path): """Full CLI init with Copilot --skills produces hyphen refs in page templates.""" from typer.testing import CliRunner @@ -994,6 +1058,10 @@ def test_full_init_copilot_skills_resolves_page_templates(self, tmp_path): assert "/speckit.plan" not in content, "dot-notation leaked into Copilot skills page template" assert "__SPECKIT_COMMAND_" not in content + script_content = self._combined_script_content(project, "sh") + assert "/speckit-specify" in script_content + assert "/speckit.specify" not in script_content + class TestIntegrationCatalogDiscoveryCLI: """End-to-end CLI tests for `integration search`, `info`, and `catalog …`. diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index abff9a5ee1..801d10aa75 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -358,6 +358,10 @@ def test_install_bare_project_gets_shared_infra(self, tmp_path): # Shared infrastructure should be present assert (project / ".specify" / "scripts").is_dir() assert (project / ".specify" / "templates").is_dir() + script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" + script_content = script.read_text(encoding="utf-8") + assert "/speckit-specify" in script_content + assert "/speckit.specify" not in script_content # ── uninstall ──────────────────────────────────────────────────────── @@ -486,7 +490,9 @@ def test_uninstall_non_default_preserves_default(self, tmp_path): def test_uninstall_default_refreshes_templates_for_fallback(self, tmp_path): project = _init_project(tmp_path, "gemini") template = project / ".specify" / "templates" / "plan-template.md" + script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" assert "/speckit.plan" in template.read_text(encoding="utf-8") + assert "/speckit.plan" in script.read_text(encoding="utf-8") old_cwd = os.getcwd() try: @@ -505,6 +511,7 @@ def test_uninstall_default_refreshes_templates_for_fallback(self, tmp_path): data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) assert data["integration"] == "claude" assert "/speckit-plan" in template.read_text(encoding="utf-8") + assert "/speckit-plan" in script.read_text(encoding="utf-8") def test_uninstall_preserves_shared_infra(self, tmp_path): """Shared scripts and templates are not removed by integration uninstall.""" @@ -565,7 +572,9 @@ def test_use_requires_installed_integration(self, tmp_path): def test_use_refreshes_shared_templates_between_command_styles(self, tmp_path): project = _init_project(tmp_path, "claude") template = project / ".specify" / "templates" / "plan-template.md" + script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" assert "/speckit-plan" in template.read_text(encoding="utf-8") + assert "/speckit-plan" in script.read_text(encoding="utf-8") old_cwd = os.getcwd() try: @@ -579,10 +588,14 @@ def test_use_refreshes_shared_templates_between_command_styles(self, tmp_path): use_gemini = runner.invoke(app, ["integration", "use", "gemini"], catch_exceptions=False) assert use_gemini.exit_code == 0, use_gemini.output assert "/speckit.plan" in template.read_text(encoding="utf-8") + assert "/speckit.plan" in script.read_text(encoding="utf-8") + assert "/speckit-plan" not in script.read_text(encoding="utf-8") use_claude = runner.invoke(app, ["integration", "use", "claude"], catch_exceptions=False) assert use_claude.exit_code == 0, use_claude.output assert "/speckit-plan" in template.read_text(encoding="utf-8") + assert "/speckit-plan" in script.read_text(encoding="utf-8") + assert "/speckit.plan" not in script.read_text(encoding="utf-8") finally: os.chdir(old_cwd) @@ -616,8 +629,7 @@ def test_use_preserves_modified_templates_unless_forced(self, tmp_path): assert "/speckit.plan" in updated assert "custom template" not in updated - @pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable") - def test_use_does_not_persist_default_when_template_refresh_fails(self, tmp_path): + def test_use_does_not_persist_default_when_shared_infra_refresh_fails(self, tmp_path, monkeypatch): project = _init_project(tmp_path, "claude") int_json = project / ".specify" / "integration.json" init_options = project / ".specify" / "init-options.json" @@ -633,12 +645,12 @@ def test_use_does_not_persist_default_when_template_refresh_fails(self, tmp_path before_state = json.loads(int_json.read_text(encoding="utf-8")) before_options = json.loads(init_options.read_text(encoding="utf-8")) + import specify_cli - outside = tmp_path / "outside-template.md" - outside.write_text("# outside\n", encoding="utf-8") - template = project / ".specify" / "templates" / "plan-template.md" - template.unlink() - os.symlink(outside, template) + def fail_refresh(*args, **kwargs): + raise ValueError("refuse refresh") + + monkeypatch.setattr(specify_cli, "_install_shared_infra", fail_refresh) result = runner.invoke(app, [ "integration", "use", "codex", @@ -648,10 +660,9 @@ def test_use_does_not_persist_default_when_template_refresh_fails(self, tmp_path os.chdir(old_cwd) assert result.exit_code != 0 - assert "Failed to refresh shared templates" in result.output + assert "Failed to refresh shared infrastructure" in result.output assert json.loads(int_json.read_text(encoding="utf-8")) == before_state assert json.loads(init_options.read_text(encoding="utf-8")) == before_options - assert outside.read_text(encoding="utf-8") == "# outside\n" # ── switch ─────────────────────────────────────────────────────────── @@ -709,7 +720,9 @@ def test_switch_same_noop(self, tmp_path): def test_switch_same_force_refreshes_shared_templates(self, tmp_path): project = _init_project(tmp_path, "claude") template = project / ".specify" / "templates" / "plan-template.md" + script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" template.write_text("# custom shared template\n", encoding="utf-8") + script.write_text("# custom shared script\n", encoding="utf-8") old_cwd = os.getcwd() try: @@ -721,8 +734,9 @@ def test_switch_same_force_refreshes_shared_templates(self, tmp_path): finally: os.chdir(old_cwd) assert result.exit_code == 0, result.output - assert "managed shared templates refreshed" in result.output + assert "managed shared infrastructure refreshed" in result.output assert "/speckit-plan" in template.read_text(encoding="utf-8") + assert "/speckit-plan" in script.read_text(encoding="utf-8") def test_switch_installed_target_rejects_integration_options(self, tmp_path): project = _init_project(tmp_path, "claude") @@ -751,6 +765,8 @@ def test_switch_between_integrations(self, tmp_path): project = _init_project(tmp_path, "claude") # Verify claude files exist (claude uses skills) assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() + shared_script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" + assert "/speckit-specify" in shared_script.read_text(encoding="utf-8") old_cwd = os.getcwd() try: @@ -769,6 +785,8 @@ def test_switch_between_integrations(self, tmp_path): # New copilot files created assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists() + assert "/speckit.specify" in shared_script.read_text(encoding="utf-8") + assert "/speckit-specify" not in shared_script.read_text(encoding="utf-8") # integration.json updated data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) @@ -910,12 +928,13 @@ def test_switch_does_not_register_disabled_extensions(self, tmp_path): assert "claude" not in git_meta["registered_commands"] assert "opencode" not in git_meta["registered_commands"] - def test_switch_preserves_shared_infra(self, tmp_path): - """Switching preserves shared scripts, templates, and memory.""" + def test_switch_refreshes_managed_shared_script_refs(self, tmp_path): + """Switching refreshes managed shared scripts to the target command style.""" project = _init_project(tmp_path, "claude") shared_script = project / ".specify" / "scripts" / "bash" / "common.sh" assert shared_script.exists() shared_content = shared_script.read_text(encoding="utf-8") + assert "/speckit-plan" in shared_content old_cwd = os.getcwd() try: @@ -928,9 +947,10 @@ def test_switch_preserves_shared_infra(self, tmp_path): os.chdir(old_cwd) assert result.exit_code == 0 - # Shared infra untouched assert shared_script.exists() - assert shared_script.read_text(encoding="utf-8") == shared_content + updated = shared_script.read_text(encoding="utf-8") + assert "/speckit.plan" in updated + assert "/speckit-plan" not in updated def test_switch_refreshes_stale_managed_shared_infra(self, tmp_path): """Regression for #2293: stale managed shared scripts get refreshed on switch.""" @@ -938,7 +958,7 @@ def test_switch_refreshes_stale_managed_shared_infra(self, tmp_path): project = _init_project(tmp_path, "claude") shared_script = project / ".specify" / "scripts" / "bash" / "common.sh" - bundled_bytes = shared_script.read_bytes() + assert "/speckit-plan" in shared_script.read_text(encoding="utf-8") # Simulate a stale vendored script: write truncated content as bytes # (write_text would translate \n→\r\n on Windows and break the hash) @@ -965,8 +985,11 @@ def test_switch_refreshes_stale_managed_shared_infra(self, tmp_path): os.chdir(old_cwd) assert result.exit_code == 0 - # Stale managed file should be replaced by the bundled version - assert shared_script.read_bytes() == bundled_bytes + # Stale managed file should be replaced by the target integration's rendered version. + updated = shared_script.read_text(encoding="utf-8") + assert "# stale vendored copy" not in updated + assert "/speckit.plan" in updated + assert "/speckit-plan" not in updated def test_switch_preserves_user_customized_shared_infra(self, tmp_path): """User customizations (hash divergence from manifest) survive switch without --refresh-shared-infra.""" @@ -996,10 +1019,11 @@ def test_switch_refresh_shared_infra_overwrites_customizations(self, tmp_path): """--refresh-shared-infra explicitly overwrites user customizations on switch.""" project = _init_project(tmp_path, "claude") shared_script = project / ".specify" / "scripts" / "bash" / "common.sh" - bundled_bytes = shared_script.read_bytes() + assert "/speckit-plan" in shared_script.read_text(encoding="utf-8") + rendered_bytes = shared_script.read_bytes() # User customization (hash diverges from manifest) - custom_bytes = bundled_bytes + b"\n# user customization\n" + custom_bytes = rendered_bytes + b"\n# user customization\n" shared_script.write_bytes(custom_bytes) old_cwd = os.getcwd() @@ -1013,8 +1037,11 @@ def test_switch_refresh_shared_infra_overwrites_customizations(self, tmp_path): finally: os.chdir(old_cwd) assert result.exit_code == 0 - # Customization is overwritten with the bundled version - assert shared_script.read_bytes() == bundled_bytes + # Customization is overwritten with the target integration's rendered version. + updated = shared_script.read_text(encoding="utf-8") + assert "# user customization" not in updated + assert "/speckit.plan" in updated + assert "/speckit-plan" not in updated def test_switch_skips_symlinked_parent_directory(self, tmp_path): """Regression: if .specify/scripts/bash is a symlink, switch must not write through it. @@ -1144,7 +1171,7 @@ def test_upgrade_invalid_manifest_reports_cli_error(self, tmp_path): assert "manifest" in result.output assert "unreadable" in result.output - def test_upgrade_does_not_persist_state_when_template_refresh_fails(self, tmp_path, monkeypatch): + def test_upgrade_does_not_persist_state_when_shared_infra_refresh_fails(self, tmp_path, monkeypatch): project = _init_project(tmp_path, "claude") int_json = project / ".specify" / "integration.json" init_options = project / ".specify" / "init-options.json" @@ -1156,10 +1183,16 @@ def test_upgrade_does_not_persist_state_when_template_refresh_fails(self, tmp_pa import specify_cli + real_install_shared_infra = specify_cli._install_shared_infra + calls = {"count": 0} + def fail_refresh(*args, **kwargs): - raise ValueError("refuse refresh") + calls["count"] += 1 + if calls["count"] == 2: + raise ValueError("refuse refresh") + return real_install_shared_infra(*args, **kwargs) - monkeypatch.setattr(specify_cli, "_refresh_shared_templates", fail_refresh) + monkeypatch.setattr(specify_cli, "_install_shared_infra", fail_refresh) result = _run_in_project(project, [ "integration", "upgrade", "claude", @@ -1167,15 +1200,40 @@ def fail_refresh(*args, **kwargs): ]) assert result.exit_code != 0 - assert "Failed to refresh shared templates" in result.output + assert "Failed to refresh shared infrastructure" in result.output assert json.loads(int_json.read_text(encoding="utf-8")) == before_state assert json.loads(init_options.read_text(encoding="utf-8")) == before_options assert manifest_path.read_text(encoding="utf-8") == before_manifest + def test_upgrade_default_refreshes_shared_script_refs_for_option_separator_change(self, tmp_path): + project = _init_project(tmp_path, "copilot") + template = project / ".specify" / "templates" / "plan-template.md" + managed_script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" + customized_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh" + + assert "/speckit.plan" in template.read_text(encoding="utf-8") + assert "/speckit.specify" in managed_script.read_text(encoding="utf-8") + customized_before = customized_script.read_text(encoding="utf-8") + "\n# user customization\n" + customized_script.write_text(customized_before, encoding="utf-8") + + result = _run_in_project(project, [ + "integration", "upgrade", "copilot", + "--integration-options", "--skills", + ]) + + assert result.exit_code == 0, result.output + assert "/speckit-plan" in template.read_text(encoding="utf-8") + managed_content = managed_script.read_text(encoding="utf-8") + assert "/speckit-specify" in managed_content + assert "/speckit.specify" not in managed_content + assert customized_script.read_text(encoding="utf-8") == customized_before + def test_upgrade_non_default_keeps_default_template_invocations(self, tmp_path): project = _init_project(tmp_path, "gemini") template = project / ".specify" / "templates" / "plan-template.md" + script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" assert "/speckit.plan" in template.read_text(encoding="utf-8") + assert "/speckit.plan" in script.read_text(encoding="utf-8") old_cwd = os.getcwd() try: @@ -1198,6 +1256,8 @@ def test_upgrade_non_default_keeps_default_template_invocations(self, tmp_path): data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) assert data["integration"] == "gemini" assert "/speckit.plan" in template.read_text(encoding="utf-8") + assert "/speckit.plan" in script.read_text(encoding="utf-8") + assert "/speckit-plan" not in script.read_text(encoding="utf-8") def test_upgrade_migrates_opencode_legacy_dir(self, tmp_path): """Upgrade moves OpenCode commands from .opencode/command/ to .opencode/commands/."""