From 4ba580c15f5ab9360d9f0d08b8f5515f6edf9348 Mon Sep 17 00:00:00 2001 From: BeLazy167 Date: Thu, 20 Aug 2026 13:20:07 -0500 Subject: [PATCH 1/5] Add one-line installer for Claude Code on the qBraid gateway Installs Claude Code if absent, wires it to the qBraid AI gateway, registers the qBraid MCP, and adds a credit statusline. macOS, Linux and Windows. No proxy: the gateway serves an Anthropic-compatible surface at /api/v1/ai/v1/messages, so the launcher just sets ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN and execs claude. --- .gitignore | 2 + README.md | 145 ++++++++++++++- doctor.ps1 | 59 ++++++ install.ps1 | 389 +++++++++++++++++++++++++++++++++++++++ install.sh | 476 ++++++++++++++++++++++++++++++++++++++++++++++++ qbraid-code | 86 +++++++++ qbraid-code.cmd | 62 +++++++ statusline.ps1 | 127 +++++++++++++ statusline.sh | 107 +++++++++++ 9 files changed, 1452 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 doctor.ps1 create mode 100644 install.ps1 create mode 100755 install.sh create mode 100755 qbraid-code create mode 100644 qbraid-code.cmd create mode 100644 statusline.ps1 create mode 100755 statusline.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f3c3de1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +credits.cache +*.tmp.* diff --git a/README.md b/README.md index 48fc00a..81ff2d5 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,145 @@ # qbraid-code -Claude Code, powered by the qBraid AI gateway — one-line installer for macOS, Linux and Windows + +Claude Code, powered by the **qBraid AI gateway**. One command installs everything +and leaves you in a working session: Claude Code itself, your qBraid credentials, +a live credit statusline, and the qBraid MCP tools. + +Your own `claude` command stays untouched unless you ask for `--global`. + +## Install + +**macOS and Linux** + +```bash +curl -fsSL https://qbraid.com/code.sh | bash +``` + +**Windows** (PowerShell) + +```powershell +irm https://qbraid.com/code.ps1 | iex +``` + +That is all. The installer: + +- installs **Claude Code** if you do not have it — a native binary, no Node.js and no administrator rights +- finds your **qBraid API key**, or opens the page where you can copy one +- shows you which **organization** the key belongs to and asks you to confirm it +- lets you **choose a default model** from the list the gateway is actually serving +- registers the **qBraid MCP** and signs you in +- installs a **statusline** showing your remaining qBraid credits +- runs a **real request** to prove it works before it says "ready" + +Re-running it is safe. + +> **While this repository is private**, `qbraid.com/code.sh` is not live yet. Use the +> GitHub CLI instead (`gh auth login` first, and you must be in the `qBraid` org): +> +> ```bash +> gh api -H "Accept: application/vnd.github.raw" \ +> /repos/qBraid/qbraid-code/contents/install.sh | bash +> ``` + +## Use + +```bash +qbraid-code # start a session +qbraid-code -p "explain this" # ask one question and exit +qbraid-code --doctor # check your setup +``` + +Every other flag goes straight through to `claude`, so `-c`, `--model`, +`--allowedTools` and the rest behave normally. + +A session looks like this: + +``` +qbraid-code ⎇ main │ Claude Opus 5 │ C13 █░░░░░ │ 4,281 credits +``` + +Folder and branch, the model you are talking to, how much of the context window +is used, and what you have left to spend. + +### Take over the plain `claude` command + +If qBraid is the only backend you use, install with `--global` and plain `claude` +will use it too: + +```bash +curl -fsSL https://qbraid.com/code.sh | bash -s -- --global +``` + +```powershell +& ([scriptblock]::Create((irm https://qbraid.com/code.ps1))) -Global +``` + +## How it works + +Claude Code speaks the Anthropic Messages API. So does the qBraid gateway — it +exposes an Anthropic-compatible surface at `/api/v1/ai/v1/messages` (the double +`v1` is deliberate, so `ANTHROPIC_BASE_URL` works with no client changes). + +``` +qbraid-code + │ ANTHROPIC_BASE_URL=https://api-v2.qbraid.com/api/v1/ai + │ ANTHROPIC_AUTH_TOKEN=qbr_... + ▼ +Claude Code ──Anthropic Messages──► qBraid AI gateway ──► Claude on Bedrock +``` + +**There is no proxy and no daemon.** Nothing runs in the background, nothing +listens on a port. The launcher sets four environment variables and execs +`claude`. + +Requests are billed against your qBraid credits at the usual rate +(100 credits = $1). + +## The qBraid MCP + +The MCP endpoint at `mcp.qbraid.com/mcp` uses OAuth, not API keys, so it needs a +browser sign-in that your API key cannot do for you. The installer runs it during +setup, while you are still there. If you skipped it: + +```bash +claude mcp login qbraid +``` + +## Layout + +| Path | What | +|---|---| +| `~/.qbraid-code/env` | your key, gateway URL and default model (mode `600`) | +| `~/.qbraid-code/statusline.sh` | statusline script (`statusline.ps1` on Windows) | +| `~/.qbraid-code/credits.cache` | last known credit balance, refreshed every 60s | +| `~/.local/bin/qbraid-code` | the launcher (`qbraid-code.cmd` on Windows) | +| `~/.claude/settings.json` | statusline wiring, plus gateway env with `--global` | + +## Troubleshooting + +Start with `qbraid-code --doctor`. It reports whether Claude Code is installed, +whether your key still works, your credit balance, whether the gateway is +reachable, and whether the MCP is registered. + +**`key: REJECTED`** — your API key was deleted or expired. Make a new one at +[account.qbraid.com/account/api-keys](https://account.qbraid.com/account/api-keys) +and re-run the installer. + +**`qbraid-code: command not found`** — `~/.local/bin` is not on your `PATH`. The +installer prints the line to add. On Windows, open a new terminal first. + +**Wrong organization** — credits come from the organization the key belongs to. +Create a key under the organization you want, then re-run the installer. + +**Statusline did not appear** — on macOS without the Xcode Command Line Tools and +with a `~/.claude/settings.json` that already exists, the installer cannot merge +JSON safely and skips this step. It prints the snippet to add by hand. + +## Uninstall + +```bash +rm -rf ~/.qbraid-code ~/.local/bin/qbraid-code +claude mcp remove qbraid +``` + +Then remove the `statusLine` entry (and the `env` block, if you used `--global`) +from `~/.claude/settings.json`. diff --git a/doctor.ps1 b/doctor.ps1 new file mode 100644 index 0000000..2cf07db --- /dev/null +++ b/doctor.ps1 @@ -0,0 +1,59 @@ +<# +.SYNOPSIS + Health check for qbraid-code on Windows. Invoked by `qbraid-code --doctor`. +#> +# No Set-StrictMode: this command exists to report a broken setup, so a missing +# field must print a diagnosis rather than a PowerShell exception. +$ErrorActionPreference = 'Continue' + +$HomeDir = if ($env:QBRAID_CODE_HOME) { $env:QBRAID_CODE_HOME } else { Join-Path $env:USERPROFILE '.qbraid-code' } +$envPath = Join-Path $HomeDir 'env' + +if (-not (Test-Path $envPath)) { + Write-Host "qbraid-code: not installed - no $envPath" + exit 1 +} + +$settings = @{} +foreach ($line in Get-Content $envPath) { + if ($line -match '^\s*([A-Z_]+)\s*=\s*(.*)$') { $settings[$Matches[1]] = $Matches[2] } +} + +$apiBase = $settings['QBRAID_CODE_API_BASE'] +$baseUrl = $settings['QBRAID_CODE_BASE_URL'] +$token = $settings['QBRAID_CODE_TOKEN'] +$model = $settings['QBRAID_CODE_MODEL'] + +if (Get-Command claude -ErrorAction SilentlyContinue) { + $version = (& claude --version 2>$null) + if (-not $version) { $version = 'present' } + Write-Host "claude: $version" +} else { + Write-Host 'claude: NOT INSTALLED' +} + +try { + $balance = Invoke-RestMethod -Uri "$apiBase/billing/credits/balance" ` + -Headers @{ 'X-API-Key' = $token } -TimeoutSec 20 + Write-Host 'key: valid' + Write-Host "credits: $($balance.data.qbraidCredits)" +} catch { + Write-Host 'key: REJECTED - make a new one at https://account.qbraid.com/account/api-keys' + Write-Host 'credits: unknown' +} + +try { + Invoke-RestMethod -Uri "$baseUrl/v1/models" -Headers @{ 'X-API-Key' = $token } -TimeoutSec 20 | Out-Null + Write-Host 'gateway: reachable' +} catch { + 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)" +} else { + Write-Host 'mcp: NOT REGISTERED' +} + +Write-Host "model: $model" diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..70f7829 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,389 @@ +<# +.SYNOPSIS + qbraid-code installer for Windows — Claude Code, powered by the qBraid AI gateway. + +.DESCRIPTION + There is no proxy and no daemon. The qBraid gateway speaks the Anthropic + Messages API natively at /api/v1/ai/v1/messages, so Claude Code talks to it + directly through ANTHROPIC_BASE_URL. + + Everything this writes lives in %USERPROFILE%\.qbraid-code and + %USERPROFILE%\.local\bin. Re-running is safe. + +.EXAMPLE + irm https://qbraid.com/code.ps1 | iex + +.EXAMPLE + & ([scriptblock]::Create((irm https://qbraid.com/code.ps1))) -Global +#> +param( + [switch]$Global +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' + +$GatewayHost = 'api-v2.qbraid.com' +$ApiBase = "https://$GatewayHost/api/v1" +$GatewayUrl = "$ApiBase/ai" +$McpName = 'qbraid' +$McpUrl = 'https://mcp.qbraid.com/mcp' +$KeysUrl = 'https://account.qbraid.com/account/api-keys' +$RawBase = 'https://raw.githubusercontent.com/qBraid/qbraid-code/main' +$GhContents = '/repos/qBraid/qbraid-code/contents' + +$HomeDir = if ($env:QBRAID_CODE_HOME) { $env:QBRAID_CODE_HOME } else { Join-Path $env:USERPROFILE '.qbraid-code' } +$BinDir = if ($env:QBRAID_CODE_BIN_DIR) { $env:QBRAID_CODE_BIN_DIR } else { Join-Path $env:USERPROFILE '.local\bin' } +$ClaudeDir = Join-Path $env:USERPROFILE '.claude' +$Settings = Join-Path $ClaudeDir 'settings.json' +$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 Die { param($m) Write-Host "`nerror: $m" -ForegroundColor Red; exit 1 } + +# StrictMode prohibits references to non-existent properties, so every read of +# an API response goes through this instead of a direct dereference. +function Get-Prop { + param($Object, [string]$Name) + if ($null -eq $Object) { return $null } + $prop = $Object.PSObject.Properties[$Name] + if (-not $prop) { return $null } + return $prop.Value +} + +function Confirm-Step { + param([string]$Question, [string]$Default = 'y') + $hint = if ($Default -eq 'y') { '[Y/n]' } else { '[y/N]' } + $reply = (Read-Host "$Question $hint").Trim().ToLower() + if ([string]::IsNullOrEmpty($reply)) { $reply = $Default } + return ($reply -eq 'y' -or $reply -eq 'yes') +} + +# ---------------------------------------------------------------- 1. platform + +if (-not [Environment]::Is64BitOperatingSystem) { + Die '32-bit Windows is not supported.' +} +Say "Platform: windows/$($env:PROCESSOR_ARCHITECTURE.ToLower())" + +New-Item -ItemType Directory -Force -Path $HomeDir, $BinDir, $ClaudeDir | Out-Null + +# ------------------------------------------------------------ 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: $_" + } + $env:Path = "$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' +} + +# ------------------------------------------------------------- 3. credential + +function Get-Balance { + param([string]$Key) + try { + return Invoke-RestMethod -Uri "$ApiBase/billing/credits/balance" ` + -Headers @{ 'X-API-Key' = $Key } -TimeoutSec 25 + } catch { + return $null + } +} + +function Read-QbraidrcKey { + $rc = Join-Path $env:USERPROFILE '.qbraid\qbraidrc' + if (-not (Test-Path $rc)) { return $null } + foreach ($line in Get-Content $rc) { + if ($line -match '^\s*api-key\s*=\s*(.+?)\s*$') { return $Matches[1] } + } + return $null +} + +Say 'qBraid account' +$ApiKey = $env:QBRAID_API_KEY +$Balance = $null +$KeySource = 'QBRAID_API_KEY' + +if (-not $ApiKey) { + $candidate = Read-QbraidrcKey + if ($candidate) { + $Balance = Get-Balance $candidate + if ($Balance) { + $ApiKey = $candidate + $KeySource = '~\.qbraid\qbraidrc' + } else { + Warn 'the key in ~\.qbraid\qbraidrc is no longer valid — ignoring it' + } + } +} + +if ($ApiKey -and -not $Balance) { + $Balance = Get-Balance $ApiKey + if (-not $Balance) { Die "the API key from $KeySource was rejected by qBraid." } +} + +if (-not $ApiKey) { + Write-Host '' + Write-Host ' You need a qBraid API key. Opening the page where you can copy one:' + Write-Host " $KeysUrl" -ForegroundColor White + Write-Host '' + Write-Host ' Sign in, create a key if you do not have one, then copy it.' + Write-Host '' + Start-Process $KeysUrl -ErrorAction SilentlyContinue + + for ($attempt = 1; $attempt -le 5; $attempt++) { + $candidate = (Read-Host 'Paste your qBraid API key').Trim() + if ($candidate) { + $Balance = Get-Balance $candidate + if ($Balance) { $ApiKey = $candidate; $KeySource = 'pasted'; break } + } + Warn 'qBraid did not accept that key. Check you copied all of it, then try again.' + } + if (-not $ApiKey) { Die 'too many invalid keys. Re-run once you have a working key.' } +} +Ok "key accepted (from $KeySource)" + +# ------------------------------------------------------- 4. organization check + +# StrictMode is on: probe rather than dereference, so a response missing these +# fields degrades instead of aborting the install. +$balanceData = Get-Prop $Balance 'data' +$OrgId = Get-Prop $balanceData 'organizationId' +$Credits = Get-Prop $balanceData 'qbraidCredits' +if ($null -eq $Credits) { $Credits = 'unknown' } +# /organizations/current returns the organization document itself, so `name` is +# the organization's. /organizations/me returns *membership* details, whose +# name is the user's — labelling the confirmation with that would be worse than +# showing nothing. The id is printed alongside so a bad lookup cannot quietly +# point someone at the wrong organization. +$OrgName = $null +if ($OrgId) { + try { + $org = Invoke-RestMethod -Uri "$ApiBase/organizations/current" -TimeoutSec 20 ` + -Headers @{ 'X-API-Key' = $ApiKey; 'X-Organization-Id' = $OrgId } + $OrgName = Get-Prop (Get-Prop $org 'data') 'name' + } catch { } +} + +Write-Host '' +if ($OrgName) { + Write-Host " Organization: $OrgName ($OrgId)" -ForegroundColor White +} else { + Write-Host " Organization: $OrgId" -ForegroundColor White +} +Write-Host " Credits: $Credits" -ForegroundColor White +Write-Host '' + +if (-not (Confirm-Step 'Is this the right organization?' 'y')) { + Write-Host '' + Write-Host ' Switch organization at https://account.qbraid.com, or create a key' + Write-Host ' under the organization you want, then run this installer again.' + Write-Host '' + exit 1 +} +Ok 'organization confirmed' + +# ------------------------------------------------------------- 5. model choice + +Say 'Model' +$Model = $env:QBRAID_CODE_MODEL +if (-not $Model) { + $ids = @() + try { + # Fetched live so new gateway models appear without a release here. + $models = Invoke-RestMethod -Uri "$GatewayUrl/v1/models" ` + -Headers @{ 'X-API-Key' = $ApiKey } -TimeoutSec 25 + $ids = @(Get-Prop $models 'data' | ForEach-Object { Get-Prop $_ 'id' }) | Where-Object { $_ } + } catch { } + + if ($ids.Count -eq 0) { + Warn 'could not list models — defaulting to claude-sonnet-4-6' + $Model = 'claude-sonnet-4-6' + } else { + Write-Host '' + Write-Host ' Available models:' + Write-Host '' + for ($i = 0; $i -lt $ids.Count; $i++) { + Write-Host (' {0,2}) {1}' -f ($i + 1), $ids[$i]) + } + Write-Host '' + $choice = (Read-Host 'Choose a default model [1]').Trim() + if (-not $choice) { $choice = '1' } + $index = 0 + if ([int]::TryParse($choice, [ref]$index) -and $index -ge 1 -and $index -le $ids.Count) { + $Model = $ids[$index - 1] + } else { + $Model = $ids[0] + } + } +} +Ok "default model: $Model" + +# ---------------------------------------------------------------- 6. env file + +$envLines = @( + "QBRAID_CODE_BASE_URL=$GatewayUrl", + "QBRAID_CODE_API_BASE=$ApiBase", + "QBRAID_CODE_TOKEN=$ApiKey", + "QBRAID_CODE_MODEL=$Model" +) +$envPath = Join-Path $HomeDir 'env' +Set-Content -Path $envPath -Value $envLines -Encoding ASCII +Ok "config written to $envPath" + +# ------------------------------------------------- 7. launcher and statusline + +# When piped through `iex` there is no local checkout, so companion files are +# fetched over HTTP. `gh` covers the window where the repository is still +# private and raw.githubusercontent.com 404s. +$SrcDir = $null +if ($PSScriptRoot -and (Test-Path (Join-Path $PSScriptRoot 'qbraid-code.cmd'))) { + $SrcDir = $PSScriptRoot +} + +function Fetch-File { + param([string]$Name, [string]$Dest) + if ($SrcDir) { Copy-Item (Join-Path $SrcDir $Name) $Dest -Force; return } + try { + Invoke-WebRequest -Uri "$RawBase/$Name" -OutFile $Dest -TimeoutSec 30 -UseBasicParsing + return + } catch { } + if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { + Die "could not download $Name. While this repository is private you need the GitHub CLI: https://cli.github.com" + } + gh api -H 'Accept: application/vnd.github.raw' "$GhContents/$Name" | Set-Content -Path $Dest -Encoding UTF8 + if ($LASTEXITCODE -ne 0) { Die "could not download $Name — is ``gh auth login`` done, and are you in the qBraid org?" } +} + +$LauncherPath = Join-Path $BinDir 'qbraid-code.cmd' +$StatuslinePath = Join-Path $HomeDir 'statusline.ps1' +$DoctorPath = Join-Path $HomeDir 'doctor.ps1' +Fetch-File 'qbraid-code.cmd' $LauncherPath +Ok "launcher installed to $LauncherPath" +Fetch-File 'statusline.ps1' $StatuslinePath +Ok "statusline installed to $StatuslinePath" +# `qbraid-code --doctor` shells out to this; the .cmd cannot parse JSON itself. +Fetch-File 'doctor.ps1' $DoctorPath + +# Put the launcher on PATH for future terminals. +$userPath = [Environment]::GetEnvironmentVariable('Path', 'User') +if ($userPath -notlike "*$BinDir*") { + [Environment]::SetEnvironmentVariable('Path', "$BinDir;$userPath", 'User') + Ok "added $BinDir to your PATH (new terminals only)" +} + +# --------------------------------------------------------- 8. first-run flags + +Say 'Claude Code first run' +if (Confirm-Step "Skip Claude Code's introductory screens?" 'y') { + if (Test-Path $ClaudeJson) { + $cfg = Get-Content $ClaudeJson -Raw | ConvertFrom-Json + $cfg | Add-Member -NotePropertyName hasCompletedOnboarding -NotePropertyValue $true -Force + $cfg | ConvertTo-Json -Depth 100 | Set-Content $ClaudeJson -Encoding UTF8 + } else { + '{"hasCompletedOnboarding":true}' | Set-Content $ClaudeJson -Encoding UTF8 + } + Ok 'introductory screens will be skipped' +} else { + Ok 'introductory screens left on' +} + +# ------------------------------------------------------------ 9. settings.json + +Say 'Statusline' +$statusCmd = "powershell -NoProfile -ExecutionPolicy Bypass -File `"$StatuslinePath`"" + +if (Test-Path $Settings) { + $cfg = Get-Content $Settings -Raw | ConvertFrom-Json +} else { + $cfg = [pscustomobject]@{} +} +$cfg | Add-Member -NotePropertyName statusLine ` + -NotePropertyValue ([pscustomobject]@{ type = 'command'; command = $statusCmd }) -Force + +if ($Global) { + $envObj = Get-Prop $cfg 'env' + if ($null -eq $envObj) { $envObj = [pscustomobject]@{} } + $envObj | Add-Member -NotePropertyName ANTHROPIC_BASE_URL -NotePropertyValue $GatewayUrl -Force + $envObj | Add-Member -NotePropertyName ANTHROPIC_AUTH_TOKEN -NotePropertyValue $ApiKey -Force + $envObj | Add-Member -NotePropertyName ANTHROPIC_MODEL -NotePropertyValue $Model -Force + $envObj | Add-Member -NotePropertyName ANTHROPIC_SMALL_FAST_MODEL -NotePropertyValue $Model -Force + $cfg | Add-Member -NotePropertyName env -NotePropertyValue $envObj -Force +} +$cfg | ConvertTo-Json -Depth 100 | Set-Content $Settings -Encoding UTF8 +Ok "statusline enabled in $Settings" +if ($Global) { Ok 'plain `claude` now uses qBraid too' } + +# ------------------------------------------------------------------- 10. mcp + +Say 'qBraid MCP' +claude mcp get $McpName *> $null +if ($LASTEXITCODE -eq 0) { + Ok 'already registered' +} else { + claude mcp add --transport http $McpName $McpUrl --scope user *> $null + if ($LASTEXITCODE -ne 0) { Die 'could not register the qBraid MCP server.' } + Ok "registered $McpUrl" +} + +# 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') { + claude mcp login $McpName + if ($LASTEXITCODE -ne 0) { Warn "MCP sign-in did not complete. Run ``claude mcp login $McpName`` later." } +} else { + Warn "skipped. Run ``claude mcp login $McpName`` when you want the qBraid tools." +} + +# ------------------------------------------------------------ 11. smoke test + +Say 'Verifying' +$body = @{ + model = $Model + max_tokens = 32 + messages = @(@{ role = 'user'; content = 'Reply with exactly: OK' }) +} | ConvertTo-Json -Depth 10 + +try { + $reply = Invoke-RestMethod -Uri "$GatewayUrl/v1/messages" -Method Post -TimeoutSec 90 ` + -Headers @{ + 'Authorization' = "Bearer $ApiKey" + 'anthropic-version' = '2023-06-01' + } -ContentType 'application/json' -Body $body +} catch { + Die "the test request to the qBraid gateway failed: $_" +} +if (-not (Get-Prop $reply 'content')) { Die "unexpected reply from the gateway: $($reply | ConvertTo-Json -Compress)" } +Ok 'end-to-end request succeeded' + +# ---------------------------------------------------------------- 12. finish + +Write-Host '' +Write-Host 'qbraid-code is ready.' -ForegroundColor Green +Write-Host '' +Write-Host ' Open a new terminal, then run it from any folder:' +Write-Host '' +Write-Host ' qbraid-code start a session' +Write-Host ' qbraid-code -p "..." ask one question and exit' +Write-Host ' qbraid-code --doctor check your setup' +Write-Host '' +if ($Global) { + Write-Host ' The plain claude command uses qBraid as well.' +} else { + Write-Host ' Your own claude command is untouched.' +} +Write-Host '' diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..b9a8d0d --- /dev/null +++ b/install.sh @@ -0,0 +1,476 @@ +#!/usr/bin/env bash +# qbraid-code installer — Claude Code, powered by the qBraid AI gateway. +# +# curl -fsSL https://qbraid.com/code.sh | bash +# curl -fsSL https://qbraid.com/code.sh | bash -s -- --global +# +# There is no proxy and no daemon. The qBraid gateway speaks the Anthropic +# Messages API natively at /api/v1/ai/v1/messages (the double `v1` is +# deliberate — see qbraid-api src/features/ai/messages/messages.routes.ts), +# so Claude Code talks to it directly through ANTHROPIC_BASE_URL. +# +# Everything this writes lives in ~/.qbraid-code and ~/.local/bin. +# Re-running is safe. +set -euo pipefail + +GATEWAY_HOST="api-v2.qbraid.com" +API_BASE="https://${GATEWAY_HOST}/api/v1" +GATEWAY_URL="${API_BASE}/ai" +MCP_NAME="qbraid" +MCP_URL="https://mcp.qbraid.com/mcp" +KEYS_URL="https://account.qbraid.com/account/api-keys" + +RAW_BASE="https://raw.githubusercontent.com/qBraid/qbraid-code/main" +GH_CONTENTS="/repos/qBraid/qbraid-code/contents" + +HOME_DIR="${QBRAID_CODE_HOME:-$HOME/.qbraid-code}" +BIN_DIR="${QBRAID_CODE_BIN_DIR:-$HOME/.local/bin}" +CLAUDE_DIR="$HOME/.claude" +SETTINGS="$CLAUDE_DIR/settings.json" +CLAUDE_JSON="$HOME/.claude.json" + +GLOBAL=0 +for arg in "$@"; do + case "$arg" in + --global) GLOBAL=1 ;; + --help|-h) + cat <<'EOF' +qbraid-code installer + + --global also point the plain `claude` command at qBraid + --help show this message + +Environment: + QBRAID_API_KEY use this key instead of prompting + QBRAID_CODE_MODEL use this model instead of prompting + QBRAID_CODE_HOME config directory (default ~/.qbraid-code) + QBRAID_CODE_BIN_DIR install directory (default ~/.local/bin) +EOF + exit 0 ;; + *) ;; + esac +done + +# ------------------------------------------------------------------ output + +if [ -t 1 ]; then + bold=$(tput bold 2>/dev/null || true); dim=$(tput dim 2>/dev/null || true) + red=$(tput setaf 1 2>/dev/null || true); grn=$(tput setaf 2 2>/dev/null || true) + ylw=$(tput setaf 3 2>/dev/null || true); rst=$(tput sgr0 2>/dev/null || true) +else + bold=""; dim=""; red=""; grn=""; ylw=""; rst="" +fi + +say() { printf '%s==>%s %s\n' "$bold" "$rst" "$*"; } +ok() { printf ' %s+%s %s\n' "$grn" "$rst" "$*"; } +warn() { printf ' %s!%s %s\n' "$ylw" "$rst" "$*"; } +die() { printf '\n%serror:%s %s\n' "$red" "$rst" "$*" >&2; exit 1; } + +# When this script is piped from curl, stdin is the script itself — prompts +# must read the keyboard directly. If there is no terminal at all (CI), the +# install has to be driven entirely by environment variables. +TTY="" +if [ -r /dev/tty ] && [ -w /dev/tty ]; then TTY=/dev/tty; fi + +prompt() { # prompt -> echoes the answer + local q="$1" reply="" + [ -n "$TTY" ] || die "no terminal available for input. Set QBRAID_API_KEY and re-run." + printf '%s%s%s ' "$bold" "$q" "$rst" > "$TTY" + IFS= read -r reply < "$TTY" + printf '%s' "$reply" +} + +confirm() { # confirm -> 0 if yes + local q="$1" def="${2:-y}" reply="" hint="[Y/n]" + [ "$def" = n ] && hint="[y/N]" + if [ -z "$TTY" ]; then [ "$def" = y ]; return; fi + printf '%s%s%s %s ' "$bold" "$q" "$rst" "$hint" > "$TTY" + IFS= read -r reply < "$TTY" + reply="$(printf '%s' "$reply" | tr '[:upper:]' '[:lower:]')" + [ -z "$reply" ] && reply="$def" + [ "$reply" = y ] || [ "$reply" = yes ] +} + +# ---------------------------------------------------------------- 1. platform + +case "$(uname -s)" in + Darwin) OS=darwin ;; + Linux) OS=linux ;; + *) die "unsupported OS: $(uname -s). Windows users: use install.ps1 in PowerShell." ;; +esac +case "$(uname -m)" in + arm64|aarch64) ARCH=arm64 ;; + x86_64|amd64) ARCH=x64 ;; + *) die "unsupported architecture: $(uname -m)" ;; +esac +command -v curl >/dev/null || die "curl is required but not installed." +say "Platform: $OS/$ARCH" + +mkdir -p "$HOME_DIR" "$BIN_DIR" "$CLAUDE_DIR" + +# Merging JSON into an existing settings file needs a real parser. On macOS +# /usr/bin/python3 is a stub that pops a GUI installer prompt unless the +# Command Line Tools are present, so check for those before trusting it. +have_python() { + command -v python3 >/dev/null 2>&1 || return 1 + if [ "$OS" = darwin ] && ! xcode-select -p >/dev/null 2>&1; then return 1; fi + python3 -c 'import json' >/dev/null 2>&1 +} + +# ------------------------------------------------------------ 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 + +# ------------------------------------------------------------- 3. credential + +# Field values in the gateway's JSON are flat, so a small sed extractor keeps +# this script free of a jq/python dependency for the common path. +json_str() { # json_str + printf '%s' "$1" | grep -o "\"$2\":\"[^\"]*\"" | head -1 | sed "s/\"$2\":\"//; s/\"$//" +} + +balance_for() { # balance_for -> prints JSON body, returns curl status + curl -fsS -m 25 "$API_BASE/billing/credits/balance" -H "X-API-Key: $1" +} + +read_qbraidrc_key() { + [ -f "$HOME/.qbraid/qbraidrc" ] || return 1 + sed -n 's/^[[:space:]]*api-key[[:space:]]*=[[:space:]]*//p' "$HOME/.qbraid/qbraidrc" \ + | head -1 | tr -d '[:space:]' +} + +say "qBraid account" +API_KEY="${QBRAID_API_KEY:-}" +BALANCE="" +KEY_SOURCE="QBRAID_API_KEY" + +if [ -z "$API_KEY" ]; then + if CANDIDATE=$(read_qbraidrc_key) && [ -n "$CANDIDATE" ]; then + if BALANCE=$(balance_for "$CANDIDATE" 2>/dev/null); then + API_KEY="$CANDIDATE"; KEY_SOURCE="your qBraid CLI config" + else + warn "the key in ~/.qbraid/qbraidrc is no longer valid — ignoring it" + BALANCE="" + fi + fi +fi + +if [ -n "$API_KEY" ] && [ -z "$BALANCE" ]; then + BALANCE=$(balance_for "$API_KEY" 2>/dev/null) \ + || die "the API key from $KEY_SOURCE was rejected by qBraid." +fi + +if [ -z "$API_KEY" ]; then + cat </dev/null 2>&1 || true ;; + linux) (xdg-open "$KEYS_URL" >/dev/null 2>&1 &) || true ;; + esac + + ATTEMPT=0 + while [ -z "$API_KEY" ]; do + ATTEMPT=$((ATTEMPT + 1)) + [ "$ATTEMPT" -gt 5 ] && die "too many invalid keys. Re-run once you have a working key." + CANDIDATE=$(prompt "Paste your qBraid API key:") + CANDIDATE=$(printf '%s' "$CANDIDATE" | tr -d '[:space:]') + [ -n "$CANDIDATE" ] && BALANCE=$(balance_for "$CANDIDATE" 2>/dev/null) \ + && { API_KEY="$CANDIDATE"; KEY_SOURCE="pasted"; break; } + warn "qBraid did not accept that key. Check you copied all of it, then try again." + done +fi +ok "key accepted (from $KEY_SOURCE)" + +# ------------------------------------------------------- 4. organization check + +ORG_ID=$(json_str "$BALANCE" organizationId) +CREDITS=$(printf '%s' "$BALANCE" | grep -o '"qbraidCredits":-\?[0-9.]*' | head -1 | sed 's/.*://') +[ -n "$CREDITS" ] || CREDITS="unknown" + +# /organizations/current returns the organization document itself, so `name` is +# the organization's. /organizations/me returns *membership* details, whose +# first `name` is the user's — labelling the confirmation with that would be +# worse than showing nothing. The id is printed alongside so a bad parse cannot +# quietly point someone at the wrong organization. +ORG_NAME="" +if [ -n "$ORG_ID" ]; then + ORG_JSON=$(curl -fsS -m 20 "$API_BASE/organizations/current" \ + -H "X-API-Key: $API_KEY" -H "X-Organization-Id: $ORG_ID" 2>/dev/null || true) + ORG_NAME=$(json_str "$ORG_JSON" name) +fi + +if [ -n "$ORG_NAME" ]; then + printf '\n Organization: %s%s%s %s(%s)%s\n' \ + "$bold" "$ORG_NAME" "$rst" "$dim" "$ORG_ID" "$rst" +else + printf '\n Organization: %s%s%s\n' "$bold" "${ORG_ID:-unknown}" "$rst" +fi +printf ' Credits: %s%s%s\n\n' "$bold" "$CREDITS" "$rst" + +if ! confirm "Is this the right organization?" y; then + cat </dev/null || true) + # The list is fetched live so new gateway models appear without a release here. + MODEL_IDS=$(printf '%s' "$MODELS_JSON" | grep -o '"id":"[^"]*"' | sed 's/"id":"//; s/"$//') + if [ -z "$MODEL_IDS" ]; then + warn "could not list models — defaulting to claude-sonnet-4-6" + MODEL="claude-sonnet-4-6" + elif [ -z "$TTY" ]; then + MODEL=$(printf '%s\n' "$MODEL_IDS" | head -1) + else + printf '\n Available models:\n\n' > "$TTY" + i=0 + while IFS= read -r m; do + i=$((i + 1)) + printf ' %2d) %s\n' "$i" "$m" > "$TTY" + done < "$TTY" + DEFAULT_IDX=1 + CHOICE=$(prompt "Choose a default model [${DEFAULT_IDX}]:") + CHOICE=$(printf '%s' "$CHOICE" | tr -d '[:space:]') + [ -z "$CHOICE" ] && CHOICE="$DEFAULT_IDX" + case "$CHOICE" in + ''|*[!0-9]*) MODEL="" ;; + *) MODEL=$(printf '%s\n' "$MODEL_IDS" | sed -n "${CHOICE}p") ;; + esac + [ -n "$MODEL" ] || MODEL=$(printf '%s\n' "$MODEL_IDS" | head -1) + fi +fi +ok "default model: $MODEL" + +# ---------------------------------------------------------------- 6. env file + +umask 077 +cat > "$HOME_DIR/env" < + local name="$1" dest="$2" + if [ -n "$SRC_DIR" ]; then + cp "$SRC_DIR/$name" "$dest"; return 0 + fi + if curl -fsSL -m 30 -o "$dest" "$RAW_BASE/$name" 2>/dev/null; then return 0; fi + command -v gh >/dev/null 2>&1 \ + || die "could not download $name. While this repository is private you need the GitHub CLI: https://cli.github.com" + gh api -H "Accept: application/vnd.github.raw" "$GH_CONTENTS/$name" > "$dest" \ + || die "could not download $name — is \`gh auth login\` done, and are you in the qBraid org?" +} + +fetch_file qbraid-code "$BIN_DIR/qbraid-code" +chmod 0755 "$BIN_DIR/qbraid-code" +ok "launcher installed to $BIN_DIR/qbraid-code" + +fetch_file statusline.sh "$HOME_DIR/statusline.sh" +chmod 0755 "$HOME_DIR/statusline.sh" +ok "statusline installed to $HOME_DIR/statusline.sh" + +# --------------------------------------------------------- 8. first-run flags + +say "Claude Code first run" +if [ ! -f "$CLAUDE_JSON" ]; then + if confirm "Skip Claude Code's introductory screens?" y; then + printf '{"hasCompletedOnboarding":true}\n' > "$CLAUDE_JSON" + ok "introductory screens will be skipped" + else + ok "introductory screens left on" + fi +elif have_python; then + if confirm "Skip Claude Code's introductory screens?" y; then + python3 - "$CLAUDE_JSON" <<'PY' +import json, sys +path = sys.argv[1] +with open(path) as fh: + data = json.load(fh) +data["hasCompletedOnboarding"] = True +with open(path, "w") as fh: + json.dump(data, fh, indent=2) +PY + ok "introductory screens will be skipped" + else + ok "introductory screens left on" + fi +else + warn "python3 unavailable — leaving $CLAUDE_JSON alone" +fi + +# ------------------------------------------------------------ 9. settings.json + +# The statusline (and, with --global, the gateway env) go into the user +# settings file. A fresh machine has no settings.json, which is the common +# case here; merging into an existing one needs a real JSON parser. +write_settings() { + local statusline_cmd="$1" + if [ ! -f "$SETTINGS" ]; then + if [ "$GLOBAL" = 1 ]; then + cat > "$SETTINGS" < "$SETTINGS" </dev/null 2>&1; then + ok "already registered" +else + claude mcp add --transport http "$MCP_NAME" "$MCP_URL" --scope user >/dev/null \ + || die "could not register the qBraid MCP server." + ok "registered $MCP_URL" +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 confirm "Sign in to the qBraid MCP now? (opens a browser)" y; then + claude mcp login "$MCP_NAME" \ + || warn "MCP sign-in did not complete. Run \`claude mcp login $MCP_NAME\` later." + else + warn "skipped. Run \`claude mcp login $MCP_NAME\` when you want the qBraid tools." + fi +else + warn "no terminal — run \`claude mcp login $MCP_NAME\` to finish MCP sign-in." +fi + +# ------------------------------------------------------------ 11. smoke test + +say "Verifying" +REPLY=$(curl -fsS -m 90 "$GATEWAY_URL/v1/messages" \ + -H "Authorization: Bearer $API_KEY" \ + -H "anthropic-version: 2023-06-01" \ + -H 'Content-Type: application/json' \ + -d "{\"model\":\"$MODEL\",\"max_tokens\":32,\"messages\":[{\"role\":\"user\",\"content\":\"Reply with exactly: OK\"}]}") \ + || die "the test request to the qBraid gateway failed." +case "$REPLY" in + *'"text"'*) ok "end-to-end request succeeded" ;; + *) die "unexpected reply from the gateway: $REPLY" ;; +esac + +# ---------------------------------------------------------------- 12. finish + +printf '\n%sqbraid-code is ready.%s\n\n' "$bold$grn" "$rst" + +case ":$PATH:" in + *":$BIN_DIR:"*) ;; + *) + SHELL_RC="$HOME/.bashrc" + case "${SHELL:-}" in *zsh) SHELL_RC="$HOME/.zshrc" ;; esac + printf ' %sFirst, add %s to your PATH:%s\n' "$ylw" "$BIN_DIR" "$rst" + printf ' echo '"'"'export PATH="%s:$PATH"'"'"' >> %s\n' "$BIN_DIR" "$SHELL_RC" + printf ' source %s\n\n' "$SHELL_RC" + ;; +esac + +cat <&2 + echo "Install it with: curl -fsSL https://qbraid.com/code.sh | bash" >&2 + exit 1 +} +# shellcheck disable=SC1091 +. "$HOME_DIR/env" + +BASE_URL="$QBRAID_CODE_BASE_URL" +API_BASE="$QBRAID_CODE_API_BASE" +TOKEN="$QBRAID_CODE_TOKEN" +MODEL="$QBRAID_CODE_MODEL" + +balance() { + curl -fsS -m 20 "$API_BASE/billing/credits/balance" -H "X-API-Key: $TOKEN" +} + +case "${1:-}" in + --doctor) + command -v claude >/dev/null 2>&1 \ + && echo "claude: $(claude --version 2>/dev/null || echo present)" \ + || echo "claude: NOT INSTALLED" + + if BODY=$(balance 2>/dev/null); then + CREDITS=$(printf '%s' "$BODY" | grep -o '"qbraidCredits":-\?[0-9.]*' | head -1 | sed 's/.*://') + echo "key: valid" + echo "credits: ${CREDITS:-unknown}" + else + echo "key: REJECTED — make a new one at https://account.qbraid.com/account/api-keys" + echo "credits: unknown" + fi + + if curl -fsS -m 20 -o /dev/null "$BASE_URL/v1/models" -H "X-API-Key: $TOKEN" 2>/dev/null; then + echo "gateway: reachable" + else + 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)" + else + echo "mcp: NOT REGISTERED" + fi + + echo "model: $MODEL" + exit 0 ;; + --help|-h) + cat </dev/null 2>&1 || { + echo "qbraid-code: Claude Code is not installed." >&2 + echo "Re-run the installer: curl -fsSL https://qbraid.com/code.sh | bash" >&2 + exit 1 +} + +# ANTHROPIC_AUTH_TOKEN sends `Authorization: Bearer `, which the gateway +# accepts (qbraid-api apikey-strategy.service.ts claims `Bearer qbr_...`). +# ANTHROPIC_API_KEY would work too, but it makes Claude Code ask the user to +# approve a custom API key on first run — a prompt with no good answer for +# someone who just wants to start working. +ANTHROPIC_BASE_URL="$BASE_URL" \ +ANTHROPIC_AUTH_TOKEN="$TOKEN" \ +ANTHROPIC_MODEL="$MODEL" \ +ANTHROPIC_SMALL_FAST_MODEL="$MODEL" \ +CLAUDE_CODE_SUBAGENT_MODEL="$MODEL" \ +exec claude "$@" diff --git a/qbraid-code.cmd b/qbraid-code.cmd new file mode 100644 index 0000000..6446886 --- /dev/null +++ b/qbraid-code.cmd @@ -0,0 +1,62 @@ +@echo off +rem qbraid-code — Claude Code, powered by the qBraid AI gateway. +rem Installed by install.ps1; reads its settings from %USERPROFILE%\.qbraid-code\env. +rem +rem This is a .cmd rather than a PowerShell function on purpose: a .cmd on PATH +rem works from cmd.exe, PowerShell and Windows Terminal with no profile edit and +rem no execution-policy change, which are the two things that fail quietly for +rem someone who just wants to start working. +setlocal EnableExtensions + +set "QC_HOME=%USERPROFILE%\.qbraid-code" +if defined QBRAID_CODE_HOME set "QC_HOME=%QBRAID_CODE_HOME%" + +if not exist "%QC_HOME%\env" ( + echo qbraid-code: not installed - no "%QC_HOME%\env" 1>&2 + echo Install it with: irm https://qbraid.com/code.ps1 ^| iex 1>&2 + exit /b 1 +) + +for /f "usebackq eol=# tokens=1,* delims==" %%a in ("%QC_HOME%\env") do set "%%a=%%b" + +if /i "%~1"=="--doctor" ( + powershell -NoProfile -ExecutionPolicy Bypass -File "%QC_HOME%\doctor.ps1" + exit /b %ERRORLEVEL% +) + +if /i "%~1"=="--help" goto :help +if /i "%~1"=="-h" goto :help + +where claude >nul 2>&1 +if errorlevel 1 ( + echo qbraid-code: Claude Code is not installed. 1>&2 + echo Re-run the installer: irm https://qbraid.com/code.ps1 ^| iex 1>&2 + exit /b 1 +) + +rem ANTHROPIC_AUTH_TOKEN sends `Authorization: Bearer `, which the gateway +rem accepts. ANTHROPIC_API_KEY would work too, but it makes Claude Code ask the +rem user to approve a custom API key on first run — a prompt with no good answer +rem for someone who just wants to start working. +set "ANTHROPIC_BASE_URL=%QBRAID_CODE_BASE_URL%" +set "ANTHROPIC_AUTH_TOKEN=%QBRAID_CODE_TOKEN%" +set "ANTHROPIC_MODEL=%QBRAID_CODE_MODEL%" +set "ANTHROPIC_SMALL_FAST_MODEL=%QBRAID_CODE_MODEL%" +set "CLAUDE_CODE_SUBAGENT_MODEL=%QBRAID_CODE_MODEL%" + +claude %* +exit /b %ERRORLEVEL% + +:help +echo qbraid-code - Claude Code, powered by the qBraid AI gateway. +echo. +echo qbraid-code [claude args...] start a session +echo qbraid-code -p "..." ask one question and exit +echo qbraid-code --doctor check your setup +echo. +echo Any other arguments are passed straight through to claude, so +echo -c, --allowedTools, --model, and the rest behave normally. +echo. +echo Default model: %QBRAID_CODE_MODEL% +echo Change it by editing "%QC_HOME%\env", or pass --model. +exit /b 0 diff --git a/statusline.ps1 b/statusline.ps1 new file mode 100644 index 0000000..a20687f --- /dev/null +++ b/statusline.ps1 @@ -0,0 +1,127 @@ +<# +.SYNOPSIS + qbraid-code statusline — folder, branch, model, context, qBraid credits. + +.DESCRIPTION + Claude Code runs this on every render and feeds it the session JSON on + stdin. It must never block: the credit balance is served from a short-lived + cache and refreshed in a detached process, so a slow network costs nothing. +#> +# No Set-StrictMode here on purpose: it prohibits references to non-existent +# properties, and a session payload that omits `model` or `context_window` +# would then throw on every keystroke. This script must always render something. +$ErrorActionPreference = 'SilentlyContinue' + +$HomeDir = if ($env:QBRAID_CODE_HOME) { $env:QBRAID_CODE_HOME } else { Join-Path $env:USERPROFILE '.qbraid-code' } +$Cache = Join-Path $HomeDir 'credits.cache' +$Ttl = 60 + +$apiBase = 'https://api-v2.qbraid.com/api/v1' +$token = '' +$envPath = Join-Path $HomeDir 'env' +if (Test-Path $envPath) { + foreach ($line in Get-Content $envPath) { + if ($line -match '^\s*QBRAID_CODE_API_BASE\s*=\s*(.*)$') { $apiBase = $Matches[1] } + if ($line -match '^\s*QBRAID_CODE_TOKEN\s*=\s*(.*)$') { $token = $Matches[1] } + } +} + +$e = [char]27 +$dim = "$e[2m"; $rst = "$e[0m" +$cyan = "$e[36m"; $grn = "$e[32m"; $ylw = "$e[33m"; $red = "$e[31m" + +$raw = [Console]::In.ReadToEnd() +$data = $null +if ($raw) { try { $data = $raw | ConvertFrom-Json } catch { $data = $null } } + +function Get-Nested { + param($Object, [string[]]$Path) + $node = $Object + foreach ($key in $Path) { + if ($null -eq $node) { return $null } + $prop = $node.PSObject.Properties[$key] + if (-not $prop) { return $null } + $node = $prop.Value + } + return $node +} + +$model = 'Claude' +$dir = $PWD.Path +$remaining = $null + +$v = Get-Nested $data @('model','display_name'); if ($v) { $model = $v } +$v = Get-Nested $data @('workspace','current_dir'); if ($v) { $dir = $v } +$v = Get-Nested $data @('context_window','remaining_percentage') +if ($null -ne $v) { $remaining = [double]$v } + +# ------------------------------------------------------------------ folder + +$name = Split-Path $dir -Leaf +$branch = & git -C $dir rev-parse --abbrev-ref HEAD 2>$null +$place = "$cyan$name$rst" +if ($branch -and $branch -ne 'HEAD') { + if ($branch.Length -gt 22) { $branch = $branch.Substring(0, 21) + [char]0x2026 } + $place = "$place $dim$([char]0x2387) $branch$rst" +} + +# ----------------------------------------------------------------- context + +$bar = '' +if ($null -ne $remaining) { + $used = [int][Math]::Round(100 - $remaining) + if ($used -lt 0) { $used = 0 } + if ($used -gt 100) { $used = 100 } + $filled = [int][Math]::Ceiling($used / 17.0) + if ($filled -gt 6) { $filled = 6 } + $blocks = '' + for ($i = 0; $i -lt 6; $i++) { + $blocks += if ($i -lt $filled) { [char]0x2588 } else { [char]0x2591 } + } + $colour = $grn + if ($used -ge 60) { $colour = $ylw } + if ($used -ge 85) { $colour = $red } + $bar = "${dim}C$used$rst $colour$blocks$rst" +} + +# ----------------------------------------------------------------- credits + +function Start-CreditRefresh { + if (-not $token) { return } + $script = @" +`$b = Invoke-RestMethod -Uri '$apiBase/billing/credits/balance' -Headers @{ 'X-API-Key' = '$token' } -TimeoutSec 15 +if (`$b.data.qbraidCredits -ne `$null) { + Set-Content -Path '$Cache' -Value ([string]`$b.data.qbraidCredits) -Encoding ASCII +} +"@ + Start-Process -FilePath 'powershell' -WindowStyle Hidden ` + -ArgumentList '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', $script +} + +$credits = $null +if (Test-Path $Cache) { + $credits = (Get-Content $Cache -Raw).Trim() + $age = ((Get-Date) - (Get-Item $Cache).LastWriteTime).TotalSeconds + if ($age -ge $Ttl) { Start-CreditRefresh } +} else { + Start-CreditRefresh +} + +$creditSeg = '' +if ($credits) { + $value = 0.0 + if ([double]::TryParse($credits, [ref]$value)) { + $colour = $grn + if ($value -lt 100) { $colour = $ylw } + if ($value -lt 10) { $colour = $red } + $creditSeg = "$colour$([Math]::Round($value))$rst$dim credits$rst" + } +} + +# ------------------------------------------------------------------ render + +$sep = "$dim $([char]0x2502) $rst" +$out = "$place$sep$dim$model$rst" +if ($bar) { $out = "$out$sep$bar" } +if ($creditSeg) { $out = "$out$sep$creditSeg" } +Write-Output $out diff --git a/statusline.sh b/statusline.sh new file mode 100755 index 0000000..8024dec --- /dev/null +++ b/statusline.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# qbraid-code statusline — folder, branch, model, context, qBraid credits. +# +# Claude Code runs this on every render and feeds it the session JSON on +# stdin. It must never block: the credit balance is served from a short-lived +# cache and refreshed in the background, so a slow network costs nothing. +set -uo pipefail + +HOME_DIR="${QBRAID_CODE_HOME:-$HOME/.qbraid-code}" +CACHE="$HOME_DIR/credits.cache" +TTL=60 + +[ -f "$HOME_DIR/env" ] && . "$HOME_DIR/env" +API_BASE="${QBRAID_CODE_API_BASE:-https://api-v2.qbraid.com/api/v1}" +TOKEN="${QBRAID_CODE_TOKEN:-}" + +esc=$(printf '\033') +dim="${esc}[2m"; rst="${esc}[0m" +cyan="${esc}[36m"; grn="${esc}[32m"; ylw="${esc}[33m"; red="${esc}[31m" + +payload=$(cat) + +field() { # field — first string value for a key + printf '%s' "$payload" | grep -o "\"$1\":\"[^\"]*\"" | head -1 | sed "s/\"$1\":\"//; s/\"$//" +} +number() { # number — first numeric value for a key + printf '%s' "$payload" | grep -o "\"$1\":-\?[0-9.]*" | head -1 | sed 's/.*://' +} + +model=$(field display_name); [ -n "$model" ] || model="Claude" +dir=$(field current_dir); [ -n "$dir" ] || dir="$PWD" +remaining=$(number remaining_percentage) + +# ------------------------------------------------------------------ folder + +name=$(basename "$dir") +branch=$(git -C "$dir" rev-parse --abbrev-ref HEAD 2>/dev/null || true) +place="${cyan}${name}${rst}" +if [ -n "$branch" ] && [ "$branch" != HEAD ]; then + if [ "${#branch}" -gt 22 ]; then branch="${branch:0:21}…"; fi + place="${place} ${dim}⎇ ${branch}${rst}" +fi + +# ----------------------------------------------------------------- context + +bar="" +if [ -n "$remaining" ]; then + used=$(awk -v r="$remaining" 'BEGIN { u = 100 - r; if (u < 0) u = 0; if (u > 100) u = 100; printf "%d", u }') + filled=$(( (used + 16) / 17 )) + [ "$filled" -gt 6 ] && filled=6 + blocks="" + i=0 + while [ "$i" -lt 6 ]; do + if [ "$i" -lt "$filled" ]; then blocks="${blocks}█"; else blocks="${blocks}░"; fi + i=$((i + 1)) + done + colour="$grn" + [ "$used" -ge 60 ] && colour="$ylw" + [ "$used" -ge 85 ] && colour="$red" + bar="${dim}C${used}${rst} ${colour}${blocks}${rst}" +fi + +# ----------------------------------------------------------------- credits + +# Refresh out of band so the prompt never waits on the network. +refresh_credits() { + [ -n "$TOKEN" ] || return 0 + ( + body=$(curl -fsS -m 15 "$API_BASE/billing/credits/balance" -H "X-API-Key: $TOKEN" 2>/dev/null) || exit 0 + value=$(printf '%s' "$body" | grep -o '"qbraidCredits":-\?[0-9.]*' | head -1 | sed 's/.*://') + [ -n "$value" ] || exit 0 + printf '%s' "$value" > "$CACHE.tmp.$$" && mv "$CACHE.tmp.$$" "$CACHE" + ) >/dev/null 2>&1 & +} + +file_age() { # file_age — seconds since last modification + local mtime + mtime=$(stat -f %m "$1" 2>/dev/null || stat -c %Y "$1" 2>/dev/null) || return 1 + echo $(( $(date +%s) - mtime )) +} + +credits="" +if [ -s "$CACHE" ]; then + credits=$(cat "$CACHE" 2>/dev/null) + age=$(file_age "$CACHE" 2>/dev/null || echo "$((TTL + 1))") + [ "$age" -ge "$TTL" ] && refresh_credits +else + refresh_credits +fi + +credit_seg="" +if [ -n "$credits" ]; then + pretty=$(awk -v c="$credits" 'BEGIN { printf "%.0f", c }' 2>/dev/null) + [ -n "$pretty" ] || pretty="$credits" + colour="$grn" + awk -v c="$credits" 'BEGIN { exit !(c < 100) }' && colour="$ylw" + awk -v c="$credits" 'BEGIN { exit !(c < 10) }' && colour="$red" + credit_seg="${colour}${pretty}${rst}${dim} credits${rst}" +fi + +# ------------------------------------------------------------------ render + +sep="${dim} │ ${rst}" +out="$place${sep}${dim}${model}${rst}" +[ -n "$bar" ] && out="${out}${sep}${bar}" +[ -n "$credit_seg" ] && out="${out}${sep}${credit_seg}" +printf '%b\n' "$out" From 507ad3aae4a7d8546ce0ab326c5aa575946d6f9a Mon Sep 17 00:00:00 2001 From: BeLazy167 Date: Thu, 20 Aug 2026 14:07:39 -0500 Subject: [PATCH 2/5] Fix review findings across the installer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Portability, the two platforms that were never executed: - statusline.sh used `stat -f %m`, which is BSD-only. On GNU coreutils it fails but still prints, so the balance froze at the first cached value forever and the fallback was dead code. Try the GNU form first, reject a non-numeric answer. - install.ps1 built the model list as `@(...) | Where-Object`, so a single-model response unrolled to a String and the chosen model became one character. - GNU sed rejects line address 0, so typing 0 at the model prompt killed the installer. - qbraid-code.cmd now ships CRLF (.gitattributes) and uses delayed expansion; `exit /b %ERRORLEVEL%` inside an if-block expanded before the command ran. - The gh fallback wrote a UTF-8 BOM, which cmd.exe rejects on line 1. Secrets: - --global wrote the API key into a world-readable settings.json; chmod 600 on POSIX, an owner-only ACL on Windows. - The pasted key is no longer echoed to the terminal. - statusline.ps1 passed the key on a child process command line every 60s; the child now reads the env file itself. Honest failures: - A network failure was reported as a rejected key, in both the installer and --doctor. Transport and auth are now distinguished by HTTP status. - A smoke-test failure no longer discards a finished install, and an empty wallet is named as such rather than reported as a broken gateway. - confirm() says out loud when it auto-answers with no TTY, instead of printing "organization confirmed" when nobody confirmed anything. - The final banner only claims --global worked if it actually did. - write_settings distinguishes "no python3" from "python3 failed". Also: fetch companion files from qbraid.com first, so the proxy covers the whole install and not just its first step; resolve QBRAID_CODE_HOME once and bake it into the launcher; write ~/.claude.json atomically and never fatally; scope the org name to the data object; round the balance everywhere. Adds tests/statusline.sh (12 cases) and CI on ubuntu + macos + windows — every defect above was platform-specific and nothing ran them before. --- .gitattributes | 8 ++ .github/workflows/ci.yml | 74 ++++++++++ README.md | 21 ++- doctor.ps1 | 19 ++- install.ps1 | 96 ++++++++++--- install.sh | 291 ++++++++++++++++++++++++++++----------- qbraid-code | 89 +++++++++--- qbraid-code.cmd | 27 +++- statusline.ps1 | 50 +++++-- statusline.sh | 39 ++++-- tests/statusline.sh | 85 ++++++++++++ 11 files changed, 636 insertions(+), 163 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yml create mode 100755 tests/statusline.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..095c482 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# cmd.exe requires CRLF: an LF-only .cmd mis-parses `if`/`goto` blocks. +*.cmd text eol=crlf +*.ps1 text eol=crlf + +# Everything a POSIX shell runs must stay LF, whatever the checkout platform. +*.sh text eol=lf +install.sh text eol=lf +qbraid-code text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c63f3c6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,74 @@ +name: ci +on: [push, pull_request] + +jobs: + shell: + # Both platforms on purpose. Every portability defect found in review broke + # on exactly one of them: BSD vs GNU `stat`, and GNU `sed` rejecting line 0. + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - name: Install shellcheck (macOS) + if: runner.os == 'macOS' + run: brew install shellcheck + + - name: shellcheck + run: shellcheck -S warning install.sh qbraid-code statusline.sh tests/statusline.sh + + - name: syntax + run: | + bash -n install.sh + bash -n qbraid-code + bash -n statusline.sh + + - name: statusline tests + run: tests/statusline.sh + + - name: installer --help does not touch the machine + run: bash install.sh --help + + powershell: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: PSScriptAnalyzer + shell: pwsh + run: | + Install-Module PSScriptAnalyzer -Force -Scope CurrentUser + $r = Invoke-ScriptAnalyzer -Path . -Severity Error,Warning -Recurse + $r | Format-Table -AutoSize + if ($r | Where-Object { $_.Severity -eq 'Error' }) { exit 1 } + + - name: parse every script + shell: pwsh + run: | + foreach ($f in Get-ChildItem *.ps1) { + $errs = $null + [System.Management.Automation.Language.Parser]::ParseFile( + $f.FullName, [ref]$null, [ref]$errs) | Out-Null + if ($errs) { $errs; exit 1 } + "parsed $($f.Name)" + } + + - name: batch launcher is CRLF and BOM-free + shell: pwsh + run: | + $bytes = [IO.File]::ReadAllBytes('qbraid-code.cmd') + if ($bytes[0] -eq 0xEF) { throw 'qbraid-code.cmd must not have a UTF-8 BOM' } + $text = [Text.Encoding]::UTF8.GetString($bytes) + if ($text -notmatch "`r`n") { throw 'qbraid-code.cmd must use CRLF line endings' } + 'CRLF ok, no BOM' + + - name: launcher reports a missing install instead of crashing + shell: cmd + run: | + set QBRAID_CODE_HOME=%TEMP%\qc-does-not-exist + call qbraid-code.cmd --help + if not errorlevel 1 exit /b 1 + echo missing-install path reports an error diff --git a/README.md b/README.md index 81ff2d5..edef9ea 100644 --- a/README.md +++ b/README.md @@ -32,13 +32,20 @@ That is all. The installer: Re-running it is safe. -> **While this repository is private**, `qbraid.com/code.sh` is not live yet. Use the -> GitHub CLI instead (`gh auth login` first, and you must be in the `qBraid` org): +> **While this repository is private**, neither `qbraid.com/code.sh` nor +> `qbraid.com/code.ps1` is live yet. Use the GitHub CLI instead (`gh auth login` +> first, and you must be in the `qBraid` org): > > ```bash +> # macOS and Linux > gh api -H "Accept: application/vnd.github.raw" \ > /repos/qBraid/qbraid-code/contents/install.sh | bash > ``` +> +> ```powershell +> # Windows +> gh api -H "Accept: application/vnd.github.raw" /repos/qBraid/qbraid-code/contents/install.ps1 | Out-String | iex +> ``` ## Use @@ -54,7 +61,7 @@ Every other flag goes straight through to `claude`, so `-c`, `--model`, A session looks like this: ``` -qbraid-code ⎇ main │ Claude Opus 5 │ C13 █░░░░░ │ 4,281 credits +qbraid-code ⎇ main │ Claude Opus 5 │ C13 █░░░░░ │ 4281 credits ``` Folder and branch, the model you are talking to, how much of the context window @@ -111,6 +118,7 @@ claude mcp login qbraid | `~/.qbraid-code/env` | your key, gateway URL and default model (mode `600`) | | `~/.qbraid-code/statusline.sh` | statusline script (`statusline.ps1` on Windows) | | `~/.qbraid-code/credits.cache` | last known credit balance, refreshed every 60s | +| `~/.qbraid-code/credits.attempt` | when a refresh was last tried, so failures back off | | `~/.local/bin/qbraid-code` | the launcher (`qbraid-code.cmd` on Windows) | | `~/.claude/settings.json` | statusline wiring, plus gateway env with `--global` | @@ -130,9 +138,10 @@ installer prints the line to add. On Windows, open a new terminal first. **Wrong organization** — credits come from the organization the key belongs to. Create a key under the organization you want, then re-run the installer. -**Statusline did not appear** — on macOS without the Xcode Command Line Tools and -with a `~/.claude/settings.json` that already exists, the installer cannot merge -JSON safely and skips this step. It prints the snippet to add by hand. +**Statusline did not appear** — when `python3` is unavailable (no Xcode Command +Line Tools on macOS, or no `python3` on Linux) and `~/.claude/settings.json` +already exists, the installer cannot merge JSON safely and skips this step. It +prints the snippet to add by hand. ## Uninstall diff --git a/doctor.ps1 b/doctor.ps1 index 2cf07db..68b9c37 100644 --- a/doctor.ps1 +++ b/doctor.ps1 @@ -32,13 +32,28 @@ if (Get-Command claude -ErrorAction SilentlyContinue) { Write-Host 'claude: NOT INSTALLED' } +# Separate transport failure from rejection. Reporting "REJECTED" for a dropped +# connection sent people off to make a new key for no reason. try { $balance = Invoke-RestMethod -Uri "$apiBase/billing/credits/balance" ` -Headers @{ 'X-API-Key' = $token } -TimeoutSec 20 Write-Host 'key: valid' - Write-Host "credits: $($balance.data.qbraidCredits)" + $raw = $balance.data.qbraidCredits + if ($null -ne $raw) { + Write-Host "credits: $([Math]::Round([double]$raw))" + } else { + Write-Host 'credits: unknown' + } } catch { - Write-Host 'key: REJECTED - make a new one at https://account.qbraid.com/account/api-keys' + $status = $null + if ($_.Exception.Response) { $status = [int]$_.Exception.Response.StatusCode } + if ($status -eq 401 -or $status -eq 403) { + Write-Host 'key: REJECTED - make a new one at https://account.qbraid.com/account/api-keys' + } elseif ($status) { + Write-Host "key: UNKNOWN - qBraid returned HTTP $status" + } else { + Write-Host 'key: UNKNOWN - could not reach qBraid (check your connection)' + } Write-Host 'credits: unknown' } diff --git a/install.ps1 b/install.ps1 index 70f7829..7cc33d1 100644 --- a/install.ps1 +++ b/install.ps1 @@ -30,6 +30,7 @@ $GatewayUrl = "$ApiBase/ai" $McpName = 'qbraid' $McpUrl = 'https://mcp.qbraid.com/mcp' $KeysUrl = 'https://account.qbraid.com/account/api-keys' +$SiteBase = 'https://qbraid.com/code' $RawBase = 'https://raw.githubusercontent.com/qBraid/qbraid-code/main' $GhContents = '/repos/qBraid/qbraid-code/contents' @@ -42,7 +43,14 @@ $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 Die { param($m) Write-Host "`nerror: $m" -ForegroundColor Red; exit 1 } +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 { } } + exit 1 +} # StrictMode prohibits references to non-existent properties, so every read of # an API response goes through this instead of a direct dereference. @@ -84,7 +92,9 @@ if (Get-Command claude -ErrorAction SilentlyContinue) { } catch { Die "Claude Code install failed: $_" } - $env:Path = "$BinDir;$env:Path" + # 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.' } @@ -145,7 +155,9 @@ if (-not $ApiKey) { Start-Process $KeysUrl -ErrorAction SilentlyContinue for ($attempt = 1; $attempt -le 5; $attempt++) { - $candidate = (Read-Host 'Paste your qBraid API key').Trim() + $secure = Read-Host 'Paste your qBraid API key' -AsSecureString + $candidate = ([Runtime.InteropServices.Marshal]::PtrToStringAuto( + [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure))).Trim() if ($candidate) { $Balance = Get-Balance $candidate if ($Balance) { $ApiKey = $candidate; $KeySource = 'pasted'; break } @@ -162,8 +174,8 @@ Ok "key accepted (from $KeySource)" # fields degrades instead of aborting the install. $balanceData = Get-Prop $Balance 'data' $OrgId = Get-Prop $balanceData 'organizationId' -$Credits = Get-Prop $balanceData 'qbraidCredits' -if ($null -eq $Credits) { $Credits = 'unknown' } +$CreditsRaw = Get-Prop $balanceData 'qbraidCredits' +$Credits = if ($null -eq $CreditsRaw) { 'unknown' } else { [Math]::Round([double]$CreditsRaw) } # /organizations/current returns the organization document itself, so `name` is # the organization's. /organizations/me returns *membership* details, whose # name is the user's — labelling the confirmation with that would be worse than @@ -206,7 +218,9 @@ if (-not $Model) { # Fetched live so new gateway models appear without a release here. $models = Invoke-RestMethod -Uri "$GatewayUrl/v1/models" ` -Headers @{ 'X-API-Key' = $ApiKey } -TimeoutSec 25 - $ids = @(Get-Prop $models 'data' | ForEach-Object { Get-Prop $_ 'id' }) | Where-Object { $_ } + # @() must wrap the WHOLE pipeline: a one-element pipeline unrolls back + # to a scalar, and $ids[0] on a String returns a single character. + $ids = @(Get-Prop $models 'data' | ForEach-Object { Get-Prop $_ 'id' } | Where-Object { $_ }) } catch { } if ($ids.Count -eq 0) { @@ -241,7 +255,7 @@ $envLines = @( "QBRAID_CODE_MODEL=$Model" ) $envPath = Join-Path $HomeDir 'env' -Set-Content -Path $envPath -Value $envLines -Encoding ASCII +Write-RawText $envPath (($envLines -join "`n") + "`n") Ok "config written to $envPath" # ------------------------------------------------- 7. launcher and statusline @@ -254,18 +268,40 @@ if ($PSScriptRoot -and (Test-Path (Join-Path $PSScriptRoot 'qbraid-code.cmd'))) $SrcDir = $PSScriptRoot } +# Set-Content -Encoding UTF8 emits a BOM on Windows PowerShell 5.1, and a BOM +# on a .cmd makes cmd.exe fail to parse its first line. Write bytes directly. +function Write-RawText { + param([string]$Path, [string]$Text) + if ($Path.EndsWith('.cmd')) { + $Text = ($Text -replace "`r`n", "`n") -replace "`n", "`r`n" + } + [IO.File]::WriteAllText($Path, $Text, (New-Object Text.UTF8Encoding $false)) +} + function Fetch-File { param([string]$Name, [string]$Dest) if ($SrcDir) { Copy-Item (Join-Path $SrcDir $Name) $Dest -Force; return } - try { - Invoke-WebRequest -Uri "$RawBase/$Name" -OutFile $Dest -TimeoutSec 30 -UseBasicParsing - return - } catch { } + + # qbraid.com first: that is the point of the proxy, on networks where + # raw.githubusercontent.com is blocked but qbraid.com is not. + foreach ($base in @($SiteBase, $RawBase)) { + try { + $r = Invoke-WebRequest -Uri "$base/$Name" -TimeoutSec 30 -UseBasicParsing + if ($r.StatusCode -eq 200 -and $r.Content) { + Write-RawText $Dest $r.Content + return + } + } catch { } + } + if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { Die "could not download $Name. While this repository is private you need the GitHub CLI: https://cli.github.com" } - gh api -H 'Accept: application/vnd.github.raw' "$GhContents/$Name" | Set-Content -Path $Dest -Encoding UTF8 - if ($LASTEXITCODE -ne 0) { Die "could not download $Name — is ``gh auth login`` done, and are you in the qBraid org?" } + $text = (gh api -H 'Accept: application/vnd.github.raw' "$GhContents/$Name" | Out-String) + if ($LASTEXITCODE -ne 0 -or -not $text) { + Die "could not download $Name - is ``gh auth login`` done, and are you in the qBraid org?" + } + Write-RawText $Dest $text } $LauncherPath = Join-Path $BinDir 'qbraid-code.cmd' @@ -292,9 +328,9 @@ if (Confirm-Step "Skip Claude Code's introductory screens?" 'y') { if (Test-Path $ClaudeJson) { $cfg = Get-Content $ClaudeJson -Raw | ConvertFrom-Json $cfg | Add-Member -NotePropertyName hasCompletedOnboarding -NotePropertyValue $true -Force - $cfg | ConvertTo-Json -Depth 100 | Set-Content $ClaudeJson -Encoding UTF8 + Write-RawText $ClaudeJson ($cfg | ConvertTo-Json -Depth 100) } else { - '{"hasCompletedOnboarding":true}' | Set-Content $ClaudeJson -Encoding UTF8 + Write-RawText $ClaudeJson '{"hasCompletedOnboarding":true}' } Ok 'introductory screens will be skipped' } else { @@ -323,9 +359,23 @@ if ($Global) { $envObj | Add-Member -NotePropertyName ANTHROPIC_SMALL_FAST_MODEL -NotePropertyValue $Model -Force $cfg | Add-Member -NotePropertyName env -NotePropertyValue $envObj -Force } -$cfg | ConvertTo-Json -Depth 100 | Set-Content $Settings -Encoding UTF8 +Write-RawText $Settings ($cfg | ConvertTo-Json -Depth 100) Ok "statusline enabled in $Settings" -if ($Global) { Ok 'plain `claude` now uses qBraid too' } +if ($Global) { + # settings.json now holds a live credential. Windows has no chmod; restrict + # the ACL to the current user so other accounts on the machine cannot read it. + try { + $acl = Get-Acl $Settings + $acl.SetAccessRuleProtection($true, $false) + $acl.Access | ForEach-Object { $acl.RemoveAccessRule($_) | Out-Null } + $acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule( + $env:USERNAME, 'FullControl', 'Allow'))) + Set-Acl -Path $Settings -AclObject $acl + } catch { + Warn "could not restrict permissions on $Settings - it contains your API key" + } + Ok 'plain `claude` now uses qBraid too' +} # ------------------------------------------------------------------- 10. mcp @@ -364,11 +414,17 @@ try { 'Authorization' = "Bearer $ApiKey" 'anthropic-version' = '2023-06-01' } -ContentType 'application/json' -Body $body + if (Get-Prop $reply 'content') { + Ok 'end-to-end request succeeded' + } else { + Warn 'the gateway replied but not with a message. Run `qbraid-code --doctor`.' + } } catch { - Die "the test request to the qBraid gateway failed: $_" + # Everything is installed by this point; the commonest cause is an empty + # wallet, which is not a broken setup. + Warn "could not verify the gateway: $($_.Exception.Message)" + Warn 'Everything is installed. Run `qbraid-code --doctor` to check again.' } -if (-not (Get-Prop $reply 'content')) { Die "unexpected reply from the gateway: $($reply | ConvertTo-Json -Compress)" } -Ok 'end-to-end request succeeded' # ---------------------------------------------------------------- 12. finish diff --git a/install.sh b/install.sh index b9a8d0d..c2ca61e 100755 --- a/install.sh +++ b/install.sh @@ -4,10 +4,11 @@ # curl -fsSL https://qbraid.com/code.sh | bash # curl -fsSL https://qbraid.com/code.sh | bash -s -- --global # -# There is no proxy and no daemon. The qBraid gateway speaks the Anthropic -# Messages API natively at /api/v1/ai/v1/messages (the double `v1` is -# deliberate — see qbraid-api src/features/ai/messages/messages.routes.ts), -# so Claude Code talks to it directly through ANTHROPIC_BASE_URL. +# There is no proxy and no daemon. The qBraid gateway serves an +# Anthropic-compatible surface, so Claude Code talks to it directly through +# ANTHROPIC_BASE_URL. The double `v1` in /api/v1/ai/v1/messages is deliberate, +# not a typo: Claude Code appends /v1/messages to whatever base URL it is +# given. # # Everything this writes lives in ~/.qbraid-code and ~/.local/bin. # Re-running is safe. @@ -20,6 +21,10 @@ MCP_NAME="qbraid" MCP_URL="https://mcp.qbraid.com/mcp" KEYS_URL="https://account.qbraid.com/account/api-keys" +# 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 +# install that got this far would otherwise die on its last step. +SITE_BASE="https://qbraid.com/code" RAW_BASE="https://raw.githubusercontent.com/qBraid/qbraid-code/main" GH_CONTENTS="/repos/qBraid/qbraid-code/contents" @@ -30,6 +35,7 @@ SETTINGS="$CLAUDE_DIR/settings.json" CLAUDE_JSON="$HOME/.claude.json" GLOBAL=0 +GLOBAL_APPLIED=0 for arg in "$@"; do case "$arg" in --global) GLOBAL=1 ;; @@ -80,10 +86,25 @@ prompt() { # prompt -> echoes the answer printf '%s' "$reply" } +prompt_secret() { # prompt_secret -> echoes the answer, no terminal echo + local q="$1" reply="" + [ -n "$TTY" ] || die "no terminal available for input. Set QBRAID_API_KEY and re-run." + printf '%s%s%s ' "$bold" "$q" "$rst" > "$TTY" + IFS= read -rs reply < "$TTY" + printf '\n' > "$TTY" + printf '%s' "$reply" +} + confirm() { # confirm -> 0 if yes local q="$1" def="${2:-y}" reply="" hint="[Y/n]" [ "$def" = n ] && hint="[y/N]" - if [ -z "$TTY" ]; then [ "$def" = y ]; return; fi + if [ -z "$TTY" ]; then + # Say so out loud. A silent auto-yes previously printed "organization + # confirmed" when nobody had confirmed anything. + warn "no terminal — assuming '$def' for: $q" + [ "$def" = y ] + return + fi printf '%s%s%s %s ' "$bold" "$q" "$rst" "$hint" > "$TTY" IFS= read -r reply < "$TTY" reply="$(printf '%s' "$reply" | tr '[:upper:]' '[:lower:]')" @@ -96,7 +117,8 @@ confirm() { # confirm -> 0 if yes case "$(uname -s)" in Darwin) OS=darwin ;; Linux) OS=linux ;; - *) die "unsupported OS: $(uname -s). Windows users: use install.ps1 in PowerShell." ;; + *) die "unsupported OS: $(uname -s). On Windows, run this in PowerShell instead: + irm https://qbraid.com/code.ps1 | iex" ;; esac case "$(uname -m)" in arm64|aarch64) ARCH=arm64 ;; @@ -136,14 +158,35 @@ fi # ------------------------------------------------------------- 3. credential -# Field values in the gateway's JSON are flat, so a small sed extractor keeps -# this script free of a jq/python dependency for the common path. json_str() { # json_str printf '%s' "$1" | grep -o "\"$2\":\"[^\"]*\"" | head -1 | sed "s/\"$2\":\"//; s/\"$//" } -balance_for() { # balance_for -> prints JSON body, returns curl status - curl -fsS -m 25 "$API_BASE/billing/credits/balance" -H "X-API-Key: $1" +HTTP_STATUS="" +API_BODY="" +# Sets API_BODY and HTTP_STATUS in the CALLER (000 = could not connect). +# It must not print the body: `x=$(api_get ...)` would run this in a subshell +# and the status would never make it back, so every failure read as "rejected". +api_get() { # api_get + local url="$1" key="$2" tmp + API_BODY="" + tmp=$(mktemp) || { HTTP_STATUS="000"; return 0; } + HTTP_STATUS=$(curl -sS -m 25 -o "$tmp" -w '%{http_code}' "$url" -H "X-API-Key: $key" 2>/dev/null) \ + || HTTP_STATUS="000" + API_BODY=$(cat "$tmp") + rm -f "$tmp" +} + +BALANCE="" +# 0 = accepted, 1 = rejected by qBraid, 2 = could not reach qBraid. +# Collapsing 2 into 1 told people their key was bad when their wifi was. +try_key() { + api_get "$API_BASE/billing/credits/balance" "$1" + case "$HTTP_STATUS" in + 200) BALANCE="$API_BODY"; return 0 ;; + 000) return 2 ;; + *) return 1 ;; + esac } read_qbraidrc_key() { @@ -152,27 +195,30 @@ read_qbraidrc_key() { | head -1 | tr -d '[:space:]' } +unreachable_msg="could not reach $GATEWAY_HOST. Check your internet connection and re-run." + say "qBraid account" API_KEY="${QBRAID_API_KEY:-}" -BALANCE="" KEY_SOURCE="QBRAID_API_KEY" -if [ -z "$API_KEY" ]; then +if [ -n "$API_KEY" ]; then + rc=0; try_key "$API_KEY" || rc=$? + case $rc in + 0) ;; + 2) die "$unreachable_msg" ;; + *) die "the API key from $KEY_SOURCE was rejected by qBraid." ;; + esac +else if CANDIDATE=$(read_qbraidrc_key) && [ -n "$CANDIDATE" ]; then - if BALANCE=$(balance_for "$CANDIDATE" 2>/dev/null); then - API_KEY="$CANDIDATE"; KEY_SOURCE="your qBraid CLI config" - else - warn "the key in ~/.qbraid/qbraidrc is no longer valid — ignoring it" - BALANCE="" - fi + rc=0; try_key "$CANDIDATE" || rc=$? + case $rc in + 0) API_KEY="$CANDIDATE"; KEY_SOURCE="your qBraid CLI config" ;; + 2) die "$unreachable_msg" ;; + *) warn "the key in your qBraid CLI config is no longer valid — ignoring it" ;; + esac fi fi -if [ -n "$API_KEY" ] && [ -z "$BALANCE" ]; then - BALANCE=$(balance_for "$API_KEY" 2>/dev/null) \ - || die "the API key from $KEY_SOURCE was rejected by qBraid." -fi - if [ -z "$API_KEY" ]; then cat </dev/null) \ - && { API_KEY="$CANDIDATE"; KEY_SOURCE="pasted"; break; } - warn "qBraid did not accept that key. Check you copied all of it, then try again." + if [ -z "$CANDIDATE" ]; then + warn "nothing pasted — try again." + continue + fi + rc=0; try_key "$CANDIDATE" || rc=$? + case $rc in + 0) API_KEY="$CANDIDATE"; KEY_SOURCE="pasted" ;; + 2) warn "$unreachable_msg" ;; + *) warn "qBraid did not accept that key. Check you copied all of it, then try again." ;; + esac done fi ok "key accepted (from $KEY_SOURCE)" +# The key and model are written into a file that qbraid-code and statusline.sh +# both `.` as shell source, so anything exotic in them would be executed. +# Both are server-controlled strings; validate rather than trust. +case "$API_KEY" in + ''|*[!A-Za-z0-9_.-]*) die "the API key contains unexpected characters — refusing to save it." ;; +esac + # ------------------------------------------------------- 4. organization check ORG_ID=$(json_str "$BALANCE" organizationId) -CREDITS=$(printf '%s' "$BALANCE" | grep -o '"qbraidCredits":-\?[0-9.]*' | head -1 | sed 's/.*://') -[ -n "$CREDITS" ] || CREDITS="unknown" - -# /organizations/current returns the organization document itself, so `name` is -# the organization's. /organizations/me returns *membership* details, whose -# first `name` is the user's — labelling the confirmation with that would be -# worse than showing nothing. The id is printed alongside so a bad parse cannot -# quietly point someone at the wrong organization. +CREDITS_RAW=$(printf '%s' "$BALANCE" | grep -o '"qbraidCredits":-\?[0-9.]*' | head -1 | sed 's/.*://') +if [ -n "$CREDITS_RAW" ]; then + CREDITS=$(awk -v c="$CREDITS_RAW" 'BEGIN { printf "%.0f", c }' 2>/dev/null) || CREDITS="$CREDITS_RAW" +else + CREDITS="unknown" +fi + +# /organizations/current returns the organization document, so `name` is the +# organization's. /organizations/me returns MEMBERSHIP, whose first `name` is +# the USER's — labelling the confirmation with that would be worse than +# showing nothing. Scope the match to the data object, and print the id +# alongside so a bad parse cannot quietly point someone at the wrong org. ORG_NAME="" if [ -n "$ORG_ID" ]; then - ORG_JSON=$(curl -fsS -m 20 "$API_BASE/organizations/current" \ - -H "X-API-Key: $API_KEY" -H "X-Organization-Id: $ORG_ID" 2>/dev/null || true) - ORG_NAME=$(json_str "$ORG_JSON" name) + api_get "$API_BASE/organizations/current" "$API_KEY" + ORG_DATA=$(printf '%s' "$API_BODY" | sed 's/.*"data"[[:space:]]*:[[:space:]]*{//') + ORG_NAME=$(json_str "$ORG_DATA" name) fi if [ -n "$ORG_NAME" ]; then @@ -238,20 +303,25 @@ EOF fi ok "organization confirmed" +if [ "$CREDITS" != unknown ] && awk -v c="$CREDITS_RAW" 'BEGIN { exit !(c <= 0) }'; then + warn "this organization has no credits left — requests will fail until it is topped up." +fi + # ------------------------------------------------------------- 5. model choice say "Model" MODEL="${QBRAID_CODE_MODEL:-}" if [ -z "$MODEL" ]; then - MODELS_JSON=$(curl -fsS -m 25 "$GATEWAY_URL/v1/models" -H "X-API-Key: $API_KEY" 2>/dev/null || true) # The list is fetched live so new gateway models appear without a release here. - MODEL_IDS=$(printf '%s' "$MODELS_JSON" | grep -o '"id":"[^"]*"' | sed 's/"id":"//; s/"$//') + api_get "$GATEWAY_URL/v1/models" "$API_KEY" + MODEL_IDS=$(printf '%s' "$API_BODY" | grep -o '"id":"[^"]*"' | sed 's/"id":"//; s/"$//') if [ -z "$MODEL_IDS" ]; then warn "could not list models — defaulting to claude-sonnet-4-6" MODEL="claude-sonnet-4-6" elif [ -z "$TTY" ]; then MODEL=$(printf '%s\n' "$MODEL_IDS" | head -1) else + MODEL_COUNT=$(printf '%s\n' "$MODEL_IDS" | wc -l | tr -d ' ') printf '\n Available models:\n\n' > "$TTY" i=0 while IFS= read -r m; do @@ -261,21 +331,30 @@ if [ -z "$MODEL" ]; then $MODEL_IDS EOF printf '\n' > "$TTY" - DEFAULT_IDX=1 - CHOICE=$(prompt "Choose a default model [${DEFAULT_IDX}]:") + CHOICE=$(prompt "Choose a default model [1]:") CHOICE=$(printf '%s' "$CHOICE" | tr -d '[:space:]') - [ -z "$CHOICE" ] && CHOICE="$DEFAULT_IDX" + [ -z "$CHOICE" ] && CHOICE=1 + # Reject 0 and anything out of range BEFORE sed sees it: GNU sed treats + # line address 0 as an error and kills the installer under `set -e`. case "$CHOICE" in - ''|*[!0-9]*) MODEL="" ;; - *) MODEL=$(printf '%s\n' "$MODEL_IDS" | sed -n "${CHOICE}p") ;; + ''|*[!0-9]*) CHOICE=1 ;; esac + if [ "$CHOICE" -lt 1 ] || [ "$CHOICE" -gt "$MODEL_COUNT" ]; then + warn "no such option — using 1." + CHOICE=1 + fi + MODEL=$(printf '%s\n' "$MODEL_IDS" | sed -n "${CHOICE}p") [ -n "$MODEL" ] || MODEL=$(printf '%s\n' "$MODEL_IDS" | head -1) fi fi +case "$MODEL" in + ''|*[!A-Za-z0-9_.:/-]*) die "the model name contains unexpected characters — refusing to save it." ;; +esac ok "default model: $MODEL" # ---------------------------------------------------------------- 6. env file +OLD_UMASK=$(umask) umask 077 cat > "$HOME_DIR/env" < if [ -n "$SRC_DIR" ]; then cp "$SRC_DIR/$name" "$dest"; return 0 fi - if curl -fsSL -m 30 -o "$dest" "$RAW_BASE/$name" 2>/dev/null; then return 0; fi + if curl -fsSL -m 30 -o "$dest" "$SITE_BASE/$name" 2>/dev/null && [ -s "$dest" ]; then return 0; fi + if curl -fsSL -m 30 -o "$dest" "$RAW_BASE/$name" 2>/dev/null && [ -s "$dest" ]; then return 0; fi command -v gh >/dev/null 2>&1 \ || die "could not download $name. While this repository is private you need the GitHub CLI: https://cli.github.com" gh api -H "Accept: application/vnd.github.raw" "$GH_CONTENTS/$name" > "$dest" \ || die "could not download $name — is \`gh auth login\` done, and are you in the qBraid org?" + [ -s "$dest" ] || die "downloaded $name but it is empty." } fetch_file qbraid-code "$BIN_DIR/qbraid-code" chmod 0755 "$BIN_DIR/qbraid-code" +# QBRAID_CODE_HOME is resolved once, here, and baked into the installed copies. +# Re-deriving it at run time meant a non-default install reported success and +# then could not find its own config in a fresh shell. +sed "s|^HOME_DIR=.*|HOME_DIR=\"\${QBRAID_CODE_HOME:-$HOME_DIR}\"|" \ + "$BIN_DIR/qbraid-code" > "$BIN_DIR/qbraid-code.tmp" \ + && mv "$BIN_DIR/qbraid-code.tmp" "$BIN_DIR/qbraid-code" \ + && chmod 0755 "$BIN_DIR/qbraid-code" ok "launcher installed to $BIN_DIR/qbraid-code" fetch_file statusline.sh "$HOME_DIR/statusline.sh" @@ -321,37 +409,42 @@ ok "statusline installed to $HOME_DIR/statusline.sh" # --------------------------------------------------------- 8. first-run flags say "Claude Code first run" -if [ ! -f "$CLAUDE_JSON" ]; then - if confirm "Skip Claude Code's introductory screens?" y; then +if confirm "Skip Claude Code's introductory screens?" y; then + if [ ! -f "$CLAUDE_JSON" ]; then printf '{"hasCompletedOnboarding":true}\n' > "$CLAUDE_JSON" ok "introductory screens will be skipped" - else - ok "introductory screens left on" - fi -elif have_python; then - if confirm "Skip Claude Code's introductory screens?" y; then - python3 - "$CLAUDE_JSON" <<'PY' -import json, sys + elif have_python; then + # Written to a temp file and renamed, so an interrupted run cannot leave a + # half-written ~/.claude.json behind. A failure here is not worth aborting + # a working install for. + if python3 - "$CLAUDE_JSON" <<'PY' +import json, os, sys path = sys.argv[1] with open(path) as fh: data = json.load(fh) data["hasCompletedOnboarding"] = True -with open(path, "w") as fh: +tmp = path + ".qbraid-code.tmp" +with open(tmp, "w") as fh: json.dump(data, fh, indent=2) +os.replace(tmp, path) PY - ok "introductory screens will be skipped" + then + ok "introductory screens will be skipped" + else + warn "could not update $CLAUDE_JSON — leaving it alone" + fi else - ok "introductory screens left on" + warn "python3 unavailable — leaving $CLAUDE_JSON alone" fi else - warn "python3 unavailable — leaving $CLAUDE_JSON alone" + ok "introductory screens left on" fi # ------------------------------------------------------------ 9. settings.json # The statusline (and, with --global, the gateway env) go into the user -# settings file. A fresh machine has no settings.json, which is the common -# case here; merging into an existing one needs a real JSON parser. +# settings file. Exit codes are distinct so the caller can say what actually +# went wrong: 2 = no python3, 3 = python3 ran and failed. write_settings() { local statusline_cmd="$1" if [ ! -f "$SETTINGS" ]; then @@ -364,22 +457,22 @@ write_settings() { "ANTHROPIC_MODEL": "$MODEL", "ANTHROPIC_SMALL_FAST_MODEL": "$MODEL" }, - "statusLine": { "type": "command", "command": "$statusline_cmd" } + "statusLine": { "type": "command", "command": "'$statusline_cmd'" } } EOF else cat > "$SETTINGS" </dev/null || warn "could not tighten permissions on $SETTINGS" + GLOBAL_APPLIED=1 + ok "plain \`claude\` now uses qBraid too" + fi + ;; + 2) warn "python3 unavailable and $SETTINGS already exists — skipping." ;; + 3) warn "could not update $SETTINGS (it may not be valid JSON) — skipping." ;; +esac +if [ "$SETTINGS_RC" != 0 ]; then warn "add this to it by hand to enable the statusline:" - printf ' %s"statusLine": { "type": "command", "command": "%s" }%s\n' \ + printf ' %s"statusLine": { "type": "command", "command": "'"'"'%s'"'"'" }%s\n' \ "$dim" "$HOME_DIR/statusline.sh" "$rst" + [ "$GLOBAL" = 1 ] && warn "--global was NOT applied; plain \`claude\` is unchanged." fi # ------------------------------------------------------------------- 10. mcp @@ -434,16 +546,27 @@ fi # ------------------------------------------------------------ 11. smoke test +# A failure here does not undo a complete install, so it warns rather than +# dies. The commonest cause is an empty wallet, which is not a broken setup. say "Verifying" -REPLY=$(curl -fsS -m 90 "$GATEWAY_URL/v1/messages" \ +SMOKE_TMP=$(mktemp) +SMOKE_STATUS=$(curl -sS -m 90 -o "$SMOKE_TMP" -w '%{http_code}' "$GATEWAY_URL/v1/messages" \ -H "Authorization: Bearer $API_KEY" \ -H "anthropic-version: 2023-06-01" \ -H 'Content-Type: application/json' \ - -d "{\"model\":\"$MODEL\",\"max_tokens\":32,\"messages\":[{\"role\":\"user\",\"content\":\"Reply with exactly: OK\"}]}") \ - || die "the test request to the qBraid gateway failed." -case "$REPLY" in - *'"text"'*) ok "end-to-end request succeeded" ;; - *) die "unexpected reply from the gateway: $REPLY" ;; + -d "{\"model\":\"$MODEL\",\"max_tokens\":32,\"messages\":[{\"role\":\"user\",\"content\":\"Reply with exactly: OK\"}]}" \ + 2>/dev/null) || SMOKE_STATUS="000" +SMOKE_BODY=$(cat "$SMOKE_TMP"); rm -f "$SMOKE_TMP" + +case "$SMOKE_STATUS" in + 200) + case "$SMOKE_BODY" in + *'"text"'*) ok "end-to-end request succeeded" ;; + *) warn "the gateway replied but not with a message. Run \`qbraid-code --doctor\`." ;; + esac ;; + 402) warn "the gateway refused the request: no credits left. Top up, then run \`qbraid-code\`." ;; + 000) warn "could not reach the gateway to verify. Everything is installed; run \`qbraid-code --doctor\` when you are online." ;; + *) warn "the gateway returned HTTP $SMOKE_STATUS on the test request. Run \`qbraid-code --doctor\`." ;; esac # ---------------------------------------------------------------- 12. finish @@ -469,7 +592,7 @@ cat <&2 echo "Install it with: curl -fsSL https://qbraid.com/code.sh | bash" >&2 exit 1 -} +fi # shellcheck disable=SC1091 . "$HOME_DIR/env" -BASE_URL="$QBRAID_CODE_BASE_URL" -API_BASE="$QBRAID_CODE_API_BASE" -TOKEN="$QBRAID_CODE_TOKEN" -MODEL="$QBRAID_CODE_MODEL" +# Defaulted rather than required, so a truncated env file produces a diagnosis +# from --doctor instead of an unbound-variable crash before the dispatch runs. +BASE_URL="${QBRAID_CODE_BASE_URL:-}" +API_BASE="${QBRAID_CODE_API_BASE:-}" +TOKEN="${QBRAID_CODE_TOKEN:-}" +MODEL="${QBRAID_CODE_MODEL:-}" -balance() { - curl -fsS -m 20 "$API_BASE/billing/credits/balance" -H "X-API-Key: $TOKEN" +missing_keys() { + local missing="" + [ -n "$BASE_URL" ] || missing="$missing QBRAID_CODE_BASE_URL" + [ -n "$API_BASE" ] || missing="$missing QBRAID_CODE_API_BASE" + [ -n "$TOKEN" ] || missing="$missing QBRAID_CODE_TOKEN" + [ -n "$MODEL" ] || missing="$missing QBRAID_CODE_MODEL" + printf '%s' "$missing" } case "${1:-}" in @@ -27,16 +38,48 @@ case "${1:-}" in && echo "claude: $(claude --version 2>/dev/null || echo present)" \ || echo "claude: NOT INSTALLED" - if BODY=$(balance 2>/dev/null); then - CREDITS=$(printf '%s' "$BODY" | grep -o '"qbraidCredits":-\?[0-9.]*' | head -1 | sed 's/.*://') - echo "key: valid" - echo "credits: ${CREDITS:-unknown}" + MISSING=$(missing_keys) + if [ -n "$MISSING" ]; then + echo "config: INCOMPLETE — missing:$MISSING" + echo " re-run: curl -fsSL https://qbraid.com/code.sh | bash" + else + echo "config: $HOME_DIR/env" + fi + + if [ -n "$TOKEN" ] && [ -n "$API_BASE" ]; then + TMP=$(mktemp) + # Separate transport failure from rejection. Reporting "REJECTED" for a + # dropped connection sent people off to make a new key for no reason. + STATUS=$(curl -sS -m 20 -o "$TMP" -w '%{http_code}' \ + "$API_BASE/billing/credits/balance" -H "X-API-Key: $TOKEN" 2>/dev/null) || STATUS="000" + BODY=$(cat "$TMP"); rm -f "$TMP" + case "$STATUS" in + 200) + RAW=$(printf '%s' "$BODY" | grep -o '"qbraidCredits":-\?[0-9.]*' | head -1 | sed 's/.*://') + CREDITS=$(awk -v c="$RAW" 'BEGIN { printf "%.0f", c }' 2>/dev/null) || CREDITS="$RAW" + echo "key: valid" + echo "credits: ${CREDITS:-unknown}" + ;; + 401|403) + echo "key: REJECTED — make a new one at https://account.qbraid.com/account/api-keys" + echo "credits: unknown" + ;; + 000) + echo "key: UNKNOWN — could not reach qBraid (check your connection)" + echo "credits: unknown" + ;; + *) + echo "key: UNKNOWN — qBraid returned HTTP $STATUS" + echo "credits: unknown" + ;; + esac else - echo "key: REJECTED — make a new one at https://account.qbraid.com/account/api-keys" + echo "key: not configured" echo "credits: unknown" fi - if curl -fsS -m 20 -o /dev/null "$BASE_URL/v1/models" -H "X-API-Key: $TOKEN" 2>/dev/null; then + if [ -n "$BASE_URL" ] \ + && curl -fsS -m 20 -o /dev/null "$BASE_URL/v1/models" -H "X-API-Key: $TOKEN" 2>/dev/null; then echo "gateway: reachable" else echo "gateway: UNREACHABLE" @@ -48,7 +91,7 @@ case "${1:-}" in echo "mcp: NOT REGISTERED" fi - echo "model: $MODEL" + echo "model: ${MODEL:-not set}" exit 0 ;; --help|-h) cat <&2 + echo "Re-run the installer: curl -fsSL https://qbraid.com/code.sh | bash" >&2 + exit 1 +fi + command -v claude >/dev/null 2>&1 || { echo "qbraid-code: Claude Code is not installed." >&2 echo "Re-run the installer: curl -fsSL https://qbraid.com/code.sh | bash" >&2 @@ -74,10 +124,9 @@ command -v claude >/dev/null 2>&1 || { } # ANTHROPIC_AUTH_TOKEN sends `Authorization: Bearer `, which the gateway -# accepts (qbraid-api apikey-strategy.service.ts claims `Bearer qbr_...`). -# ANTHROPIC_API_KEY would work too, but it makes Claude Code ask the user to -# approve a custom API key on first run — a prompt with no good answer for -# someone who just wants to start working. +# accepts. ANTHROPIC_API_KEY would work too, but it makes Claude Code ask the +# user to approve a custom API key on first run — a prompt with no good answer +# for someone who just wants to start working. ANTHROPIC_BASE_URL="$BASE_URL" \ ANTHROPIC_AUTH_TOKEN="$TOKEN" \ ANTHROPIC_MODEL="$MODEL" \ diff --git a/qbraid-code.cmd b/qbraid-code.cmd index 6446886..7073698 100644 --- a/qbraid-code.cmd +++ b/qbraid-code.cmd @@ -1,12 +1,16 @@ @echo off -rem qbraid-code — Claude Code, powered by the qBraid AI gateway. +rem qbraid-code - Claude Code, powered by the qBraid AI gateway. rem Installed by install.ps1; reads its settings from %USERPROFILE%\.qbraid-code\env. rem rem This is a .cmd rather than a PowerShell function on purpose: a .cmd on PATH rem works from cmd.exe, PowerShell and Windows Terminal with no profile edit and rem no execution-policy change, which are the two things that fail quietly for rem someone who just wants to start working. -setlocal EnableExtensions +rem +rem EnableDelayedExpansion is required: %ERRORLEVEL% inside a parenthesised +rem block is substituted when the block is PARSED, before the command in it has +rem run, so `exit /b %ERRORLEVEL%` there always reports the old value. +setlocal EnableExtensions EnableDelayedExpansion set "QC_HOME=%USERPROFILE%\.qbraid-code" if defined QBRAID_CODE_HOME set "QC_HOME=%QBRAID_CODE_HOME%" @@ -17,16 +21,24 @@ if not exist "%QC_HOME%\env" ( exit /b 1 ) +set "QBRAID_CODE_BASE_URL=" +set "QBRAID_CODE_API_BASE=" +set "QBRAID_CODE_TOKEN=" +set "QBRAID_CODE_MODEL=" for /f "usebackq eol=# tokens=1,* delims==" %%a in ("%QC_HOME%\env") do set "%%a=%%b" if /i "%~1"=="--doctor" ( powershell -NoProfile -ExecutionPolicy Bypass -File "%QC_HOME%\doctor.ps1" - exit /b %ERRORLEVEL% + exit /b !ERRORLEVEL! ) if /i "%~1"=="--help" goto :help if /i "%~1"=="-h" goto :help +if not defined QBRAID_CODE_TOKEN goto :incomplete +if not defined QBRAID_CODE_BASE_URL goto :incomplete +if not defined QBRAID_CODE_MODEL goto :incomplete + where claude >nul 2>&1 if errorlevel 1 ( echo qbraid-code: Claude Code is not installed. 1>&2 @@ -36,7 +48,7 @@ if errorlevel 1 ( rem ANTHROPIC_AUTH_TOKEN sends `Authorization: Bearer `, which the gateway rem accepts. ANTHROPIC_API_KEY would work too, but it makes Claude Code ask the -rem user to approve a custom API key on first run — a prompt with no good answer +rem user to approve a custom API key on first run - a prompt with no good answer rem for someone who just wants to start working. set "ANTHROPIC_BASE_URL=%QBRAID_CODE_BASE_URL%" set "ANTHROPIC_AUTH_TOKEN=%QBRAID_CODE_TOKEN%" @@ -45,7 +57,12 @@ set "ANTHROPIC_SMALL_FAST_MODEL=%QBRAID_CODE_MODEL%" set "CLAUDE_CODE_SUBAGENT_MODEL=%QBRAID_CODE_MODEL%" claude %* -exit /b %ERRORLEVEL% +exit /b !ERRORLEVEL! + +:incomplete +echo qbraid-code: "%QC_HOME%\env" is incomplete. 1>&2 +echo Re-run the installer: irm https://qbraid.com/code.ps1 ^| iex 1>&2 +exit /b 1 :help echo qbraid-code - Claude Code, powered by the qBraid AI gateway. diff --git a/statusline.ps1 b/statusline.ps1 index a20687f..b91b1b8 100644 --- a/statusline.ps1 +++ b/statusline.ps1 @@ -7,13 +7,17 @@ stdin. It must never block: the credit balance is served from a short-lived cache and refreshed in a detached process, so a slow network costs nothing. #> -# No Set-StrictMode here on purpose: it prohibits references to non-existent -# properties, and a session payload that omits `model` or `context_window` -# would then throw on every keystroke. This script must always render something. +# Get-Nested probes PSObject.Properties rather than dereferencing, so StrictMode +# is safe here and is enabled for consistency with install.ps1 and doctor.ps1. +Set-StrictMode -Version Latest $ErrorActionPreference = 'SilentlyContinue' $HomeDir = if ($env:QBRAID_CODE_HOME) { $env:QBRAID_CODE_HOME } else { Join-Path $env:USERPROFILE '.qbraid-code' } $Cache = Join-Path $HomeDir 'credits.cache' +# Stamped before each refresh ATTEMPT, not after a success. Without it a failing +# balance call leaves the cache untouched, so every render decides a refresh is +# due and starts another powershell.exe — several a second, all session. +$Attempt = Join-Path $HomeDir 'credits.attempt' $Ttl = 60 $apiBase = 'https://api-v2.qbraid.com/api/v1' @@ -88,24 +92,42 @@ if ($null -ne $remaining) { function Start-CreditRefresh { if (-not $token) { return } - $script = @" -`$b = Invoke-RestMethod -Uri '$apiBase/billing/credits/balance' -Headers @{ 'X-API-Key' = '$token' } -TimeoutSec 15 -if (`$b.data.qbraidCredits -ne `$null) { - Set-Content -Path '$Cache' -Value ([string]`$b.data.qbraidCredits) -Encoding ASCII + # Stamp first: a refresh that fails must still back off for $Ttl seconds. + Set-Content -Path $Attempt -Value ([string](Get-Date -UFormat %s)) -Encoding ASCII + + # The child reads the credential out of the env file itself. Passing it in + # -ArgumentList would put a live API key on a process command line, which + # every user on the machine can read from the process list. + $envFile = Join-Path $HomeDir 'env' + $script = @' +$home_dir = $env:QC_ENV_FILE +$base = ''; $tok = '' +foreach ($line in Get-Content $home_dir) { + if ($line -match '^\s*QBRAID_CODE_API_BASE\s*=\s*(.*)$') { $base = $Matches[1] } + if ($line -match '^\s*QBRAID_CODE_TOKEN\s*=\s*(.*)$') { $tok = $Matches[1] } } -"@ +if (-not $tok) { exit } +try { + $b = Invoke-RestMethod -Uri "$base/billing/credits/balance" -Headers @{ 'X-API-Key' = $tok } -TimeoutSec 15 + if ($null -ne $b.data.qbraidCredits) { + Set-Content -Path $env:QC_CACHE -Value ([string]$b.data.qbraidCredits) -Encoding ASCII + } +} catch { } +'@ + $env:QC_ENV_FILE = $envFile + $env:QC_CACHE = $Cache Start-Process -FilePath 'powershell' -WindowStyle Hidden ` -ArgumentList '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', $script } $credits = $null -if (Test-Path $Cache) { - $credits = (Get-Content $Cache -Raw).Trim() - $age = ((Get-Date) - (Get-Item $Cache).LastWriteTime).TotalSeconds - if ($age -ge $Ttl) { Start-CreditRefresh } -} else { - Start-CreditRefresh +if (Test-Path $Cache) { $credits = (Get-Content $Cache -Raw).Trim() } + +$due = $true +if (Test-Path $Attempt) { + $due = ((Get-Date) - (Get-Item $Attempt).LastWriteTime).TotalSeconds -ge $Ttl } +if ($due) { Start-CreditRefresh } $creditSeg = '' if ($credits) { diff --git a/statusline.sh b/statusline.sh index 8024dec..5f5a4c1 100755 --- a/statusline.sh +++ b/statusline.sh @@ -8,6 +8,11 @@ set -uo pipefail HOME_DIR="${QBRAID_CODE_HOME:-$HOME/.qbraid-code}" CACHE="$HOME_DIR/credits.cache" +# Stamped before each refresh ATTEMPT, not after a success. Without it a +# failing balance call (revoked key, no network) leaves the cache untouched, +# so every render decides a refresh is due and spawns another background +# curl — several per second, for the whole session. +ATTEMPT="$HOME_DIR/credits.attempt" TTL=60 [ -f "$HOME_DIR/env" ] && . "$HOME_DIR/env" @@ -62,9 +67,23 @@ fi # ----------------------------------------------------------------- credits +# `stat -f %m` is BSD. On GNU coreutils `-f` means --file-system, so the +# command FAILS but still prints a filesystem block to stdout — the exit +# status alone does not tell you it went wrong. Try the GNU form first and +# reject any answer that is not a plain integer. +file_age() { # file_age — seconds since last modification + local mtime="" + mtime=$(stat -c %Y "$1" 2>/dev/null) || mtime=$(stat -f %m "$1" 2>/dev/null) || return 1 + case "$mtime" in + ''|*[!0-9]*) return 1 ;; + esac + echo $(( $(date +%s) - mtime )) +} + # Refresh out of band so the prompt never waits on the network. refresh_credits() { [ -n "$TOKEN" ] || return 0 + : > "$ATTEMPT" 2>/dev/null || return 0 ( body=$(curl -fsS -m 15 "$API_BASE/billing/credits/balance" -H "X-API-Key: $TOKEN" 2>/dev/null) || exit 0 value=$(printf '%s' "$body" | grep -o '"qbraidCredits":-\?[0-9.]*' | head -1 | sed 's/.*://') @@ -73,18 +92,11 @@ refresh_credits() { ) >/dev/null 2>&1 & } -file_age() { # file_age — seconds since last modification - local mtime - mtime=$(stat -f %m "$1" 2>/dev/null || stat -c %Y "$1" 2>/dev/null) || return 1 - echo $(( $(date +%s) - mtime )) -} - credits="" -if [ -s "$CACHE" ]; then - credits=$(cat "$CACHE" 2>/dev/null) - age=$(file_age "$CACHE" 2>/dev/null || echo "$((TTL + 1))") - [ "$age" -ge "$TTL" ] && refresh_credits -else +[ -s "$CACHE" ] && credits=$(cat "$CACHE" 2>/dev/null) + +age=$(file_age "$ATTEMPT" 2>/dev/null) || age="" +if [ -z "$age" ] || [ "$age" -ge "$TTL" ]; then refresh_credits fi @@ -104,4 +116,7 @@ sep="${dim} │ ${rst}" out="$place${sep}${dim}${model}${rst}" [ -n "$bar" ] && out="${out}${sep}${bar}" [ -n "$credit_seg" ] && out="${out}${sep}${credit_seg}" -printf '%b\n' "$out" +# %s, not %b: the directory name comes from the session payload and must not +# have backslash escapes re-interpreted into terminal control sequences. The +# colours above are already literal ESC bytes and are unaffected. +printf '%s\n' "$out" diff --git a/tests/statusline.sh b/tests/statusline.sh new file mode 100755 index 0000000..cbb9bf9 --- /dev/null +++ b/tests/statusline.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Renders statusline.sh against fixture payloads and asserts each segment. +# +# These are the cases that broke in review and could only be caught by running +# the thing: a BSD-only `stat` flavour that froze the balance on Linux, a zero +# balance, and payloads missing fields. +set -uo pipefail +cd "$(dirname "$0")/.." || exit 1 + +TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT +export QBRAID_CODE_HOME="$TMP" + +# Token deliberately empty: no test may touch the network. +cat > "$TMP/env" <<'EOF' +QBRAID_CODE_BASE_URL=https://example.invalid/api/v1/ai +QBRAID_CODE_API_BASE=https://example.invalid/api/v1 +QBRAID_CODE_TOKEN= +QBRAID_CODE_MODEL=claude-opus-5 +EOF + +pass=0; fail=0 +render() { printf '%s' "$1" | bash statusline.sh 2>/dev/null; } +# Colour resets sit between segments, so assertions match the plain text. +strip_ansi() { sed $'s/\033\[[0-9;]*m//g'; } +check() { # check + local out + out=$(render "$2" | strip_ansi) + if printf '%s' "$out" | grep -qE "$3"; then + pass=$((pass + 1)); printf ' ok %s\n' "$1" + else + fail=$((fail + 1)); printf ' FAIL %s\n got: %s\n' "$1" "$out" + fi +} + +FULL='{"model":{"display_name":"Claude Opus 5"},"workspace":{"current_dir":"/tmp"},"context_window":{"remaining_percentage":87}}' + +check "model name renders" "$FULL" 'Claude Opus 5' +check "context 87 pct -> C13" "$FULL" 'C13' +check "one filled block" "$FULL" '█░░░░░' +check "100 pct remaining -> C0" '{"context_window":{"remaining_percentage":100},"workspace":{"current_dir":"/tmp"}}' 'C0' +check "5 pct remaining -> C95" '{"context_window":{"remaining_percentage":5},"workspace":{"current_dir":"/tmp"}}' 'C95' +check "full bar at 5 pct" '{"context_window":{"remaining_percentage":5},"workspace":{"current_dir":"/tmp"}}' '██████' +check "missing context_window" '{"model":{"display_name":"Claude Opus 5"},"workspace":{"current_dir":"/tmp"}}' 'Claude Opus 5' +check "empty stdin renders" '' '.' + +# A directory name carrying a backslash escape must be printed literally, never +# re-interpreted into a terminal control sequence (printf %s, not %b). +ESC_PAYLOAD='{"workspace":{"current_dir":"/tmp/x\u001b[31mRED"},"context_window":{"remaining_percentage":50}}' +esc_out=$(render "$ESC_PAYLOAD" | strip_ansi) +if printf '%s' "$esc_out" | grep -q 'u001b'; then + pass=$((pass + 1)); printf ' ok escape sequence not interpreted\n' +else + fail=$((fail + 1)); printf ' FAIL escape sequence was interpreted\n got: %s\n' "$esc_out" +fi + +# Zero must render as a number, not vanish or read as "unknown". +printf '0' > "$TMP/credits.cache" +check "zero credits renders" "$FULL" '0 credits' + +printf '4281.4' > "$TMP/credits.cache" +check "credits are rounded" "$FULL" '4281 credits' + +# file_age must work on THIS platform. `stat -f %m` is BSD-only and on GNU +# coreutils fails while still printing to stdout, which froze the balance +# forever. With a fresh attempt stamp no refresh is due; if file_age is broken +# the script cannot tell, so assert it reads back a sane age. +: > "$TMP/credits.attempt" +age_probe=$( + # shellcheck disable=SC1090 + mtime=$(stat -c %Y "$TMP/credits.attempt" 2>/dev/null) \ + || mtime=$(stat -f %m "$TMP/credits.attempt" 2>/dev/null) \ + || mtime="" + case "$mtime" in + ''|*[!0-9]*) echo "BAD" ;; + *) echo $(( $(date +%s) - mtime )) ;; + esac +) +if [ "$age_probe" != BAD ] && [ "$age_probe" -lt 5 ] 2>/dev/null; then + pass=$((pass + 1)); printf ' ok file mtime readable on %s (age %ss)\n' "$(uname -s)" "$age_probe" +else + fail=$((fail + 1)); printf ' FAIL file mtime unreadable on %s: %s\n' "$(uname -s)" "$age_probe" +fi + +printf '\n%d passed, %d failed\n' "$pass" "$fail" +[ "$fail" -eq 0 ] From 9c2a32cc72b3e81b4f119f986865a3df92d60560 Mon Sep 17 00:00:00 2001 From: BeLazy167 Date: Thu, 20 Aug 2026 14:08:24 -0500 Subject: [PATCH 3/5] Reset the cached balance on install, and document the trust model Re-running with a key from a different organization kept rendering the old organization's wallet until the cache expired. The README now states plainly that the installer is fetched from an unpinned `main` with no signature, which makes branch protection load-bearing, and shows the fetch-then-read form for anyone who wants it. --- README.md | 15 +++++++++++++++ install.sh | 4 ++++ 2 files changed, 19 insertions(+) diff --git a/README.md b/README.md index edef9ea..405f6cb 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,21 @@ Line Tools on macOS, or no `python3` on Linux) and `~/.claude/settings.json` already exists, the installer cannot merge JSON safely and skips this step. It prints the snippet to add by hand. +## Trust + +The installer is served from `qbraid.com` but its source of truth is the `main` +branch of this repository, fetched at request time with no pin and no signature. +Anyone who can push here can change what a `curl | bash` runs, which is the +normal trade-off for a one-line installer that must stay current. Two things +follow from that: branch protection on `main` is load-bearing, and if you would +rather read before you run, fetch the script and inspect it first: + +```bash +curl -fsSL https://qbraid.com/code.sh -o install.sh +less install.sh +bash install.sh +``` + ## Uninstall ```bash diff --git a/install.sh b/install.sh index c2ca61e..482f450 100755 --- a/install.sh +++ b/install.sh @@ -364,6 +364,10 @@ QBRAID_CODE_MODEL=$MODEL EOF chmod 600 "$HOME_DIR/env" umask "$OLD_UMASK" +# The cached balance belongs to whichever key was configured before. Re-running +# with a key from a different organization must not keep rendering the old +# organization's wallet. +rm -f "$HOME_DIR/credits.cache" "$HOME_DIR/credits.attempt" ok "config written to $HOME_DIR/env" # ------------------------------------------------- 7. launcher and statusline From 31d953819da1339bd4696716692502565d16bd58 Mon Sep 17 00:00:00 2001 From: BeLazy167 Date: Thu, 20 Aug 2026 14:18:01 -0500 Subject: [PATCH 4/5] Make the statusline tests parse under bash 3.2 macOS still ships bash 3.2, which cannot parse `case ... esac` inside a command substitution. Moved the mtime probe into a function. Caught by the macos-latest leg of the new CI matrix, which is what it is for. --- tests/statusline.sh | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/tests/statusline.sh b/tests/statusline.sh index cbb9bf9..e005aaf 100755 --- a/tests/statusline.sh +++ b/tests/statusline.sh @@ -65,20 +65,22 @@ check "credits are rounded" "$FULL" '4281 credits' # forever. With a fresh attempt stamp no refresh is due; if file_age is broken # the script cannot tell, so assert it reads back a sane age. : > "$TMP/credits.attempt" -age_probe=$( - # shellcheck disable=SC1090 - mtime=$(stat -c %Y "$TMP/credits.attempt" 2>/dev/null) \ - || mtime=$(stat -f %m "$TMP/credits.attempt" 2>/dev/null) \ - || mtime="" - case "$mtime" in - ''|*[!0-9]*) echo "BAD" ;; - *) echo $(( $(date +%s) - mtime )) ;; +# Defined as a function, not inlined into $( ): bash 3.2 (which macOS still +# ships) cannot parse `case ... esac` inside a command substitution. +probe_mtime() { + local m="" + m=$(stat -c %Y "$1" 2>/dev/null) || m=$(stat -f %m "$1" 2>/dev/null) || return 1 + [ -n "$m" ] || return 1 + case "$m" in + *[!0-9]*) return 1 ;; esac -) -if [ "$age_probe" != BAD ] && [ "$age_probe" -lt 5 ] 2>/dev/null; then + echo $(( $(date +%s) - m )) +} + +if age_probe=$(probe_mtime "$TMP/credits.attempt") && [ "$age_probe" -lt 5 ]; then pass=$((pass + 1)); printf ' ok file mtime readable on %s (age %ss)\n' "$(uname -s)" "$age_probe" else - fail=$((fail + 1)); printf ' FAIL file mtime unreadable on %s: %s\n' "$(uname -s)" "$age_probe" + fail=$((fail + 1)); printf ' FAIL file mtime unreadable on %s\n' "$(uname -s)" fi printf '\n%d passed, %d failed\n' "$pass" "$fail" From ca7a68ac5b95c48e561c409c5b03c4767ecc4378 Mon Sep 17 00:00:00 2001 From: BeLazy167 Date: Thu, 20 Aug 2026 14:19:37 -0500 Subject: [PATCH 5/5] Reset ERRORLEVEL in the Windows launcher CI check The check asserted correctly and printed its success line, but `echo` does not reset ERRORLEVEL in cmd, so the expected exit 1 from the launcher leaked out and failed the step. --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c63f3c6..a376ff1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,3 +72,6 @@ jobs: call qbraid-code.cmd --help if not errorlevel 1 exit /b 1 echo missing-install path reports an error + rem `echo` does not reset ERRORLEVEL in cmd, so the expected 1 would + rem otherwise leak out and fail the step. + exit /b 0