← back to Kickbacks Ai Upstream

src/adapters/claude-cli/cliVersion.ts

93 lines

/** Detect the terminal `claude` CLI version so we only write the
 *  `spinnerVerbs` settings.json override on builds that actually honour it.
 *
 *  spinnerVerbs support landed in CC 2.1.143 (decoded from the Bun-compiled
 *  binary: a Zod schema read via the iTH() selector). On older builds the
 *  key is silently ignored and CC shows its stock verb pool — writing it
 *  there is harmless but leaves a dead key in the user's settings.json AND
 *  would make us count a "spinner" impression for an ad that never rendered,
 *  so we gate on it.
 *
 *  NOTE: this is the CLI version, NOT the VS Code Claude Code webview
 *  extension version (the `ccVersion` from the webview adapter preflight).
 *  The two installs are independent and can differ — the spinnerVerbs key
 *  is read by the terminal CLI, so the terminal CLI's version is the one
 *  that matters. */
import { execFile } from "node:child_process";

export type SemVer = [number, number, number];

/** spinnerVerbs support floor: Claude Code 2.1.143. */
export const SPINNER_VERBS_FLOOR: SemVer = [2, 1, 143];

/** Parse a `claude --version` line like "2.1.158 (Claude Code)" into a
 *  [major, minor, patch] tuple, or null if no semver is present. */
export function parseClaudeCliVersion(stdout: string): SemVer | null {
  const m = /(\d+)\.(\d+)\.(\d+)/.exec(stdout);
  if (!m) return null;
  return [Number(m[1]), Number(m[2]), Number(m[3])];
}

/** True iff `a` >= `b` under semver ordering (major, then minor, then patch). */
export function gte(a: SemVer, b: SemVer): boolean {
  for (let i = 0; i < 3; i++) {
    if (a[i] !== b[i]) return a[i] > b[i];
  }
  return true;
}

/** True iff this CLI version honours the spinnerVerbs settings key. A null
 *  version (unparseable output) is treated as UNsupported here; callers that
 *  want fail-open behaviour on a failed *spawn* apply that policy themselves
 *  (see detectClaudeCliSpinnerSupport). */
export function supportsSpinnerVerbs(v: SemVer | null): boolean {
  return v ? gte(v, SPINNER_VERBS_FLOOR) : false;
}

/** Spawn `claude --version` (PATH-resolved) and return its parsed semver, or
 *  null if claude is absent / errors / times out / prints something we can't
 *  parse. Never throws. */
export function detectClaudeCliVersion(): Promise<SemVer | null> {
  return new Promise((res) => {
    try {
      execFile("claude", ["--version"],
        { timeout: 3000, windowsHide: true },
        (err, stdout) => {
          if (err) return res(null);
          res(parseClaudeCliVersion(String(stdout ?? "")));
        });
    } catch { res(null); }
  });
}

/** Result of resolving local-CLI spinnerVerbs support. */
export interface SpinnerSupport {
  /** Whether to write/count the spinner verb. Fail-OPEN (see below). */
  ok: boolean;
  /** The positively-detected CLI version, or null if detection failed/absent. */
  version: SemVer | null;
  /** True iff a version was POSITIVELY detected AND it is below the floor —
   *  i.e. the case worth warning the user about. Distinct from `!ok`, which is
   *  also false on a failed detection (the fail-open path). */
  outdated: boolean;
}

/** Resolve whether to write spinnerVerbs for the local CLI. Fail-OPEN: if we
 *  cannot detect a version (claude not on the extension host's PATH, spawn
 *  error, unparseable output) we assume support, because writing the key is
 *  harmless on a build that ignores it and we'd rather show the ad on a
 *  supported build that flaked detection than silently suppress it. Only a
 *  POSITIVELY detected pre-2.1.143 version turns the surface off — and the
 *  impression counter is guarded on the same flag, so a flaked detection
 *  never bills for a verb that didn't render on a genuinely old CLI (we just
 *  accept the small risk on the rare unknown-version case).
 *
 *  Returns the detected `version` and an `outdated` flag too, so callers can
 *  warn the user ONLY on a positively-detected old CLI — never on the
 *  fail-open (null) path, which would nag installs we can't even probe. */
export async function detectClaudeCliSpinnerSupport(): Promise<SpinnerSupport> {
  const version = await detectClaudeCliVersion();
  const ok = version === null ? true : supportsSpinnerVerbs(version);
  return { ok, version, outdated: version !== null && !ok };
}