Claude Code、Codex、Kimi 这三个 AI CLI 更新都很频繁,但更新命令并不统一,同一个 CLI 换一种安装方式,命令就跟着变。这篇笔记把三个 CLI 在各种安装方式下的更新命令整理成一张表,再附一个自动检测版本、按安装方式完成更新的脚本。一句话结论:先确认安装方式,再选更新命令;Codex 走 npm 更新时必须先卸载再装,直接覆盖会丢平台二进制文件。

先判断安装方式

更新命令取决于安装方式,两条命令可以查:

npm ls -g @anthropic-ai/claude-code   # 有输出即为 npm 全局安装
which -a claude codex kimi            # 看可执行文件的实际路径

可执行文件在 ~/.local/share/claude 说明 Claude Code 是原生安装,在 ~/.kimi-code/bin 说明 Kimi 用的是官方脚本,这两种都不经过 npm。

手动更新命令

CLI 安装方式 更新命令
Claude Code 原生安装(~/.local/share/claude claude update
Claude Code npm 全局 npm install -g @anthropic-ai/claude-code@latest
Codex npm 全局 npm uninstall -g @openai/codex && npm install -g @openai/codex@latest
Codex Homebrew brew upgrade codex
Kimi 官方脚本(~/.kimi-code/bin kimi upgrade(交互式),或重跑安装脚本
Kimi npm 全局 npm install -g @moonshot-ai/kimi-code@latest

想查某个 CLI 的最新版本号,用 npm view <包名> version。三个 CLI 都在 npm 上发布,包名见上表。

Codex 为什么不能直接覆盖更新

对 npm 安装的 Codex 直接跑 npm install -g @openai/codex@latest,npm 会丢失 optionalDependencies 里的平台二进制包(@openai/codex-darwin-arm64 等),之后运行报 Missing optional dependency。先 npm uninstall -g @openai/codex 再安装即可避免。Homebrew 安装的没有这个问题,brew upgrade codex 就行。

Kimi 的两种更新方式

官方脚本安装的 Kimi 用 kimi upgrade 更新,但它是交互式的,会弹出选项,不适合无人值守执行。想静默更新,直接重跑官方安装脚本:

curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash

npm 全局安装的按常规处理,npm install -g @moonshot-ai/kimi-code@latest

自动检测更新脚本

不想记这些命令,可以用下面的脚本。它检测本机装了哪几个 CLI,对比 npm 上的最新版本,发现新版本就按各自的安装方式更新。

用法:

bash ai-cli-update.sh           # 检测并更新
bash ai-cli-update.sh --check   # 只检测,不更新
#!/usr/bin/env bash
# Detect, install, and update Claude Code / Codex / Kimi Code.
# Compatible with the Bash 3.2 shipped by macOS.
set -uo pipefail

CHECK_ONLY=0
ASSUME_YES=0
DRY_RUN=0
FAILURES=0

TOOLS=(claude codex kimi)
CURRENT_VERSIONS=()
INSTALLED_SOURCES=()
NPM_LATEST=()
NPM_SUPPORTED=()
BREW_SUPPORTED=()
OFFICIAL_SUPPORTED=()
STATES=()
CANDIDATE_INDEXES=()

say() { printf '%s\n' "$*"; }

usage() {
  cat <<'EOF'
用法: ai-cli-update.sh [选项]

检测并安装/更新 Claude Code、Codex 和 Kimi Code。

选项:
  --check      只检查,不显示选择框,也不执行安装或更新
  --yes, -y    选择全部待处理项,并使用各项的默认来源
  --dry-run    显示将执行的操作,但不实际执行
  --help, -h   显示帮助
EOF
}

while [ "$#" -gt 0 ]; do
  case "$1" in
    --check) CHECK_ONLY=1 ;;
    --yes|-y) ASSUME_YES=1 ;;
    --dry-run) DRY_RUN=1 ;;
    --help|-h) usage; exit 0 ;;
    *) say "未知选项: $1" >&2; usage >&2; exit 2 ;;
  esac
  shift
done

case "$(uname -s 2>/dev/null || printf unknown)" in
  Darwin) PLATFORM=macos ;;
  Linux) PLATFORM=linux ;;
  MINGW*|MSYS*|CYGWIN*) PLATFORM=windows ;;
  *) PLATFORM=unknown ;;
esac

