#!/bin/sh # # Freckle CLI public installer. # # Install with: # sh -c "$(curl -fsSL https://install.freckle.dev)" # # Optional environment overrides: # FRECKLE_VERSION=cli-v0.0.1-abcdef0 # FRECKLE_CLI_RELEASE_BASE_URL=https://releases.freckle.dev/cli # FRECKLE_INSTALL_DIR="$HOME/.local/bin" # FRECKLE_BINARY_NAME=freckle # FRECKLE_SKIP_SKILLS_INSTALL=1 # FRECKLE_SKIP_CLAUDE_PERMISSION_SETUP=1 # FRECKLE_SKIP_CODEX_PERMISSION_SETUP=1 # FRECKLE_SKIP_AUTH_SETUP=1 # FRECKLE_INSTALL_RECOMMENDED_TOOLS=1 # FRECKLE_SKIP_RECOMMENDED_TOOLS_SETUP=1 set -eu default_release_base_url="https://releases.freckle.dev/cli" tmp_binary= tmp_checksum= tmp_version= tmp_recommended_tool= tmp_recommended_tool_checksum= tmp_recommended_tool_dir= fail() { printf 'Error: %s\n' "$*" >&2 exit 1 } cleanup() { if [ -n "${tmp_binary:-}" ]; then rm -f "$tmp_binary" fi if [ -n "${tmp_checksum:-}" ]; then rm -f "$tmp_checksum" fi if [ -n "${tmp_version:-}" ]; then rm -f "$tmp_version" fi if [ -n "${tmp_recommended_tool:-}" ]; then rm -f "$tmp_recommended_tool" fi if [ -n "${tmp_recommended_tool_checksum:-}" ]; then rm -f "$tmp_recommended_tool_checksum" fi if [ -n "${tmp_recommended_tool_dir:-}" ]; then rm -rf "$tmp_recommended_tool_dir" fi } trim_trailing_slashes() { trim_value=$1 while [ "${trim_value%/}" != "$trim_value" ]; do trim_value=${trim_value%/} done printf '%s\n' "$trim_value" } read_first_line() { first_line= if IFS= read -r first_line < "$1"; then : fi printf '%s\n' "$first_line" } path_contains_dir() { path_target=$1 case ":${PATH:-}:" in *":$path_target:"*) return 0 ;; *) return 1 ;; esac } download_file() { download_url=$1 download_output=$2 if ! curl -fsSL "$download_url" -o "$download_output"; then fail "Could not download $download_url" fi } find_checksum_tool() { if command -v sha256sum >/dev/null 2>&1; then printf '%s\n' "sha256sum" return 0 fi if command -v shasum >/dev/null 2>&1; then printf '%s\n' "shasum" return 0 fi fail "No SHA-256 checksum tool found. Install sha256sum or shasum and retry." } validate_sha256() { sha_value=$1 case "$sha_value" in "") return 1 ;; *[!0123456789abcdefABCDEF]*) return 1 ;; *) ;; esac if [ "${#sha_value}" -ne 64 ]; then return 1 fi return 0 } validate_release_tag() { tag_name=$1 tag_value=$2 case "$tag_value" in "" | *[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-]*) fail "$tag_name must contain only letters, numbers, dots, underscores, and hyphens." ;; *) ;; esac } trim_ascii_whitespace() { printf '%s' "$1" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' } is_cli_command_name() { command_name=$1 case "$command_name" in "" | [!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789]* | *[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-]*) return 1 ;; *) return 0 ;; esac } resolve_rendered_cli_command_name() { env_command_name=$(trim_ascii_whitespace "${FRECKLE_CLI_COMMAND_NAME:-}") if is_cli_command_name "$env_command_name"; then printf '%s\n' "$env_command_name" return 0 fi if is_cli_command_name "$binary_name"; then case "$binary_name" in freckle*) printf '%s\n' "$binary_name" return 0 ;; *) ;; esac fi printf '%s\n' "freckle" } verify_checksum() { checksum_binary=$1 checksum_file=$2 checksum_tool=$3 expected_sha= actual_output= if IFS=' ' read -r expected_sha _ < "$checksum_file"; then : fi if ! validate_sha256 "$expected_sha"; then fail "Invalid SHA-256 sidecar for $asset_name" fi if [ "$checksum_tool" = "sha256sum" ]; then actual_output=$(sha256sum "$checksum_binary") || fail "Could not compute SHA-256 for $asset_name" else actual_output=$(shasum -a 256 "$checksum_binary") || fail "Could not compute SHA-256 for $asset_name" fi set -- $actual_output actual_sha=${1:-} if [ "$actual_sha" != "$expected_sha" ]; then fail "Checksum mismatch for $asset_name" fi } quote_shell_word() { quote_value=$1 quoted="'" while [ -n "$quote_value" ]; do quote_head=${quote_value%%\'*} quoted=$quoted$quote_head if [ "$quote_head" = "$quote_value" ]; then quote_value= else quoted=$quoted"'\\''" quote_value=${quote_value#*\'} fi done quoted=$quoted"'" printf '%s\n' "$quoted" } has_interactive_stdin() { [ "${FRECKLE_TEST_INTERACTIVE_STDIN:-}" = "1" ] || [ -t 0 ] } run_claude_settings_permission_osascript() { mode=$1 settings_path=$2 permission_entry=$3 osascript -l JavaScript -e ' ObjC.import("Foundation") function readSettingsDocument(path) { var fileManager = $.NSFileManager.defaultManager if (!fileManager.fileExistsAtPath(path)) return { exists: false, settings: {} } var raw = $.NSString.stringWithContentsOfFileEncodingError(path, $.NSUTF8StringEncoding, null) if (raw === null) throw new Error("Could not read Claude settings.") var text = ObjC.unwrap(raw) return { exists: true, settings: text.trim().length === 0 ? {} : JSON.parse(text) } } function ensureObject(value, label) { if (value === null || Array.isArray(value) || typeof value !== "object") { throw new Error(label + " must be an object.") } } function ensureAllowList(settings) { ensureObject(settings, "Claude settings") if (settings.permissions === undefined) settings.permissions = {} ensureObject(settings.permissions, "Claude permissions") if (settings.permissions.allow === undefined) settings.permissions.allow = [] if (!Array.isArray(settings.permissions.allow)) { throw new Error("Claude permissions.allow must be an array.") } if (!settings.permissions.allow.every(function(item) { return typeof item === "string" })) { throw new Error("Claude permissions.allow must contain only strings.") } return settings.permissions.allow } function resolveSettingsWritePath(path) { var fileManager = $.NSFileManager.defaultManager var symlinkDestination = ObjC.unwrap(fileManager.destinationOfSymbolicLinkAtPathError(path, null)) if (symlinkDestination === undefined) return path var destinationPath = symlinkDestination if (destinationPath.charAt(0) !== "/") { var parentPath = ObjC.unwrap($.NSURL.fileURLWithPath(path).URLByDeletingLastPathComponent.path) destinationPath = parentPath + "/" + destinationPath } return ObjC.unwrap($.NSURL.fileURLWithPath(destinationPath).URLByResolvingSymlinksInPath.path) } function run(argv) { var mode = argv[0] var settingsPath = argv[1] var permissionEntry = argv[2] var document try { document = readSettingsDocument(settingsPath) var allow = ensureAllowList(document.settings) } catch (error) { if (mode === "state") return "malformed" throw error } if (mode === "state") { if (!document.exists) return "missing" return allow.indexOf(permissionEntry) === -1 ? "absent" : "present" } if (mode !== "update") { throw new Error("Unknown Claude permission setup mode.") } if (allow.indexOf(permissionEntry) === -1) allow.push(permissionEntry) var output = JSON.stringify(document.settings, null, 2) + "\n" var outputString = $.NSString.alloc.initWithUTF8String(output) var ok = outputString.writeToFileAtomicallyEncodingError(resolveSettingsWritePath(settingsPath), true, $.NSUTF8StringEncoding, null) if (!ok) throw new Error("Could not write Claude settings.") } ' "$mode" "$settings_path" "$permission_entry" } claude_settings_permission_state() { run_claude_settings_permission_osascript state "$1" "$2" } update_claude_settings_permission() { run_claude_settings_permission_osascript update "$1" "$2" } configure_claude_cli_permission() { if [ "${FRECKLE_SKIP_CLAUDE_PERMISSION_SETUP:-}" = "1" ]; then return 0 fi if [ "$uname_s" != "Darwin" ]; then return 0 fi if [ -z "${HOME:-}" ]; then return 0 fi claude_dir=$HOME/.claude if [ ! -d "$claude_dir" ]; then return 0 fi if ! command -v osascript >/dev/null 2>&1; then return 0 fi if ! has_interactive_stdin; then return 0 fi cli_command_name=$(resolve_rendered_cli_command_name) permission_entry="Bash($cli_command_name:*)" settings_path=$claude_dir/settings.json permission_state=$(claude_settings_permission_state "$settings_path" "$permission_entry" 2>/dev/null || printf 'error') case "$permission_state" in present) return 0 ;; absent | missing) ;; malformed) if [ -f "$settings_path" ]; then printf 'Could not update Claude settings: %s is not valid Claude settings JSON.\n' "$settings_path" >&2 fi return 0 ;; *) return 0 ;; esac printf 'Allow Claude to run %s without prompting? [y/N] ' "$permission_entry" answer= if ! IFS= read -r answer; then return 0 fi case "$answer" in y | Y | yes | YES | Yes) ;; *) return 0 ;; esac if [ -f "$settings_path" ]; then backup_timestamp=$(date +%Y%m%d%H%M%S 2>/dev/null || printf 'unknown') backup_path=$settings_path.freckle-backup-$backup_timestamp if [ -e "$backup_path" ]; then backup_path=$backup_path.$$ fi cp "$settings_path" "$backup_path" || return 0 fi update_claude_settings_permission "$settings_path" "$permission_entry" >/dev/null 2>&1 || return 0 } codex_rules_file_has_pattern() { rules_path=$1 pattern_entry=$2 if [ ! -f "$rules_path" ]; then return 1 fi grep -F "pattern = $pattern_entry" "$rules_path" >/dev/null 2>&1 } append_codex_prefix_rule() { rules_path=$1 pattern_entry=$2 justification=$3 if codex_rules_file_has_pattern "$rules_path" "$pattern_entry"; then return 0 fi { printf '\n' printf 'prefix_rule(\n' printf ' pattern = %s,\n' "$pattern_entry" printf ' decision = "allow",\n' printf ' justification = "%s",\n' "$justification" printf ')\n' } >> "$rules_path" } configure_codex_cli_permission() { if [ "${FRECKLE_SKIP_CODEX_PERMISSION_SETUP:-}" = "1" ]; then return 0 fi if [ -z "${HOME:-}" ]; then return 0 fi codex_dir=$HOME/.codex if [ ! -d "$codex_dir" ]; then return 0 fi if ! has_interactive_stdin; then return 0 fi cli_command_name=$(resolve_rendered_cli_command_name) rules_dir=$codex_dir/rules rules_path=$rules_dir/default.rules auth_status_pattern="[\"$cli_command_name\", \"auth\", \"status\"]" config_pattern="[\"$cli_command_name\", \"config\"]" connections_pattern="[\"$cli_command_name\", \"connections\"]" org_pattern="[\"$cli_command_name\", \"org\"]" skills_pattern="[\"$cli_command_name\", \"skills\"]" workflow_pattern="[\"$cli_command_name\", \"workflow\"]" if codex_rules_file_has_pattern "$rules_path" "$auth_status_pattern" \ && codex_rules_file_has_pattern "$rules_path" "$config_pattern" \ && codex_rules_file_has_pattern "$rules_path" "$connections_pattern" \ && codex_rules_file_has_pattern "$rules_path" "$org_pattern" \ && codex_rules_file_has_pattern "$rules_path" "$skills_pattern" \ && codex_rules_file_has_pattern "$rules_path" "$workflow_pattern"; then return 0 fi printf 'Allow Codex to run Freckle CLI commands without prompting? [y/N] ' answer= if ! IFS= read -r answer; then return 0 fi case "$answer" in y | Y | yes | YES | Yes) ;; *) return 0 ;; esac mkdir -p "$rules_dir" || return 0 if [ -f "$rules_path" ]; then backup_timestamp=$(date +%Y%m%d%H%M%S 2>/dev/null || printf 'unknown') backup_path=$rules_path.freckle-backup-$backup_timestamp if [ -e "$backup_path" ]; then backup_path=$backup_path.$$ fi cp "$rules_path" "$backup_path" || return 0 fi append_codex_prefix_rule "$rules_path" "$auth_status_pattern" "Freckle auth status checks are approved for local Codex use" append_codex_prefix_rule "$rules_path" "$config_pattern" "Freckle config commands are approved for local Codex use" append_codex_prefix_rule "$rules_path" "$connections_pattern" "Freckle connection commands are approved for local Codex use" append_codex_prefix_rule "$rules_path" "$org_pattern" "Freckle organization commands are approved for local Codex use" append_codex_prefix_rule "$rules_path" "$skills_pattern" "Freckle skill management commands are approved for local Codex use" append_codex_prefix_rule "$rules_path" "$workflow_pattern" "Freckle workflow commands are approved for local Codex use" printf 'Freckle Codex command approvals installed. Restart Codex for them to take effect.\n' } recommended_tools_platform_supported() { [ "$uname_s" = "Darwin" ] && [ "$uname_m" = "arm64" ] } warn_recommended_tool_setup() { printf 'Warning: %s\n' "$*" >&2 printf 'Freckle CLI installation will continue; agents can still use Freckle but may be slower or less effective without recommended tooling.\n' >&2 } cleanup_recommended_tool_temp() { if [ -n "${tmp_recommended_tool:-}" ]; then rm -f "$tmp_recommended_tool" 2>/dev/null || : tmp_recommended_tool= fi if [ -n "${tmp_recommended_tool_checksum:-}" ]; then rm -f "$tmp_recommended_tool_checksum" 2>/dev/null || : tmp_recommended_tool_checksum= fi if [ -n "${tmp_recommended_tool_dir:-}" ]; then rm -rf "$tmp_recommended_tool_dir" 2>/dev/null || : tmp_recommended_tool_dir= fi } validate_recommended_release_tag() { case "$1" in "" | *[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-]*) return 1 ;; *) return 0 ;; esac } extract_json_tag_name() { json_line=$1 json_tag=${json_line#*\"tag_name\"} if [ "$json_tag" = "$json_line" ]; then return 1 fi json_tag=${json_tag#*:} case "$json_tag" in *\"*) ;; *) return 1 ;; esac json_tag=${json_tag#*\"} json_tag=${json_tag%%\"*} printf '%s\n' "$json_tag" } resolve_latest_ripgrep_tag() { ripgrep_latest_url=https://api.github.com/repos/BurntSushi/ripgrep/releases/latest ripgrep_tag= tmp_recommended_tool=$(mktemp "$install_dir/.ripgrep-latest.XXXXXX") || { warn_recommended_tool_setup "Could not create a temporary ripgrep release metadata file." tmp_recommended_tool= return 1 } if ! curl -fsSL "$ripgrep_latest_url" -o "$tmp_recommended_tool"; then warn_recommended_tool_setup "Could not resolve the latest ripgrep release from $ripgrep_latest_url." cleanup_recommended_tool_temp return 1 fi while IFS= read -r ripgrep_release_line; do case "$ripgrep_release_line" in *\"tag_name\"*) ripgrep_tag=$(extract_json_tag_name "$ripgrep_release_line" || printf '') if [ -n "$ripgrep_tag" ]; then break fi ;; *) ;; esac done < "$tmp_recommended_tool" cleanup_recommended_tool_temp if ! validate_recommended_release_tag "$ripgrep_tag"; then warn_recommended_tool_setup "Could not read a valid ripgrep release tag from $ripgrep_latest_url." return 1 fi printf '%s\n' "$ripgrep_tag" } verify_recommended_sha256() { recommended_checksum_binary=$1 recommended_checksum_file=$2 recommended_checksum_label=$3 recommended_expected_sha= recommended_actual_output= while IFS=' ' read -r recommended_candidate_sha recommended_candidate_name _; do case "${recommended_candidate_sha:-}" in '') continue ;; *) ;; esac if [ -z "${recommended_candidate_name:-}" ] \ || [ "$recommended_candidate_name" = "$recommended_checksum_label" ] \ || [ "${recommended_candidate_name##*/}" = "$recommended_checksum_label" ]; then recommended_expected_sha=$recommended_candidate_sha break fi done < "$recommended_checksum_file" if ! validate_sha256 "$recommended_expected_sha"; then warn_recommended_tool_setup "Invalid SHA-256 sidecar for $recommended_checksum_label." return 1 fi if [ "$checksum_tool" = "sha256sum" ]; then recommended_actual_output=$(sha256sum "$recommended_checksum_binary") || { warn_recommended_tool_setup "Could not compute SHA-256 for $recommended_checksum_label." return 1 } else recommended_actual_output=$(shasum -a 256 "$recommended_checksum_binary") || { warn_recommended_tool_setup "Could not compute SHA-256 for $recommended_checksum_label." return 1 } fi set -- $recommended_actual_output recommended_actual_sha=${1:-} if [ "$recommended_actual_sha" != "$recommended_expected_sha" ]; then warn_recommended_tool_setup "Checksum mismatch for $recommended_checksum_label." return 1 fi return 0 } install_recommended_ripgrep() { recommended_rg_target=$install_dir/rg recommended_rg_version= if command -v rg >/dev/null 2>&1; then printf 'rg is already available on PATH; skipping ripgrep installation.\n' return 0 fi if [ -e "$recommended_rg_target" ]; then warn_recommended_tool_setup "Could not install ripgrep because $recommended_rg_target already exists and rg is not on PATH; skipping to avoid overwriting it." return 0 fi if ! recommended_rg_tag=$(resolve_latest_ripgrep_tag); then return 0 fi recommended_rg_asset=ripgrep-$recommended_rg_tag-aarch64-apple-darwin.tar.gz recommended_rg_archive_root=ripgrep-$recommended_rg_tag-aarch64-apple-darwin recommended_rg_url=https://github.com/BurntSushi/ripgrep/releases/download/$recommended_rg_tag/$recommended_rg_asset recommended_rg_checksum_url=$recommended_rg_url.sha256 tmp_recommended_tool=$(mktemp "$install_dir/.rg.XXXXXX") || { warn_recommended_tool_setup "Could not create a temporary ripgrep archive file." tmp_recommended_tool= return 0 } tmp_recommended_tool_checksum=$(mktemp "$install_dir/.rg.sha256.XXXXXX") || { warn_recommended_tool_setup "Could not create a temporary ripgrep checksum file." cleanup_recommended_tool_temp return 0 } tmp_recommended_tool_dir=$(mktemp -d "$install_dir/.rg.extract.XXXXXX") || { warn_recommended_tool_setup "Could not create a temporary ripgrep extraction directory." cleanup_recommended_tool_temp return 0 } if ! curl -fsSL "$recommended_rg_url" -o "$tmp_recommended_tool"; then warn_recommended_tool_setup "Could not download ripgrep from $recommended_rg_url." cleanup_recommended_tool_temp return 0 fi if ! curl -fsSL "$recommended_rg_checksum_url" -o "$tmp_recommended_tool_checksum"; then warn_recommended_tool_setup "Could not download ripgrep checksum from $recommended_rg_checksum_url." cleanup_recommended_tool_temp return 0 fi if ! verify_recommended_sha256 "$tmp_recommended_tool" "$tmp_recommended_tool_checksum" "$recommended_rg_asset"; then cleanup_recommended_tool_temp return 0 fi if ! tar -xzf "$tmp_recommended_tool" -C "$tmp_recommended_tool_dir" "$recommended_rg_archive_root/rg" 2>/dev/null; then warn_recommended_tool_setup "Could not extract rg from $recommended_rg_asset." cleanup_recommended_tool_temp return 0 fi recommended_rg_extracted=$tmp_recommended_tool_dir/$recommended_rg_archive_root/rg if [ ! -f "$recommended_rg_extracted" ]; then warn_recommended_tool_setup "ripgrep archive did not contain $recommended_rg_archive_root/rg." cleanup_recommended_tool_temp return 0 fi if ! chmod 755 "$recommended_rg_extracted"; then warn_recommended_tool_setup "Could not mark rg executable." cleanup_recommended_tool_temp return 0 fi if ! mv "$recommended_rg_extracted" "$recommended_rg_target"; then warn_recommended_tool_setup "Could not install rg to $recommended_rg_target." cleanup_recommended_tool_temp return 0 fi cleanup_recommended_tool_temp if recommended_rg_version=$("$recommended_rg_target" --version 2>&1); then : else recommended_rg_status=$? rm -f "$recommended_rg_target" 2>/dev/null || : warn_recommended_tool_setup "Installed rg did not pass 'rg --version' smoke test (exit code $recommended_rg_status)." if [ -n "$recommended_rg_version" ]; then printf '%s\n' "$recommended_rg_version" >&2 fi return 0 fi printf 'Installed rg to %s\n' "$recommended_rg_target" } install_recommended_jq() { recommended_jq_target=$install_dir/jq recommended_jq_url=https://github.com/jqlang/jq/releases/latest/download/jq-macos-arm64 recommended_jq_checksum_url=https://github.com/jqlang/jq/releases/latest/download/sha256sum.txt recommended_jq_version= if command -v jq >/dev/null 2>&1; then printf 'jq is already available on PATH; skipping jq installation.\n' return 0 fi if [ -e "$recommended_jq_target" ]; then warn_recommended_tool_setup "Could not install jq because $recommended_jq_target already exists and jq is not on PATH; skipping to avoid overwriting it." return 0 fi tmp_recommended_tool=$(mktemp "$install_dir/.jq.XXXXXX") || { warn_recommended_tool_setup "Could not create a temporary jq install file." tmp_recommended_tool= return 0 } tmp_recommended_tool_checksum=$(mktemp "$install_dir/.jq.sha256.XXXXXX") || { warn_recommended_tool_setup "Could not create a temporary jq checksum file." cleanup_recommended_tool_temp return 0 } if ! curl -fsSL "$recommended_jq_url" -o "$tmp_recommended_tool"; then warn_recommended_tool_setup "Could not download jq from $recommended_jq_url." cleanup_recommended_tool_temp return 0 fi if ! curl -fsSL "$recommended_jq_checksum_url" -o "$tmp_recommended_tool_checksum"; then warn_recommended_tool_setup "Could not download jq checksum from $recommended_jq_checksum_url." cleanup_recommended_tool_temp return 0 fi if ! verify_recommended_sha256 "$tmp_recommended_tool" "$tmp_recommended_tool_checksum" "jq-macos-arm64"; then cleanup_recommended_tool_temp return 0 fi if ! chmod 755 "$tmp_recommended_tool"; then warn_recommended_tool_setup "Could not mark jq executable." cleanup_recommended_tool_temp return 0 fi if ! mv "$tmp_recommended_tool" "$recommended_jq_target"; then warn_recommended_tool_setup "Could not install jq to $recommended_jq_target." cleanup_recommended_tool_temp return 0 fi tmp_recommended_tool= cleanup_recommended_tool_temp if recommended_jq_version=$("$recommended_jq_target" --version 2>&1); then : else recommended_jq_status=$? rm -f "$recommended_jq_target" 2>/dev/null || : warn_recommended_tool_setup "Installed jq did not pass 'jq --version' smoke test (exit code $recommended_jq_status)." if [ -n "$recommended_jq_version" ]; then printf '%s\n' "$recommended_jq_version" >&2 fi return 0 fi printf 'Installed jq to %s\n' "$recommended_jq_target" } missing_recommended_tools() { recommended_tools_missing= if ! command -v rg >/dev/null 2>&1; then recommended_tools_missing=rg fi if ! command -v jq >/dev/null 2>&1; then if [ -n "$recommended_tools_missing" ]; then recommended_tools_missing="$recommended_tools_missing jq" else recommended_tools_missing=jq fi fi printf '%s\n' "$recommended_tools_missing" } format_recommended_tools_prompt_subject() { case "$1" in rg) printf '%s\n' "recommended agent tool rg" ;; jq) printf '%s\n' "recommended agent tool jq" ;; "rg jq") printf '%s\n' "recommended agent tools rg and jq" ;; *) return 1 ;; esac } run_recommended_tools_setup() { printf 'Recommended Agent Tooling Setup selected.\n' install_recommended_ripgrep install_recommended_jq } configure_recommended_tools_setup() { if [ "${FRECKLE_SKIP_RECOMMENDED_TOOLS_SETUP:-}" = "1" ]; then printf 'Skipped Recommended Agent Tooling Setup.\n' return 0 fi if ! recommended_tools_platform_supported; then if [ "${FRECKLE_INSTALL_RECOMMENDED_TOOLS:-}" = "1" ]; then printf 'Recommended Agent Tooling Setup is only supported on macOS ARM64; skipped.\n' fi return 0 fi if [ "${FRECKLE_INSTALL_RECOMMENDED_TOOLS:-}" = "1" ]; then run_recommended_tools_setup return 0 fi if ! has_interactive_stdin; then return 0 fi recommended_tools_missing=$(missing_recommended_tools) if [ -z "$recommended_tools_missing" ]; then return 0 fi if ! recommended_tools_prompt_subject=$(format_recommended_tools_prompt_subject "$recommended_tools_missing"); then return 0 fi printf 'Install missing %s to improve the quality and performance of coding agents working with Freckle? [y/N] ' "$recommended_tools_prompt_subject" recommended_tools_answer= if ! IFS= read -r recommended_tools_answer; then return 0 fi case "$recommended_tools_answer" in y | Y | yes | YES | Yes) run_recommended_tools_setup ;; *) printf 'Skipped Recommended Agent Tooling Setup.\n' ;; esac } install_bundled_freckle_skills() { printf 'Installing bundled Freckle skills globally...\n' if "$target_path" skills install --backup; then printf 'Freckle skills installed.\n' else skill_status=$? quoted_target=$(quote_shell_word "$target_path") printf 'Freckle CLI binary was installed to %s, but bundled skill installation failed.\n' "$target_path" >&2 printf 'Retry skill installation after resolving local filesystem issues:\n' >&2 printf ' %s skills install --backup\n' "$quoted_target" >&2 exit "$skill_status" fi } configure_skills_install() { if [ "${FRECKLE_SKIP_SKILLS_INSTALL:-}" = "1" ]; then printf 'Skipped Freckle skill installation.\n' return 0 fi if ! has_interactive_stdin; then install_bundled_freckle_skills return 0 fi printf 'Install bundled Freckle CLI skills globally? [Y/n] ' skills_answer= if ! IFS= read -r skills_answer; then install_bundled_freckle_skills return 0 fi case "$skills_answer" in n | N | no | NO | No) printf 'Skipped Freckle skill installation.\n' ;; *) install_bundled_freckle_skills ;; esac } trap cleanup EXIT HUP INT TERM release_base_url=$(trim_trailing_slashes "${FRECKLE_CLI_RELEASE_BASE_URL:-$default_release_base_url}") if [ -z "$release_base_url" ]; then fail "FRECKLE_CLI_RELEASE_BASE_URL must not be empty." fi requested_release_tag=${FRECKLE_VERSION:-} if [ -n "$requested_release_tag" ]; then validate_release_tag "FRECKLE_VERSION" "$requested_release_tag" fi binary_name=${FRECKLE_BINARY_NAME:-freckle} case "$binary_name" in "" | */*) fail "FRECKLE_BINARY_NAME must be a file name, not a path." ;; *) ;; esac install_dir=${FRECKLE_INSTALL_DIR:-} if [ -z "$install_dir" ]; then if [ -z "${HOME:-}" ]; then fail "HOME is required when FRECKLE_INSTALL_DIR is not set." fi install_dir=$HOME/.local/bin fi case "$install_dir" in /*) ;; *) current_dir=$(pwd -P) || fail "Could not resolve the current directory." install_dir=$current_dir/$install_dir ;; esac target_path=$install_dir/$binary_name uname_s=$(uname -s) uname_m=$(uname -m) case "$uname_s-$uname_m" in Linux-x86_64 | Linux-amd64) platform_name="Linux x64" asset_name="freckle-linux-x64" ;; Darwin-arm64) platform_name="macOS ARM64" asset_name="freckle-darwin-arm64" ;; *) fail "Unsupported platform: $uname_s-$uname_m. Supported platforms are Linux x64 and macOS ARM64." ;; esac checksum_tool=$(find_checksum_tool) tmp_version=$(mktemp "${TMPDIR:-/tmp}/freckle-version.XXXXXX") || fail "Could not create a temporary version file." if [ -n "$requested_release_tag" ]; then release_tag=$requested_release_tag else latest_version_url=$release_base_url/latest/version download_file "$latest_version_url" "$tmp_version" release_tag=$(read_first_line "$tmp_version") validate_release_tag "Downloaded latest release version pointer" "$release_tag" fi version_url=$release_base_url/$release_tag/version binary_url=$release_base_url/$release_tag/$asset_name checksum_url=$binary_url.sha256 download_file "$version_url" "$tmp_version" version_sidecar_tag=$(read_first_line "$tmp_version") if [ "$version_sidecar_tag" != "$release_tag" ]; then fail "Immutable release version sidecar for $release_tag contained $version_sidecar_tag." fi printf 'Installing Freckle CLI %s to %s\n' "$release_tag" "$target_path" printf 'Platform: %s\n' "$platform_name" mkdir -p "$install_dir" || fail "Could not create install directory $install_dir" tmp_binary=$(mktemp "$install_dir/.$binary_name.XXXXXX") || fail "Could not create a temporary install file." tmp_checksum=$(mktemp "${TMPDIR:-/tmp}/freckle-sha256.XXXXXX") || fail "Could not create a temporary checksum file." download_file "$binary_url" "$tmp_binary" download_file "$checksum_url" "$tmp_checksum" verify_checksum "$tmp_binary" "$tmp_checksum" "$checksum_tool" chmod 755 "$tmp_binary" || fail "Could not mark $binary_name executable." mv "$tmp_binary" "$target_path" || fail "Could not install $binary_name to $target_path." tmp_binary= printf 'Installed %s to %s\n' "$binary_name" "$target_path" if version_output=$("$target_path" --version 2>&1); then printf '%s\n' "$version_output" else version_status=$? printf 'Version confirmation failed with exit code %s; checksum verification passed, continuing.\n' "$version_status" >&2 if [ -n "$version_output" ]; then printf '%s\n' "$version_output" >&2 fi fi configure_skills_install configure_claude_cli_permission configure_codex_cli_permission quoted_target=$(quote_shell_word "$target_path") if [ "${FRECKLE_SKIP_AUTH_SETUP:-}" = "1" ]; then printf 'Skipped Freckle auth setup.\n' else auth_status=$("$target_path" auth status --porcelain 2>/dev/null || printf 'unknown') case "$auth_status" in authenticated) printf 'Freckle CLI is already authenticated.\n' ;; invalid) printf 'Freckle CLI has a stored token, but it is invalid or expired.\n' printf 'Run %s auth to re-authenticate.\n' "$quoted_target" ;; network-unreachable) printf 'Freckle CLI has a stored token, but the Freckle HTTP API could not be reached to verify it.\n' printf 'Run %s auth status again after network access is available.\n' "$quoted_target" ;; not-authenticated) if has_interactive_stdin; then printf 'No Freckle auth token found. Starting authentication setup...\n' if "$target_path" auth; then printf 'Freckle auth setup completed.\n' else printf 'Freckle CLI is installed, but authentication did not complete.\n' >&2 printf 'Run %s auth to log in.\n' "$quoted_target" >&2 fi else printf 'No Freckle auth token found.\n' printf 'Run %s auth to log in.\n' "$quoted_target" fi ;; *) printf 'Could not determine Freckle auth status.\n' >&2 printf 'Run %s auth status to check authentication.\n' "$quoted_target" >&2 ;; esac fi configure_recommended_tools_setup if ! path_contains_dir "$install_dir"; then printf 'Add Freckle to your PATH:\n' printf ' export PATH="%s:$PATH"\n' "$install_dir" fi