diff --git a/docs/integrations/cli.md b/docs/integrations/cli.md index 0815a10282..c320a0f5e6 100644 --- a/docs/integrations/cli.md +++ b/docs/integrations/cli.md @@ -45,6 +45,11 @@ specify integration list Shows built-in and trusted installed external integrations, their setup requirements, and their multi-install safety declarations. Installed integrations and the default are marked separately. +The `CLI Required` column reports the agent CLI requirement declared by +`config.requires_cli`, not whether the integration is IDE-only. External adapters +can separately require executables through their descriptor's `requires.tools`; +installation and `specify check` validate those tool requirements even when +`CLI Required` is `no`. ## Search Available Integrations @@ -530,6 +535,11 @@ work through the IDE itself and may not require a separate CLI for setup. Workflow dispatch can have additional CLI requirements; see your integration's guide. `specify integration list` shows each integration's setup requirements. +If an integration has no command templates, `specify init` keeps the +project-directory guidance and shows how to choose an integration with +`specify integration list` and switch to it with `specify integration switch `, +rather than recommending unavailable agent commands. + ### When should I use `upgrade` vs `switch`? Use `upgrade` when you've upgraded Spec Kit and want to refresh an installed integration's managed files. Use `switch` when you want to replace the current default with another integration; if the target is already installed, `switch` behaves like `use`. diff --git a/src/specify_cli/command_check.py b/src/specify_cli/command_check.py index d595709bb3..9162bbb6ee 100644 --- a/src/specify_cli/command_check.py +++ b/src/specify_cli/command_check.py @@ -30,7 +30,7 @@ def _check_external_tools( args = integration.build_exec_args("test", project_root=project_root) tools = [args[0] if args else integration._resolve_executable()] if not tools: - tracker.skip(key, "IDE-based, no CLI check") + tracker.skip(key, "no CLI required") return False tools = list(dict.fromkeys(tools)) missing = [tool for tool in tools if shutil.which(tool) is None] @@ -90,7 +90,7 @@ def check() -> None: elif requires_cli: agent_results[agent_key] = check_tool(agent_key, tracker=tracker) else: - tracker.skip(agent_key, "IDE-based, no CLI check") + tracker.skip(agent_key, "no CLI required") agent_results[agent_key] = False tracker.add("code", "Visual Studio Code") diff --git a/src/specify_cli/command_init.py b/src/specify_cli/command_init.py index ea21fb1a54..7d1536d4ba 100644 --- a/src/specify_cli/command_init.py +++ b/src/specify_cli/command_init.py @@ -1057,6 +1057,24 @@ def init( steps_lines.append("1. You're already in the project directory!") step_num = 2 + if not resolved_integration.list_command_templates(): + steps_lines.append( + f"{step_num}. Choose an integration: [cyan]specify integration list[/cyan]" + ) + steps_lines.append( + f"{step_num + 1}. Switch this project to it: [cyan]specify integration switch [/cyan]" + ) + console.print() + console.print( + Panel( + "\n".join(steps_lines), + title="Next Steps", + border_style="cyan", + padding=(1, 2), + ) + ) + return + _is_skills_integration = resolved_integration.is_skills_mode( integration_parsed_options or None, project_root=project_path ) diff --git a/src/specify_cli/integrations/command_list.py b/src/specify_cli/integrations/command_list.py index 540de82d35..30f9774401 100644 --- a/src/specify_cli/integrations/command_list.py +++ b/src/specify_cli/integrations/command_list.py @@ -102,7 +102,7 @@ def integration_list( status = "[green]installed[/green]" else: status = "" - cli_req = "yes" if requires_cli else "no (IDE)" + cli_req = "yes" if requires_cli else "no" safe = "yes" if getattr(integration, "multi_install_safe", False) else "no" table.add_row(key, escape(str(name)), status, cli_req, safe) diff --git a/tests/specify_cli/integrations/test_command_list.py b/tests/specify_cli/integrations/test_command_list.py index 5d11931a3d..ad169feef9 100644 --- a/tests/specify_cli/integrations/test_command_list.py +++ b/tests/specify_cli/integrations/test_command_list.py @@ -24,6 +24,20 @@ ) class TestIntegrationList: + def test_cli_requirement_does_not_imply_ide_classification(self, tmp_path, monkeypatch): + from types import SimpleNamespace + from specify_cli.integrations import INTEGRATION_REGISTRY + + project = _init_project(tmp_path, "copilot") + monkeypatch.setitem(INTEGRATION_REGISTRY, "artifact-free", SimpleNamespace( + config={"name": "Artifact-free", "requires_cli": False}, + )) + result = _run_in_project(project, ["integration", "list"]) + assert result.exit_code == 0, result.output + assert _integration_list_row_cells(result.output, "artifact-free")[-2] == "no" + assert _integration_list_row_cells(result.output, "claude")[-2] == "yes" + assert "(IDE)" not in result.output + def test_list_requires_speckit_project(self, tmp_path): old_cwd = os.getcwd() try: diff --git a/tests/specify_cli/integrations/test_installed_adapters.py b/tests/specify_cli/integrations/test_installed_adapters.py index 6f8fbadd92..0c1bb0a60a 100644 --- a/tests/specify_cli/integrations/test_installed_adapters.py +++ b/tests/specify_cli/integrations/test_installed_adapters.py @@ -3803,7 +3803,8 @@ def which(name, *args, **kwargs): assert KEY not in probes assert "optional-helper" not in probes assert "Sample Agent" in result.output - assert "IDE-based, no CLI check" in result.output + assert "no CLI required" in result.output + assert "IDE-based" not in result.output @pytest.mark.parametrize("error_type", ["ValueError", "OSError", "NotImplementedError"]) diff --git a/tests/specify_cli/test_command_check.py b/tests/specify_cli/test_command_check.py index 2a49b64ccd..7026db27f9 100644 --- a/tests/specify_cli/test_command_check.py +++ b/tests/specify_cli/test_command_check.py @@ -2,6 +2,7 @@ from unittest.mock import patch +import pytest from typer.testing import CliRunner from specify_cli import app @@ -11,6 +12,36 @@ runner = CliRunner() +@pytest.mark.parametrize("external", [False, True]) +def test_no_cli_requirement_is_not_classified_as_ide(tmp_path, monkeypatch, external): + from specify_cli.command_check import _check_external_tools + from specify_cli._console import StepTracker + + if external: + tracker = StepTracker("Tools") + tracker.add("generic", "Generic") + with patch("specify_cli.command_check.shutil.which") as which: + assert not _check_external_tools( + tmp_path, "generic", {"requires": {}}, False, tracker, + ) + which.assert_not_called() + from rich.console import Console + console = Console(width=120) + with console.capture() as capture: + console.print(tracker.render()) + output = capture.get() + else: + monkeypatch.chdir(tmp_path) + with patch("specify_cli.check_tool", return_value=True) as check_tool: + result = runner.invoke(app, ["check"]) + assert result.exit_code == 0, result.output + assert "none" not in [call.args[0] for call in check_tool.call_args_list] + output = strip_ansi(result.output) + assert "None (shared scaffolding only) (no CLI required)" in output + assert "no CLI required" in output + assert "IDE-based" not in output + + class TestCheckTip: """`specify check` should point users to the existing version check.""" diff --git a/tests/specify_cli/test_command_init.py b/tests/specify_cli/test_command_init.py index 33ad48aa0a..e353445870 100644 --- a/tests/specify_cli/test_command_init.py +++ b/tests/specify_cli/test_command_init.py @@ -209,3 +209,66 @@ def test_shell_quote_arg_is_host_appropriate(): assert quoted == '"my project"' else: assert quoted == "'my project'" + + +@pytest.mark.parametrize("here", [False, True]) +def test_output_free_init_recommends_switching_integrations(tmp_path, monkeypatch, here): + from specify_cli.integrations.generic import GenericIntegration + + monkeypatch.setattr(GenericIntegration, "list_command_templates", lambda self: []) + monkeypatch.chdir(tmp_path) + result = CliRunner().invoke( + app, + [ + "init", *(["--here"] if here else ["proj"]), + "--integration", "generic", + "--integration-options", "--commands-dir .agent/commands", + "--ignore-agent-tools", + ], + ) + assert result.exit_code == 0, result.output + output = _strip(result.output) + assert "Next Steps" in output + if here: + assert "You're already in the project directory!" in output + else: + assert "cd proj" in output + assert "specify integration switch " in output + assert "Switch this project to it:" in output + assert "Add it to this project:" not in output + assert "specify integration list" in output + assert "Start using" not in output + assert "speckit." not in output + assert "Enhancement Commands" not in output + assert "Enhancement Skills" not in output + project = tmp_path if here else tmp_path / "proj" + assert not (project / ".agent" / "commands").exists() + + +def test_rendering_init_preserves_command_guidance(tmp_path): + result = _init(tmp_path, "proj") + assert result.exit_code == 0, result.output + output = _strip(result.output) + assert "Start using slash commands" in output + for command in ("constitution", "specify", "plan", "tasks", "implement", "converge", + "clarify", "analyze", "checklist"): + assert f"/speckit.{command}" in output + assert (tmp_path / "proj" / ".agent" / "commands" / f"speckit.{command}.md").is_file() + assert "Enhancement Commands" in output + assert "specify integration switch " not in output + + +def test_rendering_init_preserves_skill_guidance(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = CliRunner().invoke( + app, ["init", "proj", "--integration", "copilot", "--ignore-agent-tools"], + ) + assert result.exit_code == 0, result.output + output = _strip(result.output) + assert "Start using skills" in output + assert "Enhancement Skills" in output + assert "specify integration switch " not in output + for command in ("constitution", "specify", "plan", "tasks", "implement", "converge", + "clarify", "analyze", "checklist"): + assert f"/speckit-{command}" in output + assert (tmp_path / "proj" / ".github" / "skills" / f"speckit-{command}" / "SKILL.md").is_file()