Skip to content
Draft
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
22 changes: 17 additions & 5 deletions src/specify_cli/integrations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ def install_scripts(
self,
project_root: Path,
manifest: IntegrationManifest,
script_type: str | None = None,
) -> list[Path]:
"""Copy integration-specific scripts into the project.

Expand All @@ -239,19 +240,29 @@ def install_scripts(
scripts are made executable. All copied files are recorded in
*manifest*.

When *script_type* is ``"sh"`` only ``.sh`` files are copied; when
it is ``"ps"`` only ``.ps1`` files are copied. Passing ``None``
(or any other value) copies every file — preserving the previous
behaviour for callers that have not been updated yet.

Returns the list of files created.
"""
scripts_src = self.integration_scripts_dir()
if not scripts_src:
return []

_EXTENSION_FILTER: dict[str, str] = {"sh": ".sh", "ps": ".ps1"}
only_ext = _EXTENSION_FILTER.get(script_type) if script_type else None

created: list[Path] = []
scripts_dest = project_root / ".specify" / "integrations" / self.key / "scripts"
scripts_dest.mkdir(parents=True, exist_ok=True)

for src_script in sorted(scripts_src.iterdir()):
if not src_script.is_file():
continue
if only_ext is not None and src_script.suffix != only_ext:
continue
dst_script = scripts_dest / src_script.name
shutil.copy2(src_script, dst_script)
if dst_script.suffix == ".sh":
Expand Down Expand Up @@ -459,8 +470,9 @@ class MarkdownIntegration(IntegrationBase):
(and optionally ``context_file``). Everything else is inherited.

