Skip to content

Commit fff8a9b

Browse files
robrab2000cursoragentmaria-rcks
authored andcommitted
fix(provider-core): Chocolatey Claude installs stay manual-only and compare against choco (pingdotgg#22941)
Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: maria-rcks <maria@kuuro.net>
1 parent a3b3320 commit fff8a9b

5 files changed

Lines changed: 245 additions & 9 deletions

File tree

‎docs/internals/providers.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,10 @@ manual-only because their version is pinned in mise's config. A Grok cask whose
9393
cannot be verified also stays manual-only: `grok update` would install a second copy. npm updates pin `--prefix` because the
9494
`npm` on `PATH` can belong to a different Node than the one that owns the provider. Homebrew
9595
compares against `brew info` since casks trail npm by hours; native installs share npm's version
96-
train, so the registry stays authoritative for them.
96+
train, so the registry stays authoritative for them. A Chocolatey install of Claude stays
97+
manual-only: its package directory is writable only by administrators, and `claude update` through
98+
the shim installs a native copy elsewhere. The latest version comes from `choco search`, and the
99+
card shows `choco upgrade claude-code` without running it.
97100
See the [resolver](../../apps/server/src/provider/providerMaintenanceRunner.ts).
98101

99102
Ownership is cached per instance and re-read immediately before an update runs. The

‎docs/user/install.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -186,9 +186,10 @@ When a provider CLI is behind its latest release, its provider card shows the
186186
available version. **Update now** runs the installer that owns the CLI
187187
(Homebrew, or a global npm, pnpm, Yarn, Bun, Volta, or Vite+ install), or the
188188
CLI's own update command when T3 Code cannot tell. Update a CLI installed with
189-
mise through mise. Cursor and Antigravity update with T3 Code. Homebrew installs
190-
compare against the version Homebrew offers, which can trail the npm release by
191-
a few hours.
189+
mise through mise. Cursor and Antigravity update with T3 Code. Homebrew and
190+
Chocolatey installs compare against the version their package manager offers,
191+
which can trail the npm release; update Claude installed with Chocolatey
192+
through Chocolatey.
192193

193194
Add another provider instance for a separate account or configuration. Each
194195
instance can have its own environment variables, such as API keys or a custom

‎packages/provider-core/src/server/maintenance.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export interface ProviderMaintenanceCapabilities {
1515
readonly compareVersions?: (current: string, latest: string) => number;
1616
/** Resolved executable path for advisories when no update command is proven. */
1717
readonly installPath?: string;
18+
/** Displayed for manual updates; never executed by T3 Code. */
19+
readonly manualCommand?: string;
1820
}
1921

2022
export interface ProviderMaintenanceCommandAction {

‎packages/provider-core/src/server/maintenanceResolver.test.ts‎

Lines changed: 166 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,12 @@ import {
2525
homebrewOwnershipFromCommandPath,
2626
invalidateLatestProviderVersion,
2727
makeCachedProviderMaintenanceResolution,
28+
makeManualOnlyProviderMaintenanceCapabilities,
2829
makePackageManagedProviderMaintenanceResolver,
2930
makeProviderMaintenanceCapabilities,
3031
normalizeCommandPath,
3132
npmGlobalPrefixFromCommandPath,
33+
parseChocolateyLatestVersion,
3234
parseHomebrewLatestVersion,
3335
resolveLatestProviderVersion,
3436
resolvePackageManagedProviderMaintenance,
@@ -381,6 +383,26 @@ it.layer(
381383
});
382384
});
383385

386+
it("shows the Chocolatey upgrade command without offering to run it", () => {
387+
expect(
388+
createProviderVersionAdvisory({
389+
driver: driver("claudeAgent"),
390+
currentVersion: "2.1.304",
391+
latestVersion: "2.1.306",
392+
maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({
393+
provider: driver("claudeAgent"),
394+
packageName: "@anthropic-ai/claude-code",
395+
installPath: "C:\\ProgramData\\chocolatey\\bin\\claude.exe",
396+
manualCommand: "choco upgrade claude-code",
397+
}),
398+
}),
399+
).toMatchObject({
400+
canUpdate: false,
401+
canInstallVersion: false,
402+
updateCommand: "choco upgrade claude-code",
403+
});
404+
});
405+
384406
it.effect("stays manual-only when the binary cannot be located", () =>
385407
resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, {
386408
binaryPath: "package-tool",
@@ -547,12 +569,13 @@ it.layer(
547569
readonly resolved: string;
548570
readonly real: string;
549571
readonly platform?: NodeJS.Platform;
572+
readonly env?: NodeJS.ProcessEnv;
550573
}) =>
551574
resolvePackageManagedProviderMaintenance(input.definition, {
552575
binaryPath: "claude",
553576
resolvedCommandPath: input.resolved,
554577
realCommandPath: input.real,
555-
env: {},
578+
env: input.env ?? {},
556579
platform: input.platform ?? "win32",
557580
}).pipe(
558581
Effect.provideService(HostProcess.Platform, input.platform ?? "win32"),
@@ -585,6 +608,135 @@ it.layer(
585608
}),
586609
);
587610

