Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
f81490c
Initial plan
Copilot Apr 3, 2026
5391d77
Add specify integration subcommand (list, install, uninstall, switch)
Copilot Apr 3, 2026
a914d8d
Address review feedback: extract helper, fix return type annotation
Copilot Apr 3, 2026
8a36cd9
Potential fix for pull request finding 'Unused import'
mnriem Apr 3, 2026
f5e9752
Address review feedback: validate script type, handle --flag=value, f…
mnriem Apr 3, 2026
ffc1b66
Block --force with different integration, persist script type in init…
mnriem Apr 3, 2026
94a348a
Remove --force from integration install, ensure shared infra on insta…
mnriem Apr 3, 2026
42f3c5f
Remove redundant installed_key != key check
mnriem Apr 3, 2026
a831849
Run shared infra unconditionally, defer metadata removal in switch
mnriem Apr 3, 2026
e02a8a0
Add install rollback, graceful manifest errors, clear switch metadata
mnriem Apr 3, 2026
f3deae3
Log rollback failures instead of silently suppressing them
mnriem Apr 3, 2026
e7f14de
Handle corrupt manifest in switch, distinguish unknown vs missing man…
mnriem Apr 3, 2026
c949fe7
Clean up metadata on rollback, broaden init-options match in uninstall
mnriem Apr 3, 2026
3fdfa00
Fix recovery guidance for unreadable manifests, fix type annotations
mnriem Apr 3, 2026
2e5bc22
Allow manifest-only uninstall for unknown/removed integrations
mnriem Apr 3, 2026
947a8e5
Fail fast on corrupt integration.json, validate integration options
mnriem Apr 3, 2026
f6733ba
Validate integration.json is a dict, fail fast on missing manifest in…
mnriem Apr 3, 2026
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
Prev Previous commit
Next Next commit
Address review feedback: validate script type, handle --flag=value, f…
…ix metadata cleanup

- Add _normalize_script_type() to validate script type against SCRIPT_TYPE_CHOICES
- Handle --name=value syntax in _parse_integration_options()
- Clear init-options.json keys in no-manifest uninstall early-return path
- Clear stale metadata between switch teardown and install phases
- Add 5 tests covering the new edge cases
  • Loading branch information
mnriem committed Apr 3, 2026
commit f5e975269c99c7cdaddfd65800bc68bfa4baebea
40 changes: 37 additions & 3 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1535,14 +1535,26 @@ def _remove_integration_json(project_root: Path) -> None:
path.unlink()


def _normalize_script_type(script_type: str, source: str) -> str:
"""Normalize and validate a script type from CLI/config sources."""
normalized = script_type.strip().lower()
if normalized in SCRIPT_TYPE_CHOICES:
return normalized
console.print(
f"[red]Error:[/red] Invalid script type {script_type!r} from {source}. "
f"Expected one of: {', '.join(sorted(SCRIPT_TYPE_CHOICES.keys()))}."
)
raise typer.Exit(1)


def _resolve_script_type(project_root: Path, script_type: str | None) -> str:
"""Resolve the script type from the CLI flag or init-options.json."""
if script_type:
return script_type
return _normalize_script_type(script_type, "--script")
opts = load_init_options(project_root)
saved = opts.get("script")
if saved:
return saved
if isinstance(saved, str) and saved.strip():
return _normalize_script_type(saved, ".specify/init-options.json")
return "ps" if os.name == "nt" else "sh"


