From ec7ffa8d68a0adf4397043eb98cc213b97bab481 Mon Sep 17 00:00:00 2001 From: Pierluigi Lenoci Date: Mon, 16 Mar 2026 21:47:27 +0100 Subject: [PATCH 1/5] fix(scripts): harden bash scripts with escape, compat, and cleanup fixes - common.sh: complete RFC 8259 JSON escape (\b, \f, strip control chars) - common.sh: distinguish python3 success-empty vs failure in resolve_template - check-prerequisites.sh: escape doc names through json_escape in fallback path - create-new-feature.sh: remove duplicate json_escape (already in common.sh) - create-new-feature.sh: warn on stderr when spec template is not found - update-agent-context.sh: move nested function to top-level for bash 3.2 compat --- scripts/bash/check-prerequisites.sh | 2 +- scripts/bash/common.sh | 14 +++- scripts/bash/create-new-feature.sh | 18 ++---- scripts/bash/update-agent-context.sh | 96 +++++++++++++++------------- 4 files changed, 70 insertions(+), 60 deletions(-) diff --git a/scripts/bash/check-prerequisites.sh b/scripts/bash/check-prerequisites.sh index 6f7c99e03..88a555946 100644 --- a/scripts/bash/check-prerequisites.sh +++ b/scripts/bash/check-prerequisites.sh @@ -168,7 +168,7 @@ if $JSON_MODE; then if [[ ${#docs[@]} -eq 0 ]]; then json_docs="[]" else - json_docs=$(printf '"%s",' "${docs[@]}") + json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done) json_docs="[${json_docs%,}]" fi printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$(json_escape "$FEATURE_DIR")" "$json_docs" diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 52e363e6d..4015083e4 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -161,7 +161,7 @@ has_jq() { } # Escape a string for safe embedding in a JSON value (fallback when jq is unavailable). -# Handles backslash, double-quote, and control characters (newline, tab, carriage return). +# Handles backslash, double-quote, and JSON-required control character escapes (RFC 8259). json_escape() { local s="$1" s="${s//\\/\\\\}" @@ -169,6 +169,10 @@ json_escape() { s="${s//$'\n'/\\n}" s="${s//$'\t'/\\t}" s="${s//$'\r'/\\r}" + s="${s//$'\b'/\\b}" + s="${s//$'\f'/\\f}" + # Strip remaining control characters (U+0000–U+001F) not individually escaped above + s=$(printf '%s' "$s" | tr -d '\000-\007\013\016-\037') printf '%s' "$s" } @@ -207,13 +211,17 @@ try: except Exception: sys.exit(1) " 2>/dev/null) - if [ $? -eq 0 ] && [ -n "$sorted_presets" ]; then + local python_rc=$? + if [ $python_rc -eq 0 ] && [ -n "$sorted_presets" ]; then while IFS= read -r preset_id; do local candidate="$presets_dir/$preset_id/templates/${template_name}.md" [ -f "$candidate" ] && echo "$candidate" && return 0 done <<< "$sorted_presets" + elif [ $python_rc -eq 0 ]; then + # python3 succeeded but registry has no presets — nothing to search + : else - # python3 returned empty list — fall through to directory scan + # python3 failed (missing, or registry parse error) — fall back to unordered directory scan for preset in "$presets_dir"/*/; do [ -d "$preset" ] || continue local candidate="$preset/templates/${template_name}.md" diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index 0823cca27..708cffade 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -162,17 +162,6 @@ clean_branch_name() { echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//' } -# Escape a string for safe embedding in a JSON value (fallback when jq is unavailable). -json_escape() { - local s="$1" - s="${s//\\/\\\\}" - s="${s//\"/\\\"}" - s="${s//$'\n'/\\n}" - s="${s//$'\t'/\\t}" - s="${s//$'\r'/\\r}" - printf '%s' "$s" -} - # Resolve repository root. Prefer git information when available, but fall back # to searching for repository markers so the workflow still functions in repositories that # were initialised with --no-git. @@ -310,7 +299,12 @@ mkdir -p "$FEATURE_DIR" TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") SPEC_FILE="$FEATURE_DIR/spec.md" -if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then cp "$TEMPLATE" "$SPEC_FILE"; else touch "$SPEC_FILE"; fi +if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then + cp "$TEMPLATE" "$SPEC_FILE" +else + echo "Warning: Spec template not found; created empty spec file" >&2 + touch "$SPEC_FILE" +fi # Inform the user how to persist the feature variable in their own shell printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2 diff --git a/scripts/bash/update-agent-context.sh b/scripts/bash/update-agent-context.sh index e0f285484..7b0912073 100644 --- a/scripts/bash/update-agent-context.sh +++ b/scripts/bash/update-agent-context.sh @@ -686,53 +686,61 @@ update_specific_agent() { esac } -update_all_existing_agents() { - local found_agent=false - local _updated_paths=() - - # Helper: skip non-existent files and files already updated (dedup by - # realpath so that variables pointing to the same file — e.g. AMP_FILE, - # KIRO_FILE, BOB_FILE all resolving to AGENTS_FILE — are only written once). - # Uses a linear array instead of associative array for bash 3.2 compatibility. - update_if_new() { - local file="$1" name="$2" - [[ -f "$file" ]] || return 0 - local real_path - real_path=$(realpath "$file" 2>/dev/null || echo "$file") - local p - if [[ ${#_updated_paths[@]} -gt 0 ]]; then - for p in "${_updated_paths[@]}"; do - [[ "$p" == "$real_path" ]] && return 0 - done - fi - update_agent_file "$file" "$name" || return 1 - _updated_paths+=("$real_path") - found_agent=true - } +# Helper: skip non-existent files and files already updated (dedup by +# realpath so that variables pointing to the same file — e.g. AMP_FILE, +# KIRO_FILE, BOB_FILE all resolving to AGENTS_FILE — are only written once). +# Uses a linear array instead of associative array for bash 3.2 compatibility. +# Note: defined at top level because bash does not support true nested/local +# functions. _updated_paths and _found_agent are reset at the start of each +# update_all_existing_agents call. +_updated_paths=() +_found_agent=false +# Note: both variables are reset at the start of update_all_existing_agents; +# do not rely on these top-level values outside that function. + +_update_if_new() { + local file="$1" name="$2" + [[ -f "$file" ]] || return 0 + local real_path + real_path=$(realpath "$file" 2>/dev/null || echo "$file") + local p + if [[ ${#_updated_paths[@]} -gt 0 ]]; then + for p in "${_updated_paths[@]}"; do + [[ "$p" == "$real_path" ]] && return 0 + done + fi + update_agent_file "$file" "$name" || return 1 + _updated_paths+=("$real_path") + _found_agent=true +} - update_if_new "$CLAUDE_FILE" "Claude Code" - update_if_new "$GEMINI_FILE" "Gemini CLI" - update_if_new "$COPILOT_FILE" "GitHub Copilot" - update_if_new "$CURSOR_FILE" "Cursor IDE" - update_if_new "$QWEN_FILE" "Qwen Code" - update_if_new "$AGENTS_FILE" "Codex/opencode" - update_if_new "$AMP_FILE" "Amp" - update_if_new "$KIRO_FILE" "Kiro CLI" - update_if_new "$BOB_FILE" "IBM Bob" - update_if_new "$WINDSURF_FILE" "Windsurf" - update_if_new "$KILOCODE_FILE" "Kilo Code" - update_if_new "$AUGGIE_FILE" "Auggie CLI" - update_if_new "$ROO_FILE" "Roo Code" - update_if_new "$CODEBUDDY_FILE" "CodeBuddy CLI" - update_if_new "$SHAI_FILE" "SHAI" - update_if_new "$TABNINE_FILE" "Tabnine CLI" - update_if_new "$QODER_FILE" "Qoder CLI" - update_if_new "$AGY_FILE" "Antigravity" - update_if_new "$VIBE_FILE" "Mistral Vibe" - update_if_new "$KIMI_FILE" "Kimi Code" +update_all_existing_agents() { + _found_agent=false + _updated_paths=() + + _update_if_new "$CLAUDE_FILE" "Claude Code" + _update_if_new "$GEMINI_FILE" "Gemini CLI" + _update_if_new "$COPILOT_FILE" "GitHub Copilot" + _update_if_new "$CURSOR_FILE" "Cursor IDE" + _update_if_new "$QWEN_FILE" "Qwen Code" + _update_if_new "$AGENTS_FILE" "Codex/opencode" + _update_if_new "$AMP_FILE" "Amp" + _update_if_new "$KIRO_FILE" "Kiro CLI" + _update_if_new "$BOB_FILE" "IBM Bob" + _update_if_new "$WINDSURF_FILE" "Windsurf" + _update_if_new "$KILOCODE_FILE" "Kilo Code" + _update_if_new "$AUGGIE_FILE" "Auggie CLI" + _update_if_new "$ROO_FILE" "Roo Code" + _update_if_new "$CODEBUDDY_FILE" "CodeBuddy CLI" + _update_if_new "$SHAI_FILE" "SHAI" + _update_if_new "$TABNINE_FILE" "Tabnine CLI" + _update_if_new "$QODER_FILE" "Qoder CLI" + _update_if_new "$AGY_FILE" "Antigravity" + _update_if_new "$VIBE_FILE" "Mistral Vibe" + _update_if_new "$KIMI_FILE" "Kimi Code" # If no agent files exist, create a default Claude file - if [[ "$found_agent" == false ]]; then + if [[ "$_found_agent" == false ]]; then log_info "No existing agent files found, creating default Claude file..." update_agent_file "$CLAUDE_FILE" "Claude Code" || return 1 fi From f4dfb202abfe1b0c13c71ae0df2dd91ae5d2c7ac Mon Sep 17 00:00:00 2001 From: Pierluigi Lenoci Date: Mon, 16 Mar 2026 21:55:30 +0100 Subject: [PATCH 2/5] fix(scripts): explicit resolve_template return code and best-effort agent updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - common.sh: resolve_template now returns 1 when no template is found, making the "not found" case explicit instead of relying on empty stdout - setup-plan.sh, create-new-feature.sh: add || true to resolve_template calls so set -e does not abort on missing templates (non-fatal) - update-agent-context.sh: accumulate errors in update_all_existing_agents instead of silently discarding them — all agents are attempted and the composite result is returned, matching the PowerShell equivalent behavior --- scripts/bash/common.sh | 7 +++-- scripts/bash/create-new-feature.sh | 2 +- scripts/bash/setup-plan.sh | 2 +- scripts/bash/update-agent-context.sh | 45 +++++++++++++++------------- 4 files changed, 30 insertions(+), 26 deletions(-) diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 4015083e4..eb4afb178 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -254,8 +254,9 @@ except Exception: local core="$base/${template_name}.md" [ -f "$core" ] && echo "$core" && return 0 - # Return success with empty output so callers using set -e don't abort; - # callers check [ -n "$TEMPLATE" ] to detect "not found". - return 0 + # Template not found in any location. + # Return 1 so callers can distinguish "not found" from "found". + # Callers running under set -e should use: TEMPLATE=$(resolve_template ...) || true + return 1 } diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index 708cffade..f7f59884a 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -297,7 +297,7 @@ fi FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME" mkdir -p "$FEATURE_DIR" -TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") +TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true SPEC_FILE="$FEATURE_DIR/spec.md" if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then cp "$TEMPLATE" "$SPEC_FILE" diff --git a/scripts/bash/setup-plan.sh b/scripts/bash/setup-plan.sh index 2a044c679..9f5523149 100644 --- a/scripts/bash/setup-plan.sh +++ b/scripts/bash/setup-plan.sh @@ -39,7 +39,7 @@ check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1 mkdir -p "$FEATURE_DIR" # Copy plan template if it exists -TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT") +TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT") || true if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then cp "$TEMPLATE" "$IMPL_PLAN" echo "Copied plan template to $IMPL_PLAN" diff --git a/scripts/bash/update-agent-context.sh b/scripts/bash/update-agent-context.sh index 7b0912073..88e3a2efc 100644 --- a/scripts/bash/update-agent-context.sh +++ b/scripts/bash/update-agent-context.sh @@ -717,33 +717,36 @@ _update_if_new() { update_all_existing_agents() { _found_agent=false _updated_paths=() - - _update_if_new "$CLAUDE_FILE" "Claude Code" - _update_if_new "$GEMINI_FILE" "Gemini CLI" - _update_if_new "$COPILOT_FILE" "GitHub Copilot" - _update_if_new "$CURSOR_FILE" "Cursor IDE" - _update_if_new "$QWEN_FILE" "Qwen Code" - _update_if_new "$AGENTS_FILE" "Codex/opencode" - _update_if_new "$AMP_FILE" "Amp" - _update_if_new "$KIRO_FILE" "Kiro CLI" - _update_if_new "$BOB_FILE" "IBM Bob" - _update_if_new "$WINDSURF_FILE" "Windsurf" - _update_if_new "$KILOCODE_FILE" "Kilo Code" - _update_if_new "$AUGGIE_FILE" "Auggie CLI" - _update_if_new "$ROO_FILE" "Roo Code" - _update_if_new "$CODEBUDDY_FILE" "CodeBuddy CLI" - _update_if_new "$SHAI_FILE" "SHAI" - _update_if_new "$TABNINE_FILE" "Tabnine CLI" - _update_if_new "$QODER_FILE" "Qoder CLI" - _update_if_new "$AGY_FILE" "Antigravity" - _update_if_new "$VIBE_FILE" "Mistral Vibe" - _update_if_new "$KIMI_FILE" "Kimi Code" + local _all_ok=true + + _update_if_new "$CLAUDE_FILE" "Claude Code" || _all_ok=false + _update_if_new "$GEMINI_FILE" "Gemini CLI" || _all_ok=false + _update_if_new "$COPILOT_FILE" "GitHub Copilot" || _all_ok=false + _update_if_new "$CURSOR_FILE" "Cursor IDE" || _all_ok=false + _update_if_new "$QWEN_FILE" "Qwen Code" || _all_ok=false + _update_if_new "$AGENTS_FILE" "Codex/opencode" || _all_ok=false + _update_if_new "$AMP_FILE" "Amp" || _all_ok=false + _update_if_new "$KIRO_FILE" "Kiro CLI" || _all_ok=false + _update_if_new "$BOB_FILE" "IBM Bob" || _all_ok=false + _update_if_new "$WINDSURF_FILE" "Windsurf" || _all_ok=false + _update_if_new "$KILOCODE_FILE" "Kilo Code" || _all_ok=false + _update_if_new "$AUGGIE_FILE" "Auggie CLI" || _all_ok=false + _update_if_new "$ROO_FILE" "Roo Code" || _all_ok=false + _update_if_new "$CODEBUDDY_FILE" "CodeBuddy CLI" || _all_ok=false + _update_if_new "$SHAI_FILE" "SHAI" || _all_ok=false + _update_if_new "$TABNINE_FILE" "Tabnine CLI" || _all_ok=false + _update_if_new "$QODER_FILE" "Qoder CLI" || _all_ok=false + _update_if_new "$AGY_FILE" "Antigravity" || _all_ok=false + _update_if_new "$VIBE_FILE" "Mistral Vibe" || _all_ok=false + _update_if_new "$KIMI_FILE" "Kimi Code" || _all_ok=false # If no agent files exist, create a default Claude file if [[ "$_found_agent" == false ]]; then log_info "No existing agent files found, creating default Claude file..." update_agent_file "$CLAUDE_FILE" "Claude Code" || return 1 fi + + [[ "$_all_ok" == true ]] } print_summary() { echo From 74bc9a9972d7e565ab737d47c0ab05cb90c07bd3 Mon Sep 17 00:00:00 2001 From: Pierluigi Lenoci Date: Mon, 16 Mar 2026 22:25:37 +0100 Subject: [PATCH 3/5] style(scripts): add clarifying comment in resolve_template preset branch --- scripts/bash/common.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index eb4afb178..1feb4aa7b 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -213,6 +213,7 @@ except Exception: " 2>/dev/null) local python_rc=$? if [ $python_rc -eq 0 ] && [ -n "$sorted_presets" ]; then + # python3 succeeded and returned preset IDs — search in priority order while IFS= read -r preset_id; do local candidate="$presets_dir/$preset_id/templates/${template_name}.md" [ -f "$candidate" ] && echo "$candidate" && return 0 From 7601f7b458835175f7e8b2f8f037643a6ac74495 Mon Sep 17 00:00:00 2001 From: Pierluigi Lenoci Date: Mon, 16 Mar 2026 22:43:54 +0100 Subject: [PATCH 4/5] fix(scripts): wrap python3 call in if-condition to prevent set -e abort Move the python3 command substitution in resolve_template into an if-condition so that a non-zero exit (e.g. invalid .registry JSON) does not abort the function under set -e. The fallback directory scan now executes as intended regardless of caller errexit settings. --- scripts/bash/common.sh | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 1feb4aa7b..826e740f0 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -198,9 +198,11 @@ resolve_template() { if [ -d "$presets_dir" ]; then local registry_file="$presets_dir/.registry" if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then - # Read preset IDs sorted by priority (lower number = higher precedence) - local sorted_presets - sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c " + # Read preset IDs sorted by priority (lower number = higher precedence). + # The python3 call is wrapped in an if-condition so that set -e does not + # abort the function when python3 exits non-zero (e.g. invalid JSON). + local sorted_presets="" + if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c " import json, sys, os try: with open(os.environ['SPECKIT_REGISTRY']) as f: @@ -210,17 +212,15 @@ try: print(pid) except Exception: sys.exit(1) -" 2>/dev/null) - local python_rc=$? - if [ $python_rc -eq 0 ] && [ -n "$sorted_presets" ]; then - # python3 succeeded and returned preset IDs — search in priority order - while IFS= read -r preset_id; do - local candidate="$presets_dir/$preset_id/templates/${template_name}.md" - [ -f "$candidate" ] && echo "$candidate" && return 0 - done <<< "$sorted_presets" - elif [ $python_rc -eq 0 ]; then +" 2>/dev/null); then + if [ -n "$sorted_presets" ]; then + # python3 succeeded and returned preset IDs — search in priority order + while IFS= read -r preset_id; do + local candidate="$presets_dir/$preset_id/templates/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 + done <<< "$sorted_presets" + fi # python3 succeeded but registry has no presets — nothing to search - : else # python3 failed (missing, or registry parse error) — fall back to unordered directory scan for preset in "$presets_dir"/*/; do From db9fb09a8c91fe31754ad294c4a8ac0f990991ce Mon Sep 17 00:00:00 2001 From: Pierluigi Lenoci Date: Mon, 16 Mar 2026 23:32:15 +0100 Subject: [PATCH 5/5] fix(scripts): track agent file existence before update and avoid top-level globals - _update_if_new now records the path and sets _found_agent before calling update_agent_file, so that failures do not cause duplicate attempts on aliased paths (AMP/KIRO/BOB -> AGENTS_FILE) or false "no agent files found" fallback triggers - Remove top-level initialisation of _updated_paths and _found_agent; they are now created exclusively inside update_all_existing_agents, keeping the script side-effect free when sourced --- scripts/bash/update-agent-context.sh | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/scripts/bash/update-agent-context.sh b/scripts/bash/update-agent-context.sh index 88e3a2efc..b4b901fb1 100644 --- a/scripts/bash/update-agent-context.sh +++ b/scripts/bash/update-agent-context.sh @@ -690,13 +690,10 @@ update_specific_agent() { # realpath so that variables pointing to the same file — e.g. AMP_FILE, # KIRO_FILE, BOB_FILE all resolving to AGENTS_FILE — are only written once). # Uses a linear array instead of associative array for bash 3.2 compatibility. -# Note: defined at top level because bash does not support true nested/local -# functions. _updated_paths and _found_agent are reset at the start of each -# update_all_existing_agents call. -_updated_paths=() -_found_agent=false -# Note: both variables are reset at the start of update_all_existing_agents; -# do not rely on these top-level values outside that function. +# Note: defined at top level because bash 3.2 does not support true +# nested/local functions. _updated_paths, _found_agent, and _all_ok are +# initialised exclusively inside update_all_existing_agents so that +# sourcing this script has no side effects on the caller's environment. _update_if_new() { local file="$1" name="$2" @@ -709,9 +706,12 @@ _update_if_new() { [[ "$p" == "$real_path" ]] && return 0 done fi - update_agent_file "$file" "$name" || return 1 + # Record the file as seen before attempting the update so that: + # (a) aliases pointing to the same path are not retried on failure + # (b) _found_agent reflects file existence, not update success _updated_paths+=("$real_path") _found_agent=true + update_agent_file "$file" "$name" } update_all_existing_agents() {