Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ cac env rm <name> # 删除环境
cac env set [name] proxy <url> # 设置 / 修改代理
cac env set [name] proxy --remove # 移除代理
cac env set [name] version <ver> # 切换版本
cac env set [name] timezone <IANA_TZ> # 设置时区,例如 America/Los_Angeles
cac env set [name] language <locale> # 设置语言,例如 en_US.UTF-8 或 en-US
cac <name> # 激活环境(快捷方式)
cac ls # = cac env ls
```
Expand All @@ -125,7 +127,7 @@ cac ls # = cac env ls
| `cac env create <name> [-p proxy] [-c ver] [--clone] [--telemetry mode] [--persona preset]` | 创建环境(自动激活,`--telemetry transparent/stealth/paranoid` 控制遥测,`--persona macos-vscode/...` 用于容器) |
| `cac env ls` | 列出环境 |
| `cac env rm <name>` | 删除环境 |
| `cac env set [name] <key> <value>` | 修改环境(proxy / version / telemetry / persona) |
| `cac env set [name] <key> <value>` | 修改环境(proxy / version / timezone / language / telemetry / persona) |
| `cac env check [-d]` | 验证当前环境(`-d` 显示详情) |
| `cac <name>` | 激活环境 |
| **自管理** | |
Expand Down Expand Up @@ -287,6 +289,8 @@ cac env rm <name> # remove environment
cac env set [name] proxy <url> # set / change proxy
cac env set [name] proxy --remove # remove proxy
cac env set [name] version <ver> # change version
cac env set [name] timezone <IANA_TZ> # set timezone, e.g. America/Los_Angeles
cac env set [name] language <locale> # set LANG, e.g. en_US.UTF-8 or en-US
cac <name> # activate (shortcut)
cac ls # = cac env ls
```
Expand All @@ -310,7 +314,7 @@ Each environment is fully isolated:
| `cac env create <name> [-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 <name>` | Remove environment |
| `cac env set [name] <key> <value>` | Modify environment (proxy / version / telemetry / persona) |
| `cac env set [name] <key> <value>` | Modify environment (proxy / version / timezone / language / telemetry / persona) |
| `cac env check [-d]` | Verify current environment (`-d` for details) |
| `cac <name>` | Activate environment |
| **Self-management** | |
Expand Down
223 changes: 191 additions & 32 deletions cac
Original file line number Diff line number Diff line change
Expand Up @@ -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: <timezone>\t<locale>\t<country_code>
_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() {
Expand Down Expand Up @@ -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")"
Expand Down Expand Up @@ -1977,7 +2077,7 @@ _env_cmd_set() {
# Parse: cac env set [name] <key> <value|--remove>
# 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
Expand All @@ -1986,6 +2086,12 @@ _env_cmd_set() {
echo " $(_green "set") [name] proxy <url> Set proxy"
echo " $(_green "set") [name] proxy --remove Remove proxy"
echo " $(_green "set") [name] version <ver|latest> Change Claude version"
echo " $(_green "set") [name] timezone <IANA_TZ|auto> Set timezone"
echo " $(_green "set") [name] timezone --remove Remove timezone override"
echo " Accepts IANA names like America/Los_Angeles"
echo " $(_green "set") [name] language <locale|tag|auto> 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 <stealth|paranoid|transparent>"
echo " Telemetry blocking: stealth (1p_events only), paranoid (max), transparent (none)"
echo " $(_green "set") [name] persona <macos-vscode|macos-cursor|macos-iterm|linux-desktop|--remove>"
Expand All @@ -2007,7 +2113,7 @@ _env_cmd_set() {
_require_env "$name"
local env_dir="$ENVS_DIR/$name"

[[ $# -ge 1 ]] || _die "usage: cac env set [name] <proxy|version|bypass> <value|--remove>"
[[ $# -ge 1 ]] || _die "usage: cac env set [name] <proxy|version|timezone|language|telemetry|persona> <value|--remove>"
key="$1"; shift

# Parse value or --remove
Expand Down Expand Up @@ -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 <IANA_TZ|auto>"
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 <locale|tag|auto>"
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 <stealth|paranoid|transparent>"
Expand All @@ -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
}
Expand Down Expand Up @@ -2101,7 +2259,7 @@ cmd_env() {
echo " $(_green "create") <name> [-p proxy] [-c ver] [--telemetry mode] [--persona preset]"
echo " Create isolated environment (auto-activates)"
echo " $(_green "set") [name] <key> <value> 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") <name> Remove an environment"
echo " $(_green "check") Verify current environment"
Expand Down Expand Up @@ -3500,6 +3658,7 @@ cmd_help() {
echo " $(_bold "Environment")"
echo " $(_green "cac env create") <name> [-p proxy] [-c ver]"
echo " $(_green "cac env set") [name] <key> <value> Modify environment"
echo " proxy|version|timezone|language|telemetry|persona"
echo " $(_green "cac env ls") List all environments"
echo " $(_green "cac env rm") <name> Remove an environment"
echo " $(_green "cac env check") Verify current environment"
Expand Down
11 changes: 11 additions & 0 deletions docs/changelog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> timezone <IANA_TZ|auto>` and alias `tz`.
- Added `cac env set <name> language <locale|tag|auto>` 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
<sub>2026-04-02</sub>

Expand Down
34 changes: 33 additions & 1 deletion docs/commands/env.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ If `name` is omitted, the currently active environment is used.

**Shorthand:** `cac env <name> <key> <value>` (equivalent to `cac env set <name> <key> <value>`).

<Info>
`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.
</Info>

### Proxy

```bash
Expand All @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
Loading