Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion base_api_manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion docs/v2-api-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down
23 changes: 23 additions & 0 deletions lib/bash/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down
205 changes: 205 additions & 0 deletions lib/bash/cli/lib_cli.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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=()
Expand Down Expand Up @@ -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]
Expand Down
33 changes: 33 additions & 0 deletions lib/bash/cli/tests/lib_cli.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
Loading