``setup()`` processes command templates (replacing ``{SCRIPT}``,
``{ARGS}``, ``__AGENT__``, rewriting paths) and installs
integration-specific scripts (``update-context.sh`` / ``.ps1``).
``{ARGS}``, ``__AGENT__``, rewriting paths) and installs the
integration-specific script matching the selected *script_type*
(e.g. ``update-context.sh`` for ``sh``, ``update-context.ps1`` for ``ps``).
"""

def setup(
Expand Down Expand Up @@ -504,7 +516,7 @@ def setup(
)
created.append(dst_file)

created.extend(self.install_scripts(project_root, manifest))
created.extend(self.install_scripts(project_root, manifest, script_type))
return created


Expand Down Expand Up @@ -680,7 +692,7 @@ def setup(
)
created.append(dst_file)

created.extend(self.install_scripts(project_root, manifest))
created.extend(self.install_scripts(project_root, manifest, script_type))
return created


Expand Down Expand Up @@ -832,5 +844,5 @@ def _quote(v: str) -> str:
)
created.append(dst)

created.extend(self.install_scripts(project_root, manifest))
created.extend(self.install_scripts(project_root, manifest, script_type))
return created
2 changes: 1 addition & 1 deletion src/specify_cli/integrations/forge/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ def setup(
created.append(dst_file)

# Install integration-specific update-context scripts
created.extend(self.install_scripts(project_root, manifest))
created.extend(self.install_scripts(project_root, manifest, script_type))

return created

Expand Down
23 changes: 17 additions & 6 deletions tests/integrations/test_integration_base_markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,18 +139,27 @@ def test_modified_file_survives_uninstall(self, tmp_path):
def test_setup_installs_update_context_scripts(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
i.setup(tmp_path, m, script_type="sh")
scripts_dir = tmp_path / ".specify" / "integrations" / self.KEY / "scripts"
assert scripts_dir.is_dir(), f"Scripts directory not created for {self.KEY}"
assert (scripts_dir / "update-context.sh").exists()
assert not (scripts_dir / "update-context.ps1").exists()

def test_setup_installs_ps1_script_when_ps_type(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m, script_type="ps")
scripts_dir = tmp_path / ".specify" / "integrations" / self.KEY / "scripts"
assert scripts_dir.is_dir(), f"Scripts directory not created for {self.KEY}"
assert (scripts_dir / "update-context.ps1").exists()
assert not (scripts_dir / "update-context.sh").exists()

def test_scripts_tracked_in_manifest(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
i.setup(tmp_path, m, script_type="sh")
script_rels = [k for k in m.files if "update-context" in k]
assert len(script_rels) >= 2
assert len(script_rels) >= 1

def test_sh_script_is_executable(self, tmp_path):
i = get_integration(self.KEY)
Expand Down Expand Up @@ -222,9 +231,11 @@ def _expected_files(self, script_variant: str) -> list[str]:
for stem in self.COMMAND_STEMS:
files.append(f"{cmd_dir}/speckit.{stem}.md")

# Integration scripts
files.append(f".specify/integrations/{self.KEY}/scripts/update-context.ps1")
files.append(f".specify/integrations/{self.KEY}/scripts/update-context.sh")
# Integration scripts — only the matching variant is installed
if script_variant == "sh":
files.append(f".specify/integrations/{self.KEY}/scripts/update-context.sh")
else:
files.append(f".specify/integrations/{self.KEY}/scripts/update-context.ps1")

# Framework files
files.append(f".specify/integration.json")
Expand Down
22 changes: 17 additions & 5 deletions tests/integrations/test_integration_base_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,18 +224,27 @@ def test_pre_existing_skills_not_removed(self, tmp_path):
def test_setup_installs_update_context_scripts(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
i.setup(tmp_path, m, script_type="sh")
scripts_dir = tmp_path / ".specify" / "integrations" / self.KEY / "scripts"
assert scripts_dir.is_dir(), f"Scripts directory not created for {self.KEY}"
assert (scripts_dir / "update-context.sh").exists()
assert not (scripts_dir / "update-context.ps1").exists()

def test_setup_installs_ps1_script_when_ps_type(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m, script_type="ps")
scripts_dir = tmp_path / ".specify" / "integrations" / self.KEY / "scripts"
assert scripts_dir.is_dir(), f"Scripts directory not created for {self.KEY}"
assert (scripts_dir / "update-context.ps1").exists()
assert not (scripts_dir / "update-context.sh").exists()

def test_scripts_tracked_in_manifest(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
i.setup(tmp_path, m, script_type="sh")
script_rels = [k for k in m.files if "update-context" in k]
assert len(script_rels) >= 2
assert len(script_rels) >= 1

def test_sh_script_is_executable(self, tmp_path):
i = get_integration(self.KEY)
Expand Down Expand Up @@ -318,11 +327,14 @@ def _expected_files(self, script_variant: str) -> list[str]:
".specify/init-options.json",
".specify/integration.json",
f".specify/integrations/{self.KEY}.manifest.json",
f".specify/integrations/{self.KEY}/scripts/update-context.ps1",
f".specify/integrations/{self.KEY}/scripts/update-context.sh",
".specify/integrations/speckit.manifest.json",
".specify/memory/constitution.md",
]
# Only the matching script variant is installed
if script_variant == "sh":
files.append(f".specify/integrations/{self.KEY}/scripts/update-context.sh")
else:
files.append(f".specify/integrations/{self.KEY}/scripts/update-context.ps1")
# Script variant
if script_variant == "sh":
files += [
Expand Down
23 changes: 17 additions & 6 deletions tests/integrations/test_integration_base_toml.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,18 +339,27 @@ def test_modified_file_survives_uninstall(self, tmp_path):
def test_setup_installs_update_context_scripts(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
i.setup(tmp_path, m, script_type="sh")
scripts_dir = tmp_path / ".specify" / "integrations" / self.KEY / "scripts"
assert scripts_dir.is_dir(), f"Scripts directory not created for {self.KEY}"
assert (scripts_dir / "update-context.sh").exists()
assert not (scripts_dir / "update-context.ps1").exists()

def test_setup_installs_ps1_script_when_ps_type(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m, script_type="ps")
scripts_dir = tmp_path / ".specify" / "integrations" / self.KEY / "scripts"
assert scripts_dir.is_dir(), f"Scripts directory not created for {self.KEY}"
assert (scripts_dir / "update-context.ps1").exists()
assert not (scripts_dir / "update-context.sh").exists()

def test_scripts_tracked_in_manifest(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
i.setup(tmp_path, m, script_type="sh")
script_rels = [k for k in m.files if "update-context" in k]
assert len(script_rels) >= 2
assert len(script_rels) >= 1

def test_sh_script_is_executable(self, tmp_path):
i = get_integration(self.KEY)
Expand Down Expand Up @@ -422,9 +431,11 @@ def _expected_files(self, script_variant: str) -> list[str]:
for stem in self.COMMAND_STEMS:
files.append(f"{cmd_dir}/speckit.{stem}.toml")

# Integration scripts
files.append(f".specify/integrations/{self.KEY}/scripts/update-context.ps1")
files.append(f".specify/integrations/{self.KEY}/scripts/update-context.sh")
# Integration scripts — only the matching variant is installed
if script_variant == "sh":
files.append(f".specify/integrations/{self.KEY}/scripts/update-context.sh")
else:
files.append(f".specify/integrations/{self.KEY}/scripts/update-context.ps1")

# Framework files
files.append(f".specify/integration.json")
Expand Down
4 changes: 2 additions & 2 deletions tests/integrations/test_integration_claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,11 @@ def test_setup_installs_update_context_scripts(self, tmp_path):
scripts_dir = tmp_path / ".specify" / "integrations" / "claude" / "scripts"
assert scripts_dir.is_dir()
assert (scripts_dir / "update-context.sh").exists()
assert (scripts_dir / "update-context.ps1").exists()
assert not (scripts_dir / "update-context.ps1").exists()

tracked = {path.resolve().relative_to(tmp_path.resolve()).as_posix() for path in created}
assert ".specify/integrations/claude/scripts/update-context.sh" in tracked
assert ".specify/integrations/claude/scripts/update-context.ps1" in tracked
assert ".specify/integrations/claude/scripts/update-context.ps1" not in tracked

def test_ai_flag_auto_promotes_and_enables_skills(self, tmp_path):
from typer.testing import CliRunner
Expand Down
6 changes: 3 additions & 3 deletions tests/integrations/test_integration_forge.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,15 +77,15 @@ def test_setup_installs_update_scripts(self, tmp_path):
from specify_cli.integrations.forge import ForgeIntegration
forge = ForgeIntegration()
m = IntegrationManifest("forge", tmp_path)
created = forge.setup(tmp_path, m)
created = forge.setup(tmp_path, m, script_type="sh")
script_files = [f for f in created if "scripts" in f.parts]
assert len(script_files) > 0
sh_script = tmp_path / ".specify" / "integrations" / "forge" / "scripts" / "update-context.sh"
ps_script = tmp_path / ".specify" / "integrations" / "forge" / "scripts" / "update-context.ps1"
assert sh_script in created
assert ps_script in created
assert ps_script not in created
assert sh_script.exists()
assert ps_script.exists()
assert not ps_script.exists()

def test_all_created_files_tracked_in_manifest(self, tmp_path):
from specify_cli.integrations.forge import ForgeIntegration
Expand Down
Loading