cursor-opencode-provider 0.7.5 → 0.8.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 +9 -7
- package/dist/agent-url.js +2 -2
- package/dist/auth-renewal.d.ts +85 -0
- package/dist/auth-renewal.js +284 -0
- package/dist/auth.d.ts +38 -22
- package/dist/auth.js +79 -96
- package/dist/classic-tools.d.ts +15 -0
- package/dist/classic-tools.js +45 -0
- package/dist/context/build.d.ts +42 -5
- package/dist/context/build.js +96 -92
- package/dist/context/dynamic-catalog.d.ts +3 -28
- package/dist/context/dynamic-catalog.js +25 -130
- package/dist/context/epoch.d.ts +6 -4
- package/dist/context/epoch.js +22 -10
- package/dist/context/frozen.js +37 -9
- package/dist/context/index.d.ts +1 -1
- package/dist/context/index.js +1 -1
- package/dist/context/overlay.d.ts +2 -23
- package/dist/context/overlay.js +6 -30
- package/dist/context/paths.d.ts +7 -4
- package/dist/context/paths.js +13 -9
- package/dist/context/rules.d.ts +3 -15
- package/dist/context/rules.js +10 -186
- package/dist/debug.d.ts +2 -0
- package/dist/debug.js +4 -0
- package/dist/errors.d.ts +13 -0
- package/dist/errors.js +27 -3
- package/dist/host-agent-mode.js +2 -2
- package/dist/image-save.js +3 -3
- package/dist/index.d.ts +10 -0
- package/dist/language-model.d.ts +59 -8
- package/dist/language-model.js +458 -118
- package/dist/opencode2/integration.d.ts +30 -7
- package/dist/opencode2/integration.js +75 -46
- package/dist/plan-execution-kickoff.js +2 -2
- package/dist/plugin-opencode2.js +10 -16
- package/dist/plugin.d.ts +0 -10
- package/dist/plugin.js +138 -109
- package/dist/protocol/blob-store.js +3 -2
- package/dist/protocol/conversation-bind.js +1 -1
- package/dist/protocol/conversation-persistence.d.ts +2 -0
- package/dist/protocol/conversation-persistence.js +11 -1
- package/dist/protocol/conversation-state.d.ts +2 -0
- package/dist/protocol/conversation-state.js +19 -0
- package/dist/protocol/create-plan.js +2 -1
- package/dist/protocol/messages.js +22 -4
- package/dist/protocol/request.d.ts +11 -9
- package/dist/protocol/request.js +15 -25
- package/dist/protocol/tools.d.ts +17 -10
- package/dist/protocol/tools.js +53 -93
- package/dist/protocol/turn-provenance.d.ts +52 -0
- package/dist/protocol/turn-provenance.js +174 -0
- package/dist/session.d.ts +9 -1
- package/dist/shared.d.ts +7 -0
- package/dist/shared.js +7 -0
- package/package.json +1 -1
- package/dist/context/agents.d.ts +0 -12
- package/dist/context/agents.js +0 -136
- package/dist/context/skills.d.ts +0 -11
- package/dist/context/skills.js +0 -110
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { LanguageModelV3CallOptions } from "@ai-sdk/provider";
|
|
2
|
+
/** Mirrors MAX_TURN_STATE_SESSIONS; evicted sessions re-hydrate from disk. */
|
|
3
|
+
export declare const MAX_PROVENANCE_SESSIONS = 256;
|
|
4
|
+
export type TurnProvenance = {
|
|
5
|
+
conversationId: string;
|
|
6
|
+
/** Tool call ids emitted in the latest non-empty step. */
|
|
7
|
+
toolCallIds: string[];
|
|
8
|
+
/** Whitespace-free text emitted in the latest non-empty step (first 4 KiB). */
|
|
9
|
+
text: string;
|
|
10
|
+
/**
|
|
11
|
+
* Whitespace-free reasoning and text of the same step in emission order
|
|
12
|
+
* (first 4 KiB). OpenCode 1.x replays a previous model's reasoning as text
|
|
13
|
+
* parts after a model switch (`session/message-v2.ts` `differentModel`), and
|
|
14
|
+
* OpenCode 2 does the same for an assistant message that ended in error.
|
|
15
|
+
*/
|
|
16
|
+
textWithReasoning: string;
|
|
17
|
+
};
|
|
18
|
+
export type ForeignHistoryReason = "foreign-assistant";
|
|
19
|
+
/**
|
|
20
|
+
* Mark the start of one host step (one doStream). The previous step stays
|
|
21
|
+
* recorded until this one emits content, because hosts drop empty assistant
|
|
22
|
+
* turns from history.
|
|
23
|
+
*/
|
|
24
|
+
export declare function beginEmittedStep(sessionKey: string, conversationId: string): void;
|
|
25
|
+
/** Record one stream part this provider handed to the host. */
|
|
26
|
+
export declare function recordEmittedPart(sessionKey: string, conversationId: string, part: {
|
|
27
|
+
type: string;
|
|
28
|
+
delta?: unknown;
|
|
29
|
+
toolCallId?: unknown;
|
|
30
|
+
}): void;
|
|
31
|
+
/**
|
|
32
|
+
* Bind provenance to the conversation a Run was opened on. A reminted
|
|
33
|
+
* conversation starts with an empty record, so it never inherits the previous
|
|
34
|
+
* conversation's steps.
|
|
35
|
+
*/
|
|
36
|
+
export declare function trackTurnProvenance(sessionKey: string, conversationId: string): void;
|
|
37
|
+
export declare function getTurnProvenance(sessionKey: string): TurnProvenance | undefined;
|
|
38
|
+
export declare function restoreTurnProvenance(sessionKey: string, value: TurnProvenance): void;
|
|
39
|
+
export declare function resetTurnProvenanceForTests(): void;
|
|
40
|
+
export declare function serializeTurnProvenance(value: TurnProvenance): string;
|
|
41
|
+
export declare function parseTurnProvenance(raw: string): TurnProvenance | undefined;
|
|
42
|
+
/**
|
|
43
|
+
* Decide whether the host history moved past this Cursor conversation.
|
|
44
|
+
* Returns undefined when there is no evidence either way (no record for this
|
|
45
|
+
* conversation, or no assistant turn yet), so unknown state never forces a
|
|
46
|
+
* rebase.
|
|
47
|
+
*/
|
|
48
|
+
export declare function detectForeignHistory(input: {
|
|
49
|
+
sessionKey: string | undefined;
|
|
50
|
+
conversationId: string;
|
|
51
|
+
prompt: LanguageModelV3CallOptions["prompt"];
|
|
52
|
+
}): ForeignHistoryReason | undefined;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// A sticky Cursor conversation only knows what Cursor itself produced. When the
|
|
2
|
+
// host answers a turn with another model (another provider or a local model)
|
|
3
|
+
// and then comes back, resuming the old checkpoint hides that work from Cursor.
|
|
4
|
+
// A direct switch between two Cursor models is not foreign history: like the
|
|
5
|
+
// Cursor CLI, the next Run resumes the same conversation with the new model.
|
|
6
|
+
// Remember what this provider emitted in its most recent step per OpenCode
|
|
7
|
+
// session so the next fresh Run can tell whether the host's latest assistant
|
|
8
|
+
// turn is ours.
|
|
9
|
+
/** Mirrors MAX_TURN_STATE_SESSIONS; evicted sessions re-hydrate from disk. */
|
|
10
|
+
export const MAX_PROVENANCE_SESSIONS = 256;
|
|
11
|
+
// Per-session footprint stays small: a step is identified by its first tool
|
|
12
|
+
// call ids and the first 4 KiB of its text (and of its reasoning plus text),
|
|
13
|
+
// which is enough to tell turns apart.
|
|
14
|
+
const MAX_TOOL_CALL_IDS = 64;
|
|
15
|
+
const MAX_TEXT_CHARS = 4 * 1024;
|
|
16
|
+
const provenanceBySession = new Map();
|
|
17
|
+
function normalizeText(text) {
|
|
18
|
+
return text.replace(/\s+/g, "");
|
|
19
|
+
}
|
|
20
|
+
function touch(sessionKey, entry) {
|
|
21
|
+
provenanceBySession.delete(sessionKey);
|
|
22
|
+
provenanceBySession.set(sessionKey, entry);
|
|
23
|
+
while (provenanceBySession.size > MAX_PROVENANCE_SESSIONS) {
|
|
24
|
+
const oldest = provenanceBySession.keys().next().value;
|
|
25
|
+
if (!oldest)
|
|
26
|
+
break;
|
|
27
|
+
provenanceBySession.delete(oldest);
|
|
28
|
+
}
|
|
29
|
+
return entry;
|
|
30
|
+
}
|
|
31
|
+
function entryFor(sessionKey, conversationId) {
|
|
32
|
+
const existing = provenanceBySession.get(sessionKey);
|
|
33
|
+
if (existing && existing.conversationId === conversationId)
|
|
34
|
+
return touch(sessionKey, existing);
|
|
35
|
+
return touch(sessionKey, { conversationId, toolCallIds: [], text: "", textWithReasoning: "", stepPending: false });
|
|
36
|
+
}
|
|
37
|
+
function appendCapped(value, delta) {
|
|
38
|
+
return value.length < MAX_TEXT_CHARS ? (value + delta).slice(0, MAX_TEXT_CHARS) : value;
|
|
39
|
+
}
|
|
40
|
+
function contentEntry(sessionKey, conversationId) {
|
|
41
|
+
const entry = entryFor(sessionKey, conversationId);
|
|
42
|
+
if (entry.stepPending) {
|
|
43
|
+
entry.stepPending = false;
|
|
44
|
+
entry.toolCallIds = [];
|
|
45
|
+
entry.text = "";
|
|
46
|
+
entry.textWithReasoning = "";
|
|
47
|
+
}
|
|
48
|
+
return entry;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Mark the start of one host step (one doStream). The previous step stays
|
|
52
|
+
* recorded until this one emits content, because hosts drop empty assistant
|
|
53
|
+
* turns from history.
|
|
54
|
+
*/
|
|
55
|
+
export function beginEmittedStep(sessionKey, conversationId) {
|
|
56
|
+
entryFor(sessionKey, conversationId).stepPending = true;
|
|
57
|
+
}
|
|
58
|
+
/** Record one stream part this provider handed to the host. */
|
|
59
|
+
export function recordEmittedPart(sessionKey, conversationId, part) {
|
|
60
|
+
if ((part.type === "text-delta" || part.type === "reasoning-delta") && typeof part.delta === "string") {
|
|
61
|
+
const delta = normalizeText(part.delta);
|
|
62
|
+
if (!delta)
|
|
63
|
+
return;
|
|
64
|
+
const entry = contentEntry(sessionKey, conversationId);
|
|
65
|
+
entry.textWithReasoning = appendCapped(entry.textWithReasoning, delta);
|
|
66
|
+
if (part.type === "text-delta")
|
|
67
|
+
entry.text = appendCapped(entry.text, delta);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (part.type === "tool-call" && typeof part.toolCallId === "string" && part.toolCallId) {
|
|
71
|
+
const entry = contentEntry(sessionKey, conversationId);
|
|
72
|
+
if (entry.toolCallIds.length < MAX_TOOL_CALL_IDS)
|
|
73
|
+
entry.toolCallIds.push(part.toolCallId);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Bind provenance to the conversation a Run was opened on. A reminted
|
|
78
|
+
* conversation starts with an empty record, so it never inherits the previous
|
|
79
|
+
* conversation's steps.
|
|
80
|
+
*/
|
|
81
|
+
export function trackTurnProvenance(sessionKey, conversationId) {
|
|
82
|
+
entryFor(sessionKey, conversationId);
|
|
83
|
+
}
|
|
84
|
+
export function getTurnProvenance(sessionKey) {
|
|
85
|
+
const entry = provenanceBySession.get(sessionKey);
|
|
86
|
+
if (!entry)
|
|
87
|
+
return undefined;
|
|
88
|
+
return {
|
|
89
|
+
conversationId: entry.conversationId,
|
|
90
|
+
toolCallIds: [...entry.toolCallIds],
|
|
91
|
+
text: entry.text,
|
|
92
|
+
textWithReasoning: entry.textWithReasoning,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
export function restoreTurnProvenance(sessionKey, value) {
|
|
96
|
+
touch(sessionKey, { ...value, toolCallIds: [...value.toolCallIds], stepPending: false });
|
|
97
|
+
}
|
|
98
|
+
export function resetTurnProvenanceForTests() {
|
|
99
|
+
provenanceBySession.clear();
|
|
100
|
+
}
|
|
101
|
+
export function serializeTurnProvenance(value) {
|
|
102
|
+
return JSON.stringify(value);
|
|
103
|
+
}
|
|
104
|
+
export function parseTurnProvenance(raw) {
|
|
105
|
+
try {
|
|
106
|
+
const value = JSON.parse(raw);
|
|
107
|
+
if (typeof value.conversationId !== "string" || !value.conversationId)
|
|
108
|
+
return undefined;
|
|
109
|
+
return {
|
|
110
|
+
conversationId: value.conversationId,
|
|
111
|
+
toolCallIds: Array.isArray(value.toolCallIds)
|
|
112
|
+
? value.toolCallIds.filter((id) => typeof id === "string").slice(0, MAX_TOOL_CALL_IDS)
|
|
113
|
+
: [],
|
|
114
|
+
text: typeof value.text === "string" ? value.text.slice(0, MAX_TEXT_CHARS) : "",
|
|
115
|
+
textWithReasoning: typeof value.textWithReasoning === "string"
|
|
116
|
+
? value.textWithReasoning.slice(0, MAX_TEXT_CHARS)
|
|
117
|
+
: "",
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Decide whether the host history moved past this Cursor conversation.
|
|
126
|
+
* Returns undefined when there is no evidence either way (no record for this
|
|
127
|
+
* conversation, or no assistant turn yet), so unknown state never forces a
|
|
128
|
+
* rebase.
|
|
129
|
+
*/
|
|
130
|
+
export function detectForeignHistory(input) {
|
|
131
|
+
if (!input.sessionKey)
|
|
132
|
+
return undefined;
|
|
133
|
+
const entry = provenanceBySession.get(input.sessionKey);
|
|
134
|
+
if (!entry || entry.conversationId !== input.conversationId)
|
|
135
|
+
return undefined;
|
|
136
|
+
let lastAssistant;
|
|
137
|
+
for (let i = input.prompt.length - 1; i >= 0; i--) {
|
|
138
|
+
if (input.prompt[i].role === "assistant") {
|
|
139
|
+
lastAssistant = input.prompt[i];
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (!lastAssistant || !Array.isArray(lastAssistant.content))
|
|
144
|
+
return undefined;
|
|
145
|
+
const toolCallIds = [];
|
|
146
|
+
let text = "";
|
|
147
|
+
let textWithReasoning = "";
|
|
148
|
+
for (const part of lastAssistant.content) {
|
|
149
|
+
if (part.type === "tool-call" && typeof part.toolCallId === "string")
|
|
150
|
+
toolCallIds.push(part.toolCallId);
|
|
151
|
+
if ((part.type === "text" || part.type === "reasoning") && typeof part.text === "string") {
|
|
152
|
+
const normalized = normalizeText(part.text);
|
|
153
|
+
textWithReasoning += normalized;
|
|
154
|
+
if (part.type === "text")
|
|
155
|
+
text += normalized;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// A turn carrying only typed reasoning gives no evidence either way.
|
|
159
|
+
if (toolCallIds.length === 0 && !text)
|
|
160
|
+
return undefined;
|
|
161
|
+
if (toolCallIds.some((id) => entry.toolCallIds.includes(id)))
|
|
162
|
+
return undefined;
|
|
163
|
+
// Compare against the latest step only: a foreign "Done." must not match an
|
|
164
|
+
// older Cursor turn that happened to contain the same words. Same model: the
|
|
165
|
+
// host keeps our reasoning typed, so its text parts match our text. After a
|
|
166
|
+
// model switch OpenCode 1.x replays that reasoning as text parts in place,
|
|
167
|
+
// so the turn matches our reasoning and text in emission order instead.
|
|
168
|
+
if (entry.text && text.slice(0, MAX_TEXT_CHARS) === entry.text)
|
|
169
|
+
return undefined;
|
|
170
|
+
if (entry.textWithReasoning && textWithReasoning.slice(0, MAX_TEXT_CHARS) === entry.textWithReasoning) {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
return "foreign-assistant";
|
|
174
|
+
}
|
package/dist/session.d.ts
CHANGED
|
@@ -140,10 +140,18 @@ export type CursorSession = {
|
|
|
140
140
|
hostAgent?: string;
|
|
141
141
|
/** Stable host-system + provider-guidance identity for restart validation. */
|
|
142
142
|
stableSystemPromptHash?: string;
|
|
143
|
+
/** Fresh turn resumed from a stored checkpoint; a blob miss may reseed it. */
|
|
144
|
+
checkpointRebaseEligible?: boolean;
|
|
143
145
|
/** Completed compaction must rebase once before resuming a normal agent. */
|
|
144
146
|
postCompactionRebase?: boolean;
|
|
145
|
-
/**
|
|
147
|
+
/**
|
|
148
|
+
* Epoch-held host catalog for this OpenCode session. Refreshed on every
|
|
149
|
+
* `doStream` (including held-Run continuation) so exec #36 and permission
|
|
150
|
+
* can see tools that connected after Run open. Also the lifecycle-turn fallback.
|
|
151
|
+
*/
|
|
146
152
|
toolCatalog?: OpencodeToolDef[];
|
|
153
|
+
/** Configured MCP server ids used to split flattened host tool names. */
|
|
154
|
+
knownMcpServers?: string[];
|
|
147
155
|
stream: BidiStream;
|
|
148
156
|
frames: AsyncIterator<Frame>;
|
|
149
157
|
pending: Map<number, PendingExec>;
|
package/dist/shared.d.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
export declare const CURSOR_API_HOST = "api2.cursor.sh";
|
|
2
2
|
export declare const CURSOR_WEBSITE_HOST = "cursor.com";
|
|
3
|
+
/**
|
|
4
|
+
* Production OAuth client id Cursor's IDE sends on `POST /oauth/token`
|
|
5
|
+
* (Cursor 3.17.19 `workbench.desktop.main.js`, `cursorCreds.authClientId` for
|
|
6
|
+
* `backendUrl` `https://api2.cursor.sh`). The IDE's non-production id is not
|
|
7
|
+
* used: a custom `CURSOR_API_BASE_URL` keeps this one.
|
|
8
|
+
*/
|
|
9
|
+
export declare const CURSOR_OAUTH_CLIENT_ID = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB";
|
|
3
10
|
export declare const FALLBACK_CLIENT_VERSION = "cli-2026.07.09-a3815c0";
|
|
4
11
|
export declare const CURSOR_PROVIDER_ID = "cursor";
|
|
5
12
|
/** Private provider option injected by the OpenCode plugin for summary turns. */
|
package/dist/shared.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
export const CURSOR_API_HOST = "api2.cursor.sh";
|
|
2
2
|
export const CURSOR_WEBSITE_HOST = "cursor.com";
|
|
3
|
+
/**
|
|
4
|
+
* Production OAuth client id Cursor's IDE sends on `POST /oauth/token`
|
|
5
|
+
* (Cursor 3.17.19 `workbench.desktop.main.js`, `cursorCreds.authClientId` for
|
|
6
|
+
* `backendUrl` `https://api2.cursor.sh`). The IDE's non-production id is not
|
|
7
|
+
* used: a custom `CURSOR_API_BASE_URL` keeps this one.
|
|
8
|
+
*/
|
|
9
|
+
export const CURSOR_OAUTH_CLIENT_ID = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB";
|
|
3
10
|
export const FALLBACK_CLIENT_VERSION = "cli-2026.07.09-a3815c0";
|
|
4
11
|
export const CURSOR_PROVIDER_ID = "cursor";
|
|
5
12
|
/** Private provider option injected by the OpenCode plugin for summary turns. */
|
package/package.json
CHANGED
package/dist/context/agents.d.ts
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
export type CollectedAgent = {
|
|
2
|
-
fullPath: string;
|
|
3
|
-
name: string;
|
|
4
|
-
description: string;
|
|
5
|
-
prompt: string;
|
|
6
|
-
};
|
|
7
|
-
/**
|
|
8
|
-
* Custom agents from the active OpenCode-compatible config roots. A host bridge may
|
|
9
|
-
* install a host-neutral bridge before loading this module; without one this
|
|
10
|
-
* remains the direct OpenCode discovery path.
|
|
11
|
-
*/
|
|
12
|
-
export declare function collectAgents(workspaceRoot: string): Promise<CollectedAgent[]>;
|
package/dist/context/agents.js
DELETED
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
import { readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { opencodeGlobalConfigDirs, opencodeProjectConfigDirs } from "./paths.js";
|
|
4
|
-
async function exists(p) {
|
|
5
|
-
try {
|
|
6
|
-
await stat(p);
|
|
7
|
-
return true;
|
|
8
|
-
}
|
|
9
|
-
catch {
|
|
10
|
-
return false;
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
|
-
function parseAgentMarkdown(raw, fallbackName) {
|
|
14
|
-
let name = fallbackName;
|
|
15
|
-
let description = "";
|
|
16
|
-
let body = raw;
|
|
17
|
-
if (raw.startsWith("---")) {
|
|
18
|
-
const end = raw.indexOf("\n---", 3);
|
|
19
|
-
if (end >= 0) {
|
|
20
|
-
const fm = raw.slice(3, end).trim();
|
|
21
|
-
body = raw.slice(end + 4).replace(/^\n/, "");
|
|
22
|
-
for (const line of fm.split("\n")) {
|
|
23
|
-
const m = line.match(/^(\w+):\s*(.*)$/);
|
|
24
|
-
if (!m)
|
|
25
|
-
continue;
|
|
26
|
-
let val = m[2].trim();
|
|
27
|
-
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
28
|
-
val = val.slice(1, -1);
|
|
29
|
-
}
|
|
30
|
-
if (m[1] === "name")
|
|
31
|
-
name = val;
|
|
32
|
-
if (m[1] === "description")
|
|
33
|
-
description = val;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
return { name, description, prompt: body.slice(0, 20_000) };
|
|
38
|
-
}
|
|
39
|
-
function uniquePaths(paths) {
|
|
40
|
-
const seen = new Set();
|
|
41
|
-
return paths.filter((value) => {
|
|
42
|
-
const normalized = path.resolve(value);
|
|
43
|
-
if (seen.has(normalized))
|
|
44
|
-
return false;
|
|
45
|
-
seen.add(normalized);
|
|
46
|
-
return true;
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
/**
|
|
50
|
-
* Read one host config root's `agent/` and `agents/` trees. Host loaders use
|
|
51
|
-
* both spellings and recurse through nested markdown files. Directory symlinks
|
|
52
|
-
* are followed with realpath cycle detection, matching host behavior without
|
|
53
|
-
* allowing a symlink loop to hang context construction.
|
|
54
|
-
*/
|
|
55
|
-
async function scanAgentRoot(root, out) {
|
|
56
|
-
if (!(await exists(root)))
|
|
57
|
-
return;
|
|
58
|
-
const visitedDirs = new Set();
|
|
59
|
-
const scanDir = async (dir, relativePrefix) => {
|
|
60
|
-
let canonical;
|
|
61
|
-
try {
|
|
62
|
-
canonical = await realpath(dir);
|
|
63
|
-
}
|
|
64
|
-
catch {
|
|
65
|
-
return;
|
|
66
|
-
}
|
|
67
|
-
if (visitedDirs.has(canonical))
|
|
68
|
-
return;
|
|
69
|
-
visitedDirs.add(canonical);
|
|
70
|
-
let entries;
|
|
71
|
-
try {
|
|
72
|
-
entries = await readdir(dir, { withFileTypes: true });
|
|
73
|
-
entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
74
|
-
}
|
|
75
|
-
catch {
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
for (const entry of entries) {
|
|
79
|
-
const full = path.join(dir, entry.name);
|
|
80
|
-
const relative = path.join(relativePrefix, entry.name);
|
|
81
|
-
let isDirectory = entry.isDirectory();
|
|
82
|
-
let isFile = entry.isFile();
|
|
83
|
-
if (entry.isSymbolicLink()) {
|
|
84
|
-
try {
|
|
85
|
-
const target = await stat(full);
|
|
86
|
-
isDirectory = target.isDirectory();
|
|
87
|
-
isFile = target.isFile();
|
|
88
|
-
}
|
|
89
|
-
catch {
|
|
90
|
-
continue;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
if (isDirectory) {
|
|
94
|
-
await scanDir(full, relative);
|
|
95
|
-
continue;
|
|
96
|
-
}
|
|
97
|
-
if (!isFile || !entry.name.endsWith(".md"))
|
|
98
|
-
continue;
|
|
99
|
-
try {
|
|
100
|
-
const raw = await readFile(full, "utf-8");
|
|
101
|
-
const fallbackName = relative.replace(/\\/g, "/").replace(/\.md$/, "");
|
|
102
|
-
const parsed = parseAgentMarkdown(raw, fallbackName);
|
|
103
|
-
if (!out.has(parsed.name)) {
|
|
104
|
-
out.set(parsed.name, {
|
|
105
|
-
fullPath: path.resolve(full),
|
|
106
|
-
name: parsed.name,
|
|
107
|
-
description: parsed.description,
|
|
108
|
-
prompt: parsed.prompt,
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
catch {
|
|
113
|
-
/* skip unreadable or malformed files without failing context build */
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
};
|
|
117
|
-
// Scan singular before plural, then let root ordering establish host
|
|
118
|
-
// precedence. This is deterministic and mirrors the hosts' brace glob.
|
|
119
|
-
await scanDir(path.join(root, "agent"), "agent");
|
|
120
|
-
await scanDir(path.join(root, "agents"), "agents");
|
|
121
|
-
}
|
|
122
|
-
/**
|
|
123
|
-
* Custom agents from the active OpenCode-compatible config roots. A host bridge may
|
|
124
|
-
* install a host-neutral bridge before loading this module; without one this
|
|
125
|
-
* remains the direct OpenCode discovery path.
|
|
126
|
-
*/
|
|
127
|
-
export async function collectAgents(workspaceRoot) {
|
|
128
|
-
const out = new Map();
|
|
129
|
-
const roots = uniquePaths([
|
|
130
|
-
...opencodeProjectConfigDirs(workspaceRoot),
|
|
131
|
-
...opencodeGlobalConfigDirs(),
|
|
132
|
-
]);
|
|
133
|
-
for (const root of roots)
|
|
134
|
-
await scanAgentRoot(root, out);
|
|
135
|
-
return [...out.values()];
|
|
136
|
-
}
|
package/dist/context/skills.d.ts
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
export type CollectedSkill = {
|
|
2
|
-
fullPath: string;
|
|
3
|
-
name: string;
|
|
4
|
-
description: string;
|
|
5
|
-
content: string;
|
|
6
|
-
};
|
|
7
|
-
/**
|
|
8
|
-
* Discover skills the way OpenCode does: `.opencode/skills`, global opencode,
|
|
9
|
-
* plus `.claude/skills` and `.agents/skills` (project + home).
|
|
10
|
-
*/
|
|
11
|
-
export declare function collectSkills(workspaceRoot: string, worktree: string): Promise<CollectedSkill[]>;
|
package/dist/context/skills.js
DELETED
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { homedir } from "node:os";
|
|
4
|
-
import { opencodeGlobalConfigDirs, opencodeProjectConfigDirs } from "./paths.js";
|
|
5
|
-
async function exists(p) {
|
|
6
|
-
try {
|
|
7
|
-
await stat(p);
|
|
8
|
-
return true;
|
|
9
|
-
}
|
|
10
|
-
catch {
|
|
11
|
-
return false;
|
|
12
|
-
}
|
|
13
|
-
}
|
|
14
|
-
function parseFrontmatter(raw) {
|
|
15
|
-
if (!raw.startsWith("---"))
|
|
16
|
-
return { body: raw };
|
|
17
|
-
const end = raw.indexOf("\n---", 3);
|
|
18
|
-
if (end < 0)
|
|
19
|
-
return { body: raw };
|
|
20
|
-
const fm = raw.slice(3, end).trim();
|
|
21
|
-
const body = raw.slice(end + 4).replace(/^\n/, "");
|
|
22
|
-
let name;
|
|
23
|
-
let description;
|
|
24
|
-
for (const line of fm.split("\n")) {
|
|
25
|
-
const m = line.match(/^(\w+):\s*(.*)$/);
|
|
26
|
-
if (!m)
|
|
27
|
-
continue;
|
|
28
|
-
const key = m[1];
|
|
29
|
-
let val = m[2].trim();
|
|
30
|
-
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
31
|
-
val = val.slice(1, -1);
|
|
32
|
-
}
|
|
33
|
-
if (key === "name")
|
|
34
|
-
name = val;
|
|
35
|
-
if (key === "description")
|
|
36
|
-
description = val;
|
|
37
|
-
}
|
|
38
|
-
return { name, description, body };
|
|
39
|
-
}
|
|
40
|
-
async function loadSkillFile(file) {
|
|
41
|
-
try {
|
|
42
|
-
const raw = await readFile(file, "utf-8");
|
|
43
|
-
const { name, description, body } = parseFrontmatter(raw);
|
|
44
|
-
const dirName = path.basename(path.dirname(file));
|
|
45
|
-
return {
|
|
46
|
-
fullPath: path.resolve(file),
|
|
47
|
-
name: name || dirName,
|
|
48
|
-
description: description || "",
|
|
49
|
-
content: body.slice(0, 20_000),
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
catch {
|
|
53
|
-
return undefined;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
async function scanSkillsRoot(root, out) {
|
|
57
|
-
if (!(await exists(root)))
|
|
58
|
-
return;
|
|
59
|
-
let entries;
|
|
60
|
-
try {
|
|
61
|
-
entries = await readdir(root);
|
|
62
|
-
}
|
|
63
|
-
catch {
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
entries.sort();
|
|
67
|
-
for (const name of entries) {
|
|
68
|
-
const skillMd = path.join(root, name, "SKILL.md");
|
|
69
|
-
if (!(await exists(skillMd)))
|
|
70
|
-
continue;
|
|
71
|
-
const skill = await loadSkillFile(skillMd);
|
|
72
|
-
if (!skill)
|
|
73
|
-
continue;
|
|
74
|
-
if (!out.has(skill.name))
|
|
75
|
-
out.set(skill.name, skill);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
async function walkAncestorsFor(dir, rel, stop, out) {
|
|
79
|
-
let cur = path.resolve(dir);
|
|
80
|
-
const root = path.resolve(stop);
|
|
81
|
-
for (;;) {
|
|
82
|
-
await scanSkillsRoot(path.join(cur, rel), out);
|
|
83
|
-
if (cur === root)
|
|
84
|
-
break;
|
|
85
|
-
const parent = path.dirname(cur);
|
|
86
|
-
if (parent === cur)
|
|
87
|
-
break;
|
|
88
|
-
cur = parent;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
/**
|
|
92
|
-
* Discover skills the way OpenCode does: `.opencode/skills`, global opencode,
|
|
93
|
-
* plus `.claude/skills` and `.agents/skills` (project + home).
|
|
94
|
-
*/
|
|
95
|
-
export async function collectSkills(workspaceRoot, worktree) {
|
|
96
|
-
const out = new Map();
|
|
97
|
-
const home = homedir();
|
|
98
|
-
for (const projectDir of opencodeProjectConfigDirs(workspaceRoot)) {
|
|
99
|
-
const relative = path.relative(workspaceRoot, projectDir);
|
|
100
|
-
await walkAncestorsFor(workspaceRoot, path.join(relative, "skills"), worktree, out);
|
|
101
|
-
}
|
|
102
|
-
for (const globalDir of opencodeGlobalConfigDirs()) {
|
|
103
|
-
await scanSkillsRoot(path.join(globalDir, "skills"), out);
|
|
104
|
-
}
|
|
105
|
-
await walkAncestorsFor(workspaceRoot, path.join(".claude", "skills"), worktree, out);
|
|
106
|
-
await scanSkillsRoot(path.join(home, ".claude", "skills"), out);
|
|
107
|
-
await walkAncestorsFor(workspaceRoot, path.join(".agents", "skills"), worktree, out);
|
|
108
|
-
await scanSkillsRoot(path.join(home, ".agents", "skills"), out);
|
|
109
|
-
return [...out.values()];
|
|
110
|
-
}
|