Move shell completion to the mvt command and generate one script for every MVT command

Setting up shell completion had nothing to do with the acquisition of one
platform, yet it was a command of mvt-ios and mvt-android, each
generating the script of the program it ran under only. Completion now
leaves the platform CLIs for mvt, which emits one script covering mvt,
mvt-ios and mvt-android, installed as a single file loaded when the shell
starts. Click names the completion function of each program after the
program, so the three scripts concatenate without colliding, and fish
takes the file in conf.d rather than one named after a single command.

With one script there is nothing left for the platform commands to
generate, so their completion command goes, and with it the banner
suppression which was keyed on the command name: mvt-ios and mvt-android
now print the banner for every command they have.

The long help line says which commands the completion covers, so a
short_help keeps "mvt --help" from truncating it.
This commit is contained in:
Donncha Ó Cearbhaill
2026-08-26 12:43:12 +02:00
parent 6070fc06fb
commit 36d1b27c4c
9 changed files with 187 additions and 185 deletions
+5 -7
View File
@@ -58,22 +58,20 @@ For alternative installation options and known issues, please refer to the [docu
## Usage
MVT provides three commands: `mvt-ios` and `mvt-android` analyse acquisitions from devices of that platform, and `mvt` hosts what belongs to neither: `version` and `download-iocs` (both remain available on the platform commands for now). Running `mvt` on its own shows the installed version, update notices and the available commands. [Check out the documentation to learn how to use them!](https://docs.mvt.re/)
MVT provides three commands: `mvt-ios` and `mvt-android` analyse acquisitions from devices of that platform, and `mvt` hosts what belongs to neither: `version`, `completion` and `download-iocs` (`version` and `download-iocs` remain available on the platform commands for now). Running `mvt` on its own shows the installed version, update notices and the available commands. [Check out the documentation to learn how to use them!](https://docs.mvt.re/)
### Shell completion
MVT can generate shell completion scripts for Bash, Zsh, and Fish:
MVT can generate a shell completion script for Bash, Zsh, and Fish which covers `mvt`, `mvt-ios` and `mvt-android`:
```bash
mvt-ios completion
mvt-android completion
mvt completion
```
The commands print setup instructions by default. To generate a completion script directly, pass the shell name:
The command prints setup instructions by default. To generate the completion script directly, pass the shell name:
```bash
mvt-ios completion bash
mvt-android completion zsh
mvt completion bash
```
MVT only writes completion files or shell configuration when `--install` is passed. See the [command completion documentation](https://docs.mvt.re/en/latest/command_completion/) for details.
+22 -22
View File
@@ -6,61 +6,61 @@ Click provides tab completion support for Bash (version 4.4 and up), Zsh, and Fi
To enable it, you need to register a completion script with your shell, which varies depending on the shell you are using.
The following describes how to generate the command completion scripts and add them to your shell configuration.
`mvt completion` generates one script which covers `mvt`, `mvt-ios` and `mvt-android`. The following describes how to generate that script and add it to your shell configuration.
> **Note: You will need to start a new shell for the changes to take effect.**
### For Bash
```bash
# Generate bash completion scripts
mvt-ios completion bash > ~/.mvt-ios-complete.bash
mvt-android completion bash > ~/.mvt-android-complete.bash
# Generate the bash completion script
mvt completion bash > ~/.mvt-complete.bash
```
Add the following to `~/.bashrc`:
```bash
# source mvt completion scripts
[ -f ~/.mvt-ios-complete.bash ] && . ~/.mvt-ios-complete.bash
[ -f ~/.mvt-android-complete.bash ] && . ~/.mvt-android-complete.bash
# source the mvt completion script
[ -f ~/.mvt-complete.bash ] && . ~/.mvt-complete.bash
```
### For Zsh
```bash
# Generate zsh completion scripts
mvt-ios completion zsh > ~/.mvt-ios-complete.zsh
mvt-android completion zsh > ~/.mvt-android-complete.zsh
# Generate the zsh completion script
mvt completion zsh > ~/.mvt-complete.zsh
```
Add the following to `~/.zshrc`:
```bash
# source mvt completion scripts
[ -f ~/.mvt-ios-complete.zsh ] && . ~/.mvt-ios-complete.zsh
[ -f ~/.mvt-android-complete.zsh ] && . ~/.mvt-android-complete.zsh
# source the mvt completion script
[ -f ~/.mvt-complete.zsh ] && . ~/.mvt-complete.zsh
```
### For Fish
```bash
# Generate fish completion scripts
mkdir -p ~/.config/fish/completions
mvt-ios completion fish > ~/.config/fish/completions/mvt-ios.fish
mvt-android completion fish > ~/.config/fish/completions/mvt-android.fish
# Generate the fish completion script
mkdir -p ~/.config/fish/conf.d
mvt completion fish > ~/.config/fish/conf.d/mvt-completion.fish
```
Fish loads completion files from `~/.config/fish/completions` automatically.
Fish loads the files in `~/.config/fish/conf.d` automatically.
### Automatic Installation
MVT can write the completion file and update the relevant shell configuration for Bash and Zsh when you pass `--install`:
```bash
mvt-ios completion bash --install
mvt-android completion bash --install
mvt completion bash --install
```
Replace `bash` with `zsh` or `fish` as needed. For Fish, `--install` writes the completion file into `~/.config/fish/completions`.
Replace `bash` with `zsh` or `fish` as needed. For Fish, `--install` writes the completion file into `~/.config/fish/conf.d` and changes no shell configuration.
!!! note
Earlier versions generated one script per command, with `mvt-ios completion`
and `mvt-android completion`. Files written by them keep working. When you
switch to the single script, remove the old files and the lines which load
them from your shell configuration.
For more information, visit the official [Click Docs](https://click.palletsprojects.com/en/stable/shell-completion/#enabling-completion).
+5 -4
View File
@@ -68,10 +68,11 @@ rather than in MVT's own `config.yaml`.
### Commands on `mvt`
The `mvt` command hosts what belongs to neither platform: `version` and
`download-iocs`. A plugin command which is not about the acquisition of one
platform, such as one which configures the plugin or synchronizes the
indicators it uses, belongs there too, in the `mvt.cli_plugins` group:
The `mvt` command hosts what belongs to neither platform: `version`,
`completion` and `download-iocs`. A plugin command which is not about the
acquisition of one platform, such as one which configures the plugin or
synchronizes the indicators it uses, belongs there too, in the
`mvt.cli_plugins` group:
```toml
[project.entry-points."mvt.cli_plugins"]
+4 -46
View File
@@ -15,12 +15,6 @@ from mvt.common.cli_plugins import (
register_cli_plugins,
)
from mvt.common.cmd_check_iocs import CmdCheckIOCS
from mvt.common.completion import (
SUPPORTED_SHELLS,
completion_instructions,
generate_completion_script,
install_completion_script,
)
from mvt.common.help import (
HELP_MSG_ANDROID_BACKUP_PASSWORD,
HELP_MSG_CHECK_ADB_REMOVED,
@@ -31,7 +25,6 @@ from mvt.common.help import (
HELP_MSG_CHECK_IOCS,
HELP_MSG_CHECK_INTRUSION_LOGS,
HELP_MSG_DELAY_CHECKS,
HELP_MSG_COMPLETION,
HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK,
HELP_MSG_DISABLE_UPDATE_CHECK,
HELP_MSG_HASHES,
@@ -102,11 +95,10 @@ def cli(ctx, disable_update_check, disable_indicator_update_check):
ctx.ensure_object(dict)
ctx.obj["disable_version_check"] = disable_update_check
ctx.obj["disable_indicator_check"] = disable_indicator_update_check
if ctx.invoked_subcommand != "completion":
logo(
disable_version_check=disable_update_check,
disable_indicator_check=disable_indicator_update_check,
)
logo(
disable_version_check=disable_update_check,
disable_indicator_check=disable_indicator_update_check,
)
# ==============================================================================
@@ -117,40 +109,6 @@ def version():
return
# ==============================================================================
# Command: completion
# ==============================================================================
@cli.command("completion", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_COMPLETION)
@click.argument("shell", required=False, type=click.Choice(SUPPORTED_SHELLS))
@click.option(
"--install",
is_flag=True,
help="Write completion files and update shell configuration.",
)
@click.pass_context
def completion(ctx, shell, install):
program_name = "mvt-android"
if shell is None:
if install:
raise click.UsageError("A shell is required when using --install.")
click.echo(completion_instructions(program_name))
return
root_cli = ctx.find_root().command
if install:
script_path = install_completion_script(root_cli, program_name, shell)
click.echo(f"Installed {shell} completion to {script_path}")
if shell in ("bash", "zsh"):
click.echo(f"Updated ~/.{shell}rc")
else:
click.echo("Fish loads completion files automatically.")
return
click.echo(generate_completion_script(root_cli, program_name, shell))
# ==============================================================================
# Command: check-adb (removed)
# ==============================================================================
+12 -4
View File
@@ -11,6 +11,7 @@ from mvt.common.cli_plugins import (
load_cli_commands_option,
register_cli_plugins,
)
from mvt.common.completion import completion
from mvt.common.help import (
HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK,
HELP_MSG_DISABLE_UPDATE_CHECK,
@@ -51,10 +52,11 @@ def cli(ctx, disable_update_check, disable_indicator_update_check):
ctx.ensure_object(dict)
ctx.obj["disable_version_check"] = disable_update_check
ctx.obj["disable_indicator_check"] = disable_indicator_update_check
logo(
disable_version_check=disable_update_check,
disable_indicator_check=disable_indicator_update_check,
)
if ctx.invoked_subcommand != "completion":
logo(
disable_version_check=disable_update_check,
disable_indicator_check=disable_indicator_update_check,
)
if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())
@@ -68,6 +70,12 @@ def download_iocs():
ioc_updates.update()
# ==============================================================================
# Command: completion
# ==============================================================================
cli.add_command(completion)
# ==============================================================================
# Command: version
# ==============================================================================
+89 -32
View File
@@ -9,34 +9,83 @@ import shlex
import click
from click.shell_completion import get_completion_class
from .help import HELP_MSG_COMPLETION
SUPPORTED_SHELLS = ("bash", "zsh", "fish")
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
COMPLETION_INSTRUCTIONS = """Shell completion for mvt, mvt-ios and mvt-android
def completion_instructions(program_name: str) -> str:
return f"""Shell completion for {program_name}
Print a completion script:
{program_name} completion bash > ~/.{program_name}-complete.bash
{program_name} completion zsh > ~/.{program_name}-complete.zsh
mkdir -p ~/.config/fish/completions
{program_name} completion fish > ~/.config/fish/completions/{program_name}.fish
Print one completion script covering the three commands:
mvt completion bash > ~/.mvt-complete.bash
mvt completion zsh > ~/.mvt-complete.zsh
mkdir -p ~/.config/fish/conf.d
mvt completion fish > ~/.config/fish/conf.d/mvt-completion.fish
Load the generated Bash script from ~/.bashrc:
[ -f ~/.{program_name}-complete.bash ] && . ~/.{program_name}-complete.bash
[ -f ~/.mvt-complete.bash ] && . ~/.mvt-complete.bash
Load the generated Zsh script from ~/.zshrc:
[ -f ~/.{program_name}-complete.zsh ] && . ~/.{program_name}-complete.zsh
[ -f ~/.mvt-complete.zsh ] && . ~/.mvt-complete.zsh
Fish loads completion files from ~/.config/fish/completions automatically.
Fish loads the files in ~/.config/fish/conf.d automatically.
To write these files and update Bash/Zsh shell configuration automatically:
{program_name} completion bash --install
{program_name} completion zsh --install
{program_name} completion fish --install
To write these files and update the Bash/Zsh shell configuration automatically:
mvt completion bash --install
mvt completion zsh --install
mvt completion fish --install
"""
def _mvt_programs() -> list[tuple[str, click.Command]]:
"""Return the console script name and CLI group of every MVT program.
The three CLIs are imported here rather than at module level: mvt.cli
imports this module while it is being defined, and generating a completion
script should not make the start-up of `mvt` import the platform CLIs.
"""
from mvt.android.cli import cli as android_cli
from mvt.cli import cli as mvt_cli
from mvt.ios.cli import cli as ios_cli
return [("mvt", mvt_cli), ("mvt-ios", ios_cli), ("mvt-android", android_cli)]
@click.command(
"completion",
context_settings=CONTEXT_SETTINGS,
help=HELP_MSG_COMPLETION,
short_help="Generate or install shell completion",
)
@click.argument("shell", required=False, type=click.Choice(SUPPORTED_SHELLS))
@click.option(
"--install",
is_flag=True,
help="Write completion files and update shell configuration.",
)
def completion(shell, install):
if shell is None:
if install:
raise click.UsageError("A shell is required when using --install.")
click.echo(COMPLETION_INSTRUCTIONS)
return
if install:
script_path = install_completion_script(shell)
click.echo(
f"Installed {shell} completion for mvt, mvt-ios and mvt-android "
f"to {script_path}"
)
if shell in ("bash", "zsh"):
click.echo(f"Updated ~/.{shell}rc")
else:
click.echo("Fish loads the files in ~/.config/fish/conf.d automatically.")
return
click.echo(generate_mvt_completion_script(shell))
def generate_completion_script(cli: click.Command, program_name: str, shell: str) -> str:
completion_class = get_completion_class(shell)
if completion_class is None:
@@ -46,41 +95,49 @@ def generate_completion_script(cli: click.Command, program_name: str, shell: str
return completion_class(cli, {}, program_name, complete_var).source()
def install_completion_script(
cli: click.Command,
program_name: str,
shell: str,
) -> Path:
script = generate_completion_script(cli, program_name, shell)
script_path = _completion_script_path(program_name, shell)
def generate_mvt_completion_script(shell: str) -> str:
"""Return one script completing every MVT command.
Click names the completion function of each program after the program, so
the scripts of the three commands can simply be concatenated.
"""
scripts = [
generate_completion_script(cli, program_name, shell).strip("\n")
for program_name, cli in _mvt_programs()
]
return "\n\n".join(scripts)
def install_completion_script(shell: str) -> Path:
script = generate_mvt_completion_script(shell)
script_path = _completion_script_path(shell)
script_path.parent.mkdir(parents=True, exist_ok=True)
script_path.write_text(script, encoding="utf-8")
script_path.write_text(f"{script}\n", encoding="utf-8")
if shell in ("bash", "zsh"):
_install_shell_source_line(program_name, shell, script_path)
_install_shell_source_line(shell, script_path)
return script_path
def _completion_script_path(program_name: str, shell: str) -> Path:
def _completion_script_path(shell: str) -> Path:
home = Path.home()
if shell == "fish":
return home / ".config" / "fish" / "completions" / f"{program_name}.fish"
# conf.d is sourced when the shell starts, unlike the completions
# folder, whose files fish loads on demand by command name.
return home / ".config" / "fish" / "conf.d" / "mvt-completion.fish"
return home / f".{program_name}-complete.{shell}"
return home / f".mvt-complete.{shell}"
def _install_shell_source_line(program_name: str, shell: str, script_path: Path) -> None:
def _install_shell_source_line(shell: str, script_path: Path) -> None:
shell_config_path = Path.home() / f".{shell}rc"
source_line = (
f"[ -f {shlex.quote(str(script_path))} ] && "
f". {shlex.quote(str(script_path))}"
)
block = (
f"# MVT shell completion for {program_name}\n"
f"{source_line}\n"
)
block = f"# MVT shell completion\n{source_line}\n"
if shell_config_path.exists():
shell_config = shell_config_path.read_text(encoding="utf-8")
+3 -1
View File
@@ -21,7 +21,9 @@ HELP_MSG_CHECK_IOCS = "Compare stored JSON results to provided indicators"
HELP_MSG_STIX2 = "Download public STIX2 indicators"
HELP_MSG_DISABLE_UPDATE_CHECK = "Disable MVT version update check"
HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK = "Disable indicators update check"
HELP_MSG_COMPLETION = "Generate or install shell completion"
HELP_MSG_COMPLETION = (
"Generate or install shell completion for mvt, mvt-ios and mvt-android"
)
# IOS Specific
HELP_MSG_DECRYPT_BACKUP = "Decrypt an encrypted iTunes backup"
+4 -46
View File
@@ -15,12 +15,6 @@ from mvt.common.cli_plugins import (
register_cli_plugins,
)
from mvt.common.cmd_check_iocs import CmdCheckIOCS
from mvt.common.completion import (
SUPPORTED_SHELLS,
completion_instructions,
generate_completion_script,
install_completion_script,
)
from mvt.common.logo import logo
from mvt.common.options import MutuallyExclusiveOption
from mvt.common.updates import IndicatorsUpdates
@@ -51,7 +45,6 @@ from mvt.common.help import (
HELP_MSG_CHECK_SYSDIAGNOSE,
HELP_MSG_DISABLE_UPDATE_CHECK,
HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK,
HELP_MSG_COMPLETION,
)
from mvt.common.module_loader import CustomModuleLoadError, load_custom_modules
from mvt.common.password import prompt_password
@@ -106,11 +99,10 @@ def cli(ctx, disable_update_check, disable_indicator_update_check):
ctx.ensure_object(dict)
ctx.obj["disable_version_check"] = disable_update_check
ctx.obj["disable_indicator_check"] = disable_indicator_update_check
if ctx.invoked_subcommand != "completion":
logo(
disable_version_check=disable_update_check,
disable_indicator_check=disable_indicator_update_check,
)
logo(
disable_version_check=disable_update_check,
disable_indicator_check=disable_indicator_update_check,
)
# ==============================================================================
@@ -121,40 +113,6 @@ def version():
return
# ==============================================================================
# Command: completion
# ==============================================================================
@cli.command("completion", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_COMPLETION)
@click.argument("shell", required=False, type=click.Choice(SUPPORTED_SHELLS))
@click.option(
"--install",
is_flag=True,
help="Write completion files and update shell configuration.",
)
@click.pass_context
def completion(ctx, shell, install):
program_name = "mvt-ios"
if shell is None:
if install:
raise click.UsageError("A shell is required when using --install.")
click.echo(completion_instructions(program_name))
return
root_cli = ctx.find_root().command
if install:
script_path = install_completion_script(root_cli, program_name, shell)
click.echo(f"Installed {shell} completion to {script_path}")
if shell in ("bash", "zsh"):
click.echo(f"Updated ~/.{shell}rc")
else:
click.echo("Fish loads completion files automatically.")
return
click.echo(generate_completion_script(root_cli, program_name, shell))
# ==============================================================================
# Command: decrypt-backup
# ==============================================================================
+43 -23
View File
@@ -6,56 +6,60 @@
from click.testing import CliRunner
from mvt.android.cli import cli as android_cli
from mvt.cli import cli as mvt_cli
from mvt.ios.cli import cli as ios_cli
class TestCompletionCommand:
def test_completion_prints_instructions_by_default(self):
runner = CliRunner()
result = runner.invoke(ios_cli, ["completion"])
result = runner.invoke(mvt_cli, ["completion"])
assert result.exit_code == 0
assert "Shell completion for mvt-ios" in result.output
assert "mvt-ios completion bash > ~/.mvt-ios-complete.bash" in result.output
assert "Shell completion for mvt, mvt-ios and mvt-android" in result.output
assert "mvt completion bash > ~/.mvt-complete.bash" in result.output
assert "Mobile Verification Toolkit" not in result.output
def test_completion_prints_bash_script(self):
def test_completion_bash_script_covers_every_cli(self):
runner = CliRunner()
result = runner.invoke(ios_cli, ["completion", "bash"])
result = runner.invoke(mvt_cli, ["completion", "bash"])
assert result.exit_code == 0
assert "_MVT_COMPLETE=bash_complete" in result.output
assert "_MVT_IOS_COMPLETE=bash_complete" in result.output
assert "_MVT_ANDROID_COMPLETE=bash_complete" in result.output
assert "complete -o nosort" in result.output
assert "mvt-ios" in result.output
assert "Mobile Verification Toolkit" not in result.output
def test_completion_prints_fish_script(self):
def test_completion_fish_script_covers_every_cli(self):
runner = CliRunner()
result = runner.invoke(android_cli, ["completion", "fish"])
result = runner.invoke(mvt_cli, ["completion", "fish"])
assert result.exit_code == 0
assert "_MVT_ANDROID_COMPLETE=fish_complete" in result.output
assert "complete --no-files --command mvt-ios" in result.output
assert "complete --no-files --command mvt-android" in result.output
assert "complete --no-files --command mvt " in result.output
assert "Mobile Verification Toolkit" not in result.output
def test_completion_install_updates_bashrc_once(self, tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
runner = CliRunner()
result = runner.invoke(ios_cli, ["completion", "bash", "--install"])
result = runner.invoke(mvt_cli, ["completion", "bash", "--install"])
assert result.exit_code == 0
script_path = tmp_path / ".mvt-ios-complete.bash"
script_path = tmp_path / ".mvt-complete.bash"
bashrc_path = tmp_path / ".bashrc"
assert script_path.exists()
assert "_MVT_IOS_COMPLETE=bash_complete" in script_path.read_text(
encoding="utf-8"
)
script = script_path.read_text(encoding="utf-8")
assert "_MVT_COMPLETE=bash_complete" in script
assert "_MVT_IOS_COMPLETE=bash_complete" in script
assert "_MVT_ANDROID_COMPLETE=bash_complete" in script
bashrc = bashrc_path.read_text(encoding="utf-8")
assert "[ -f" in bashrc
assert ".mvt-ios-complete.bash" in bashrc
assert ".mvt-complete.bash" in bashrc
result = runner.invoke(ios_cli, ["completion", "bash", "--install"])
result = runner.invoke(mvt_cli, ["completion", "bash", "--install"])
assert result.exit_code == 0
assert bashrc_path.read_text(encoding="utf-8") == bashrc
@@ -65,14 +69,30 @@ class TestCompletionCommand:
monkeypatch.setenv("HOME", str(tmp_path))
runner = CliRunner()
result = runner.invoke(android_cli, ["completion", "fish", "--install"])
result = runner.invoke(mvt_cli, ["completion", "fish", "--install"])
assert result.exit_code == 0
script_path = (
tmp_path / ".config" / "fish" / "completions" / "mvt-android.fish"
)
script_path = tmp_path / ".config" / "fish" / "conf.d" / "mvt-completion.fish"
assert script_path.exists()
assert "_MVT_ANDROID_COMPLETE=fish_complete" in script_path.read_text(
encoding="utf-8"
)
script = script_path.read_text(encoding="utf-8")
assert "_MVT_COMPLETE=fish_complete" in script
assert "_MVT_IOS_COMPLETE=fish_complete" in script
assert "_MVT_ANDROID_COMPLETE=fish_complete" in script
assert not (tmp_path / ".fishrc").exists()
assert not (tmp_path / ".bashrc").exists()
assert not (tmp_path / ".zshrc").exists()
def test_completion_install_without_shell_is_a_usage_error(self):
runner = CliRunner()
result = runner.invoke(mvt_cli, ["completion", "--install"])
assert result.exit_code == 2
assert "A shell is required when using --install." in result.output
def test_completion_is_not_a_command_of_the_platform_clis(self):
runner = CliRunner()
assert "completion" not in ios_cli.commands
assert "completion" not in android_cli.commands
assert runner.invoke(ios_cli, ["completion"]).exit_code == 2
assert runner.invoke(android_cli, ["completion"]).exit_code == 2