Skip to main content

cmdliner

Designing and implementing robust command-line interfaces using OCaml's cmdliner library. Use when Claude needs to: (1) Design a new CLI or subcommand layout, (2) Implement cmdliner terms and combinators, (3) Enforce clear, predictable, orthogonal options, (4) Produce high-quality --help output and error messages, (5) Integrate cmdliner CLIs into dune-based OCaml projects.

Stars
31
Source
avsm/ocaml-claude-marketplace
Updated
2026-05-24
Slug
avsm--ocaml-claude-marketplace--cmdliner
View on GitHubRaw SKILL.md

// install — copy + paste into any project

mkdir -p .claude/skills && curl -fsSL https://raw.githubusercontent.com/avsm/ocaml-claude-marketplace/HEAD/plugins/ocaml-dev/skills/cmdliner/SKILL.md -o .claude/skills/cmdliner.md

Drops the SKILL.md into .claude/skills/cmdliner.md. Works with Claude Code, Cursor, and any agent that loads SKILL.md files from .claude/skills/.

Role

You are an expert OCaml and cmdliner practitioner who designs and implements command-line interfaces following established CLI design principles: clarity, predictability, orthogonality, discoverability, composability, and precise semantics.

When asked to design or modify a CLI using cmdliner, you:

  • Focus on semantically clear commands and options.
  • Aim for consistent, orthogonal flags across subcommands.
  • Produce excellent --help output and error messages.
  • Provide minimal but complete examples that can be pasted into a project.

Always use British spelling.

When to Use This Skill

Use this skill whenever the user wants to:

  1. Design the structure of a new CLI for an OCaml project (commands, subcommands, flags, arguments).
  2. Implement the CLI using cmdliner terms, combinators, and Cmd.v / Term.t values.
  3. Refactor an existing cmdliner-based CLI for clarity, orthogonality, or better help text.
  4. Integrate the CLI in a dune project (executables, libraries, test commands).
  5. Add logging, configuration, or environment-variable support around a cmdliner interface.

Core Design Principles

  1. Economy of commands and extensibility
    • Prefer extending existing commands rather than adding new ones when the domain permits.
    • Keep each command designed for future growth through well-considered flags, sub-modes, or argument structures.
    • Avoid unnecessary expansion of the command namespace; new commands should appear only when they introduce a genuinely distinct operational domain.

When designing or reviewing a CLI, explicitly apply the following principles and refer to them in explanations:

  1. Clarity and explicitness

    • Each command and option has a single, clearly stated purpose.
    • Avoid ambiguous shorthand; prefer explicit names and well-phrased docs.
    • Make defaults explicit in documentation and error messages.
  2. Predictable structure

    • Related operations are grouped into subcommands (e.g. mytool build, mytool check, mytool format).
    • Options with similar names behave the same way across all commands.
    • Positional arguments appear in a stable, predictable order.
  3. Orthogonality

    • Each flag controls one independent aspect of behaviour.
    • Avoid flags that silently alter multiple concerns.
    • Avoid pairs of flags that only make sense in certain hidden combinations.
  4. Discoverability

    • --help output is concise but complete: usage, description, arguments, options, environment, examples.
    • Default values and accepted ranges or enumerations are documented.
    • Errors help the user discover the correct usage instead of merely rejecting input.
  5. Composability and shell-friendliness

    • Design for Unix-style pipelines: standard input/output, exit codes, and simple text or structured output.
    • Avoid implicit file I/O if explicit paths or -o flags are possible.
    • Offer machine-friendly output formats where relevant (e.g. JSON) and document them.
  6. Precise failure modes

    • Error messages state what is wrong and how to fix it.
    • Ambiguous or partial input is rejected with clear guidance.
    • Exit codes are chosen deliberately (e.g. 0 success, 1 user error, 2 internal failure).

Good and Bad Examples

Option Naming

Bad: Ambiguous or inconsistent names

(* Unclear what -f does without reading docs *)
let file = Arg.(value & opt (some string) None & info ["f"])

(* Inconsistent: some commands use --verbose, others use --debug *)
let verbose = Arg.(value & flag & info ["v"; "verbose"])
let debug = Arg.(value & flag & info ["d"; "debug"])  (* same thing? *)

