Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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/providerMaintenanceRunner.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 @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/provider-core/src/server/maintenance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
167 changes: 166 additions & 1 deletion packages/provider-core/src/server/maintenanceResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@ import {
homebrewOwnershipFromCommandPath,
invalidateLatestProviderVersion,
makeCachedProviderMaintenanceResolution,
makeManualOnlyProviderMaintenanceCapabilities,
makePackageManagedProviderMaintenanceResolver,
makeProviderMaintenanceCapabilities,
normalizeCommandPath,
npmGlobalPrefixFromCommandPath,
parseChocolateyLatestVersion,
parseHomebrewLatestVersion,
resolveLatestProviderVersion,
resolvePackageManagedProviderMaintenance,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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<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, 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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"),
Expand Down
73 changes: 69 additions & 4 deletions packages/provider-core/src/server/maintenanceResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,8 @@ export function makeManualOnlyProviderMaintenanceCapabilities(input: {
readonly provider: ProviderDriverKind;
readonly packageName: string | null;
readonly installPath?: string;
readonly lookupLatestVersion?: Effect.Effect<string | null>;
readonly manualCommand?: string;
}): ProviderMaintenanceCapabilities {
return {
...makeProviderMaintenanceCapabilities({
Expand All @@ -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 } : {}),
};
}

Expand Down Expand Up @@ -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 `<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 @@ -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<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 nativeEnv = nativeUpdate?.env;
Expand Down Expand Up @@ -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,
Expand All @@ -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}`;
}

Expand Down
Loading