diff --git a/.gitmodules b/.gitmodules index 15a62b5f..08a06558 100644 --- a/.gitmodules +++ b/.gitmodules @@ -10,3 +10,6 @@ [submodule "cli/support/bats-mock"] path = cli/support/bats-mock url = https://github.com/buildkite-plugins/bats-mock +[submodule "cli/lib/betteropts"] + path = cli/lib/betteropts + url = https://github.com/VirtusLab/betteropts/ diff --git a/cli/README.md b/cli/README.md index 73df128a..fc2f1780 100644 --- a/cli/README.md +++ b/cli/README.md @@ -2,7 +2,27 @@ Command-line tool for managing sandcat configurations and Docker Compose setups. -Requires `docker` (and `docker compose`) and [`yq`](https://github.com/mikefarah/yq). +Requires Bash >= 4.2, `docker` (and `docker compose`), and [`yq`](https://github.com/mikefarah/yq). +macOS ships Bash 3.2 by default; install a newer one (e.g. `brew install bash`) and make sure it's +first on `PATH`. + +Argument parsing is handled by [betteropts](https://github.com/VirtusLab/betteropts), included as a +git submodule at `lib/betteropts`. Every command supports `--help` for usage details. + +## Shell Completion + +Bash completion (module and command names, plus each command's own flags/arguments) is available +via `--completion`: + +```bash +# Try it for the current shell session +source <(sandcat --completion) + +# Or add it to your ~/.bashrc for persistence +echo 'source <(sandcat --completion)' >> ~/.bashrc +``` + +Only bash is supported. ## Modules and Commands diff --git a/cli/bin/sandcat b/cli/bin/sandcat index 42a7bf2b..af9d36a3 100755 --- a/cli/bin/sandcat +++ b/cli/bin/sandcat @@ -8,42 +8,142 @@ # shellcheck disable=2292 if [ -z "${BASH_VERSION-}" ] || [[ -z "${BASH_VERSINFO-}" ]] || - ((BASH_VERSINFO[0] < 3 || (BASH_VERSINFO[0] == 3 && BASH_VERSINFO[1] < 2))) + ((BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 2))) then - echo "$0: this program needs to be run by Bash >= 3.2" >&2 + echo "$0: this program needs to be run by Bash >= 4.2" >&2 exit 1 fi set -euo pipefail +shopt -s inherit_errexit lastpipe + +sandcat_modules() { + find "$SCT_LIBEXECDIR" \ + -mindepth 1 \ + -maxdepth 1 \ + -type d \ + -print | + while IFS= read -r + do + basename -- "$REPLY" + done | + sort +} + +sandcat_commands() { + local module="$1" + + [[ -d "$SCT_LIBEXECDIR/$module" ]] || return 0 + + find "$SCT_LIBEXECDIR/$module" \ + -mindepth 1 \ + -maxdepth 1 \ + -type f \ + \( -perm -100 -o -perm -010 -o -perm -001 \) \ + -print | + while IFS= read -r + do + basename -- "$REPLY" + done | + sort +} + +sandcat_compgen() { + local -a words=("$@") + local cur="${words[$((${#words[@]} - 1))]}" + + case "${#words[@]}" in + 1 | 2) + local -a modules + sandcat_modules | + readarray -t modules + compgen -W "${modules[*]}" -- "$cur" + ;; + 3) + local module="${words[1]}" + local -a commands + sandcat_commands "$module" | + readarray -t commands + compgen -W "${commands[*]}" -- "$cur" + ;; + *) + local module="${words[1]}" command="${words[2]}" + # Delegates to the leaf's own betteropts --__complete implementation + # (the native completion wire protocol), unfiltered - the shell's + # compgen/complete machinery does the prefix filtering. + "$SCT_LIBEXECDIR/$module/$command" --__complete -- "${words[@]:3}" 2>/dev/null || true + ;; + esac +} + +sandcat_completion_script() { + local target_shell="$1" + if [[ "$target_shell" != "bash" ]] + then + echo "Unsupported shell: $target_shell (only bash is supported)" >&2 + return 1 + fi + cat </dev/null) +} +complete -F _sandcat_completions -o nosort sandcat +EOF +} + +# Prints the shortest name that still invokes $1: its basename if the bare +# name on $PATH resolves back to $1, otherwise $1 unchanged. +sandcat_resolve_bin() { + local bin="$1" + local name_on_path + name_on_path="$(command -v -- "$(basename -- "$bin")" 2>/dev/null)" || true + # shellcheck disable=SC2312 + if [[ -n "$name_on_path" ]] && + [[ "$(readlink -f -- "$name_on_path")" == "$(readlink -f -- "$bin")" ]] + then + basename -- "$bin" + else + echo "$bin" + fi +} main() { + # shellcheck disable=SC2312 SCT_ROOT="$(dirname -- "$(readlink -f -- "${BASH_SOURCE[0]}")")/.." export SCT_ROOT export SCT_LIBDIR="$SCT_ROOT/lib" export SCT_LIBEXECDIR="$SCT_ROOT/libexec" export SCT_TEMPLATEDIR="$SCT_ROOT/templates" - # shellcheck source=../lib/compat.bash - source "$SCT_LIBDIR/compat.bash" + SCT_BIN="$(sandcat_resolve_bin "${BASH_SOURCE[0]}")" + export SCT_BIN + + if [[ "${1:-}" == "--__complete" ]] + then + shift + sandcat_compgen "$@" + exit 0 + fi + + if [[ "${1:-}" == "--completion" ]] + then + sandcat_completion_script "${2:-bash}" + exit 0 + fi local -a modules - mapfile -t modules < <( - find "$SCT_LIBEXECDIR" \ - -mindepth 1 \ - -maxdepth 1 \ - -type d \ - -print | - while IFS= read -r - do - basename -- "$REPLY" - done | - sort - ) + # shellcheck disable=SC2312 + mapfile -t modules < <(sandcat_modules) local -r usage=" -Usage: ${BASH_SOURCE[0]} <$(IFS='|'; echo "${modules[*]}")> [command] [args...] +Usage: $SCT_BIN <$(IFS='|'; echo "${modules[*]}")> [command] [args...] -To list available commands use: ${BASH_SOURCE[0]} help +To list available commands use: $SCT_BIN help " if [[ $# -lt 1 ]] || [[ "$1" == "--help" ]] || [[ "$1" == "-h" ]] @@ -75,17 +175,8 @@ To list available commands use: ${BASH_SOURCE[0]} help if [[ "$command" == "help" ]] || [[ "$command" == "--help" ]] || [[ "$command" == "-h" ]] then echo "Commands in $module:" >&2 - find "$SCT_LIBEXECDIR/$module" \ - -maxdepth 1 \ - -type f \ - -perm -0100 \ - -print | - while IFS= read -r - do - echo -n ' ' - basename -- "$REPLY" - done | - sort >&2 + sandcat_commands "$module" | + sed 's/^/ /' >&2 exit 0 fi @@ -113,4 +204,7 @@ To list available commands use: ${BASH_SOURCE[0]} help exec "$exec" "$@" } -main "$@" +if [[ "${BASH_SOURCE[0]}" == "${0}" ]] +then + main "$@" +fi diff --git a/cli/lib/betteropts b/cli/lib/betteropts new file mode 160000 index 00000000..acd72be0 --- /dev/null +++ b/cli/lib/betteropts @@ -0,0 +1 @@ +Subproject commit acd72be01842942f57ce3ec9c9a06b1f79c39bd1 diff --git a/cli/libexec/attach/attach b/cli/libexec/attach/attach index d66a0764..63a76745 100755 --- a/cli/libexec/attach/attach +++ b/cli/libexec/attach/attach @@ -1,23 +1,38 @@ #!/usr/bin/env bash set -euo pipefail +# shellcheck source=../../lib/betteropts/betteropts.sh +source "$SCT_LIBDIR/betteropts/betteropts.sh" # shellcheck source=../../lib/require.bash source "$SCT_LIBDIR/require.bash" # shellcheck source=../../lib/path.bash source "$SCT_LIBDIR/path.bash" +# shellcheck disable=SC2154 # populated by betteropts_parse attach() { + summary "Attaches to a running agent container." + + description " +Opens an interactive shell in the container by default (bash --login), or +runs the given command instead. +" + + argument command passthrough \ + help="Command to run inside the agent container (default: bash --login)" + + betteropts_parse "$@" + require docker local compose_file compose_file="$(find_compose_file)" - if (($# == 0)) + if ((${#command[@]} == 0)) then exec docker compose -f "$compose_file" exec -u vscode agent bash --login fi - exec docker compose -f "$compose_file" exec -u vscode agent "$@" + exec docker compose -f "$compose_file" exec -u vscode agent "${command[@]}" } if [[ "${BASH_SOURCE[0]}" == "${0}" ]] diff --git a/cli/libexec/cache/list b/cli/libexec/cache/list index 7359b08d..93575602 100755 --- a/cli/libexec/cache/list +++ b/cli/libexec/cache/list @@ -1,6 +1,8 @@ #!/usr/bin/env bash set -euo pipefail +# shellcheck source=../../lib/betteropts/betteropts.sh +source "$SCT_LIBDIR/betteropts/betteropts.sh" # shellcheck source=../../lib/require.bash source "$SCT_LIBDIR/require.bash" # shellcheck source=../../lib/cache.bash @@ -9,6 +11,10 @@ source "$SCT_LIBDIR/cache.bash" # Lists every host-scoped shared-cache volume with its size, file count, # and any container currently mounting it. list() { + summary "Lists every host-scoped shared-cache volume with its size, file count, and any container mounting it." + + betteropts_parse "$@" + require docker local -a names diff --git a/cli/libexec/cache/rm b/cli/libexec/cache/rm index 678c4367..e2812e7a 100755 --- a/cli/libexec/cache/rm +++ b/cli/libexec/cache/rm @@ -1,6 +1,8 @@ #!/usr/bin/env bash set -euo pipefail +# shellcheck source=../../lib/betteropts/betteropts.sh +source "$SCT_LIBDIR/betteropts/betteropts.sh" # shellcheck source=../../lib/require.bash source "$SCT_LIBDIR/require.bash" # shellcheck source=../../lib/cache.bash @@ -8,10 +10,9 @@ source "$SCT_LIBDIR/cache.bash" # shellcheck source=../../lib/logging.bash source "$SCT_LIBDIR/logging.bash" -# Removes one or all shared-cache volumes. Refuses (unless --force) when -# a running container still holds a volume open, because docker rm would -# fail with an unclear error and the user's build would keep behaving as -# if the cache were live. +# Business-logic usage error (missing target, or --all combined with a +# volume name) - distinct from betteropts' own --help, which covers +# unrecognized flags/malformed CLI syntax. usage() { cat >&2 <|--all [--force] [--yes] @@ -24,27 +25,37 @@ EOF exit 1 } +# Removes one or all shared-cache volumes. Refuses (unless --force) when +# a running container still holds a volume open, because docker rm would +# fail with an unclear error and the user's build would keep behaving as +# if the cache were live. +# shellcheck disable=SC2154 # populated by betteropts_parse rm_cmd() { + summary "Removes one or all shared-cache volumes." + + flag all --all \ + help="Remove every sandcat-shared-cache-labelled volume" + + flag force --force \ + help="Skip the \"container still using this volume\" check" + + flag yes -y --yes \ + help="Skip the confirmation prompt" + + argument target optional \ + help="Full volume name, e.g. sandcat-cache-maven" + + betteropts_parse "$@" + require docker - local target="" - local all=false force=false yes=false - while [[ $# -gt 0 ]]; do - case $1 in - --all) all=true ;; - --force) force=true ;; - --yes|-y) yes=true ;; - -h|--help) usage ;; - -*) echo "Unknown flag: $1" >&2; usage ;; - *) [[ -n "$target" ]] && { echo "Only one volume name accepted" >&2; usage; } - target=$1 ;; - esac - shift - done + if "$all" && [[ -n "$target" ]]; then + echo "Use --all OR a volume name, not both" >&2 + usage + fi local -a targets=() - if [[ $all == "true" ]]; then - [[ -n "$target" ]] && { echo "Use --all OR a volume name, not both" >&2; usage; } + if "$all"; then mapfile -t targets < <(sct_cache_volume_names) else [[ -z "$target" ]] && usage @@ -58,7 +69,7 @@ rm_cmd() { # Container-in-use check first — abort BEFORE the confirmation prompt # so the user isn't asked to confirm a removal that will fail. - if [[ $force != "true" ]]; then + if ! "$force"; then local name in_use for name in "${targets[@]}"; do in_use=$(sct_cache_volume_containers "$name" | paste -sd, -) @@ -71,7 +82,7 @@ rm_cmd() { fi # Show what will happen + ask, unless --yes given. - if [[ $yes != "true" ]]; then + if ! "$yes"; then echo "The following shared-cache volumes will be removed from the host:" local name for name in "${targets[@]}"; do @@ -85,7 +96,7 @@ rm_cmd() { # --force implies docker rm --force too, so an in-use volume yields # a docker-native error (rare — we already checked, but belt+braces). local rm_flags=() - [[ $force == "true" ]] && rm_flags+=(--force) + "$force" && rm_flags+=(--force) docker volume rm "${rm_flags[@]+"${rm_flags[@]}"}" "${targets[@]}" } diff --git a/cli/libexec/cache/size b/cli/libexec/cache/size index 24c2fe1a..3442d22a 100755 --- a/cli/libexec/cache/size +++ b/cli/libexec/cache/size @@ -1,6 +1,8 @@ #!/usr/bin/env bash set -euo pipefail +# shellcheck source=../../lib/betteropts/betteropts.sh +source "$SCT_LIBDIR/betteropts/betteropts.sh" # shellcheck source=../../lib/require.bash source "$SCT_LIBDIR/require.bash" # shellcheck source=../../lib/cache.bash @@ -9,6 +11,10 @@ source "$SCT_LIBDIR/cache.bash" # Prints the total size of all shared-cache volumes on this host — quick # one-liner useful for deciding whether it's worth cleaning up. size() { + summary "Prints the total size of all shared-cache volumes on this host." + + betteropts_parse "$@" + require docker local -a names diff --git a/cli/libexec/compose/compose b/cli/libexec/compose/compose index 81fe1e61..14d093a3 100755 --- a/cli/libexec/compose/compose +++ b/cli/libexec/compose/compose @@ -1,6 +1,8 @@ #!/usr/bin/env bash set -euo pipefail +# shellcheck source=../../lib/betteropts/betteropts.sh +source "$SCT_LIBDIR/betteropts/betteropts.sh" # shellcheck source=../../lib/require.bash source "$SCT_LIBDIR/require.bash" # shellcheck source=../../lib/path.bash @@ -8,13 +10,21 @@ source "$SCT_LIBDIR/path.bash" # Helper that automatically locates the docker compose file # All arguments are passed to docker compose +# shellcheck disable=SC2154 # populated by betteropts_parse compose() { + summary "Runs docker compose with the project's compose file automatically located." + + argument compose_args passthrough \ + help="Arguments forwarded to docker compose" + + betteropts_parse "$@" + require docker local compose_file compose_file="$(find_compose_file)" - exec docker compose -f "$compose_file" "$@" + exec docker compose -f "$compose_file" "${compose_args[@]}" } if [[ "${BASH_SOURCE[0]}" == "${0}" ]] diff --git a/cli/libexec/destroy/destroy b/cli/libexec/destroy/destroy index af3d402a..1b9da429 100755 --- a/cli/libexec/destroy/destroy +++ b/cli/libexec/destroy/destroy @@ -1,6 +1,8 @@ #!/usr/bin/env bash set -euo pipefail +# shellcheck source=../../lib/betteropts/betteropts.sh +source "$SCT_LIBDIR/betteropts/betteropts.sh" # shellcheck source=../../lib/logging.bash source "$SCT_LIBDIR/logging.bash" # shellcheck source=../../lib/constants.bash @@ -10,14 +12,25 @@ source "$SCT_LIBDIR/path.bash" # shellcheck source=../../lib/select.bash source "$SCT_LIBDIR/select.bash" +# shellcheck disable=SC2154 # populated by betteropts_parse destroy() { + summary "Removes all sandcat configuration and containers from a project." + + description " +Stops running containers, removes volumes, and deletes configuration +directories. +" + + flag force -f --force \ + help="Skip the confirmation prompt" + + betteropts_parse "$@" + local repo_root repo_root="$(find_repo_root)" - case "${1:-}" in - -f | --force) - ;; - "") + if ! "$force" + then echo "This will stop containers, remove volumes, and delete .devcontainer and $SCT_PROJECT_DIR directories." | warning if [[ "$(select_yes_no "Continue?")" == "false" ]] @@ -25,12 +38,7 @@ destroy() { echo "Aborting" | warning return 0 fi - ;; - *) - echo "$0: unknown parameter: $1" >&2 - return 1 - ;; - esac + fi echo "Stopping containers" | info "$SCT_LIBEXECDIR/compose/compose" down --volumes diff --git a/cli/libexec/edit/compose b/cli/libexec/edit/compose index f8c54b17..44ac57e2 100755 --- a/cli/libexec/edit/compose +++ b/cli/libexec/edit/compose @@ -1,6 +1,8 @@ #!/usr/bin/env bash set -euo pipefail +# shellcheck source=../../lib/betteropts/betteropts.sh +source "$SCT_LIBDIR/betteropts/betteropts.sh" # shellcheck source=../../lib/path.bash source "$SCT_LIBDIR/path.bash" # shellcheck source=../../lib/select.bash @@ -9,22 +11,23 @@ source "$SCT_LIBDIR/select.bash" source "$SCT_LIBDIR/logging.bash" # Opens the compose file in an editor. Warns if containers need restarting. +# shellcheck disable=SC2154 # populated by betteropts_parse edit() { - local no_restart="${SANDCAT_NO_RESTART:-false}" + summary "Opens the Docker Compose file in your editor." - while [[ $# -gt 0 ]] - do - case $1 in - --no-restart) - no_restart=true - shift - ;; - *) - echo "Unknown option: $1" | error - return 1 - ;; - esac - done + description " +Warns if containers need restarting after changes are saved. +" + + flag no_restart --no-restart \ + help="Do not automatically restart containers after changes" + + betteropts_parse "$@" + + if [[ "${SANDCAT_NO_RESTART:-false}" == "true" ]] + then + no_restart=true + fi local compose_file compose_file="$(find_compose_file)" diff --git a/cli/libexec/edit/dockerfile b/cli/libexec/edit/dockerfile index ad87ec75..f41be67d 100755 --- a/cli/libexec/edit/dockerfile +++ b/cli/libexec/edit/dockerfile @@ -1,6 +1,8 @@ #!/usr/bin/env bash set -euo pipefail +# shellcheck source=../../lib/betteropts/betteropts.sh +source "$SCT_LIBDIR/betteropts/betteropts.sh" # shellcheck source=../../lib/logging.bash source "$SCT_LIBDIR/logging.bash" # shellcheck source=../../lib/select.bash @@ -10,6 +12,10 @@ source "$SCT_LIBDIR/path.bash" # Opens the Dockerfile.app in the default editor. dockerfile() { + summary "Opens the Dockerfile.app in your editor." + + betteropts_parse "$@" + local repo_root repo_root="$(find_repo_root)" diff --git a/cli/libexec/edit/edit b/cli/libexec/edit/edit index 27ef62ad..19cceaa9 100755 --- a/cli/libexec/edit/edit +++ b/cli/libexec/edit/edit @@ -1,5 +1,19 @@ #!/usr/bin/env bash set -euo pipefail -echo "Usage: sandcat edit " >&2 -exit 1 +# shellcheck source=../../lib/betteropts/betteropts.sh +source "$SCT_LIBDIR/betteropts/betteropts.sh" + +edit() { + summary "Edit sandcat configuration (compose, project-settings, user-settings, or dockerfile)." + + betteropts_parse "$@" + + echo "Usage: sandcat edit " >&2 + exit 1 +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]] +then + edit "$@" +fi diff --git a/cli/libexec/edit/project-settings b/cli/libexec/edit/project-settings index 02d6e959..9d6755b9 100755 --- a/cli/libexec/edit/project-settings +++ b/cli/libexec/edit/project-settings @@ -1,6 +1,8 @@ #!/usr/bin/env bash set -euo pipefail +# shellcheck source=../../lib/betteropts/betteropts.sh +source "$SCT_LIBDIR/betteropts/betteropts.sh" # shellcheck source=../../lib/logging.bash source "$SCT_LIBDIR/logging.bash" # shellcheck source=../../lib/constants.bash @@ -12,6 +14,10 @@ source "$SCT_LIBDIR/path.bash" # Opens the project network settings file in the default editor. project-settings() { + summary "Opens the project network settings file in your editor." + + betteropts_parse "$@" + local repo_root repo_root="$(find_repo_root)" diff --git a/cli/libexec/edit/user-settings b/cli/libexec/edit/user-settings index 714980b8..fcea92c4 100755 --- a/cli/libexec/edit/user-settings +++ b/cli/libexec/edit/user-settings @@ -1,6 +1,8 @@ #!/usr/bin/env bash set -euo pipefail +# shellcheck source=../../lib/betteropts/betteropts.sh +source "$SCT_LIBDIR/betteropts/betteropts.sh" # shellcheck source=../../lib/logging.bash source "$SCT_LIBDIR/logging.bash" # shellcheck source=../../lib/select.bash @@ -11,6 +13,10 @@ source "$SCT_LIBDIR/constants.bash" # Opens the user-level settings file (~/.config/sandcat/settings.json) in the # default editor. user-settings() { + summary "Opens the user-level settings file in your editor." + + betteropts_parse "$@" + local user_settings user_settings="$(sct_home)/settings.json" diff --git a/cli/libexec/init/devcontainer b/cli/libexec/init/devcontainer index f08bcf4e..b32dd20f 100755 --- a/cli/libexec/init/devcontainer +++ b/cli/libexec/init/devcontainer @@ -1,6 +1,8 @@ #!/usr/bin/env bash set -euo pipefail +# shellcheck source=../../lib/betteropts/betteropts.sh +source "$SCT_LIBDIR/betteropts/betteropts.sh" # shellcheck source=../../lib/logging.bash source "$SCT_LIBDIR/logging.bash" # shellcheck source=../../lib/composefile.bash @@ -10,78 +12,49 @@ source "$SCT_LIBDIR/devcontainer.bash" # Sets up a Devcontainer configuration for an agent. # Copies devcontainer template files and customizes the compose-all.yml. -# Args: -# --settings-file - Path to the settings file (relative to project directory) -# --project-path - Path to the project directory -# --agent - The agent name (e.g., "claude") -# --ide - The IDE name (e.g., "vscode", "jetbrains", "none") (optional) +# shellcheck disable=SC2154 # populated by betteropts_parse devcontainer() { - local settings_file="" - local project_path="" - local agent="" - local ide="" - local name="" - local stacks="" - local proxy_mode="web" - local secret_provider="none" - - while [[ $# -gt 0 ]] - do - case $1 in - --settings-file|--project-path|--agent|--ide|--name|--stacks|--proxy|--secret-provider) - if [[ $# -lt 2 ]]; then - echo "Option $1 requires a value" | error - return 1 - fi - ;; - esac - case $1 in - --settings-file) - settings_file="$2" - shift 2 - ;; - --project-path) - project_path="$2" - shift 2 - ;; - --agent) - agent="$2" - shift 2 - ;; - --ide) - ide="$2" - shift 2 - ;; - --name) - name="$2" - shift 2 - ;; - --stacks) - stacks="$2" - shift 2 - ;; - --proxy) - proxy_mode="$2" - shift 2 - ;; - --secret-provider) - secret_provider="$2" - shift 2 - ;; - --1password) - secret_provider="1password" - shift 1 - ;; - *) - echo "Unknown option: $1" | error - return 1 - ;; - esac - done - - : "${settings_file?Missing required parameter: --settings-file}" - : "${project_path?Missing required parameter: --project-path}" - : "${agent?Missing required parameter: --agent}" + summary "Sets up a Devcontainer configuration for an agent." + + description " +Copies devcontainer template files and customizes the compose-all.yml. +" + + option settings_file --settings-file VALUE \ + required \ + help="Path to the settings file (relative to project directory)" + + option project_path --project-path VALUE \ + required \ + help="Path to the project directory" + + option agent --agent VALUE \ + required \ + help="The agent name (e.g., claude)" + + option ide --ide VALUE \ + help="The IDE name (e.g., vscode, jetbrains, none)" + + option name --name VALUE \ + help="Project name for Docker Compose" + + option stacks --stacks VALUE \ + help="Space-separated list of resolved development stacks" + + option proxy_mode --proxy VALUE \ + default="web" \ + help="Proxy UI mode: web or tui" + + option secret_provider --secret-provider VALUE \ + default="none" \ + help="Secret backend: none, 1password, protonpass" + + flag onepassword_alias --1password \ + help="Deprecated; same as --secret-provider 1password" + + betteropts_parse "$@" + + "$onepassword_alias" && secret_provider="1password" local devcontainer_dir="$project_path/.devcontainer" local compose_file="$devcontainer_dir/compose-all.yml" diff --git a/cli/libexec/init/init b/cli/libexec/init/init index 53fe7394..761a5b68 100755 --- a/cli/libexec/init/init +++ b/cli/libexec/init/init @@ -1,6 +1,8 @@ #!/usr/bin/env bash set -euo pipefail +# shellcheck source=../../lib/betteropts/betteropts.sh +source "$SCT_LIBDIR/betteropts/betteropts.sh" # shellcheck source=../../lib/logging.bash source "$SCT_LIBDIR/logging.bash" # shellcheck source=../../lib/require.bash @@ -111,86 +113,71 @@ add_secret_provider_tokens_to_user_settings() { # Initializes sandcat for a project. # Prompts for any options not provided via flags. -# Args: -# --agent - Agent type (claude, cursor) -# --ide - IDE for devcontainer mode (vscode, jetbrains, none) -# --name - Project name for Docker Compose -# --path - Project directory path -# --stacks - Comma-separated list of development stacks (node,python,java,...) -# --proxy - Proxy UI mode: web (default) or tui -# --secret-provider / --sp - Secret backend: none, 1password, protonpass -# --1password - Deprecated; same as --secret-provider 1password -# --features - Comma-separated optional non-provider features (tui) +# shellcheck disable=SC2154 # populated by betteropts_parse init() { + summary "Initializes sandcat for a project." + + description " +Prompts for any options not provided via flags, then sets up the necessary +configuration files and network policy. +" + + option name --name VALUE \ + help="Project name for Docker Compose" + + option path --path VALUE \ + var=project_path \ + help="Project directory path" + + option agent --agent VALUE \ + type=choice choices="$(sct_available_agents | tr ' ' ',')" \ + help="Agent type (claude, cursor)" + + option ide --ide VALUE \ + type=choice choices=vscode,jetbrains,none \ + help="IDE for devcontainer mode (vscode, jetbrains, none)" + + option stacks_csv --stacks VALUE \ + help="Comma-separated list of development stacks (node,python,java,...)" + + option proxy_mode --proxy VALUE \ + help="Proxy UI mode: web (default) or tui" + + option features_csv --features VALUE \ + help="Comma-separated optional non-provider features (tui, no-shared-cache, no-gitignore, no-rtk)" + + option secret_provider --secret-provider VALUE \ + type=choice choices=none,1password,protonpass \ + help="Secret backend: none, 1password, protonpass" + + # betteropts allows only one long form per option, so --sp (a second + # double-dash alias, not a single-char short flag) needs its own + # declaration, merged into secret_provider below. + option secret_provider_sp --sp VALUE \ + type=choice choices=none,1password,protonpass \ + help="Alias for --secret-provider" + + flag onepassword_alias --1password \ + help="Deprecated; same as --secret-provider 1password" + + betteropts_parse "$@" + require yq - local name="" - local project_path="" - local agent="" - local ide="" - local stacks_csv="" - local stacks_provided=false - local proxy_mode="" - local secret_provider="" - local secret_provider_provided=false - local onepassword_alias=false - local features_csv="" - local features_provided=false - - while [[ $# -gt 0 ]] - do - case $1 in - --name|--path|--agent|--ide|--stacks|--proxy|--features|--secret-provider|--sp) - if [[ $# -lt 2 ]]; then - echo "Option $1 requires a value" | error - return 1 - fi - ;; - esac - case $1 in - --name) - name="$2" - shift 2 - ;; - --path) - project_path="$2" - shift 2 - ;; - --agent) - agent="$2" - shift 2 - ;; - --ide) - ide="$2" - shift 2 - ;; - --stacks) - stacks_csv="$2" - stacks_provided=true - shift 2 - ;; - --proxy) - proxy_mode="$2" - shift 2 - ;; - --secret-provider|--sp) - secret_provider="$2" - secret_provider_provided=true - shift 2 - ;; - --1password) - onepassword_alias=true - shift 1 - ;; - --features) - features_csv="$2" - features_provided=true - shift 2 - ;; - *) - echo "Unknown option: $1" | error - return 1 - ;; + [[ -n "$secret_provider_sp" ]] && secret_provider="$secret_provider_sp" + + # betteropts populates each option's value but not whether it was + # explicitly passed vs. defaulted - and here that distinction matters: + # an explicit `--stacks ""` (etc.) must skip the interactive prompt, + # not just an empty value. A plain presence scan over the raw args + # recovers it without re-implementing the parser. + local stacks_provided=false secret_provider_provided=false features_provided=false + local arg + for arg in "$@"; do + case "$arg" in + --stacks | --stacks=*) stacks_provided=true ;; + --secret-provider | --secret-provider=* | --sp | --sp=*) secret_provider_provided=true ;; + --features | --features=*) features_provided=true ;; esac done @@ -224,15 +211,11 @@ init() { name=$(read_line "Project name [$default_name]:") fi - local available_agents=() - read -ra available_agents <<< "$(sct_available_agents)" if [[ -z "$agent" ]] then + local available_agents=() + read -ra available_agents <<< "$(sct_available_agents)" agent=$(select_option "Select agent:" "${available_agents[@]}") - elif ! sct_is_valid_agent "$agent" - then - echo "Invalid agent: $agent (expected: ${available_agents[*]})" | error - return 1 fi if [[ -z "$name" ]] @@ -242,14 +225,10 @@ init() { local settings_file="$SCT_PROJECT_DIR/settings.json" - local available_ides=(vscode jetbrains none) if [[ -z "$ide" ]] then + local available_ides=(vscode jetbrains none) ide=$(select_option "Select IDE:" "${available_ides[@]}") - elif [[ ! " ${available_ides[*]} " =~ [[:space:]]${ide}[[:space:]] ]] - then - echo "Invalid IDE: $ide (expected: ${available_ides[*]})" | error - return 1 fi # Secret provider (none | 1password | protonpass) @@ -267,11 +246,6 @@ init() { fi fi - if [[ ! " none 1password protonpass " =~ [[:space:]]${secret_provider}[[:space:]] ]]; then - echo "Invalid secret provider: $secret_provider (expected: none 1password protonpass)" | error - return 1 - fi - # Optional non-provider features # tui → mitmproxy console instead of web UI # no-shared-cache → disable shared JVM dependency cache volumes diff --git a/cli/libexec/init/settings b/cli/libexec/init/settings index 6d8dd57a..11956192 100755 --- a/cli/libexec/init/settings +++ b/cli/libexec/init/settings @@ -1,6 +1,8 @@ #!/usr/bin/env bash set -euo pipefail +# shellcheck source=../../lib/betteropts/betteropts.sh +source "$SCT_LIBDIR/betteropts/betteropts.sh" # shellcheck source=../../lib/logging.bash source "$SCT_LIBDIR/logging.bash" # shellcheck source=../../lib/require.bash @@ -9,14 +11,19 @@ source "$SCT_LIBDIR/require.bash" source "$SCT_LIBDIR/constants.bash" # Creates a network settings file for the proxy. -# Args: -# $1 - Path to the settings file -# $@ - Service names to include (e.g., claude, copilot, vscode, jetbrains, github) # Outputs: # Creates a JSON settings file from template +# shellcheck disable=SC2154 # populated by betteropts_parse settings() { - local settings_file=$1 - shift + summary "Creates a network settings file for the proxy." + + argument settings_file required \ + help="Path to the settings file" + + argument services variadic \ + help="Service names to include (e.g., claude, copilot, vscode, jetbrains, github)" + + betteropts_parse "$@" require yq diff --git a/cli/libexec/proxy/proxy b/cli/libexec/proxy/proxy index 65d89d99..a8a3f5ad 100755 --- a/cli/libexec/proxy/proxy +++ b/cli/libexec/proxy/proxy @@ -1,6 +1,8 @@ #!/usr/bin/env bash set -euo pipefail +# shellcheck source=../../lib/betteropts/betteropts.sh +source "$SCT_LIBDIR/betteropts/betteropts.sh" # shellcheck source=../../lib/logging.bash source "$SCT_LIBDIR/logging.bash" # shellcheck source=../../lib/require.bash @@ -9,9 +11,16 @@ source "$SCT_LIBDIR/require.bash" source "$SCT_LIBDIR/path.bash" # Opens the mitmproxy interface for traffic inspection. -# In console mode (mitmdump): tails the proxy logs. -# In web mode (mitmweb): prints the URL of the web UI. proxy() { + summary "Opens the mitmproxy interface for traffic inspection." + + description " +In console mode (mitmdump): tails the proxy logs. +In web mode (mitmweb): prints the URL of the web UI. +" + + betteropts_parse "$@" + require docker local compose_file diff --git a/cli/libexec/restart-proxy/restart-proxy b/cli/libexec/restart-proxy/restart-proxy index b5ca6e81..f4a40f0d 100755 --- a/cli/libexec/restart-proxy/restart-proxy +++ b/cli/libexec/restart-proxy/restart-proxy @@ -1,6 +1,8 @@ #!/usr/bin/env bash set -euo pipefail +# shellcheck source=../../lib/betteropts/betteropts.sh +source "$SCT_LIBDIR/betteropts/betteropts.sh" # shellcheck source=../../lib/logging.bash source "$SCT_LIBDIR/logging.bash" # shellcheck source=../../lib/require.bash @@ -10,6 +12,10 @@ source "$SCT_LIBDIR/path.bash" # Restarts the mitmproxy and wg-client services to pick up settings changes. restart-proxy() { + summary "Restarts the mitmproxy and wg-client services to pick up settings changes." + + betteropts_parse "$@" + require docker local compose_file diff --git a/cli/libexec/run/run b/cli/libexec/run/run index 3c1cafa4..5d290ad1 100755 --- a/cli/libexec/run/run +++ b/cli/libexec/run/run @@ -1,6 +1,8 @@ #!/usr/bin/env bash set -euo pipefail +# shellcheck source=../../lib/betteropts/betteropts.sh +source "$SCT_LIBDIR/betteropts/betteropts.sh" # shellcheck source=../../lib/require.bash source "$SCT_LIBDIR/require.bash" # shellcheck source=../../lib/path.bash @@ -10,36 +12,39 @@ source "$SCT_LIBDIR/volume.bash" # Run a command in the agent container. # Starts dependencies, runs the command, then tears everything down. -# Options before '--' are passed to docker compose run (e.g. --build). -# Arguments after '--' (or all args if no '--') are the command to run. +# shellcheck disable=SC2154 # populated by betteropts_parse run() { + summary "Run a command in the agent container." + + description " +Starts dependencies, runs the command, then tears everything down. +" + + flag build --build \ + help="Rebuild images before running (passed to docker compose run)" + + argument command optional \ + default=bash \ + help="Command to run inside the agent container" + + argument command_args passthrough \ + help="Arguments forwarded to the command" + + betteropts_parse "$@" + require docker local compose_file compose_file="$(find_compose_file)" local -a run_opts=() - while [[ $# -gt 0 ]]; do - case $1 in - --) - shift - break - ;; - --build) - run_opts+=("$1") - shift - ;; - *) - break - ;; - esac - done + "$build" && run_opts+=(--build) warn_stale_home_volume "$compose_file" ensure_shared_cache_volumes "$compose_file" local rc=0 - docker compose -f "$compose_file" run --rm "${run_opts[@]+"${run_opts[@]}"}" agent "${1-bash}" "${@:2}" || rc=$? + docker compose -f "$compose_file" run --rm "${run_opts[@]+"${run_opts[@]}"}" agent "$command" "${command_args[@]}" || rc=$? docker compose -f "$compose_file" down return "$rc" } diff --git a/cli/libexec/version/version b/cli/libexec/version/version index e81cef8a..325bfc12 100755 --- a/cli/libexec/version/version +++ b/cli/libexec/version/version @@ -1,17 +1,31 @@ #!/usr/bin/env bash set -euo pipefail +# shellcheck source=../../lib/betteropts/betteropts.sh +source "$SCT_LIBDIR/betteropts/betteropts.sh" # shellcheck source=../../lib/logging.bash source "$SCT_LIBDIR/logging.bash" # Displays version information for the sandcat project. # Uses git to determine the version from commit date and SHA, or falls back to .version file. -# Args: -# $1 - Optional name to display (default: "Agent Sandbox") -# $2 - Optional path to repository (default: $SCT_ROOT) +# shellcheck disable=SC2154 # populated by betteropts_parse version() { - local -r name=${1-"Sandcat"} - local -r path=${2-$SCT_ROOT} + summary "Displays version information for the sandcat project." + + description " +Uses git to determine the version from commit date and SHA, or falls back +to a .version file. +" + + argument name optional \ + default="Sandcat" \ + help="Name to display" + + argument path optional \ + default="$SCT_ROOT" \ + help="Path to repository" + + betteropts_parse "$@" if [[ -s "$path/.version" ]] then @@ -34,4 +48,3 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]] then version "$@" fi - diff --git a/cli/test/init/init.bats b/cli/test/init/init.bats index 1b5e792b..ef49eb81 100644 --- a/cli/test/init/init.bats +++ b/cli/test/init/init.bats @@ -28,14 +28,16 @@ teardown() { @test "init rejects invalid --agent value" { run init --name my-project --agent "invalid" --path "$PROJECT_DIR" assert_failure - assert_output --partial "Invalid agent: invalid" + assert_output --partial "Invalid value:" + assert_output --partial "--agent invalid (choices: claude, cursor)" } @test "init rejects invalid --ide value" { run init --agent claude --ide "invalid" --name test --path "$PROJECT_DIR" assert_failure - assert_output --partial "Invalid IDE: invalid (expected: vscode jetbrains none)" + assert_output --partial "Invalid value:" + assert_output --partial "--ide invalid (choices: vscode, jetbrains, none)" } @test "init accepts valid --ide value" { @@ -61,7 +63,8 @@ teardown() { @test "init rejects invalid --secret-provider value" { run init --agent claude --ide vscode --name test --path "$PROJECT_DIR" --stacks "" --proxy web --features "" --secret-provider invalid assert_failure - assert_output --partial "Invalid secret provider: invalid (expected: none 1password protonpass)" + assert_output --partial "Invalid value:" + assert_output --partial "--secret-provider invalid (choices: none, 1password, protonpass)" } @test "init rejects combining --1password and --secret-provider" { @@ -346,7 +349,11 @@ teardown() { "--settings-file .sandcat/settings.json --project-path * --agent claude --ide vscode --name test --stacks * --proxy web --secret-provider none : :" \ "--settings-file .sandcat/settings.json --project-path * --agent claude --ide vscode --name test --stacks * --proxy web --secret-provider none : :" - init --agent claude --ide vscode --name test --path "$PROJECT_DIR" --stacks "" --features "" --secret-provider none >/dev/null + # Each `init` call re-declares its betteropts schema, which is process- + # global state - `run` forks a subshell, keeping the first call's schema + # from leaking into the second. + run init --agent claude --ide vscode --name test --path "$PROJECT_DIR" --stacks "" --features "" --secret-provider none + assert_success run init --agent claude --ide vscode --name test --path "$PROJECT_DIR" --stacks "" --features "" --secret-provider none assert_success assert_output --partial "Gitignore: Sandcat block already present" @@ -414,8 +421,9 @@ teardown() { "--settings-file .sandcat/settings.json --project-path * --agent claude --ide vscode --name test --stacks * --proxy web --secret-provider none : :" \ "--settings-file .sandcat/settings.json --project-path * --agent claude --ide vscode --name test --stacks * --proxy web --secret-provider none : :" - # First init adds block. - init --agent claude --ide vscode --name test --path "$PROJECT_DIR" --stacks "" --features "" --secret-provider none >/dev/null + # First init adds block. `run`: see the idempotent-re-init test above. + run init --agent claude --ide vscode --name test --path "$PROJECT_DIR" --stacks "" --features "" --secret-provider none + assert_success grep -qxF "# Sandcat" "$PROJECT_DIR/.gitignore" grep -qxF "# /Sandcat" "$PROJECT_DIR/.gitignore" @@ -441,7 +449,9 @@ teardown() { "--settings-file .sandcat/settings.json --project-path * --agent claude --ide vscode --name test --stacks * --proxy web --secret-provider none : :" \ "--settings-file .sandcat/settings.json --project-path * --agent claude --ide vscode --name test --stacks * --proxy web --secret-provider none : :" - init --agent claude --ide vscode --name test --path "$PROJECT_DIR" --stacks "" --features "" --secret-provider none >/dev/null + # `run`: see the idempotent-re-init test above. + run init --agent claude --ide vscode --name test --path "$PROJECT_DIR" --stacks "" --features "" --secret-provider none + assert_success # Env-var opt-out also removes. SANDCAT_GITIGNORE=false run init --agent claude --ide vscode --name test --path "$PROJECT_DIR" --stacks "" --features "" --secret-provider none