Skip to content

Commit 284937e

Browse files
yeldarbyclaude
andcommitted
fix(cli): polish help output, error hints, consistency
- Hide login/whoami/upload/import aliases from --help (keep download visible) - Commands fully alphabetized in help output - Exit code 0 (not 2) when running roboflow with no args - Translate raw API hints ('issuing a GET request') to CLI hints ('roboflow project list', 'roboflow workspace get') - workspace get defaults to current workspace when no arg given - Hide --install-completion/--show-completion (we have 'completion' subcommand) - Add 'roboflow help' command as alias for --help 374 tests pass, all linting clean. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fbe9587 commit 284937e

3 files changed

Lines changed: 113 additions & 96 deletions

File tree

roboflow/cli/__init__.py

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525
"Manage datasets, train models, run inference, and deploy "
2626
"workflows \u2014 from the command line or via structured JSON for AI agents."
2727
),
28-
no_args_is_help=True,
2928
pretty_exceptions_enable=False, # We handle errors ourselves via output_error
3029
rich_markup_mode="rich",
3130
add_completion=False, # We have our own 'completion' subcommand
@@ -68,13 +67,18 @@ def _root_callback(
6867
typer.Option("--version", help="Show package version and exit", callback=_version_callback, is_eager=True),
6968
] = None,
7069
) -> None:
71-
"""Roboflow CLI: computer vision at your fingertips."""
70+
"""Build and deploy computer vision models with Roboflow."""
7271
ctx.ensure_object(dict)
7372
ctx.obj["json"] = json_output
7473
ctx.obj["api_key"] = api_key
7574
ctx.obj["workspace"] = workspace
7675
ctx.obj["quiet"] = quiet
7776

77+
# Show help and exit 0 when no subcommand is given
78+
if ctx.invoked_subcommand is None:
79+
print(ctx.get_help()) # noqa: T201
80+
raise typer.Exit(code=0)
81+
7882

7983
# ---------------------------------------------------------------------------
8084
# Register command groups (explicit imports — no auto-discovery needed)
@@ -98,32 +102,41 @@ def _root_callback(
98102
from roboflow.cli.handlers.workflow import workflow_app # noqa: E402
99103
from roboflow.cli.handlers.workspace import workspace_app # noqa: E402
100104

101-
# Register command groups (alphabetical order)
105+
# Register ALL commands in alphabetical order for clean --help output
102106
app.add_typer(annotation_app, name="annotation")
103107
app.add_typer(auth_app, name="auth")
104108
app.add_typer(batch_app, name="batch")
105109
app.add_typer(completion_app, name="completion")
106110
app.add_typer(deployment_app, name="deployment")
111+
112+
# "download" alias — registered here alphabetically (visible shorthand)
113+
from roboflow.cli.handlers._aliases import register_download_alias # noqa: E402
114+
115+
register_download_alias(app)
116+
107117
app.add_typer(folder_app, name="folder")
108118
app.add_typer(image_app, name="image")
119+
120+
# "infer" — top-level command, registered alphabetically
121+
infer_command(app)
122+
109123
app.add_typer(model_app, name="model")
110124
app.add_typer(project_app, name="project")
125+
126+
# "search" — top-level command, registered alphabetically
127+
search_command(app)
128+
111129
app.add_typer(train_app, name="train")
112130
app.add_typer(universe_app, name="universe")
113131
app.add_typer(version_app, name="version")
114132
app.add_typer(video_app, name="video")
115133
app.add_typer(workflow_app, name="workflow")
116134
app.add_typer(workspace_app, name="workspace")
117135

118-
# Top-level commands (not nested under a group)
119-
infer_command(app)
120-
search_command(app)
121-
122-
# "roboflow download" — visible shorthand (common enough to show)
123-
# "roboflow help" — alias for --help
124-
from roboflow.cli.handlers._aliases import register_aliases # noqa: E402
136+
# Hidden aliases (loaded last — still functional but not in --help)
137+
from roboflow.cli.handlers._aliases import register_hidden_aliases # noqa: E402
125138

126-
register_aliases(app)
139+
register_hidden_aliases(app)
127140

128141

129142
# "roboflow help" command

roboflow/cli/_output.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,22 @@ def output(args: Any, data: Any, text: Optional[str] = None) -> None:
4545
("over_quota", "Your workspace has exceeded its quota. Visit https://roboflow.com/pricing to upgrade."),
4646
]
4747

