Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/integrations/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 <key>`,
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`.
Expand Down
4 changes: 2 additions & 2 deletions src/specify_cli/command_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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")
Expand Down
18 changes: 18 additions & 0 deletions src/specify_cli/command_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <key>[/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
)
Expand Down
2 changes: 1 addition & 1 deletion src/specify_cli/integrations/command_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
14 changes: 14 additions & 0 deletions tests/specify_cli/integrations/test_command_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion tests/specify_cli/integrations/test_installed_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
31 changes: 31 additions & 0 deletions tests/specify_cli/test_command_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from unittest.mock import patch

import pytest
from typer.testing import CliRunner

from specify_cli import app
Expand All @@ -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."""

Expand Down
63 changes: 63 additions & 0 deletions tests/specify_cli/test_command_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <key>" 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 <key>" 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 <key>" 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()
Loading