Skip to content

Commit fa3405a

Browse files
committed
fix: tighten integration switch semantics
1 parent 4c9105b commit fa3405a

5 files changed

Lines changed: 107 additions & 9 deletions

File tree

docs/reference/integrations.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,9 @@ specify integration switch <key>
8989
| ------------------------ | ------------------------------------------------------------------------ |
9090
| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) |
9191
| `--force` | Force removal of modified files during uninstall; when the target is already installed, overwrite managed shared templates while changing the default |
92-
| `--integration-options` | Options for the target integration |
92+
| `--integration-options` | Options for the target integration when it is not already installed |
9393

94-
If the target integration is not already installed, equivalent to running `uninstall` followed by `install` in a single step. In this mode, `--force` controls whether modified files from the removed integration are deleted. If the target integration is already installed, `switch` only changes the default integration, like `use`; in this mode, `--force` controls whether managed shared templates are overwritten while the default changes.
94+
If the target integration is not already installed, equivalent to running `uninstall` followed by `install` in a single step. In this mode, `--force` controls whether modified files from the removed integration are deleted. If the target integration is already installed, `switch` only changes the default integration, like `use`; in this mode, `--force` controls whether managed shared templates are overwritten while the default changes. `--integration-options` is rejected for already-installed targets because changing integration options requires reinstalling managed files; run `upgrade <key> --integration-options ...` first, then `use <key>`.
9595

9696
## Use an Installed Integration
9797