611+
const chocolateyShim = "C:\\ProgramData\\chocolatey\\bin\\claude.exe";
612+
const chocolateyPackage = "C:\\ProgramData\\chocolatey\\lib\\claude-code\\tools\\claude.exe";
613+
614+
it.effect.each([
615+
{ name: "the shimgen shim", resolved: chocolateyShim, real: chocolateyShim },
616+
{ name: "the package binary", resolved: chocolateyPackage, real: chocolateyPackage },
617+
{
618+
name: "a custom root",
619+
resolved: "D:\\Tools\\Choco\\bin\\claude.exe",
620+
real: "D:\\Tools\\Choco\\bin\\claude.exe",
621+
env: { ChocolateyInstall: "D:\\Tools\\Choco" },
622+
},
623+
{
624+
name: "a custom root package",
625+
resolved: "D:\\Tools\\Choco\\lib\\claude-code\\tools\\claude.exe",
626+
real: "D:\\Tools\\Choco\\lib\\claude-code\\tools\\claude.exe",
627+
env: { ChocolateyInstall: "D:\\Tools\\Choco" },
628+
},
629+
{
630+
name: "a mixed-case custom root with a trailing separator",
631+
resolved: "D:\\Tools\\Choco\\bin\\Claude.EXE",
632+
real: "D:\\Tools\\Choco\\bin\\Claude.EXE",
633+
env: { ChocolateyInstall: "d:/tools/choco/" },
634+
},
635+
{ name: "a native-looking alias", resolved: nativeClaude, real: chocolateyPackage },
636+
] as const)("keeps a Chocolatey Claude install manual-only through $name", (fixture) =>
637+
Effect.gen(function* () {
638+
const capabilities = yield* resolveInstall({ definition: claudeUpdate, ...fixture });
639+
640+
expect(capabilities.update).toBeNull();
641+
expect(capabilities.installPath).toBe(fixture.real);
642+
expect(capabilities.latestVersion).toBeNull();
643+
expect(capabilities.lookupLatestVersion).toBeDefined();
644+
expect(capabilities.manualCommand).toBe("choco upgrade claude-code");
645+
}),
646+
);
647+
648+
it.effect("keeps a Chocolatey-shaped path off Windows on the native updater", () =>
649+
Effect.gen(function* () {
650+
const commandPath = "/home/me/chocolatey/bin/claude.exe";
651+
const capabilities = yield* resolveInstall({
652+
definition: claudeUpdate,
653+
resolved: commandPath,
654+
real: commandPath,
655+
platform: "linux",
656+
});
657+
658+
expect(capabilities.update).toMatchObject({ lockKey: "claudeAgent-native" });
659+
}),
660+
);
661+
662+
// Chocolatey publishes behind npm, so comparing against the registry would
663+
// report an update `choco upgrade` cannot install yet.
664+
it.effect("compares a Chocolatey Claude install against choco search", () =>
665+
Effect.gen(function* () {
666+
const probes: Array<{ command: string; args: ReadonlyArray<string> }> = [];
667+
const capabilities = yield* resolvePackageManagedProviderMaintenance(claudeUpdate, {
668+
binaryPath: "claude",
669+
resolvedCommandPath: chocolateyShim,
670+
realCommandPath: chocolateyShim,
671+
env: {},
672+
platform: "win32",
673+
}).pipe(
674+
Effect.provideService(HostProcess.Platform, "win32"),
675+
Effect.provideService(
676+
ChildProcessSpawner.ChildProcessSpawner,
677+
stdoutSpawner((command, args) => {
678+
probes.push({ command, args });
679+
return "claude-code|2.1.304\r\n";
680+
}),
681+
),
682+
);
683+
expect(probes).toEqual([]);
684+
685+
const latest = yield* resolveLatestProviderVersion(capabilities).pipe(
686+
Effect.provide(ProviderLatestVersions.layer),
687+
Effect.provideService(
688+
HttpClient.HttpClient,
689+
HttpClient.make(() => Effect.die("the installer's latest should not come from npm")),
690+
),
691+
);
692+
693+
expect(latest).toBe("2.1.304");
694+
expect(probes.map(({ command }) => NodePath.basename(command).replace(/\.\w+$/, ""))).toEqual(
695+
["choco"],
696+
);
697+
expect(probes[0]?.args).toEqual(["search", "claude-code", "--exact", "--limit-output"]);
698+
expect(
699+
createProviderVersionAdvisory({
700+
driver: driver("claudeAgent"),
701+
currentVersion: "2.1.304",
702+
latestVersion: latest,
703+
maintenanceCapabilities: capabilities,
704+
}),
705+
).toMatchObject({ status: "current", canUpdate: false, canInstallVersion: false });
706+
}).pipe(Effect.provide(NodeServices.layer)),
707+
);
708+
709+
it.effect("stays unknown when choco search fails instead of asking npm", () =>
710+
Effect.gen(function* () {
711+
const capabilities = yield* resolvePackageManagedProviderMaintenance(claudeUpdate, {
712+
binaryPath: "claude",
713+
resolvedCommandPath: chocolateyShim,
714+
realCommandPath: chocolateyShim,
715+
env: {},
716+
platform: "win32",
717+
}).pipe(
718+
Effect.provideService(HostProcess.Platform, "win32"),
719+
Effect.provideService(
720+
ChildProcessSpawner.ChildProcessSpawner,
721+
stdoutSpawner(
722+
() => "",
723+
() => 1,
724+
),
725+
),
726+
);
727+
728+
const latest = yield* resolveLatestProviderVersion(capabilities).pipe(
729+
Effect.provide(ProviderLatestVersions.layer),
730+
Effect.provideService(
731+
HttpClient.HttpClient,
732+
HttpClient.make(() => Effect.die("a failed choco search should not fall back to npm")),
733+
),
734+
);
735+
736+
expect(latest).toBeNull();
737+
}).pipe(Effect.provide(NodeServices.layer)),
738+
);
739+
588740
it.effect.each([
589741
{
590742
name: "a native install",
@@ -617,6 +769,12 @@ it.layer(
617769
resolved: userWinGetLink,
618770
real: userWinGetPackage,
619771
},
772+
{
773+
name: "a claude-code tools path outside Chocolatey",
774+
definition: claudeUpdate,
775+
resolved: "C:\\Work\\lib\\claude-code\\tools\\claude.exe",
776+
real: "C:\\Work\\lib\\claude-code\\tools\\claude.exe",
777+
},
620778
{
621779
name: "another WinGet package",
622780
definition: claudeUpdate,
@@ -1052,6 +1210,13 @@ it.layer(
10521210
}),
10531211
);
10541212

1213+
it("reads the Chocolatey package version from choco search --limit-output", () => {
1214+
expect(parseChocolateyLatestVersion("claude-code|2.1.304\r\n", "claude-code")).toBe("2.1.304");
1215+
expect(parseChocolateyLatestVersion("Claude-Code|2.1.304\r\n", "claude-code")).toBe("2.1.304");
1216+
expect(parseChocolateyLatestVersion("claude-code-other|9.9.9\r\n", "claude-code")).toBeNull();
1217+
expect(parseChocolateyLatestVersion("", "claude-code")).toBeNull();
1218+
});
1219+
10551220
it("recognizes Homebrew kegs and casks from the real executable path", () => {
10561221
expect(
10571222
homebrewOwnershipFromCommandPath("/opt/homebrew/Cellar/claude-code@latest/2.1.0/bin/claude"),

‎packages/provider-core/src/server/maintenanceResolver.ts‎

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,8 @@ export function makeManualOnlyProviderMaintenanceCapabilities(input: {
199199
readonly provider: ProviderDriverKind;
200200
readonly packageName: string | null;
201201
readonly installPath?: string;
202+
readonly lookupLatestVersion?: Effect.Effect<string | null>;
203+
readonly manualCommand?: string;
202204
}): ProviderMaintenanceCapabilities {
203205
return {
204206
...makeProviderMaintenanceCapabilities({
@@ -207,8 +209,12 @@ export function makeManualOnlyProviderMaintenanceCapabilities(input: {
207209
updateExecutable: null,
208210
updateArgs: [],
209211
updateLockKey: null,
212+
...(input.lookupLatestVersion
213+
? { latestVersion: null, lookupLatestVersion: input.lookupLatestVersion }
214+
: {}),
210215
}),
211216
...(input.installPath ? { installPath: input.installPath } : {}),
217+
...(input.manualCommand ? { manualCommand: input.manualCommand } : {}),
212218
};
213219
}
214220

@@ -248,6 +254,32 @@ function isWinGetClaudeCommandPath(commandPath: string): boolean {
248254
return winGetClaudeCommandPath.test(normalizeCommandPath(commandPath));
249255
}
250256

257+
const CHOCOLATEY_CLAUDE_PACKAGE_ID = "claude-code";
258+
259+
// Chocolatey's executable shim is not a symlink, so realpath cannot prove its owner.
260+
const chocolateyClaudeCommandPath = /\/chocolatey\/(?:bin|lib\/claude-code\/tools)\/claude\.exe$/;
261+
262+
function isChocolateyClaudeCommandPath(commandPath: string, env: NodeJS.ProcessEnv): boolean {
263+
const normalized = normalizeCommandPath(commandPath);
264+
if (chocolateyClaudeCommandPath.test(normalized)) return true;
265+
const root = nonEmptyString(env.ChocolateyInstall);
266+
if (!root) return false;
267+
const prefix = normalizeCommandPath(root.replace(/[\\/]+$/, ""));
268+
return (
269+
normalized === `${prefix}/bin/claude.exe` ||
270+
normalized === `${prefix}/lib/claude-code/tools/claude.exe`
271+
);
272+
}
273+
274+
/** `choco search --exact --limit-output` prints `<id>|<version>` for a match and nothing otherwise. */
275+
export function parseChocolateyLatestVersion(output: string, packageId: string): string | null {
276+
for (const line of output.split(/\r?\n/)) {
277+
const [id, version] = line.trim().split("|", 2);
278+
if (id?.toLowerCase() === packageId.toLowerCase()) return nonEmptyString(version);
279+
}
280+
return null;
281+
}
282+
251283
function isPnpmGlobalCommandPath(commandPath: string): boolean {
252284
const normalized = normalizeCommandPath(commandPath);
253285
return (
@@ -447,6 +479,32 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn(
447479
) {
448480
return manual;
449481
}
482+
// Chocolatey's package directory is writable only by administrators, so an
483+
// unelevated server cannot run `choco upgrade`; `claude update` through the
484+
// shim installs a native copy elsewhere and leaves the shim's version alone.
485+
if (
486+
definition.provider === "claudeAgent" &&
487+
context.platform === "win32" &&
488+
commandPaths.some((commandPath) => isChocolateyClaudeCommandPath(commandPath, context.env))
489+
) {
490+
const services = yield* Effect.context<ProviderMaintenanceResolverServices>();
491+
return makeManualOnlyProviderMaintenanceCapabilities({
492+
provider: definition.provider,
493+
packageName,
494+
installPath: context.realCommandPath,
495+
manualCommand: "choco upgrade claude-code",
496+
lookupLatestVersion: runProbe(
497+
"choco",
498+
["search", CHOCOLATEY_CLAUDE_PACKAGE_ID, "--exact", "--limit-output"],
499+
context.env,
500+
).pipe(
501+
Effect.map((output) =>
502+
output ? parseChocolateyLatestVersion(output, CHOCOLATEY_CLAUDE_PACKAGE_ID) : null,
503+
),
504+
Effect.provideContext(services),
505+
),
506+
});
507+
}
450508

451509
const nativeUpdate = definition.nativeUpdate;
452510
const nativeEnv = nativeUpdate?.env;
@@ -872,7 +930,10 @@ export function createProviderVersionAdvisory(input: {
872930
status: advisory.status,
873931
currentVersion: input.currentVersion,
874932
latestVersion,
875-
updateCommand: resolveLatestProviderUpdateAction(capabilities, latestVersion)?.command ?? null,
933+
updateCommand:
934+
resolveLatestProviderUpdateAction(capabilities, latestVersion)?.command ??
935+
capabilities.manualCommand ??
936+
null,
876937
canUpdate: capabilities.update !== null,
877938
canInstallVersion: makeTargetedProviderUpdateAction(capabilities, "0.0.0") !== null,
878939
checkedAt: input.checkedAt ?? null,
@@ -895,12 +956,16 @@ const fetchNpmLatestVersion = Effect.fn("fetchNpmLatestVersion")(function* (pack
895956
return Option.isSome(payload) ? nonEmptyString(payload.value.version) : null;
896957
});
897958

898-
/** The installer's own answer is cached apart from the npm registry entry. */
959+
/**
960+
* The installer's own answer is cached apart from the npm registry entry. A
961+
* manual-only install has no command to key on, so its executable stands in.
962+
*/
899963
function installerLatestVersionKey(capabilities: ProviderMaintenanceCapabilities) {
900964
if (!capabilities.lookupLatestVersion) return null;
901965
const update = capabilities.update;
902-
return update
903-
? [update.lockKey, update.executable, ...update.args].join("\0")
966+
if (update) return [update.lockKey, update.executable, ...update.args].join("\0");
967+
return capabilities.installPath
968+
? ["manual", capabilities.installPath].join("\0")
904969
: capabilities.packageName && `npm-registry\0${capabilities.packageName}`;
905970
}
906971

0 commit comments

Comments
 (0)