Making hf CLI Dual-Mode: The out Framework
Problem
The hf CLI is used by both humans and AI agents. Today's output is human-only: ANSI bold, padded tables truncated at 35 chars, prose messages, emoji booleans ✔, progress spinners. Agents need: no ANSI, no truncation, compact structured output, token efficiency.
The real problem isn't "how do I output differently" — it's that print logic and business logic are interleaved. Every command calls print() directly with hard-coded formatting. We want a mini-framework where developers call semantic output methods and the framework picks the rendering.
Current State
What we have
OutputFormat enum (table | json) + FormatOpt annotation (_cli_utils.py:551)
--json shorthand → auto-rewritten to --format json in HFCliTyperGroup
--quiet / -q → prints IDs only
print_list_output() handles table/json/quiet for ~12 command files
ANSI class respects NO_COLOR env var
is_agent() helper in progress (not merged yet)
build_skill_md() in skills.py already walks the Click command tree to auto-generate docs
Output patterns today
# Pattern 1: Action — ~30 call sites (repos, collections, discussions, webhooks, jobs, spaces)
print(f"Successfully created {ANSI.bold(repo_url.repo_id)} on the Hub.")
# Pattern 2: List — ~12 call sites, shared utility
print_list_output(results, format=format, quiet=quiet)
# Pattern 3: Info/detail — ~8 call sites
print(json.dumps(api_object_to_dict(info), indent=2))
# Pattern 4: Error (hf.py:117)
print(f"Error: {message}", file=sys.stderr)
print(ANSI.gray("Set HF_DEBUG=1 as environment variable for full traceback."))
# Pattern 5: Confirmation (repos.py:504)
choice = input("Proceed? [Y/n] ") # ← hangs for agents!
# Pattern 6: Progress (spaces.py, buckets.py)
status = StatusLine(); status.update("building...")
# Pattern 7: Streaming (jobs.py)
for log in api.fetch_job_logs(...): print(log)
What's missing
- Action commands have no structured output (just prose with ANSI)
- Info commands always
indent=2 (no compact mode)
- No auto-switch between human/agent rendering
- stdout pollution:
check_cli_update() and Set HF_DEBUG=1... hint go to stdout
input() prompts hang for agents
The Framework: out Singleton
Detection
def is_agent() -> bool:
"""Simple helper. Checks env vars set by known agent frameworks."""
# HF_OUTPUT=agent (explicit override)
# CLAUDE_CODE, CURSOR_AGENT, CI, etc. (well-known signals)
# Fallback: could check isatty(), but that's debatable (see Open Questions)
...
This is just a helper. The out singleton reads it once, lazily.
Output Modes
class OutputMode(str, Enum):
AUTO = "auto" # is_agent() decides
HUMAN = "human" # Force rich/human output
AGENT = "agent" # Force agent output
JSON = "json" # Force JSON everywhere
QUIET = "quiet" # IDs only
Resolved once at startup. Precedence: --output flag > HF_OUTPUT env var > is_agent() auto-detection.
The out API
from huggingface_hub.cli._output import out
out.text(...) — Flexible text output
The most general primitive. Three calling conventions:
# Same message for both:
out.text("Processing complete.")
# Different message per audience:
out.text(
human=f"Created {ANSI.bold(repo_id)} on the Hub.\nAvailable at {ANSI.bold(url)}",
agent=f"repo_id={repo_id} url={url}",
)
| Mode |
Behavior |
| Human/Rich |
Prints human (or message) as-is, with ANSI |
| Agent |
Prints agent (or message with ANSI stripped) |
| JSON |
skip message |
| Quiet |
skip message |
out.table(headers, rows, ...) — Tabular data
out.table(
headers=["ID", "Downloads", "Updated"],
rows=[(m.id, m.downloads, m.updated_at) for m in models],
alignments={"Downloads": "right"},
)
| Mode |
Behavior |
| Human |
Rich Table (colored headers, box borders, padded, truncated cells) |
| Agent |
TSV: tab-separated, header row, no truncation, true/false bools, full ISO timestamps |
| JSON |
[{"ID": "...", "Downloads": 123, ...}, ...] compact |
| Quiet |
First column only, one per line |
Agent-mode specifics vs today:
- No 35-char cell truncation
true/false instead of ✔/empty
- Full ISO timestamps (not date-only)
- No "No results found." (empty output = no results)
out.dict(data) — Structured data dump
out.dict(api_object_to_dict(info))
| Mode |
Behavior |
| Human |
json.dumps(data, indent=2) — pretty |
| Agent |
json.dumps(data) — compact (~40% fewer tokens) |
| JSON |
json.dumps(data) |
| Quiet |
for now skip? (let's think about it later) |
out.result(message, **data) — Success summary for an action
out.result("Repository created", repo_id=repo_id, url=str(url), visibility="public")
| Mode |
Behavior |
| Human |
Green ✓ Repository created followed by kv-formatted **data lines |
| Agent |
repo_id=user/my-model url=https://... visibility=public (just the data, compact) |
| JSON |
json.dumps(data) (and ignore text) |
| Quiet |
print first item from data? |
This combines out.text + out.kv into a single call for the common "action succeeded" pattern.
out.warning(message) — Non-fatal notice (stderr)
out.warning("3 files were ignored by .gitignore patterns")
| Mode |
Behavior |
| Human |
Yellow text on stderr |
| Agent |
Warning: {message} on stderr (or suppressed for low-value warnings) |
| JSON |
print in stderr based on human/agent |
| Quiet |
print in stderr based on human/agent |
out.hint(message) — Helpful suggestion (stderr)
out.hint("Set HF_DEBUG=1 for full traceback.")
out.hint("Clone it with: git clone https://huggingface.co/user/my-model")
# can be for human/agent alone
out.hint(agent="Use ... command to see get more details.")
| Mode |
Behavior |
| Human |
Dim/gray text on stderr |
| Agent |
Hint: {message} on stderr, no ANSI |
| JSON |
print in stderr based on human/agent |
| Quiet |
print in stderr based on human/agent |
out.confirm(message, *, yes=False) — Interactive confirmation
# The command always has a --yes flag
out.confirm(f"This will permanently delete {repo_id}.", yes=yes)
| Condition |
Behavior |
yes=True |
No-op (proceed silently) |
| Human + TTY |
This will permanently delete user/my-model. [Y/n] prompt |
| Agent or no TTY |
Raise CLIError("Use --yes to skip confirmation") |
This replaces all bare input("Proceed? [Y/n]") calls. Commands always pair it with a --yes flag.
out.error(message) — Error output (stderr)
out.error("Repository 'user/nonexistent' not found.")
| Mode |
Behavior |
| Human |
Error: {message} on stderr + hint |
| Agent |
Error: {message} on stderr (plain text, no error codes needed) |
| JSON |
print in stderr based on human/agent |
| Quiet |
print in stderr based on human/agent |
=> in practice this one will likely be needed only in the global try/catch we have
out API for later
(not needed at first but let's keep them in mind)
out.section(title) — Visual grouping
out.section("Upload Plan")
out.kv("repo", repo_id)
out.kv("revision", revision)
out.kv("files", 42)
| Mode |
Behavior |
| Human |
\n── Upload Plan ── (or Rich heading) |
| Agent |
# Upload Plan (or suppressed — the kv pairs carry the info) |
| JSON |
skip |
| Quiet |
skip |
out.kv(key, value) — Key-value pair
out.kv("repo_id", "user/my-model")
out.kv("url", "https://huggingface.co/user/my-model")
out.kv("private", True)
| Mode |
Behavior |
| Human |
repo_id: user/my-model (dim key, bold value — or Rich markup) |
| Agent |
repo_id=user/my-model |
| JSON |
skip |
| Quiet |
skip |
This is the workhorse for action command output. Instead of print(f"Created {ANSI.bold(x)}"), commands emit structured key-value pairs.
Progress & Status (Multiple Options)
Option 1: for item in out.progress(iterable) — tqdm-like wrapping
for file in out.progress(files, description="Uploading"):
upload(file)
| Mode |
Behavior |
| Human |
tqdm-style progress bar on stderr |
| Agent |
Passthrough (no output, just iterates) |
Pros: Dead simple, feels like tqdm. Cons: Only works for iterable-based progress — doesn't handle "indeterminate" progress or multi-phase operations.
Option 2: with out.status(message) — Spinner for indeterminate work
with out.status("Building Space..."):
result = build()
out.result("Build complete", url=result.url)
| Mode |
Behavior |
| Human |
StatusLine spinner on stderr (existing pattern) |
| Agent |
No-op context manager (silent) |
Pros: Clean for indeterminate work. Cons: No progress percentage.
Option 3: Manual progress updates — For complex multi-phase operations
progress = out.start_progress("Uploading", total=len(files))
for file in files:
upload(file)
progress.update(1, suffix=file.name)
progress.done()
| Mode |
Behavior |
| Human |
Progress bar with percentage and current file name |
| Agent |
Silent (or periodic status lines like Uploading: 50/100) |
Pros: Full control, works for non-iterable workflows. Cons: More verbose than out.progress().
Recommendation: Start with Options 1 and 2 (cover 90% of cases). Add Option 3 later if needed.
Before/After Examples
repos create
# BEFORE
print(f"Successfully created {ANSI.bold(repo_url.repo_id)} on the Hub.")
print(f"Your repo is now available at {ANSI.bold(repo_url)}")
# AFTER
out.result("Repository created", repo_id=repo_url.repo_id, url=str(repo_url))
out.hint(human=f"Clone it with: git clone {repo_url}")
Human output:
✓ Repository created
repo_id: user/my-model
url: https://huggingface.co/user/my-model
Clone it with: git clone https://huggingface.co/user/my-model
Agent output:
repo_id=user/my-model url=https://huggingface.co/user/my-model
models ls
# BEFORE
results = [api_object_to_dict(m) for m in api.list_models(...)]
print_list_output(results, format=format, quiet=quiet)
# AFTER — option 2: explicit (for new commands)
results = [api_object_to_dict(m) for m in api.list_models(...)]
out.table(
headers=["id", "downloads", "likes", "updated_at"],
rows=[[r[h] for h in headers] for r in results],
)
models info
# BEFORE
print(json.dumps(api_object_to_dict(info), indent=2))
# AFTER - dataclass handled automatically
out.dict(info)
tag delete (confirmation)
# BEFORE
if not yes:
choice = input("Proceed? [Y/n] ")
if choice.lower() not in ("", "y", "yes"):
raise typer.Abort()
# AFTER
out.confirm(f"Delete tag '{tag}' from {repo_id}?", yes=yes)
buckets sync (indeterminate status)
# BEFORE
status = StatusLine()
status.update("Syncing...")
status.done("Sync complete!")
# AFTER
with out.status("Syncing..."):
sync_bucket(...)
out.result("Sync complete", bucket=bucket_id, files=n)
Integration With Typer/Click
Mode Initialization
No decorator needed. The out singleton is initialized once in hf.py's main() or app_callback() to respect CLI flags:
# In _cli_utils.py — extend the existing app_callback or HFCliApp
# Option: add a hidden --output flag at the top level
@app.callback(invoke_without_command=True)
def app_callback(
version: ... = None,
output: Annotated[Optional[str], typer.Option("--output", hidden=True)] = None,
) -> None:
if output:
out.set_mode(output) # "agent", "rich", "json", "quiet"
The flag is hidden so it doesn't pollute --help for most users.
Cross-Cutting Concerns
1. stdout/stderr Discipline
The highest-leverage change, independent of everything else.
Rule: stdout = command result data, stderr = everything else.
Things that must move to stderr:
check_cli_update() — currently writes to stdout via click.echo() (_cli_utils.py)
Set HF_DEBUG=1... hint — currently stdout (hf.py:121)
- All
out.warning(), out.hint(), out.error() → stderr
- Progress bars, spinners → stderr (already the case for tqdm/StatusLine)
In agent/JSON mode, stdout must contain only valid structured output.
2. --json Should Work Everywhere
Today --format json only works on ~12 list commands. The out framework makes it trivial to add JSON output to all commands:
out.result() → emits key=value for agent, could also emit JSON
out.dict() → already JSON
out.table() → respects --format json when available
The existing --json shorthand rewriting in HFCliTyperGroup continues to work.
3. Suppress Update Check in Agent Mode
check_cli_update() should be skipped entirely when out.is_agent is true. Agents don't need version nag messages.
4. Agent-Mode Defaults
When is_agent() is true, automatically:
NO_COLOR=1 (ANSI becomes no-op)
- Progress bars suppressed
- Hints suppressed
- Confirmations fail immediately (require
--yes)
- Tables: no truncation, no emoji, full timestamps
Roadmap
Phase 0: detect is_agent()
Based on env variables.
Phase 1: Foundation
Ship out singleton + use it in a single command (hf auth whoami?)
out.text, out.table, out.dict, out.result
Phase 1.b: testing pipeline
Implement a test framework for out singleton. Let's aim for an developer-friendly way of checking things.
Phase 2: Migrate Action Commands
Replace bare print() and print_output_table calls with out.xxx() calls module by module.
Likely update related tests (only the failing ones).
Phase 3: Progress & Status
- Implement
out.progress(iterable) and with out.status(msg)
- Migrate
download.py, upload.py, buckets sync, spaces dev-mode
Phase 5: Rich for Human Mode (Optional)
- Replace
tabulate() with rich.table.Table for human-mode tables
- Add Rich section headers, styled kv pairs
- Keep Rich as optional/conditional import — agent mode never touches it
Phase 6: Update AGENTS.md instructions for CLI
So that future agents knows how to add features to the CLI.
Remaining stuff
- Check 'Cross-Cutting Concerns' section if there are still some relevant parts.
- Orthogonal: current --help is nice for agent (plain text) but could be made better for human (rich)
- Orthogonal: how to add instructions (not just examples) in the --help for agents? links to existing skills?
Making
hfCLI Dual-Mode: TheoutFrameworkProblem
The
hfCLI is used by both humans and AI agents. Today's output is human-only: ANSI bold, padded tables truncated at 35 chars, prose messages, emoji booleans✔, progress spinners. Agents need: no ANSI, no truncation, compact structured output, token efficiency.The real problem isn't "how do I output differently" — it's that print logic and business logic are interleaved. Every command calls
print()directly with hard-coded formatting. We want a mini-framework where developers call semantic output methods and the framework picks the rendering.Current State
What we have
OutputFormatenum (table|json) +FormatOptannotation (_cli_utils.py:551)--jsonshorthand → auto-rewritten to--format jsoninHFCliTyperGroup--quiet/-q→ prints IDs onlyprint_list_output()handles table/json/quiet for ~12 command filesANSIclass respectsNO_COLORenv varis_agent()helper in progress (not merged yet)build_skill_md()inskills.pyalready walks the Click command tree to auto-generate docsOutput patterns today
What's missing
indent=2(no compact mode)check_cli_update()andSet HF_DEBUG=1...hint go to stdoutinput()prompts hang for agentsThe Framework:
outSingletonDetection
This is just a helper. The
outsingleton reads it once, lazily.Output Modes
Resolved once at startup. Precedence:
--outputflag >HF_OUTPUTenv var >is_agent()auto-detection.The
outAPIout.text(...)— Flexible text outputThe most general primitive. Three calling conventions:
human(ormessage) as-is, with ANSIagent(or message with ANSI stripped)out.table(headers, rows, ...)— Tabular dataTable(colored headers, box borders, padded, truncated cells)true/falsebools, full ISO timestamps[{"ID": "...", "Downloads": 123, ...}, ...]compactAgent-mode specifics vs today:
true/falseinstead of✔/emptyout.dict(data)— Structured data dumpjson.dumps(data, indent=2)— prettyjson.dumps(data)— compact (~40% fewer tokens)json.dumps(data)out.result(message, **data)— Success summary for an action✓ Repository createdfollowed by kv-formatted**datalinesrepo_id=user/my-model url=https://... visibility=public(just the data, compact)json.dumps(data)(and ignore text)data?This combines
out.text+out.kvinto a single call for the common "action succeeded" pattern.out.warning(message)— Non-fatal notice (stderr)Warning: {message}on stderr (or suppressed for low-value warnings)out.hint(message)— Helpful suggestion (stderr)Hint: {message}on stderr, no ANSIout.confirm(message, *, yes=False)— Interactive confirmationyes=TrueThis will permanently delete user/my-model. [Y/n]promptCLIError("Use --yes to skip confirmation")This replaces all bare
input("Proceed? [Y/n]")calls. Commands always pair it with a--yesflag.out.error(message)— Error output (stderr)Error: {message}on stderr + hintError: {message}on stderr (plain text, no error codes needed)=> in practice this one will likely be needed only in the global try/catch we have
outAPI for later(not needed at first but let's keep them in mind)
out.section(title)— Visual grouping\n── Upload Plan ──(or Rich heading)# Upload Plan(or suppressed — the kv pairs carry the info)out.kv(key, value)— Key-value pairrepo_id: user/my-model(dim key, bold value — or Rich markup)repo_id=user/my-modelThis is the workhorse for action command output. Instead of
print(f"Created {ANSI.bold(x)}"), commands emit structured key-value pairs.Progress & Status (Multiple Options)
Option 1:
for item in out.progress(iterable)— tqdm-like wrappingPros: Dead simple, feels like tqdm. Cons: Only works for iterable-based progress — doesn't handle "indeterminate" progress or multi-phase operations.
Option 2:
with out.status(message)— Spinner for indeterminate workStatusLinespinner on stderr (existing pattern)Pros: Clean for indeterminate work. Cons: No progress percentage.
Option 3: Manual progress updates — For complex multi-phase operations
Uploading: 50/100)Pros: Full control, works for non-iterable workflows. Cons: More verbose than
out.progress().Recommendation: Start with Options 1 and 2 (cover 90% of cases). Add Option 3 later if needed.
Before/After Examples
repos create
Human output:
Agent output:
models ls
models info
tag delete (confirmation)
buckets sync (indeterminate status)
Integration With Typer/Click
Mode Initialization
No decorator needed. The
outsingleton is initialized once inhf.py'smain()orapp_callback()to respect CLI flags:The flag is hidden so it doesn't pollute
--helpfor most users.Cross-Cutting Concerns
1. stdout/stderr Discipline
The highest-leverage change, independent of everything else.
Rule: stdout = command result data, stderr = everything else.
Things that must move to stderr:
check_cli_update()— currently writes to stdout viaclick.echo()(_cli_utils.py)Set HF_DEBUG=1...hint — currently stdout (hf.py:121)out.warning(),out.hint(),out.error()→ stderrIn agent/JSON mode, stdout must contain only valid structured output.
2.
--jsonShould Work EverywhereToday
--format jsononly works on ~12 list commands. Theoutframework makes it trivial to add JSON output to all commands:out.result()→ emits key=value for agent, could also emit JSONout.dict()→ already JSONout.table()→ respects--format jsonwhen availableThe existing
--jsonshorthand rewriting inHFCliTyperGroupcontinues to work.3. Suppress Update Check in Agent Mode
check_cli_update()should be skipped entirely whenout.is_agentis true. Agents don't need version nag messages.4. Agent-Mode Defaults
When
is_agent()is true, automatically:NO_COLOR=1(ANSI becomes no-op)--yes)Roadmap
Phase 0: detect
is_agent()Based on env variables.
Phase 1: Foundation
Ship
outsingleton + use it in a single command (hf auth whoami?)out.text,out.table,out.dict,out.resultPhase 1.b: testing pipeline
Implement a test framework for
outsingleton. Let's aim for an developer-friendly way of checking things.Phase 2: Migrate Action Commands
Replace bare
print()andprint_output_tablecalls without.xxx()calls module by module.Likely update related tests (only the failing ones).
Phase 3: Progress & Status
out.progress(iterable)andwith out.status(msg)download.py,upload.py,buckets sync,spaces dev-modePhase 5: Rich for Human Mode (Optional)
tabulate()withrich.table.Tablefor human-mode tablesPhase 6: Update AGENTS.md instructions for CLI
So that future agents knows how to add features to the CLI.
Remaining stuff