tool_name() {
  case "$1" in
    claude) printf '%s' 'Claude Code' ;;
    codex) printf '%s' 'Codex' ;;
    kimi) printf '%s' 'Kimi Code' ;;
  esac
}

tool_command() { printf '%s' "$1"; }

npm_package() {
  case "$1" in
    claude) printf '%s' '@anthropic-ai/claude-code' ;;
    codex) printf '%s' '@openai/codex' ;;
    kimi) printf '%s' '@moonshot-ai/kimi-code' ;;
  esac
}

brew_package() {
  case "$1" in
    claude) printf '%s' 'claude-code' ;;
    codex) printf '%s' 'codex' ;;
    kimi) printf '%s' 'kimi-code' ;;
  esac
}

brew_kind() {
  case "$1" in
    claude|codex) printf '%s' 'cask' ;;
    kimi) printf '%s' 'formula' ;;
  esac
}

official_url() {
  case "$PLATFORM:$1" in
    windows:claude) printf '%s' 'https://claude.ai/install.ps1' ;;
    windows:kimi) printf '%s' 'https://code.kimi.com/kimi-code/install.ps1' ;;
    macos:claude|linux:claude) printf '%s' 'https://claude.ai/install.sh' ;;
    macos:kimi|linux:kimi) printf '%s' 'https://code.kimi.com/kimi-code/install.sh' ;;
    *) return 1 ;;
  esac
}

extract_version() {
  awk '{
    for (i = 1; i <= NF; i++) {
      value = $i
      gsub(/^[^0-9]*/, "", value)
      gsub(/[^0-9A-Za-z.+-].*$/, "", value)
      if (value ~ /^[0-9]+\.[0-9]+\.[0-9]+([.+-][0-9A-Za-z.-]+)?$/) {
        print value
        exit
      }
    }
  }'
}

current_version() {
  local cmd=$1
  command -v "$cmd" >/dev/null 2>&1 || return 1
  "$cmd" --version 2>/dev/null | extract_version
}

