From acb4625129964e15847860b99b3b4bcedb342a63 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:07:33 +0530 Subject: [PATCH] feat: add ergonomic declarative cli declarations (#302) --- base_api_manifest.yaml | 2 +- docs/api-reference.md | 1 + docs/v2-api-contract.md | 2 +- lib/bash/cli/README.md | 23 ++++ lib/bash/cli/lib_cli.sh | 205 ++++++++++++++++++++++++++++++++ lib/bash/cli/tests/lib_cli.bats | 33 +++++ 6 files changed, 264 insertions(+), 2 deletions(-) diff --git a/base_api_manifest.yaml b/base_api_manifest.yaml index 5b1d74a..8b875d8 100644 --- a/base_api_manifest.yaml +++ b/base_api_manifest.yaml @@ -175,7 +175,7 @@ modules: dependencies: std optional_commands: none artifacts: lib/bash/cli/lib_cli.sh,lib/bash/cli/README.md,lib/bash/cli/tests/lib_cli.bats - public_symbols: base_cli_command,base_cli_complete,base_cli_completion_script,base_cli_help,base_cli_model_init,base_cli_option,base_cli_parse,base_cli_positional,base_cli_result_count,base_cli_result_get,base_cli_result_get_positional,base_cli_run,base_cli_validate_model + public_symbols: base_cli_command,base_cli_complete,base_cli_completion_script,base_cli_declare,base_cli_help,base_cli_model_init,base_cli_option,base_cli_parse,base_cli_positional,base_cli_result_count,base_cli_result_get,base_cli_result_get_positional,base_cli_run,base_cli_validate_model signature_source: lib/bash/cli/README.md inputs: documented per symbol in the module README and API charter outputs: documented per symbol; parsed results use BASE_BASH_LIBS_CLI_RESULT_* globals and named result variables diff --git a/docs/api-reference.md b/docs/api-reference.md index 69a1ddd..7f13164 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -260,6 +260,7 @@ statuses, and side effects are normative in the linked module README and - `base_cli_command` — signature: see [`lib/bash/cli/README.md`](../lib/bash/cli/README.md). - `base_cli_complete` — signature: see [`lib/bash/cli/README.md`](../lib/bash/cli/README.md). - `base_cli_completion_script` — signature: see [`lib/bash/cli/README.md`](../lib/bash/cli/README.md). +- `base_cli_declare` — signature: see [`lib/bash/cli/README.md`](../lib/bash/cli/README.md). - `base_cli_help` — signature: see [`lib/bash/cli/README.md`](../lib/bash/cli/README.md). - `base_cli_model_init` — signature: see [`lib/bash/cli/README.md`](../lib/bash/cli/README.md). - `base_cli_option` — signature: see [`lib/bash/cli/README.md`](../lib/bash/cli/README.md). diff --git a/docs/v2-api-contract.md b/docs/v2-api-contract.md index ed6e0c3..6360e9f 100644 --- a/docs/v2-api-contract.md +++ b/docs/v2-api-contract.md @@ -197,7 +197,7 @@ signature/effects reference; this table makes coverage auditable. | str | `base_str_lower`, `base_str_upper`, `base_str_ltrim`, `base_str_rtrim`, `base_str_trim`, `base_str_contains`, `base_str_starts_with`, `base_str_ends_with`, `base_str_split`, `base_str_join` | String transforms/predicates preserve caller values until validation succeeds; split/join use validated named outputs. | | arg | `base_arg_parse` | Parses into caller-owned validated arrays/maps and leaves them unchanged on failure. | | list | `base_list_append`, `base_list_prepend`, `base_list_remove`, `base_list_contains`, `base_list_unique`, `base_list_length` | Indexed-array mutators/predicates use caller-owned arrays; usage and operational errors return rather than exit. | -| cli | `base_cli_model_init`, `base_cli_validate_model`, `base_cli_command`, `base_cli_option`, `base_cli_positional`, `base_cli_help`, `base_cli_parse`, `base_cli_run`, `base_cli_complete`, `base_cli_completion_script`, `base_cli_result_get`, `base_cli_result_get_positional`, `base_cli_result_count` | A single declarative model drives nested parsing, aliases, defaults, required/enum/validator/conflict checks, deterministic help, and completion. `base_cli_validate_model` provides an explicit post-declaration handler-wiring check for tests and CI. Successful parses publish fixed `BASE_BASH_LIBS_CLI_RESULT_*` globals; usage and validation errors return status `2`. | +| cli | `base_cli_declare`, `base_cli_model_init`, `base_cli_validate_model`, `base_cli_command`, `base_cli_option`, `base_cli_positional`, `base_cli_help`, `base_cli_parse`, `base_cli_run`, `base_cli_complete`, `base_cli_completion_script`, `base_cli_result_get`, `base_cli_result_get_positional`, `base_cli_result_count` | A single declarative model drives nested parsing, aliases, defaults, required/enum/validator/conflict checks, deterministic help, and completion. `base_cli_declare` is a data-only pipe-delimited table layer that orders parent commands before children and expands into the same low-level model calls; `base_cli_validate_model` provides an explicit post-declaration handler-wiring check for tests and CI. Successful parses publish fixed `BASE_BASH_LIBS_CLI_RESULT_*` globals; usage and validation errors return status `2`. | | app | `base_app_init`, `base_app_config_define`, `base_app_config_set_cli`, `base_app_config_load`, `base_app_config_get`, `base_app_config_provenance`, `base_app_config_report`, `base_app_add_standard_options`, `base_app_apply_standard_options`, `base_app_should_prompt`, `base_app_prompt`, `base_app_hook`, `base_app_run`, `base_app_status` | Optional typed configuration and lifecycle policy. Configuration is data-only with CLI > environment > project > user > default precedence; reports redact secrets. Hooks are named functions, LIFO, exactly-once, and preserve the application status. | | launcher | `base_launcher_check_project`, `base_launcher_die`, `base_launcher_resolve_path`, `base_launcher_package_root`, `base_launcher_ensure_supported_bash`, `base_launcher_lib_dir_is_usable`, `base_launcher_resolve_lib_dir`, `base_launcher_source_stdlib`, `base_launcher_import_base_bash_lib`, `base_launcher_init`, `base_launcher_run_script`, `base_launcher_usage` | Entrypoint helpers may terminate only at the executable process boundary; path and usability helpers return status. `base_launcher_init` creates a deterministic minimal or standard scaffold and refuses divergent overwrites. `base_launcher_check_project` is non-mutating and emits human or JSON conformance records. `main` remains application-defined. | diff --git a/lib/bash/cli/README.md b/lib/bash/cli/README.md index 2ee989e..fe48746 100644 --- a/lib/bash/cli/README.md +++ b/lib/bash/cli/README.md @@ -8,6 +8,13 @@ metadata in one source of truth. It is one sourceable file and requires ## Public API - `base_cli_model_init MODEL [name=PROGRAM] [version=VERSION] [description=TEXT] [handler=FUNCTION]` +- `base_cli_declare MODEL [ROW...]` builds the same model from compact, + pipe-delimited data rows. With no `ROW` arguments it reads rows from stdin, + making a quoted heredoc a convenient declaration table. Row kinds are + `model`, `command`, `option`, and `positional`; values may contain spaces, + but `|` is reserved as the field delimiter. The helper applies parent + commands before children, so rows may be ordered for readability, and never + evaluates row contents as shell code. - `base_cli_validate_model MODEL` checks all declared handlers after the model and application functions have been loaded; use it in tests or CI. starts or replaces a model. `MODEL` is an in-process identifier and `name` @@ -59,6 +66,22 @@ and may have partially inspected input, but does not claim a valid result. ## Example +The quick declaration layer is useful for a small or generated command table: + +```bash +base_cli_declare deploy <<'EOF' +model|name=deploy|version=2.0.0|description=Release tooling +command|path=release|description=Create a release|handler=deploy_release|aliases=r +option|path=release|name=dry_run|type=flag|tokens=--dry-run,-n|help=Do not mutate +option|path=release|name=channel|type=value|tokens=--channel|default=stable|enum=stable,canary +positional|path=release|name=target|required=true|metavar=TARGET +EOF +``` + +The lower-level calls below remain available when declarations are built +programmatically or a caller needs full control over the order of individual +mutations. + ```bash source "/path/to/base-bash-libs/lib/bash/std/lib_std.sh" base_std_import cli/lib_cli.sh diff --git a/lib/bash/cli/lib_cli.sh b/lib/bash/cli/lib_cli.sh index ef01799..30784a3 100644 --- a/lib/bash/cli/lib_cli.sh +++ b/lib/bash/cli/lib_cli.sh @@ -22,6 +22,8 @@ declare -ga __base_bash_libs_cli_option_paths=() declare -ga __base_bash_libs_cli_positional_names=() declare -ga __base_bash_libs_cli_repeat_values=() declare -ga __base_bash_libs_cli_completion_candidates=() +declare -ga __base_bash_libs_cli_quick_columns=() +declare -g __base_bash_libs_cli_quick_depth=0 declare -ga BASE_BASH_LIBS_CLI_RESULT_POSITIONALS=() declare -gA BASE_BASH_LIBS_CLI_RESULT_OPTIONS=() declare -gA BASE_BASH_LIBS_CLI_RESULT_REPEATED=() @@ -301,6 +303,209 @@ __base_bash_libs_cli_declaration_usage__() { return 2 } +__base_bash_libs_cli_quick_parse_row__() { + local row="${1-}" + + __base_bash_libs_cli_quick_columns=() + IFS='|' read -r -a __base_bash_libs_cli_quick_columns <<< "$row" + ((${#__base_bash_libs_cli_quick_columns[@]} >= 1)) || { + __base_bash_libs_cli_declaration_usage__ 'base_cli_declare: each row must start with model, command, option, or positional.' + return 2 + } + [[ -n "${__base_bash_libs_cli_quick_columns[0]}" ]] || { + __base_bash_libs_cli_declaration_usage__ 'base_cli_declare: row kind cannot be empty.' + return 2 + } + __base_bash_libs_cli_parse_attrs__ "${__base_bash_libs_cli_quick_columns[@]:1}" || return $? +} + +__base_bash_libs_cli_quick_validate_keys__() { + local kind="$1" key + + for key in "${!__base_bash_libs_cli_attrs[@]}"; do + case "$kind:$key" in + model:name | model:version | model:description | model:handler | \ + command:path | command:description | command:handler | command:aliases | \ + option:path | option:name | option:type | option:tokens | option:help | \ + option:metavar | option:default | option:required | option:enum | \ + option:validator | option:conflicts | option:sensitive | option:hidden | \ + positional:path | positional:name | positional:help | positional:metavar | \ + positional:default | positional:required | positional:enum | \ + positional:validator | positional:repeatable) ;; + *) + __base_bash_libs_cli_declaration_usage__ \ + "base_cli_declare: attribute '$key' is not valid for a $kind row." + return 2 + ;; + esac + done +} + +__base_bash_libs_cli_quick_path_depth__() { + local path="${1-}" depth=0 + + [[ -n "$path" ]] || { + __base_bash_libs_cli_quick_depth=0 + return 0 + } + while [[ "$path" == */* ]]; do + path="${path#*/}" + depth=$((depth + 1)) + done + __base_bash_libs_cli_quick_depth=$((depth + 1)) +} + +# base_cli_declare - Build a model from compact pipe-delimited declaration rows. +# +# Usage: base_cli_declare MODEL [ROW...] +# base_cli_declare MODEL <<'EOF' +# model|name=tool|version=1.0.0|description=Example CLI +# command|path=admin|description=Administration +# option|path=admin|name=verbose|type=flag|tokens=--verbose,-v +# positional|path=admin|name=target|required=true +# EOF +# +# Rows are data, never shell code. Values may contain spaces; `|` is the field +# delimiter. The model row is applied first and command rows are applied from +# shallowest to deepest path, so declarations may be ordered for readability. +# Options and positionals use the same attributes as base_cli_option and +# base_cli_positional. Option tokens are supplied as one comma-separated +# `tokens=` field. When ROW arguments are omitted, rows are read from stdin. +base_cli_declare() { + local model="${1-}" line kind row path description name type tokens_value key depth + local model_row_count=0 max_depth=0 line_number=0 + local -a rows=() model_row=() command_rows=() option_rows=() positional_rows=() + local -a declaration_args=() option_tokens=() + + (($# >= 1)) || { + __base_bash_libs_cli_declaration_usage__ 'base_cli_declare: expected a model identifier and declaration rows.' + return 2 + } + __base_bash_libs_cli_valid_model__ "$model" || { + __base_bash_libs_cli_declaration_usage__ "base_cli_declare: invalid model '$model'." + return 2 + } + shift + if (($# > 0)); then + rows=("$@") + else + while IFS= read -r line || [[ -n "$line" ]]; do + [[ "$line" =~ ^[[:space:]]*$ || "$line" =~ ^[[:space:]]*# ]] && continue + rows+=("$line") + done + fi + ((${#rows[@]} > 0)) || { + __base_bash_libs_cli_declaration_usage__ 'base_cli_declare: at least one declaration row is required.' + return 2 + } + + # Parse every row before mutating the model. This catches malformed fields, + # unknown row kinds, and missing required table columns up front. + for row in "${rows[@]}"; do + line_number=$((line_number + 1)) + __base_bash_libs_cli_quick_parse_row__ "$row" || { + __base_bash_libs_cli_error__ "base_cli_declare: invalid row $line_number." + return 2 + } + kind="${__base_bash_libs_cli_quick_columns[0]}" + case "$kind" in + model) + model_row_count=$((model_row_count + 1)) + ((model_row_count == 1)) || { + __base_bash_libs_cli_declaration_usage__ 'base_cli_declare: exactly one model row is required.' + return 2 + } + __base_bash_libs_cli_quick_validate_keys__ "$kind" || return $? + model_row=("${__base_bash_libs_cli_quick_columns[@]:1}") + ;; + command | option | positional) + __base_bash_libs_cli_quick_validate_keys__ "$kind" || return $? + [[ -n "${__base_bash_libs_cli_attrs[path]+set}" ]] || { + __base_bash_libs_cli_declaration_usage__ "base_cli_declare: $kind row requires path=." + return 2 + } + path="${__base_bash_libs_cli_attrs[path]}" + if [[ "$kind" == command ]]; then + [[ -n "${__base_bash_libs_cli_attrs[description]+set}" ]] || { + __base_bash_libs_cli_declaration_usage__ 'base_cli_declare: command row requires description=.' + return 2 + } + __base_bash_libs_cli_quick_path_depth__ "$path" + depth="$__base_bash_libs_cli_quick_depth" + ((depth > max_depth)) && max_depth="$depth" + command_rows+=("$row") + elif [[ "$kind" == option ]]; then + for key in name type tokens; do + [[ -n "${__base_bash_libs_cli_attrs[$key]+set}" ]] || { + __base_bash_libs_cli_declaration_usage__ "base_cli_declare: option row requires $key=." + return 2 + } + done + option_rows+=("$row") + else + [[ -n "${__base_bash_libs_cli_attrs[name]+set}" ]] || { + __base_bash_libs_cli_declaration_usage__ 'base_cli_declare: positional row requires name=.' + return 2 + } + positional_rows+=("$row") + fi + ;; + *) + __base_bash_libs_cli_declaration_usage__ \ + "base_cli_declare: unknown row kind '$kind' on row $line_number." + return 2 + ;; + esac + done + ((model_row_count == 1)) || { + __base_bash_libs_cli_declaration_usage__ 'base_cli_declare: exactly one model row is required.' + return 2 + } + + base_cli_model_init "$model" "${model_row[@]}" || return $? + for ((depth = 1; depth <= max_depth; depth++)); do + for row in "${command_rows[@]}"; do + __base_bash_libs_cli_quick_parse_row__ "$row" || return $? + __base_bash_libs_cli_quick_path_depth__ "${__base_bash_libs_cli_attrs[path]}" + [[ "$__base_bash_libs_cli_quick_depth" -eq "$depth" ]] || continue + path="${__base_bash_libs_cli_attrs[path]}" + description="${__base_bash_libs_cli_attrs[description]}" + declaration_args=("$model" "$path" "$description") + [[ -n "${__base_bash_libs_cli_attrs[handler]+set}" ]] && + declaration_args+=("${__base_bash_libs_cli_attrs[handler]}") + [[ -n "${__base_bash_libs_cli_attrs[aliases]+set}" ]] && + declaration_args+=("aliases=${__base_bash_libs_cli_attrs[aliases]}") + base_cli_command "${declaration_args[@]}" || return $? + done + done + for row in "${option_rows[@]}"; do + __base_bash_libs_cli_quick_parse_row__ "$row" || return $? + path="${__base_bash_libs_cli_attrs[path]}" + name="${__base_bash_libs_cli_attrs[name]}" + type="${__base_bash_libs_cli_attrs[type]}" + tokens_value="${__base_bash_libs_cli_attrs[tokens]}" + IFS=, read -r -a option_tokens <<< "$tokens_value" + declaration_args=("$model" "$path" "$name" "$type" "${option_tokens[@]}") + for key in help metavar default required enum validator conflicts sensitive hidden; do + [[ -n "${__base_bash_libs_cli_attrs[$key]+set}" ]] && + declaration_args+=("$key=${__base_bash_libs_cli_attrs[$key]}") + done + base_cli_option "${declaration_args[@]}" || return $? + done + for row in "${positional_rows[@]}"; do + __base_bash_libs_cli_quick_parse_row__ "$row" || return $? + path="${__base_bash_libs_cli_attrs[path]}" + name="${__base_bash_libs_cli_attrs[name]}" + declaration_args=("$model" "$path" "$name") + for key in help metavar default required enum validator repeatable; do + [[ -n "${__base_bash_libs_cli_attrs[$key]+set}" ]] && + declaration_args+=("$key=${__base_bash_libs_cli_attrs[$key]}") + done + base_cli_positional "${declaration_args[@]}" || return $? + done + return 0 +} + # base_cli_model_init - Starts or replaces a named declarative CLI model. # # Usage: base_cli_model_init model [name=tool] [version=1.0.0] [description=text] diff --git a/lib/bash/cli/tests/lib_cli.bats b/lib/bash/cli/tests/lib_cli.bats index f1641fb..d3d9659 100644 --- a/lib/bash/cli/tests/lib_cli.bats +++ b/lib/bash/cli/tests/lib_cli.bats @@ -25,6 +25,39 @@ declare_demo_model() { base_cli_positional demo admin/user target required=true validator=valid_target help="Target name" } +@test "quick declaration builds an order-independent model from table rows" { + base_cli_declare quick <<'EOF' +# Rows are intentionally not in engine declaration order. +option|path=admin/user|name=verbose|type=flag|tokens=--verbose,-v|help=Verbose output +positional|path=admin/user|name=target|required=true|validator=valid_target +command|path=admin/user|description=Show a user|aliases=u +command|path=admin|description=Administration|aliases=manage +model|name=quick|version=2.0.0|description=Quick CLI +option|path=admin/user|name=color|type=value|tokens=--color|default=blue|enum=blue,green +EOF + + base_cli_parse quick -- manage u target-name --color green --verbose + + [ "$BASE_BASH_LIBS_CLI_RESULT_COMMAND" = "admin/user" ] + [ "${BASE_BASH_LIBS_CLI_RESULT_OPTIONS[color]}" = "green" ] + [ "${BASE_BASH_LIBS_CLI_RESULT_OPTIONS[verbose]}" = "1" ] + [ "${BASE_BASH_LIBS_CLI_RESULT_POSITIONALS[0]}" = "target-name" ] +} + +@test "quick declaration accepts argument rows and rejects unknown row kinds" { + base_cli_declare args \ + 'model|name=args|version=2.0.0' \ + 'command|path=run|description=Run' + base_cli_parse args -- run + [ "$BASE_BASH_LIBS_CLI_RESULT_COMMAND" = "run" ] + + bats_run base_cli_declare broken \ + 'model|name=broken' \ + 'unknown|path=run' + [ "$status" -eq 2 ] + [[ "$output" == *"unknown row kind 'unknown'"* ]] +} + @test "lib_cli can be sourced more than once" { source "$BASE_BASH_DIR/cli/lib_cli.sh"