What happens
Installing an extension that ships a shell script and then running that script the way
the extension's docs / CI instructions say to run it fails:
$ specify extension add <id> --from https://…/ext.zip
✓ Extension installed successfully!
$ .specify/extensions/<id>/scripts/bash/gate.sh check
bash: .../gate.sh: Permission denied
The installed script is -rw-r--r-- — the execute bit is gone.
Why
Extension archives are unpacked with zipfile.extractall (extensions/__init__.py),
and --dev installs copy the tree; neither restores a stripped Unix mode. So a bundled
*.sh lands non-executable.
The project already has ensure_executable_scripts() (specify_cli/__init__.py), whose
docstring explicitly covers .specify/extensions, and it is called from init,
migrate, and integration-install. extension add is the one install path that never
calls it — so the bit is only restored later, incidentally, if the user happens to run
specify init afterward. For someone adding an extension to an existing project (the
common case), it stays broken.
Impact
Any extension that ships a .sh and documents a direct
.specify/extensions/<id>/scripts/... invocation — e.g. a CI gate — fails on a fresh
extension add. The workaround (bash <path>) works but shouldn't be necessary, and it
isn't obvious.
Repro
- Any extension whose zip contains an executable-intended
scripts/foo.sh.
specify extension add … --from <zip-url> into a project.
ls -l .specify/extensions/<id>/scripts/foo.sh → -rw-r--r--; running it directly →
Permission denied.
Verified against specify built from main (c0ba811).
Fix
One line: call ensure_executable_scripts(project_root) after a successful install in
extension_add, matching init / migrate / integration-install. PR attached.
AI assistance: this report and the linked fix were prepared with Claude Code; the
behaviour was verified end-to-end against the real CLI.
What happens
Installing an extension that ships a shell script and then running that script the way
the extension's docs / CI instructions say to run it fails:
The installed script is
-rw-r--r--— the execute bit is gone.Why
Extension archives are unpacked with
zipfile.extractall(extensions/__init__.py),and
--devinstalls copy the tree; neither restores a stripped Unix mode. So a bundled*.shlands non-executable.The project already has
ensure_executable_scripts()(specify_cli/__init__.py), whosedocstring explicitly covers
.specify/extensions, and it is called frominit,migrate, and integration-install.extension addis the one install path that nevercalls it — so the bit is only restored later, incidentally, if the user happens to run
specify initafterward. For someone adding an extension to an existing project (thecommon case), it stays broken.
Impact
Any extension that ships a
.shand documents a direct.specify/extensions/<id>/scripts/...invocation — e.g. a CI gate — fails on a freshextension add. The workaround (bash <path>) works but shouldn't be necessary, and itisn't obvious.
Repro
scripts/foo.sh.specify extension add … --from <zip-url>into a project.ls -l .specify/extensions/<id>/scripts/foo.sh→-rw-r--r--; running it directly →Permission denied.Verified against
specifybuilt frommain(c0ba811).Fix
One line: call
ensure_executable_scripts(project_root)after a successful install inextension_add, matchinginit/migrate/ integration-install. PR attached.AI assistance: this report and the linked fix were prepared with Claude Code; the
behaviour was verified end-to-end against the real CLI.