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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,7 @@ assets/gen_typing_gif.py
!/docs/PREVIEW_PROGRESS.md
!/docs/FREECAD_VIDEO_REFERENCE.md
!/docs/PREVIEW_MECHANISM_PROGRESS.md
!/docs/FORK_MAINTENANCE.md
!/docs/scripts/
!/docs/scripts/**
/docs/scripts/__pycache__/
Expand Down
6 changes: 6 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,12 @@ python3 -m pytest cli_anything/<software>/tests/ -v
- Click 8.0+
- pytest 7.0+

### Downstream fork maintenance

If you maintain a downstream mirror or temporary patch branch, follow
[Downstream Fork Maintenance](docs/FORK_MAINTENANCE.md) before carrying local
changes for more than one upstream sync cycle.

## Code Style

- Follow PEP 8 conventions.
Expand Down
2 changes: 1 addition & 1 deletion cli-anything-plugin/commands/list.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ def extract_version_from_setup(setup_path):
content = Path(setup_path).read_text()
match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content)
return match.group(1) if match else None
except:
except (OSError, UnicodeDecodeError):
return None

def build_glob_patterns(base_path, depth):
Expand Down
19 changes: 19 additions & 0 deletions cli-hub/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,25 @@ cli-hub update gimp
cli-hub uninstall gimp
```

## Shell install commands

Most registry install commands run without a shell. Commands that intentionally
need shell syntax such as pipes, `&&` chains, command substitution, or
redirection must be reviewed in the registry and marked with
`requires_shell: true`.

If an install fails with `Command contains shell operators and was not
executed`, the registry entry is missing that reviewed opt-in. Use one of these
paths:

- Prefer updating the registry entry to a safer command shape or adding
`requires_shell: true` after reviewing the command.
- For a one-off local override, run `CLI_HUB_ALLOW_SHELL_COMMANDS=1 cli-hub
install <name>` only after reviewing the registry entry and install command.

This applies to script-style entries such as `curl ... | bash` and older
multi-step commands like `cd ... && npm install && npm link`.

## Preview Viewer

`cli-hub` also includes the generic preview consumer for preview-capable
Expand Down
90 changes: 75 additions & 15 deletions cli-hub/cli_hub/installer.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Install, uninstall, and manage CLIs — dispatches to pip or npm based on source."""

import json
import os
import shlex
import shutil
import subprocess
Expand Down Expand Up @@ -46,24 +47,81 @@ def _find_uv():
)


_SHELL_METACHARACTERS = ("|", "&&", "||", ";", "$(", "`")


def _run_command(cmd):
_ALLOW_SHELL_ENV = "CLI_HUB_ALLOW_SHELL_COMMANDS"


def _contains_shell_operator(cmd):
"""Return True when cmd contains shell syntax outside literal quoting."""
quote = None
escaped = False
i = 0
while i < len(cmd):
ch = cmd[i]
if escaped:
escaped = False
i += 1
continue
if ch == "\\":
escaped = True
i += 1
continue
if quote:
if ch == quote:
quote = None
elif quote == '"' and (ch == "`" or (ch == "$" and i + 1 < len(cmd) and cmd[i + 1] == "(")):
return True
i += 1
continue
if ch in {"'", '"'}:
quote = ch
elif ch in {"|", "&", ";", "<", ">"} or ch in {"\n", "\r"}:
return True
elif ch == "`" or (ch == "$" and i + 1 < len(cmd) and cmd[i + 1] == "("):
return True
i += 1
return False


def _allows_shell(cli):
return bool(cli and cli.get("requires_shell") is True)


def _run_command(cmd, *, allow_shell=False):
"""Run a command string.

Uses shell=True when the command contains shell operators (pipes, &&, etc.)
so that script-type installs like ``curl … | bash`` work correctly.
Commands come from the trusted registry, not from user input.
Simple commands run without a shell. Commands containing shell operators
such as pipes, redirections, or command substitutions require a reviewed
registry entry with ``requires_shell: true``. CLI_HUB_ALLOW_SHELL_COMMANDS=1
remains a hard override for callers that have reviewed the command locally.
"""
use_shell = any(c in cmd for c in _SHELL_METACHARACTERS)
use_shell = _contains_shell_operator(cmd)
if use_shell and not allow_shell and os.environ.get(_ALLOW_SHELL_ENV) != "1":
return subprocess.CompletedProcess(
args=cmd,
returncode=2,
stdout="",
stderr=(
"Command contains shell operators and was not executed. "
"Registry entries that intentionally need shell syntax must "
f"set requires_shell=true. Set {_ALLOW_SHELL_ENV}=1 only after "
"reviewing the registry entry and install command."
),
)
try:
argv = cmd if use_shell else shlex.split(cmd)
return subprocess.run(
cmd if use_shell else shlex.split(cmd),
argv,
capture_output=True,
text=True,
shell=use_shell,
)
except ValueError as exc:
return subprocess.CompletedProcess(
args=cmd,
returncode=2,
stdout="",
stderr=f"Invalid command syntax: {exc}",
)
except FileNotFoundError as exc:
missing = exc.filename or shlex.split(cmd)[0]
return subprocess.CompletedProcess(
Expand Down Expand Up @@ -105,7 +163,7 @@ def _generic_install(cli):
install_cmd = cli.get("install_cmd")
if not install_cmd:
return False, f"No install command is defined for {cli['display_name']}."
result = _run_command(install_cmd)
result = _run_command(install_cmd, allow_shell=_allows_shell(cli))
if result.returncode == 0:
return True, f"Installed {cli['display_name']} ({cli['entry_point']})"
return False, f"Install failed:\n{result.stderr or result.stdout}"
Expand All @@ -116,7 +174,7 @@ def _generic_uninstall(cli):
if not uninstall_cmd:
note = cli.get("uninstall_notes") or f"No uninstall command is defined for {cli['display_name']}."
return False, note
result = _run_command(uninstall_cmd)
result = _run_command(uninstall_cmd, allow_shell=_allows_shell(cli))
if result.returncode == 0:
return True, f"Uninstalled {cli['display_name']}"
return False, f"Uninstall failed:\n{result.stderr or result.stdout}"
Expand All @@ -127,7 +185,7 @@ def _generic_update(cli):
if not update_cmd:
note = cli.get("update_notes") or f"No update command is defined for {cli['display_name']}."
return False, note
result = _run_command(update_cmd)
result = _run_command(update_cmd, allow_shell=_allows_shell(cli))
if result.returncode == 0:
return True, f"Updated {cli['display_name']}"
return False, f"Update failed:\n{result.stderr or result.stdout}"
Expand Down Expand Up @@ -203,7 +261,7 @@ def _pip_update(cli):
def _uv_install(cli):
if _find_uv() is None:
return False, _UV_INSTALL_HINT
result = _run_command(cli["install_cmd"])
result = _run_command(cli["install_cmd"], allow_shell=_allows_shell(cli))
if result.returncode == 0:
return True, f"Installed {cli['display_name']} ({cli['entry_point']})"
return False, f"uv install failed:\n{result.stderr or result.stdout}"
Expand All @@ -215,7 +273,7 @@ def _uv_uninstall(cli):
uninstall_cmd = cli.get("uninstall_cmd")
if not uninstall_cmd:
return False, f"No uninstall command is defined for {cli['display_name']}."
result = _run_command(uninstall_cmd)
result = _run_command(uninstall_cmd, allow_shell=_allows_shell(cli))
if result.returncode == 0:
return True, f"Uninstalled {cli['display_name']}"
return False, f"uv uninstall failed:\n{result.stderr or result.stdout}"
Expand All @@ -227,7 +285,7 @@ def _uv_update(cli):
update_cmd = cli.get("update_cmd")
if not update_cmd:
return False, f"No update command is defined for {cli['display_name']}."
result = _run_command(update_cmd)
result = _run_command(update_cmd, allow_shell=_allows_shell(cli))
if result.returncode == 0:
return True, f"Updated {cli['display_name']}"
return False, f"uv update failed:\n{result.stderr or result.stdout}"
Expand Down Expand Up @@ -312,6 +370,8 @@ def _installed_entry(cli, source, strategy):
entry["uninstall_cmd"] = cli["uninstall_cmd"]
if cli.get("update_cmd"):
entry["update_cmd"] = cli["update_cmd"]
if cli.get("requires_shell") is True:
entry["requires_shell"] = True
return entry


Expand Down
Loading
Loading