Skip to content

[CLI] Dual mode in CLI output (human vs agents) #3979

@Wauplin

Description

@Wauplin

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?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions