diff --git a/docs/internals/providers.md b/docs/internals/providers.md index 42f0a70101b3a..3a31a9dac93cd 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -93,7 +93,10 @@ manual-only because their version is pinned in mise's config. A Grok cask whose cannot be verified also stays manual-only: `grok update` would install a second copy. npm updates pin `--prefix` because the `npm` on `PATH` can belong to a different Node than the one that owns the provider. Homebrew compares against `brew info` since casks trail npm by hours; native installs share npm's version -train, so the registry stays authoritative for them. +train, so the registry stays authoritative for them. A Chocolatey install of Claude stays +manual-only: its package directory is writable only by administrators, and `claude update` through +the shim installs a native copy elsewhere. The latest version comes from `choco search`, and the +card shows `choco upgrade claude-code` without running it. See the [resolver](../../apps/server/src/provider/providerMaintenanceRunner.ts). Ownership is cached per instance and re-read immediately before an update runs. The diff --git a/docs/user/install.md b/docs/user/install.md index 1e1693d0b32ee..c7ad13e02cc2e 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -185,9 +185,10 @@ When a provider CLI is behind its latest release, its provider card shows the available version. **Update now** runs the installer that owns the CLI (Homebrew, or a global npm, pnpm, Yarn, Bun, Volta, or Vite+ install), or the CLI's own update command when T3 Code cannot tell. Update a CLI installed with -mise through mise. Cursor and Antigravity update with T3 Code. Homebrew installs -compare against the version Homebrew offers, which can trail the npm release by -a few hours. +mise through mise. Cursor and Antigravity update with T3 Code. Homebrew and +Chocolatey installs compare against the version their package manager offers, +which can trail the npm release; update Claude installed with Chocolatey +through Chocolatey. Add another provider instance for a separate account or configuration. Each instance can have its own environment variables, such as API keys or a custom diff --git a/packages/provider-core/src/server/maintenance.ts b/packages/provider-core/src/server/maintenance.ts index 4e402717608b8..86341e74ac534 100644 --- a/packages/provider-core/src/server/maintenance.ts +++ b/packages/provider-core/src/server/maintenance.ts @@ -15,6 +15,8 @@ export interface ProviderMaintenanceCapabilities { readonly compareVersions?: (current: string, latest: string) => number; /** Resolved executable path for advisories when no update command is proven. */ readonly installPath?: string; + /** Displayed for manual updates; never executed by T3 Code. */ + readonly manualCommand?: string; } export interface ProviderMaintenanceCommandAction { diff --git a/packages/provider-core/src/server/maintenanceResolver.test.ts b/packages/provider-core/src/server/maintenanceResolver.test.ts index da59b77886a59..cc20da24a33ec 100644 --- a/packages/provider-core/src/server/maintenanceResolver.test.ts +++ b/packages/provider-core/src/server/maintenanceResolver.test.ts @@ -25,10 +25,12 @@ import { homebrewOwnershipFromCommandPath, invalidateLatestProviderVersion, makeCachedProviderMaintenanceResolution, + makeManualOnlyProviderMaintenanceCapabilities, makePackageManagedProviderMaintenanceResolver, makeProviderMaintenanceCapabilities, normalizeCommandPath, npmGlobalPrefixFromCommandPath, + parseChocolateyLatestVersion, parseHomebrewLatestVersion, resolveLatestProviderVersion, resolvePackageManagedProviderMaintenance, @@ -381,6 +383,26 @@ it.layer( }); }); + it("shows the Chocolatey upgrade command without offering to run it", () => { + expect( + createProviderVersionAdvisory({ + driver: driver("claudeAgent"), + currentVersion: "2.1.304", + latestVersion: "2.1.306", + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: driver("claudeAgent"), + packageName: "@anthropic-ai/claude-code", + installPath: "C:\\ProgramData\\chocolatey\\bin\\claude.exe", + manualCommand: "choco upgrade claude-code", + }), + }), + ).toMatchObject({ + canUpdate: false, + canInstallVersion: false, + updateCommand: "choco upgrade claude-code", + }); + }); + it.effect("stays manual-only when the binary cannot be located", () => resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { binaryPath: "package-tool", @@ -547,12 +569,13 @@ it.layer( readonly resolved: string; readonly real: string; readonly platform?: NodeJS.Platform; + readonly env?: NodeJS.ProcessEnv; }) => resolvePackageManagedProviderMaintenance(input.definition, { binaryPath: "claude", resolvedCommandPath: input.resolved, realCommandPath: input.real, - env: {}, + env: input.env ?? {}, platform: input.platform ?? "win32", }).pipe( Effect.provideService(HostProcess.Platform, input.platform ?? "win32"), @@ -585,6 +608,135 @@ it.layer( }), ); + const chocolateyShim = "C:\\ProgramData\\chocolatey\\bin\\claude.exe"; + const chocolateyPackage = "C:\\ProgramData\\chocolatey\\lib\\claude-code\\tools\\claude.exe"; + + it.effect.each([ + { name: "the shimgen shim", resolved: chocolateyShim, real: chocolateyShim }, + { name: "the package binary", resolved: chocolateyPackage, real: chocolateyPackage }, + { + name: "a custom root", + resolved: "D:\\Tools\\Choco\\bin\\claude.exe", + real: "D:\\Tools\\Choco\\bin\\claude.exe", + env: { ChocolateyInstall: "D:\\Tools\\Choco" }, + }, + { + name: "a custom root package", + resolved: "D:\\Tools\\Choco\\lib\\claude-code\\tools\\claude.exe", + real: "D:\\Tools\\Choco\\lib\\claude-code\\tools\\claude.exe", + env: { ChocolateyInstall: "D:\\Tools\\Choco" }, + }, + { + name: "a mixed-case custom root with a trailing separator", + resolved: "D:\\Tools\\Choco\\bin\\Claude.EXE", + real: "D:\\Tools\\Choco\\bin\\Claude.EXE", + env: { ChocolateyInstall: "d:/tools/choco/" }, + }, + { name: "a native-looking alias", resolved: nativeClaude, real: chocolateyPackage }, + ] as const)("keeps a Chocolatey Claude install manual-only through $name", (fixture) => + Effect.gen(function* () { + const capabilities = yield* resolveInstall({ definition: claudeUpdate, ...fixture }); + + expect(capabilities.update).toBeNull(); + expect(capabilities.installPath).toBe(fixture.real); + expect(capabilities.latestVersion).toBeNull(); + expect(capabilities.lookupLatestVersion).toBeDefined(); + expect(capabilities.manualCommand).toBe("choco upgrade claude-code"); + }), + ); + + it.effect("keeps a Chocolatey-shaped path off Windows on the native updater", () => + Effect.gen(function* () { + const commandPath = "/home/me/chocolatey/bin/claude.exe"; + const capabilities = yield* resolveInstall({ + definition: claudeUpdate, + resolved: commandPath, + real: commandPath, + platform: "linux", + }); + + expect(capabilities.update).toMatchObject({ lockKey: "claudeAgent-native" }); + }), + ); + + // Chocolatey publishes behind npm, so comparing against the registry would + // report an update `choco upgrade` cannot install yet. + it.effect("compares a Chocolatey Claude install against choco search", () => + Effect.gen(function* () { + const probes: Array<{ command: string; args: ReadonlyArray }> = []; + const capabilities = yield* resolvePackageManagedProviderMaintenance(claudeUpdate, { + binaryPath: "claude", + resolvedCommandPath: chocolateyShim, + realCommandPath: chocolateyShim, + env: {}, + platform: "win32", + }).pipe( + Effect.provideService(HostProcess.Platform, "win32"), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + stdoutSpawner((command, args) => { + probes.push({ command, args }); + return "claude-code|2.1.304\r\n"; + }), + ), + ); + expect(probes).toEqual([]); + + const latest = yield* resolveLatestProviderVersion(capabilities).pipe( + Effect.provide(ProviderLatestVersions.layer), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("the installer's latest should not come from npm")), + ), + ); + + expect(latest).toBe("2.1.304"); + expect(probes.map(({ command }) => NodePath.basename(command).replace(/\.\w+$/, ""))).toEqual( + ["choco"], + ); + expect(probes[0]?.args).toEqual(["search", "claude-code", "--exact", "--limit-output"]); + expect( + createProviderVersionAdvisory({ + driver: driver("claudeAgent"), + currentVersion: "2.1.304", + latestVersion: latest, + maintenanceCapabilities: capabilities, + }), + ).toMatchObject({ status: "current", canUpdate: false, canInstallVersion: false }); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("stays unknown when choco search fails instead of asking npm", () => + Effect.gen(function* () { + const capabilities = yield* resolvePackageManagedProviderMaintenance(claudeUpdate, { + binaryPath: "claude", + resolvedCommandPath: chocolateyShim, + realCommandPath: chocolateyShim, + env: {}, + platform: "win32", + }).pipe( + Effect.provideService(HostProcess.Platform, "win32"), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + stdoutSpawner( + () => "", + () => 1, + ), + ), + ); + + const latest = yield* resolveLatestProviderVersion(capabilities).pipe( + Effect.provide(ProviderLatestVersions.layer), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("a failed choco search should not fall back to npm")), + ), + ); + + expect(latest).toBeNull(); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect.each([ { name: "a native install", @@ -617,6 +769,12 @@ it.layer( resolved: userWinGetLink, real: userWinGetPackage, }, + { + name: "a claude-code tools path outside Chocolatey", + definition: claudeUpdate, + resolved: "C:\\Work\\lib\\claude-code\\tools\\claude.exe", + real: "C:\\Work\\lib\\claude-code\\tools\\claude.exe", + }, { name: "another WinGet package", definition: claudeUpdate, @@ -1052,6 +1210,13 @@ it.layer( }), ); + it("reads the Chocolatey package version from choco search --limit-output", () => { + expect(parseChocolateyLatestVersion("claude-code|2.1.304\r\n", "claude-code")).toBe("2.1.304"); + expect(parseChocolateyLatestVersion("Claude-Code|2.1.304\r\n", "claude-code")).toBe("2.1.304"); + expect(parseChocolateyLatestVersion("claude-code-other|9.9.9\r\n", "claude-code")).toBeNull(); + expect(parseChocolateyLatestVersion("", "claude-code")).toBeNull(); + }); + it("recognizes Homebrew kegs and casks from the real executable path", () => { expect( homebrewOwnershipFromCommandPath("/opt/homebrew/Cellar/claude-code@latest/2.1.0/bin/claude"), diff --git a/packages/provider-core/src/server/maintenanceResolver.ts b/packages/provider-core/src/server/maintenanceResolver.ts index 61ff6edc0d9ac..7b1a46e1dbe47 100644 --- a/packages/provider-core/src/server/maintenanceResolver.ts +++ b/packages/provider-core/src/server/maintenanceResolver.ts @@ -199,6 +199,8 @@ export function makeManualOnlyProviderMaintenanceCapabilities(input: { readonly provider: ProviderDriverKind; readonly packageName: string | null; readonly installPath?: string; + readonly lookupLatestVersion?: Effect.Effect; + readonly manualCommand?: string; }): ProviderMaintenanceCapabilities { return { ...makeProviderMaintenanceCapabilities({ @@ -207,8 +209,12 @@ export function makeManualOnlyProviderMaintenanceCapabilities(input: { updateExecutable: null, updateArgs: [], updateLockKey: null, + ...(input.lookupLatestVersion + ? { latestVersion: null, lookupLatestVersion: input.lookupLatestVersion } + : {}), }), ...(input.installPath ? { installPath: input.installPath } : {}), + ...(input.manualCommand ? { manualCommand: input.manualCommand } : {}), }; } @@ -248,6 +254,32 @@ function isWinGetClaudeCommandPath(commandPath: string): boolean { return winGetClaudeCommandPath.test(normalizeCommandPath(commandPath)); } +const CHOCOLATEY_CLAUDE_PACKAGE_ID = "claude-code"; + +// Chocolatey's executable shim is not a symlink, so realpath cannot prove its owner. +const chocolateyClaudeCommandPath = /\/chocolatey\/(?:bin|lib\/claude-code\/tools)\/claude\.exe$/; + +function isChocolateyClaudeCommandPath(commandPath: string, env: NodeJS.ProcessEnv): boolean { + const normalized = normalizeCommandPath(commandPath); + if (chocolateyClaudeCommandPath.test(normalized)) return true; + const root = nonEmptyString(env.ChocolateyInstall); + if (!root) return false; + const prefix = normalizeCommandPath(root.replace(/[\\/]+$/, "")); + return ( + normalized === `${prefix}/bin/claude.exe` || + normalized === `${prefix}/lib/claude-code/tools/claude.exe` + ); +} + +/** `choco search --exact --limit-output` prints `|` for a match and nothing otherwise. */ +export function parseChocolateyLatestVersion(output: string, packageId: string): string | null { + for (const line of output.split(/\r?\n/)) { + const [id, version] = line.trim().split("|", 2); + if (id?.toLowerCase() === packageId.toLowerCase()) return nonEmptyString(version); + } + return null; +} + function isPnpmGlobalCommandPath(commandPath: string): boolean { const normalized = normalizeCommandPath(commandPath); return ( @@ -447,6 +479,32 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( ) { return manual; } + // Chocolatey's package directory is writable only by administrators, so an + // unelevated server cannot run `choco upgrade`; `claude update` through the + // shim installs a native copy elsewhere and leaves the shim's version alone. + if ( + definition.provider === "claudeAgent" && + context.platform === "win32" && + commandPaths.some((commandPath) => isChocolateyClaudeCommandPath(commandPath, context.env)) + ) { + const services = yield* Effect.context(); + return makeManualOnlyProviderMaintenanceCapabilities({ + provider: definition.provider, + packageName, + installPath: context.realCommandPath, + manualCommand: "choco upgrade claude-code", + lookupLatestVersion: runProbe( + "choco", + ["search", CHOCOLATEY_CLAUDE_PACKAGE_ID, "--exact", "--limit-output"], + context.env, + ).pipe( + Effect.map((output) => + output ? parseChocolateyLatestVersion(output, CHOCOLATEY_CLAUDE_PACKAGE_ID) : null, + ), + Effect.provideContext(services), + ), + }); + } const nativeUpdate = definition.nativeUpdate; const nativeEnv = nativeUpdate?.env; @@ -872,7 +930,10 @@ export function createProviderVersionAdvisory(input: { status: advisory.status, currentVersion: input.currentVersion, latestVersion, - updateCommand: resolveLatestProviderUpdateAction(capabilities, latestVersion)?.command ?? null, + updateCommand: + resolveLatestProviderUpdateAction(capabilities, latestVersion)?.command ?? + capabilities.manualCommand ?? + null, canUpdate: capabilities.update !== null, canInstallVersion: makeTargetedProviderUpdateAction(capabilities, "0.0.0") !== null, checkedAt: input.checkedAt ?? null, @@ -895,12 +956,16 @@ const fetchNpmLatestVersion = Effect.fn("fetchNpmLatestVersion")(function* (pack return Option.isSome(payload) ? nonEmptyString(payload.value.version) : null; }); -/** The installer's own answer is cached apart from the npm registry entry. */ +/** + * The installer's own answer is cached apart from the npm registry entry. A + * manual-only install has no command to key on, so its executable stands in. + */ function installerLatestVersionKey(capabilities: ProviderMaintenanceCapabilities) { if (!capabilities.lookupLatestVersion) return null; const update = capabilities.update; - return update - ? [update.lockKey, update.executable, ...update.args].join("\0") + if (update) return [update.lockKey, update.executable, ...update.args].join("\0"); + return capabilities.installPath + ? ["manual", capabilities.installPath].join("\0") : capabilities.packageName && `npm-registry\0${capabilities.packageName}`; }