diff --git a/README.md b/README.md index b7f3751..4d7ece9 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,8 @@ cac env rm # 删除环境 cac env set [name] proxy # 设置 / 修改代理 cac env set [name] proxy --remove # 移除代理 cac env set [name] version # 切换版本 +cac env set [name] timezone # 设置时区,例如 America/Los_Angeles +cac env set [name] language # 设置语言,例如 en_US.UTF-8 或 en-US cac # 激活环境(快捷方式) cac ls # = cac env ls ``` @@ -125,7 +127,7 @@ cac ls # = cac env ls | `cac env create [-p proxy] [-c ver] [--clone] [--telemetry mode] [--persona preset]` | 创建环境(自动激活,`--telemetry transparent/stealth/paranoid` 控制遥测,`--persona macos-vscode/...` 用于容器) | | `cac env ls` | 列出环境 | | `cac env rm ` | 删除环境 | -| `cac env set [name] ` | 修改环境(proxy / version / telemetry / persona) | +| `cac env set [name] ` | 修改环境(proxy / version / timezone / language / telemetry / persona) | | `cac env check [-d]` | 验证当前环境(`-d` 显示详情) | | `cac ` | 激活环境 | | **自管理** | | @@ -287,6 +289,8 @@ cac env rm # remove environment cac env set [name] proxy # set / change proxy cac env set [name] proxy --remove # remove proxy cac env set [name] version # change version +cac env set [name] timezone # set timezone, e.g. America/Los_Angeles +cac env set [name] language # set LANG, e.g. en_US.UTF-8 or en-US cac # activate (shortcut) cac ls # = cac env ls ``` @@ -310,7 +314,7 @@ Each environment is fully isolated: | `cac env create [-p proxy] [-c ver] [--clone] [--telemetry mode] [--persona preset]` | Create environment (auto-activates, `--telemetry transparent/stealth/paranoid` for telemetry control, `--persona macos-vscode/...` for containers) | | `cac env ls` | List environments | | `cac env rm ` | Remove environment | -| `cac env set [name] ` | Modify environment (proxy / version / telemetry / persona) | +| `cac env set [name] ` | Modify environment (proxy / version / timezone / language / telemetry / persona) | | `cac env check [-d]` | Verify current environment (`-d` for details) | | `cac ` | Activate environment | | **Self-management** | | diff --git a/cac b/cac index 2d312f0..49db916 100755 --- a/cac +++ b/cac @@ -120,6 +120,129 @@ _proxy_reachable() { (echo >/dev/tcp/"$host"/"$port") 2>/dev/null } +_locale_from_country_code() { + case "$1" in + US) echo "en_US.UTF-8" ;; + GB) echo "en_GB.UTF-8" ;; + AU) echo "en_AU.UTF-8" ;; + CA) echo "en_CA.UTF-8" ;; + SG) echo "en_SG.UTF-8" ;; + HK) echo "zh_HK.UTF-8" ;; + TW) echo "zh_TW.UTF-8" ;; + JP) echo "ja_JP.UTF-8" ;; + KR) echo "ko_KR.UTF-8" ;; + DE) echo "de_DE.UTF-8" ;; + FR) echo "fr_FR.UTF-8" ;; + ES) echo "es_ES.UTF-8" ;; + IT) echo "it_IT.UTF-8" ;; + PT|BR) echo "pt_BR.UTF-8" ;; + RU) echo "ru_RU.UTF-8" ;; + NL) echo "nl_NL.UTF-8" ;; + IN) echo "en_IN.UTF-8" ;; + *) echo "en_US.UTF-8" ;; + esac +} + +# Query timezone and locale from the current proxy exit IP. +# Output: \t\t +_geo_detect_tz_lang() { + local proxy_url="$1" ip_info detected_tz country_code + [[ -n "$proxy_url" ]] || return 1 + ip_info=$(curl -s --proxy "$proxy_url" --connect-timeout 8 "http://ip-api.com/json/?fields=timezone,countryCode" 2>/dev/null || true) + [[ -n "$ip_info" ]] || return 1 + + read -r detected_tz country_code < <( + echo "$ip_info" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('timezone',''), d.get('countryCode',''))" 2>/dev/null || echo "" + ) + [[ -n "$detected_tz" ]] || return 1 + + printf '%s\t%s\t%s\n' "$detected_tz" "$(_locale_from_country_code "$country_code")" "$country_code" +} + +# Validate an IANA timezone name. +_validate_timezone() { + local value="$1" + python3 - "$value" << 'PY' +import sys +from zoneinfo import ZoneInfo + +value = sys.argv[1].strip() +if not value: + raise SystemExit(1) + +try: + ZoneInfo(value) +except Exception: + raise SystemExit(1) + +print(value) +PY +} + +# Accept a POSIX locale (en_US.UTF-8) or a simple BCP 47 tag (en-US), +# then normalize to a UTF-8 locale for LANG. +_normalize_language() { + local value="$1" + python3 - "$value" << 'PY' +import re +import sys + +value = sys.argv[1].strip() +if not value: + raise SystemExit(1) + +aliases = { + "C": "C.UTF-8", + "POSIX": "C.UTF-8", + "C.UTF-8": "C.UTF-8", + "C.UTF8": "C.UTF-8", +} +default_regions = { + "ar": "SA", + "cs": "CZ", + "de": "DE", + "en": "US", + "es": "ES", + "fr": "FR", + "hi": "IN", + "id": "ID", + "it": "IT", + "ja": "JP", + "ko": "KR", + "ms": "MY", + "nl": "NL", + "pl": "PL", + "pt": "BR", + "ru": "RU", + "th": "TH", + "tr": "TR", + "uk": "UA", + "vi": "VN", + "zh": "CN", +} + +if value in aliases: + print(aliases[value]) + raise SystemExit(0) + +normalized = value.replace("-", "_") +match = re.fullmatch(r"([A-Za-z]{2,3})(?:_([A-Za-z]{2}|\d{3}))?(?:\.(?:UTF-?8|utf-?8))?", normalized) +if not match: + raise SystemExit(1) + +language = match.group(1).lower() +region = match.group(2) +if region: + region = region.upper() +else: + region = default_regions.get(language) + if not region: + raise SystemExit(1) + +print(f"{language}_{region}.UTF-8") +PY +} + # Auto-detect proxy protocol (when user didn't specify http/socks5/https) # Usage: _auto_detect_proxy "host:port:user:pass" → returns a working full URL _auto_detect_proxy() { @@ -1728,38 +1851,15 @@ _env_cmd_create() { fi fi - # Geo-detect timezone (single request via proxy) + # Geo-detect timezone and locale (single request via proxy) local tz="America/New_York" lang="en_US.UTF-8" if [[ -n "$proxy_url" ]]; then printf " $(_dim "Detecting timezone ...") " - local ip_info - ip_info=$(curl -s --proxy "$proxy_url" --connect-timeout 8 "http://ip-api.com/json/?fields=timezone,countryCode" 2>/dev/null || true) - if [[ -n "$ip_info" ]]; then - local detected_tz country_code - read -r detected_tz country_code < <(echo "$ip_info" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('timezone',''), d.get('countryCode',''))" 2>/dev/null || echo "") + local geo_info detected_tz detected_lang country_code + if geo_info=$(_geo_detect_tz_lang "$proxy_url"); then + IFS=$'\t' read -r detected_tz detected_lang country_code <<< "$geo_info" [[ -n "$detected_tz" ]] && tz="$detected_tz" - if [[ -n "$country_code" ]]; then - case "$country_code" in - US) lang="en_US.UTF-8" ;; - GB) lang="en_GB.UTF-8" ;; - AU) lang="en_AU.UTF-8" ;; - CA) lang="en_CA.UTF-8" ;; - SG) lang="en_SG.UTF-8" ;; - HK) lang="zh_HK.UTF-8" ;; - TW) lang="zh_TW.UTF-8" ;; - JP) lang="ja_JP.UTF-8" ;; - KR) lang="ko_KR.UTF-8" ;; - DE) lang="de_DE.UTF-8" ;; - FR) lang="fr_FR.UTF-8" ;; - ES) lang="es_ES.UTF-8" ;; - IT) lang="it_IT.UTF-8" ;; - PT|BR) lang="pt_BR.UTF-8" ;; - RU) lang="ru_RU.UTF-8" ;; - NL) lang="nl_NL.UTF-8" ;; - IN) lang="en_IN.UTF-8" ;; - *) lang="en_US.UTF-8" ;; - esac - fi + [[ -n "$detected_lang" ]] && lang="$detected_lang" echo "$(_cyan "$tz") $(_dim "($country_code)")" else echo "$(_dim "default $tz")" @@ -1977,7 +2077,7 @@ _env_cmd_set() { # Parse: cac env set [name] # If first arg is a known key, use current env; otherwise treat as env name local name="" key="" value="" remove=false - local known_keys="proxy version telemetry persona" + local known_keys="proxy version telemetry persona timezone tz language lang" if [[ $# -lt 1 ]] || [[ "${1:-}" == "-h" ]] || [[ "${1:-}" == "--help" ]] || [[ "${1:-}" == "help" ]]; then echo @@ -1986,6 +2086,12 @@ _env_cmd_set() { echo " $(_green "set") [name] proxy Set proxy" echo " $(_green "set") [name] proxy --remove Remove proxy" echo " $(_green "set") [name] version Change Claude version" + echo " $(_green "set") [name] timezone Set timezone" + echo " $(_green "set") [name] timezone --remove Remove timezone override" + echo " Accepts IANA names like America/Los_Angeles" + echo " $(_green "set") [name] language Set LANG locale" + echo " $(_green "set") [name] language --remove Remove LANG override" + echo " Accepts en_US.UTF-8 or en-US, normalizes to UTF-8 locale" echo " $(_green "set") [name] telemetry " echo " Telemetry blocking: stealth (1p_events only), paranoid (max), transparent (none)" echo " $(_green "set") [name] persona " @@ -2007,7 +2113,7 @@ _env_cmd_set() { _require_env "$name" local env_dir="$ENVS_DIR/$name" - [[ $# -ge 1 ]] || _die "usage: cac env set [name] " + [[ $# -ge 1 ]] || _die "usage: cac env set [name] " key="$1"; shift # Parse value or --remove @@ -2047,6 +2153,58 @@ _env_cmd_set() { echo "$ver" > "$env_dir/version" echo "$(_green_bold "Set") version for $(_bold "$name") → $(_cyan "$ver")" ;; + timezone|tz) + if [[ "$remove" == "true" ]]; then + rm -f "$env_dir/tz" + echo "$(_green_bold "Removed") timezone override from $(_bold "$name")" + else + [[ -n "$value" ]] || _die "usage: cac env set [name] timezone " + if [[ "$value" == "auto" ]]; then + local proxy_url geo_info detected_tz detected_lang country_code + proxy_url=$(_read "$env_dir/proxy" "") + [[ -n "$proxy_url" ]] || _die "cannot auto-detect timezone without a proxy configured" + printf " $(_dim "Detecting timezone ...") " + if geo_info=$(_geo_detect_tz_lang "$proxy_url"); then + IFS=$'\t' read -r detected_tz detected_lang country_code <<< "$geo_info" + value="$detected_tz" + echo "$(_cyan "$value") $(_dim "($country_code)")" + else + echo "$(_red "failed")" + _die "failed to detect timezone from proxy exit IP" + fi + else + value=$(_validate_timezone "$value") || _die "invalid timezone '$value' (use an IANA name like America/Los_Angeles)" + fi + echo "$value" > "$env_dir/tz" + echo "$(_green_bold "Set") timezone for $(_bold "$name") → $(_cyan "$value")" + fi + ;; + language|lang) + if [[ "$remove" == "true" ]]; then + rm -f "$env_dir/lang" + echo "$(_green_bold "Removed") language override from $(_bold "$name")" + else + [[ -n "$value" ]] || _die "usage: cac env set [name] language " + if [[ "$value" == "auto" ]]; then + local proxy_url geo_info detected_tz detected_lang country_code + proxy_url=$(_read "$env_dir/proxy" "") + [[ -n "$proxy_url" ]] || _die "cannot auto-detect language without a proxy configured" + printf " $(_dim "Detecting language ...") " + if geo_info=$(_geo_detect_tz_lang "$proxy_url"); then + IFS=$'\t' read -r detected_tz detected_lang country_code <<< "$geo_info" + value="$detected_lang" + echo "$(_cyan "$value") $(_dim "($country_code)")" + else + echo "$(_red "failed")" + _die "failed to detect language from proxy exit IP" + fi + else + value=$(_normalize_language "$value") || _die "invalid language '$value' (use a locale like en_US.UTF-8, a tag like en-US, or 'auto')" + fi + echo "$value" > "$env_dir/lang" + echo "$(_green_bold "Set") language for $(_bold "$name") → $(_cyan "$value")" + fi + ;; telemetry) [[ "$remove" != "true" ]] || _die "cannot remove telemetry mode" [[ -n "$value" ]] || _die "usage: cac env set [name] telemetry " @@ -2072,7 +2230,7 @@ _env_cmd_set() { fi ;; *) - _die "unknown key '$key' — use proxy, version, telemetry, or persona" + _die "unknown key '$key' — use proxy, version, timezone, language, telemetry, or persona" ;; esac } @@ -2101,7 +2259,7 @@ cmd_env() { echo " $(_green "create") [-p proxy] [-c ver] [--telemetry mode] [--persona preset]" echo " Create isolated environment (auto-activates)" echo " $(_green "set") [name] Modify environment" - echo " proxy, version, telemetry, or persona" + echo " proxy, version, timezone, language, telemetry, or persona" echo " $(_green "ls") List all environments" echo " $(_green "rm") Remove an environment" echo " $(_green "check") Verify current environment" @@ -3500,6 +3658,7 @@ cmd_help() { echo " $(_bold "Environment")" echo " $(_green "cac env create") [-p proxy] [-c ver]" echo " $(_green "cac env set") [name] Modify environment" + echo " proxy|version|timezone|language|telemetry|persona" echo " $(_green "cac env ls") List all environments" echo " $(_green "cac env rm") Remove an environment" echo " $(_green "cac env check") Verify current environment" diff --git a/docs/changelog.mdx b/docs/changelog.mdx index ea1d8f7..95b2e2b 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -5,6 +5,17 @@ description: Release history and notable changes All notable changes to this project are documented here. Each entry links to the corresponding PR or issue. +## Unreleased + +**New: `cac env set timezone|language` with validation and auto-detect** + +- Added `cac env set timezone ` and alias `tz`. +- Added `cac env set language ` and alias `lang`. +- Timezones are validated against Python `zoneinfo`, so invalid names are rejected early. +- Languages accept either POSIX locales like `en_US.UTF-8` or simple BCP 47 tags like `en-US`, then normalize to UTF-8 locales for `LANG`. +- `auto` re-detects timezone and language from the current proxy exit IP geolocation, making it easy to resync after changing proxies. +- Help output, README, and command docs were updated to document the new behavior and examples. + ## v1.5.4 2026-04-02 diff --git a/docs/commands/env.mdx b/docs/commands/env.mdx index 54ac4b1..390c6e2 100644 --- a/docs/commands/env.mdx +++ b/docs/commands/env.mdx @@ -119,6 +119,10 @@ If `name` is omitted, the currently active environment is used. **Shorthand:** `cac env ` (equivalent to `cac env set `). + +`timezone` and `language` are validated before being written. `auto` re-detects from the current proxy exit IP. This is especially useful after changing proxies, because proxy changes do not silently rewrite existing `tz` or `lang` files. + + ### Proxy ```bash @@ -139,6 +143,34 @@ cac env set work version 2.1.81 # pin a specific version cac env set work version latest # resolve and pin the current latest ``` +### Timezone + +Use a standard IANA timezone name. `auto` re-detects from the current proxy exit IP. + +```bash +cac env set work timezone America/Los_Angeles +cac env set work timezone Asia/Shanghai +cac env set work timezone auto +cac env set work timezone --remove +``` + +### Language + +Use a UTF-8 locale for `LANG`. The command also accepts simple BCP 47 tags and normalizes them to a POSIX locale. + +```bash +cac env set work language en_US.UTF-8 +cac env set work language en-US # normalized to en_US.UTF-8 +cac env set work language zh-CN # normalized to zh_CN.UTF-8 +cac env set work language auto +cac env set work language --remove +``` + +Validation and normalization: +- `timezone` must be a valid IANA timezone such as `America/Los_Angeles` or `Asia/Shanghai` +- `language` accepts `en_US.UTF-8`, `zh_CN.UTF-8`, `en-US`, `zh-CN`, and similar forms +- Language values are normalized to `ll_CC.UTF-8` before writing to the environment + ### Telemetry mode Change telemetry blocking strategy after environment creation. @@ -175,7 +207,7 @@ cac env check -d # detailed output | `-d, --details` | Show detailed diagnostic output instead of just the summary. | Checks: -- Environment info (proxy, UUID, version, timezone) +- Environment info (proxy, UUID, version, timezone, language) - Proxy connectivity and exit IP (if proxy configured) - TUN conflict detection (if proxy configured) - Security protections (DNS guard, telemetry env vars, mTLS) diff --git a/docs/zh/changelog.mdx b/docs/zh/changelog.mdx index dc500a2..7f89aa9 100644 --- a/docs/zh/changelog.mdx +++ b/docs/zh/changelog.mdx @@ -5,6 +5,17 @@ description: 版本发布历史和重要变更 所有重要变更记录在此,每个条目关联对应的 PR 或 Issue。 +## Unreleased + +**新增:`cac env set timezone|language`,带校验和自动探测** + +- 新增 `cac env set timezone `,别名 `tz`。 +- 新增 `cac env set language `,别名 `lang`。 +- 时区使用 Python `zoneinfo` 做 IANA 名称校验,非法值会直接拒绝。 +- 语言既支持 `en_US.UTF-8` 这种 POSIX locale,也支持 `en-US` 这种常见标签,并统一规范化为 UTF-8 locale 写入 `LANG`。 +- `auto` 会根据当前代理出口 IP 的地理信息重新探测时区和语言,方便在换代理后重新同步。 +- 帮助输出、README 和命令文档已同步更新。 + ## v1.5.4 2026-04-02 diff --git a/docs/zh/commands/env.mdx b/docs/zh/commands/env.mdx index a51be95..f1f9097 100644 --- a/docs/zh/commands/env.mdx +++ b/docs/zh/commands/env.mdx @@ -118,11 +118,19 @@ cac env set [name] 不指定 `name` 时,默认修改当前活跃环境。 + +`timezone` 和 `language` 会在写入前做校验。`auto` 会按当前代理出口 IP 重新探测。这个设计是有意的:修改代理时不会悄悄覆盖已有的 `tz` / `lang`,需要你显式同步,避免误改。 + + | 用法 | 说明 | |:---|:---| | `cac env set [name] proxy ` | 设置或更换代理 | | `cac env set [name] proxy --remove` | 移除代理 | | `cac env set [name] version ` | 更换 Claude Code 版本,`latest` 自动解析 | +| `cac env set [name] timezone ` | 设置时区,或按当前代理出口自动探测 | +| `cac env set [name] timezone --remove` | 移除时区覆盖,回退为宿主默认行为 | +| `cac env set [name] language ` | 设置语言,接受 `en_US.UTF-8` 或 `en-US` | +| `cac env set [name] language --remove` | 移除语言覆盖,回退为宿主默认行为 | | `cac env set [name] telemetry ` | 修改遥测屏蔽模式:`transparent` / `stealth` / `paranoid` | | `cac env set [name] persona ` | 修改或移除终端人设 | @@ -141,6 +149,19 @@ cac env set work proxy --remove cac env set work version 2.1.81 cac env set work version latest +# 时区 +cac env set work timezone America/Los_Angeles +cac env set work timezone Asia/Shanghai +cac env set work timezone auto +cac env set work timezone --remove + +# 语言 +cac env set work language en_US.UTF-8 +cac env set work language en-US +cac env set work language zh-CN +cac env set work language auto +cac env set work language --remove + # 遥测模式 cac env set work telemetry stealth # 屏蔽 1p_events,Feature flags 正常 cac env set work telemetry paranoid # 最大屏蔽 @@ -155,6 +176,11 @@ cac env work proxy 1.2.3.4:1080:user:pass cac env proxy --remove # 移除当前环境代理 ``` +校验与规范化规则: +- `timezone` 必须是合法的 IANA 时区名,例如 `America/Los_Angeles`、`Asia/Shanghai` +- `language` 接受 `en_US.UTF-8`、`zh_CN.UTF-8`,也接受 `en-US`、`zh-CN` 这类常见标签 +- `language` 写入环境前会统一规范化成 `ll_CC.UTF-8` + ## check 对当前活跃环境运行诊断检查。输出简洁的通过/失败结论摘要。 @@ -169,7 +195,7 @@ cac env check -d # 详细输出 | `-d, --details` | 显示详细诊断输出,而非仅摘要。 | 检查项目: -- 环境信息(代理、UUID、版本、时区) +- 环境信息(代理、UUID、版本、时区、语言) - 代理连通性和出口 IP(如果配置了代理) - TUN 冲突检测(如果配置了代理) - 安全防护(DNS 守卫、遥测环境变量、mTLS) diff --git a/src/cmd_env.sh b/src/cmd_env.sh index 36993c1..d130a30 100644 --- a/src/cmd_env.sh +++ b/src/cmd_env.sh @@ -54,38 +54,15 @@ _env_cmd_create() { fi fi - # Geo-detect timezone (single request via proxy) + # Geo-detect timezone and locale (single request via proxy) local tz="America/New_York" lang="en_US.UTF-8" if [[ -n "$proxy_url" ]]; then printf " $(_dim "Detecting timezone ...") " - local ip_info - ip_info=$(curl -s --proxy "$proxy_url" --connect-timeout 8 "http://ip-api.com/json/?fields=timezone,countryCode" 2>/dev/null || true) - if [[ -n "$ip_info" ]]; then - local detected_tz country_code - read -r detected_tz country_code < <(echo "$ip_info" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('timezone',''), d.get('countryCode',''))" 2>/dev/null || echo "") + local geo_info detected_tz detected_lang country_code + if geo_info=$(_geo_detect_tz_lang "$proxy_url"); then + IFS=$'\t' read -r detected_tz detected_lang country_code <<< "$geo_info" [[ -n "$detected_tz" ]] && tz="$detected_tz" - if [[ -n "$country_code" ]]; then - case "$country_code" in - US) lang="en_US.UTF-8" ;; - GB) lang="en_GB.UTF-8" ;; - AU) lang="en_AU.UTF-8" ;; - CA) lang="en_CA.UTF-8" ;; - SG) lang="en_SG.UTF-8" ;; - HK) lang="zh_HK.UTF-8" ;; - TW) lang="zh_TW.UTF-8" ;; - JP) lang="ja_JP.UTF-8" ;; - KR) lang="ko_KR.UTF-8" ;; - DE) lang="de_DE.UTF-8" ;; - FR) lang="fr_FR.UTF-8" ;; - ES) lang="es_ES.UTF-8" ;; - IT) lang="it_IT.UTF-8" ;; - PT|BR) lang="pt_BR.UTF-8" ;; - RU) lang="ru_RU.UTF-8" ;; - NL) lang="nl_NL.UTF-8" ;; - IN) lang="en_IN.UTF-8" ;; - *) lang="en_US.UTF-8" ;; - esac - fi + [[ -n "$detected_lang" ]] && lang="$detected_lang" echo "$(_cyan "$tz") $(_dim "($country_code)")" else echo "$(_dim "default $tz")" @@ -303,7 +280,7 @@ _env_cmd_set() { # Parse: cac env set [name] # If first arg is a known key, use current env; otherwise treat as env name local name="" key="" value="" remove=false - local known_keys="proxy version telemetry persona" + local known_keys="proxy version telemetry persona timezone tz language lang" if [[ $# -lt 1 ]] || [[ "${1:-}" == "-h" ]] || [[ "${1:-}" == "--help" ]] || [[ "${1:-}" == "help" ]]; then echo @@ -312,6 +289,12 @@ _env_cmd_set() { echo " $(_green "set") [name] proxy Set proxy" echo " $(_green "set") [name] proxy --remove Remove proxy" echo " $(_green "set") [name] version Change Claude version" + echo " $(_green "set") [name] timezone Set timezone" + echo " $(_green "set") [name] timezone --remove Remove timezone override" + echo " Accepts IANA names like America/Los_Angeles" + echo " $(_green "set") [name] language Set LANG locale" + echo " $(_green "set") [name] language --remove Remove LANG override" + echo " Accepts en_US.UTF-8 or en-US, normalizes to UTF-8 locale" echo " $(_green "set") [name] telemetry " echo " Telemetry blocking: stealth (1p_events only), paranoid (max), transparent (none)" echo " $(_green "set") [name] persona " @@ -333,7 +316,7 @@ _env_cmd_set() { _require_env "$name" local env_dir="$ENVS_DIR/$name" - [[ $# -ge 1 ]] || _die "usage: cac env set [name] " + [[ $# -ge 1 ]] || _die "usage: cac env set [name] " key="$1"; shift # Parse value or --remove @@ -373,6 +356,58 @@ _env_cmd_set() { echo "$ver" > "$env_dir/version" echo "$(_green_bold "Set") version for $(_bold "$name") → $(_cyan "$ver")" ;; + timezone|tz) + if [[ "$remove" == "true" ]]; then + rm -f "$env_dir/tz" + echo "$(_green_bold "Removed") timezone override from $(_bold "$name")" + else + [[ -n "$value" ]] || _die "usage: cac env set [name] timezone " + if [[ "$value" == "auto" ]]; then + local proxy_url geo_info detected_tz detected_lang country_code + proxy_url=$(_read "$env_dir/proxy" "") + [[ -n "$proxy_url" ]] || _die "cannot auto-detect timezone without a proxy configured" + printf " $(_dim "Detecting timezone ...") " + if geo_info=$(_geo_detect_tz_lang "$proxy_url"); then + IFS=$'\t' read -r detected_tz detected_lang country_code <<< "$geo_info" + value="$detected_tz" + echo "$(_cyan "$value") $(_dim "($country_code)")" + else + echo "$(_red "failed")" + _die "failed to detect timezone from proxy exit IP" + fi + else + value=$(_validate_timezone "$value") || _die "invalid timezone '$value' (use an IANA name like America/Los_Angeles)" + fi + echo "$value" > "$env_dir/tz" + echo "$(_green_bold "Set") timezone for $(_bold "$name") → $(_cyan "$value")" + fi + ;; + language|lang) + if [[ "$remove" == "true" ]]; then + rm -f "$env_dir/lang" + echo "$(_green_bold "Removed") language override from $(_bold "$name")" + else + [[ -n "$value" ]] || _die "usage: cac env set [name] language " + if [[ "$value" == "auto" ]]; then + local proxy_url geo_info detected_tz detected_lang country_code + proxy_url=$(_read "$env_dir/proxy" "") + [[ -n "$proxy_url" ]] || _die "cannot auto-detect language without a proxy configured" + printf " $(_dim "Detecting language ...") " + if geo_info=$(_geo_detect_tz_lang "$proxy_url"); then + IFS=$'\t' read -r detected_tz detected_lang country_code <<< "$geo_info" + value="$detected_lang" + echo "$(_cyan "$value") $(_dim "($country_code)")" + else + echo "$(_red "failed")" + _die "failed to detect language from proxy exit IP" + fi + else + value=$(_normalize_language "$value") || _die "invalid language '$value' (use a locale like en_US.UTF-8, a tag like en-US, or 'auto')" + fi + echo "$value" > "$env_dir/lang" + echo "$(_green_bold "Set") language for $(_bold "$name") → $(_cyan "$value")" + fi + ;; telemetry) [[ "$remove" != "true" ]] || _die "cannot remove telemetry mode" [[ -n "$value" ]] || _die "usage: cac env set [name] telemetry " @@ -398,7 +433,7 @@ _env_cmd_set() { fi ;; *) - _die "unknown key '$key' — use proxy, version, telemetry, or persona" + _die "unknown key '$key' — use proxy, version, timezone, language, telemetry, or persona" ;; esac } @@ -427,7 +462,7 @@ cmd_env() { echo " $(_green "create") [-p proxy] [-c ver] [--telemetry mode] [--persona preset]" echo " Create isolated environment (auto-activates)" echo " $(_green "set") [name] Modify environment" - echo " proxy, version, telemetry, or persona" + echo " proxy, version, timezone, language, telemetry, or persona" echo " $(_green "ls") List all environments" echo " $(_green "rm") Remove an environment" echo " $(_green "check") Verify current environment" diff --git a/src/cmd_help.sh b/src/cmd_help.sh index 106b54c..8161aba 100644 --- a/src/cmd_help.sh +++ b/src/cmd_help.sh @@ -8,6 +8,7 @@ cmd_help() { echo " $(_bold "Environment")" echo " $(_green "cac env create") [-p proxy] [-c ver]" echo " $(_green "cac env set") [name] Modify environment" + echo " proxy|version|timezone|language|telemetry|persona" echo " $(_green "cac env ls") List all environments" echo " $(_green "cac env rm") Remove an environment" echo " $(_green "cac env check") Verify current environment" diff --git a/src/utils.sh b/src/utils.sh index d89141a..5d08f95 100644 --- a/src/utils.sh +++ b/src/utils.sh @@ -110,6 +110,129 @@ _proxy_reachable() { (echo >/dev/tcp/"$host"/"$port") 2>/dev/null } +_locale_from_country_code() { + case "$1" in + US) echo "en_US.UTF-8" ;; + GB) echo "en_GB.UTF-8" ;; + AU) echo "en_AU.UTF-8" ;; + CA) echo "en_CA.UTF-8" ;; + SG) echo "en_SG.UTF-8" ;; + HK) echo "zh_HK.UTF-8" ;; + TW) echo "zh_TW.UTF-8" ;; + JP) echo "ja_JP.UTF-8" ;; + KR) echo "ko_KR.UTF-8" ;; + DE) echo "de_DE.UTF-8" ;; + FR) echo "fr_FR.UTF-8" ;; + ES) echo "es_ES.UTF-8" ;; + IT) echo "it_IT.UTF-8" ;; + PT|BR) echo "pt_BR.UTF-8" ;; + RU) echo "ru_RU.UTF-8" ;; + NL) echo "nl_NL.UTF-8" ;; + IN) echo "en_IN.UTF-8" ;; + *) echo "en_US.UTF-8" ;; + esac +} + +# Query timezone and locale from the current proxy exit IP. +# Output: \t\t +_geo_detect_tz_lang() { + local proxy_url="$1" ip_info detected_tz country_code + [[ -n "$proxy_url" ]] || return 1 + ip_info=$(curl -s --proxy "$proxy_url" --connect-timeout 8 "http://ip-api.com/json/?fields=timezone,countryCode" 2>/dev/null || true) + [[ -n "$ip_info" ]] || return 1 + + read -r detected_tz country_code < <( + echo "$ip_info" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('timezone',''), d.get('countryCode',''))" 2>/dev/null || echo "" + ) + [[ -n "$detected_tz" ]] || return 1 + + printf '%s\t%s\t%s\n' "$detected_tz" "$(_locale_from_country_code "$country_code")" "$country_code" +} + +# Validate an IANA timezone name. +_validate_timezone() { + local value="$1" + python3 - "$value" << 'PY' +import sys +from zoneinfo import ZoneInfo + +value = sys.argv[1].strip() +if not value: + raise SystemExit(1) + +try: + ZoneInfo(value) +except Exception: + raise SystemExit(1) + +print(value) +PY +} + +# Accept a POSIX locale (en_US.UTF-8) or a simple BCP 47 tag (en-US), +# then normalize to a UTF-8 locale for LANG. +_normalize_language() { + local value="$1" + python3 - "$value" << 'PY' +import re +import sys + +value = sys.argv[1].strip() +if not value: + raise SystemExit(1) + +aliases = { + "C": "C.UTF-8", + "POSIX": "C.UTF-8", + "C.UTF-8": "C.UTF-8", + "C.UTF8": "C.UTF-8", +} +default_regions = { + "ar": "SA", + "cs": "CZ", + "de": "DE", + "en": "US", + "es": "ES", + "fr": "FR", + "hi": "IN", + "id": "ID", + "it": "IT", + "ja": "JP", + "ko": "KR", + "ms": "MY", + "nl": "NL", + "pl": "PL", + "pt": "BR", + "ru": "RU", + "th": "TH", + "tr": "TR", + "uk": "UA", + "vi": "VN", + "zh": "CN", +} + +if value in aliases: + print(aliases[value]) + raise SystemExit(0) + +normalized = value.replace("-", "_") +match = re.fullmatch(r"([A-Za-z]{2,3})(?:_([A-Za-z]{2}|\d{3}))?(?:\.(?:UTF-?8|utf-?8))?", normalized) +if not match: + raise SystemExit(1) + +language = match.group(1).lower() +region = match.group(2) +if region: + region = region.upper() +else: + region = default_regions.get(language) + if not region: + raise SystemExit(1) + +print(f"{language}_{region}.UTF-8") +PY +} + # Auto-detect proxy protocol (when user didn't specify http/socks5/https) # Usage: _auto_detect_proxy "host:port:user:pass" → returns a working full URL _auto_detect_proxy() {