Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,6 @@ const recordCursorReplayCommand = Command.make(
);

Command.run(recordCursorReplayCommand, { version: "0.0.0" }).pipe(
Effect.provide(Layer.mergeAll(CursorSdk.layer, NodeServices.layer)),
Effect.provide(CursorSdk.layer.pipe(Layer.provideMerge(NodeServices.layer))),
NodeRuntime.runMain,
);
107 changes: 106 additions & 1 deletion packages/provider-cursor/src/server/CursorSdk.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,42 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, describe, it } from "@effect/vitest";
import type { CursorConfigureOptions } from "@cursor/sdk";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Logger from "effect/Logger";
import * as Path from "effect/Path";
import { vi } from "vite-plus/test";

import * as CursorSdk from "./CursorSdk.ts";

const sdkDefaults = vi.hoisted(() => ({ useHttp1ForAgent: undefined as boolean | undefined }));

// Holds the SDK's process-wide default the way it does: only keys present change, null clears.
vi.mock("./sdk.ts", () => ({
loadCursorSdk: () => ({
Cursor: {
configure: ({ local }: CursorConfigureOptions) => {
if (local !== undefined && "useHttp1ForAgent" in local) {
sdkDefaults.useHttp1ForAgent = local.useHttp1ForAgent ?? undefined;
}
},
},
}),
loadCursorSqlite: () => ({}),
}));

const withHost = (environment: NodeJS.ProcessEnv, home: string) =>
Effect.provide(
Layer.mergeAll(
NodeServices.layer,
Layer.succeed(HostProcess.Environment, environment),
Layer.succeed(HostProcess.HomeDirectory, home),
),
);

// Shaped like the SDK's minified class: the constructor is not named after the error.
// @effect-diagnostics-next-line extendsNativeError:off - mimics the Cursor SDK's own native Error class.
class o extends Error {
Expand All @@ -27,7 +59,7 @@ const rejectThroughGuard = (reason: unknown) =>
for (const listener of others) process.on("unhandledRejection", listener);
}
return exit.mock.calls;
}).pipe(Effect.scoped);
}).pipe(Effect.scoped, withHost({}, "/nonexistent-home"));

describe("CursorSdk unhandled rejection guard", () => {
it.effect("keeps the server running when the SDK writes to a closed stream", () =>
Expand Down Expand Up @@ -70,3 +102,76 @@ describe("CursorSdk unhandled rejection guard", () => {
}),
);
});

const http1 = JSON.stringify({ network: { useHttp1ForAgent: true } });

describe("CursorSdk agent transport", () => {
it.effect.each([
{
name: "when set in CURSOR_CONFIG_DIR over XDG_CONFIG_HOME",
env: { CURSOR_CONFIG_DIR: "config", XDG_CONFIG_HOME: "xdg" },
file: "config/cli-config.json",
text: http1,
expected: true,
},
{
name: "when set in XDG_CONFIG_HOME",
env: { XDG_CONFIG_HOME: "xdg" },
file: "xdg/cursor/cli-config.json",
text: http1,
expected: true,
},
{
name: "when set in the home directory",
env: {},
file: "home/.cursor/cli-config.json",
text: http1,
expected: true,
},
{
name: "when it is off",
env: {},
file: "home/.cursor/cli-config.json",
text: JSON.stringify({ network: { useHttp1ForAgent: false } }),
expected: undefined,
},
{
name: "when the file is damaged",
env: {},
file: "home/.cursor/cli-config.json",
text: "{",
expected: undefined,
warns: true,
},
{ name: "when there is no file", env: {}, file: undefined, text: "", expected: undefined },
])("follows the Cursor CLI's HTTP/1.1 setting $name", ({ env, file, text, expected, warns }) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-cursor-cli-config-" });
if (file !== undefined) {
yield* fileSystem.makeDirectory(path.dirname(path.join(root, file)), { recursive: true });
yield* fileSystem.writeFileString(path.join(root, file), text);
}
const environment = Object.fromEntries(
Object.entries(env).map(([key, directory]) => [key, path.join(root, directory)]),
);
const warnings: Array<unknown> = [];
const captureWarnings = Logger.make(({ logLevel, message }) => {
if (logLevel === "Warn") warnings.push(message);
});
yield* Effect.gen(function* () {
const sdk = Context.get(yield* Layer.build(CursorSdk.layer), CursorSdk.CursorSdk);
assert.isUndefined(sdkDefaults.useHttp1ForAgent);
sdk.load();
assert.strictEqual(sdkDefaults.useHttp1ForAgent, expected);
}).pipe(
Effect.scoped,
withHost(environment, path.join(root, "home")),
Effect.provide(Logger.layer([captureWarnings], { mergeWithExisting: false })),
);
assert.isUndefined(sdkDefaults.useHttp1ForAgent);
assert.lengthOf(warnings, warns ? 1 : 0);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);
});
72 changes: 65 additions & 7 deletions packages/provider-cursor/src/server/CursorSdk.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
// @effect-diagnostics nodeBuiltinImport:off globalConsole:off -- The guard runs in Node's unhandled-rejection callback, outside an Effect runtime. stderr must match Node's default unhandled-rejection print.
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";