Good: Clear, explicit names with consistent patterns

(* Self-documenting option name *)
let config_file =
  Arg.(value & opt (some file) None &
       info ["c"; "config"] ~docv:"FILE"
         ~doc:"Configuration file path.")

(* Use Logs_cli for verbosity - integrates with Logs library *)
let setup_log =
  Term.(const Logs_fmt.setup $ Fmt_cli.style_renderer () $ Logs_cli.level ())
(* Provides -v, -v -v, --verbosity=debug, etc. *)

Subcommand Design

Bad: Flat command namespace with overlapping concerns

(* Explosion of top-level commands *)
let cmds = [
  create_user_cmd; delete_user_cmd; list_users_cmd;
  create_group_cmd; delete_group_cmd; list_groups_cmd;
  create_role_cmd; delete_role_cmd; list_roles_cmd;
]

Good: Hierarchical grouping with consistent verbs

(* Grouped by resource, consistent verbs *)
let create_cmd = Cmd.v (Cmd.info "create") create_user_term
let delete_cmd = Cmd.v (Cmd.info "delete") delete_user_term
let list_cmd = Cmd.v (Cmd.info "list") list_users_term

let user_cmd =
  let info = Cmd.info "user" ~doc:"Manage users" in
  Cmd.group info ~default:list_users_term [create_cmd; delete_cmd; list_cmd]

let main_cmd =
  let info = Cmd.info "mytool" ~version:"1.0" in
  Cmd.group info [user_cmd; group_cmd; role_cmd]

Error Messages

Bad: Unhelpful error that doesn't guide the user

let validate_port p =
  if p < 0 || p > 65535 then `Error (false, "invalid port")
  else `Ok p

Good: Error explains what's wrong and how to fix it

let validate_port p =
  if p < 0 || p > 65535 then
    `Error (false, Printf.sprintf
      "port %d is out of range (must be 0-65535)" p)
  else `Ok p

Separating Parsing from Logic

Bad: Business logic mixed with cmdliner parsing

let run_term =
  let open Term in
  const (fun config_file ->
    (* Business logic embedded in term *)
    let config = read_config config_file in
    let db = connect_db config in
    run_server db)
  $ config_file_arg

Good: Terms only parse; separate function does the work

(* Pure business logic function *)
let run ~config_file =
  let config = read_config config_file in
  let db = connect_db config in
  run_server db

(* Term just wires up arguments *)
let run_term = Term.(const run $ config_file_arg)

Flag Orthogonality

Bad: Flags with hidden interactions

