#!/usr/bin/env bash # core/framework/command.sh # # Command definition, routing, and lazy loading. # # Usage in on_load: # command::define show "Show logs" [*] # command::define clean "Remove keepalives" [c, dump] # command::define watch "Watch live" [w, follow] # # Routing: # wgctl logs → cmd::logs::show::run (default) # wgctl logs clean → cmd::logs::clean::run # wgctl logs c → cmd::logs::clean::run (alias) # wgctl logs --fw → cmd::logs::show::run (default, flag passed through) # ── Storage ─────────────────────────────────────────────────────────────────── # Command definitions: "cmd:subcmd" → "desc|aliases|is_default" declare -gA _COMMAND_DEFS=() # Alias map: "cmd:alias" → "subcmd" declare -gA _COMMAND_ALIASES_MAP=() # Default subcommand per command: "cmd" → "subcmd" declare -gA _COMMAND_DEFAULT=() # Currently loading command context (set by load_command) declare -g _CURRENT_LOADING_CMD="" # Currently executing command context (set by command::run) declare -g _CURRENT_COMMAND="" # ── Definition ──────────────────────────────────────────────────────────────── # command::define "description" [*] or [alias1, alias2] # [*] marks as default subcommand function command::define() { local subcmd="${1:-}" local desc="${2:-}" local meta="${3:-}" # [*] or [alias1, alias2] or [*, alias1] local cmd="${_CURRENT_LOADING_CMD:-}" [[ -z "$cmd" || -z "$subcmd" ]] && return 1 local is_default=false local aliases="" if [[ -n "$meta" ]]; then # Strip brackets local inner="${meta#[}" inner="${inner%]}" # Check for * (default marker) if [[ "$inner" == *"*"* ]]; then is_default=true inner="${inner//\*/}" inner="${inner//,/}" inner="${inner// /}" fi # Remaining are aliases aliases="$inner" # Clean up leading/trailing commas and spaces aliases=$(echo "$aliases" | sed 's/^[, ]*//' | sed 's/[, ]*$//' | tr -s ' ,') fi # Store definition: desc|aliases|is_default _COMMAND_DEFS["${cmd}:${subcmd}"]="${desc}|${aliases}|${is_default}" # Register as default if $is_default; then _COMMAND_DEFAULT["$cmd"]="$subcmd" fi # Register aliases if [[ -n "$aliases" ]]; then local alias # Split aliases on comma or space while IFS=',' read -ra alias_list; do for alias in "${alias_list[@]}"; do alias="${alias// /}" [[ -n "$alias" ]] && _COMMAND_ALIASES_MAP["${cmd}:${alias}"]="$subcmd" done done <<< "$aliases" fi } # ── Routing ─────────────────────────────────────────────────────────────────── # command::route # Determines which subcommand to run and routes to it. # Returns: sets _ROUTED_SUBCMD and _ROUTED_ARGS declare -g _ROUTED_SUBCMD="" declare -ga _ROUTED_ARGS=() function command::route() { local cmd="$1" shift local -a args=("$@") _ROUTED_SUBCMD="" _ROUTED_ARGS=() # Check if _COMMAND_DEFS has any entries for this cmd local has_defs=false local key for key in "${!_COMMAND_DEFS[@]}"; do [[ "$key" == "${cmd}:"* ]] && has_defs=true && break done if ! $has_defs; then # No subcommands defined — run directly _ROUTED_SUBCMD="" _ROUTED_ARGS=("${args[@]:-}") return 0 fi # Find first non-flag arg local first_nonflag="" first_nonflag_idx=-1 local i for (( i=0; i<${#args[@]}; i++ )); do if [[ "${args[$i]}" != --* && "${args[$i]}" != -* ]]; then first_nonflag="${args[$i]}" first_nonflag_idx=$i break fi done local matched_subcmd="" if [[ -n "$first_nonflag" ]]; then # Check direct match if [[ -n "${_COMMAND_DEFS[${cmd}:${first_nonflag}]+x}" ]]; then matched_subcmd="$first_nonflag" # Check alias elif [[ -n "${_COMMAND_ALIASES_MAP[${cmd}:${first_nonflag}]+x}" ]]; then matched_subcmd="${_COMMAND_ALIASES_MAP[${cmd}:${first_nonflag}]}" fi fi if [[ -n "$matched_subcmd" ]]; then # Remove matched subcmd from args local -a new_args=() for (( i=0; i<${#args[@]}; i++ )); do [[ $i -eq $first_nonflag_idx ]] && continue new_args+=("${args[$i]}") done _ROUTED_SUBCMD="$matched_subcmd" _ROUTED_ARGS=("${new_args[@]:-}") else # Use default subcommand local default_subcmd="${_COMMAND_DEFAULT[$cmd]:-}" _ROUTED_SUBCMD="$default_subcmd" _ROUTED_ARGS=("${args[@]:-}") fi } # ── Loading ─────────────────────────────────────────────────────────────────── # command::load_subcmd # Lazy-loads a subcommand's file and calls its on_load function command::load_subcmd() { local cmd="$1" subcmd="$2" [[ -z "$cmd" || -z "$subcmd" ]] && return 1 # Determine file path — commands//.sh local cmd_dir cmd_dir="${WGCTL_DIR}/commands/${cmd}" local subcmd_file="${cmd_dir}/${subcmd}.sh" [[ ! -f "$subcmd_file" ]] && return 1 _CURRENT_LOADING_CMD="${cmd}::${subcmd}" _CURRENT_COMMAND="${cmd}::${subcmd}" source "$subcmd_file" local on_load_fn="cmd::${cmd}::${subcmd}::on_load" if declare -f "$on_load_fn" &>/dev/null; then "$on_load_fn" fi _CURRENT_LOADING_CMD="" } function command::helpers() { local file="${1:-}" local cmd="${_CURRENT_LOADING_CMD:-}" [[ -z "$file" || -z "$cmd" ]] && return 1 local cmd_name="${cmd%%::*}" local path="$(ctx::commands)/${cmd_name}/${file}" [[ -f "$path" ]] && source "$path" } # ── Run ─────────────────────────────────────────────────────────────────────── # command::run_routed # Runs a subcommand after routing is resolved. function command::run_routed() { local cmd="$1" subcmd="$2" shift 2 local -a args=("$@") _CURRENT_COMMAND="${cmd}::${subcmd}" # Apply command defaults (only for default subcommand) local default_subcmd="${_COMMAND_DEFAULT[$cmd]:-}" local -a default_args=() if [[ "$subcmd" == "$default_subcmd" || -z "$default_subcmd" ]]; then local defaults="${_COMMAND_DEFAULTS[$cmd]:-}" if [[ -n "$defaults" ]]; then read -ra default_args <<< "$defaults" fi fi local -a user_args=() [[ ${#args[@]} -gt 0 ]] && user_args=("${args[@]}") # Resolve exclusive group conflicts local groups="${_FLAG_EXCLUSIVE_GROUPS[$cmd]:-}" if [[ -n "$groups" && ${#default_args[@]} -gt 0 && ${#user_args[@]} -gt 0 ]]; then command::_resolve_conflicts default_args user_args "$groups" fi # Combine args local -a final_args=() for _d in "${default_args[@]:-}"; do [[ -n "$_d" ]] && final_args+=("$_d") done for _u in "${user_args[@]:-}"; do [[ -n "$_u" ]] && final_args+=("$_u") done # Preprocess mixin flags (--json, --no-color etc) command::_preprocess_flags final_args # Run local run_fn="cmd::${cmd}::${subcmd}::run" if declare -f "$run_fn" &>/dev/null; then if [[ ${#final_args[@]} -gt 0 ]]; then "$run_fn" "${final_args[@]}" else "$run_fn" fi else log::error "Run function not found: ${run_fn}" return 1 fi } # ── Helpers ─────────────────────────────────────────────────────────────────── # command::has_subcmds # Returns 0 if command has defined subcommands function command::has_subcmds() { local cmd="$1" key for key in "${!_COMMAND_DEFS[@]}"; do [[ "$key" == "${cmd}:"* ]] && return 0 done return 1 } # command::subcmds # Prints list of subcommand names function command::subcmds() { local cmd="$1" key for key in "${!_COMMAND_DEFS[@]}"; do [[ "$key" == "${cmd}:"* ]] && echo "${key#${cmd}:}" done } # ============================================ # Command Registry # ============================================ declare -A _LOADED_COMMANDS=() readonly _COMMAND_NAMESPACE="cmd" readonly _COMMAND_AUTO_LOAD_HOOK="on_load" _CURRENT_LOADING_CMD="" # ============================================ # Helpers # ============================================ function command::loaded() { [[ -n "${_LOADED_COMMANDS["$1"]:-}" ]]; } # Convert path-style name to namespace # e.g. service/wireguard -> service::wireguard function command::to_namespace() { echo "${1//\//:}"; } # Build fully qualified function name # e.g. command::fn "add" "run" -> cmd::add::run # e.g. command::fn "service/wg" "run" -> cmd::service::wg::run function command::fn() { local name namespace namespace=$(command::to_namespace "$1") echo "${_COMMAND_NAMESPACE}::${namespace}::${2}" } function command::has_function() { declare -F "$(command::fn "$1" "$2")" >/dev/null 2>&1; } function command::is_auto_load() { declare -F "$(command::fn "$1" on_load)" >/dev/null 2>&1; } function command::exists() { local name="$1" # New-style: has subcommands defined command::has_subcmds "$name" && return 0 # Legacy: has cmd::name::run function declare -f "$(command::fn "$name" run)" &>/dev/null } # ============================================ # Runner # ============================================ function command::run() { local cmd="$1" shift command::_reset_mixin_state # Check if this command uses new directory-based routing if command::has_subcmds "$cmd"; then # Route to subcommand command::route "$cmd" "$@" local subcmd="$_ROUTED_SUBCMD" local -a routed_args=("${_ROUTED_ARGS[@]:-}") # Lazy load subcommand file local subcmd_file="$(ctx::commands)/${cmd}/${subcmd}.sh" if [[ -f "$subcmd_file" ]]; then _CURRENT_LOADING_CMD="${cmd}::${subcmd}" _CURRENT_COMMAND="${cmd}::${subcmd}" source "$subcmd_file" core::call_if_exists "cmd::${cmd}::${subcmd}::on_load" _CURRENT_LOADING_CMD="" for arg in "$@"; do [[ "$arg" == "--help" || "$arg" == "-h" ]] && { hook::fire "command:help:${cmd}" "$cmd" "$_ROUTED_SUBCMD" return 0 } done fi command::run_routed "$cmd" "$subcmd" "${routed_args[@]:-}" return $? fi # Legacy path — existing command::run logic local -a default_args=() local defaults="${_COMMAND_DEFAULTS[$cmd]:-}" if [[ -n "$defaults" ]]; then read -ra default_args <<< "$defaults" fi local -a user_args=() [[ $# -gt 0 ]] && user_args=("$@") local groups="${_FLAG_EXCLUSIVE_GROUPS[$cmd]:-}" if [[ -n "$groups" && ${#default_args[@]} -gt 0 && ${#user_args[@]} -gt 0 ]]; then command::_resolve_conflicts default_args user_args "$groups" fi local -a args=() for _d in "${default_args[@]:-}"; do [[ -n "$_d" ]] && args+=("$_d"); done for _u in "${user_args[@]:-}"; do [[ -n "$_u" ]] && args+=("$_u"); done command::_preprocess_flags args local fn fn=$(command::fn "$cmd" run) if [[ ${#args[@]} -gt 0 ]]; then core::call_function "$fn" "${args[@]}" else core::call_function "$fn" fi } function core::call_function() { local fn="$1" shift "$fn" "$@" } # ============================================ # Loader # ============================================ function load_command() { local name="$1" command::loaded "$name" && return 0 # Check for new directory-based structure first local cmd_dir cmd_dir="$(ctx::commands)/${name}" local cmd_file="${cmd_dir}/${name}.sh" if [[ -d "$cmd_dir" && -f "$cmd_file" ]]; then source "$cmd_file" _LOADED_COMMANDS["$name"]=1 _CURRENT_LOADING_CMD="$name" core::call_if_exists "cmd::${name}::on_load" _CURRENT_LOADING_CMD="" return 0 fi # Fall back to legacy .command.sh local path path="$(ctx::commands)/${name}.command.sh" if [[ ! -f "$path" ]]; then log::error "Command not found: ${name} (${path})" return 1 fi source "$path" _LOADED_COMMANDS["$name"]=1 _CURRENT_LOADING_CMD="$name" core::call_if_exists "$(command::fn "$name" on_load)" _CURRENT_LOADING_CMD="" return 0 }