Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/internals/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/providerMaintenance.ts).

Ownership is cached per instance and re-read immediately before an update runs. The
Expand Down
7 changes: 4 additions & 3 deletions docs/user/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,9 +161,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
Expand Down
4 changes: 4 additions & 0 deletions packages/provider-core/src/server/maintenance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ export interface ProviderMaintenanceCapabilities {
* install to update.
*/
readonly installPath?: string;
/**
* A command T3 Code shows but cannot run.
*/
readonly manualCommand?: string;
}

export interface ProviderMaintenanceCommandAction {
Expand Down
151 changes: 150 additions & 1 deletion packages/provider-core/src/server/maintenanceResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,12 @@ import {
homebrewOwnershipFromCommandPath,
invalidateLatestProviderVersion,
makeCachedProviderMaintenanceResolution,
makeManualOnlyProviderMaintenanceCapabilities,
makePackageManagedProviderMaintenanceResolver,
makeProviderMaintenanceCapabilities,
normalizeCommandPath,
npmGlobalPrefixFromCommandPath,
parseChocolateyLatestVersion,
parseHomebrewLatestVersion,
resolveLatestProviderVersion,
resolvePackageManagedProviderMaintenance,
Expand Down Expand Up @@ -254,6 +256,26 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
});
});

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",
Expand Down Expand Up @@ -390,12 +412,13 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
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"),
Expand Down Expand Up @@ -428,6 +451,119 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
}),
);

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" },
},
] 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<string> = [];
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) => {
probes.push(command);
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",
]);
}).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",
Expand Down Expand Up @@ -460,6 +596,12 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
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,
Expand Down Expand Up @@ -863,6 +1005,13 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
}),
);

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"),
Expand Down
85 changes: 80 additions & 5 deletions packages/provider-core/src/server/maintenanceResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,13 @@ export function makeManualOnlyProviderMaintenanceCapabilities(input: {
readonly provider: ProviderDriverKind;
readonly packageName: string | null;
readonly installPath?: string;
/**
* The owning installer's own release, when it publishes behind npm. Without
* it the npm registry is authoritative, and the advisory would keep asking
* for a version the installer cannot provide yet.
*/
readonly lookupLatestVersion?: Effect.Effect<string | null>;
readonly manualCommand?: string;
}): ProviderMaintenanceCapabilities {
return {
...makeProviderMaintenanceCapabilities({
Expand All @@ -202,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 } : {}),
};
}

Expand Down Expand Up @@ -248,6 +259,38 @@ function isWinGetClaudeCommandPath(commandPath: string): boolean {
return winGetClaudeCommandPath.test(normalizeCommandPath(commandPath));
}

const CHOCOLATEY_CLAUDE_PACKAGE_ID = "claude-code";

/**
* Chocolatey's Claude Code package: the shimgen shim in `<root>\bin` or the
* binary it launches from `<root>\lib\claude-code\tools`. The default root's
* directory is named `chocolatey`. A custom root is not, so those paths are
* compared with `ChocolateyInstall`. The shim is a real executable rather than
* a link, so following symlinks never reaches the package directory.
*/
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 `<id>|<version>` 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 (
Expand Down Expand Up @@ -450,6 +493,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<ProviderMaintenanceResolverServices>();
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 native = nativeUpdate
Expand Down Expand Up @@ -839,7 +908,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,
Expand Down Expand Up @@ -870,12 +942,15 @@ const fetchNpmLatestVersion = Effect.fn("fetchNpmLatestVersion")(function* (pack
return payload ? nonEmptyString(payload.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 capabilities.lookupLatestVersion && update
? [update.lockKey, update.executable, ...update.args].join("\0")
: null;
if (update) return [update.lockKey, update.executable, ...update.args].join("\0");
return capabilities.installPath ? ["manual", capabilities.installPath].join("\0") : null;
}

export function latestProviderVersionCacheKeys(
Expand Down
Loading