48+
# Patterns to translate raw API hints into CLI-friendly hints
49+
_API_HINT_REPLACEMENTS: list[tuple[str, str]] = [
50+
(
51+
"You can see your active workspace by issuing a GET request to / with your api_key",
52+
"Check available resources with 'roboflow project list' or 'roboflow workspace get'.",
53+
),
54+
(
55+
"You can find the API docs at https://docs.roboflow.com",
56+
"Run the command with --help for usage information.",
57+
),
58+
(
59+
"You can see your available workspaces by issuing a GET request to /workspaces",
60+
"List workspaces with 'roboflow workspace list'.",
61+
),
62+
]
63+
4864

4965
def _detect_plan_hint(message: str) -> Optional[str]:
5066
"""Detect plan/billing-related errors and return an appropriate upgrade hint."""
@@ -55,6 +71,21 @@ def _detect_plan_hint(message: str) -> Optional[str]:
5571
return None
5672

5773

74+
def _translate_api_hints(message: str) -> str:
75+
"""Replace raw API hints with CLI-friendly equivalents."""
76+
for api_hint, cli_hint in _API_HINT_REPLACEMENTS:
77+
message = message.replace(api_hint, cli_hint)
78+
# Generic fallback: strip any remaining "issuing a GET/POST request" phrasing
79+
import re
80+
81+
message = re.sub(
82+
r"You can [^.]*(?:GET|POST|PUT|DELETE) request[^.]*\.",
83+
"Run the command with --help for usage information.",
84+
message,
85+
)
86+
return message
87+
88+
5889
def _sanitize_credentials(text: str) -> str:
5990
"""Strip API keys from URLs and other sensitive patterns in error messages."""
6091
import re
@@ -70,7 +101,7 @@ def _parse_error_message(raw: str) -> tuple[Optional[dict[str, Any]], str]:
70101
otherwise ``None``. The *human_readable_message* drills into nested
71102
``error.message`` structures so the text-mode output is clean.
72103
"""
73-
text = _sanitize_credentials(raw.strip())
104+
text = _translate_api_hints(_sanitize_credentials(raw.strip()))
74105
# Strip status-code prefix like "404: {...}"
75106
colon_idx = text.find(": {")
76107
if 0 < colon_idx < 5:
@@ -81,9 +112,12 @@ def _parse_error_message(raw: str) -> tuple[Optional[dict[str, Any]], str]:
81112
err = parsed.get("error", parsed)
82113
if isinstance(err, dict):
83114
human = str(err.get("message") or err.get("hint") or err)
115+
# Translate API hints in the parsed dict too
116+
if "hint" in err and isinstance(err["hint"], str):
117+
err["hint"] = _translate_api_hints(err["hint"])
84118
else:
85119
human = str(err)
86-
return parsed, human
120+
return parsed, _translate_api_hints(human)
87121
except (json.JSONDecodeError, TypeError, ValueError):
88122
pass
89123
return None, text # Return sanitized text, not the original raw

roboflow/cli/handlers/_aliases.py

Lines changed: 53 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
"""Top-level backwards-compatibility aliases.
22
3-
Registers convenience commands at the root level (``roboflow login``,
4-
``roboflow upload``, etc.) that delegate to the canonical noun-verb handlers.
3+
Split into three registration functions called at different points in
4+
``__init__.py`` to control help ordering:
55
6-
This module is loaded *after* all other handlers so that it can import
7-
their handler functions.
6+
- ``register_download_alias(app)`` — visible ``download`` command (alphabetical slot)
7+
- ``register_hidden_aliases(app)`` — all hidden aliases (loaded last)
88
"""
99

1010
from __future__ import annotations
@@ -16,10 +16,27 @@
1616
from roboflow.cli._compat import ctx_to_args
1717

1818

19-
def register_aliases(app: typer.Typer) -> None:
20-
"""Register top-level aliases for common commands."""
19+
def register_download_alias(app: typer.Typer) -> None:
20+
"""Register the visible ``download`` shorthand at its alphabetical position."""
2121

22-
# --- roboflow login (hidden alias for auth login) ---
22+
@app.command("download")
23+
def download_alias(
24+
ctx: typer.Context,
25+
url_or_id: Annotated[
26+
str, typer.Argument(metavar="datasetUrl", help="Dataset URL (e.g. workspace/project/version)")
27+
],
28+
format: Annotated[str, typer.Option("-f", "--format", help="Export format")] = "voc",
29+
location: Annotated[Optional[str], typer.Option("-l", "--location", help="Download location")] = None,
30+
) -> None:
31+
"""Download a dataset version (shorthand for 'version download')."""
32+
from roboflow.cli.handlers.version import _download
33+
34+
args = ctx_to_args(ctx, url_or_id=url_or_id, format=format, location=location)
35+
_download(args)
36+
37+
38+
def register_hidden_aliases(app: typer.Typer) -> None:
39+
"""Register all hidden backwards-compat aliases (not shown in --help)."""
2340

2441
@app.command("login", hidden=True)
2542
def login_alias(
@@ -35,8 +52,6 @@ def login_alias(
3552
args = ctx_to_args(ctx, login_api_key=login_api_key, force=force)
3653
_login(args)
3754

38-
# --- roboflow whoami (hidden alias for auth status) ---
39-
4055
@app.command("whoami", hidden=True)
4156
def whoami_alias(ctx: typer.Context) -> None:
4257
"""Show current user (alias for 'auth status')."""
@@ -45,21 +60,19 @@ def whoami_alias(ctx: typer.Context) -> None:
4560
args = ctx_to_args(ctx)
4661
_status(args)
4762

48-
# --- roboflow upload (hidden alias for image upload) ---
49-
5063
@app.command("upload", hidden=True)
5164
def upload_alias(
5265
ctx: typer.Context,
5366
path: Annotated[str, typer.Argument(help="Path to image file or directory")],
5467
project: Annotated[str, typer.Option("-p", "--project", help="Project ID")],
55-
annotation: Annotated[Optional[str], typer.Option("-a", "--annotation", help="Path to annotation file")] = None,
56-
labelmap: Annotated[Optional[str], typer.Option("-m", "--labelmap", help="Path to labelmap file")] = None,
68+
annotation: Annotated[Optional[str], typer.Option("-a", "--annotation", help="Annotation file")] = None,
69+
labelmap: Annotated[Optional[str], typer.Option("-m", "--labelmap", help="Labelmap file")] = None,
5770
split: Annotated[str, typer.Option("-s", "--split", help="Split (train/valid/test)")] = "train",
5871
num_retries: Annotated[int, typer.Option("-r", "--retries", help="Retry count")] = 0,
5972
batch: Annotated[Optional[str], typer.Option("-b", "--batch", help="Batch name")] = None,
60-
tag_names: Annotated[Optional[str], typer.Option("-t", "--tag", help="Comma-separated tag names")] = None,
61-
metadata: Annotated[Optional[str], typer.Option("-M", "--metadata", help="JSON metadata string")] = None,
62-
concurrency: Annotated[int, typer.Option("-c", "--concurrency", help="Upload concurrency")] = 10,
73+
tag_names: Annotated[Optional[str], typer.Option("-t", "--tag", help="Tag names")] = None,
74+
metadata: Annotated[Optional[str], typer.Option("-M", "--metadata", help="JSON metadata")] = None,
75+
concurrency: Annotated[int, typer.Option("-c", "--concurrency", help="Concurrency")] = 10,
6376
is_prediction: Annotated[bool, typer.Option("--is-prediction", help="Mark as prediction")] = False,
6477
) -> None:
6578
"""Upload images to a project (alias for 'image upload')."""
@@ -81,59 +94,33 @@ def upload_alias(
8194
)
8295
_handle_upload(args)
8396

84-
# --- roboflow import (hidden alias for image upload with directory) ---
85-
8697
@app.command("import", hidden=True)
8798
def import_alias(
8899
ctx: typer.Context,
89100
path: Annotated[str, typer.Argument(metavar="folder", help="Path to dataset folder")],
90101
project: Annotated[str, typer.Option("-p", "--project", help="Project ID")],
91-
concurrency: Annotated[int, typer.Option("-c", "--concurrency", help="Upload concurrency")] = 10,
102+
concurrency: Annotated[int, typer.Option("-c", "--concurrency", help="Concurrency")] = 10,
92103
batch: Annotated[Optional[str], typer.Option("-n", "--batch-name", help="Batch name")] = None,
93104
num_retries: Annotated[int, typer.Option("-r", "--retries", help="Retry count")] = 0,
94105
) -> None:
95106
"""Import dataset from folder (alias for 'image upload')."""
96107
from roboflow.cli.handlers.image import _handle_upload
97108

98109
args = ctx_to_args(
99-
ctx,
100-
path=path,
101-
project=project,
102-
concurrency=concurrency,
103-
batch=batch,
104-
num_retries=num_retries,
110+
ctx, path=path, project=project, concurrency=concurrency, batch=batch, num_retries=num_retries
105111
)
106112
_handle_upload(args)
107113

108-
# --- roboflow download (visible alias for version download) ---
109-
110-
@app.command("download")
111-
def download_alias(
112-
ctx: typer.Context,
113-
url_or_id: Annotated[
114-
str, typer.Argument(metavar="datasetUrl", help="Dataset URL (e.g. workspace/project/version)")
115-
],
116-
format: Annotated[str, typer.Option("-f", "--format", help="Export format")] = "voc",
117-
location: Annotated[Optional[str], typer.Option("-l", "--location", help="Download location")] = None,
118-
) -> None:
119-
"""Download a dataset version (alias for 'version download')."""
120-
from roboflow.cli.handlers.version import _download
121-
122-
args = ctx_to_args(ctx, url_or_id=url_or_id, format=format, location=location)
123-
_download(args)
124-
125-
# --- roboflow search-export (hidden alias for search --export) ---
126-
127114
@app.command("search-export", hidden=True)
128115
def search_export_alias(
129116
ctx: typer.Context,
130-
query: Annotated[str, typer.Argument(help="Search query (e.g. 'tag:annotate' or '*')")],
131-
format: Annotated[str, typer.Option("-f", help="Annotation format")] = "coco",
132-
location: Annotated[Optional[str], typer.Option("-l", help="Local directory for export")] = None,
133-
dataset: Annotated[Optional[str], typer.Option("-d", help="Limit to specific dataset")] = None,
134-
annotation_group: Annotated[Optional[str], typer.Option("-g", help="Limit to annotation group")] = None,
117+
query: Annotated[str, typer.Argument(help="Search query")],
118+
format: Annotated[str, typer.Option("-f", help="Format")] = "coco",
119+
location: Annotated[Optional[str], typer.Option("-l", help="Export location")] = None,
120+
dataset: Annotated[Optional[str], typer.Option("-d", help="Limit to dataset")] = None,
121+
annotation_group: Annotated[Optional[str], typer.Option("-g", help="Annotation group")] = None,
135122
name: Annotated[Optional[str], typer.Option("-n", help="Export name")] = None,
136-
no_extract: Annotated[bool, typer.Option("--no-extract", help="Keep zip, skip extraction")] = False,
123+
no_extract: Annotated[bool, typer.Option("--no-extract", help="Keep zip")] = False,
137124
) -> None:
138125
"""Export search results as a dataset."""
139126
from roboflow.cli.handlers.search import _search
@@ -151,16 +138,14 @@ def search_export_alias(
151138
)
152139
_search(args)
153140

154-
# --- roboflow upload_model (hidden alias for model upload) ---
155-
156141
@app.command("upload_model", hidden=True)
157142
def upload_model_alias(
158143
ctx: typer.Context,
159144
project: Annotated[Optional[list[str]], typer.Option("-p", help="Project ID (repeatable)")] = None,
160-
version_number: Annotated[Optional[int], typer.Option("-v", help="Version number")] = None,
145+
version_number: Annotated[Optional[int], typer.Option("-v", help="Version")] = None,
161146
model_type: Annotated[Optional[str], typer.Option("-t", help="Model type")] = None,
162-
model_path: Annotated[Optional[str], typer.Option("-m", help="Model file path")] = None,
163-
filename: Annotated[str, typer.Option("-f", help="Model filename")] = "weights/best.pt",
147+
model_path: Annotated[Optional[str], typer.Option("-m", help="Model path")] = None,
148+
filename: Annotated[str, typer.Option("-f", help="Filename")] = "weights/best.pt",
164149
model_name: Annotated[Optional[str], typer.Option("-n", help="Model name")] = None,
165150
) -> None:
166151
"""Upload a model (hidden legacy alias)."""
@@ -177,49 +162,34 @@ def upload_model_alias(
177162
)
178163
_upload_model(args)
179164

180-
# --- roboflow get_workspace_info (hidden alias, preserved) ---
181-
182165
@app.command("get_workspace_info", hidden=True)
183166
def get_workspace_info_alias(
184167
ctx: typer.Context,
185168
project: Annotated[Optional[str], typer.Option("-p", help="Project ID")] = None,
186-
version_number: Annotated[Optional[int], typer.Option("-v", help="Version number")] = None,
169+
version_number: Annotated[Optional[int], typer.Option("-v", help="Version")] = None,
187170
) -> None:
188171
"""Get workspace info (hidden legacy alias)."""
189-
args = ctx_to_args(ctx, project=project, version_number=version_number)
190-
_get_workspace_info_compat(args)
172+
import roboflow as rf_mod
191173

192-
# --- roboflow run_video_inference_api (hidden alias for video infer) ---
174+
args = ctx_to_args(ctx, project=project, version_number=version_number)
175+
rf_obj = rf_mod.Roboflow(args.api_key)
176+
workspace = rf_obj.workspace()
177+
print("workspace", workspace) # noqa: T201
178+
proj = workspace.project(args.project)
179+
print("project", proj) # noqa: T201
180+
ver = proj.version(args.version_number)
181+
print("version", ver) # noqa: T201
193182

194183
@app.command("run_video_inference_api", hidden=True)
195184
def run_video_inference_api_alias(
196185
ctx: typer.Context,
197186
project: Annotated[Optional[str], typer.Option("-p", help="Project ID")] = None,
198-
version_number: Annotated[Optional[int], typer.Option("-v", help="Version number")] = None,
199-
video_file: Annotated[Optional[str], typer.Option("-f", help="Video file path")] = None,
187+
version_number: Annotated[Optional[int], typer.Option("-v", help="Version")] = None,
188+
video_file: Annotated[Optional[str], typer.Option("-f", help="Video file")] = None,
200189
fps: Annotated[int, typer.Option("-fps", help="FPS")] = 5,
201190
) -> None:
202191
"""Run video inference (hidden legacy alias)."""
203192
from roboflow.cli.handlers.video import _video_infer
204193

205-
args = ctx_to_args(
206-
ctx,
207-
project=project,
208-
version_number=version_number,
209-
video_file=video_file,
210-
fps=fps,
211-
)
194+
args = ctx_to_args(ctx, project=project, version_number=version_number, video_file=video_file, fps=fps)
212195
_video_infer(args)
213-
214-
215-
def _get_workspace_info_compat(args) -> None: # noqa: ANN001
216-
"""Backwards-compat handler for the old get_workspace_info command."""
217-
import roboflow
218-
219-
rf = roboflow.Roboflow(args.api_key)
220-
workspace = rf.workspace()
221-
print("workspace", workspace)
222-
project = workspace.project(args.project)
223-
print("project", project)
224-
version = project.version(args.version_number)
225-
print("version", version)

0 commit comments

Comments
 (0)