authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-04 02:45:37-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-04 02:45:37-03:00
logfe93a5dbdd3c4496f402f71d232597c391c4973b
tree972f60cfab3d9dab7b8894f0e26cecd784a0f60b
parentf4f3abf71e98361918e360a89d48780cc65cafd1
signature Signed by PGP key B5690EEEBB952194

fix(server): offer one-click provider updates for every install (#15416)


5 files changed, 257 insertions(+), 37 deletions(-)

apps/server/src/provider/Drivers/PiDriver.ts+2-1
...@@ -53,7 +53,8 @@ const DRIVER_KIND = ProviderDriverKind.make("pi");...@@ -53,7 +53,8 @@ const DRIVER_KIND = ProviderDriverKind.make("pi");
53const UPDATE = makePackageManagedProviderMaintenanceResolver({53const UPDATE = makePackageManagedProviderMaintenanceResolver({
54 provider: DRIVER_KIND,54 provider: DRIVER_KIND,
55 npmPackageName: "@earendil-works/pi-coding-agent",55 npmPackageName: "@earendil-works/pi-coding-agent",
56 nativeUpdate: null,56 // Pi's updater covers its own installer and npm, pnpm, yarn, and bun globals.
57 nativeUpdate: { args: ["update", "--self"] },
57});58});
5859
59export type PiDriverEnv =60export type PiDriverEnv =
apps/server/src/provider/providerMaintenance.test.ts+116
...@@ -769,6 +769,122 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {...@@ -769,6 +769,122 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
769 }),769 }),
770 );770 );
771771
772 it.effect.skipIf(windowsHost)(
773 "falls back to the provider's own updater when no installer is proven",
774 () =>
775 Effect.gen(function* () {
776 const tempDir = yield* makeTempDir("t3-self-update-fallback");
777 const customPath = NodePath.join(tempDir, "tools", "package-tool");
778 writeExecutable(customPath);
779 const selfUpdating = makePackageManagedProviderMaintenanceResolver({
780 provider: driver("packageTool"),
781 npmPackageName: "@example/package-tool",
782 nativeUpdate: { args: ["update", "--self"] },
783 });
784
785 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(selfUpdating, {
786 binaryPath: customPath,
787 env: { PATH: "" },
788 }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn));
789 expect(capabilities.update).toMatchObject({
790 executable: customPath,
791 args: ["update", "--self"],
792 lockKey: "packageTool-native",
793 });
794
795 // A mise install is pinned in mise's config, so it stays manual.
796 const misePath = NodePath.join(tempDir, "mise", "installs", "package-tool", "1.0.0", "bin");
797 writeExecutable(NodePath.join(misePath, "package-tool"));
798 const mise = yield* resolveProviderMaintenanceCapabilitiesEffect(selfUpdating, {
799 binaryPath: NodePath.join(misePath, "package-tool"),
800 env: { PATH: "" },
801 }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn));
802 expect(mise.update).toBeNull();
803 }),
804 );
805
806 it.effect.skipIf(!symlinksSupported)("updates Yarn global installs with yarn", () =>
807 Effect.gen(function* () {
808 const tempDir = yield* makeTempDir("t3-yarn-capabilities");
809 const link = linkIntoPackage(tempDir, "package-tool", [
810 ".config",
811 "yarn",
812 "global",
813 "node_modules",
814 "@example",
815 "package-tool",
816 ]);
817
818 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, {
819 binaryPath: link,
820 env: { PATH: "" },
821 }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn));
822
823 expect(capabilities.update).toMatchObject({
824 command: "yarn global add @example/package-tool@latest",
825 lockKey: "yarn-global",
826 });
827 expect(makeTargetedProviderUpdateAction(capabilities, "2.0.0")?.args).toEqual([
828 "global",
829 "add",
830 "@example/package-tool@2.0.0",
831 ]);
832 }),
833 );
834
835 it.effect.skipIf(!symlinksSupported)(
836 "updates Volta installs only when Volta has the package",
837 () =>
838 Effect.gen(function* () {
839 const voltaHome = NodePath.join(yield* makeTempDir("t3-volta-capabilities"), ".volta");
840 const shim = NodePath.join(voltaHome, "bin", "volta-shim");
841 writeExecutable(shim);
842 const link = NodePath.join(voltaHome, "bin", "package-tool");
843 NodeFS.symlinkSync(shim, link);
844 const resolve = resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, {
845 binaryPath: link,
846 env: { PATH: "" },
847 }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn));
848
849 expect((yield* resolve).update).toBeNull();
850 NodeFS.mkdirSync(
851 NodePath.join(voltaHome, "tools", "image", "packages", "@example", "package-tool"),
852 { recursive: true },
853 );
854 expect((yield* resolve).update).toMatchObject({
855 command: "volta install @example/package-tool@latest",
856 lockKey: "volta",
857 });
858 }),
859 );
860
861 it.effect.skipIf(windowsHost)("upgrades with the keg's own brew when brew is not on PATH", () =>
862 Effect.gen(function* () {
863 const tempDir = yield* makeTempDir("t3-homebrew-keg-brew");
864 const brewPath = NodePath.join(tempDir, "bin", "brew");
865 writeExecutable(brewPath);
866 const kegBinary = NodePath.join(tempDir, "Cellar", "package-tool", "1.0.0", "bin", "tool");
867 writeExecutable(kegBinary);
868
869 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, {
870 binaryPath: kegBinary,
871 env: { PATH: "" },
872 }).pipe(
873 Effect.provideService(HostProcessPlatform, "darwin"),
874 Effect.provideService(
875 ChildProcessSpawner.ChildProcessSpawner,
876 stdoutSpawner((_command, args) => (args[0] === "--prefix" ? `${tempDir}\n` : "{}")),
877 ),
878 );
879
880 expect(capabilities.update).toMatchObject({
881 executable: brewPath,
882 args: ["upgrade", "package-tool"],
883 lockKey: "homebrew",
884 });
885 }),
886 );
887
772 it.effect("caches resolution until a fresh read is requested", () =>888 it.effect("caches resolution until a fresh read is requested", () =>
773 Effect.gen(function* () {889 Effect.gen(function* () {
774 let resolutions = 0;890 let resolutions = 0;
apps/server/src/provider/providerMaintenance.ts+123-22
...@@ -26,6 +26,7 @@ const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000;...@@ -26,6 +26,7 @@ const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000;
26const LATEST_VERSION_TIMEOUT_MS = 4_000;26const LATEST_VERSION_TIMEOUT_MS = 4_000;
27const HOMEBREW_INFO_TIMEOUT_MS = 10_000;27const HOMEBREW_INFO_TIMEOUT_MS = 10_000;
28const HOMEBREW_INFO_MAX_BYTES = 256 * 1_024;28const HOMEBREW_INFO_MAX_BYTES = 256 * 1_024;
29const MISE_WRAPPER_MAX_BYTES = 16 * 1_024;
29const PROVIDER_UPDATE_ACTION_TOAST_MESSAGE = "Install the update now or review provider settings.";30const PROVIDER_UPDATE_ACTION_TOAST_MESSAGE = "Install the update now or review provider settings.";
3031
31/**32/**
...@@ -105,9 +106,15 @@ export interface ProviderMaintenanceCapabilitiesResolver {...@@ -105,9 +106,15 @@ export interface ProviderMaintenanceCapabilitiesResolver {
105export interface PackageManagedProviderMaintenanceDefinition {106export interface PackageManagedProviderMaintenanceDefinition {
106 readonly provider: ProviderDriverKind;107 readonly provider: ProviderDriverKind;
107 readonly npmPackageName: string;108 readonly npmPackageName: string;
109 /**
110 * The provider's own updater (`claude update`, `pi update --self`). It runs
111 * first for paths its installer owns, and otherwise only when no package
112 * manager is proven, because these updaters detect their installer too.
113 */
108 readonly nativeUpdate: {114 readonly nativeUpdate: {
109 readonly args: ReadonlyArray<string>;115 readonly args: ReadonlyArray<string>;
110 readonly isCommandPath: (commandPath: string) => boolean;116 /** Paths the provider's own installer owns; omit when it has none. */
117 readonly isCommandPath?: (commandPath: string) => boolean;
111 /** Environment the native updater needs to target this instance's install. */118 /** Environment the native updater needs to target this instance's install. */
112 readonly env?: NodeJS.ProcessEnv;119 readonly env?: NodeJS.ProcessEnv;
113 } | null;120 } | null;
...@@ -195,7 +202,11 @@ export function makeTargetedProviderUpdateAction(...@@ -195,7 +202,11 @@ export function makeTargetedProviderUpdateAction(
195 const update = capabilities.update;202 const update = capabilities.update;
196 const packageName = capabilities.packageName;203 const packageName = capabilities.packageName;
197 if (!update || !packageName) return null;204 if (!update || !packageName) return null;
198 if (!/^(?:npm-global:|bun-global$|pnpm-global$|vite-plus-global$)/.test(update.lockKey))205 if (
206 !/^(?:npm-global:|bun-global$|pnpm-global$|vite-plus-global$|yarn-global$|volta$)/.test(
207 update.lockKey,
208 )
209 )
199 return null;210 return null;
200 const packageIndex = update.args.findIndex(211 const packageIndex = update.args.findIndex(
201 (arg) => arg === `${packageName}@latest` || arg === packageName,212 (arg) => arg === `${packageName}@latest` || arg === packageName,
...@@ -238,6 +249,17 @@ function isBunGlobalCommandPath(commandPath: string): boolean {...@@ -238,6 +249,17 @@ function isBunGlobalCommandPath(commandPath: string): boolean {
238 return normalizeCommandPath(commandPath).includes("/.bun/bin/");249 return normalizeCommandPath(commandPath).includes("/.bun/bin/");
239}250}
240251
252function isYarnGlobalCommandPath(commandPath: string): boolean {
253 // `~/.config/yarn/global/…` on POSIX, `%LOCALAPPDATA%\Yarn\Data\global\…` on Windows.
254 return /\/yarn\/(?:data\/)?global\/node_modules\//.test(normalizeCommandPath(commandPath));
255}
256
257/** Version-manager installs are pinned in its config, so updating them means editing that. */
258function isMiseCommandPath(commandPath: string): boolean {
259 const normalized = normalizeCommandPath(commandPath);
260 return normalized.includes("/mise/installs/") || normalized.includes("/mise/shims/");
261}
262
241function isPnpmGlobalCommandPath(commandPath: string): boolean {263function isPnpmGlobalCommandPath(commandPath: string): boolean {
242 const normalized = normalizeCommandPath(commandPath);264 const normalized = normalizeCommandPath(commandPath);
243 return (265 return (
...@@ -371,9 +393,10 @@ const runHomebrew = Effect.fn("runHomebrew")(function* (...@@ -371,9 +393,10 @@ const runHomebrew = Effect.fn("runHomebrew")(function* (
371393
372/**394/**
373 * Derive update capabilities from where the executable actually lives. Every395 * Derive update capabilities from where the executable actually lives. Every
374 * branch that yields a one-click command has evidence that the named tool396 * package-manager branch has evidence that the named tool owns that path, so
375 * owns that path; anything unproven stays manual-only so T3 Code never runs397 * T3 Code never runs a package manager against an install it did not create.
376 * a package manager against an install it did not create.398 * An unproven install falls back to the provider's own updater, which detects
399 * its installer itself, and stays manual-only without one.
377 */400 */
378export const resolvePackageManagedProviderMaintenance = Effect.fn(401export const resolvePackageManagedProviderMaintenance = Effect.fn(
379 "resolvePackageManagedProviderMaintenance",402 "resolvePackageManagedProviderMaintenance",
...@@ -392,17 +415,27 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn(...@@ -392,17 +415,27 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn(
392 const packageName = definition.npmPackageName;415 const packageName = definition.npmPackageName;
393416
394 const nativeUpdate = definition.nativeUpdate;417 const nativeUpdate = definition.nativeUpdate;
395 if (nativeUpdate && commandPaths.some((commandPath) => nativeUpdate.isCommandPath(commandPath))) {418 const native = nativeUpdate
396 return makeProviderMaintenanceCapabilities({419 ? makeProviderMaintenanceCapabilities({
397 provider: definition.provider,420 provider: definition.provider,
398 packageName,421 packageName,
399 updateExecutable: context.resolvedCommandPath,422 updateExecutable: context.resolvedCommandPath,
400 updateArgs: nativeUpdate.args,423 updateArgs: nativeUpdate.args,
401 updateLockKey: `${definition.provider}-native`,424 updateLockKey: `${definition.provider}-native`,
402 platform: context.platform,425 platform: context.platform,
403 ...(nativeUpdate.env ? { env: nativeUpdate.env } : {}),426 ...(nativeUpdate.env ? { env: nativeUpdate.env } : {}),
404 });427 })
428 : manual;
429 if (nativeUpdate?.isCommandPath && commandPaths.some(nativeUpdate.isCommandPath)) {
430 return native;
405 }431 }
432 // A `node_modules` path not proven below belongs to another package or a
433 // project, so the provider's own updater could act on the wrong install.
434 const fallback = commandPaths.some((commandPath) =>
435 normalizeCommandPath(commandPath).includes("/node_modules/"),
436 )
437 ? manual
438 : native;
406 if (commandPaths.some(isVitePlusGlobalCommandPath)) {439 if (commandPaths.some(isVitePlusGlobalCommandPath)) {
407 return makeProviderMaintenanceCapabilities({440 return makeProviderMaintenanceCapabilities({
408 provider: definition.provider,441 provider: definition.provider,
...@@ -431,6 +464,25 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn(...@@ -431,6 +464,25 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn(
431 });464 });
432 }465 }
433466
467 if (commandPaths.some(isYarnGlobalCommandPath)) {
468 return makeProviderMaintenanceCapabilities({
469 provider: definition.provider,
470 packageName,
471 updateExecutable: "yarn",
472 updateArgs: ["global", "add", `${packageName}@latest`],
473 updateLockKey: "yarn-global",
474 });
475 }
476 if (yield* isVoltaPackageInstall(context, packageName)) {
477 return makeProviderMaintenanceCapabilities({
478 provider: definition.provider,
479 packageName,
480 updateExecutable: "volta",
481 updateArgs: ["install", `${packageName}@latest`],
482 updateLockKey: "volta",
483 });
484 }
485
434 // npm proof names the package, so it outranks a keg the path merely passes486 // npm proof names the package, so it outranks a keg the path merely passes
435 // through: a Homebrew-installed Node keeps its globals under487 // through: a Homebrew-installed Node keeps its globals under
436 // `Cellar/node/<ver>/lib/node_modules/`, and that is npm's install, not brew's.488 // `Cellar/node/<ver>/lib/node_modules/`, and that is npm's install, not brew's.
...@@ -457,21 +509,31 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn(...@@ -457,21 +509,31 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn(
457 });509 });
458 }510 }
459511
512 if (commandPaths.some(isMiseCommandPath) || (yield* isMiseWrapperScript(context))) {
513 return manual;
514 }
515
460 const homebrew = homebrewOwnershipFromCommandPath(context.realCommandPath);516 const homebrew = homebrewOwnershipFromCommandPath(context.realCommandPath);
461 if (homebrew) {517 if (homebrew) {
462 // Mise shims resolve to the version manager, not the provider.518 // Mise shims resolve to the version manager, not the provider.
463 if (homebrew.kind === "formula" && homebrew.name.toLowerCase() === "mise") {519 if (homebrew.kind === "formula" && homebrew.name.toLowerCase() === "mise") {
464 return manual;520 return manual;
465 }521 }
466 const brewPath = yield* resolveCommandPath("brew", { env: context.env }).pipe(522 const fileSystem = yield* FileSystem.FileSystem;
467 Effect.catchTags({ CommandResolutionError: () => Effect.succeed(null) }),523 const path = yield* Path.Path;
468 );524 // The keg's own brew works even when a GUI-launched server has no
525 // Homebrew on PATH; the prefix check below still applies to it.
526 const kegBrewPath = path.join(homebrew.prefix, "bin", "brew");
527 const brewPath = (yield* fileSystem.exists(kegBrewPath).pipe(Effect.orElseSucceed(() => false)))
528 ? kegBrewPath
529 : yield* resolveCommandPath("brew", { env: context.env }).pipe(
530 Effect.catchTags({ CommandResolutionError: () => Effect.succeed(null) }),
531 );
469 if (!brewPath) {532 if (!brewPath) {
470 return manual;533 return fallback;
471 }534 }
472 // A keg-shaped path is only Homebrew's if it sits under the prefix of the535 // A keg-shaped path is only Homebrew's if it sits under the prefix of the
473 // `brew` that would upgrade it; `brew --prefix` is a cheap shell script.536 // `brew` that would upgrade it; `brew --prefix` is a cheap shell script.
474 const fileSystem = yield* FileSystem.FileSystem;
475 const brewPrefix = nonEmptyString(yield* runHomebrew(brewPath, ["--prefix"], context.env));537 const brewPrefix = nonEmptyString(yield* runHomebrew(brewPath, ["--prefix"], context.env));
476 const realBrewPrefix = brewPrefix538 const realBrewPrefix = brewPrefix
477 ? yield* fileSystem.realPath(brewPrefix).pipe(Effect.orElseSucceed(() => brewPrefix))539 ? yield* fileSystem.realPath(brewPrefix).pipe(Effect.orElseSucceed(() => brewPrefix))
...@@ -480,7 +542,7 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn(...@@ -480,7 +542,7 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn(
480 !realBrewPrefix ||542 !realBrewPrefix ||
481 normalizeCommandPath(realBrewPrefix) !== normalizeCommandPath(homebrew.prefix)543 normalizeCommandPath(realBrewPrefix) !== normalizeCommandPath(homebrew.prefix)
482 ) {544 ) {
483 return manual;545 return fallback;
484 }546 }
485 const args =547 const args =
486 homebrew.kind === "cask" ? ["upgrade", "--cask", homebrew.name] : ["upgrade", homebrew.name];548 homebrew.kind === "cask" ? ["upgrade", "--cask", homebrew.name] : ["upgrade", homebrew.name];
...@@ -498,7 +560,46 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn(...@@ -498,7 +560,46 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn(
498 });560 });
499 }561 }
500562
501 return manual;563 return fallback;
564});
565
566/** A launcher script that runs the provider through mise (`exec mise x codex -- codex`). */
567const isMiseWrapperScript = Effect.fn("isMiseWrapperScript")(function* (
568 context: ProviderMaintenanceResolutionContext,
569) {
570 const fileSystem = yield* FileSystem.FileSystem;
571 const size = yield* fileSystem.stat(context.realCommandPath).pipe(
572 Effect.map((info) => Number(info.size)),
573 Effect.orElseSucceed(() => Infinity),
574 );
575 if (size > MISE_WRAPPER_MAX_BYTES) {
576 return false;
577 }
578 const script = yield* fileSystem
579 .readFileString(context.realCommandPath)
580 .pipe(Effect.orElseSucceed(() => ""));
581 return script.startsWith("#!") && /\bmise\s+(?:x|exec)\b/.test(script);
582});
583
584/**
585 * Volta's `bin/<cmd>` is a link to its `volta-shim`, which picks the package
586 * at run time; the package's own image directory proves Volta installed it.
587 */
588const isVoltaPackageInstall = Effect.fn("isVoltaPackageInstall")(function* (
589 context: ProviderMaintenanceResolutionContext,
590 packageName: string,
591) {
592 const path = yield* Path.Path;
593 if (
594 path.basename(normalizeCommandPath(context.realCommandPath)).replace(/\.exe$/, "") !==
595 "volta-shim"
596 ) {
597 return false;
598 }
599 const voltaHome = path.dirname(path.dirname(context.resolvedCommandPath));
600 const packageDir = path.join(voltaHome, "tools", "image", "packages", ...packageName.split("/"));
601 const fileSystem = yield* FileSystem.FileSystem;
602 return yield* fileSystem.exists(packageDir).pipe(Effect.orElseSucceed(() => false));
502});603});
503604
504/**605/**
docs/internals/providers.md+10-9
...@@ -80,16 +80,17 @@ See [helper constraints](../../apps/server/src/textGeneration/AntigravityTextGen...@@ -80,16 +80,17 @@ See [helper constraints](../../apps/server/src/textGeneration/AntigravityTextGen
8080
81## Provider updates run only through the owning installer81## Provider updates run only through the owning installer
8282
83A one-click update is offered only when the resolved executable's path proves which installer owns83A package manager runs only when the resolved executable's path proves it owns the install. Homebrew
84it. Homebrew and npm are proven by the real path (symlinks followed): a versioned keg or cask under84and npm are proven by the real path (symlinks followed): a versioned keg or cask under
85`brew --prefix`, or `<prefix>/lib/node_modules/<pkg>/` (Windows: the shim beside `node_modules`).85`brew --prefix`, or `<prefix>/lib/node_modules/<pkg>/` (Windows: the shim beside `node_modules`).
86Native installer layouts and the global bin directories of pnpm, Bun, and Vite+ may match on either86Native installer layouts and the global directories of pnpm, Bun, Yarn, and Vite+ may match on
87the resolved path or its real target, since those installers place real files or their own symlinks87either the resolved path or its real target, since those installers place real files or their own
88there. Cursor and Grok are the exception: their only updater is the CLI itself, which detects its88symlinks there. Volta is proven by its `volta-shim` link plus the package's image directory. When
89own installer, so any resolved executable runs `<binary> update`. Anything unproven stays89nothing is proven, the provider's own updater (`claude update`, `codex update`, `opencode upgrade`,
90manual-only but still reports the version gap. npm updates pin90`pi update --self`, `grok update`) runs instead, because each one detects its installer itself;
91`--prefix` because the `npm` on `PATH` can belong to a different Node than the one that owns the91the runner's version check catches an updater that exits 0 without updating. Mise installs stay
92provider. Homebrew92manual-only because their version is pinned in mise's config. npm updates pin `--prefix` because the
93`npm` on `PATH` can belong to a different Node than the one that owns the provider. Homebrew
93compares against `brew info` since casks trail npm by hours; native installs share npm's version94compares against `brew info` since casks trail npm by hours; native installs share npm's version
94train, so the registry stays authoritative for them.95train, so the registry stays authoritative for them.
95See the [resolver](../../apps/server/src/provider/providerMaintenance.ts).96See the [resolver](../../apps/server/src/provider/providerMaintenance.ts).
docs/user/install.md+6-5
...@@ -142,11 +142,12 @@ you can install the recommendation there. Otherwise use the provider's installer...@@ -142,11 +142,12 @@ you can install the recommendation there. Otherwise use the provider's installer
142on the environment's machine. An unlisted version is unverified.142on the environment's machine. An unlisted version is unverified.
143143
144When a provider CLI is behind its latest release, its provider card shows the144When a provider CLI is behind its latest release, its provider card shows the
145available version. **Update now** appears only when T3 Code can tell which145available version. **Update now** runs the installer that owns the CLI
146installer owns the CLI (its own update command, Homebrew, or a global npm, pnpm,146(Homebrew, or a global npm, pnpm, Yarn, Bun, Volta, or Vite+ install), or the
147bun, or Vite+ install) and runs that installer. Otherwise update the CLI the same147CLI's own update command when T3 Code cannot tell. Update a CLI installed with
148way you installed it. Homebrew installs compare against the version Homebrew148mise through mise. Cursor and Antigravity update with T3 Code. Homebrew installs
149offers, which can trail the npm release by a few hours.149compare against the version Homebrew offers, which can trail the npm release by
150a few hours.
150151
151Add another provider instance for a separate account or configuration. Each152Add another provider instance for a separate account or configuration. Each
152instance can have its own environment variables, such as API keys or a custom153instance can have its own environment variables, such as API keys or a custom