Skip to content
Merged
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
28 changes: 27 additions & 1 deletion CLI-COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,32 @@ roboflow video status <job-id>

### Shell completion

The fastest path: let the CLI install completion for you. Auto-detects your shell from `$SHELL`.

```bash
roboflow completion install
```

This writes the completion script to a per-user location and updates your shell rc file (`~/.bashrc` or `~/.zshrc`) so completion works in new shells. Idempotent — safe to re-run. Delegates to `typer.completion.install` under the hood.

Supported shells: `bash`, `zsh`, `fish`. Windows / PowerShell is not supported.

Override detection or scope to one shell:

```bash
roboflow completion install --shell zsh
roboflow completion install --shell bash
roboflow completion install --shell fish
```

Hidden commands (legacy aliases, snake_case shims, not-yet-implemented stubs) are filtered from completion automatically.

To uninstall, delete the completion script (location depends on your shell — typer writes to `~/.bash_completions/roboflow.sh`, `~/.zfunc/_roboflow`, or `~/.config/fish/completions/roboflow.fish`) and remove any `source ...` line typer added to your `~/.bashrc`.

#### Advanced: print the script yourself

If you want full control, generate the raw script and source it however you like:

```bash
# Zsh
eval "$(roboflow completion zsh)"
Expand Down Expand Up @@ -237,7 +263,7 @@ Version numbers are always numeric — that's how `x/y` is disambiguated between
| `universe` | Search Roboflow Universe |
| `video` | Video inference |
| `batch` | Batch processing jobs *(coming soon)* |
| `completion` | Generate shell completion scripts (bash, zsh, fish) |
| `completion` | Install or generate shell completion scripts (bash, zsh, fish) |

Run `roboflow <command> --help` for details on any command.

Expand Down
20 changes: 20 additions & 0 deletions roboflow/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from __future__ import annotations

import json
import os
from typing import Annotated, Any, Optional

import click
Expand All @@ -31,6 +32,9 @@
cls=SortedGroup,
pretty_exceptions_enable=False,
rich_markup_mode="rich",
# We expose shell completion through our own `completion` command group
# (see roboflow/cli/handlers/completion.py) so that there is exactly one
# documented entry-point.
add_completion=False,
context_settings={"help_option_names": ["-h", "--help"]},
)
Expand Down Expand Up @@ -163,6 +167,16 @@ def _walk(group: Any, prefix: str = "") -> None:
styled_name.append(parts[1], style="bold")
cmd_table.add_row(styled_name, help_text)
console.print(Panel(cmd_table, title="Commands", title_align="left", border_style="dim"))
# Footer tip: nudge users to enable shell completion. Suppressed under
# --quiet (explicit opt-out of non-essential output). --json doesn't apply
# here because the flattened help only renders in non-JSON mode anyway.
import sys as _sys

if "--quiet" not in _sys.argv and "-q" not in _sys.argv:
console.print(
" Tip: enable shell completion with [bold]roboflow completion install[/bold]",
highlight=False,
)
console.print()


Expand Down Expand Up @@ -362,6 +376,12 @@ def main() -> None:
"""CLI entry point — called by ``roboflow`` console script."""
import sys

complete_mode = os.environ.get("_ROBOFLOW_COMPLETE")
if complete_mode in {"complete_bash", "bash_complete"} and (
"COMP_WORDS" not in os.environ or "COMP_CWORD" not in os.environ
):
sys.exit(0)

sys.argv[1:] = _reorder_argv(sys.argv[1:])

# Intercept root-level --help/-h: show our flattened help instead of typer's grouped view.
Expand Down
14 changes: 14 additions & 0 deletions roboflow/cli/handlers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,17 @@ def _mask_key(key: str) -> str:
return key[:2] + "*" * (len(key) - 4) + key[-2:]


def _print_completion_tip(args) -> None: # noqa: ANN001
"""Nudge users towards shell completion after a successful login.

Suppressed under --json (would corrupt the JSON output) and --quiet
(user explicitly opted out of non-essential output).
"""
if getattr(args, "json", False) or getattr(args, "quiet", False):
return
print("\nTip: enable shell completion with 'roboflow completion install'") # noqa: T201


def _login(args): # noqa: ANN001
from roboflow.cli._output import output, output_error

Expand Down Expand Up @@ -154,6 +165,7 @@ def _login(args): # noqa: ANN001
{"status": "logged_in", "workspace": ws_url, "api_key": _mask_key(api_key)},
text=f"Logged in. Default workspace: {ws_url}{note}",
)
_print_completion_tip(args)
else:
# Interactive flow
import roboflow
Expand Down Expand Up @@ -181,6 +193,8 @@ def _login(args): # noqa: ANN001
{"status": "logged_in", "workspace": ws, "api_key": "****"},
text=f"Logged in. Default workspace: {ws}",
)
_print_completion_tip(args)
_print_completion_tip(args)


def _status(args): # noqa: ANN001
Expand Down
103 changes: 63 additions & 40 deletions roboflow/cli/handlers/completion.py
Original file line number Diff line number Diff line change
@@ -1,65 +1,88 @@
"""Shell completion commands.
"""Shell completion: install + raw script generators.

Generates completion scripts for bash, zsh, and fish shells.
Uses Click's built-in completion generation via the ``_ROBOFLOW_COMPLETE``
environment variable.
Delegates installation to ``typer.completion.install`` (which itself
wraps Click's ``shell_completion`` and auto-detects the shell via
shellingham). Hidden commands are filtered by Click automatically.
"""

from __future__ import annotations

import sys
import shutil
from typing import Annotated, Optional

import click
import typer
from typer._completion_classes import completion_init
from typer._completion_shared import get_completion_script
from typer.completion import install as typer_install

from roboflow.cli._compat import SortedGroup
from roboflow.cli._compat import SortedGroup, ctx_to_args
from roboflow.cli._output import output, output_error

completion_app = typer.Typer(cls=SortedGroup, help="Generate shell completions", no_args_is_help=True)
completion_app = typer.Typer(
cls=SortedGroup,
help="Generate and install shell completions",
no_args_is_help=True,
)

completion_init()

def _generate_completion(shell: str) -> None:
"""Generate completion script for the given shell using Click's completion system."""
from click.shell_completion import get_completion_class

comp_cls = get_completion_class(shell)
if comp_cls is None:
print(f"Shell '{shell}' is not supported for completion.", file=sys.stderr)
raise typer.Exit(code=1)

from roboflow.cli import app

# Access the underlying Click command
click_app = typer.main.get_command(app)
ctx = click.Context(click_app, info_name="roboflow")
comp = comp_cls(click_app, ctx, "roboflow", "_ROBOFLOW_COMPLETE") # type: ignore[arg-type]
print(comp.source()) # noqa: T201
def _generate_completion(shell: str) -> str:
return get_completion_script(prog_name="roboflow", complete_var="_ROBOFLOW_COMPLETE", shell=shell)


@completion_app.command("bash")
def bash() -> None:
"""Generate bash completion script.

Usage: eval "$(roboflow completion bash)"
Or save to a file: roboflow completion bash > ~/.roboflow-complete.bash
"""
_generate_completion("bash")
"""Print bash completion script. Usage: eval "$(roboflow completion bash)"."""
print(_generate_completion("bash")) # noqa: T201


@completion_app.command("zsh")
def zsh() -> None:
"""Generate zsh completion script.

Usage: eval "$(roboflow completion zsh)"
Or save to a file: roboflow completion zsh > ~/.roboflow-complete.zsh
"""
_generate_completion("zsh")
"""Print zsh completion script. Usage: eval "$(roboflow completion zsh)"."""
print(_generate_completion("zsh")) # noqa: T201


@completion_app.command("fish")
def fish() -> None:
"""Generate fish completion script.

Usage: roboflow completion fish | source
Or save to a file: roboflow completion fish > ~/.config/fish/completions/roboflow.fish
"""
_generate_completion("fish")
"""Print fish completion script. Usage: roboflow completion fish | source."""
print(_generate_completion("fish")) # noqa: T201


@completion_app.command("install")
def install(
ctx: typer.Context,
shell: Annotated[
Optional[str],
typer.Option("--shell", help="bash, zsh, or fish. Auto-detected when omitted."),
] = None,
) -> None:
"""Install shell completion. Writes the script and updates your shell rc. Idempotent."""
args = ctx_to_args(ctx, shell=shell)

if shutil.which("roboflow") is None:
output_error(
args,
"The 'roboflow' command is not on your PATH.",
hint="Ensure your install bin directory (e.g. ~/.local/bin) is on PATH.",
exit_code=1,
)
return

try:
installed_shell, path = typer_install(shell=shell, prog_name="roboflow", complete_var="_ROBOFLOW_COMPLETE")
except click.exceptions.Exit:
output_error(
args,
"Could not detect or install completion.",
hint="Pass --shell with one of: bash, zsh, fish.",
exit_code=3,
)
return

output(
args,
{"shell": installed_shell, "path": str(path)},
text=f"Installed {installed_shell} completion to {path}.\nOpen a new shell to enable it.",
)
31 changes: 31 additions & 0 deletions tests/cli/test_auth.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
"""Tests for the auth CLI handler."""

import re
import types
import unittest

from typer.testing import CliRunner

from roboflow.cli import app
from roboflow.cli.handlers import auth as auth_module

runner = CliRunner()

Expand Down Expand Up @@ -64,5 +66,34 @@ def test_mask_key(self) -> None:
self.assertEqual(_mask_key(""), "****")


class TestCompletionTip(unittest.TestCase):
"""Verify the post-login completion tip honours --json and --quiet."""

def _capture(self, args_ns) -> str: # noqa: ANN001
import io
import sys

buf = io.StringIO()
prev = sys.stdout
sys.stdout = buf
try:
auth_module._print_completion_tip(args_ns)
finally:
sys.stdout = prev
return buf.getvalue()

def test_tip_printed_in_normal_mode(self) -> None:
out = self._capture(types.SimpleNamespace(json=False, quiet=False))
self.assertIn("roboflow completion install", out)

def test_tip_suppressed_in_json_mode(self) -> None:
out = self._capture(types.SimpleNamespace(json=True, quiet=False))
self.assertEqual(out, "")

def test_tip_suppressed_in_quiet_mode(self) -> None:
out = self._capture(types.SimpleNamespace(json=False, quiet=True))
self.assertEqual(out, "")


if __name__ == "__main__":
unittest.main()
Loading
Loading