opencode-cursor-provider 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -107,6 +107,19 @@ bun run build
107
107
 
108
108
  The source code is in `src/`. The build writes the package files to `dist/`.
109
109
 
110
+ ## Release
111
+
112
+ Releases use [Release Please](https://github.com/googleapis/release-please) with conventional commits (`feat:`, `fix:`, `docs:`, …). Merging to `main` opens or updates a release PR. Merging that PR tags a GitHub release and publishes to npm.
113
+
114
+ Configure an npm trusted publisher with these values:
115
+
116
+ - Organization or user: `pragmaticivan`
117
+ - Repository: `opencode-cursor-provider`
118
+ - Workflow filename: `release-please.yml`
119
+ - Allowed action: `npm publish`
120
+
121
+ The workflow uses OpenID Connect and does not need an npm token.
122
+
110
123
  ## License
111
124
 
112
125
  Apache License 2.0. See [`LICENSE`](LICENSE).
@@ -1,8 +1,15 @@
1
- import type { Plugin } from "@opencode-ai/plugin";
2
- import type { CursorLink } from "../auth/link.ts";
1
+ import type { CursorApiKey } from "../ids.ts";
3
2
  import { type CursorModelDescriptor } from "./catalog.ts";
3
+ export interface ModelSourceDependencies {
4
+ restoreCredential(): Promise<void>;
5
+ resolveApiKey(): Promise<CursorApiKey | undefined>;
6
+ rejectCredential(): void;
7
+ reloadCatalog(): Promise<void>;
8
+ listModels?(apiKey: CursorApiKey): Promise<unknown>;
9
+ }
4
10
  export interface ModelSource {
5
11
  list(): readonly CursorModelDescriptor[];
6
- refresh(ctx: Plugin.Context): Promise<void>;
12
+ refresh(): Promise<void>;
13
+ close(): Promise<void>;
7
14
  }
8
- export declare function createModelSource(link: CursorLink): ModelSource;
15
+ export declare function createModelSource(dependencies: ModelSourceDependencies): ModelSource;
@@ -1,30 +1,83 @@
1
1
  import { AuthenticationError, Cursor } from "@cursor/sdk";
2
2
  import { parseListedModels, SEED_MODELS } from "./catalog.js";
3
- export function createModelSource(link) {
4
- let models = [...SEED_MODELS];
5
- return {
6
- list() {
7
- return models;
8
- },
9
- async refresh(ctx) {
10
- await link.restore(ctx);
11
- const apiKey = await link.resolve();
12
- if (apiKey === undefined)
13
- return;
14
- try {
15
- models = parseListedModels(await Cursor.models.list({ apiKey }));
16
- }
17
- catch (error) {
18
- if (error instanceof AuthenticationError)
19
- link.reject();
20
- return;
3
+ export function createModelSource(dependencies) {
4
+ const listModels = dependencies.listModels ?? ((apiKey) => Cursor.models.list({ apiKey }));
5
+ let snapshot = { kind: "seed", models: SEED_MODELS };
6
+ let catalogDirty = false;
7
+ let activeDrain;
8
+ let trailingRefresh = false;
9
+ let closed = false;
10
+ let closing;
11
+ async function reloadDirtyCatalog() {
12
+ if (!catalogDirty)
13
+ return;
14
+ try {
15
+ await dependencies.reloadCatalog();
16
+ catalogDirty = false;
17
+ }
18
+ catch {
19
+ return;
20
+ }
21
+ }
22
+ function selectSeeds() {
23
+ if (snapshot.kind === "seed")
24
+ return;
25
+ snapshot = { kind: "seed", models: SEED_MODELS };
26
+ catalogDirty = true;
27
+ }
28
+ async function refreshOnce() {
29
+ await dependencies.restoreCredential();
30
+ const apiKey = await dependencies.resolveApiKey();
31
+ if (apiKey === undefined) {
32
+ selectSeeds();
33
+ await reloadDirtyCatalog();
34
+ return;
35
+ }
36
+ try {
37
+ snapshot = { kind: "listed", models: parseListedModels(await listModels(apiKey)) };
38
+ catalogDirty = true;
39
+ }
40
+ catch (error) {
41
+ if (error instanceof AuthenticationError) {
42
+ dependencies.rejectCredential();
43
+ selectSeeds();
21
44
  }
45
+ }
46
+ await reloadDirtyCatalog();
47
+ }
48
+ function startDrain() {
49
+ return (async () => {
22
50
  try {
23
- await ctx.catalog.reload();
51
+ do {
52
+ trailingRefresh = false;
53
+ await refreshOnce();
54
+ } while (trailingRefresh);
24
55
  }
25
- catch {
26
- // Keep the listed models. The next credential or poll can replay the catalog.
56
+ finally {
57
+ activeDrain = undefined;
27
58
  }
59
+ })();
60
+ }
61
+ return {
62
+ list() {
63
+ return snapshot.models;
64
+ },
65
+ refresh() {
66
+ if (closed)
67
+ return Promise.resolve();
68
+ if (activeDrain !== undefined) {
69
+ trailingRefresh = true;
70
+ return activeDrain;
71
+ }
72
+ activeDrain = startDrain();
73
+ return activeDrain;
74
+ },
75
+ close() {
76
+ if (closing !== undefined)
77
+ return closing;
78
+ closed = true;
79
+ closing = activeDrain ?? Promise.resolve();
80
+ return closing;
28
81
  },
29
82
  };
30
83
  }
package/dist/plugin.d.ts CHANGED
@@ -1,2 +1,11 @@
1
1
  import { Plugin } from "@opencode-ai/plugin";
2
2
  export declare const plugin: Plugin.Plugin;
3
+ export interface ModelWatcherDependencies {
4
+ onLinked(listener: () => void): () => void;
5
+ subscribe(signal: AbortSignal): AsyncIterable<{
6
+ readonly type: string;
7
+ }>;
8
+ refresh(): Promise<void>;
9
+ close(): Promise<void>;
10
+ }
11
+ export declare function watchModels(dependencies: ModelWatcherDependencies): () => Promise<void>;
package/dist/plugin.js CHANGED
@@ -13,7 +13,12 @@ export const plugin = Plugin.define({
13
13
  id: "opencode-cursor-provider",
14
14
  async setup(ctx) {
15
15
  const link = createCursorLink();
16
- const models = createModelSource(link);
16
+ const models = createModelSource({
17
+ restoreCredential: () => link.restore(ctx),
18
+ resolveApiKey: () => link.resolve(),
19
+ rejectCredential: () => link.reject(),
20
+ reloadCatalog: () => ctx.catalog.reload(),
21
+ });
17
22
  const bridge = createSessionAgentBridge({
18
23
  link,
19
24
  models: () => models.list(),
@@ -54,9 +59,14 @@ export const plugin = Plugin.define({
54
59
  event.language = languageModel(event.model, event.model.id, event.options);
55
60
  }, { providerID: PROVIDER_ID }),
56
61
  ];
57
- const stopRefresh = watchModels(ctx, link, models);
62
+ const stopRefresh = watchModels({
63
+ onLinked: (listener) => link.onLinked(listener),
64
+ subscribe: (signal) => ctx.event.subscribe({ signal }),
65
+ refresh: () => models.refresh(),
66
+ close: () => models.close(),
67
+ });
58
68
  return async () => {
59
- stopRefresh();
69
+ await stopRefresh();
60
70
  for (const registration of registrations.toReversed()) {
61
71
  await registration.dispose();
62
72
  }
@@ -94,16 +104,20 @@ function writeSystem(system, stamped) {
94
104
  system[index] = { type: "text", text };
95
105
  }
96
106
  }
97
- function watchModels(ctx, link, models) {
107
+ export function watchModels(dependencies) {
98
108
  const events = new AbortController();
109
+ let stopped = false;
110
+ let cleanup;
99
111
  const refresh = () => {
100
- void models.refresh(ctx);
112
+ if (stopped)
113
+ return;
114
+ void dependencies.refresh().catch(() => { });
101
115
  };
102
- const stopLinked = link.onLinked(refresh);
116
+ const stopLinked = dependencies.onLinked(refresh);
103
117
  refresh();
104
- void (async () => {
118
+ const eventTask = (async () => {
105
119
  try {
106
- for await (const event of ctx.event.subscribe({ signal: events.signal })) {
120
+ for await (const event of dependencies.subscribe(events.signal)) {
107
121
  if (event.type === "credential.updated" || event.type === "credential.switched")
108
122
  refresh();
109
123
  }
@@ -119,9 +133,17 @@ function watchModels(ctx, link, models) {
119
133
  }, REFRESH_FOR_MS);
120
134
  stop.unref?.();
121
135
  return () => {
122
- stopLinked();
123
- events.abort();
124
- clearInterval(retry);
125
- clearTimeout(stop);
136
+ if (cleanup !== undefined)
137
+ return cleanup;
138
+ cleanup = (async () => {
139
+ stopped = true;
140
+ stopLinked();
141
+ events.abort();
142
+ clearInterval(retry);
143
+ clearTimeout(stop);
144
+ await eventTask;
145
+ await dependencies.close();
146
+ })();
147
+ return cleanup;
126
148
  };
127
149
  }
package/package.json CHANGED
@@ -1,22 +1,38 @@
1
1
  {
2
2
  "name": "opencode-cursor-provider",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "OpenCode 2 plugin that connects Cursor via the official SDK",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/pragmaticivan/opencode-cursor-provider.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/pragmaticivan/opencode-cursor-provider/issues"
13
+ },
14
+ "homepage": "https://github.com/pragmaticivan/opencode-cursor-provider#readme",
7
15
  "engines": {
8
16
  "node": ">=22.13"
9
17
  },
10
- "main": "./src/index.ts",
18
+ "main": "./dist/index.js",
19
+ "types": "./dist/index.d.ts",
11
20
  "exports": {
12
- ".": "./src/index.ts",
13
- "./server": "./src/index.ts"
21
+ ".": {
22
+ "types": "./dist/index.d.ts",
23
+ "import": "./dist/index.js"
24
+ },
25
+ "./server": {
26
+ "types": "./dist/index.d.ts",
27
+ "import": "./dist/index.js"
28
+ }
14
29
  },
15
30
  "files": [
16
31
  "dist"
17
32
  ],
18
33
  "scripts": {
19
34
  "build": "tsc -p tsconfig.build.json",
35
+ "prepublishOnly": "bun run build",
20
36
  "typecheck": "tsc -p tsconfig.json --noEmit",
21
37
  "test": "bun test"
22
38
  },