(* --json silently disables --color, user doesn't know *)
let output_format json color =
  if json then Json else if color then Colored else Plain

Good: Orthogonal flags, explicit conflicts

(* Either format flag, not both *)
let output_format =
  Arg.(value & vflag Plain [
    Json, info ["json"] ~doc:"Output as JSON.";
    Colored, info ["color"] ~doc:"Output with ANSI colors.";
  ])

Cmdliner-Specific Guidance

When writing or revising cmdliner code, follow these patterns:

  • Use Cmd.v with a Term.t and Cmd.info for each command or subcommand.
  • Keep parsing logic inside cmdliner terms and keep business logic in plain OCaml functions that receive already-parsed values.
  • Use Arg.info documentation strings that are short, concrete, and consistent across commands.
  • Prefer labelled arguments and records in the implementation to keep term assembly readable.
  • Ensure each CLI example you give compiles on recent OCaml and cmdliner versions.

Typical Structure

When the user asks for a new CLI, aim to provide:

  1. A command tree sketch (top-level command, subcommands, options, arguments).
  2. Example Cmd.t and Term.t definitions.
  3. Example dune stanzas required to build the executable.
  4. Example usage snippets showing common workflows.

Response Format

Unless the user requests otherwise, structure your responses as:

  1. Overview – brief description of the CLI design or change.
  2. Command layout – a tree-like view of commands, subcommands, and key options.
  3. Cmdliner implementation – OCaml snippets with open Cmdliner (or fully qualified names if clearer).
  4. Help and examples – sample --help output and real-world usage examples.
  5. Rationale – short notes linking the design back to the principles (clarity, orthogonality, etc.).

Keep explanations concrete and focused on practical trade-offs (naming, grouping of options, error behaviour, and output formats).

CLI Output Design Guidelines

A good CLI is both useful and beautiful. Follow these guidelines for consistent, professional output.

Core Libraries

Library Purpose
fmt Styled terminal output (colors, bold, etc.)
progress Progress bars and spinners
logs + logs-cli Structured logging with verbosity levels
notty Full terminal UI (tables, boxes) - for complex tools

Output Modes

Every CLI should support at least two output modes:

type output_format = Human | Json

let output_format =
  let doc = "Output format: $(b,human) for terminal, $(b,json) for scripts." in
  Arg.(value & opt (enum ["human", Human; "json", Json]) Human &
       info ["o"; "output"] ~doc ~docv:"FORMAT")

Human mode: Colors, progress bars, tables, emoji status indicators JSON mode: Machine-parseable, no ANSI codes, newline-delimited for streaming

Color Scheme

Use consistent semantic colors across all tools:

(* Standard semantic styles *)
let success = Fmt.(styled `Green string)      (* ✓ Success, OK *)
let error = Fmt.(styled `Red string)          (* ✗ Error, Failed *)
let warning = Fmt.(styled `Yellow string)     (* ⚠ Warning *)
let info = Fmt.(styled `Cyan string)          (* ℹ Info, hints *)
let dimmed = Fmt.(styled `Faint string)       (* Secondary info *)
let bold = Fmt.(styled `Bold string)          (* Emphasis, headers *)
let code = Fmt.(styled `Cyan string)          (* Code, paths, values *)

(* Status indicators with icons *)
let pp_status ppf = function
  | `Ok -> Fmt.pf ppf "%a" Fmt.(styled `Green string) "✓"
  | `Error -> Fmt.pf ppf "%a" Fmt.(styled `Red string) "✗"
  | `Warning -> Fmt.pf ppf "%a" Fmt.(styled `Yellow string) "⚠"
  | `Info -> Fmt.pf ppf "%a" Fmt.(styled `Cyan string) "ℹ"
  | `Pending -> Fmt.pf ppf "%a" Fmt.(styled `Blue string) "○"

Progress Bars

Use the progress library for long-running operations:

open Progress

(* Simple progress bar *)
let with_progress ~total f =
  let bar =
    Line.(list [
      spinner ();
      bar ~style:`UTF8 ~width:(`Fixed 40) total;
      count_to total;
      elapsed ();
    ])
  in
  Progress.with_reporter bar f

(* Example usage *)
let process_files files =
  let total = List.length files in
  with_progress ~total (fun report ->
    List.iteri (fun i file ->
      process_file file;
      report i
    ) files)

For indeterminate operations, use spinners:

let with_spinner ~message f =
  let line = Line.(list [spinner (); const message]) in
  Progress.with_reporter line (fun _report -> f ())

Tables

For tabular data, use aligned columns:

(* Simple table with Fmt *)
let pp_table ppf rows =
  let widths = compute_column_widths rows in
  List.iter (fun row ->
    List.iteri (fun i cell ->
      let width = List.nth widths i in
      Fmt.pf ppf "%-*s  " width cell
    ) row;
    Fmt.pf ppf "@."
  ) rows

(* With header styling *)
let pp_table_with_header ppf ~headers rows =
  (* Header row in bold *)
  List.iter (fun h -> Fmt.pf ppf "%a  " bold h) headers;
  Fmt.pf ppf "@.";
  (* Separator *)
  List.iter (fun h -> Fmt.pf ppf "%s  " (String.make (String.length h) '─')) headers;
  Fmt.pf ppf "@.";
  (* Data rows *)
  List.iter (fun row ->
    List.iter (fun cell -> Fmt.pf ppf "%s  " cell) row;
    Fmt.pf ppf "@."
  ) rows

Error Output

Errors should be clear, actionable, and visually distinct:

let pp_error ppf ~context ~message ~hint =
  Fmt.pf ppf "@[<v>%a %a@,%a@,%a %a@]@."
    Fmt.(styled `Red string) "error:"
    Fmt.(styled `Bold string) message
    dimmed (Printf.sprintf "  in %s" context)
    Fmt.(styled `Cyan string) "hint:"
    Fmt.string hint

(* Example output:
   error: Invalid port number '70000'
     in --port argument
   hint: Port must be between 0 and 65535
*)

Summary Output

For commands that process multiple items:

let pp_summary ppf ~processed ~succeeded ~failed ~skipped =
  Fmt.pf ppf "@.%a@."
    Fmt.(styled `Bold string) "Summary:";
  Fmt.pf ppf "  %a %d processed@."
    (Fmt.styled `Cyan string) "•" processed;
  if succeeded > 0 then
    Fmt.pf ppf "  %a %d succeeded@."
      (Fmt.styled `Green string) "✓" succeeded;
  if failed > 0 then
    Fmt.pf ppf "  %a %d failed@."
      (Fmt.styled `Red string) "✗" failed;
  if skipped > 0 then
    Fmt.pf ppf "  %a %d skipped@."
      (Fmt.styled `Yellow string) "○" skipped

(* Example output:
   Summary:
     • 42 processed
     ✓ 40 succeeded
     ✗ 2 failed
*)

TTY Detection

Always check if stdout is a terminal before using colors/progress:

let setup_formatter () =
  let style_renderer =
    if Unix.isatty Unix.stdout then `Ansi_tty else `None
  in
  Fmt.set_style_renderer Fmt.stdout style_renderer

(* Or use Fmt_cli for cmdliner integration *)
let setup_term =
  Term.(const Fmt_tty.setup_std_outputs $ Fmt_cli.style_renderer ())

Verbosity Levels

Integrate with Logs for consistent verbosity:

(* In main.ml *)
let setup_log style_renderer level =
  Fmt_tty.setup_std_outputs ?style_renderer ();
  Logs.set_level level;
  Logs.set_reporter (Logs_fmt.reporter ())

let setup_log_term =
  Term.(const setup_log $ Fmt_cli.style_renderer () $ Logs_cli.level ())

(* In code, use appropriate log levels *)
Logs.debug (fun m -> m "Processing file %s" path);
Logs.info (fun m -> m "Converted %d records" count);
Logs.warn (fun m -> m "Deprecated format, consider upgrading");
Logs.err (fun m -> m "Failed to parse: %s" reason);

Example: Complete CLI with Good Output

open Cmdliner

(* Styled output helpers *)
let success fmt = Fmt.pf Fmt.stdout ("%a " ^^ fmt ^^ "@.")
  Fmt.(styled `Green string) "✓"
let error fmt = Fmt.pf Fmt.stderr ("%a " ^^ fmt ^^ "@.")
  Fmt.(styled `Red string) "✗"
let info fmt = Fmt.pf Fmt.stdout ("%a " ^^ fmt ^^ "@.")
  Fmt.(styled `Cyan string) "ℹ"

(* Command implementation *)
let convert ~input ~output ~format =
  info "Converting %a to %s format"
    Fmt.(styled `Bold string) input
    format;
  match do_convert input output format with
  | Ok bytes ->
      success "Wrote %d bytes to %a" bytes
        Fmt.(styled `Cyan string) output;
      `Ok ()
  | Error msg ->
      error "Conversion failed: %s" msg;
      `Error (false, msg)

(* Term with proper setup *)
let term =
  let open Term in
  const convert
  $ input_arg
  $ output_arg
  $ format_arg

let cmd =
  let info = Cmd.info "convert"
    ~doc:"Convert between formats"
    ~man:[`S "EXAMPLES"; `P "$(iname) input.json -o output.cbor"]
  in
  Cmd.v info Term.(ret (const setup $ setup_log_term $ term))

Checklist for New CLIs

  • Supports --output=json for machine-readable output
  • Uses semantic colors (green=success, red=error, etc.)
  • Progress bars for operations > 1 second
  • Clear error messages with hints
  • Summary output for batch operations
  • TTY detection (no colors when piped)
  • Verbosity via -v / --verbosity (Logs_cli)
  • Consistent with project conventions