Expand Down Expand Up @@ -1673,10 +1685,17 @@ def _parse_integration_options(integration: Any, raw_options: str) -> dict[str,
while i < len(tokens):
token = tokens[i]
name = token.lstrip("-")
value: str | None = None
# Handle --name=value syntax
if "=" in name:
name, value = name.split("=", 1)
opt = declared.get(name)
if opt and opt.is_flag:
parsed[name.replace("-", "_")] = True
i += 1
elif opt and value is not None:
parsed[name.replace("-", "_")] = value
i += 1
elif opt and i + 1 < len(tokens):
parsed[name.replace("-", "_")] = tokens[i + 1]
i += 2
Comment thread
mnriem marked this conversation as resolved.
Expand Down Expand Up @@ -1740,6 +1759,13 @@ def integration_uninstall(
if not manifest_path.exists():
console.print(f"[yellow]No manifest found for integration '{key}'. Nothing to uninstall.[/yellow]")
_remove_integration_json(project_root)
Comment thread
mnriem marked this conversation as resolved.
# Clear integration-related keys from init-options.json
opts = load_init_options(project_root)
if opts.get("integration") == key:
opts.pop("integration", None)
opts.pop("ai", None)
opts.pop("ai_skills", None)
save_init_options(project_root, opts)
Comment thread
mnriem marked this conversation as resolved.
raise typer.Exit(0)

manifest = IntegrationManifest.load(key, project_root)
Comment thread
mnriem marked this conversation as resolved.
Outdated
Expand Down Expand Up @@ -1820,6 +1846,14 @@ def integration_switch(
else:
console.print(f"[dim]No manifest for '{installed_key}' — skipping uninstall phase[/dim]")

# Clear stale metadata so a failed Phase 2 doesn't reference the removed integration
_remove_integration_json(project_root)
opts = load_init_options(project_root)
Comment thread
mnriem marked this conversation as resolved.
opts.pop("integration", None)
opts.pop("ai", None)
opts.pop("ai_skills", None)
save_init_options(project_root, opts)
Comment thread
mnriem marked this conversation as resolved.
Outdated

# Phase 2: Install target integration
console.print(f"Installing integration: [cyan]{target}[/cyan]")
manifest = IntegrationManifest(
Expand Down
122 changes: 122 additions & 0 deletions tests/integrations/test_integration_subcommand.py
Original file line number Diff line number Diff line change
Expand Up @@ -394,3 +394,125 @@ def test_install_modify_uninstall_preserves_modified(self, tmp_path):
assert plan_file.read_text(encoding="utf-8") == "# user customization\n"
finally:
os.chdir(old_cwd)


# ── Edge-case fixes ─────────────────────────────────────────────────


class TestScriptTypeValidation:
def test_invalid_script_type_rejected(self, tmp_path):
"""--script with an invalid value should fail with a clear error."""
project = tmp_path / "proj"
project.mkdir()
(project / ".specify").mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "install", "claude",
"--script", "bash",
])
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
assert "Invalid script type" in result.output

def test_valid_script_types_accepted(self, tmp_path):
"""Both 'sh' and 'ps' should be accepted."""
project = tmp_path / "proj"
project.mkdir()
(project / ".specify").mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "install", "claude",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0


class TestParseIntegrationOptionsEqualsForm:
def test_equals_form_parsed(self):
"""--commands-dir=./x should be parsed the same as --commands-dir ./x."""
from specify_cli import _parse_integration_options
from specify_cli.integrations import get_integration

integration = get_integration("generic")
assert integration is not None

result_space = _parse_integration_options(integration, "--commands-dir ./mydir")
result_equals = _parse_integration_options(integration, "--commands-dir=./mydir")
assert result_space is not None
assert result_equals is not None
assert result_space["commands_dir"] == "./mydir"
assert result_equals["commands_dir"] == "./mydir"


class TestUninstallNoManifestClearsInitOptions:
def test_init_options_cleared_on_no_manifest_uninstall(self, tmp_path):
"""When no manifest exists, uninstall should still clear init-options.json."""
project = tmp_path / "proj"
project.mkdir()
(project / ".specify").mkdir()

# Write integration.json and init-options.json without a manifest
int_json = project / ".specify" / "integration.json"
int_json.write_text(json.dumps({"integration": "claude"}), encoding="utf-8")

opts_json = project / ".specify" / "init-options.json"
opts_json.write_text(json.dumps({
"integration": "claude",
"ai": "claude",
"ai_skills": True,
"script": "sh",
}), encoding="utf-8")

old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "uninstall", "claude"])
finally:
os.chdir(old_cwd)
assert result.exit_code == 0

# init-options.json should have integration keys cleared
opts = json.loads(opts_json.read_text(encoding="utf-8"))
assert "integration" not in opts
assert "ai" not in opts
assert "ai_skills" not in opts
# Non-integration keys preserved
assert opts.get("script") == "sh"


class TestSwitchClearsMetadataAfterTeardown:
def test_metadata_cleared_between_phases(self, tmp_path):
"""If install phase fails during switch, metadata should not reference the removed integration."""
project = _init_project(tmp_path, "claude")

# Verify initial state
int_json = project / ".specify" / "integration.json"
assert json.loads(int_json.read_text(encoding="utf-8"))["integration"] == "claude"

old_cwd = os.getcwd()
try:
os.chdir(project)
# Switch to copilot — should succeed and update metadata
result = runner.invoke(app, [
"integration", "switch", "copilot",
"--script", "sh",
], catch_exceptions=False)
Comment thread
mnriem marked this conversation as resolved.
finally:
os.chdir(old_cwd)
assert result.exit_code == 0

# integration.json should reference copilot, not claude
data = json.loads(int_json.read_text(encoding="utf-8"))
assert data["integration"] == "copilot"

# init-options.json should reference copilot
opts_json = project / ".specify" / "init-options.json"
opts = json.loads(opts_json.read_text(encoding="utf-8"))
assert opts.get("ai") == "copilot"
Loading