import { loadCursorSdk, loadCursorSqlite } from "./sdk.ts";

Expand Down Expand Up @@ -67,6 +71,39 @@ function onUnhandledRejection(reason: unknown): void {
process.exit(1);
}

const decodeCursorCliConfig = Schema.decodeEffect(
Schema.fromJsonString(
Schema.Struct({
network: Schema.optionalKey(
Schema.Struct({ useHttp1ForAgent: Schema.optionalKey(Schema.Boolean) }),
),
}),
),
);

// Proxies without HTTP/2 need this Cursor CLI setting, which the SDK never reads itself.
const readCursorCliUseHttp1ForAgent = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const { CURSOR_CONFIG_DIR, XDG_CONFIG_HOME } = yield* HostProcess.Environment;
const configDirectory = CURSOR_CONFIG_DIR?.trim()
? CURSOR_CONFIG_DIR
: XDG_CONFIG_HOME?.trim()
? path.join(XDG_CONFIG_HOME, "cursor")
: path.join(yield* HostProcess.HomeDirectory, ".cursor");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const configPath = path.join(configDirectory, "cli-config.json");
const config = yield* fileSystem.readFileString(configPath).pipe(
Effect.flatMap(decodeCursorCliConfig),
Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined),
Effect.catch(() =>
Effect.logWarning("Cursor CLI config could not be read; using the default agent transport", {
path: configPath,
}).pipe(Effect.as(undefined)),
),
);
return config?.network?.useHttp1ForAgent === true;
}).pipe(Effect.withSpan("CursorSdk.readCliUseHttp1ForAgent"));

/** Keeps the shell spawn guard armed while in-process agents run. */
export class CursorSdk extends Context.Service<
CursorSdk,
Expand All @@ -79,13 +116,34 @@ export class CursorSdk extends Context.Service<
/** Provide once per process: a second guard would defer to the first and swallow real rejections. */
export const layer = Layer.effect(
CursorSdk,
Effect.acquireRelease(
Effect.sync(() => {
process.on("unhandledRejection", onUnhandledRejection);
}),
() =>
Effect.gen(function* () {
yield* Effect.acquireRelease(
Effect.sync(() => {
process.on("unhandledRejection", onUnhandledRejection);
}),
() =>
Effect.sync(() => {
process.off("unhandledRejection", onUnhandledRejection);
}),
);
// The SDK picks the agent transport per process, so no provider instance can own this.
const useHttp1ForAgent = yield* readCursorCliUseHttp1ForAgent;
let configured = false;
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
process.off("unhandledRejection", onUnhandledRejection);
if (configured) loadCursorSdk().Cursor.configure({ local: { useHttp1ForAgent: null } });
}),
).pipe(Effect.as(CursorSdk.of({ load: loadCursorSdk, loadSqlite: loadCursorSqlite }))),
);
return CursorSdk.of({
load: () => {
const sdk = loadCursorSdk();
if (useHttp1ForAgent && !configured) {
sdk.Cursor.configure({ local: { useHttp1ForAgent: true } });
configured = true;
}
return sdk;
},
loadSqlite: loadCursorSqlite,
});
}),
);
2 changes: 1 addition & 1 deletion packages/provider-cursor/src/server/driver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ const layerTest = TestProviderHost.layer({ runBackgroundWork: false }).pipe(
Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
Layer.provideMerge(IdAllocator.layer),
Layer.provideMerge(McpProviderSessions.layer),
Layer.provideMerge(CursorSdk.layer),
Layer.provideMerge(CursorSdk.layer.pipe(Layer.provide(NodeServices.layer))),
Layer.provideMerge(
Layer.mock(CursorAgentSdk.CursorAgentSdkRunner)({
open: () => Effect.die("Maintenance resolution must not open a Cursor session"),
Expand Down
3 changes: 2 additions & 1 deletion packages/provider-cursor/src/server/textGeneration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { describe, expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import { createModelSelection } from "@t3tools/shared/model";
import { beforeEach, vi } from "vite-plus/test";
Expand Down Expand Up @@ -40,7 +41,7 @@ const layerFs = FileSystem.layerNoop({
exists: () => Effect.succeed(hasCustomPolicy),
makeTempDirectoryScoped: () => Effect.succeed("/isolated-text-generation"),
});
const layerTest = Layer.merge(layerFs, CursorSdk.layer);
const layerTest = CursorSdk.layer.pipe(Layer.provideMerge(Layer.merge(layerFs, Path.layer)));

const decodeCursorSettings = Schema.decodeSync(CursorSettings);
const cursorSettings = decodeCursorSettings({ enabled: true });
Expand Down
Loading