From 874eeb1dd33faad81c31f52bfab68cb55a5d4090 Mon Sep 17 00:00:00 2001 From: jaeminbird Date: Wed, 28 Oct 2026 03:44:45 -0400 Subject: [PATCH 1/3] fix(cli): don't self-upgrade uv tool installs on Windows `uv tool install --force` deletes the tool environment before reinstalling it. When `specify self upgrade` runs it from that environment on Windows, uv removes Lib\site-packages and then fails on Scripts\ because the running python.exe is locked, leaving `specify` without its package. When the running interpreter is a uv tool environment on Windows, print the uv command to run once specify exits and make no changes. pipx installs and other platforms are unchanged. Fixes #5141 Assisted-by: Claude Code (model: Claude Opus 5.5, supervised) --- docs/upgrade.md | 2 +- src/specify_cli/_version.py | 7 +++ src/specify_cli/selfs/command_upgrade.py | 20 +++++++ tests/specify_cli/test_version_execution.py | 65 +++++++++++++++++++++ 4 files changed, 93 insertions(+), 1 deletion(-) diff --git a/docs/upgrade.md b/docs/upgrade.md index 943d9f41a1..c8bd1d7a2b 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -43,7 +43,7 @@ specify self upgrade specify self upgrade --tag vX.Y.Z[suffix] ``` -Bare `specify self upgrade` executes immediately, matching the no-prompt behavior of commands like `pip install -U` and `npm update`. The CLI classifies your runtime into one of: `uv tool`, `pipx`, `uvx (ephemeral)`, source checkout, or unsupported. Only `uv tool` and `pipx` are upgraded automatically; for `uv tool` installs, it runs `uv tool install specify-cli --force --from ` under the hood so pinned release tags work. The other paths print path-specific guidance and exit 0 without touching anything. +Bare `specify self upgrade` executes immediately, matching the no-prompt behavior of commands like `pip install -U` and `npm update`. The CLI classifies your runtime into one of: `uv tool`, `pipx`, `uvx (ephemeral)`, source checkout, or unsupported. Only `uv tool` and `pipx` are upgraded automatically; for `uv tool` installs, it runs `uv tool install specify-cli --force --from ` under the hood so pinned release tags work. On Windows, `uv` cannot replace the tool environment `specify` is running from, so for `uv tool` installs `specify self upgrade` prints that command for you to run once it exits, and exits 0 without touching anything. The other paths print path-specific guidance and exit 0 without touching anything. Pinned tags must start with `vMAJOR.MINOR.PATCH`. Optional suffixes are limited to dev, alpha/beta/rc, and/or build metadata forms such as `v1.0.0-rc1`, `v0.8.0.dev0`, `v0.8.0+build.42`, or the combination `v1.0.0-rc1+build.42`; branch names, hash refs, `latest`, and bare versions without `v` are rejected. diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index c8fcad7c7e..ac716fe001 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -673,6 +673,13 @@ def _installer_binary_name(method: _InstallMethod) -> str | None: return None +def _runs_from_uv_tool_env_on_windows() -> bool: + """Return whether this process runs from a uv tool environment on Windows.""" + return os.name == "nt" and os.path.isfile( + os.path.join(sys.prefix, "uv-receipt.toml") + ) + + def _is_path_like_command(value: str) -> bool: """Return whether an argv[0] names a path rather than a bare command.""" return Path(value).parent != Path(".") or "/" in value or "\\" in value diff --git a/src/specify_cli/selfs/command_upgrade.py b/src/specify_cli/selfs/command_upgrade.py index 1f645c0e45..047be541bb 100644 --- a/src/specify_cli/selfs/command_upgrade.py +++ b/src/specify_cli/selfs/command_upgrade.py @@ -73,6 +73,8 @@ def self_upgrade( _parse_version_text, _render_argv, _run_installer, + _runs_from_uv_tool_env_on_windows, + _source_spec, _validate_tag, _verify_upgrade, console, @@ -153,6 +155,24 @@ def self_upgrade( console.print(f"Already on requested release: {target_tag}") raise typer.Exit(0) + if plan.method == _InstallMethod.UV_TOOL and _runs_from_uv_tool_env_on_windows(): + # uv deletes the tool environment before reinstalling it, and Windows + # will not delete the python.exe this process is running from, so uv + # would leave the environment without its packages. Leave the + # reinstall to the user's shell instead. + console.print( + f"Not upgrading specify-cli {plan.current_version} → {target_tag}: " + "on Windows, uv cannot replace the tool environment specify is " + "running from. No changes were made.", + soft_wrap=True, + ) + console.print("Run it yourself once this command exits:", soft_wrap=True) + console.print( + f" uv tool install specify-cli --force --from {_source_spec(target_tag)}", + soft_wrap=True, + ) + raise typer.Exit(0) + installed_version = _parse_version_text(plan.current_version) verb = ( "Downgrading" diff --git a/tests/specify_cli/test_version_execution.py b/tests/specify_cli/test_version_execution.py index 9e3f2514fe..02253e6e6b 100644 --- a/tests/specify_cli/test_version_execution.py +++ b/tests/specify_cli/test_version_execution.py @@ -1,10 +1,12 @@ """Installer execution, verification, and error-path tests for `specify self upgrade`.""" import errno +import os import subprocess from unittest.mock import patch from specify_cli import app +from specify_cli._version import _runs_from_uv_tool_env_on_windows from tests.specify_cli.self_upgrade_helpers import ( route_opener_open_through_urlopen, # noqa: F401 (autouse fixture) @@ -541,3 +543,66 @@ def test_prerelease_failure_degrades_rollback_hint_to_releases_page( assert "Previous version was not an exact stable release tag" in out assert "https://github.com/github/spec-kit/releases" in out assert "git+https://github.com/github/spec-kit.git@v1.0.0rc1" not in out + + +class TestWindowsUvToolEnvironment: + """Windows uv-tool installs cannot reinstall the environment they run from.""" + + def test_windows_uv_tool_prints_command_without_running_installer( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="1.2.2" + ), patch( + "specify_cli._version._runs_from_uv_tool_env_on_windows", return_value=True + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.2.4"}) + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 0 + out = strip_ansi(result.output) + assert "Not upgrading specify-cli 1.2.2 → v1.2.4" in out + assert ( + " uv tool install specify-cli --force --from " + "git+https://github.com/github/spec-kit.git@v1.2.4" + ) in out + assert "Upgrade failed" not in out + assert mock_run.call_count == 0 + + def test_windows_pipx_still_runs_installer(self, pipx_argv0, clean_environ): + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="pipx" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="1.2.2" + ), patch( + "specify_cli._version._runs_from_uv_tool_env_on_windows", return_value=True + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.2.4"}) + mock_run.side_effect = [ + _completed_process(0), # installer + _completed_process(0, stdout="specify 1.2.4\n"), # verify + ] + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 0 + assert "Upgraded specify-cli: 1.2.2 → 1.2.4" in strip_ansi(result.output) + assert mock_run.call_count == 2 + + def test_detects_uv_tool_environment_on_windows(self, tmp_path, monkeypatch): + (tmp_path / "uv-receipt.toml").write_text("[tool]\n") + monkeypatch.setattr("sys.prefix", str(tmp_path)) + monkeypatch.setattr(os, "name", "nt") + assert _runs_from_uv_tool_env_on_windows() is True + + def test_ignores_environment_without_uv_receipt(self, tmp_path, monkeypatch): + monkeypatch.setattr("sys.prefix", str(tmp_path)) + monkeypatch.setattr(os, "name", "nt") + assert _runs_from_uv_tool_env_on_windows() is False + + def test_ignores_uv_tool_environment_off_windows(self, tmp_path, monkeypatch): + (tmp_path / "uv-receipt.toml").write_text("[tool]\n") + monkeypatch.setattr("sys.prefix", str(tmp_path)) + monkeypatch.setattr(os, "name", "posix") + assert _runs_from_uv_tool_env_on_windows() is False From dcc351be61b744124a5e0d4eb78d549627741896 Mon Sep 17 00:00:00 2001 From: jaeminbird Date: Wed, 28 Oct 2026 13:28:46 -0400 Subject: [PATCH 2/3] fix(cli): preview the Windows uv tool outcome in self upgrade --dry-run On Windows, a real `specify self upgrade` from a uv tool environment prints the uv command instead of running it, but `--dry-run` still listed that command as the one that would be executed. Preview the same outcome, printing the command to run once specify exits exactly as the real run does, and add the Windows exception to the `--help` text. Refs #5141 Assisted-by: Claude Code (model: Claude Opus 5.5, supervised) --- src/specify_cli/_version.py | 11 ++ src/specify_cli/selfs/command_upgrade.py | 18 ++- tests/specify_cli/test_version_execution.py | 125 ++++++++++++++++++++ 3 files changed, 148 insertions(+), 6 deletions(-) diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index ac716fe001..d25ff72152 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -680,6 +680,11 @@ def _runs_from_uv_tool_env_on_windows() -> bool: ) +def _uv_tool_manual_command(target_tag: str | None) -> str: + """Return the uv command a Windows uv-tool user runs after specify exits.""" + return f"uv tool install specify-cli --force --from {_source_spec(target_tag)}" + + def _is_path_like_command(value: str) -> bool: """Return whether an argv[0] names a path rather than a bare command.""" return Path(value).parent != Path(".") or "/" in value or "\\" in value @@ -736,6 +741,12 @@ def _build_upgrade_plan( command_preview = ( f"(installer {_installer_binary_name(method)} not found on PATH)" ) + elif method == _InstallMethod.UV_TOOL and _runs_from_uv_tool_env_on_windows(): + # self_upgrade prints the uv command instead of running it. + command_preview = ( + "(none — on Windows, uv cannot replace the tool environment specify " + "is running from)" + ) else: command_preview = ( _render_argv(argv) if argv is not None else "(none — non-upgradable path)" diff --git a/src/specify_cli/selfs/command_upgrade.py b/src/specify_cli/selfs/command_upgrade.py index 047be541bb..3fd9ff4f52 100644 --- a/src/specify_cli/selfs/command_upgrade.py +++ b/src/specify_cli/selfs/command_upgrade.py @@ -33,7 +33,9 @@ def self_upgrade( Detection classifies the runtime into uv-tool / pipx / uvx (ephemeral) / source-checkout / unsupported. Only uv-tool and pipx are upgraded automatically; the other three paths print path-specific guidance and - exit 0. + exit 0. On Windows, uv cannot replace the uv-tool environment specify is + running from, so uv-tool installs print the uv command to run once + specify exits, and exit 0 without changes. Exit codes: 0 success or no-op-success (already on latest, --dry-run, or @@ -74,7 +76,7 @@ def self_upgrade( _render_argv, _run_installer, _runs_from_uv_tool_env_on_windows, - _source_spec, + _uv_tool_manual_command, _validate_tag, _verify_upgrade, console, @@ -112,6 +114,13 @@ def self_upgrade( console.print("Dry run — no changes will be made.") for line in plan.preview_summary.splitlines(): console.print(line) + if ( + plan.method == _InstallMethod.UV_TOOL + and plan.installer_argv is not None + and _runs_from_uv_tool_env_on_windows() + ): + console.print("Run it yourself once this command exits:", soft_wrap=True) + console.print(f" {_uv_tool_manual_command(plan.target_tag)}", soft_wrap=True) raise typer.Exit(0) if plan.method in ( @@ -167,10 +176,7 @@ def self_upgrade( soft_wrap=True, ) console.print("Run it yourself once this command exits:", soft_wrap=True) - console.print( - f" uv tool install specify-cli --force --from {_source_spec(target_tag)}", - soft_wrap=True, - ) + console.print(f" {_uv_tool_manual_command(target_tag)}", soft_wrap=True) raise typer.Exit(0) installed_version = _parse_version_text(plan.current_version) diff --git a/tests/specify_cli/test_version_execution.py b/tests/specify_cli/test_version_execution.py index 02253e6e6b..3042f6a811 100644 --- a/tests/specify_cli/test_version_execution.py +++ b/tests/specify_cli/test_version_execution.py @@ -590,6 +590,131 @@ def test_windows_pipx_still_runs_installer(self, pipx_argv0, clean_environ): assert "Upgraded specify-cli: 1.2.2 → 1.2.4" in strip_ansi(result.output) assert mock_run.call_count == 2 + def test_windows_uv_tool_dry_run_previews_manual_command( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="1.2.2" + ), patch( + "specify_cli._version._runs_from_uv_tool_env_on_windows", return_value=True + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.2.4"}) + result = runner.invoke(app, ["self", "upgrade", "--dry-run"]) + + assert result.exit_code == 0 + out = strip_ansi(result.output) + assert "Dry run — no changes will be made." in out + assert "Target version: v1.2.4" in out + assert ( + "Command that would be executed: (none — on Windows, uv cannot " + "replace the tool environment specify is running from)" + ) in " ".join(out.split()) + assert ( + "Run it yourself once this command exits:\n" + " uv tool install specify-cli --force --from " + "git+https://github.com/github/spec-kit.git@v1.2.4\n" + ) in out + assert mock_run.call_count == 0 + + def test_windows_uv_tool_dry_run_with_tag_previews_pinned_command( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="1.2.2" + ), patch( + "specify_cli._version._runs_from_uv_tool_env_on_windows", return_value=True + ): + result = runner.invoke( + app, ["self", "upgrade", "--dry-run", "--tag", "v1.2.0"] + ) + + assert result.exit_code == 0 + assert ( + "\n uv tool install specify-cli --force --from " + "git+https://github.com/github/spec-kit.git@v1.2.0\n" + ) in strip_ansi(result.output) + mock_urlopen.assert_not_called() + assert mock_run.call_count == 0 + + def test_windows_uv_tool_dry_run_matches_real_run_command( + self, uv_tool_argv0, clean_environ + ): + outputs = {} + for args in (["self", "upgrade", "--dry-run"], ["self", "upgrade"]): + with patch( + "specify_cli.authentication.http.urllib.request.urlopen" + ) as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="uv" + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="1.2.2" + ), patch( + "specify_cli._version._runs_from_uv_tool_env_on_windows", + return_value=True, + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.2.4"}) + result = runner.invoke(app, args) + assert result.exit_code == 0 + assert mock_run.call_count == 0 + outputs[len(args)] = strip_ansi(result.output) + + manual_steps = ( + "Run it yourself once this command exits:\n" + " uv tool install specify-cli --force --from " + "git+https://github.com/github/spec-kit.git@v1.2.4\n" + ) + assert manual_steps in outputs[3] + assert manual_steps in outputs[2] + + def test_non_windows_uv_tool_dry_run_still_previews_installer( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value="uv" + ), patch("specify_cli._version.subprocess.run"), patch( + "specify_cli._version._get_installed_version", return_value="1.2.2" + ), patch( + "specify_cli._version._runs_from_uv_tool_env_on_windows", return_value=False + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.2.4"}) + result = runner.invoke(app, ["self", "upgrade", "--dry-run"]) + + assert result.exit_code == 0 + out = strip_ansi(result.output) + assert ( + "Command that would be executed: uv tool install specify-cli --force " + "--from git+https://github.com/github/spec-kit.git@v1.2.4" + ) in " ".join(out.split()) + assert "Run it yourself" not in out + + def test_windows_uv_tool_dry_run_without_uv_reports_missing_installer( + self, uv_tool_argv0, clean_environ + ): + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( + "specify_cli._version.shutil.which", return_value=None + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( + "specify_cli._version._get_installed_version", return_value="1.2.2" + ), patch( + "specify_cli._version._runs_from_uv_tool_env_on_windows", return_value=True + ): + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.2.4"}) + result = runner.invoke(app, ["self", "upgrade", "--dry-run"]) + + assert result.exit_code == 0 + out = strip_ansi(result.output) + assert "Command that would be executed: (installer uv not found on PATH)" in out + assert "Run it yourself" not in out + assert mock_run.call_count == 0 + + def test_help_mentions_windows_uv_tool_exception(self): + result = runner.invoke(app, ["self", "upgrade", "--help"]) + assert result.exit_code == 0 + out = " ".join(strip_ansi(result.output).split()) + assert "On Windows, uv cannot replace the uv-tool environment" in out + def test_detects_uv_tool_environment_on_windows(self, tmp_path, monkeypatch): (tmp_path / "uv-receipt.toml").write_text("[tool]\n") monkeypatch.setattr("sys.prefix", str(tmp_path)) From 68e38e95ac2bb8db7599ad876c99bd15025e39c4 Mon Sep 17 00:00:00 2001 From: jaeminbird Date: Thu, 29 Oct 2026 02:05:13 -0400 Subject: [PATCH 3/3] fix(cli): tell Windows uv tool users to stop other specify processes Exiting `specify self upgrade` does not release the Windows lock on the tool environment's python.exe if another process from it is still running, such as an editor's `specify mcp` server. Running the printed uv command then fails partway through the same way. Say to stop those processes first, in both the real run and --dry-run output and in docs/upgrade.md. Refs #5141 Assisted-by: Claude Code (model: Claude Opus 5.5, autonomous) --- docs/upgrade.md | 2 +- src/specify_cli/_version.py | 8 ++++++++ src/specify_cli/selfs/command_upgrade.py | 5 +++-- tests/specify_cli/test_version_execution.py | 10 ++++++---- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/docs/upgrade.md b/docs/upgrade.md index c8bd1d7a2b..8de9a81e9f 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -43,7 +43,7 @@ specify self upgrade specify self upgrade --tag vX.Y.Z[suffix] ``` -Bare `specify self upgrade` executes immediately, matching the no-prompt behavior of commands like `pip install -U` and `npm update`. The CLI classifies your runtime into one of: `uv tool`, `pipx`, `uvx (ephemeral)`, source checkout, or unsupported. Only `uv tool` and `pipx` are upgraded automatically; for `uv tool` installs, it runs `uv tool install specify-cli --force --from ` under the hood so pinned release tags work. On Windows, `uv` cannot replace the tool environment `specify` is running from, so for `uv tool` installs `specify self upgrade` prints that command for you to run once it exits, and exits 0 without touching anything. The other paths print path-specific guidance and exit 0 without touching anything. +Bare `specify self upgrade` executes immediately, matching the no-prompt behavior of commands like `pip install -U` and `npm update`. The CLI classifies your runtime into one of: `uv tool`, `pipx`, `uvx (ephemeral)`, source checkout, or unsupported. Only `uv tool` and `pipx` are upgraded automatically; for `uv tool` installs, it runs `uv tool install specify-cli --force --from ` under the hood so pinned release tags work. On Windows, `uv` cannot replace the tool environment `specify` is running from, so for `uv tool` installs `specify self upgrade` prints that command for you to run once it exits, and exits 0 without touching anything. Stop any other process running from that environment first, such as a `specify mcp` server, or uv can still fail partway through. The other paths print path-specific guidance and exit 0 without touching anything. Pinned tags must start with `vMAJOR.MINOR.PATCH`. Optional suffixes are limited to dev, alpha/beta/rc, and/or build metadata forms such as `v1.0.0-rc1`, `v0.8.0.dev0`, `v0.8.0+build.42`, or the combination `v1.0.0-rc1+build.42`; branch names, hash refs, `latest`, and bare versions without `v` are rejected. diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index d25ff72152..4282d976f0 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -680,6 +680,14 @@ def _runs_from_uv_tool_env_on_windows() -> bool: ) +# Another process from the same tool environment, such as an editor's +# `specify mcp` server, keeps python.exe locked after this command exits. +_UV_TOOL_MANUAL_INTRO = ( + "Stop any other specify process (such as `specify mcp`), then run this " + "once this command exits:" +) + + def _uv_tool_manual_command(target_tag: str | None) -> str: """Return the uv command a Windows uv-tool user runs after specify exits.""" return f"uv tool install specify-cli --force --from {_source_spec(target_tag)}" diff --git a/src/specify_cli/selfs/command_upgrade.py b/src/specify_cli/selfs/command_upgrade.py index 3fd9ff4f52..c950dc2852 100644 --- a/src/specify_cli/selfs/command_upgrade.py +++ b/src/specify_cli/selfs/command_upgrade.py @@ -76,6 +76,7 @@ def self_upgrade( _render_argv, _run_installer, _runs_from_uv_tool_env_on_windows, + _UV_TOOL_MANUAL_INTRO, _uv_tool_manual_command, _validate_tag, _verify_upgrade, @@ -119,7 +120,7 @@ def self_upgrade( and plan.installer_argv is not None and _runs_from_uv_tool_env_on_windows() ): - console.print("Run it yourself once this command exits:", soft_wrap=True) + console.print(_UV_TOOL_MANUAL_INTRO, soft_wrap=True) console.print(f" {_uv_tool_manual_command(plan.target_tag)}", soft_wrap=True) raise typer.Exit(0) @@ -175,7 +176,7 @@ def self_upgrade( "running from. No changes were made.", soft_wrap=True, ) - console.print("Run it yourself once this command exits:", soft_wrap=True) + console.print(_UV_TOOL_MANUAL_INTRO, soft_wrap=True) console.print(f" {_uv_tool_manual_command(target_tag)}", soft_wrap=True) raise typer.Exit(0) diff --git a/tests/specify_cli/test_version_execution.py b/tests/specify_cli/test_version_execution.py index 3042f6a811..eba34884b9 100644 --- a/tests/specify_cli/test_version_execution.py +++ b/tests/specify_cli/test_version_execution.py @@ -612,7 +612,8 @@ def test_windows_uv_tool_dry_run_previews_manual_command( "replace the tool environment specify is running from)" ) in " ".join(out.split()) assert ( - "Run it yourself once this command exits:\n" + "Stop any other specify process (such as `specify mcp`), then run " + "this once this command exits:\n" " uv tool install specify-cli --force --from " "git+https://github.com/github/spec-kit.git@v1.2.4\n" ) in out @@ -662,7 +663,8 @@ def test_windows_uv_tool_dry_run_matches_real_run_command( outputs[len(args)] = strip_ansi(result.output) manual_steps = ( - "Run it yourself once this command exits:\n" + "Stop any other specify process (such as `specify mcp`), then run " + "this once this command exits:\n" " uv tool install specify-cli --force --from " "git+https://github.com/github/spec-kit.git@v1.2.4\n" ) @@ -688,7 +690,7 @@ def test_non_windows_uv_tool_dry_run_still_previews_installer( "Command that would be executed: uv tool install specify-cli --force " "--from git+https://github.com/github/spec-kit.git@v1.2.4" ) in " ".join(out.split()) - assert "Run it yourself" not in out + assert "then run this once this command exits" not in out def test_windows_uv_tool_dry_run_without_uv_reports_missing_installer( self, uv_tool_argv0, clean_environ @@ -706,7 +708,7 @@ def test_windows_uv_tool_dry_run_without_uv_reports_missing_installer( assert result.exit_code == 0 out = strip_ansi(result.output) assert "Command that would be executed: (installer uv not found on PATH)" in out - assert "Run it yourself" not in out + assert "then run this once this command exits" not in out assert mock_run.call_count == 0 def test_help_mentions_windows_uv_tool_exception(self):