Skip to content
Open
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: 20 additions & 2 deletions src/specify_cli/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,13 +183,13 @@ def atomic_write_json(target_file: Path, payload: dict[str, Any]) -> None:
else:
log("Skipped merge (preserved existing settings)", "yellow")
else:
shutil.copy2(sub_item, dest_file)
shutil.copyfile(sub_item, dest_file)
log("Copied (no existing settings.json):", "blue")

except Exception as e:
log(f"Warning: Could not merge settings: {e}", "yellow")
if not dest_file.exists():
shutil.copy2(sub_item, dest_file)
shutil.copyfile(sub_item, dest_file)


def merge_json_files(existing_path: Path, new_content: Any, verbose: bool = False) -> dict[str, Any] | None:
Expand Down Expand Up @@ -272,6 +272,24 @@ def deep_merge_polite(base: dict[str, Any], update: dict[str, Any]) -> dict[str,
return merged


def ensure_writable_tree(path: Path) -> None:
"""Add owner write+execute to every directory under path.

shutil.copytree always calls copystat() on directories, propagating
read-only bits from any read-only source. Call this after copytree to
guarantee destination directories accept writes regardless of source
permissions.
"""
if os.name == "nt":
return
for dirpath, _, _ in os.walk(path):
dp = Path(dirpath)
try:
dp.chmod(dp.stat().st_mode | 0o300)
except OSError:
pass


def _display_project_path(project_root: Path, path: str | Path) -> str:
"""Return a stable POSIX-style display path for paths under a project."""
path_obj = Path(path)
Expand Down
2 changes: 1 addition & 1 deletion src/specify_cli/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,7 @@ def init(
import shutil as _shutil
dest_wf = project_path / ".specify" / "workflows" / "speckit"
dest_wf.mkdir(parents=True, exist_ok=True)
_shutil.copy2(
_shutil.copyfile(
bundled_wf / "workflow.yml",
dest_wf / "workflow.yml",
)
Expand Down
4 changes: 3 additions & 1 deletion src/specify_cli/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

from .catalogs import CatalogEntry as BaseCatalogEntry, CatalogStackBase
from ._init_options import is_ai_skills_enabled
from ._utils import ensure_writable_tree

_FALLBACK_CORE_COMMAND_NAMES = frozenset({
"analyze",
Expand Down Expand Up @@ -1280,7 +1281,8 @@ def install_from_directory(
shutil.rmtree(dest_dir)

ignore_fn = self._load_extensionignore(source_dir)
shutil.copytree(source_dir, dest_dir, ignore=ignore_fn)
shutil.copytree(source_dir, dest_dir, ignore=ignore_fn, copy_function=shutil.copyfile)
ensure_writable_tree(dest_dir)

# Register commands with AI agents
registered_commands = {}
Expand Down
4 changes: 3 additions & 1 deletion src/specify_cli/presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from .extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority
from .integrations.base import IntegrationBase
from ._init_options import is_ai_skills_enabled
from ._utils import ensure_writable_tree


def _substitute_core_template(
Expand Down Expand Up @@ -1534,7 +1535,8 @@ def install_from_directory(
if dest_dir.exists():
shutil.rmtree(dest_dir)

shutil.copytree(source_dir, dest_dir)
shutil.copytree(source_dir, dest_dir, copy_function=shutil.copyfile)
ensure_writable_tree(dest_dir)

# Pre-register the preset so that composition resolution can see it
# in the priority stack when resolving composed command content.
Expand Down