diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 836be32..6fc360b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,10 @@ jobs: shell: powershell run: tests/windows-profiles.ps1 + - name: Claude compatibility policy tests + shell: powershell + run: tests/claude-compat.Tests.ps1 + - name: batch launcher is CRLF and BOM-free shell: pwsh run: | @@ -80,3 +84,25 @@ jobs: rem `echo` does not reset ERRORLEVEL in cmd, so the expected 1 would rem otherwise leak out and fail the step. exit /b 0 + + claude-compat: + strategy: + fail-fast: false + matrix: + include: + - release: '2.1.186' + experimental: false + - release: stable + experimental: false + - release: latest + experimental: true + continue-on-error: ${{ matrix.experimental }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Claude Code ${{ matrix.release }} + run: curl -fsSL https://claude.ai/install.sh | bash -s "${{ matrix.release }}" + - name: Check installed CLI capabilities + run: | + export PATH="$HOME/.local/bin:$PATH" + tests/claude-installed-compat.sh diff --git a/README.md b/README.md index 6a9b143..26497f9 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,24 @@ Older installs that use one account migrate to `profiles/default` once. Migratio not overwrite an existing profile. A running legacy proxy keeps its private config until it exits. The next launch removes the retired secret files. +### Keep Claude Code compatible + +`qbraid-code` supports Claude Code 2.1.186 or newer. It is tested through +2.1.238. The installer checks both the version and required HTTP MCP commands. + +Set `QBRAID_CODE_CLAUDE_POLICY` before installation to control an incompatible +Claude Code installation. + +| Value | Installer behavior | +|---|---| +| `prompt` | Ask before installing or upgrading. Fail without a terminal. | +| `upgrade` | Install Anthropic's stable channel without prompting. | +| `fail` | Stop without changing Claude Code. | +| `continue` | Keep the installed version and skip unavailable features. | + +The installer never downgrades a newer or unrecognized version. If your version +cannot run `claude mcp login`, authenticate through Claude Code's `/mcp` menu. + ## Start a session Start an interactive session with the active organization and default model. diff --git a/doctor.ps1 b/doctor.ps1 index c41a086..b713f3e 100644 --- a/doctor.ps1 +++ b/doctor.ps1 @@ -41,14 +41,68 @@ if (-not $token -and $settings['QBRAID_CODE_SECRET_BACKEND'] -eq 'credential-loc } catch { } } $model = $settings['QBRAID_CODE_MODEL'] +$claudeMinVersion = '2.1.186' +$claudeTestedMax = '2.1.238' +function ConvertFrom-ClaudeVersionString { + param([string]$Text) + if ($Text -match '(\d+\.\d+\.\d+)') { return $Matches[1] } + return $null +} + +function Test-ClaudeMcpCommand { + param([string]$Command) + $help = (& claude mcp --help 2>$null | Out-String) + return $help -match "(?m)^\s+$([regex]::Escape($Command))(?:\s|$)" +} + +function Test-ClaudeMcpHttp { + $help = (& claude mcp add --help 2>$null | Out-String) + return $help -match '(?s)--transport.*\bhttp\b' +} + +function Test-ClaudeMcpUserScope { + $help = (& claude mcp add --help 2>$null | Out-String) + return $help -match '(?s)--scope.*\buser\b' +} + +$claudePresent = $false +$claudeVersion = $null +$mcpAdd = $false +$mcpGet = $false +$mcpLogin = $false +$mcpHttp = $false +$mcpUserScope = $false if (Get-Command claude -ErrorAction SilentlyContinue) { + $claudePresent = $true $version = (& claude --version 2>$null) if (-not $version) { $version = 'present' } Write-Host "claude: $version" + $claudeVersion = ConvertFrom-ClaudeVersionString ($version | Out-String) + $mcpAdd = Test-ClaudeMcpCommand 'add' + $mcpGet = Test-ClaudeMcpCommand 'get' + $mcpLogin = Test-ClaudeMcpCommand 'login' + $mcpHttp = Test-ClaudeMcpHttp + $mcpUserScope = Test-ClaudeMcpUserScope } else { Write-Host 'claude: NOT INSTALLED' } +if (-not $claudeVersion) { + Write-Host "claude-min: UNKNOWN (requires $claudeMinVersion+)" + Write-Host 'claude-tested: unknown' +} elseif ([version]$claudeVersion -lt [version]$claudeMinVersion) { + Write-Host "claude-min: FAIL (requires $claudeMinVersion+, found $claudeVersion)" + Write-Host 'claude-tested: unsupported' +} elseif ([version]$claudeVersion -gt [version]$claudeTestedMax) { + Write-Host "claude-min: PASS (requires $claudeMinVersion+)" + Write-Host "claude-tested: NEWER than tested $claudeTestedMax (not blocked)" +} else { + Write-Host "claude-min: PASS (requires $claudeMinVersion+)" + Write-Host "claude-tested: within tested range through $claudeTestedMax" +} +$claudePolicy = if ($env:QBRAID_CODE_CLAUDE_POLICY) { $env:QBRAID_CODE_CLAUDE_POLICY } else { 'prompt' } +Write-Host "claude-policy: $claudePolicy" +Write-Host "capabilities: mcp-add=$($mcpAdd.ToString().ToLower()) mcp-get=$($mcpGet.ToString().ToLower()) mcp-login=$($mcpLogin.ToString().ToLower()) mcp-http=$($mcpHttp.ToString().ToLower()) mcp-user-scope=$($mcpUserScope.ToString().ToLower())" # Separate transport failure from rejection. Reporting "REJECTED" for a dropped # connection sent people off to make a new key for no reason. @@ -82,11 +136,23 @@ try { Write-Host 'gateway: UNREACHABLE' } -& claude mcp get qbraid *> $null -if ($LASTEXITCODE -eq 0) { - Write-Host "mcp: registered (run 'claude mcp login qbraid' if tools are missing)" +if (-not $claudePresent -or -not $mcpGet) { + Write-Host 'mcp: UNAVAILABLE - upgrade Claude Code or configure it through /mcp' } else { - Write-Host 'mcp: NOT REGISTERED' + & claude mcp get qbraid *> $null + if ($LASTEXITCODE -eq 0) { + if ($mcpLogin) { + Write-Host "mcp: registered (run 'claude mcp login qbraid' if tools are missing)" + } else { + Write-Host 'mcp: registered (run /mcp inside Claude Code to authenticate)' + } + } else { + if ($mcpLogin) { + Write-Host 'mcp: NOT REGISTERED - re-run the installer' + } else { + Write-Host 'mcp: NOT REGISTERED - configure and authenticate through /mcp' + } + } } Write-Host "model: $model" diff --git a/install.ps1 b/install.ps1 index 94e3184..1dfee80 100644 --- a/install.ps1 +++ b/install.ps1 @@ -37,6 +37,9 @@ $GatewayUrl = "$ApiBase/ai" $McpName = 'qbraid' $McpUrl = 'https://mcp.qbraid.com/mcp' $KeysUrl = 'https://account.qbraid.com/account/api-keys' +$ClaudeReleasesUrl = 'https://downloads.claude.ai/claude-code-releases' +$script:ClaudeMinVersion = '2.1.186' +$script:ClaudeTestedMax = '2.1.238' $SiteBase = 'https://qbraid.com/code' $RawBase = 'https://raw.githubusercontent.com/qBraid/qbraid-code/main' $GhContents = '/repos/qBraid/qbraid-code/contents' @@ -51,12 +54,17 @@ $ClaudeJson = Join-Path $env:USERPROFILE '.claude.json' function Say { param($m) Write-Host "==> $m" -ForegroundColor White } function Ok { param($m) Write-Host " + $m" -ForegroundColor Green } function Warn { param($m) Write-Host " ! $m" -ForegroundColor Yellow } +function Test-InteractiveConsole { + try { return -not [Console]::IsInputRedirected } catch { return $false } +} function Die { param($m) Write-Host "`nerror: $m" -ForegroundColor Red # Under `irm | iex` in a fresh window, exiting closes the window with the # message still on screen for a fraction of a second. Hold it open. - if ($Host.UI.RawUI) { try { Read-Host 'Press Enter to close' | Out-Null } catch { } } + if ((Test-InteractiveConsole) -and $Host.UI.RawUI) { + try { Read-Host 'Press Enter to close' | Out-Null } catch { } + } exit 1 } @@ -93,6 +101,200 @@ function Read-PidFile { return $value } +# ------------------------------------------------------ Claude compatibility + +function ConvertFrom-ClaudeVersionString { + param([string]$Text) + if ($Text -match '(\d+\.\d+\.\d+)') { return $Matches[1] } + return $null +} + +function Compare-ClaudeVersion { + param([string]$Left, [string]$Right) + return ([version]$Left).CompareTo([version]$Right) +} + +function Test-ClaudeUpgradeSafe { + param([string]$Installed, [string]$Target) + return -not $Installed -or (Compare-ClaudeVersion $Installed $Target) -le 0 +} + +function Get-ClaudeVersionStatus { + param([string]$Version) + if (-not $Version) { return 'unknown' } + if ((Compare-ClaudeVersion $Version $script:ClaudeMinVersion) -lt 0) { + return 'below-minimum' + } + if ((Compare-ClaudeVersion $Version $script:ClaudeTestedMax) -gt 0) { + return 'newer-than-tested' + } + return 'tested' +} + +function Get-ClaudePolicyAction { + param([string]$Policy, [bool]$Interactive) + switch ($Policy) { + 'upgrade' { return 'upgrade' } + 'fail' { return 'fail' } + 'continue' { return 'continue' } + 'prompt' { if ($Interactive) { return 'prompt' } else { return 'fail' } } + default { return 'invalid' } + } +} + +function Test-ClaudeMcpCommand { + param([string]$Command) + $help = (& claude mcp --help 2>$null | Out-String) + return $help -match "(?m)^\s+$([regex]::Escape($Command))(?:\s|$)" +} + +function Test-ClaudeMcpHttp { + $help = (& claude mcp add --help 2>$null | Out-String) + return $help -match '(?s)--transport.*\bhttp\b' +} + +function Test-ClaudeMcpUserScope { + $help = (& claude mcp add --help 2>$null | Out-String) + return $help -match '(?s)--scope.*\buser\b' +} + +function Install-ClaudeStable { + $installed = $null + $installedVariable = Get-Variable ClaudeVersion -Scope Script -ErrorAction SilentlyContinue + if ($installedVariable) { $installed = $installedVariable.Value } + try { + $rawTarget = (Invoke-RestMethod -Uri "$ClaudeReleasesUrl/stable").ToString().Trim() + $target = ConvertFrom-ClaudeVersionString $rawTarget + if (-not $target -or $target -ne $rawTarget) { + Die "Anthropic's stable Claude Code version was invalid." + } + if (-not (Test-ClaudeUpgradeSafe $installed $target)) { + Die "Claude Code $installed is newer than stable $target. Refusing to downgrade it; update Claude Code manually or set QBRAID_CODE_CLAUDE_POLICY=continue." + } + Warn "installing Claude Code $target from Anthropic's stable channel" + $installer = [scriptblock]::Create( + (Invoke-RestMethod -Uri 'https://claude.ai/install.ps1')) + & $installer $target + } catch { + Die "Claude Code stable-channel install failed: $_" + } + $env:Path = "$(Join-Path $env:USERPROFILE '.local\bin');$BinDir;$env:Path" + if (-not (Get-Command claude -ErrorAction SilentlyContinue)) { + Die 'Claude Code installed but `claude` is not on PATH. Open a new terminal and re-run.' + } +} + +function Update-ClaudeState { + $output = (& claude --version 2>$null | Out-String).Trim() + $script:ClaudeVersion = ConvertFrom-ClaudeVersionString $output + $script:ClaudeVersionStatus = Get-ClaudeVersionStatus $script:ClaudeVersion + $script:ClaudeMcpAdd = Test-ClaudeMcpCommand 'add' + $script:ClaudeMcpGet = Test-ClaudeMcpCommand 'get' + $script:ClaudeMcpLogin = Test-ClaudeMcpCommand 'login' + $script:ClaudeMcpHttp = Test-ClaudeMcpHttp + $script:ClaudeMcpUserScope = Test-ClaudeMcpUserScope +} + +function Test-ClaudeRequiredCapabilities { + return $script:ClaudeMcpAdd -and $script:ClaudeMcpGet -and + $script:ClaudeMcpHttp -and $script:ClaudeMcpUserScope +} + +function Confirm-ClaudeCompatibility { + $policy = if ($env:QBRAID_CODE_CLAUDE_POLICY) { + $env:QBRAID_CODE_CLAUDE_POLICY.ToLower() + } else { + 'prompt' + } + if ($policy -notin @('prompt', 'upgrade', 'fail', 'continue')) { + Die 'QBRAID_CODE_CLAUDE_POLICY must be prompt, upgrade, fail, or continue.' + } + $interactive = Test-InteractiveConsole + + if (-not (Get-Command claude -ErrorAction SilentlyContinue)) { + $action = Get-ClaudePolicyAction $policy $interactive + if ($action -eq 'upgrade') { + Install-ClaudeStable + } elseif ($action -eq 'prompt') { + if (Confirm-Step 'Claude Code is not installed. Install the stable channel now?' 'y') { + Install-ClaudeStable + } else { + Die 'Claude Code is required. Re-run with QBRAID_CODE_CLAUDE_POLICY=upgrade to install it.' + } + } else { + Die 'Claude Code is not installed. Re-run with QBRAID_CODE_CLAUDE_POLICY=upgrade.' + } + } + + Update-ClaudeState + $issue = $null + if ($script:ClaudeVersionStatus -eq 'unknown') { + $issue = 'could not determine its version' + } elseif ($script:ClaudeVersionStatus -eq 'below-minimum') { + $issue = "version $($script:ClaudeVersion) is below the supported minimum $script:ClaudeMinVersion" + } + if (-not (Test-ClaudeRequiredCapabilities)) { + if ($issue) { $issue += '; ' } + $issue += 'required HTTP MCP commands are unavailable' + } + + $upgraded = $false + if ($issue) { + $action = Get-ClaudePolicyAction $policy $interactive + if ($script:ClaudeVersionStatus -eq 'newer-than-tested') { + if ($action -in @('continue', 'prompt')) { + Warn "Claude Code $($script:ClaudeVersion) is newer than tested and lacks required capabilities; refusing to downgrade it and continuing with reduced compatibility." + $action = 'handled' + } else { + Die "Claude Code $($script:ClaudeVersion) is newer than tested and lacks required capabilities. Refusing to downgrade it; set QBRAID_CODE_CLAUDE_POLICY=continue to skip unavailable features." + } + } elseif ($script:ClaudeVersionStatus -eq 'unknown') { + if ($action -in @('continue', 'prompt')) { + Warn "Claude Code's version is unknown; refusing to replace it with stable because that could downgrade it, and continuing with reduced compatibility." + $action = 'handled' + } else { + Die "Claude Code's version is unknown. Refusing to replace it with stable because that could downgrade it; update Claude Code manually or set QBRAID_CODE_CLAUDE_POLICY=continue." + } + } + if ($action -eq 'upgrade') { + Warn "the installed Claude Code is incompatible: $issue" + Install-ClaudeStable + $upgraded = $true + } elseif ($action -eq 'prompt') { + Warn "the installed Claude Code is incompatible: $issue" + if (Confirm-Step 'Upgrade Claude Code to the stable channel now?' 'y') { + Install-ClaudeStable + $upgraded = $true + } else { + Warn 'continuing with reduced compatibility at your request' + } + } elseif ($action -eq 'continue') { + Warn "continuing with an unsupported Claude Code: $issue" + } elseif ($action -eq 'handled') { + # The newer-version branch above already reported the safe fallback. + } else { + Die "the installed Claude Code is incompatible: $issue. Upgrade it, or explicitly set QBRAID_CODE_CLAUDE_POLICY=continue." + } + } + + if ($upgraded) { + Update-ClaudeState + if ($script:ClaudeVersionStatus -in @('unknown', 'below-minimum') -or + -not (Test-ClaudeRequiredCapabilities)) { + Die "Claude Code was upgraded, but version $script:ClaudeMinVersion+ with HTTP MCP support is still unavailable." + } + } + + if ($script:ClaudeVersionStatus -eq 'newer-than-tested') { + Warn "Claude Code $($script:ClaudeVersion) is newer than the latest tested version ($script:ClaudeTestedMax); continuing without downgrading." + } elseif ($script:ClaudeVersionStatus -eq 'tested') { + Ok "Claude Code $($script:ClaudeVersion)" + } else { + $displayVersion = if ($script:ClaudeVersion) { $script:ClaudeVersion } else { 'present' } + Warn "Claude Code $displayVersion remains outside the supported range; unavailable features will be skipped." + } +} + # ---------------------------------------------------------------- 1. platform if (-not [Environment]::Is64BitOperatingSystem) { @@ -245,24 +447,7 @@ Remove-Item $globalProfilePath -Force -ErrorAction SilentlyContinue # ------------------------------------------------------------ 2. claude code Say 'Claude Code' -if (Get-Command claude -ErrorAction SilentlyContinue) { - Ok 'already installed' -} else { - Warn 'not installed - installing' - # Anthropic's official installer: a native binary, no Node.js, no admin rights. - try { - & ([scriptblock]::Create((Invoke-RestMethod -Uri 'https://claude.ai/install.ps1'))) - } catch { - Die "Claude Code install failed: $_" - } - # Anthropic's installer always writes to %USERPROFILE%\.local\bin; $BinDir is - # overridable and may be somewhere else entirely. - $env:Path = "$(Join-Path $env:USERPROFILE '.local\bin');$BinDir;$env:Path" - if (-not (Get-Command claude -ErrorAction SilentlyContinue)) { - Die 'Claude Code installed but `claude` is not on PATH. Open a new terminal and re-run.' - } - Ok 'installed' -} +Confirm-ClaudeCompatibility # ------------------------------------------------------------- 3. credential @@ -719,19 +904,33 @@ Ok "statusline enabled in $Settings" # ------------------------------------------------------------------- 10. mcp Say 'qBraid MCP' -claude mcp get $McpName *> $null -if ($LASTEXITCODE -eq 0) { - Ok 'already registered' -} else { +$mcpRegistered = $false +if ($script:ClaudeMcpGet) { + claude mcp get $McpName *> $null + if ($LASTEXITCODE -eq 0) { + $mcpRegistered = $true + Ok 'already registered' + } +} +if (-not $mcpRegistered -and (Test-ClaudeRequiredCapabilities)) { claude mcp add --transport http $McpName $McpUrl --scope user *> $null if ($LASTEXITCODE -ne 0) { Die 'could not register the qBraid MCP server.' } + $mcpRegistered = $true Ok "registered $McpUrl" +} elseif (-not $mcpRegistered) { + Warn 'this Claude Code version cannot register an HTTP MCP server from the command line.' + Warn "Start Claude Code, run /mcp, and add $McpUrl manually; or upgrade Claude Code." } # The MCP endpoint is JWT-only (OAuth + dynamic client registration): the API # key above cannot authorize it. Do the browser sign-in now, while the user is # still here, rather than surprising them mid-session. -if (Confirm-Step 'Sign in to the qBraid MCP now? (opens a browser)' 'y') { +if (-not $mcpRegistered) { + Warn 'MCP sign-in was skipped because registration is incomplete.' +} elseif (-not $script:ClaudeMcpLogin) { + Warn 'this Claude Code version authenticates MCP servers through its interactive menu.' + Warn "Start Claude Code, run /mcp, select '$McpName', and choose Authenticate." +} elseif (Confirm-Step 'Sign in to the qBraid MCP now? (opens a browser)' 'y') { # Unlike the piped bash path, `iex` keeps the console attached, so the # OAuth prompt can read the redirect URL directly. claude mcp login $McpName diff --git a/install.sh b/install.sh index 738871a..bdc9d7f 100755 --- a/install.sh +++ b/install.sh @@ -25,6 +25,9 @@ PROXY_PORT="" PROXY_REPO="router-for-me/CLIProxyAPI" MCP_URL="https://mcp.qbraid.com/mcp" KEYS_URL="https://account.qbraid.com/account/api-keys" +CLAUDE_RELEASES_URL="https://downloads.claude.ai/claude-code-releases" +CLAUDE_MIN_VERSION="2.1.186" +CLAUDE_TESTED_MAX="2.1.238" # Companion files are fetched from qbraid.com first. That is the whole point # of the proxy: on a campus network that blocks raw.githubusercontent.com, an @@ -60,6 +63,7 @@ Environment: QBRAID_API_KEY use this key instead of prompting QBRAID_CODE_MODEL use this model instead of prompting QBRAID_CODE_PROFILE_LABEL readable local account label + QBRAID_CODE_CLAUDE_POLICY prompt, upgrade, fail, or continue QBRAID_CODE_HOME config directory (default ~/.qbraid-code) QBRAID_CODE_BIN_DIR install directory (default ~/.local/bin) EOF @@ -245,6 +249,188 @@ confirm() { # confirm -> 0 if yes [ "$reply" = y ] || [ "$reply" = yes ] } +# ------------------------------------------------------ Claude compatibility + +parse_claude_version() { # parse_claude_version + ( + set +o pipefail + printf '%s' "$1" | grep -o '[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*' | head -1 + ) || true +} + +compare_versions() { # compare_versions -> -1, 0, or 1 + awk -v left="$1" -v right="$2" 'BEGIN { + split(left, l, "."); split(right, r, ".") + for (i = 1; i <= 3; i++) { + if ((l[i] + 0) < (r[i] + 0)) { print -1; exit } + if ((l[i] + 0) > (r[i] + 0)) { print 1; exit } + } + print 0 + }' +} + +claude_upgrade_is_safe() { # claude_upgrade_is_safe + [ -z "$1" ] || [ "$(compare_versions "$1" "$2")" -le 0 ] +} + +claude_version_status() { # claude_version_status + local version="$1" compared + [ -n "$version" ] || { printf 'unknown'; return; } + compared=$(compare_versions "$version" "$CLAUDE_MIN_VERSION") + [ "$compared" -ge 0 ] || { printf 'below-minimum'; return; } + compared=$(compare_versions "$version" "$CLAUDE_TESTED_MAX") + [ "$compared" -le 0 ] && printf 'tested' || printf 'newer-than-tested' +} + +claude_policy_action() { # claude_policy_action + case "$1" in + upgrade|fail|continue) printf '%s' "$1" ;; + prompt) [ "$2" = yes ] && printf 'prompt' || printf 'fail' ;; + *) printf 'invalid' ;; + esac +} + +claude_supports_mcp_command() { # claude_supports_mcp_command + local help + help=$(claude mcp --help 2>/dev/null || true) + printf '%s\n' "$help" | grep -Eq "^[[:space:]]+$1([[:space:]]|$)" +} + +claude_supports_mcp_http() { + local help + help=$(claude mcp add --help 2>/dev/null || true) + printf '%s\n' "$help" | grep -Eq -- '--transport.*http' +} + +claude_supports_mcp_user_scope() { + local help + help=$(claude mcp add --help 2>/dev/null || true) + printf '%s\n' "$help" | grep -Eq -- '--scope.*user' +} + +install_claude_stable() { + local installed="${CLAUDE_VERSION:-}" target raw + raw=$(curl -fsSL --max-time 20 "$CLAUDE_RELEASES_URL/stable") \ + || die "could not resolve Anthropic's stable Claude Code version." + target=$(parse_claude_version "$raw") + [ -n "$target" ] && [ "$raw" = "$target" ] \ + || die "Anthropic's stable Claude Code version was invalid." + claude_upgrade_is_safe "$installed" "$target" \ + || die "Claude Code $installed is newer than stable $target. Refusing to downgrade it; update Claude Code manually or set QBRAID_CODE_CLAUDE_POLICY=continue." + warn "installing Claude Code $target from Anthropic's stable channel" + curl -fsSL https://claude.ai/install.sh | bash -s "$target" \ + || die "Claude Code stable-channel install failed. See https://claude.com/product/claude-code" + export PATH="$HOME/.local/bin:$PATH" + hash -r + command -v claude >/dev/null 2>&1 \ + || die "Claude Code installed but \`claude\` is not on PATH." +} + +refresh_claude_state() { + local output + output=$(claude --version 2>/dev/null || true) + CLAUDE_VERSION=$(parse_claude_version "$output") + CLAUDE_VERSION_STATUS=$(claude_version_status "$CLAUDE_VERSION") + CLAUDE_MCP_ADD=0; CLAUDE_MCP_GET=0; CLAUDE_MCP_LOGIN=0 + CLAUDE_MCP_HTTP=0; CLAUDE_MCP_USER_SCOPE=0 + claude_supports_mcp_command add && CLAUDE_MCP_ADD=1 + claude_supports_mcp_command get && CLAUDE_MCP_GET=1 + claude_supports_mcp_command login && CLAUDE_MCP_LOGIN=1 + claude_supports_mcp_http && CLAUDE_MCP_HTTP=1 + claude_supports_mcp_user_scope && CLAUDE_MCP_USER_SCOPE=1 + return 0 +} + +claude_required_capabilities_present() { + [ "$CLAUDE_MCP_ADD" = 1 ] && [ "$CLAUDE_MCP_GET" = 1 ] \ + && [ "$CLAUDE_MCP_HTTP" = 1 ] && [ "$CLAUDE_MCP_USER_SCOPE" = 1 ] +} + +ensure_claude_compatible() { + local policy="${QBRAID_CODE_CLAUDE_POLICY:-prompt}" interactive=no action issue="" upgraded=0 + [ -n "$TTY" ] && interactive=yes + case "$policy" in + prompt|upgrade|fail|continue) ;; + *) die "QBRAID_CODE_CLAUDE_POLICY must be prompt, upgrade, fail, or continue." ;; + esac + + if ! command -v claude >/dev/null 2>&1; then + action=$(claude_policy_action "$policy" "$interactive") + case "$action" in + upgrade) install_claude_stable ;; + prompt) + confirm "Claude Code is not installed. Install the stable channel now?" y \ + && install_claude_stable \ + || die "Claude Code is required. Re-run with QBRAID_CODE_CLAUDE_POLICY=upgrade to install it." + ;; + *) die "Claude Code is not installed. Re-run with QBRAID_CODE_CLAUDE_POLICY=upgrade." ;; + esac + fi + + refresh_claude_state + case "$CLAUDE_VERSION_STATUS" in + unknown) issue="could not determine its version" ;; + below-minimum) issue="version $CLAUDE_VERSION is below the supported minimum $CLAUDE_MIN_VERSION" ;; + esac + if ! claude_required_capabilities_present; then + [ -n "$issue" ] && issue="$issue; " + issue="${issue}required HTTP MCP commands are unavailable" + fi + + if [ -n "$issue" ]; then + action=$(claude_policy_action "$policy" "$interactive") + case "$CLAUDE_VERSION_STATUS" in + newer-than-tested) + case "$action" in + continue|prompt) warn "Claude Code $CLAUDE_VERSION is newer than tested and lacks required capabilities; refusing to downgrade it and continuing with reduced compatibility." ;; + *) die "Claude Code $CLAUDE_VERSION is newer than tested and lacks required capabilities. Refusing to downgrade it; set QBRAID_CODE_CLAUDE_POLICY=continue to skip unavailable features." ;; + esac + action=handled + ;; + unknown) + case "$action" in + continue|prompt) warn "Claude Code's version is unknown; refusing to replace it with stable because that could downgrade it, and continuing with reduced compatibility." ;; + *) die "Claude Code's version is unknown. Refusing to replace it with stable because that could downgrade it; update Claude Code manually or set QBRAID_CODE_CLAUDE_POLICY=continue." ;; + esac + action=handled + ;; + esac + case "$action" in + upgrade) + warn "the installed Claude Code is incompatible: $issue" + install_claude_stable; upgraded=1 + ;; + prompt) + warn "the installed Claude Code is incompatible: $issue" + if confirm "Upgrade Claude Code to the stable channel now?" y; then + install_claude_stable; upgraded=1 + else + warn "continuing with reduced compatibility at your request" + fi + ;; + continue) warn "continuing with an unsupported Claude Code: $issue" ;; + fail) + die "the installed Claude Code is incompatible: $issue. Upgrade it, or explicitly set QBRAID_CODE_CLAUDE_POLICY=continue." + ;; + handled) ;; + esac + fi + + if [ "$upgraded" = 1 ]; then + refresh_claude_state + [ "$CLAUDE_VERSION_STATUS" != unknown ] \ + && [ "$CLAUDE_VERSION_STATUS" != below-minimum ] \ + && claude_required_capabilities_present \ + || die "Claude Code was upgraded, but version $CLAUDE_MIN_VERSION+ with HTTP MCP support is still unavailable." + fi + + case "$CLAUDE_VERSION_STATUS" in + tested) ok "Claude Code $CLAUDE_VERSION" ;; + newer-than-tested) warn "Claude Code $CLAUDE_VERSION is newer than the latest tested version ($CLAUDE_TESTED_MAX); continuing without downgrading." ;; + *) warn "Claude Code ${CLAUDE_VERSION:-present} remains outside the supported range; unavailable features will be skipped." ;; + esac +} + # ---------------------------------------------------------------- 1. platform case "$(uname -s)" in @@ -408,19 +594,7 @@ store_profile_secret() { # ------------------------------------------------------------ 2. claude code say "Claude Code" -if command -v claude >/dev/null 2>&1; then - ok "already installed ($(claude --version 2>/dev/null || echo present))" -else - warn "not installed — installing" - # Anthropic's official installer: a native binary into ~/.local/bin. - # No Node.js and no administrator rights required. - curl -fsSL https://claude.ai/install.sh | bash \ - || die "Claude Code install failed. See https://claude.com/product/claude-code" - export PATH="$HOME/.local/bin:$PATH" - command -v claude >/dev/null 2>&1 \ - || die "Claude Code installed but \`claude\` is not on PATH." - ok "installed" -fi +ensure_claude_compatible # ------------------------------------------------------------- 3. credential @@ -978,18 +1152,29 @@ fi # ------------------------------------------------------------------- 10. mcp say "qBraid MCP" -if claude mcp get "$MCP_NAME" >/dev/null 2>&1; then +MCP_REGISTERED=0 +if [ "$CLAUDE_MCP_GET" = 1 ] && claude mcp get "$MCP_NAME" >/dev/null 2>&1; then + MCP_REGISTERED=1 ok "already registered" -else +elif claude_required_capabilities_present; then claude mcp add --transport http "$MCP_NAME" "$MCP_URL" --scope user >/dev/null \ || die "could not register the qBraid MCP server." + MCP_REGISTERED=1 ok "registered $MCP_URL" +else + warn "this Claude Code version cannot register an HTTP MCP server from the command line." + warn "Start Claude Code, run /mcp, and add $MCP_URL manually; or upgrade Claude Code." fi # The MCP endpoint is JWT-only (OAuth + dynamic client registration): the API # key above cannot authorize it. Do the browser sign-in now, while the user is # still here, rather than surprising them mid-session. -if [ -n "$TTY" ]; then +if [ "$MCP_REGISTERED" != 1 ]; then + warn "MCP sign-in was skipped because registration is incomplete." +elif [ "$CLAUDE_MCP_LOGIN" != 1 ]; then + warn "this Claude Code version authenticates MCP servers through its interactive menu." + warn "Start Claude Code, run /mcp, select '$MCP_NAME', and choose Authenticate." +elif [ -n "$TTY" ]; then if confirm "Sign in to the qBraid MCP now? (opens a browser)" y; then # `claude mcp login` needs a real terminal to take the redirect URL. Under # `curl … | bash` this script's stdin IS the pipe, so it must be handed the diff --git a/qbraid-code b/qbraid-code index 2680386..0e8a775 100755 --- a/qbraid-code +++ b/qbraid-code @@ -7,6 +7,9 @@ # shell that never set the variable. set -euo pipefail +CLAUDE_MIN_VERSION="2.1.186" +CLAUDE_TESTED_MAX="2.1.238" + HOME_DIR="${QBRAID_CODE_HOME:-}" if [ -z "$HOME_DIR" ] && [ -f "${BASH_SOURCE[0]}.home" ]; then IFS= read -r HOME_DIR < "${BASH_SOURCE[0]}.home" || true @@ -313,6 +316,51 @@ model_context() { awk -F '\t' -v wanted="$id" '$1 == wanted { print $2; exit }' "$PROFILE_HOME/models.tsv" } +parse_claude_version() { # parse_claude_version + ( + set +o pipefail + printf '%s' "$1" | grep -o '[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*' | head -1 + ) || true +} + +compare_versions() { # compare_versions -> -1, 0, or 1 + awk -v left="$1" -v right="$2" 'BEGIN { + split(left, l, "."); split(right, r, ".") + for (i = 1; i <= 3; i++) { + if ((l[i] + 0) < (r[i] + 0)) { print -1; exit } + if ((l[i] + 0) > (r[i] + 0)) { print 1; exit } + } + print 0 + }' +} + +claude_version_status() { # claude_version_status + local version="$1" compared + [ -n "$version" ] || { printf 'unknown'; return; } + compared=$(compare_versions "$version" "$CLAUDE_MIN_VERSION") + [ "$compared" -ge 0 ] || { printf 'below-minimum'; return; } + compared=$(compare_versions "$version" "$CLAUDE_TESTED_MAX") + [ "$compared" -le 0 ] && printf 'tested' || printf 'newer-than-tested' +} + +claude_supports_mcp_command() { # claude_supports_mcp_command + local help + help=$(claude mcp --help 2>/dev/null || true) + printf '%s\n' "$help" | grep -Eq "^[[:space:]]+$1([[:space:]]|$)" +} + +claude_supports_mcp_http() { + local help + help=$(claude mcp add --help 2>/dev/null || true) + printf '%s\n' "$help" | grep -Eq -- '--transport.*http' +} + +claude_supports_mcp_user_scope() { + local help + help=$(claude mcp add --help 2>/dev/null || true) + printf '%s\n' "$help" | grep -Eq -- '--scope.*user' +} + missing_keys() { local missing="" [ -n "$BASE_URL" ] || missing="$missing QBRAID_CODE_BASE_URL" @@ -328,9 +376,29 @@ case "${1:-}" in echo "qbraid-code: local proxies stopped" exit 0 ;; --doctor) - command -v claude >/dev/null 2>&1 \ - && echo "claude: $(claude --version 2>/dev/null || echo present)" \ - || echo "claude: NOT INSTALLED" + CLAUDE_VERSION=""; CLAUDE_STATUS=missing + MCP_ADD=no; MCP_GET=no; MCP_LOGIN=no; MCP_HTTP=no; MCP_USER_SCOPE=no + if command -v claude >/dev/null 2>&1; then + CLAUDE_OUTPUT=$(claude --version 2>/dev/null || true) + CLAUDE_VERSION=$(parse_claude_version "$CLAUDE_OUTPUT") + CLAUDE_STATUS=$(claude_version_status "$CLAUDE_VERSION") + claude_supports_mcp_command add && MCP_ADD=yes + claude_supports_mcp_command get && MCP_GET=yes + claude_supports_mcp_command login && MCP_LOGIN=yes + claude_supports_mcp_http && MCP_HTTP=yes + claude_supports_mcp_user_scope && MCP_USER_SCOPE=yes + echo "claude: ${CLAUDE_OUTPUT:-present}" + else + echo "claude: NOT INSTALLED" + fi + case "$CLAUDE_STATUS" in + tested) echo "claude-min: PASS (requires $CLAUDE_MIN_VERSION+)"; echo "claude-tested: within tested range through $CLAUDE_TESTED_MAX" ;; + newer-than-tested) echo "claude-min: PASS (requires $CLAUDE_MIN_VERSION+)"; echo "claude-tested: NEWER than tested $CLAUDE_TESTED_MAX (not blocked)" ;; + below-minimum) echo "claude-min: FAIL (requires $CLAUDE_MIN_VERSION+, found $CLAUDE_VERSION)"; echo "claude-tested: unsupported" ;; + *) echo "claude-min: UNKNOWN (requires $CLAUDE_MIN_VERSION+)"; echo "claude-tested: unknown" ;; + esac + echo "claude-policy: ${QBRAID_CODE_CLAUDE_POLICY:-prompt}" + echo "capabilities: mcp-add=$MCP_ADD mcp-get=$MCP_GET mcp-login=$MCP_LOGIN mcp-http=$MCP_HTTP mcp-user-scope=$MCP_USER_SCOPE" MISSING=$(missing_keys) if [ -n "$MISSING" ]; then @@ -382,10 +450,20 @@ case "${1:-}" in echo "gateway: UNREACHABLE" fi - if claude mcp get qbraid >/dev/null 2>&1; then - echo "mcp: registered (run 'claude mcp login qbraid' if tools are missing)" + if [ "$MCP_GET" != yes ]; then + echo "mcp: UNAVAILABLE — upgrade Claude Code or configure it through /mcp" + elif claude mcp get qbraid >/dev/null 2>&1; then + if [ "$MCP_LOGIN" = yes ]; then + echo "mcp: registered (run 'claude mcp login qbraid' if tools are missing)" + else + echo "mcp: registered (run /mcp inside Claude Code to authenticate)" + fi else - echo "mcp: NOT REGISTERED" + if [ "$MCP_LOGIN" = yes ]; then + echo "mcp: NOT REGISTERED — re-run the installer" + else + echo "mcp: NOT REGISTERED — configure and authenticate through /mcp" + fi fi echo "model: ${MODEL:-not set}" diff --git a/tests/claude-compat.Tests.ps1 b/tests/claude-compat.Tests.ps1 new file mode 100644 index 0000000..2dc4679 --- /dev/null +++ b/tests/claude-compat.Tests.ps1 @@ -0,0 +1,176 @@ +$ErrorActionPreference = 'Stop' + +$source = Join-Path $PSScriptRoot '..\install.ps1' +$tokens = $null +$errors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile( + $source, [ref]$tokens, [ref]$errors) +if ($errors) { throw "could not parse install.ps1: $errors" } + +$needed = @( + 'ConvertFrom-ClaudeVersionString', + 'Compare-ClaudeVersion', + 'Test-ClaudeUpgradeSafe', + 'Get-ClaudeVersionStatus', + 'Get-ClaudePolicyAction', + 'Test-ClaudeMcpCommand', + 'Test-ClaudeMcpHttp', + 'Test-ClaudeMcpUserScope', + 'Update-ClaudeState', + 'Test-ClaudeRequiredCapabilities', + 'Confirm-ClaudeCompatibility' +) + +foreach ($name in $needed) { + $definition = $ast.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq $name + }, $true) | Select-Object -First 1 + if (-not $definition) { throw "could not extract $name from install.ps1" } + Invoke-Expression $definition.Extent.Text +} + +$script:Passed = 0 +$script:Failed = 0 +function Assert-Equal { + param([string]$Name, $Actual, $Expected) + if ($Actual -eq $Expected) { + $script:Passed++ + Write-Host " ok $Name" + } else { + $script:Failed++ + Write-Host " FAIL $Name`: got [$Actual] want [$Expected]" + } +} + +$script:ClaudeMinVersion = '2.1.186' +$script:ClaudeTestedMax = '2.1.238' + +Assert-Equal 'parse native version output' ` + (ConvertFrom-ClaudeVersionString '2.1.179 (Claude Code)') '2.1.179' +Assert-Equal 'parse version with surrounding text' ` + (ConvertFrom-ClaudeVersionString 'Claude Code version 2.1.238') '2.1.238' +Assert-Equal 'missing version parses empty' ` + (ConvertFrom-ClaudeVersionString 'present but unparseable') $null + +Assert-Equal 'older version compares below' (Compare-ClaudeVersion '2.1.185' '2.1.186') -1 +Assert-Equal 'equal version compares equal' (Compare-ClaudeVersion '2.1.186' '2.1.186') 0 +Assert-Equal 'newer patch compares above' (Compare-ClaudeVersion '2.1.238' '2.1.186') 1 +Assert-Equal 'newer minor compares above' (Compare-ClaudeVersion '2.2.0' '2.1.999') 1 +Assert-Equal 'stable target allows an upgrade' (Test-ClaudeUpgradeSafe '2.1.185' '2.1.228') $true +Assert-Equal 'stable target refuses a tested-range downgrade' (Test-ClaudeUpgradeSafe '2.1.238' '2.1.228') $false + +Assert-Equal 'unknown version status' (Get-ClaudeVersionStatus $null) 'unknown' +Assert-Equal 'below minimum status' (Get-ClaudeVersionStatus '2.1.185') 'below-minimum' +Assert-Equal 'minimum is tested' (Get-ClaudeVersionStatus '2.1.186') 'tested' +Assert-Equal 'tested maximum is tested' (Get-ClaudeVersionStatus '2.1.238') 'tested' +Assert-Equal 'newer version is informational' (Get-ClaudeVersionStatus '2.1.239') 'newer-than-tested' + +Assert-Equal 'upgrade policy upgrades' (Get-ClaudePolicyAction 'upgrade' $false) 'upgrade' +Assert-Equal 'fail policy fails' (Get-ClaudePolicyAction 'fail' $true) 'fail' +Assert-Equal 'continue policy continues' (Get-ClaudePolicyAction 'continue' $false) 'continue' +Assert-Equal 'interactive prompt prompts' (Get-ClaudePolicyAction 'prompt' $true) 'prompt' +Assert-Equal 'noninteractive prompt fails' (Get-ClaudePolicyAction 'prompt' $false) 'fail' + +function global:claude { + $joined = $args -join ' ' + if ($joined -eq '--version') { + "$script:FakeClaudeVersion (Claude Code)" + } elseif ($joined -eq 'mcp --help') { + 'Commands:' + ' add [options]' + ' get ' + if ($script:FakeMcpLogin) { ' login ' } + } elseif ($joined -eq 'mcp add --help') { + ' --transport stdio, sse, or http' + if ($script:FakeUserScope) { ' --scope local, project, or user' } + } +} + +$script:FakeClaudeVersion = '2.1.179' +$script:FakeMcpLogin = $false +$script:FakeUserScope = $true +Assert-Equal 'detect mcp add' (Test-ClaudeMcpCommand 'add') $true +Assert-Equal 'detect mcp get' (Test-ClaudeMcpCommand 'get') $true +Assert-Equal 'detect missing mcp login' (Test-ClaudeMcpCommand 'login') $false +$script:FakeMcpLogin = $true +Assert-Equal 'detect available mcp login' (Test-ClaudeMcpCommand 'login') $true +Assert-Equal 'detect HTTP transport' (Test-ClaudeMcpHttp) $true +Assert-Equal 'detect user scope' (Test-ClaudeMcpUserScope) $true + +function Warn { param($Message) $script:Messages += "WARN $Message" } +function Ok { param($Message) $script:Messages += "OK $Message" } +function Die { param($Message) throw "DIE $Message" } +function Test-InteractiveConsole { return $script:Interactive } +function Confirm-Step { return $script:ConfirmResult } +function Install-ClaudeStable { $script:FakeClaudeVersion = '2.1.238' } + +$env:QBRAID_CODE_CLAUDE_POLICY = 'continue' +$script:Interactive = $false +$script:Messages = @() +Confirm-ClaudeCompatibility +Assert-Equal 'continue flow keeps old version' $script:FakeClaudeVersion '2.1.179' +Assert-Equal 'continue flow warns' ($script:Messages -join "`n" -match 'unsupported Claude Code') $true + +$env:QBRAID_CODE_CLAUDE_POLICY = 'fail' +$script:Messages = @() +$failedClosed = $false +try { Confirm-ClaudeCompatibility } catch { $failedClosed = $_.Exception.Message -match 'incompatible' } +Assert-Equal 'fail flow rejects old version' $failedClosed $true + +$env:QBRAID_CODE_CLAUDE_POLICY = 'upgrade' +$script:FakeClaudeVersion = '2.1.179' +$script:Messages = @() +Confirm-ClaudeCompatibility +Assert-Equal 'upgrade flow reaches stable version' $script:FakeClaudeVersion '2.1.238' + +$env:QBRAID_CODE_CLAUDE_POLICY = 'prompt' +$script:FakeClaudeVersion = '2.1.179' +$script:Interactive = $false +$failedClosed = $false +try { Confirm-ClaudeCompatibility } catch { $failedClosed = $_.Exception.Message -match 'incompatible' } +Assert-Equal 'noninteractive prompt fails closed' $failedClosed $true + +$script:Interactive = $true +$script:ConfirmResult = $false +$script:Messages = @() +Confirm-ClaudeCompatibility +Assert-Equal 'interactive prompt can decline upgrade' ` + ($script:Messages -join "`n" -match 'reduced compatibility') $true + +$env:QBRAID_CODE_CLAUDE_POLICY = 'upgrade' +$script:FakeClaudeVersion = '2.1.239' +$script:FakeUserScope = $false +$refusedDowngrade = $false +try { Confirm-ClaudeCompatibility } catch { $refusedDowngrade = $_.Exception.Message -match 'Refusing to downgrade' } +Assert-Equal 'upgrade never downgrades a newer CLI' $refusedDowngrade $true +Assert-Equal 'newer CLI version remains installed' $script:FakeClaudeVersion '2.1.239' + +$env:QBRAID_CODE_CLAUDE_POLICY = 'prompt' +$script:Interactive = $true +$script:Messages = @() +Confirm-ClaudeCompatibility +Assert-Equal 'prompt never downgrades a newer CLI' ` + ($script:Messages -join "`n" -match 'refusing to downgrade') $true + +$env:QBRAID_CODE_CLAUDE_POLICY = 'upgrade' +$script:FakeClaudeVersion = 'mystery-version' +$script:FakeUserScope = $true +$unknownRefused = $false +try { Confirm-ClaudeCompatibility } catch { $unknownRefused = $_.Exception.Message -match 'could downgrade' } +Assert-Equal 'upgrade never replaces an unknown version' $unknownRefused $true +Assert-Equal 'unknown version remains installed' $script:FakeClaudeVersion 'mystery-version' + +$env:QBRAID_CODE_CLAUDE_POLICY = 'prompt' +$script:Interactive = $true +$script:Messages = @() +Confirm-ClaudeCompatibility +Assert-Equal 'prompt never replaces an unknown version' ` + ($script:Messages -join "`n" -match 'could downgrade') $true + +Remove-Item Env:\QBRAID_CODE_CLAUDE_POLICY +Remove-Item Function:\claude + +Write-Host "`n$($script:Passed) passed, $($script:Failed) failed" +if ($script:Failed -ne 0) { exit 1 } diff --git a/tests/claude-compat.sh b/tests/claude-compat.sh new file mode 100755 index 0000000..6597c42 --- /dev/null +++ b/tests/claude-compat.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# Exercise the installer compatibility policy through its real helper +# definitions. The fake claude executable keeps the tests deterministic and +# avoids changing whichever Claude Code version is installed on the runner. +set -uo pipefail +cd "$(dirname "$0")/.." || exit 1 + +extract_fn() { # extract_fn + awk -v fn="$1" ' + $0 ~ "^"fn"\\(\\) \\{" { inside = 1 } + inside { print } + inside && /^\}$/ { exit } + ' install.sh +} + +FUNCTIONS="" +for fn in parse_claude_version compare_versions claude_upgrade_is_safe claude_version_status \ + claude_policy_action claude_supports_mcp_command \ + claude_supports_mcp_http claude_supports_mcp_user_scope; do + src=$(extract_fn "$fn") + if [ -z "$src" ]; then + printf ' FAIL could not extract %s from install.sh\n' "$fn" + exit 1 + fi + FUNCTIONS="$FUNCTIONS +$src" +done + +pass=0; fail=0 +check_eq() { # check_eq + local name="$1" want="$2" got + shift 2 + got=$(bash -c "set -euo pipefail; CLAUDE_MIN_VERSION=2.1.186; CLAUDE_TESTED_MAX=2.1.238; $FUNCTIONS; $*" 2>/dev/null) || got="" + if [ "$got" = "$want" ]; then + pass=$((pass + 1)); printf ' ok %s\n' "$name" + else + fail=$((fail + 1)); printf ' FAIL %s: got [%s] want [%s]\n' "$name" "$got" "$want" + fi +} + +check_eq "parse native version output" 2.1.179 \ + "parse_claude_version '2.1.179 (Claude Code)'" +check_eq "parse version with surrounding text" 2.1.238 \ + "parse_claude_version 'Claude Code version 2.1.238'" +check_eq "missing version parses empty" "" \ + "parse_claude_version 'present but unparseable'" + +check_eq "older version compares below" -1 "compare_versions 2.1.185 2.1.186" +check_eq "equal version compares equal" 0 "compare_versions 2.1.186 2.1.186" +check_eq "newer patch compares above" 1 "compare_versions 2.1.238 2.1.186" +check_eq "newer minor compares above" 1 "compare_versions 2.2.0 2.1.999" +check_eq "stable target allows an upgrade" safe \ + "claude_upgrade_is_safe 2.1.185 2.1.228 && printf safe || printf downgrade" +check_eq "stable target refuses a tested-range downgrade" downgrade \ + "claude_upgrade_is_safe 2.1.238 2.1.228 && printf safe || printf downgrade" + +check_eq "unknown version status" unknown "claude_version_status ''" +check_eq "below minimum status" below-minimum "claude_version_status 2.1.185" +check_eq "minimum is tested" tested "claude_version_status 2.1.186" +check_eq "tested maximum is tested" tested "claude_version_status 2.1.238" +check_eq "newer version is informational" newer-than-tested "claude_version_status 2.1.239" + +check_eq "upgrade policy upgrades" upgrade "claude_policy_action upgrade no" +check_eq "fail policy fails" fail "claude_policy_action fail yes" +check_eq "continue policy continues" continue "claude_policy_action continue no" +check_eq "interactive prompt prompts" prompt "claude_policy_action prompt yes" +check_eq "noninteractive prompt fails" fail "claude_policy_action prompt no" + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +cat > "$tmp/claude" <<'EOF' +#!/usr/bin/env bash +case "${1:-} ${2:-}" in + "mcp --help") + printf '%s\n' 'Commands:' ' add [options]' ' get ' + [ "${FAKE_MCP_LOGIN:-no}" = yes ] && printf '%s\n' ' login ' + ;; + "mcp add") + [ "${3:-}" = --help ] && printf '%s\n' \ + ' --transport stdio, sse, or http' \ + ' --scope local, project, or user' + ;; +esac +EOF +chmod +x "$tmp/claude" + +check_cap() { # check_cap + local name="$1" login="$2" want="$3" expr="$4" got + got=$(PATH="$tmp:$PATH" FAKE_MCP_LOGIN="$login" bash -c \ + "set -euo pipefail; $FUNCTIONS; if $expr; then printf yes; else printf no; fi") + if [ "$got" = "$want" ]; then + pass=$((pass + 1)); printf ' ok %s\n' "$name" + else + fail=$((fail + 1)); printf ' FAIL %s: got [%s] want [%s]\n' "$name" "$got" "$want" + fi +} + +check_cap "detect mcp add" no yes "claude_supports_mcp_command add" +check_cap "detect mcp get" no yes "claude_supports_mcp_command get" +check_cap "detect missing mcp login" no no "claude_supports_mcp_command login" +check_cap "detect available mcp login" yes yes "claude_supports_mcp_command login" +check_cap "detect HTTP transport" no yes "claude_supports_mcp_http" +check_cap "detect user scope" no yes "claude_supports_mcp_user_scope" + +printf '\n%d passed, %d failed\n' "$pass" "$fail" +[ "$fail" -eq 0 ] diff --git a/tests/claude-installed-compat.sh b/tests/claude-installed-compat.sh new file mode 100755 index 0000000..e2fa5aa --- /dev/null +++ b/tests/claude-installed-compat.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Smoke-check a real Claude Code release installed by the CI version matrix. +set -euo pipefail + +if ! command -v claude >/dev/null 2>&1; then + printf 'Claude Code is not installed; real-release compatibility check skipped +' + exit 0 +fi + +version=$(claude --version) +printf 'claude: %s\n' "$version" + +cli_help=$(claude --help) +for option in --settings --setting-sources --strict-mcp-config; do + printf '%s +' "$cli_help" | grep -Fq -- "$option" || { + printf 'missing required isolation option: %s +' "$option" >&2 + exit 1 + } +done + +help=$(claude mcp --help) +for command in add get; do + printf '%s\n' "$help" | grep -Eq "^[[:space:]]+$command([[:space:]]|$)" || { + printf 'missing required mcp command: %s\n' "$command" >&2 + exit 1 + } +done + +if printf '%s\n' "$help" | grep -Eq '^[[:space:]]+login([[:space:]]|$)'; then + printf 'mcp login: available\n' +else + printf 'mcp login: unavailable; interactive /mcp fallback required\n' +fi + +add_help=$(claude mcp add --help) +printf '%s\n' "$add_help" | grep -Eq -- '--transport.*http' || { + printf 'mcp add does not advertise HTTP transport\n' >&2 + exit 1 +} +printf '%s\n' "$add_help" | grep -Eq -- '--scope.*user' || { + printf 'mcp add does not advertise user scope\n' >&2 + exit 1 +} + +printf 'Claude Code compatibility checks passed\n' diff --git a/tests/claude-policy-flow.sh b/tests/claude-policy-flow.sh new file mode 100755 index 0000000..9de486b --- /dev/null +++ b/tests/claude-policy-flow.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Drive the real top-level compatibility decision against a fake Claude binary. +set -uo pipefail +cd "$(dirname "$0")/.." || exit 1 + +extract_fn() { # extract_fn + awk -v fn="$1" ' + $0 ~ "^"fn"\\(\\) \\{" { inside = 1 } + inside { print } + inside && /^\}$/ { exit } + ' install.sh +} + +FUNCTIONS="" +for fn in parse_claude_version compare_versions claude_version_status \ + claude_policy_action claude_supports_mcp_command \ + claude_supports_mcp_http claude_supports_mcp_user_scope \ + refresh_claude_state claude_required_capabilities_present \ + ensure_claude_compatible; do + src=$(extract_fn "$fn") + [ -n "$src" ] || { printf ' FAIL could not extract %s\n' "$fn"; exit 1; } + FUNCTIONS="$FUNCTIONS +$src" +done + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +state="$tmp/version" +printf '2.1.179' > "$state" +cat > "$tmp/claude" <<'EOF' +#!/usr/bin/env bash +version=$(cat "$FAKE_CLAUDE_STATE") +case "${1:-} ${2:-}" in + "--version ") printf '%s (Claude Code)\n' "$version" ;; + "mcp --help") + printf '%s\n' 'Commands:' ' add [options]' ' get ' + [ "$version" != 2.1.179 ] && printf '%s\n' ' login ' + ;; + "mcp add") + if [ "${3:-}" = --help ]; then + printf '%s\n' ' --transport stdio, sse, or http' + [ "${FAKE_USER_SCOPE:-yes}" = yes ] \ + && printf '%s\n' ' --scope local, project, or user' + fi + ;; +esac +EOF +chmod +x "$tmp/claude" + +HARNESS=" +CLAUDE_MIN_VERSION=2.1.186 +CLAUDE_TESTED_MAX=2.1.238 +warn() { printf 'WARN %s\\n' \"\$*\"; } +ok() { printf 'OK %s\\n' \"\$*\"; } +die() { printf 'DIE %s\\n' \"\$*\"; exit 1; } +confirm() { [ \"\${CONFIRM_RESULT:-no}\" = yes ]; } +install_claude_stable() { printf '2.1.238' > \"\$FAKE_CLAUDE_STATE\"; } +$FUNCTIONS +ensure_claude_compatible +" + +pass=0; fail=0 +run_case() { # run_case [scope] [version] + local name="$1" policy="$2" tty="$3" want_rc="$4" want_text="$5" + local user_scope="${6:-yes}" initial_version="${7:-2.1.179}" output rc=0 + printf '%s' "$initial_version" > "$state" + if [ "$tty" = yes ]; then tty_value=/dev/tty; else tty_value=""; fi + output=$(PATH="$tmp:$PATH" FAKE_CLAUDE_STATE="$state" FAKE_USER_SCOPE="$user_scope" \ + QBRAID_CODE_CLAUDE_POLICY="$policy" TTY="$tty_value" \ + bash -c "set -euo pipefail; $HARNESS" 2>&1) || rc=$? + if [ "$rc" = "$want_rc" ] && printf '%s\n' "$output" | grep -Fq "$want_text"; then + pass=$((pass + 1)); printf ' ok %s\n' "$name" + else + fail=$((fail + 1)); printf ' FAIL %s: rc=%s output=[%s]\n' "$name" "$rc" "$output" + fi +} + +run_case "continue keeps old version" continue no 0 \ + "continuing with an unsupported Claude Code" +run_case "fail rejects old version" fail no 1 \ + "the installed Claude Code is incompatible" +run_case "upgrade reaches stable version" upgrade no 0 "OK Claude Code 2.1.238" +run_case "noninteractive prompt fails closed" prompt no 1 \ + "the installed Claude Code is incompatible" +run_case "continue degrades missing capability" continue no 0 \ + "required HTTP MCP commands are unavailable" no +run_case "upgrade never downgrades a newer CLI" upgrade no 1 \ + "Refusing to downgrade it" no 2.1.239 +run_case "prompt never downgrades a newer CLI" prompt yes 0 \ + "refusing to downgrade it and continuing" no 2.1.239 +run_case "upgrade never replaces an unknown version" upgrade no 1 \ + "could downgrade it" yes mystery-version +run_case "prompt never replaces an unknown version" prompt yes 0 \ + "could downgrade it" yes mystery-version + +printf '2.1.179' > "$state" +rc=0 +prompt_output=$(PATH="$tmp:$PATH" FAKE_CLAUDE_STATE="$state" \ + QBRAID_CODE_CLAUDE_POLICY=prompt TTY=/dev/tty CONFIRM_RESULT=no \ + bash -c "set -euo pipefail; $HARNESS" 2>&1) || rc=$? +if [ "$rc" = 0 ] && printf '%s\n' "$prompt_output" | grep -Fq \ + "continuing with reduced compatibility at your request"; then + pass=$((pass + 1)); printf ' ok interactive prompt can decline upgrade\n' +else + fail=$((fail + 1)); printf ' FAIL interactive prompt decline: rc=%s output=[%s]\n' "$rc" "$prompt_output" +fi + +printf '\n%d passed, %d failed\n' "$pass" "$fail" +[ "$fail" -eq 0 ] diff --git a/tests/doctor.sh b/tests/doctor.sh new file mode 100755 index 0000000..2f35202 --- /dev/null +++ b/tests/doctor.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# The doctor must explain unsupported versions and use the interactive MCP +# fallback without contacting qBraid or relying on the host's Claude install. +set -euo pipefail +cd "$(dirname "$0")/.." || exit 1 + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +mkdir -p "$tmp/home/.qbraid-code" "$tmp/bin" +cat > "$tmp/home/.qbraid-code/env" <<'EOF' +QBRAID_CODE_BASE_URL= +QBRAID_CODE_API_BASE= +QBRAID_CODE_TOKEN= +QBRAID_CODE_MODEL=claude-sonnet-4-6 +QBRAID_CODE_PROXY_BIN= +EOF + +cat > "$tmp/bin/claude" <<'EOF' +#!/usr/bin/env bash +case "${1:-} ${2:-}" in + "--version ") printf '%s\n' "${FAKE_CLAUDE_VERSION:-2.1.179} (Claude Code)" ;; + "mcp --help") + printf '%s\n' 'Commands:' ' add [options]' ' get ' + [ "${FAKE_MCP_LOGIN:-no}" = yes ] && printf '%s\n' ' login ' + ;; + "mcp add") + [ "${3:-}" = --help ] && printf '%s\n' \ + ' --transport stdio, sse, or http' \ + ' --scope local, project, or user' + ;; + "mcp get") [ "${3:-}" = qbraid ] && [ "${FAKE_MCP_REGISTERED:-yes}" = yes ] ;; +esac +EOF +chmod +x "$tmp/bin/claude" + +pass=0; fail=0 +check_contains() { # check_contains + local name="$1" output="$2" text="$3" + if printf '%s\n' "$output" | grep -Fq "$text"; then + pass=$((pass + 1)); printf ' ok %s\n' "$name" + else + fail=$((fail + 1)); printf ' FAIL %s: missing [%s]\n' "$name" "$text" + fi +} +check_not_contains() { # check_not_contains + local name="$1" output="$2" text="$3" + if ! printf '%s\n' "$output" | grep -Fq "$text"; then + pass=$((pass + 1)); printf ' ok %s\n' "$name" + else + fail=$((fail + 1)); printf ' FAIL %s: found [%s]\n' "$name" "$text" + fi +} + +old=$(HOME="$tmp/home" PATH="$tmp/bin:$PATH" ./qbraid-code --doctor) +check_contains "doctor rejects old version" "$old" \ + "claude-min: FAIL (requires 2.1.186+, found 2.1.179)" +check_contains "doctor reports missing login capability" "$old" "mcp-login=no" +check_contains "doctor gives interactive MCP fallback" "$old" \ + "registered (run /mcp inside Claude Code to authenticate)" + +unregistered=$(HOME="$tmp/home" PATH="$tmp/bin:$PATH" \ + FAKE_MCP_REGISTERED=no ./qbraid-code --doctor) +check_contains "doctor gives fallback when MCP is unregistered" "$unregistered" \ + "NOT REGISTERED — configure and authenticate through /mcp" + +new=$(HOME="$tmp/home" PATH="$tmp/bin:$PATH" \ + FAKE_CLAUDE_VERSION=2.1.239 FAKE_MCP_LOGIN=yes ./qbraid-code --doctor) +check_contains "doctor permits newer version" "$new" \ + "claude-min: PASS (requires 2.1.186+)" +check_contains "doctor labels newer version without blocking" "$new" \ + "claude-tested: NEWER than tested 2.1.238 (not blocked)" +check_contains "doctor reports login capability" "$new" "mcp-login=yes" + +mkdir -p "$tmp/home/.qbraid-code/profiles/research/generations/g1" +printf 'research\n' > "$tmp/home/.qbraid-code/active-profile" +printf 'g1\n' > "$tmp/home/.qbraid-code/profiles/research/current" +printf 'key-research\n' > "$tmp/profile-secret" +chmod 600 "$tmp/profile-secret" +printf '#!/usr/bin/env bash\nexit 0\n' > "$tmp/bin/cliproxyapi" +cat > "$tmp/bin/curl" <<'EOF' +#!/usr/bin/env bash +out='' +while [ "$#" -gt 0 ]; do + case "$1" in -o) out="$2"; shift 2 ;; -w) shift 2 ;; *) shift ;; esac +done +[ -z "$out" ] || : > "$out" +printf 000 +exit 1 +EOF +chmod +x "$tmp/bin/cliproxyapi" "$tmp/bin/curl" +cat > "$tmp/home/.qbraid-code/profiles/research/generations/g1/env" < "$HOME_ROOT/.claude/settings.json" <<'EOF' EOF cat > "$FAKE_BIN/claude" <<'EOF' #!/usr/bin/env bash -case "${1:-}" in --version) echo 2.1.238 ;; mcp) exit 0 ;; *) exit 0 ;; esac +case "${1:-}" in + --version) echo 2.1.238 ;; + mcp) + if [ "${2:-}" = --help ]; then + printf ' add + get + login +' + elif [ "${2:-}" = add ] && [ "${3:-}" = --help ]; then + printf '%s +' '--transport [http]' '--scope [user]' + fi + ;; +esac +exit 0 EOF cat > "$FAKE_BIN/cliproxyapi" <<'EOF' #!/usr/bin/env bash diff --git a/tests/windows-profiles.ps1 b/tests/windows-profiles.ps1 index 09eb355..42103b3 100644 --- a/tests/windows-profiles.ps1 +++ b/tests/windows-profiles.ps1 @@ -104,6 +104,31 @@ echo args=%* $status = ('{"model":{"display_name":"Haiku"},"workspace":{"current_dir":"C:\\tmp"}}' | & powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $root 'statusline.ps1')) -join "`n" if ($status -notmatch 'qBraid' -or $status -notmatch 'Beta Lab' -or $status -notmatch '19') { throw "status binding failed: $status" } + function global:claude { + $joined = $args -join ' ' + $global:LASTEXITCODE = 0 + if ($joined -eq '--version') { '2.1.238 (Claude Code)' } + elseif ($joined -eq 'mcp --help') { ' add [options]'; ' get '; ' login ' } + elseif ($joined -eq 'mcp add --help') { ' --transport http'; ' --scope user' } + } + function global:Invoke-RestMethod { + param($Uri, $Headers, $TimeoutSec) + if ($Uri -like '*/billing/credits/balance') { + if ($Headers['X-API-Key'] -ne 'token-beta') { throw 'doctor did not resolve the selected Credential Locker secret' } + return [pscustomobject]@{ data = [pscustomobject]@{ qbraidCredits = 19 } } + } + return [pscustomobject]@{} + } + $env:QBRAID_CODE_PROFILE_HOME = $beta + $doctor = (& (Join-Path $root 'doctor.ps1') 6>&1) -join "`n" + if ($doctor -notmatch 'claude:\s+2\.1\.238' -or + $doctor -notmatch 'model:\s+claude-haiku-4-5' -or + $doctor -notmatch 'proxy:\s+installed \(starts once per launch\)') { + throw "selected-profile doctor integration failed: $doctor" + } + Remove-Item Function:\claude + Remove-Item Function:\Invoke-RestMethod + $sources = Get-Content (Join-Path $root 'qbraid-code.cmd'), (Join-Path $root 'install.ps1') -Raw if ($sources -match 'MAX_THINKING_TOKENS=0') { throw 'thinking workaround remains' } Write-Host 'windows profile tests passed'