src/specify_cli/__init__.py

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2158,8 +2158,8 @@ def integration_install(
21582158
if key in installed_keys:
21592159
console.print(f"[yellow]Integration '{key}' is already installed.[/yellow]")
21602160
console.print(
2161-
"Run [cyan]specify integration upgrade[/cyan] to reinstall managed files, "
2162-
"or [cyan]specify integration uninstall[/cyan] first."
2161+
f"Run [cyan]specify integration upgrade {key}[/cyan] to reinstall managed files, "
2162+
f"or [cyan]specify integration uninstall {key}[/cyan] first."
21632163
)
21642164
raise typer.Exit(0)
21652165

@@ -2528,10 +2528,49 @@ def integration_switch(
25282528
installed_key = _default_integration_key(current)
25292529

25302530
if installed_key == target:
2531-
console.print(f"[yellow]Integration '{target}' is already installed. Nothing to switch.[/yellow]")
2531+
if integration_options is not None:
2532+
console.print(
2533+
"[red]Error:[/red] --integration-options cannot be used when switching "
2534+
"to an already installed integration."
2535+
)
2536+
console.print(
2537+
f"Run [cyan]specify integration upgrade {target} --integration-options ...[/cyan] "
2538+
"to update managed files/options."
2539+
)
2540+
raise typer.Exit(1)
2541+
if force:
2542+
raw_options, parsed_options = _resolve_integration_options(
2543+
target_integration, current, target, None
2544+
)
2545+
_set_default_integration(
2546+
project_root,
2547+
current,
2548+
target,
2549+
target_integration,
2550+
installed_keys,
2551+
raw_options=raw_options,
2552+
parsed_options=parsed_options,
2553+
refresh_templates_force=True,
2554+
)
2555+
console.print(
2556+
f"\n[green]✓[/green] Default integration remains [bold]{target}[/bold]; "
2557+
"managed shared templates refreshed."
2558+
)
2559+
raise typer.Exit(0)
2560+
console.print(f"[yellow]Integration '{target}' is already the default integration. Nothing to switch.[/yellow]")
25322561
raise typer.Exit(0)
25332562

25342563
if target in installed_keys:
2564+
if integration_options is not None:
2565+
console.print(
2566+
"[red]Error:[/red] --integration-options cannot be used when switching "
2567+
"to an already installed integration."
2568+
)
2569+
console.print(
2570+
f"Run [cyan]specify integration upgrade {target} --integration-options ...[/cyan] "
2571+
f"to update managed files/options, then [cyan]specify integration use {target}[/cyan]."
2572+
)
2573+
raise typer.Exit(1)
25352574
raw_options, parsed_options = _resolve_integration_options(
25362575
target_integration, current, target, None
25372576
)

src/specify_cli/integration_state.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,8 @@ def normalize_integration_settings(settings: Any) -> dict[str, dict[str, Any]]:
5757
clean["parsed_options"] = parsed_options
5858

5959
invoke_separator = value.get("invoke_separator")
60-
if isinstance(invoke_separator, str) and invoke_separator:
61-
clean["invoke_separator"] = invoke_separator
60+
if isinstance(invoke_separator, str) and invoke_separator.strip():
61+
clean["invoke_separator"] = invoke_separator.strip()
6262

6363
if clean:
6464
normalized[key.strip()] = clean

tests/integrations/test_integration_state.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from specify_cli.integration_state import INTEGRATION_JSON
66
from specify_cli.integration_state import (
77
default_integration_key,
8+
integration_setting,
89
normalize_integration_state,
910
write_integration_json,
1011
)
@@ -42,6 +43,21 @@ def test_default_integration_key_strips_raw_state_values():
4243
assert default_integration_key({"integration": " codex "}) == "codex"
4344

4445

46+
def test_integration_settings_strip_invoke_separator():
47+
setting = integration_setting(
48+
{
49+
"integration_settings": {
50+
"claude": {
51+
"invoke_separator": " - ",
52+
}
53+
}
54+
},
55+
"claude",
56+
)
57+
58+
assert setting["invoke_separator"] == "-"
59+
60+
4561
def test_write_integration_json_strips_integration_key(tmp_path):
4662
write_integration_json(
4763
tmp_path,

tests/integrations/test_integration_subcommand.py

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,9 @@ def test_install_already_installed(self, tmp_path):
127127
os.chdir(old_cwd)
128128
assert result.exit_code == 0
129129
assert "already installed" in result.output
130-
assert "uninstall" in result.output
130+
normalized = " ".join(result.output.split())
131+
assert "specify integration upgrade copilot" in normalized
132+
assert "specify integration uninstall copilot" in normalized
131133

132134
def test_install_different_when_one_exists(self, tmp_path):
133135
project = _init_project(tmp_path, "copilot")
@@ -569,7 +571,48 @@ def test_switch_same_noop(self, tmp_path):
569571
finally:
570572
os.chdir(old_cwd)
571573
assert result.exit_code == 0
572-
assert "already installed" in result.output
574+
assert "already the default integration" in result.output
575+
576+
def test_switch_same_force_refreshes_shared_templates(self, tmp_path):
577+
project = _init_project(tmp_path, "claude")
578+
template = project / ".specify" / "templates" / "plan-template.md"
579+
template.write_text("# custom shared template\n", encoding="utf-8")
580+
581+
old_cwd = os.getcwd()
582+
try:
583+
os.chdir(project)
584+
result = runner.invoke(app, [
585+
"integration", "switch", "claude",
586+
"--force",
587+
], catch_exceptions=False)
588+
finally:
589+
os.chdir(old_cwd)
590+
assert result.exit_code == 0, result.output
591+
assert "managed shared templates refreshed" in result.output
592+
assert "/speckit-plan" in template.read_text(encoding="utf-8")
593+
594+
def test_switch_installed_target_rejects_integration_options(self, tmp_path):
595+
project = _init_project(tmp_path, "claude")
596+
old_cwd = os.getcwd()
597+
try:
598+
os.chdir(project)
599+
install = runner.invoke(app, [
600+
"integration", "install", "codex",
601+
"--script", "sh",
602+
], catch_exceptions=False)
603+
assert install.exit_code == 0, install.output
604+
605+
result = runner.invoke(app, [
606+
"integration", "switch", "codex",
607+
"--integration-options", "--bogus",
608+
])
609+
finally:
610+
os.chdir(old_cwd)
611+
assert result.exit_code != 0
612+
assert "--integration-options cannot be used" in result.output
613+
614+
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
615+
assert data["default_integration"] == "claude"
573616

574617
def test_switch_between_integrations(self, tmp_path):
575618
project = _init_project(tmp_path, "claude")

0 commit comments

Comments
 (0)