diff --git a/src/specify_cli/_utils.py b/src/specify_cli/_utils.py index beae253593..d009f25866 100644 --- a/src/specify_cli/_utils.py +++ b/src/specify_cli/_utils.py @@ -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: @@ -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) diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index 68f5bed31f..4a606ab970 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -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", ) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index bddf637cbc..a9ce528709 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -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", @@ -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 = {} diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py index 47b4240d85..4c93651c79 100644 --- a/src/specify_cli/presets.py +++ b/src/specify_cli/presets.py @@ -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( @@ -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.