diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 5e78f6e7d38d9..30ba5dfaaae2e 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -7,6 +7,7 @@ import { getClientSettings, setClientSettings } from "./methods/clientSettings.t import { getConnectionCatalog, setConnectionCatalog } from "./methods/connectionCatalog.ts"; import { discoverLanServices } from "./methods/lanDiscovery.ts"; import { + getHostEnvironmentId, getLocalEnvironmentEnabled, setLocalEnvironmentEnabled, } from "./methods/localEnvironment.ts"; @@ -101,6 +102,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handleSync(getLocalEnvironmentBootstraps); yield* ipc.handleSync(getLocalEnvironmentEnabled); yield* ipc.handle(setLocalEnvironmentEnabled); + yield* ipc.handle(getHostEnvironmentId); yield* ipc.handle(getLocalEnvironmentBearerToken); yield* ipc.handle(getClientSettings); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 0a28533a9c6c2..3b58641e82d17 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -38,6 +38,7 @@ export const GET_PREFERRED_SYSTEM_LANGUAGES_CHANNEL = "desktop:get-preferred-sys export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps"; export const GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:get-local-environment-enabled"; export const SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:set-local-environment-enabled"; +export const GET_HOST_ENVIRONMENT_ID_CHANNEL = "desktop:get-host-environment-id"; export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL = "desktop:get-local-environment-bearer-token"; export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; diff --git a/apps/desktop/src/ipc/methods/localEnvironment.test.ts b/apps/desktop/src/ipc/methods/localEnvironment.test.ts index 94bbf085a9921..36e89eb880c15 100644 --- a/apps/desktop/src/ipc/methods/localEnvironment.test.ts +++ b/apps/desktop/src/ipc/methods/localEnvironment.test.ts @@ -1,4 +1,7 @@ import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -10,7 +13,11 @@ import * as ElectronApp from "../../electron/ElectronApp.ts"; import * as ElectronTheme from "../../electron/ElectronTheme.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; import * as DesktopWindow from "../../window/DesktopWindow.ts"; -import { getLocalEnvironmentEnabled, setLocalEnvironmentEnabled } from "./localEnvironment.ts"; +import { + getHostEnvironmentId, + getLocalEnvironmentEnabled, + setLocalEnvironmentEnabled, +} from "./localEnvironment.ts"; const layerUnusedLifecycleRuntime = Layer.mergeAll( DesktopShutdown.layer, @@ -27,6 +34,26 @@ const layerUnusedLifecycleRuntime = Layer.mergeAll( ); describe("local environment IPC", () => { + it.effect("reads the shared home identity without creating one", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const stateDir = yield* fs.makeTempDirectoryScoped(); + const environment = DesktopEnvironment.DesktopEnvironment.of({ + path, + stateDir, + } as DesktopEnvironment.DesktopEnvironment["Service"]); + const read = getHostEnvironmentId + .handler(undefined) + .pipe(Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment)); + assert.strictEqual(yield* read, null); + assert.isFalse(yield* fs.exists(path.join(stateDir, "environment-id"))); + yield* fs.writeFileString(path.join(stateDir, "environment-id"), " service-id\n"); + assert.strictEqual(yield* read, "service-id"); + yield* fs.writeFileString(path.join(stateDir, "environment-id"), "\n"); + assert.strictEqual(yield* read, null); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); it.effect("relaunches only when the setting changes and keeps other settings", () => { const relaunchReasons: Array = []; const layer = Layer.mergeAll( diff --git a/apps/desktop/src/ipc/methods/localEnvironment.ts b/apps/desktop/src/ipc/methods/localEnvironment.ts index 74cccd04a0c58..3c5c2c63d77da 100644 --- a/apps/desktop/src/ipc/methods/localEnvironment.ts +++ b/apps/desktop/src/ipc/methods/localEnvironment.ts @@ -1,6 +1,8 @@ import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; +import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; import * as IpcChannels from "../channels.ts"; @@ -28,3 +30,18 @@ export const setLocalEnvironmentEnabled = makeIpcMethod({ } }), }); + +// A background service sharing this app's T3 home records its environment ID in the same state dir. +export const getHostEnvironmentId = makeIpcMethod({ + channel: IpcChannels.GET_HOST_ENVIRONMENT_ID_CHANNEL, + payload: Schema.Void, + result: Schema.NullOr(Schema.String), + handler: Effect.fn("desktop.ipc.localEnvironment.getHostEnvironmentId")(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const contents = yield* fileSystem + .readFileString(environment.path.join(environment.stateDir, "environment-id")) + .pipe(Effect.orElseSucceed(() => "")); + return contents.trim() || null; + }), +}); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 1f596a90446f3..36fa9f1c5bfda 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -126,6 +126,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL) !== false, setLocalEnvironmentEnabled: (enabled) => ipcRenderer.invoke(IpcChannels.SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL, enabled), + getHostEnvironmentId: () => ipcRenderer.invoke(IpcChannels.GET_HOST_ENVIRONMENT_ID_CHANNEL), getClientSettings: () => ipcRenderer.invoke(IpcChannels.GET_CLIENT_SETTINGS_CHANNEL), setClientSettings: (settings) => ipcRenderer.invoke(IpcChannels.SET_CLIENT_SETTINGS_CHANNEL, settings), diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt index 14f91964dcaf7..e5c8f6c0fd8f4 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt @@ -286,8 +286,10 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex onInput(mapOf("data" to "\r")) }, ) + val navigationKeys = TerminalNavigationKeyListener { onInput(mapOf("data" to it)) } inputView.setOnKeyListener { _, keyCode, event -> if (readOnly) return@setOnKeyListener true + if (navigationKeys.onKey(inputView, keyCode, event)) return@setOnKeyListener true if (event.action != KeyEvent.ACTION_DOWN) return@setOnKeyListener false when { keyCode == KeyEvent.KEYCODE_DEL -> { diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalNavigationKeyListener.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalNavigationKeyListener.kt new file mode 100644 index 0000000000000..3ba3ec935aa59 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalNavigationKeyListener.kt @@ -0,0 +1,28 @@ +package expo.modules.t3terminal + +import android.view.KeyEvent +import android.view.View + +internal class TerminalNavigationKeyListener( + private val send: (String) -> Unit +) : View.OnKeyListener { + override fun onKey(view: View?, keyCode: Int, event: KeyEvent): Boolean { + val sequence = sequenceFor(keyCode, event) ?: return false + if (event.action == KeyEvent.ACTION_DOWN) send(sequence) + // Unconsumed arrow and Tab key events move focus off the terminal input. + return true + } + + // Matches iOS: these keys only without modifiers, plus Shift+Tab. + private fun sequenceFor(keyCode: Int, event: KeyEvent): String? = when { + keyCode == KeyEvent.KEYCODE_TAB && event.hasModifiers(KeyEvent.META_SHIFT_ON) -> "\u001b[Z" + !event.hasNoModifiers() -> null + keyCode == KeyEvent.KEYCODE_DPAD_UP -> "\u001b[A" + keyCode == KeyEvent.KEYCODE_DPAD_DOWN -> "\u001b[B" + keyCode == KeyEvent.KEYCODE_DPAD_RIGHT -> "\u001b[C" + keyCode == KeyEvent.KEYCODE_DPAD_LEFT -> "\u001b[D" + keyCode == KeyEvent.KEYCODE_TAB -> "\t" + keyCode == KeyEvent.KEYCODE_ESCAPE -> "\u001b" + else -> null + } +} diff --git a/apps/mobile/modules/t3-terminal/android/src/test/java/expo/modules/t3terminal/TerminalNavigationKeyListenerTest.kt b/apps/mobile/modules/t3-terminal/android/src/test/java/expo/modules/t3terminal/TerminalNavigationKeyListenerTest.kt new file mode 100644 index 0000000000000..532160dae18b9 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/android/src/test/java/expo/modules/t3terminal/TerminalNavigationKeyListenerTest.kt @@ -0,0 +1,67 @@ +package expo.modules.t3terminal + +import android.app.Activity +import android.view.KeyEvent +import android.view.View +import android.widget.Button +import android.widget.EditText +import android.widget.LinearLayout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36], manifest = Config.NONE) +class TerminalNavigationKeyListenerTest { + @Test + fun hardwareNavigationKeysReachThePtyAndKeepFocus() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val sent = StringBuilder() + val input = EditText(activity).apply { + setSingleLine(true) + isFocusableInTouchMode = true + setOnKeyListener(TerminalNavigationKeyListener { sent.append(it) }) + } + val nextControl = Button(activity).apply { isFocusableInTouchMode = true } + val root = LinearLayout(activity).apply { + orientation = LinearLayout.VERTICAL + addView(input) + addView(nextControl) + } + activity.setContentView(root) + // Focus search below the input only finds controls that have bounds. + root.measure( + View.MeasureSpec.makeMeasureSpec(1080, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(1920, View.MeasureSpec.EXACTLY), + ) + root.layout(0, 0, 1080, 1920) + assertTrue(input.requestFocus()) + + val shift = KeyEvent.META_SHIFT_ON or KeyEvent.META_SHIFT_LEFT_ON + val keys = listOf( + Triple(KeyEvent.KEYCODE_DPAD_UP, 0, "\u001b[A"), + Triple(KeyEvent.KEYCODE_DPAD_DOWN, 0, "\u001b[B"), + Triple(KeyEvent.KEYCODE_DPAD_RIGHT, 0, "\u001b[C"), + Triple(KeyEvent.KEYCODE_DPAD_LEFT, 0, "\u001b[D"), + Triple(KeyEvent.KEYCODE_TAB, 0, "\t"), + Triple(KeyEvent.KEYCODE_TAB, shift, "\u001b[Z"), + Triple(KeyEvent.KEYCODE_ESCAPE, 0, "\u001b"), + ) + for ((keyCode, meta, expected) in keys) { + sent.clear() + input.dispatchKeyEvent(KeyEvent(0, 0, KeyEvent.ACTION_DOWN, keyCode, 0, meta)) + input.dispatchKeyEvent(KeyEvent(0, 0, KeyEvent.ACTION_UP, keyCode, 0, meta)) + assertEquals("bytes for key $keyCode meta $meta", expected, sent.toString()) + assertTrue("focus left the input after key $keyCode meta $meta", input.isFocused) + } + + sent.clear() + val ctrl = KeyEvent.META_CTRL_ON or KeyEvent.META_CTRL_LEFT_ON + input.dispatchKeyEvent(KeyEvent(0, 0, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_UP, 0, ctrl)) + assertEquals("modified arrows are left to the app, as on iOS", "", sent.toString()) + } +} diff --git a/apps/mobile/src/components/ui/AppBar.android.tsx b/apps/mobile/src/components/ui/AppBar.android.tsx index f312999c58d14..9708048704235 100644 --- a/apps/mobile/src/components/ui/AppBar.android.tsx +++ b/apps/mobile/src/components/ui/AppBar.android.tsx @@ -155,13 +155,15 @@ function AppBarView(props: AppBarProps & { readonly searchState?: SearchState }) )); const confirmButton = props.confirm ? ( - - ); - if (!tooltip) return button; - return ( - - - {CURSOR_KEYCHAIN_COPY} - - ); -} - -function CursorEnableRow({ - environmentId, - label, - showEnvironment, - onEnabled, -}: { - readonly environmentId: EnvironmentId; - readonly label: string; - readonly showEnvironment: boolean; - readonly onEnabled: () => void; -}) { - return ( -
- - - - Cursor{showEnvironment ? ` · ${label}` : ""} - - -
- ); -} - -function CursorEnableLimits({ - environments, - onEnabled, -}: { - readonly environments: readonly EnvironmentUsageStatus[]; - readonly onEnabled: () => void; -}) { - return ( -
-

- - Cursor -

-
-

{CURSOR_KEYCHAIN_COPY}

-
- {environments.map((environment) => ( - 1 ? `Enable on ${environment.label}` : "Enable"} - onEnabled={onEnabled} - tooltip={false} - /> - ))} -
-
-
- ); -} - function ProviderMark({ provider, className, diff --git a/apps/web/src/hooks/useDefaultTheme.ts b/apps/web/src/hooks/useDefaultTheme.ts index 5731da6eb53d5..9077142289c03 100644 --- a/apps/web/src/hooks/useDefaultTheme.ts +++ b/apps/web/src/hooks/useDefaultTheme.ts @@ -1,6 +1,6 @@ import { appAtomRegistry } from "../rpc/atomRegistry"; -import { primaryEnvironmentIdAtom } from "../state/primaryEnvironment"; -import { primaryServerSettingsAtom } from "../state/server"; +import { themeEnvironmentIdAtom } from "../state/primaryEnvironment"; +import { themeServerSettingsAtom } from "../state/server"; import { getThemeDefinition, singleAppearanceOf, subscribeToCustomThemes } from "../themePalette"; import { setAppearanceMode, setTheme } from "./useTheme"; @@ -45,9 +45,9 @@ function writeAppliedGeneration(storageKey: string, generation: string): void { function adoptDefaultTheme(): void { if (typeof window === "undefined") return; - const environmentId = appAtomRegistry.get(primaryEnvironmentIdAtom); + const environmentId = appAtomRegistry.get(themeEnvironmentIdAtom); if (environmentId === null) return; - const { defaultTheme, defaultThemeSetAt } = appAtomRegistry.get(primaryServerSettingsAtom); + const { defaultTheme, defaultThemeSetAt } = appAtomRegistry.get(themeServerSettingsAtom); const definition = getThemeDefinition(defaultTheme); const storageKey = `${APPLIED_DEFAULT_THEME_STORAGE_PREFIX}${environmentId}`; const generation = defaultThemeToApply({ @@ -68,15 +68,15 @@ function adoptDefaultTheme(): void { } /** - * Applies the primary environment's default theme once per generation. Start it after the + * Applies the theme environment's default theme once per generation. Start it after the * environment theme sync so a first-run client adopting the environment's own theme finds it * already in the library; it re-checks when published or custom palettes change, so a theme * that resolves late still applies. */ export function startDefaultThemeAdoption(): () => void { const unsubscribes = [ - appAtomRegistry.subscribe(primaryEnvironmentIdAtom, adoptDefaultTheme), - appAtomRegistry.subscribe(primaryServerSettingsAtom, adoptDefaultTheme), + appAtomRegistry.subscribe(themeEnvironmentIdAtom, adoptDefaultTheme), + appAtomRegistry.subscribe(themeServerSettingsAtom, adoptDefaultTheme), subscribeToCustomThemes(adoptDefaultTheme), ]; adoptDefaultTheme(); diff --git a/apps/web/src/hooks/useEnvironmentTheme.test.ts b/apps/web/src/hooks/useEnvironmentTheme.test.ts index 80b8fcbc216a0..855d1a3d5463a 100644 --- a/apps/web/src/hooks/useEnvironmentTheme.test.ts +++ b/apps/web/src/hooks/useEnvironmentTheme.test.ts @@ -9,7 +9,7 @@ const publishedThemes = vi.hoisted(() => ({ vi.mock(import("../state/server"), async () => { const { Atom } = await import("effect/reactivity"); publishedThemes.atom = Atom.make>([]); - return { primaryServerEnvironmentThemesAtom: publishedThemes.atom } as never; + return { themeServerEnvironmentThemesAtom: publishedThemes.atom } as never; }); const refreshTheme = vi.hoisted(() => vi.fn()); vi.mock(import("./useTheme"), () => ({ refreshTheme }) as never); diff --git a/apps/web/src/state/primaryEnvironment.test.ts b/apps/web/src/state/primaryEnvironment.test.ts new file mode 100644 index 0000000000000..2a90209ae463f --- /dev/null +++ b/apps/web/src/state/primaryEnvironment.test.ts @@ -0,0 +1,41 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { Atom, AtomRegistry } from "effect/reactivity"; +import { describe, expect, it } from "vite-plus/test"; + +import { createThemeEnvironmentIdAtom } from "./primaryEnvironment"; + +const PRIMARY = EnvironmentId.make("primary"); +const SERVICE = EnvironmentId.make("service"); +const REMOTE = EnvironmentId.make("remote"); + +function themeEnvironmentId(input: { + primary: EnvironmentId | null; + host: string | null; + catalog: ReadonlyArray; +}) { + const atom = createThemeEnvironmentIdAtom({ + primaryEnvironmentIdAtom: Atom.make(input.primary), + hostEnvironmentIdAtom: Atom.make(input.host), + catalogValueAtom: Atom.make({ entries: new Map(input.catalog.map((id) => [id, null])) }), + }); + return AtomRegistry.make().get(atom); +} + +describe("themeEnvironmentIdAtom", () => { + it("prefers the primary environment over this machine's background service", () => { + expect( + themeEnvironmentId({ primary: PRIMARY, host: "service", catalog: [PRIMARY, SERVICE] }), + ).toBe(PRIMARY); + }); + + it("uses the connected background service sharing the app's T3 home when there is no primary", () => { + expect(themeEnvironmentId({ primary: null, host: "service", catalog: [REMOTE, SERVICE] })).toBe( + SERVICE, + ); + }); + + it("uses no environment when that service is not among the connections", () => { + expect(themeEnvironmentId({ primary: null, host: "service", catalog: [REMOTE] })).toBeNull(); + expect(themeEnvironmentId({ primary: null, host: null, catalog: [REMOTE] })).toBeNull(); + }); +}); diff --git a/apps/web/src/state/primaryEnvironment.ts b/apps/web/src/state/primaryEnvironment.ts index 75739b8ddeffb..92b0b4bb8b3d5 100644 --- a/apps/web/src/state/primaryEnvironment.ts +++ b/apps/web/src/state/primaryEnvironment.ts @@ -1,6 +1,8 @@ +import type { EnvironmentId } from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; import { environmentCatalog } from "../connection/catalog"; +import { appAtomRegistry } from "../rpc/atomRegistry"; export const primaryEnvironmentIdAtom = Atom.make((get) => { for (const [environmentId, entry] of get(environmentCatalog.catalogValueAtom).entries) { @@ -10,3 +12,38 @@ export const primaryEnvironmentIdAtom = Atom.make((get) => { } return null; }).pipe(Atom.withLabel("web-primary-environment-id")); + +const hostEnvironmentIdAtom = Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel("web-host-environment-id"), +); + +// Without a primary, only the service sharing the desktop app's T3 home supplies themes. +export function createThemeEnvironmentIdAtom(input: { + readonly primaryEnvironmentIdAtom: Atom.Atom; + readonly hostEnvironmentIdAtom: Atom.Atom; + readonly catalogValueAtom: Atom.Atom<{ readonly entries: ReadonlyMap }>; +}) { + return Atom.make((get) => { + const primaryEnvironmentId = get(input.primaryEnvironmentIdAtom); + if (primaryEnvironmentId !== null) return primaryEnvironmentId; + const hostEnvironmentId = get(input.hostEnvironmentIdAtom); + for (const environmentId of get(input.catalogValueAtom).entries.keys()) { + if (environmentId === hostEnvironmentId) return environmentId; + } + return null; + }).pipe(Atom.withLabel("web-theme-environment-id")); +} + +export const themeEnvironmentIdAtom = createThemeEnvironmentIdAtom({ + primaryEnvironmentIdAtom, + hostEnvironmentIdAtom, + catalogValueAtom: environmentCatalog.catalogValueAtom, +}); + +export function startHostEnvironmentLookup(): void { + void window.desktopBridge + ?.getHostEnvironmentId?.() + .then((environmentId) => appAtomRegistry.set(hostEnvironmentIdAtom, environmentId)) + .catch(() => {}); +} diff --git a/apps/web/src/state/server.ts b/apps/web/src/state/server.ts index 9d1c591aaeea9..c0b70bd178ed9 100644 --- a/apps/web/src/state/server.ts +++ b/apps/web/src/state/server.ts @@ -27,7 +27,7 @@ import { isConnectedWithConfig } from "../components/settings/scopedSettings"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; import { environmentAtomList } from "./environmentAtomList"; -import { primaryEnvironmentIdAtom } from "./primaryEnvironment"; +import { primaryEnvironmentIdAtom, themeEnvironmentIdAtom } from "./primaryEnvironment"; import { environmentSession } from "./session"; import { reuseProvidersIgnoringCheckedAt } from "./serverProviders.logic"; @@ -175,10 +175,19 @@ export const primaryServerAvailableEditorsAtom = Atom.make( const EMPTY_ENVIRONMENT_THEMES: ReadonlyArray = []; -export const primaryServerEnvironmentThemesAtom = Atom.make( +const themeServerConfigAtom = Atom.make((get): ServerConfig | null => { + const environmentId = get(themeEnvironmentIdAtom); + return environmentId === null ? null : get(serverEnvironment.configValueAtom(environmentId)); +}).pipe(Atom.withLabel("web-theme-server-config")); + +export const themeServerEnvironmentThemesAtom = Atom.make( (get): ReadonlyArray => - get(primaryServerConfigAtom)?.environmentThemes ?? EMPTY_ENVIRONMENT_THEMES, -).pipe(Atom.withLabel("web-primary-server-environment-themes")); + get(themeServerConfigAtom)?.environmentThemes ?? EMPTY_ENVIRONMENT_THEMES, +).pipe(Atom.withLabel("web-theme-server-environment-themes")); + +export const themeServerSettingsAtom = Atom.make( + (get): ServerSettings => get(themeServerConfigAtom)?.settings ?? DEFAULT_SERVER_SETTINGS, +).pipe(Atom.withLabel("web-theme-server-settings")); export const providersIgnoringCheckedAtAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get): ReadonlyArray => diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index f47a65cd08268..bf5de2e330a3f 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -8,11 +8,7 @@ import { type UsageProviderKind, type UsageSummaryInput, } from "@t3tools/contracts"; -import { - needsCursorKeychainAccess, - refreshUsage, - usageSummaryInput, -} from "@t3tools/client-runtime/state/usage"; +import { refreshUsage, usageSummaryInput } from "@t3tools/client-runtime/state/usage"; import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access"; import { AsyncResult, Atom } from "effect/reactivity"; import { useState } from "react"; @@ -31,7 +27,6 @@ export interface EnvironmentUsageStatus { readonly isConnected: boolean; readonly error: string | null; readonly summary: UsageSummary | null; - readonly needsCursorKeychainAccess: boolean; } const usageByWindowAtom = Atom.family((windowKey: string) => @@ -55,7 +50,6 @@ const usageByWindowAtom = Atom.family((windowKey: string) => isConnected, ...access, summary: null, - needsCursorKeychainAccess: false, }); continue; } @@ -77,10 +71,6 @@ const usageByWindowAtom = Atom.family((windowKey: string) => isConnected, error: result._tag === "Failure" ? "This environment could not report usage." : null, summary, - needsCursorKeychainAccess: needsCursorKeychainAccess( - summary, - get(serverEnvironment.providersValueAtom(environmentId)), - ), }); } return statuses; diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index 3c561e5417eac..309010a383602 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -30,7 +30,7 @@ import { } from "./lib/contextChipAccents"; import { appAtomRegistry, subscribeAtomChanges } from "./rpc/atomRegistry"; -import { primaryServerEnvironmentThemesAtom } from "./state/server"; +import { themeServerEnvironmentThemesAtom } from "./state/server"; export { EMBER_THEME, GROVE_THEME, IRIS_THEME, OCEAN_THEME, T3_CHAT_THEME, THEME_COLOR_ROLES }; export type { ThemeAppearance, ThemeColorRole, ThemeColors, ThemeDefinition, ThemeVariants }; @@ -315,7 +315,7 @@ export function publishedThemeDefinitions( // Every reconnect snapshot delivers a fresh but usually identical array; keeping the // previous one skips regenerating palettes and repainting the document for it. const publishedThemesAtom = Atom.make((get): ReadonlyArray => { - const next = get(primaryServerEnvironmentThemesAtom); + const next = get(themeServerEnvironmentThemesAtom); const previous = Option.getOrNull(get.self>()); return previous !== null && Equal.equals(previous, next) ? previous : next; }).pipe(Atom.withLabel("web-published-environment-themes")); diff --git a/docs/user/appearance.md b/docs/user/appearance.md index 49d8e02d79a82..b27173dd60ff9 100644 --- a/docs/user/appearance.md +++ b/docs/user/appearance.md @@ -57,7 +57,9 @@ change. Export your theme as JSON to share it. ## Environment themes Environment themes and defaults come from the server serving your web app or the desktop app's -main local environment. app.t3.codes and additional connections do not use them. +main local environment. With the desktop app's local environment off, they come from the +[background service](./background-service.md) on the same machine while the app is connected to +it. app.t3.codes and other connections do not use them. Select a published theme in **Settings → Appearance** to follow its palette as the server updates it. **Duplicate** makes an independent copy you can edit. A saved custom theme with the same ID diff --git a/docs/user/outside-agents.md b/docs/user/outside-agents.md index c0e12c91c6a42..d226c7cbdfd33 100644 --- a/docs/user/outside-agents.md +++ b/docs/user/outside-agents.md @@ -45,9 +45,10 @@ Only approve a sign-in you just started. To approve, enter a pairing code from browser already signed in to the environment with permission to manage access can approve without a code. -Then choose what the agent may do: +Then choose what the agent may do. **Auto** is selected by default, or the +closest lower mode allowed by your organization’s policy: -- **Read only** is the default. The agent can read projects, threads, and project +- **Read only** lets the agent read projects, threads, and project code, recall saved facts, and see the available providers and models. It cannot change source files or saved facts. - **Supervised** through **Full access** also let it start, message and stop diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 94c9cc03c8de9..8de252f90c35e 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -304,9 +304,10 @@ You can also choose **Side chat** from the **+** menu in the right panel. The si chat opens as a tab there, with its own composer. Side chats do not show in the sidebar. Find one again under **Side chats** in thread details. -A side chat works in the same folder as the thread and inherits its permissions -when created, including for its first reply. Change permissions for an individual -side chat in its composer. Later changes to the parent do not change existing side chats. +A side chat works in the same folder as the thread and starts with the permissions, +model, and reasoning level shown in the thread's composer, including for its first +reply. Change them for an individual side chat in its composer. Later changes to the +parent do not change existing side chats. To use what you learned, choose **Bring back**. The thread gets the side chat with your next message there. Bring back is available while the thread is idle. diff --git a/docs/user/usage.md b/docs/user/usage.md index 351639c1da81f..35e94277b2628 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -25,13 +25,9 @@ databases, including T3-managed profiles. Set `OPENCODE_DATA_DIR` or `ANTIGRAVIT server to read a different data directory; comma-separated paths read multiple directories. Cursor reads account usage from Cursor's API for the account you signed in with in T3 or set as -`CURSOR_API_KEY`, or otherwise the CLI login saved on the server. -This includes headless T3 sessions and desktop usage across machines; the same account counts -once across connected environments. Without any of these, T3 shows a -notice instead of incomplete local totals. T3 does not estimate missing tokens from conversation text. -On macOS without a T3 sign-in, choose **Enable Cursor usage** on Usage to allow T3 to read your -existing CLI login from Keychain. You can turn it off in **Settings → Providers → Usage providers**. macOS may ask -you to allow access on the server Mac. +`CURSOR_API_KEY`. This includes headless T3 sessions and desktop usage across machines; the same +account counts once across connected environments. T3 does not estimate missing tokens from +conversation text. Usage includes each configured account's history, including disabled accounts. Custom homes follow the account's home setting or its `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, or `GROK_HOME` environment @@ -161,10 +157,9 @@ in OpenCode with an API key. A Console browser sign-in alone does not report the your Go API key as `OPENCODE_API_KEY` in the OpenCode instance's **Environment variables**, then refresh provider status. Cursor reports its monthly allowance, including separate Auto and API usage, for `CURSOR_AUTH_TOKEN` when it is set, -otherwise for the account you signed in with in T3 or set as `CURSOR_API_KEY`, otherwise for the CLI -login. On macOS, this includes the default Keychain login after you enable Cursor usage. Sign-in, API key, -and Keychain login report limits only with Cursor's default API endpoint. If you configure a custom -Cursor endpoint, use an explicit token or file-based CLI login for limits. +otherwise for the account you signed in with in T3 or set as `CURSOR_API_KEY`. +Sign-in and API keys report limits only with Cursor's default API endpoint. If you configure a custom +Cursor endpoint, use an explicit `CURSOR_AUTH_TOKEN` for limits. Grok reports the remaining subscription allowance and reset time for its current billing period after signing in with `grok login`. Explicit `XAI_API_KEY` connections and custom authentication diff --git a/packages/client-runtime/src/modelTraits.test.ts b/packages/client-runtime/src/modelTraits.test.ts new file mode 100644 index 0000000000000..67b22e3099464 --- /dev/null +++ b/packages/client-runtime/src/modelTraits.test.ts @@ -0,0 +1,36 @@ +import { ProviderDriverKind, type ProviderOptionDescriptor } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { buildTraitsTriggerDisplay, getDescriptorStringValue } from "./modelTraits.ts"; + +describe("buildTraitsTriggerDisplay", () => { + it.each([ + { currentValue: undefined, defaultValue: undefined, value: null, label: "Thinking" }, + { currentValue: undefined, defaultValue: "high", value: "high", label: "High" }, + { currentValue: "low", defaultValue: undefined, value: "low", label: "Low" }, + { currentValue: "max", defaultValue: "high", value: "max", label: "Max" }, + { id: "effort", currentValue: undefined, defaultValue: undefined, value: null, label: "" }, + ])("labels Pi thinking without inventing a selection: $label ($value)", (input) => { + const descriptor: Extract = { + id: input.id ?? "thinking", + label: "Thinking", + type: "select", + options: ["low", "high", "max"].map((id) => ({ + id, + label: id[0]!.toUpperCase() + id.slice(1), + ...(id === input.defaultValue ? { isDefault: true } : {}), + })), + ...(input.currentValue ? { currentValue: input.currentValue } : {}), + }; + + expect( + buildTraitsTriggerDisplay({ + provider: ProviderDriverKind.make("pi"), + descriptors: [descriptor], + primarySelectDescriptorId: descriptor.id, + ultrathinkPromptControlled: false, + }).label, + ).toBe(input.label); + expect(getDescriptorStringValue(descriptor)).toBe(input.value); + }); +}); diff --git a/packages/client-runtime/src/modelTraits.ts b/packages/client-runtime/src/modelTraits.ts index 9a3dc06a15173..3782b79e567f8 100644 --- a/packages/client-runtime/src/modelTraits.ts +++ b/packages/client-runtime/src/modelTraits.ts @@ -169,11 +169,11 @@ export function buildTraitsTriggerDisplay(input: { ? "Ultrathink" : descriptor.type === "boolean" ? `${descriptor.label} ${descriptor.currentValue === true ? "On" : "Off"}` - : getProviderOptionCurrentLabel( + : (getProviderOptionCurrentLabel( descriptor, input.modelSelection, input.reportedModelSelection, - ); + ) ?? (descriptor.id === "thinking" ? descriptor.label : null)); if (typeof label === "string" && label.length > 0) { if (reasoningLabelIndex === -1 && isReasoningSelect(descriptor)) { reasoningLabelIndex = labels.length; diff --git a/packages/client-runtime/src/state/pendingUserInput.test.ts b/packages/client-runtime/src/state/pendingUserInput.test.ts index 7f5a2b0b84c3d..5baaaac41c83a 100644 --- a/packages/client-runtime/src/state/pendingUserInput.test.ts +++ b/packages/client-runtime/src/state/pendingUserInput.test.ts @@ -178,3 +178,30 @@ describe("buildPendingUserInputAnswers", () => { }); }); }); + +it.each([ + ["single-select", singleSelectQuestion, ["Orchestration-first"], '"Orchestration-first"'], + [ + "multi-select", + { + ...singleSelectQuestion, + multiSelect: true, + options: [{ label: "Server" }, { label: "Web" }], + }, + ["Server", "Web"], + '["Server","Web"]', + ], +])("sends a %s answer to a question with the id __proto__", (_kind, question, selected, json) => { + const answers = buildPendingUserInputAnswers( + [ + { ...question, id: "__proto__" }, + { ...singleSelectQuestion, id: "name" }, + ], + { + ["__proto__"]: { selectedOptionValues: selected }, + name: { selectedOptionValues: ["Orchestration-first"] }, + }, + ); + + expect(JSON.stringify(answers)).toBe(`{"__proto__":${json},"name":"Orchestration-first"}`); +}); diff --git a/packages/client-runtime/src/state/pendingUserInput.ts b/packages/client-runtime/src/state/pendingUserInput.ts index 1c71cdb29b4df..520fb626b5936 100644 --- a/packages/client-runtime/src/state/pendingUserInput.ts +++ b/packages/client-runtime/src/state/pendingUserInput.ts @@ -162,15 +162,15 @@ export function buildPendingUserInputAnswers( questions: ReadonlyArray, draftAnswers: Record, ): Record | null { - const answers: Record = {}; + const entries: Array<[string, PendingUserInputAnswer]> = []; for (const question of questions) { const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]); if (answer === null) { return null; } - answers[question.id] = answer; + entries.push([question.id, answer]); } - return answers; + return Object.fromEntries(entries); } diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 0e8ecdc890bd2..baa2657119db0 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1136,7 +1136,6 @@ export function createServerEnvironmentAtoms( Object.keys(aliases) .sort() .map((model) => [model, aliases[model]]), - settings?.cursorKeychainUsageEnabled ?? false, ]); }).pipe(Atom.withLabel(`environment-data:server:usage-scan-settings:${environmentId}`)), ); diff --git a/packages/client-runtime/src/state/threadRequests.test.ts b/packages/client-runtime/src/state/threadRequests.test.ts index c2e1024e232ae..c8c43f2334087 100644 --- a/packages/client-runtime/src/state/threadRequests.test.ts +++ b/packages/client-runtime/src/state/threadRequests.test.ts @@ -235,6 +235,20 @@ describe("initial user input drafts", () => { customAnswer: " Proposed commit\n\n", }); }); + + it("seeds a question with the id __proto__ as its own draft", () => { + const seeded = seedUserInputDraftAnswers( + [ + { id: "__proto__", initialAnswer: "Proposed" }, + { id: "name", initialAnswer: "Control" }, + ], + {}, + ); + + expect(JSON.stringify(seeded)).toBe( + '{"__proto__":{"customAnswer":"Proposed"},"name":{"customAnswer":"Control"}}', + ); + }); }); const projection: OrchestrationV2ThreadProjection = { ...v2Projection, diff --git a/packages/client-runtime/src/state/threadRequests.ts b/packages/client-runtime/src/state/threadRequests.ts index 6cbad534cba1d..7c3c44c4caa59 100644 --- a/packages/client-runtime/src/state/threadRequests.ts +++ b/packages/client-runtime/src/state/threadRequests.ts @@ -99,18 +99,15 @@ export function seedUserInputDraftAnswers, drafts: Record, ): Record { - let seeded: Record = drafts; - for (const question of questions) { - if ( - question.initialAnswer === undefined || - question.allowCustomAnswer === false || - seeded[question.id] !== undefined - ) - continue; - if (seeded === drafts) seeded = { ...drafts }; - seeded[question.id] = { customAnswer: question.initialAnswer }; - } - return seeded; + // Entries, not assignment, so an id such as `__proto__` becomes an own draft. + const seeds = questions.flatMap((question) => + question.initialAnswer === undefined || + question.allowCustomAnswer === false || + Object.hasOwn(drafts, question.id) + ? [] + : [[question.id, { customAnswer: question.initialAnswer }] as const], + ); + return seeds.length === 0 ? drafts : { ...drafts, ...Object.fromEntries(seeds) }; } /** Keys a user-input draft by thread so a thread's resolved requests can be pruned together. */ diff --git a/packages/client-runtime/src/state/usage.test.ts b/packages/client-runtime/src/state/usage.test.ts index 0650b1f2093c7..380e2de731b5d 100644 --- a/packages/client-runtime/src/state/usage.test.ts +++ b/packages/client-runtime/src/state/usage.test.ts @@ -1,10 +1,7 @@ import { EnvironmentId, - ProviderDriverKind, - ProviderInstanceId, UsageDay, USAGE_CONTRACT_VERSION, - type ServerProvider, type UsageModelFilter, type UsageSummary, type UsageSummaryInput, @@ -14,12 +11,7 @@ import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import type { EnvironmentPresentation } from "../connection/presentation.ts"; -import { - cursorKeychainAccessEnvironments, - needsCursorKeychainAccess, - refreshUsage, - refreshUsageLimits, -} from "./usage.ts"; +import { refreshUsage, refreshUsageLimits } from "./usage.ts"; const input = { sinceDay: UsageDay.make("2026-09-05"), @@ -199,91 +191,17 @@ describe("manual usage refresh", () => { }); describe("limits refresh cooldown", () => { - it("runs a fresh check after an in-flight check when settings change", async () => { - const id = EnvironmentId.make("limits-after-enable"); + it("shares an in-flight check and skips duplicate automatic refreshes", async () => { + const id = EnvironmentId.make("limits-in-flight"); const oldCheck = Promise.withResolvers(); const first = refreshUsageLimits(id, () => oldCheck.promise, true); const newCheck = vi.fn(async () => "new limits"); - const afterEnable = refreshUsageLimits(id, newCheck, false, true); + const manual = refreshUsageLimits(id, newCheck); + expect(await refreshUsageLimits(id, newCheck, true)).toBeUndefined(); expect(newCheck).not.toHaveBeenCalled(); oldCheck.resolve("old limits"); expect(await first).toBe("old limits"); - expect(await afterEnable).toBe("new limits"); - expect(newCheck).toHaveBeenCalledTimes(1); - }); -}); - -describe("needsCursorKeychainAccess", () => { - const cursorPrompt: UsageSummary = { - ...summary, - sources: [ - { - fingerprint: { - hostId: "host", - provider: "cursor", - resolvedHomePath: "/Users/me/.cursor/auth.json", - volumeId: "volume", - }, - status: "ok", - scannedFiles: 0, - skippedFiles: 0, - malformedRecords: 0, - distinctSessions: 0, - message: "Cursor account usage is off on this environment.", - action: "enableCursorKeychain", - }, - ], - }; - const cursor = (status: ServerProvider["status"]): ServerProvider => ({ - instanceId: ProviderInstanceId.make("cursor"), - driver: ProviderDriverKind.make("cursor"), - enabled: status !== "disabled", - installed: status === "ready", - version: null, - status, - auth: { status: "unknown" }, - checkedAt: "2026-09-05T12:00:00.000Z", - models: [], - slashCommands: [], - skills: [], - }); - - it("offers access only when Cursor is ready on that environment", () => { - expect(needsCursorKeychainAccess(cursorPrompt, [cursor("ready")])).toBe(true); - expect(needsCursorKeychainAccess(cursorPrompt, [cursor("error")])).toBe(false); - expect(needsCursorKeychainAccess(cursorPrompt, [cursor("disabled")])).toBe(false); - expect(needsCursorKeychainAccess(cursorPrompt, [])).toBe(false); - expect(needsCursorKeychainAccess(cursorPrompt, null)).toBe(false); - expect(needsCursorKeychainAccess(summary, [cursor("ready")])).toBe(false); - }); - - it("stops offering access once any environment reads the Cursor account", () => { - const off = { summary: cursorPrompt, needsCursorKeychainAccess: true }; - const account: UsageSummary = { - ...summary, - sources: [ - { - fingerprint: { - hostId: "cursor.com", - provider: "cursor", - resolvedHomePath: "cursor-account:abc", - volumeId: "abc", - }, - status: "ok", - scannedFiles: 1, - skippedFiles: 0, - malformedRecords: 0, - distinctSessions: 1, - message: null, - }, - ], - }; - expect(cursorKeychainAccessEnvironments([off, off])).toEqual([off, off]); - expect( - cursorKeychainAccessEnvironments([ - off, - { summary: account, needsCursorKeychainAccess: false }, - ]), - ).toEqual([]); + expect(await manual).toBe("old limits"); + expect(newCheck).not.toHaveBeenCalled(); }); }); diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts index 2dea725be1f14..f302ffbdfd85c 100644 --- a/packages/client-runtime/src/state/usage.ts +++ b/packages/client-runtime/src/state/usage.ts @@ -1,10 +1,4 @@ -import type { - EnvironmentId, - ServerProvider, - UsageModelFilter, - UsageSummary, - UsageSummaryInput, -} from "@t3tools/contracts"; +import type { EnvironmentId, UsageModelFilter, UsageSummaryInput } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import type { AtomRegistry } from "effect/reactivity"; @@ -15,35 +9,6 @@ import type { createServerEnvironmentAtoms } from "./server.ts"; const isEnvironmentRpcUnavailable = Schema.is(EnvironmentRpcUnavailableError); -export function needsCursorKeychainAccess( - summary: UsageSummary | null, - providers: readonly ServerProvider[] | null, -): boolean { - return ( - summary?.sources.some((source) => source.action === "enableCursorKeychain") === true && - providers?.some((provider) => provider.driver === "cursor" && provider.status === "ready") === - true - ); -} - -/** - * Any environment reading a Cursor account already reports that account's - * history from every machine, so the prompt only adds duplicates there. - */ -export function cursorKeychainAccessEnvironments< - E extends { readonly summary: UsageSummary | null; readonly needsCursorKeychainAccess: boolean }, ->(environments: readonly E[]): readonly E[] { - const hasCursorAccount = environments.some((environment) => - environment.summary?.sources.some( - (source) => - source.fingerprint.provider === "cursor" && source.fingerprint.hostId === "cursor.com", - ), - ); - return hasCursorAccount - ? [] - : environments.filter((environment) => environment.needsCursorKeychainAccess); -} - /** The Usage tab asks each environment with that environment's configured model filter. */ export function usageSummaryInput( input: UsageSummaryInput, @@ -59,16 +24,9 @@ export async function refreshUsageLimits( environmentId: EnvironmentId, refresh: () => Promise, automatic = false, - afterPending = false, ): Promise { const pending = limitsRefreshes.get(environmentId); if (pending !== undefined) { - if (afterPending) { - try { - await pending; - } catch {} - return refreshUsageLimits(environmentId, refresh, false, true); - } return automatic ? undefined : ((await pending) as A); } const refreshAfter = limitsRefreshAfter.get(environmentId) ?? 0; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index e3e5574328ac8..8925b9819f2dd 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1053,6 +1053,8 @@ export interface DesktopBridge { getLocalEnvironmentBootstraps: () => readonly DesktopEnvironmentBootstrap[]; getLocalEnvironmentEnabled: () => boolean; setLocalEnvironmentEnabled: (enabled: boolean) => Promise; + /** The environment ID recorded in this app's T3 home, such as its own machine's background service. */ + getHostEnvironmentId?: () => Promise; getLocalEnvironmentBearerToken: () => Promise; getClientSettings: () => Promise; setClientSettings: (settings: ClientSettings) => Promise; diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index c0fb1cfa5f39f..ad2ac1125691e 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -486,6 +486,7 @@ export const PullRequestLinkedThreadsResult = Schema.Struct({ projectId: ProjectId, title: Schema.String, archivedAt: Schema.NullOr(IsoDateTime), + settled: Schema.optionalKey(Schema.Boolean), }), ), }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index e8934795faaa3..42fa7e687e858 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1539,9 +1539,6 @@ export const ServerSettings = Schema.Struct({ secretProviders: Schema.Record(SecretProviderId, SecretProviderConfig).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), - cursorKeychainUsageEnabled: Schema.Boolean.pipe( - Schema.withDecodingDefault(Effect.succeed(false)), - ), usagePriceOverrides: Schema.Record(TrimmedNonEmptyString, UsageModelPriceOverride).pipe( Schema.withDecodingDefault(Effect.succeed({})), @@ -1934,7 +1931,6 @@ export const ServerSettingsPatch = Schema.Struct({ secretProviders: Schema.optionalKey( Schema.Record(SecretProviderId, Schema.NullOr(SecretProviderConfigWrite)), ), - cursorKeychainUsageEnabled: Schema.optionalKey(Schema.Boolean), usagePriceOverrides: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, Schema.NullOr(UsageModelPriceOverride)), diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 7f5a609eee20a..b473a7264b1a4 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -91,7 +91,6 @@ export const UsageSource = Schema.Struct({ /** Total this count; bucket counts double-count sessions spanning days or models. */ distinctSessions: NonNegativeInt, message: Schema.NullOr(TrimmedNonEmptyString), - action: Schema.optionalKey(Schema.Literal("enableCursorKeychain")), /** Cached response during refresh; repeat with awaitRefresh for fresh data. */ refreshing: Schema.optionalKey(Schema.Literal(true)), }); diff --git a/packages/effect-acp/scripts/generate.ts b/packages/effect-acp/scripts/generate.ts index 134b64aa0cf3c..8617d16dd4b15 100644 --- a/packages/effect-acp/scripts/generate.ts +++ b/packages/effect-acp/scripts/generate.ts @@ -166,6 +166,37 @@ function isJsonArray(value: Schema.Json | undefined): value is ReadonlyArray Schema.Json, +): Record { + return Object.fromEntries( + Object.entries(value).map(([key, child]) => { + if (DATA_KEYWORDS.has(key)) return [key, child]; + if ( + key === "properties" && + child !== null && + typeof child === "object" && + !isJsonArray(child) + ) { + return [ + key, + Object.fromEntries( + Object.entries(child).map(([name, schema]) => [name, transform(schema)]), + ), + ]; + } + return [key, isJsonArray(child) ? child.map(transform) : transform(child)]; + }), + ); +} + function normalizeSchemaKeywords(value: Schema.Json): Schema.Json { if (isJsonArray(value)) { return value.map(normalizeSchemaKeywords); @@ -174,11 +205,7 @@ function normalizeSchemaKeywords(value: Schema.Json): Schema.Json { return value; } - const normalizedEntries = Object.entries(value).map(([key, child]) => [ - key, - normalizeSchemaKeywords(child), - ]); - const normalizedObject = Object.fromEntries(normalizedEntries) as Record; + const normalizedObject = mapSubschemas(value, normalizeSchemaKeywords); // ACP's forward-compatible variants exclude known discriminator values. // The Effect generator does not support `not`; express this exact exclusion // as a string pattern while preserving validation of the known variants. @@ -316,9 +343,7 @@ function flattenCompositions( ): Schema.Json { if (isJsonArray(value)) return value.map((child) => flattenCompositions(child, definitions)); if (value === null || typeof value !== "object") return value; - const normalized = Object.fromEntries( - Object.entries(value).map(([key, child]) => [key, flattenCompositions(child, definitions)]), - ); + const normalized = mapSubschemas(value, (child) => flattenCompositions(child, definitions)); const { allOf, anyOf, ...base } = normalized; const merge = (left: Schema.Json, right: Schema.Json): Schema.Json => { if ( diff --git a/packages/effect-acp/src/_generated/schema-v1.gen.ts b/packages/effect-acp/src/_generated/schema-v1.gen.ts index 268118dab204e..20a7c26d61c78 100644 --- a/packages/effect-acp/src/_generated/schema-v1.gen.ts +++ b/packages/effect-acp/src/_generated/schema-v1.gen.ts @@ -946,8 +946,22 @@ export const EnumOption = Schema.Struct({ identifier: "EnumOption", }); -export type TitledMultiSelectItems = never; -export const TitledMultiSelectItems = Schema.Never.annotate({ +export type TitledMultiSelectItems = { + readonly anyOf: ReadonlyArray; + readonly _meta?: { readonly [x: string]: Schema.Json } | null; +}; +export const TitledMultiSelectItems = Schema.Struct({ + anyOf: Schema.Array(EnumOption).annotate({ description: "Titled enum options." }), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Json.annotate({ expected: "JSON value" })).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nOptional. Omitted and `null` are equivalent and mean no metadata.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), +}).annotate({ description: "Items definition for titled multi-select enum properties.", identifier: "TitledMultiSelectItems", }); diff --git a/packages/effect-acp/src/_generated/schema.gen.ts b/packages/effect-acp/src/_generated/schema.gen.ts index ad12b7e197e48..eb489ddf45f36 100644 --- a/packages/effect-acp/src/_generated/schema.gen.ts +++ b/packages/effect-acp/src/_generated/schema.gen.ts @@ -1128,8 +1128,24 @@ export const EnumOption = Schema.Struct({ identifier: "EnumOption", }); -export type TitledMultiSelectItems = never; -export const TitledMultiSelectItems = Schema.Never.annotate({ +export type TitledMultiSelectItems = { + readonly anyOf: ReadonlyArray; + readonly _meta?: { readonly [x: string]: Schema.Json } | null; +}; +export const TitledMultiSelectItems = Schema.Struct({ + anyOf: Schema.Array(EnumOption) + .annotate({ description: "Titled enum options. Must contain at least one option." }) + .check(Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" })), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Json.annotate({ expected: "JSON value" })).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nOptional. Omitted and `null` are equivalent and mean no metadata.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/draft/extensibility)", + }), + Schema.Null, + ]), + ), +}).annotate({ description: "Items definition for titled multi-select enum properties.", identifier: "TitledMultiSelectItems", }); diff --git a/packages/effect-acp/src/client.test.ts b/packages/effect-acp/src/client.test.ts index 0736cb369c994..c5ab6d4b95364 100644 --- a/packages/effect-acp/src/client.test.ts +++ b/packages/effect-acp/src/client.test.ts @@ -49,6 +49,7 @@ const PermissionRequest = jsonRpcRequest( "session/request_permission", AcpSchema.RequestPermissionRequest, ); +const ElicitationResponse = jsonRpcResponse(AcpSchema.CreateElicitationResponse); const PermissionResponse = jsonRpcResponse(AcpSchema.RequestPermissionResponse); const ErrorResponse = Schema.Struct({ id: Schema.String, error: Schema.Unknown }); const DieResponse = Schema.Struct({ @@ -1295,6 +1296,59 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { }), ); + it.effect("hands titled multi-select elicitation fields to the handler", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const scope = yield* Scope.make(); + const acp = yield* AcpClient.make(stdio).pipe(Effect.provideService(Scope.Scope, scope)); + const received = yield* Deferred.make(); + yield* acp.handleElicitation((request) => + Deferred.succeed(received, request).pipe( + Effect.as({ action: "accept" as const, content: { areas: ["ui"] } }), + ), + ); + const requestedSchema = { + type: "object", + properties: { + areas: { + type: "array", + title: "Areas", + items: { + anyOf: [ + { const: "api", title: "API", description: "Server routes" }, + { const: "ui", title: "Interface" }, + ], + }, + }, + }, + required: ["areas"], + }; + + yield* Queue.offer( + input, + yield* encodeJsonl(jsonRpcRequest("elicitation/create", Schema.Unknown), { + jsonrpc: "2.0", + id: "elicitation", + method: "elicitation/create", + params: { + sessionId: "session-1", + mode: "form", + message: "Pick areas", + requestedSchema, + }, + headers: [], + }), + ); + const response = yield* Queue.take(output).pipe( + Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(ElicitationResponse))), + ); + assert.deepEqual(response.result, { action: "accept", content: { areas: ["ui"] } }); + const request = yield* Deferred.await(received); + assert.deepEqual("requestedSchema" in request && request.requestedSchema, requestedSchema); + yield* Scope.close(scope, Exit.void); + }), + ); + it.effect( "routes a standard prompt response after Grok extension notifications in the same batch", () => diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts index 44c8a789bf98e..c61ee315e3536 100644 --- a/packages/effect-acp/src/protocol.ts +++ b/packages/effect-acp/src/protocol.ts @@ -178,6 +178,13 @@ const makeStrictNdJsonRpcParser = () => { const codec = RpcSerialization.jsonRpc().makeUnsafe(); const decoder = new TextDecoder(); let buffer = ""; + const decodeLine = (line: string): ReadonlyArray => + codec.decode(line).map((message) => ({ + message: normalizeAcpJsonRpcError( + message as RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded, + ), + lineLength: line.length, + })); return { // Every message in a batch line is charged the whole line. decode: (bytes: Uint8Array | string): ReadonlyArray => { @@ -190,28 +197,14 @@ const makeStrictNdJsonRpcParser = () => { } const lines = (buffer + chunk.slice(0, lastNewline)).split("\n"); buffer = chunk.slice(lastNewline + 1); - return lines.flatMap((line) => - codec.decode(line).map((message) => ({ - message: normalizeAcpJsonRpcError( - message as RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded, - ), - lineLength: line.length, - })), - ); + return lines.flatMap(decodeLine); }, // A truncated final line is left to the exit status, which explains a crash better. end: (): ReadonlyArray => { const line = buffer + decoder.decode(); buffer = ""; try { - return line.trim() === "" - ? [] - : codec.decode(line).map((message) => ({ - message: normalizeAcpJsonRpcError( - message as RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded, - ), - lineLength: line.length, - })); + return line.trim() === "" ? [] : decodeLine(line); } catch { return []; } diff --git a/packages/effect-acp/src/schema.test.ts b/packages/effect-acp/src/schema.test.ts index a066f2a5d1c94..ed8775271b52b 100644 --- a/packages/effect-acp/src/schema.test.ts +++ b/packages/effect-acp/src/schema.test.ts @@ -13,6 +13,27 @@ describe("pinned ACP wire schemas", () => { expect(accepts({ type: "future_content", payload: { value: 1 } })).toBe(true); }); + it("decodes titled multi-select form fields in v1 and v2", () => { + const titled = (anyOf: ReadonlyArray) => ({ + sessionId: "session-1", + mode: "form", + message: "Pick areas", + requestedSchema: { + type: "object", + properties: { areas: { type: "array", minItems: 1, items: { anyOf } } }, + required: ["areas"], + }, + }); + const form = titled([ + { const: "api", title: "API", description: "Server routes" }, + { const: "ui", title: "Interface" }, + ]); + for (const request of [V1.CreateElicitationRequest, V2.CreateElicitationRequest]) { + expect(Schema.decodeUnknownSync(request)(form)).toEqual(form); + expect(Schema.is(request)(titled([{ const: "api" }]))).toBe(false); + } + }); + it("keeps v1 command inputs compatible with native agents", () => { const command = { name: "plan", description: "Make a plan", input: { hint: "Task" } }; const isAvailableCommand = Schema.is(V1.AvailableCommand); diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index 245b182166b4f..0be04e8a1097b 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -100,6 +100,7 @@ import { makeAcpClientPolicyGrants, unknownRecord, } from "./clientPolicy.ts"; +import { parseElicitationForm } from "./elicitationForm.ts"; import { makeAcpClientTerminals, resolveEmbeddedTerminalContent, @@ -1326,22 +1327,6 @@ function selectAutoApprovedPermissionOption( ); } -function elicitationContent( - answers: ProviderUserInputAnswers, - allowedKeys: ReadonlySet, -): Record { - const content: Record = {}; - for (const [key, value] of Object.entries(answers)) { - if (!allowedKeys.has(key)) continue; - if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { - content[key] = value; - } else if (Array.isArray(value)) { - content[key] = value.filter((entry): entry is string => typeof entry === "string"); - } - } - return content; -} - interface ActiveTextSegment { readonly nativeItemId: string; readonly startedAt: DateTime.Utc; @@ -6764,36 +6749,30 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( if (params.mode !== "form" || !("requestedSchema" in params)) { return { action: "decline" } as const; } - const requestedSchema = unknownRecord(params.requestedSchema); - const properties = unknownRecord(requestedSchema?.properties) ?? {}; const nativeForm = flavor.extractElicitationForm?.(params); + const parsedForm = + nativeForm === undefined + ? parseElicitationForm(params.requestedSchema, params.message) + : undefined; + const form = + nativeForm ?? + (parsedForm === undefined + ? undefined + : { + questions: parsedForm.questions, + respond: ( + answers: ProviderUserInputAnswers, + ): EffectAcpSchema.CreateElicitationResponse => { + const content = parsedForm.content(answers); + return content === undefined + ? { action: "cancel" } + : { action: "accept", content }; + }, + }); + if (form === undefined) return { action: "decline" } as const; const elicitationScopeId = "sessionId" in params ? params.sessionId : `request:${params.requestId}`; - const questions = - nativeForm?.questions ?? - Object.entries(properties).map( - ([id, property], index): OrchestrationV2UserInputQuestion => { - const record = unknownRecord(property); - const enumValues = Array.isArray(record?.enum) - ? record.enum.filter((value): value is string => typeof value === "string") - : []; - const options = - enumValues.length > 0 - ? enumValues.map((value) => ({ label: value, description: value })) - : record?.type === "boolean" - ? [ - { label: "true", description: "Yes" }, - { label: "false", description: "No" }, - ] - : []; - return { - id, - header: nonEmptyText(record?.title, `Question ${index + 1}`), - question: nonEmptyText(record?.description, params.message), - options, - }; - }, - ); + const userInput = yield* requestUserInputWithAdmission( handlerGeneration, Effect.gen(function* () { @@ -6805,7 +6784,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( return { nativeItemId: nativeRequestId, nativeRequestId, - questions, + questions: form.questions, }; }), transportRequestId, @@ -6813,14 +6792,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const response = userInput.answers === null ? ({ action: "cancel" } as const) - : (nativeForm?.respond(userInput.answers) ?? - ({ - action: "accept", - content: elicitationContent( - userInput.answers, - new Set(Object.keys(properties)), - ), - } as const)); + : form.respond(userInput.answers); yield* userInput.acknowledgeNativeResponse; return response; }).pipe(holdingAgentRequest(handlerGeneration)), diff --git a/packages/provider-acp/src/server/elicitationForm.test.ts b/packages/provider-acp/src/server/elicitationForm.test.ts new file mode 100644 index 0000000000000..bbc82f036c8b8 --- /dev/null +++ b/packages/provider-acp/src/server/elicitationForm.test.ts @@ -0,0 +1,47 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { parseElicitationForm } from "./elicitationForm.ts"; + +describe("parseElicitationForm", () => { + for (const type of ["string", "array"] as const) { + it.each([ + { values: [" padded ", "", "plain"], labels: ["padded", '""', "plain"] }, + { values: [" a", "a", "b"], labels: ['" a"', '"a"', "b"] }, + { values: [" a", "a", '"a"'], labels: ['" a"', '"a"', '"\\"a\\""'] }, + ])( + `preserves ${type} enum values with distinct readable labels: $values`, + ({ values, labels }) => { + const form = parseElicitationForm( + { + type: "object", + properties: { + answer: + type === "array" + ? { type, items: { type: "string", enum: values } } + : { type, enum: values }, + }, + required: ["answer"], + }, + "Choose", + ); + assert(form !== undefined); + const question = form.questions[0]; + assert(question !== undefined); + assert.deepStrictEqual( + question.options.map((option) => option.label), + labels, + ); + assert.deepStrictEqual( + question.options.map((option) => option.value), + values, + ); + assert.strictEqual(question.allowCustomAnswer, false); + assert.strictEqual(question.multiSelect, type === "array" ? true : undefined); + for (const value of values) { + const answer = type === "array" ? [value] : value; + assert.deepStrictEqual(form.content({ answer }), { answer }); + } + }, + ); + } +}); diff --git a/packages/provider-acp/src/server/elicitationForm.ts b/packages/provider-acp/src/server/elicitationForm.ts new file mode 100644 index 0000000000000..df7c29cab2950 --- /dev/null +++ b/packages/provider-acp/src/server/elicitationForm.ts @@ -0,0 +1,223 @@ +import type { + OrchestrationV2UserInputQuestion, + ProviderUserInputAnswers, +} from "@t3tools/contracts"; +import type * as EffectAcpSchema from "effect-acp/compat"; + +import { unknownRecord } from "./clientPolicy.ts"; + +type Field = + | { readonly kind: "text" } + | { readonly kind: "number"; readonly integer: boolean } + | { readonly kind: "boolean" } + | { readonly kind: "choice"; readonly values: ReadonlySet } + | { + readonly kind: "multiChoice"; + readonly values: ReadonlySet; + readonly minItems: number; + readonly maxItems: number; + }; + +type Option = OrchestrationV2UserInputQuestion["options"][number]; + +export interface ElicitationForm { + readonly questions: ReadonlyArray; + /** + * Types answers as the schema declares them. Unknown keys and options are + * dropped, as is an answer that does not fit its field. Returns undefined + * when a required field is left without a valid answer. + */ + readonly content: ( + answers: ProviderUserInputAnswers, + ) => Record | undefined; +} + +function text(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +/** All of `entries` as options, or undefined when any entry cannot be one. */ +function choiceOptions( + entries: unknown, + toOption: (entry: unknown) => Option | undefined, +): Option[] | undefined { + if (!Array.isArray(entries) || entries.length === 0) return undefined; + const options = entries.map(toOption); + return options.every((option) => option !== undefined) ? options : undefined; +} + +function enumOptions(entries: unknown): Option[] | undefined { + if ( + !Array.isArray(entries) || + entries.length === 0 || + !entries.every((value): value is string => typeof value === "string") + ) + return undefined; + const counts = new Map(); + for (const value of entries) counts.set(value.trim(), (counts.get(value.trim()) ?? 0) + 1); + const options = entries.map((value) => { + const plain = value.trim(); + const label = plain !== "" && counts.get(plain) === 1 ? plain : JSON.stringify(value); + return { label, description: label, value }; + }); + // A quoted fallback can collide with a literal label; quote every value in that case. + return new Set(options.map((option) => option.label)).size === options.length + ? options + : options.map((option) => { + const label = JSON.stringify(option.value); + return { ...option, label, description: label }; + }); +} + +/** Titled entries (`oneOf` on strings, `anyOf` on array items) carry a title for their `const`. */ +function titledOption(entry: unknown): Option | undefined { + const option = unknownRecord(entry); + const label = text(option?.title) ?? text(option?.const); + return typeof option?.const === "string" && label !== undefined + ? { label, description: text(option.description) ?? label, value: option.const } + : undefined; +} + +function parseField( + property: Readonly>, +): { readonly field: Field; readonly options: Option[] } | undefined { + switch (property.type) { + case "string": { + // ACP treats a null `enum` or `oneOf` like an omitted one. + if ((property.enum ?? property.oneOf ?? undefined) === undefined) { + return { field: { kind: "text" }, options: [] }; + } + const options = Array.isArray(property.oneOf) + ? choiceOptions(property.oneOf, titledOption) + : enumOptions(property.enum); + return options === undefined + ? undefined + : { + field: { kind: "choice", values: new Set(options.map(optionValue)) }, + options, + }; + } + case "array": { + // String items list plain values in `enum`; titled items list `{ const, title }` in `anyOf`. + const items = unknownRecord(property.items); + const options = + items?.type === "string" + ? enumOptions(items.enum) + : items !== undefined && items.type === undefined + ? choiceOptions(items.anyOf, titledOption) + : undefined; + if (options === undefined) return undefined; + // The schema decode already limits counts to non-negative integers. + const values = new Set(options.map(optionValue)); + const minItems = typeof property.minItems === "number" ? property.minItems : 0; + const maxItems = typeof property.maxItems === "number" ? property.maxItems : Infinity; + // Clients require a selection, so empty-only questions cannot be answered. + if (maxItems === 0 || minItems > maxItems || minItems > values.size) return undefined; + return { field: { kind: "multiChoice", values, minItems, maxItems }, options }; + } + case "boolean": + return { + field: { kind: "boolean" }, + options: [ + { label: "true", description: "Yes", value: "true" }, + { label: "false", description: "No", value: "false" }, + ], + }; + case "number": + case "integer": + return { field: { kind: "number", integer: property.type === "integer" }, options: [] }; + default: + return undefined; + } +} + +function optionValue(option: Option): string { + return option.value ?? option.label; +} + +function typedAnswer( + field: Field, + answer: unknown, +): EffectAcpSchema.ElicitationContentValue | undefined { + switch (field.kind) { + case "text": + return typeof answer === "string" ? answer : undefined; + case "boolean": + if (typeof answer === "boolean") return answer; + return answer === "true" ? true : answer === "false" ? false : undefined; + case "number": { + const parsed = + typeof answer === "number" + ? answer + : typeof answer === "string" && answer.trim().length > 0 + ? Number(answer.trim()) + : Number.NaN; + if (!Number.isFinite(parsed)) return undefined; + return field.integer && !Number.isInteger(parsed) ? undefined : parsed; + } + case "choice": + return typeof answer === "string" && field.values.has(answer) ? answer : undefined; + case "multiChoice": { + if (!Array.isArray(answer)) return undefined; + const selected = [ + ...new Set( + answer.filter( + (entry): entry is string => typeof entry === "string" && field.values.has(entry), + ), + ), + ]; + // `[]` is an explicit empty selection, but a selection of only unknown + // options is not one. + if (selected.length === 0 && answer.length > 0) return undefined; + return selected.length >= field.minItems && selected.length <= field.maxItems + ? selected + : undefined; + } + } +} + +// Decline unaskable forms; validate types and selection counts, leaving other constraints to the agent. +export function parseElicitationForm( + requestedSchema: unknown, + message: string, +): ElicitationForm | undefined { + const schema = unknownRecord(requestedSchema); + const required = new Set( + Array.isArray(schema?.required) + ? schema.required.filter((key): key is string => typeof key === "string") + : [], + ); + const fields = new Map(); + const questions: OrchestrationV2UserInputQuestion[] = []; + for (const [id, value] of Object.entries(unknownRecord(schema?.properties) ?? {})) { + const property = unknownRecord(value); + const parsed = property === undefined ? undefined : parseField(property); + // Question ids must survive the contract's trim to round-trip answers. + if (property === undefined || parsed === undefined || text(id) !== id) return undefined; + fields.set(id, parsed.field); + questions.push({ + id, + header: text(property.title) ?? `Question ${questions.length + 1}`, + question: text(property.description) ?? message, + options: parsed.options, + ...(parsed.field.kind === "text" || parsed.field.kind === "number" + ? {} + : { allowCustomAnswer: false }), + ...(parsed.field.kind === "multiChoice" ? { multiSelect: true } : {}), + }); + } + return { + questions, + content: (answers) => { + const entries: Array<[string, EffectAcpSchema.ElicitationContentValue]> = []; + for (const [id, field] of fields) { + const typed = Object.hasOwn(answers, id) ? typedAnswer(field, answers[id]) : undefined; + if (typed !== undefined) entries.push([id, typed]); + else if (required.has(id)) return undefined; + } + // Own properties for every field, `__proto__` included; assigning to + // `{}` would hit its inherited setter instead. + return Object.fromEntries(entries); + }, + }; +} diff --git a/packages/provider-core/src/server/usage.ts b/packages/provider-core/src/server/usage.ts index 509d890808987..3915ce7b2d9fa 100644 --- a/packages/provider-core/src/server/usage.ts +++ b/packages/provider-core/src/server/usage.ts @@ -89,7 +89,6 @@ export interface ProviderUsageScan { readonly hostId?: string; readonly status?: UsageSource["status"]; readonly message?: string; - readonly action?: UsageSource["action"]; readonly files: | readonly { readonly path: string; readonly records: readonly UsageRecord[] }[] | null; diff --git a/packages/provider-cursor/package.json b/packages/provider-cursor/package.json index b8e38d3b778cd..f073e7c921e35 100644 --- a/packages/provider-cursor/package.json +++ b/packages/provider-cursor/package.json @@ -31,10 +31,6 @@ "types": "./src/server/CursorAccountReader.ts", "import": "./src/server/CursorAccountReader.ts" }, - "./server/CursorKeychain": { - "types": "./src/server/CursorKeychain.ts", - "import": "./src/server/CursorKeychain.ts" - }, "./server/CursorUsageAccounts": { "types": "./src/server/CursorUsageAccounts.ts", "import": "./src/server/CursorUsageAccounts.ts" @@ -50,7 +46,6 @@ }, "dependencies": { "@cursor/sdk": "1.2.0", - "@napi-rs/keyring": "^1.3.0", "@t3tools/contracts": "workspace:*", "@t3tools/provider-core": "workspace:*", "@t3tools/shared": "workspace:*", diff --git a/packages/provider-cursor/src/server/CursorAccountReader.ts b/packages/provider-cursor/src/server/CursorAccountReader.ts index 183819b05c8c8..f6e9b384a40d8 100644 --- a/packages/provider-cursor/src/server/CursorAccountReader.ts +++ b/packages/provider-cursor/src/server/CursorAccountReader.ts @@ -1,13 +1,11 @@ import * as Context from "effect/Context"; import * as Digest from "effect/crypto/Digest"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; import * as HttpClient from "effect/http/HttpClient"; import * as Layer from "effect/Layer"; import type { CursorCredentialSource } from "./accountCache.ts"; import { readCursorAccountUsage, type CursorAccountUsageReadResult } from "./accountUsage.ts"; -import * as CursorKeychain from "./CursorKeychain.ts"; export class CursorAccountReader extends Context.Service< CursorAccountReader, @@ -23,18 +21,12 @@ export class CursorAccountReader extends Context.Service< export const layer = Layer.effect( CursorAccountReader, Effect.gen(function* () { - const keychain = yield* CursorKeychain.CursorKeychain; - const context = yield* Effect.context< - FileSystem.FileSystem | Digest.Digest | HttpClient.HttpClient - >(); + const context = yield* Effect.context(); return CursorAccountReader.of({ read: Effect.fn("CursorAccountReader.read")(function (credentialSource, sinceMs, untilMs) { - return readCursorAccountUsage( - credentialSource, - sinceMs, - untilMs, - keychain.accessToken, - ).pipe(Effect.provideContext(context)); + return readCursorAccountUsage(credentialSource, sinceMs, untilMs).pipe( + Effect.provideContext(context), + ); }), }); }), diff --git a/packages/provider-cursor/src/server/CursorKeychain.ts b/packages/provider-cursor/src/server/CursorKeychain.ts deleted file mode 100644 index 20947971814ac..0000000000000 --- a/packages/provider-cursor/src/server/CursorKeychain.ts +++ /dev/null @@ -1,102 +0,0 @@ -// Remote clients cannot answer macOS prompts; time out reads but retain the in-flight request for reuse. -import * as NodeModule from "node:module"; - -import * as Clock from "effect/Clock"; -import * as Context from "effect/Context"; -import * as Deferred from "effect/Deferred"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; - -const CACHE_MS = 5 * 60_000; -const DEFAULT_TIMEOUT = Duration.seconds(30); - -const requireForKeyring = NodeModule.createRequire(import.meta.url); - -export class CursorKeychainTimeoutError extends Schema.TaggedError()( - "CursorKeychainTimeoutError", - {}, -) { - override get message(): string { - return "Timed out waiting for Keychain access."; - } -} - -export const isCursorKeychainTimeoutError = Schema.is(CursorKeychainTimeoutError); - -export class CursorKeychainReadError extends Schema.TaggedError()( - "CursorKeychainReadError", - { cause: Schema.Defect() }, -) { - override get message(): string { - return "Cursor Keychain credentials could not be read."; - } -} - -export class CursorKeychain extends Context.Service< - CursorKeychain, - { - readonly accessToken: Effect.Effect< - string | null, - CursorKeychainTimeoutError | CursorKeychainReadError - >; - } ->()("@t3tools/provider-cursor/server/CursorKeychain") {} - -const readKeychainEntry = Effect.tryPromise({ - try: async () => { - const { AsyncEntry } = requireForKeyring( - "@napi-rs/keyring", - ) as typeof import("@napi-rs/keyring"); - return (await new AsyncEntry("cursor-access-token", "cursor-user").getPassword()) ?? null; - }, - catch: (cause) => new CursorKeychainReadError({ cause }), -}); - -const make = ( - read: Effect.Effect = readKeychainEntry, - timeout: Duration.Input = DEFAULT_TIMEOUT, -): CursorKeychain["Service"] => { - let cached: { readonly token: string; readonly untilMs: number } | null = null; - let pending: Deferred.Deferred | null = null; - - const startRead = Effect.uninterruptible( - Effect.gen(function* () { - if (pending !== null) return pending; - const done = Deferred.makeUnsafe(); - pending = done; - yield* read.pipe( - Effect.tap((token) => - Clock.currentTimeMillis.pipe( - Effect.map((nowMs) => { - cached = token ? { token, untilMs: nowMs + CACHE_MS } : null; - }), - ), - ), - Effect.onExit((exit) => - Effect.suspend(() => { - pending = null; - return Deferred.done(done, exit); - }), - ), - Effect.forkDetach, - ); - return done; - }), - ); - - const accessToken = Effect.gen(function* () { - const nowMs = yield* Clock.currentTimeMillis; - if (cached !== null && cached.untilMs > nowMs) return cached.token; - const done = yield* startRead; - const token = yield* Deferred.await(done).pipe(Effect.timeoutOption(timeout)); - if (Option.isNone(token)) return yield* new CursorKeychainTimeoutError(); - return token.value; - }); - - return CursorKeychain.of({ accessToken }); -}; - -export const layer = Layer.sync(CursorKeychain, () => make()); diff --git a/packages/provider-cursor/src/server/CursorUsageAccounts.ts b/packages/provider-cursor/src/server/CursorUsageAccounts.ts index 0bb9550ba9d71..21ab0e62e8948 100644 --- a/packages/provider-cursor/src/server/CursorUsageAccounts.ts +++ b/packages/provider-cursor/src/server/CursorUsageAccounts.ts @@ -2,7 +2,6 @@ import type { ProviderInstanceId } from "@t3tools/contracts"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import type { ProviderUsageScan } from "@t3tools/provider-core/server/usage"; import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; -import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Digest from "effect/crypto/Digest"; @@ -44,7 +43,6 @@ export class CursorUsageAccounts extends Context.Service< readonly instanceId: ProviderInstanceId; readonly environment: NodeJS.ProcessEnv; }>; - readonly keychainUsageEnabled: boolean; readonly windowStartMs: number; readonly retentionCutoffMs: number; readonly awaitRefresh: boolean; @@ -58,8 +56,6 @@ const make = Effect.gen(function* () { const path = yield* Path.Path; const host = yield* ProviderHost.ProviderHost; const accountReader = yield* CursorAccountReader.CursorAccountReader; - const hostEnvironment = yield* HostProcess.Environment; - const platform = yield* HostProcess.Platform; const digest = yield* Digest.Digest; const cachePath = path.join(host.paths.stateDir, CURSOR_ACCOUNT_CACHE_FILE_NAME); @@ -190,34 +186,12 @@ const make = Effect.gen(function* () { const accountSource = Effect.fn("CursorUsageAccounts.accountSource")(function* ( credential: CursorCredentialSource, - authPath: string, windowStartMs: number, retentionStartMs: number, awaitRefresh: boolean, ) { - // A Keychain login outlives an uninstalled Cursor, so it also needs Cursor's local directory. - const localLogin = - typeof credential === "string" - ? credential - : credential.kind === "keychain" - ? path.dirname(authPath) - : null; - if ( - localLogin !== null && - !(yield* fileSystem.exists(localLogin).pipe(Effect.orElseSucceed(() => true))) - ) { - return null; - } yield* ensureLoaded; - const credentialKey = - typeof credential === "string" - ? credential - : credential.kind === "apiKey" - ? // A fingerprint, so a replaced key never reads the previous account's cache. - `api-key:${Hex.encode( - yield* digest.digest(Digest.sha256, new TextEncoder().encode(credential.apiKey)), - )}` - : "keychain"; + const credentialKey = `api-key:${Hex.encode(yield* digest.digest(Digest.sha256, new TextEncoder().encode(credential.apiKey)))}`; const nowMs = yield* Clock.currentTimeMillis; const recentFailure = failures.get(credentialKey); let refreshing = false; @@ -236,8 +210,7 @@ const make = Effect.gen(function* () { if (failureMessage === null) return null; if (cache === undefined) { return { - dir: authPath, - // Never combine a local fallback with another server's account-wide history. + dir: "cursor-account", ...(refreshing ? { files: [], refreshing: true } : { files: null, message: failureMessage ?? CURSOR_ACCOUNT_READ_ERROR }), @@ -263,30 +236,7 @@ const make = Effect.gen(function* () { }); const scan: CursorUsageAccounts["Service"]["scan"] = Effect.fn("CursorUsageAccounts.scan")( - function* ({ - instances, - keychainUsageEnabled, - windowStartMs, - retentionCutoffMs, - awaitRefresh, - }) { - const home = yield* HostProcess.HomeDirectory; - const userHome = - (platform === "win32" ? hostEnvironment["USERPROFILE"] : hostEnvironment["HOME"]) || home; - const configHome = hostEnvironment["XDG_CONFIG_HOME"]?.trim(); - const cursorHome = - platform === "darwin" - ? path.join(userHome, "Library", "Application Support") - : platform === "win32" - ? hostEnvironment["APPDATA"] || path.join(userHome, "AppData", "Roaming") - : configHome && path.isAbsolute(configHome) - ? configHome - : path.join(userHome, ".config"); - const authPath = - platform === "darwin" - ? path.join(userHome, ".cursor", "auth.json") - : path.join(cursorHome, platform === "win32" ? "Cursor" : "cursor", "auth.json"); - // T3's Cursor sign-in and CURSOR_API_KEY name the account the agent uses, so they win. + function* ({ instances, windowStartMs, retentionCutoffMs, awaitRefresh }) { const apiKeys = yield* Effect.forEach(instances, ({ instanceId, environment }) => environment.CURSOR_API_KEY?.trim() ? Effect.succeed(environment.CURSOR_API_KEY.trim()) @@ -302,7 +252,6 @@ const make = Effect.gen(function* () { (apiKey) => accountSource( { kind: "apiKey", apiKey }, - authPath, windowStartMs, retentionCutoffMs, awaitRefresh, @@ -323,38 +272,7 @@ const make = Effect.gen(function* () { } return [...byAccount.values()]; } - const credentialStore = hostEnvironment["AGENT_CLI_CREDENTIAL_STORE"]; - const loginUnavailable = - Boolean(hostEnvironment["CURSOR_AUTH_TOKEN"]?.trim()) || credentialStore === "memory"; - const useKeychain = platform === "darwin" && credentialStore !== "file"; - if (useKeychain && !loginUnavailable && !keychainUsageEnabled) { - return [ - { - dir: authPath, - volumeId: "", - files: null, - message: "Cursor account usage is off on this environment.", - action: "enableCursorKeychain", - }, - ]; - } - if (loginUnavailable) { - return [ - { - dir: authPath, - files: null, - message: "Cursor account history needs a Cursor CLI login on this server.", - }, - ]; - } - const source = yield* accountSource( - useKeychain ? { kind: "keychain" } : authPath, - authPath, - windowStartMs, - retentionCutoffMs, - awaitRefresh, - ); - return source === null ? [] : [source]; + return []; }, ); diff --git a/packages/provider-cursor/src/server/accountCache.ts b/packages/provider-cursor/src/server/accountCache.ts index b2ff2fbd6b3a2..ffbfcc6400533 100644 --- a/packages/provider-cursor/src/server/accountCache.ts +++ b/packages/provider-cursor/src/server/accountCache.ts @@ -2,10 +2,7 @@ import { cursorRateModel } from "./accountUsage.ts"; import type { UsageRecord } from "@t3tools/provider-core/server/usage"; -export type CursorCredentialSource = - | string - | { readonly kind: "keychain" } - | { readonly kind: "apiKey"; readonly apiKey: string }; +export type CursorCredentialSource = { readonly kind: "apiKey"; readonly apiKey: string }; export const CURSOR_ACCOUNT_TTL_MS = 60 * 1000; diff --git a/packages/provider-cursor/src/server/accountUsage.test.ts b/packages/provider-cursor/src/server/accountUsage.test.ts index a2ccc42cc7502..d907c46f9ac3f 100644 --- a/packages/provider-cursor/src/server/accountUsage.test.ts +++ b/packages/provider-cursor/src/server/accountUsage.test.ts @@ -5,27 +5,14 @@ import { assert, describe, it } from "@effect/vitest"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; -import * as FileSystem from "effect/FileSystem"; import * as FetchHttpClient from "effect/http/FetchHttpClient"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientError from "effect/http/HttpClientError"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; -import * as Path from "effect/Path"; import * as TestClock from "effect/testing/TestClock"; -import * as CursorKeychain from "./CursorKeychain.ts"; import { readCursorAccountUsage } from "./accountUsage.ts"; -const writeAuth = (accessToken: string) => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "usage-reader-test-" }); - const authPath = path.join(dir, "auth.json"); - yield* fileSystem.writeFileString(authPath, JSON.stringify({ accessToken })); - return { dir, authPath }; - }); - interface DashboardRequest { readonly url: string; readonly init: RequestInit; @@ -40,6 +27,15 @@ const dashboard = (answer: (request: DashboardRequest) => Response | Promise Response | Promise, - keychainToken?: Parameters[3], ) => - readCursorAccountUsage(credentialSource, sinceMs, endDate, keychainToken).pipe( + readCursorAccountUsage(credentialSource, sinceMs, endDate).pipe( Effect.provideService(HttpClient.HttpClient, dashboard(answer)), ); @@ -72,15 +67,18 @@ describe("Cursor account history", () => { () => Effect.gen(function* () { const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`; - const { authPath } = yield* writeAuth(accessToken); + const credential = { kind: "apiKey", apiKey: accessToken } as const; const pages: number[] = []; const signals: AbortSignal[] = []; const request = ({ url, init, headers, page }: DashboardRequest) => { - assert.strictEqual(url, "https://cursor.com/api/dashboard/get-filtered-usage-events"); + assert.strictEqual( + url, + "https://api2.cursor.sh/aiserver.v1.DashboardService/GetFilteredUsageEvents", + ); assert.strictEqual(init.redirect, "error"); - assert.strictEqual(headers.get("origin"), "https://cursor.com"); + assert.strictEqual(headers.get("connect-protocol-version"), "1"); assert.strictEqual(headers.get("content-type"), "application/json"); - assert.include(headers.get("cookie") ?? "", "WorkosCursorSessionToken=demo%3A%3A"); + assert.strictEqual(headers.get("authorization"), `Bearer ${accessToken}`); const body = { page }; pages.push(body.page); if (init.signal) signals.push(init.signal); @@ -102,7 +100,7 @@ describe("Cursor account history", () => { })), }); }; - const result = yield* readAccountUsage(authPath, 0, 1781000000000, request); + const result = yield* readAccountUsage(credential, 0, 1781000000000, request); assert.isNull(result.error); assert.deepStrictEqual(pages, [1, 2]); assert.lengthOf(signals, 2); @@ -124,10 +122,10 @@ describe("Cursor account history", () => { it.effect("reads Cursor account history beyond 100 pages", () => Effect.gen(function* () { const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; - const { authPath } = yield* writeAuth(accessToken); + const credential = { kind: "apiKey", apiKey: accessToken } as const; const fullPage = Array.from({ length: 1000 }, () => ({ tokenUsage: null })); let requests = 0; - const result = yield* readAccountUsage(authPath, 0, 1781000000000, async () => { + const result = yield* readAccountUsage(credential, 0, 1781000000000, async () => { requests += 1; return Response.json({ totalUsageEventsCount: 100_001, @@ -143,13 +141,13 @@ describe("Cursor account history", () => { it.effect("accepts confirmed empty Cursor usage but rejects error envelopes", () => Effect.gen(function* () { const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; - const { authPath } = yield* writeAuth(accessToken); + const credential = { kind: "apiKey", apiKey: accessToken } as const; for (const body of [ {}, { totalUsageEventsCount: 0 }, { totalUsageEventsCount: 0, usageEventsDisplay: [] }, ]) { - const result = yield* readAccountUsage(authPath, 0, 1781000000000, async () => + const result = yield* readAccountUsage(credential, 0, 1781000000000, async () => Response.json(body), ); assert.isNull(result.error); @@ -165,7 +163,7 @@ describe("Cursor account history", () => { "invalid", 0, ]) { - const result = yield* readAccountUsage(authPath, 0, 1781000000000, async () => + const result = yield* readAccountUsage(credential, 0, 1781000000000, async () => Response.json(body), ); assert.isNotNull(result.error); @@ -177,9 +175,9 @@ describe("Cursor account history", () => { it.effect("requires a terminal Cursor page after a full page reaches the reported count", () => Effect.gen(function* () { const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; - const { authPath } = yield* writeAuth(accessToken); + const credential = { kind: "apiKey", apiKey: accessToken } as const; let requests = 0; - const result = yield* readAccountUsage(authPath, 0, 1781000000000, async () => { + const result = yield* readAccountUsage(credential, 0, 1781000000000, async () => { requests++; return Response.json( requests === 1 @@ -205,7 +203,7 @@ describe("Cursor account history", () => { () => Effect.gen(function* () { const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; - const { authPath } = yield* writeAuth(accessToken); + const credential = { kind: "apiKey", apiKey: accessToken } as const; const event = (index: number) => ({ timestamp: String(1780000000000 + index), model: "gpt-5", @@ -213,7 +211,7 @@ describe("Cursor account history", () => { }); for (const total of [2000, 2001]) { let requests = 0; - const result = yield* readAccountUsage(authPath, 0, 1781000000000, async () => { + const result = yield* readAccountUsage(credential, 0, 1781000000000, async () => { requests++; return Response.json({ totalUsageEventsCount: total, @@ -236,7 +234,7 @@ describe("Cursor account history", () => { assert.strictEqual(new Set(result.records.map((record) => record.dedupeKey)).size, total); } let requests = 0; - const inconsistent = yield* readAccountUsage(authPath, 0, 1781000000000, async () => { + const inconsistent = yield* readAccountUsage(credential, 0, 1781000000000, async () => { requests++; return Response.json({ totalUsageEventsCount: 1001, @@ -256,14 +254,14 @@ describe("Cursor account history", () => { () => Effect.gen(function* () { const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`; - const { dir, authPath } = yield* writeAuth(accessToken); - const truncated = yield* readAccountUsage(authPath, 0, 1781000000000, async () => + const credential = { kind: "apiKey", apiKey: accessToken } as const; + const truncated = yield* readAccountUsage(credential, 0, 1781000000000, async () => Response.json({ totalUsageEventsCount: 101, usageEventsDisplay: [] }), ); assert.isNotNull(truncated.error); assert.deepStrictEqual(truncated.records, []); const denied = yield* readAccountUsage( - authPath, + credential, 0, 1781000000000, async () => new Response(accessToken, { status: 401 }), @@ -271,18 +269,6 @@ describe("Cursor account history", () => { assert.isNotNull(denied.error); assert.isFalse(denied.error?.includes(accessToken) ?? true); assert.deepStrictEqual(denied.records, []); - let requested = false; - const missing = yield* readAccountUsage( - (yield* Path.Path).join(dir, "missing.json"), - 0, - 1781000000000, - async () => { - requested = true; - return Response.json({}); - }, - ); - assert.isTrue(missing.missing); - assert.isFalse(requested); }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); }); @@ -336,33 +322,13 @@ describe("readCursorAccountUsage", () => { }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); - it.effect("asks for Keychain approval when the prompt goes unanswered", () => - Effect.gen(function* () { - const result = yield* readAccountUsage( - { kind: "keychain" }, - 0, - 1, - () => { - throw new Error("no network expected"); - }, - Effect.fail(new CursorKeychain.CursorKeychainTimeoutError()), - ); - assert.deepStrictEqual(result, { - accountKey: null, - records: [], - missing: false, - error: "Allow Keychain access on the Mac running T3 Code, then refresh.", - }); - }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), - ); - it.effect("reads the pages behind the first together and keeps them in page order", () => Effect.gen(function* () { const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; let inFlight = 0; let mostInFlight = 0; const result = yield* readAccountUsage( - { kind: "keychain" }, + { kind: "apiKey", apiKey: accessToken }, 0, 1781000000000, async ({ page }) => { @@ -379,7 +345,6 @@ describe("readCursorAccountUsage", () => { })), }); }, - Effect.succeed(accessToken), ); assert.isNull(result.error); assert.strictEqual(mostInFlight, 6); @@ -406,7 +371,7 @@ describe("readCursorAccountUsage", () => { ] as const) { let open = 0; const result = yield* readAccountUsage( - { kind: "keychain" }, + { kind: "apiKey", apiKey: accessToken }, 0, 1781000000000, ({ init, page }) => { @@ -421,7 +386,6 @@ describe("readCursorAccountUsage", () => { }); }); }, - Effect.succeed(accessToken), ); assert.strictEqual(result.error, error); assert.deepStrictEqual(result.records, []); diff --git a/packages/provider-cursor/src/server/accountUsage.ts b/packages/provider-cursor/src/server/accountUsage.ts index c26d691ad16ee..6d28410f7c831 100644 --- a/packages/provider-cursor/src/server/accountUsage.ts +++ b/packages/provider-cursor/src/server/accountUsage.ts @@ -5,17 +5,14 @@ import * as Effect from "effect/Effect"; import * as Base64Url from "effect/encoding/Base64Url"; import * as Hex from "effect/encoding/Hex"; import * as Fiber from "effect/Fiber"; -import * as FileSystem from "effect/FileSystem"; import * as FetchHttpClient from "effect/http/FetchHttpClient"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; -import * as PlatformError from "effect/PlatformError"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import type { CursorCredentialSource } from "./accountCache.ts"; import { DEFAULT_CURSOR_API_ENDPOINT, exchangeCursorApiKey } from "./apiKey.ts"; -import * as CursorKeychain from "./CursorKeychain.ts"; function object(value: unknown): Record { return typeof value === "object" && value !== null && !Array.isArray(value) @@ -87,84 +84,25 @@ function boundaryOverlap(previous: readonly string[], current: readonly string[] return lengths.at(-1) ?? 0; } -const DASHBOARD_USAGE_URL = "https://cursor.com/api/dashboard/get-filtered-usage-events"; - -/** The session cookie must never follow a redirect off cursor.com. */ +/** Never forward the access token through a redirect. */ const NO_REDIRECT: RequestInit = { redirect: "error" }; export const readCursorAccountUsage = Effect.fn("readCursorAccountUsage")(function* ( credentialSource: CursorCredentialSource, sinceMs: number, endDate: number, - keychainToken: Effect.Effect< - string | null, - | CursorKeychain.CursorKeychainTimeoutError - | CursorKeychain.CursorKeychainReadError - | CursorAccountUsageInvalidError - > = invalid("No Keychain reader"), -): Effect.fn.Return< - CursorAccountUsageReadResult, - never, - FileSystem.FileSystem | Digest.Digest | HttpClient.HttpClient -> { - const fileSystem = yield* FileSystem.FileSystem; +): Effect.fn.Return { const httpClient = yield* HttpClient.HttpClient; - const credential = yield* Effect.result< - unknown, - | PlatformError.PlatformError - | Schema.SchemaError - | CursorKeychain.CursorKeychainTimeoutError - | CursorKeychain.CursorKeychainReadError - | CursorAccountUsageInvalidError, - never - >( - typeof credentialSource === "string" - ? fileSystem.readFileString(credentialSource).pipe( - Effect.flatMap(decodeJson), - Effect.map((credentials) => object(credentials).accessToken), - ) - : credentialSource.kind === "apiKey" - ? exchangeCursorApiKey(credentialSource.apiKey).pipe( - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.mapError( - () => new CursorAccountUsageInvalidError({ reason: "Invalid API key" }), - ), - ) - : keychainToken, - ); + const credential = yield* Effect.result(exchangeCursorApiKey(credentialSource.apiKey)); if (Result.isFailure(credential)) { - const cause = credential.failure; - const missing = - typeof credentialSource === "string" && - PlatformError.isPlatformError(cause) && - cause.reason._tag === "NotFound"; return { accountKey: null, records: [], - missing, - error: missing - ? null - : typeof credentialSource === "string" - ? "Cursor credentials could not be read." - : credentialSource.kind === "apiKey" - ? "Cursor account usage could not be read." - : CursorKeychain.isCursorKeychainTimeoutError(cause) - ? "Allow Keychain access on the Mac running T3 Code, then refresh." - : "Cursor Keychain credentials could not be read.", + missing: false, + error: "Cursor account usage could not be read.", }; } const accessToken = credential.success; - if (typeof accessToken !== "string" || !accessToken) { - return { - accountKey: null, - records: [], - missing: true, - error: - typeof credentialSource === "string" - ? null - : "Cursor account history needs a macOS Keychain CLI login on this server.", - }; - } let accountKey: string | null = null; const read = Effect.gen(function* () { const payload = Base64Url.decodeString(accessToken.split(".")[1] ?? ""); @@ -175,26 +113,15 @@ export const readCursorAccountUsage = Effect.fn("readCursorAccountUsage")(functi ), ).sub; if (typeof subject !== "string" || !subject) return yield* invalid("Invalid authentication"); - const userId = subject.split("|").at(-1); - if (!userId) return yield* invalid("Invalid authentication"); accountKey = yield* accountHash(subject); if (!Number.isFinite(sinceMs) || !Number.isFinite(endDate) || sinceMs < 0 || sinceMs > endDate) return yield* invalid("Invalid date window"); - // The web dashboard rejects API-key tokens; Cursor's RPC API accepts them. - const pageRequest = - typeof credentialSource !== "string" && credentialSource.kind === "apiKey" - ? HttpClientRequest.post( - `${DEFAULT_CURSOR_API_ENDPOINT}/aiserver.v1.DashboardService/GetFilteredUsageEvents`, - ).pipe( - HttpClientRequest.bearerToken(accessToken), - HttpClientRequest.setHeader("connect-protocol-version", "1"), - ) - : HttpClientRequest.post(DASHBOARD_USAGE_URL).pipe( - HttpClientRequest.setHeaders({ - Origin: "https://cursor.com", - Cookie: `WorkosCursorSessionToken=${encodeURIComponent(`${userId}::${accessToken}`)}`, - }), - ); + const pageRequest = HttpClientRequest.post( + `${DEFAULT_CURSOR_API_ENDPOINT}/aiserver.v1.DashboardService/GetFilteredUsageEvents`, + ).pipe( + HttpClientRequest.bearerToken(accessToken), + HttpClientRequest.setHeader("connect-protocol-version", "1"), + ); const records: UsageRecord[] = []; const occurrences = new Map(); const pages: unknown[][] = []; diff --git a/packages/provider-cursor/src/server/driver.test.ts b/packages/provider-cursor/src/server/driver.test.ts index d273e4b278194..164118085d233 100644 --- a/packages/provider-cursor/src/server/driver.test.ts +++ b/packages/provider-cursor/src/server/driver.test.ts @@ -34,7 +34,6 @@ import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; import { CursorDriver } from "./driver.ts"; import * as CursorAgentSdk from "./CursorAgentSdk.ts"; import * as CursorSdk from "./CursorSdk.ts"; -import * as CursorKeychain from "./CursorKeychain.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; @@ -52,11 +51,6 @@ const layerTest = TestProviderHost.layer({ runBackgroundWork: false }).pipe( open: () => Effect.die("Maintenance resolution must not open a Cursor session"), }), ), - Layer.provideMerge( - Layer.succeed(CursorKeychain.CursorKeychain, { - accessToken: Effect.die("The driver test must not read the Keychain"), - }), - ), Layer.provideMerge( Layer.succeed( ProviderEventLoggers.ProviderEventLoggers, diff --git a/packages/provider-cursor/src/server/driver.ts b/packages/provider-cursor/src/server/driver.ts index 088c97c685c58..ef50abb0a93b4 100644 --- a/packages/provider-cursor/src/server/driver.ts +++ b/packages/provider-cursor/src/server/driver.ts @@ -37,7 +37,6 @@ import { makeCursorAuth } from "./auth.ts"; import * as CursorCredentialStore from "./credentialStore.ts"; import * as CursorAgentSdk from "./CursorAgentSdk.ts"; import * as CursorSdk from "./CursorSdk.ts"; -import * as CursorKeychain from "./CursorKeychain.ts"; import * as CursorUsageAccounts from "./CursorUsageAccounts.ts"; const decodeCursorSettings = Schema.decodeSync(CursorSettings); @@ -56,8 +55,7 @@ export type CursorDriverEnv = | Path.Path | HttpClient.HttpClient | CursorSdk.CursorSdk - | ProviderHost.ProviderHost - | CursorKeychain.CursorKeychain; + | ProviderHost.ProviderHost; export const CursorDriver: ProviderDriver< CursorSettings, @@ -74,12 +72,11 @@ export const CursorDriver: ProviderDriver< usage: { kind: "scan", provider: "cursor", - scan: ({ instances, settings, windowStartMs, retentionCutoffMs, awaitRefresh }) => + scan: ({ instances, windowStartMs, retentionCutoffMs, awaitRefresh }) => CursorUsageAccounts.CursorUsageAccounts.pipe( Effect.flatMap((accounts) => accounts.scan({ instances, - keychainUsageEnabled: settings.cursorKeychainUsageEnabled, windowStartMs, retentionCutoffMs, awaitRefresh, @@ -99,7 +96,6 @@ export const CursorDriver: ProviderDriver< const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; - const keychain = yield* CursorKeychain.CursorKeychain; const sdkRunner = yield* CursorAgentSdk.CursorAgentSdkRunner; const processEnv = yield* mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ @@ -233,22 +229,15 @@ export const CursorDriver: ProviderDriver< snapshot.auth.status === "authenticated" ), (snapshot) => - host.settings.get.pipe( - Effect.flatMap((settings) => - readCursorUsageLimits( - effectiveConfig, - { ...processEnv, CURSOR_API_KEY: apiKey }, - settings.cursorKeychainUsageEnabled, - ), - ), - Effect.map((usageLimits) => ({ ...snapshot, usageLimits })), - ), + readCursorUsageLimits(effectiveConfig, { + ...processEnv, + CURSOR_API_KEY: apiKey, + }).pipe(Effect.map((usageLimits) => ({ ...snapshot, usageLimits }))), ), ), ), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), - Effect.provideService(CursorKeychain.CursorKeychain, keychain), Effect.map(stampSnapshot), Effect.tap((provider) => Effect.sync(() => { diff --git a/packages/provider-cursor/src/server/usageLimits.test.ts b/packages/provider-cursor/src/server/usageLimits.test.ts index 617616d14bc25..0b30313c46058 100644 --- a/packages/provider-cursor/src/server/usageLimits.test.ts +++ b/packages/provider-cursor/src/server/usageLimits.test.ts @@ -1,25 +1,9 @@ import { describe, expect, it } from "@effect/vitest"; -import { NodeServices } from "@effect/platform-node"; -import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; -import * as CursorKeychain from "./CursorKeychain.ts"; import { cursorUsageResponseToLimits, readCursorUsageLimits } from "./usageLimits.ts"; -const withNodeServices = ( - effect: Effect.Effect, - accessToken: CursorKeychain.CursorKeychain["Service"]["accessToken"] = Effect.die( - "must not read Keychain", - ), -) => - effect.pipe( - Effect.provideService(CursorKeychain.CursorKeychain, { accessToken }), - Effect.provide(NodeServices.layer), - ); - describe("Cursor usage limits", () => { const checkedAt = "2026-09-16T00:00:00.000Z"; @@ -70,227 +54,105 @@ describe("Cursor usage limits", () => { ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 100 }]); }); - it.effect("reads the instance's credentials and endpoint even when usage enabled is false", () => - withNodeServices( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const directory = yield* fs.makeTempDirectoryScoped(); - yield* fs.makeDirectory(path.join(directory, "cursor")); - yield* fs.writeFileString( - path.join(directory, "cursor", "auth.json"), - '{"accessToken":"instance-token"}', - ); - const client = HttpClient.make((request) => { - expect(request.url).toBe( - "https://cursor.example/aiserver.v1.DashboardService/GetCurrentPeriodUsage", - ); - expect(request.method).toBe("POST"); - expect(request.headers.authorization).toBe("Bearer instance-token"); - expect(request.headers["connect-protocol-version"]).toBe("1"); - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json({ enabled: false, planUsage: { totalPercentUsed: 42 } }), - ), - ); - }); - yield* fs.makeDirectory(path.join(directory, ".cursor")); - yield* fs.writeFileString( - path.join(directory, ".cursor", "auth.json"), - '{"accessToken":"instance-token"}', - ); - for (const platform of ["linux", "darwin"] as const) { - const limits = yield* readCursorUsageLimits( - { apiEndpoint: "https://cursor.example/" }, - { XDG_CONFIG_HOME: directory, HOME: directory, AGENT_CLI_CREDENTIAL_STORE: "file" }, - ).pipe( - Effect.provideService(HostProcess.Platform, platform), - Effect.provideService(HttpClient.HttpClient, client), - ); - expect(limits.windows[0]?.usedPercent).toBe(42); - } - }).pipe(Effect.scoped), - ), - ); - - it.effect( - "never reads stale files for keychain or memory logins, but accepts an explicit auth token", - () => - Effect.gen(function* () { - for (const platform of ["linux", "darwin"] as const) { - for (const token of [undefined, "explicit-token"]) { - const limits = yield* withNodeServices( - readCursorUsageLimits( - { apiEndpoint: "" }, - { - AGENT_CLI_CREDENTIAL_STORE: platform === "linux" ? "memory" : "default", - ...(token ? { CURSOR_AUTH_TOKEN: token } : {}), - }, - false, - ).pipe( - Effect.provideService(HostProcess.Platform, platform), - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - readFileString: () => Effect.die("must not read an unrelated credential file"), - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => { - expect(token).toBe("explicit-token"); - expect(request.headers.authorization).toBe("Bearer explicit-token"); - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json({ planUsage: { totalPercentUsed: 10 } }), - ), - ); - }), - ), - ), - ); - if (token) expect(limits.windows[0]?.usedPercent).toBe(10); - else expect(limits.unavailable?.reason).toBe("unsupported"); - } - } - }), - ); - - it.effect("reports failed requests without exposing credentials or response bodies", () => + it.effect("requires explicit credentials and accepts an auth token", () => Effect.gen(function* () { - const limits = yield* withNodeServices( - readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_AUTH_TOKEN: "private-token" }).pipe( + for (const token of [undefined, "explicit-token"]) { + const limits = yield* readCursorUsageLimits( + { apiEndpoint: "" }, + token ? { CURSOR_AUTH_TOKEN: token } : {}, + ).pipe( Effect.provideService( HttpClient.HttpClient, - HttpClient.make((request) => - Effect.succeed( + HttpClient.make((request) => { + expect(token).toBe("explicit-token"); + expect(request.headers.authorization).toBe("Bearer explicit-token"); + return Effect.succeed( HttpClientResponse.fromWeb( request, - new Response("private response", { status: 401 }), + Response.json({ planUsage: { totalPercentUsed: 10 } }), ), - ), - ), - ), - ), - ); - expect(limits.unavailable).toEqual({ - reason: "probeFailed", - message: "Cursor could not read usage limits.", - }); - }), - ); - - it.effect("reads an API key's own account instead of a stored login", () => - Effect.gen(function* () { - for (const platform of ["linux", "darwin"] as const) { - const requests: Array = []; - const limits = yield* withNodeServices( - readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_API_KEY: "api-key" }, true).pipe( - Effect.provideService(HostProcess.Platform, platform), - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - readFileString: () => Effect.die("must not read a stored login"), - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => { - requests.push([request.url, request.headers.authorization]); - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json( - request.url.endsWith("/auth/exchange_user_api_key") - ? { accessToken: "api-key-access-token" } - : { planUsage: { totalPercentUsed: 37 } }, - ), - ), - ); - }), - ), + ); + }), ), ); - expect(requests).toEqual([ - ["https://api2.cursor.sh/auth/exchange_user_api_key", "Bearer api-key"], - [ - "https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage", - "Bearer api-key-access-token", - ], - ]); - expect(limits.windows[0]?.usedPercent).toBe(37); + if (token) expect(limits.windows[0]?.usedPercent).toBe(10); + else expect(limits.unavailable?.reason).toBe("unsupported"); } }), ); - it.effect("reads the default macOS Cursor login from Keychain for limits", () => + it.effect("reports failed requests without exposing credentials or response bodies", () => Effect.gen(function* () { - const limits = yield* withNodeServices( - readCursorUsageLimits({ apiEndpoint: "" }, {}, true).pipe( - Effect.provideService(HostProcess.Platform, "darwin"), - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - readFileString: () => Effect.die("must not read a stale credential file"), - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => { - expect(request.headers.authorization).toBe("Bearer keychain-token"); - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json({ planUsage: { totalPercentUsed: 42 } }), - ), - ); - }), + const limits = yield* readCursorUsageLimits( + { apiEndpoint: "" }, + { CURSOR_AUTH_TOKEN: "private-token" }, + ).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response("private response", { status: 401 }), + ), + ), ), ), - Effect.succeed("keychain-token"), ); - expect(limits.windows[0]?.usedPercent).toBe(42); + expect(limits.unavailable).toEqual({ + reason: "probeFailed", + message: "Cursor could not read usage limits.", + }); }), ); - it.effect("reports a Keychain initialization failure without failing the provider refresh", () => + it.effect("exchanges an API key to read its account limits", () => Effect.gen(function* () { - const limits = yield* withNodeServices( - readCursorUsageLimits({ apiEndpoint: "" }, {}, true).pipe( - Effect.provideService(HostProcess.Platform, "darwin"), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die("must not request limits without a login")), - ), - ), - Effect.fail( - new CursorKeychain.CursorKeychainReadError({ - cause: new Error("Keychain initialization failed"), + const requests: Array = []; + const limits = yield* readCursorUsageLimits( + { apiEndpoint: "" }, + { CURSOR_API_KEY: "api-key" }, + ).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + requests.push([request.url, request.headers.authorization]); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json( + request.url.endsWith("/auth/exchange_user_api_key") + ? { accessToken: "api-key-access-token" } + : { planUsage: { totalPercentUsed: 37 } }, + ), + ), + ); }), ), ); - expect(limits.unavailable?.reason).toBe("probeFailed"); + expect(requests).toEqual([ + ["https://api2.cursor.sh/auth/exchange_user_api_key", "Bearer api-key"], + [ + "https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage", + "Bearer api-key-access-token", + ], + ]); + expect(limits.windows[0]?.usedPercent).toBe(37); }), ); - it.effect("does not send a Keychain login or API key to a custom endpoint", () => + it.effect("does not send an API key to a custom endpoint", () => Effect.gen(function* () { for (const [apiEndpoint, environment] of [ - ["http://localhost:3000", {}], - ["", { CURSOR_API_ENDPOINT: "http://localhost:3000" }], - ["https://cursor-proxy.example", {}], - ["", { CURSOR_API_ENDPOINT: "https://cursor-proxy.example" }], + ["http://localhost:3000", { CURSOR_API_KEY: "api-key" }], + ["", { CURSOR_API_ENDPOINT: "http://localhost:3000", CURSOR_API_KEY: "api-key" }], + ["", { CURSOR_API_ENDPOINT: "https://cursor-proxy.example", CURSOR_API_KEY: "api-key" }], ["https://cursor-proxy.example", { CURSOR_API_KEY: "api-key" }], ] as const) { - const limits = yield* withNodeServices( - readCursorUsageLimits({ apiEndpoint }, environment, true).pipe( - Effect.provideService(HostProcess.Platform, "darwin"), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die("must not send a Keychain credential to a proxy")), - ), + const limits = yield* readCursorUsageLimits({ apiEndpoint }, environment).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("must not send an API key to a proxy")), ), ); expect(limits.unavailable?.reason).toBe("unsupported"); diff --git a/packages/provider-cursor/src/server/usageLimits.ts b/packages/provider-cursor/src/server/usageLimits.ts index 87fcecef4e97f..a0746c3e013bf 100644 --- a/packages/provider-cursor/src/server/usageLimits.ts +++ b/packages/provider-cursor/src/server/usageLimits.ts @@ -1,12 +1,9 @@ import type { ServerProviderUsageWindow } from "@t3tools/contracts"; import type { CursorSettings } from "../settings.ts"; -import * as HostProcess from "@t3tools/shared/HostProcess"; import { CURSOR_USAGE_WINDOWS } from "@t3tools/shared/usageLimits"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; -import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; @@ -17,10 +14,7 @@ import { makeUsageLimits, } from "@t3tools/provider-core/server/usageLimits"; import { DEFAULT_CURSOR_API_ENDPOINT, exchangeCursorApiKey } from "./apiKey.ts"; -import * as CursorKeychain from "./CursorKeychain.ts"; -const CursorCredentials = Schema.Struct({ accessToken: Schema.optional(Schema.String) }); -const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(CursorCredentials)); const CursorUsageResponse = Schema.Struct({ billingCycleEnd: Schema.optional(Schema.Union([Schema.String, Schema.Number])), planUsage: Schema.optional( @@ -64,13 +58,9 @@ export function cursorUsageResponseToLimits( export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function* ( settings: Pick, environment: NodeJS.ProcessEnv = process.env, - allowKeychain = false, ) { const checkedAt = DateTime.formatIso(yield* DateTime.now); return yield* Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const platform = yield* HostProcess.Platform; const endpoint = ( settings.apiEndpoint?.trim() || environment.CURSOR_API_ENDPOINT?.trim() || @@ -78,8 +68,6 @@ export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function ).replace(/\/$/, ""); let token = environment.CURSOR_AUTH_TOKEN?.trim(); const apiKey = environment.CURSOR_API_KEY?.trim(); - const credentialStore = environment.AGENT_CLI_CREDENTIAL_STORE; - // An API key can name a different account from the stored login, so read the key's own. if (!token && apiKey) { if (endpoint !== DEFAULT_CURSOR_API_ENDPOINT) { return makeUnavailableUsageLimits({ @@ -89,47 +77,6 @@ export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function }); } token = yield* exchangeCursorApiKey(apiKey); - } else if (!token && credentialStore === "memory") { - return makeUnavailableUsageLimits({ - checkedAt, - reason: "unsupported", - message: "Cursor usage requires a CLI login or CURSOR_AUTH_TOKEN.", - }); - } else if (!token && platform === "darwin" && credentialStore !== "file") { - if (!allowKeychain) { - return makeUnavailableUsageLimits({ - checkedAt, - reason: "unsupported", - message: "Enable Cursor account usage in T3 Code to read its Keychain login.", - }); - } - if (endpoint !== DEFAULT_CURSOR_API_ENDPOINT) { - return makeUnavailableUsageLimits({ - checkedAt, - reason: "unsupported", - message: "Cursor account usage requires the default Cursor endpoint when using Keychain.", - }); - } - const keychain = yield* CursorKeychain.CursorKeychain; - token = (yield* keychain.accessToken)?.trim(); - } else if (!token) { - const home = - (platform === "win32" ? environment.USERPROFILE : environment.HOME) || - (yield* HostProcess.HomeDirectory); - const directory = - platform === "win32" - ? path.join(environment.APPDATA || path.join(home, "AppData", "Roaming"), "Cursor") - : platform === "darwin" - ? path.join(home, ".cursor") - : path.join(environment.XDG_CONFIG_HOME || path.join(home, ".config"), "cursor"); - const credentials = yield* fs.readFileString(path.join(directory, "auth.json")).pipe( - Effect.catchTags({ - PlatformError: (error) => - error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), - }), - Effect.flatMap(decodeCredentials), - ); - token = credentials.accessToken?.trim(); } if (!token) return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); const client = yield* HttpClient.HttpClient; diff --git a/packages/shared/src/usageFormat.test.ts b/packages/shared/src/usageFormat.test.ts index 6682a194ab278..c847f323a2065 100644 --- a/packages/shared/src/usageFormat.test.ts +++ b/packages/shared/src/usageFormat.test.ts @@ -116,12 +116,11 @@ describe("usage source messages", () => { ]); }); - it("omits empty missing sources, healthy sources, and diagnostics handled by an action", () => { + it("omits empty missing sources and healthy sources", () => { expect( usageSourceMessages([ malformedSource(0, { status: "missing" }), malformedSource(1, { status: "ok" }), - malformedSource(1, { action: "enableCursorKeychain" }), ]), ).toEqual([]); expect(usageSourceMessages([])).toEqual([]); diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts index 51398982e04c8..1c6ba7b8fd75a 100644 --- a/packages/shared/src/usageFormat.ts +++ b/packages/shared/src/usageFormat.ts @@ -26,11 +26,10 @@ export function usageSourceMessages(sources: readonly UsageSource[]): readonly s const malformedBySource = new Map(); for (const source of sources) { if ( - source.action || - (source.status !== "partial" && - source.status !== "failed" && - source.fingerprint.provider !== "cursor" && - !(source.status === "missing" && source.malformedRecords > 0)) + source.status !== "partial" && + source.status !== "failed" && + source.fingerprint.provider !== "cursor" && + !(source.status === "missing" && source.malformedRecords > 0) ) { continue; } diff --git a/packages/source-control-forgejo/src/shared/definition.test.ts b/packages/source-control-forgejo/src/shared/definition.test.ts index 19628b44d3cdf..139d21c37f5ca 100644 --- a/packages/source-control-forgejo/src/shared/definition.test.ts +++ b/packages/source-control-forgejo/src/shared/definition.test.ts @@ -120,6 +120,21 @@ describe("Forgejo remotes", () => { }); }); +describe("Forgejo CLI commands", () => { + it("recognizes shell merges and closes under every spelling tea accepts", () => { + for (const command of [ + "tea pulls merge 7", + "tea pull merge 7", + "tea pr m 7", + "tea pull close 7", + "fj pr close 7", + ]) { + expect(definition.changeRequestCloseCommand.test(command), command).toBe(true); + } + expect(definition.changeRequestCloseCommand.test("tea pull checkout 7")).toBe(false); + }); +}); + describe("Forgejo change request host", () => { it("keeps an HTTP remote's port, because a server's identity includes it", () => { const project = identity("http://forge.example:3000/git/team/repo.git"); diff --git a/packages/source-control-forgejo/src/shared/definition.ts b/packages/source-control-forgejo/src/shared/definition.ts index c75b071c2ab80..f78805fee1c60 100644 --- a/packages/source-control-forgejo/src/shared/definition.ts +++ b/packages/source-control-forgejo/src/shared/definition.ts @@ -93,7 +93,8 @@ export const definition = defineSourceControl({ repositoryUrl ? `git fetch '${repositoryUrl.replaceAll("'", "'\\''")}' ${changeRequestHeadRef(number)} && git checkout -B pulls/${number} FETCH_HEAD` : null, - changeRequestCloseCommand: /\b(?:tea\s+(?:pr|pulls)|fj\s+pr)\s+(?:merge|close)\b/u, + // tea also takes `pull` for `pulls` and `m` for `merge`. + changeRequestCloseCommand: /\b(?:tea\s+(?:pr|pulls?)|fj\s+pr)\s+(?:merge|m|close)\b/u, authorProfileUrl: () => null, hardLineBreaks: true, // Forgejo refuses a change request review without a summary, even with inline comments. diff --git a/packages/source-control-gitlab/src/shared/definition.test.ts b/packages/source-control-gitlab/src/shared/definition.test.ts index 9cd95fadfafcc..a67cfeda25cd8 100644 --- a/packages/source-control-gitlab/src/shared/definition.test.ts +++ b/packages/source-control-gitlab/src/shared/definition.test.ts @@ -100,6 +100,15 @@ describe("GitLab change request URLs", () => { }); }); +describe("GitLab CLI commands", () => { + it("recognizes shell merges and closes, including glab's `accept` alias", () => { + for (const command of ["glab mr merge 7 --yes", "glab mr accept 7 --yes", "glab mr close 7"]) { + expect(definition.changeRequestCloseCommand.test(command), command).toBe(true); + } + expect(definition.changeRequestCloseCommand.test("glab mr view 7")).toBe(false); + }); +}); + describe("GitLab remotes", () => { const recognize = (url: string) => recognizeRemoteWith(definition, url); diff --git a/packages/source-control-gitlab/src/shared/definition.ts b/packages/source-control-gitlab/src/shared/definition.ts index 2ec891cae34b8..16d5bb8cf0efa 100644 --- a/packages/source-control-gitlab/src/shared/definition.ts +++ b/packages/source-control-gitlab/src/shared/definition.ts @@ -62,7 +62,8 @@ export const definition = defineSourceControl({ "delete-branch", ] as const), checkoutCommand: ({ number }) => `glab mr checkout ${number}`, - changeRequestCloseCommand: /\bglab\s+mr\s+(?:merge|close)\b/u, + // `accept` is glab's alias for `merge`. + changeRequestCloseCommand: /\bglab\s+mr\s+(?:merge|accept|close)\b/u, changeRequestCreateCommand: /\bglab\s+mr\s+create\b/u, // Bot users, unlike GitHub's, have real profile pages. authorProfileUrl: (login, repositoryUrl) => diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b17675ebe9bf..b4d1037f3ec5e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1721,9 +1721,6 @@ importers: '@cursor/sdk': specifier: 1.2.0 version: 1.2.0(zod@4.6.5) - '@napi-rs/keyring': - specifier: ^1.3.0 - version: 1.3.0 '@t3tools/contracts': specifier: workspace:* version: link:../contracts