resolve_command_path() {
  local path target dir count=0
  path=$(command -v "$1" 2>/dev/null) || return 1
  while [ -L "$path" ] && [ "$count" -lt 12 ]; do
    target=$(readlink "$path") || break
    case "$target" in
      /*) path=$target ;;
      *)
        dir=$(dirname "$path")
        path=$(cd "$dir" 2>/dev/null && cd "$(dirname "$target")" 2>/dev/null && pwd)/$(basename "$target")
        ;;
    esac
    count=$((count + 1))
  done
  printf '%s\n' "$path"
}

npm_latest() {
  local package=$1
  command -v npm >/dev/null 2>&1 || return 1
  npm --fetch-timeout=8000 --fetch-retries=0 view "$package" version 2>/dev/null | extract_version
}

npm_global_has() {
  command -v npm >/dev/null 2>&1 || return 1
  npm ls -g --depth=0 "$1" >/dev/null 2>&1
}

brew_source_supported() {
  local tool=$1 package kind
  command -v brew >/dev/null 2>&1 || return 1
  package=$(brew_package "$tool")
  kind=$(brew_kind "$tool")
  [ "$kind" != cask ] || [ "$PLATFORM" = macos ] || return 1
  if [ "$kind" = cask ]; then
    brew info --cask "$package" >/dev/null 2>&1
  else
    brew info --formula "$package" >/dev/null 2>&1
  fi
}

brew_is_installed() {
  local tool=$1 package kind
  command -v brew >/dev/null 2>&1 || return 1
  package=$(brew_package "$tool")
  kind=$(brew_kind "$tool")
  if [ "$kind" = cask ]; then
    brew list --cask --versions "$package" >/dev/null 2>&1
  else
    brew list --formula --versions "$package" >/dev/null 2>&1
  fi
}

# Returns 0 when outdated, 1 when current, and 2 when the check failed.
brew_is_outdated() {
  local package output rc
  package=$(brew_package "$1")
  output=$(brew outdated --greedy "$package" 2>/dev/null)
  rc=$?
  [ -n "$output" ] && return 0
  [ "$rc" -eq 0 ] || return 2
  return 1
}

official_source_supported() {
  local tool=$1 url
  url=$(official_url "$tool" 2>/dev/null) || return 1
  if [ "$PLATFORM" = windows ]; then
    command -v powershell.exe >/dev/null 2>&1 || command -v pwsh.exe >/dev/null 2>&1
  else
    command -v curl >/dev/null 2>&1 || return 1
    curl -fsSL --connect-timeout 4 --max-time 10 -o /dev/null "$url" 2>/dev/null
  fi
}

source_is_supported() {
  local idx=$1 source=$2
  case "$source" in
    npm) [ "${NPM_SUPPORTED[$idx]:-0}" = 1 ] ;;
    homebrew) [ "${BREW_SUPPORTED[$idx]:-0}" = 1 ] ;;
    official) [ "${OFFICIAL_SUPPORTED[$idx]:-0}" = 1 ] ;;
    *) return 1 ;;
  esac
}

detect_installed_source() {
  local tool=$1 cmd path npm_root brew_prefix package
  cmd=$(tool_command "$tool")
  path=$(resolve_command_path "$cmd" 2>/dev/null) || return 1
  package=$(npm_package "$tool")

  if command -v brew >/dev/null 2>&1 && brew_is_installed "$tool"; then
    brew_prefix=$(brew --prefix 2>/dev/null || true)
    case "$path" in
      "$brew_prefix"/*) printf '%s' homebrew; return 0 ;;
    esac
  fi

  if command -v npm >/dev/null 2>&1 && npm_global_has "$package"; then
    npm_root=$(npm root -g 2>/dev/null || true)
    case "$path" in
      "$npm_root"/*) printf '%s' npm; return 0 ;;
    esac
  fi

  case "$tool:$path" in
    claude:*/.local/share/claude/versions/*) printf '%s' official; return 0 ;;
  esac

  # Fall back to the only installed package manager record, if unambiguous.
  if brew_is_installed "$tool"; then
    printf '%s' homebrew
  elif npm_global_has "$package"; then
    printf '%s' npm
  elif official_url "$tool" >/dev/null 2>&1; then
    printf '%s' official
  else
    printf '%s' unknown
  fi
}

source_label() {
  case "$1" in
    npm) printf '%s' 'npm' ;;
    homebrew) printf '%s' 'Homebrew' ;;
    official) printf '%s' '官方安装器' ;;
    unknown) printf '%s' '未知来源' ;;
  esac
}

available_sources_text() {
  local idx=$1 text=''
  [ "${OFFICIAL_SUPPORTED[$idx]:-0}" = 1 ] && text='官方安装器'
  [ "${BREW_SUPPORTED[$idx]:-0}" = 1 ] && text="${text}${text:+、}Homebrew"
  [ "${NPM_SUPPORTED[$idx]:-0}" = 1 ] && text="${text}${text:+、}npm"
  [ -n "$text" ] && printf '%s' "$text" || printf '%s' '无'
}

inspect_tool() {
  local idx=$1 tool=${TOOLS[$1]} cmd package cur='' source='' latest='' state=''
  local brew_rc
  cmd=$(tool_command "$tool")
  package=$(npm_package "$tool")

  latest=$(npm_latest "$package" || true)
  if [ -n "$latest" ]; then NPM_SUPPORTED[$idx]=1; else NPM_SUPPORTED[$idx]=0; fi
  if brew_source_supported "$tool"; then BREW_SUPPORTED[$idx]=1; else BREW_SUPPORTED[$idx]=0; fi
  if official_source_supported "$tool"; then OFFICIAL_SUPPORTED[$idx]=1; else OFFICIAL_SUPPORTED[$idx]=0; fi
  NPM_LATEST[$idx]=$latest

  if command -v "$cmd" >/dev/null 2>&1; then
    cur=$(current_version "$cmd" || true)
    source=$(detect_installed_source "$tool" || printf '%s' unknown)
    case "$source" in
      homebrew)
        brew_is_outdated "$tool"
        brew_rc=$?
        case "$brew_rc" in
          0) state=outdated ;;
          1) state=current ;;
          *) state=unknown ;;
        esac
        ;;
      npm|official|unknown)
        if [ -n "$cur" ] && [ -n "$latest" ]; then
          if [ "$cur" = "$latest" ]; then state=current; else state=outdated; fi
        else
          state=unknown
        fi
        ;;
    esac
  else
    source=none
    state=missing
  fi

  CURRENT_VERSIONS[$idx]=$cur
  INSTALLED_SOURCES[$idx]=$source
  STATES[$idx]=$state

  printf '[%s] ' "$(tool_name "$tool")"
  case "$state" in
    missing) say "未安装 | 可用来源: $(available_sources_text "$idx")" ;;
    current) say "已是最新 (${cur:-?}) | 来源: $(source_label "$source")" ;;
    outdated) say "可更新 (${cur:-?}${latest:+ -> $latest}) | 来源: $(source_label "$source") | 可用: $(available_sources_text "$idx")" ;;
    unknown) say "已安装 (${cur:-版本未知}),无法确认最新版本 | 来源: $(source_label "$source") | 可用: $(available_sources_text "$idx")" ;;
  esac
}

build_candidates() {
  local idx
  CANDIDATE_INDEXES=()
  for idx in 0 1 2; do
    case "${STATES[$idx]}" in
      missing|outdated|unknown)
        if [ "$(available_sources_text "$idx")" != '无' ]; then
          CANDIDATE_INDEXES[${#CANDIDATE_INDEXES[@]}]=$idx
        fi
        ;;
    esac
  done
}

candidate_reason() {
  case "$1" in
    missing) printf '%s' '未安装' ;;
    outdated) printf '%s' '有新版本' ;;
    unknown) printf '%s' '版本待确认' ;;
  esac
}

select_candidates() {
  local count=${#CANDIDATE_INDEXES[@]} answer token pos idx
  SELECTED_INDEXES=()
  [ "$count" -gt 0 ] || return 0

  if [ "$ASSUME_YES" = 1 ]; then
    SELECTED_INDEXES=("${CANDIDATE_INDEXES[@]}")
    return 0
  fi

  say
  say '请选择要安装/更新的项目(默认全选):'
  pos=1
  for idx in "${CANDIDATE_INDEXES[@]}"; do
    say "  [x] $pos) $(tool_name "${TOOLS[$idx]}") - $(candidate_reason "${STATES[$idx]}")"
    pos=$((pos + 1))
  done
  printf '输入编号(空格或逗号分隔,Enter/a=全选,n=取消): '
  IFS= read -r answer || answer=n
  case "$answer" in
    ''|a|A) SELECTED_INDEXES=("${CANDIDATE_INDEXES[@]}"); return 0 ;;
    n|N) return 0 ;;
  esac

  answer=$(printf '%s' "$answer" | tr ',' ' ')
  for token in $answer; do
    case "$token" in
      *[!0-9]*|'') say "  ! 忽略无效编号: $token" ;;
      *)
        if [ "$token" -ge 1 ] 2>/dev/null && [ "$token" -le "$count" ]; then
          idx=${CANDIDATE_INDEXES[$((token - 1))]}
          case " ${SELECTED_INDEXES[*]:-} " in
            *" $idx "*) ;;
            *) SELECTED_INDEXES[${#SELECTED_INDEXES[@]}]=$idx ;;
          esac
        else
          say "  ! 忽略超出范围的编号: $token"
        fi
        ;;
    esac
  done
}

default_source() {
  local idx=$1 installed=${INSTALLED_SOURCES[$1]}
  if source_is_supported "$idx" "$installed"; then
    printf '%s' "$installed"
  elif source_is_supported "$idx" official; then
    printf '%s' official
  elif source_is_supported "$idx" homebrew; then
    printf '%s' homebrew
  elif source_is_supported "$idx" npm; then
    printf '%s' npm
  fi
}

choose_source() {
  local idx=$1 tool=${TOOLS[$1]} default source answer pos=1 default_pos=1
  local sources=()
  default=$(default_source "$idx")
  for source in official homebrew npm; do
    if source_is_supported "$idx" "$source"; then
      sources[${#sources[@]}]=$source
      [ "$source" = "$default" ] && default_pos=$pos
      pos=$((pos + 1))
    fi
  done

  if [ "$ASSUME_YES" = 1 ] || [ "${#sources[@]}" -eq 1 ]; then
    printf '%s' "$default"
    return 0
  fi

  say "  $(tool_name "$tool") 可用来源:" >&2
  pos=1
  for source in "${sources[@]}"; do
    if [ "$source" = "$default" ]; then
      say "    (*) $pos) $(source_label "$source")(默认)" >&2
    else
      say "    ( ) $pos) $(source_label "$source")" >&2
    fi
    pos=$((pos + 1))
  done
  while :; do
    printf '  选择来源 [%s]: ' "$default_pos" >&2
    IFS= read -r answer || answer=$default_pos
    answer=${answer:-$default_pos}
    case "$answer" in
      *[!0-9]*|'') say '  ! 请输入有效编号' >&2 ;;
      *)
        if [ "$answer" -ge 1 ] 2>/dev/null && [ "$answer" -le "${#sources[@]}" ]; then
          printf '%s' "${sources[$((answer - 1))]}"
          return 0
        fi
        say '  ! 请输入有效编号' >&2
        ;;
    esac
  done
}

show_command() { say "  > $*"; }

run_npm() {
  local tool=$1 package
  package=$(npm_package "$tool")
  show_command "npm install -g $package@latest"
  [ "$DRY_RUN" = 1 ] || npm install -g "$package@latest"
}

run_homebrew() {
  local idx=$1 tool=${TOOLS[$1]} package kind action
  package=$(brew_package "$tool")
  kind=$(brew_kind "$tool")
  if [ "${INSTALLED_SOURCES[$idx]}" = homebrew ] && brew_is_installed "$tool"; then
    action=upgrade
  else
    action=install
  fi
  if [ "$kind" = cask ]; then
    show_command "brew $action --cask $package"
    [ "$DRY_RUN" = 1 ] || brew "$action" --cask "$package"
  else
    show_command "brew $action $package"
    [ "$DRY_RUN" = 1 ] || brew "$action" "$package"
  fi
}

run_official() {
  local idx=$1 tool=${TOOLS[$1]} url shell_command
  url=$(official_url "$tool") || return 1
  if [ "$PLATFORM" = windows ]; then
    if command -v powershell.exe >/dev/null 2>&1; then shell_command=powershell.exe; else shell_command=pwsh.exe; fi
    show_command "$shell_command -NoProfile -Command \"irm $url | iex\""
    [ "$DRY_RUN" = 1 ] || "$shell_command" -NoProfile -Command "irm '$url' | iex"
  elif [ "$tool" = claude ] && [ "${INSTALLED_SOURCES[$idx]}" = official ] && command -v claude >/dev/null 2>&1; then
    show_command 'claude update'
    [ "$DRY_RUN" = 1 ] || claude update
  else
    show_command "curl -fsSL $url | bash"
    [ "$DRY_RUN" = 1 ] || curl -fsSL "$url" | bash
  fi
}

perform_action() {
  local idx=$1 source=$2 tool=${TOOLS[$1]} rc=0
  say
  say "[$(tool_name "$tool")] 使用 $(source_label "$source")"
  case "$source" in
    npm) run_npm "$tool" || rc=$? ;;
    homebrew) run_homebrew "$idx" || rc=$? ;;
    official) run_official "$idx" || rc=$? ;;
    *) say "  ! 不支持的来源: $source"; rc=1 ;;
  esac
  if [ "$rc" -ne 0 ]; then
    say "  ✗ 操作失败 (exit $rc)"
    FAILURES=$((FAILURES + 1))
  elif [ "$DRY_RUN" = 1 ]; then
    say '  - dry-run,未执行'
  else
    say '  ✓ 完成'
  fi
}

say "正在检查安装状态与可用来源(平台: ${PLATFORM})..."
for INDEX in 0 1 2; do
  inspect_tool "$INDEX"
done
build_candidates

if [ "$CHECK_ONLY" = 1 ]; then
  exit 0
fi

if [ "${#CANDIDATE_INDEXES[@]}" -eq 0 ]; then
  say
  say '所有已安装工具均为最新,且没有待安装项。'
  exit 0
fi

if [ "$ASSUME_YES" != 1 ] && { [ ! -t 0 ] || [ ! -t 1 ]; }; then
  say
  say '当前不是交互式终端,未执行操作。使用 --yes 采用默认来源处理全部待处理项。'
  exit 0
fi

select_candidates
if [ "${#SELECTED_INDEXES[@]}" -eq 0 ]; then
  say
  say '未选择任何项目。'
  exit 0
fi

for INDEX in "${SELECTED_INDEXES[@]}"; do
  SOURCE=$(choose_source "$INDEX")
  perform_action "$INDEX" "$SOURCE"
done

[ "$FAILURES" -eq 0 ]

脚本里 Codex 的更新同样走先卸载再装,对应上面提到的坑。平时跑一次 bash ai-cli-update.sh --check,确认有没有新版本就够了。

参考

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