From ocaml-dev
Designs and implements command-line interfaces using OCaml's cmdliner library. Useful for CLI layouts, subcommands, options, help output, error messages, and dune project integration.
How this skill is triggered — by the user, by Claude, or both
Slash command
/ocaml-dev:cmdlinerThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
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.
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:
--help output and error messages.Always use British spelling.
Use this skill whenever the user wants to:
Cmd.v / Term.t values.When designing or reviewing a CLI, explicitly apply the following principles and refer to them in explanations:
Clarity and explicitness
Predictable structure
mytool build, mytool check, mytool format).Orthogonality
Discoverability
--help output is concise but complete: usage, description, arguments, options, environment, examples.Composability and shell-friendliness
-o flags are possible.Precise failure modes
0 success, 1 user error, 2 internal failure).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. *)
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]
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
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)
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.";
])
When writing or revising cmdliner code, follow these patterns:
Cmd.v with a Term.t and Cmd.info for each command or subcommand.Arg.info documentation strings that are short, concrete, and consistent across commands.When the user asks for a new CLI, aim to provide:
Cmd.t and Term.t definitions.dune stanzas required to build the executable.Unless the user requests otherwise, structure your responses as:
open Cmdliner (or fully qualified names if clearer).--help output and real-world usage examples.Keep explanations concrete and focused on practical trade-offs (naming, grouping of options, error behaviour, and output formats).
A good CLI is both useful and beautiful. Follow these guidelines for consistent, professional output.
| 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 |
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
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) "○"
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 ())
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
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
*)
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
*)
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 ())
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);
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))
--output=json for machine-readable output-v / --verbosity (Logs_cli)npx claudepluginhub avsm/ocaml-claude-marketplace --plugin ocaml-devProvides design and implementation patterns for building command-line tools with modern UX. Covers commands, flags, output, error handling, signals, config, and distribution.
Designs, reviews, and improves CLI user interfaces: command structures, subcommands, flags, arguments, help text, and terminal output formatting. For new CLI tools or usability enhancements.
Provides patterns for building production CLI tools in Python with Typer/Click, featuring parseable JSON output, predictable command structure, and composability for agentic AI workflows.