@@ -103,6 +103,65 @@ def _init_dotted_kiro_project(tmp_path, monkeypatch, *commands):
103103 return project
104104
105105
106+ @pytest .fixture
107+ def offline_adapter (tmp_path , monkeypatch ):
108+ import io
109+ import zipfile
110+ import yaml
111+ from specify_cli .authentication import http
112+ from specify_cli .integrations import IntegrationCatalog , installer
113+
114+ home = tmp_path / "home"
115+ home .mkdir ()
116+ monkeypatch .setenv ("HOME" , str (home ))
117+ monkeypatch .setenv ("USERPROFILE" , str (home ))
118+ installer .unload_installed_integrations ()
119+
120+ def install (project , * , operation = "install" , extra_code = "" ):
121+ descriptor = {
122+ "schema_version" : "1.0" ,
123+ "integration" : {
124+ "id" : "sample-agent" , "name" : "Sample Agent" , "version" : "1.0.0" ,
125+ "description" : "Offline test adapter" ,
126+ },
127+ "requires" : {"speckit_version" : ">=0.6.0" },
128+ }
129+ code = '''from specify_cli.integrations.base import MarkdownIntegration
130+ class SampleIntegration(MarkdownIntegration):
131+ key = "sample-agent"
132+ config = {"name": "Sample Agent", "folder": ".sample-agent", "commands_subdir": "commands", "requires_cli": False, "install_url": "https://example.com"}
133+ registrar_config = {"dir": ".sample-agent/commands", "format": "markdown", "args": "$ARGUMENTS", "extension": ".md"}
134+ multi_install_safe = True
135+ ''' + extra_code
136+ archive = io .BytesIO ()
137+ with zipfile .ZipFile (archive , "w" ) as package :
138+ package .writestr ("integration.yml" , yaml .safe_dump (descriptor ))
139+ package .writestr ("__init__.py" , code )
140+ data = archive .getvalue ()
141+ url = "https://example.com/sample-agent.zip"
142+ info = {
143+ ** descriptor ["integration" ], "download_url" : url ,
144+ "sha256" : hashlib .sha256 (data ).hexdigest (),
145+ "_catalog_name" : "offline" , "_install_allowed" : True ,
146+ }
147+
148+ class Response (io .BytesIO ):
149+ headers = {"Content-Type" : "application/zip" }
150+
151+ def geturl (self ):
152+ return url
153+
154+ with monkeypatch .context () as patch :
155+ patch .setattr (IntegrationCatalog , "get_integration_info" , lambda * args , ** kwargs : info )
156+ patch .setattr (http , "open_url" , lambda * args , ** kwargs : Response (data ))
157+ return _run_in_project (project , [
158+ "integration" , operation , "sample-agent" , "--trust-integration" , "--script" , "sh" ,
159+ ])
160+
161+ yield install
162+ installer .unload_installed_integrations ()
163+
164+
106165class TestIntegrationUpgradeDetailed :
107166 def test_upgrade_invalid_manifest_reports_cli_error (self , tmp_path ):
108167 project = _init_project (tmp_path , "claude" )
@@ -402,6 +461,72 @@ def test_upgrade_replaces_dotted_kiro_prompts(self, tmp_path, monkeypatch):
402461 assert (prompts / "speckit-plan.md" ).is_file ()
403462 assert (prompts / "speckit-git-commit.md" ).is_file ()
404463
464+ def test_failed_external_switch_restores_legacy_prompt (self , tmp_path , monkeypatch , offline_adapter ):
465+ from specify_cli .integrations import installer
466+
467+ source = _write_command_extension (tmp_path )
468+ project = _init_dotted_kiro_project (tmp_path , monkeypatch , ["extension" , "add" , "--dev" , str (source )])
469+ legacy = project / ".kiro/prompts/speckit.audit.run.md"
470+ before = legacy .read_bytes ()
471+ registry = project / ".specify/extensions/.registry"
472+ before_registry = registry .read_bytes ()
473+
474+ def fail_commit (* args , ** kwargs ):
475+ assert not os .path .lexists (legacy )
476+ raise OSError ("Injected external package commit failure" )
477+
478+ monkeypatch .setattr (installer , "write_records" , fail_commit )
479+ result = offline_adapter (project , operation = "switch" )
480+ assert result .exit_code == 1 , result .output
481+ assert "Injected external package commit failure" in result .output
482+ assert registry .read_bytes () == before_registry
483+ assert legacy .read_bytes () == before
484+
485+ @pytest .mark .parametrize ("agent" , ["kiro-cli" , "qodercli" ])
486+ def test_remove_after_generic_settings_damage (self , tmp_path , agent ):
487+ project = _init_project (tmp_path , agent )
488+ source = _write_command_extension (tmp_path )
489+ assert _run_in_project (project , ["extension" , "add" , "--dev" , str (source )]).exit_code == 0
490+ command = project / (".kiro/prompts/speckit-audit-run.md" if agent == "kiro-cli" else
491+ ".qoder/skills/speckit-audit-run/SKILL.md" )
492+ assert command .is_file ()
493+ result = _run_in_project (project , [
494+ "integration" , "install" , "generic" , "--integration-options=--commands-dir .custom/commands" , "--force" ,
495+ ])
496+ assert result .exit_code == 0 , result .output
497+ assert _run_in_project (project , ["integration" , "use" , "generic" ]).exit_code == 0
498+ state = project / ".specify/integration.json"
499+ data = json .loads (state .read_bytes ())
500+ data ["integration_settings" ]["generic" ].update (parsed_options = {}, raw_options = "" )
501+ state .write_bytes (json .dumps (data ).encode ())
502+
503+ result = _run_in_project (project , ["extension" , "remove" , "audit" , "--force" ])
504+ assert result .exit_code == 0 , result .output
505+ assert not os .path .lexists (command )
506+ assert not (project / ".specify/extensions/audit" ).exists ()
507+
508+ def test_external_adapter_removal_preserves_unowned_command (self , tmp_path , offline_adapter ):
509+ from specify_cli .integrations .installer import unload_installed_integrations
510+
511+ project = _init_project (tmp_path , "kiro-cli" )
512+ result = offline_adapter (project , extra_code = (
513+ ' legacy_flat_command_dir = ".sample-agent/legacy"\n '
514+ ' legacy_flat_command_extension = ".md"\n '
515+ ))
516+ assert result .exit_code == 0 , result .output
517+ assert _run_in_project (project , ["integration" , "use" , "sample-agent" ]).exit_code == 0
518+ source = _write_command_extension (tmp_path )
519+ assert _run_in_project (project , ["extension" , "add" , "--dev" , str (source )]).exit_code == 0
520+ command = project / ".sample-agent/commands/speckit.audit.run.md"
521+ assert command .is_file ()
522+ command .unlink ()
523+ command .write_bytes (b"USER OWNED FILE\n " )
524+ unload_installed_integrations ()
525+
526+ result = _run_in_project (project , ["extension" , "remove" , "audit" , "--force" ])
527+ assert result .exit_code == 0 , result .output
528+ assert command .read_bytes () == b"USER OWNED FILE\n "
529+
405530 @pytest .mark .parametrize ("empty_manifest" , [False , True ])
406531 def test_upgrade_refuses_kiro_prompt_rename_while_presets_are_installed (
407532 self , tmp_path , monkeypatch , empty_manifest
0 commit comments