@evanovation/open-cursor 2.4.15
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/LICENSE +28 -0
- package/README.md +270 -0
- package/dist/cli/discover.js +527 -0
- package/dist/cli/mcptool.js +10339 -0
- package/dist/cli/opencode-cursor.js +2989 -0
- package/dist/index.js +20588 -0
- package/dist/plugin-entry.js +19848 -0
- package/package.json +82 -0
- package/scripts/cursor-agent-runner.mjs +272 -0
- package/scripts/sdk-runner.mjs +412 -0
- package/src/acp/metrics.ts +83 -0
- package/src/acp/sessions.ts +107 -0
- package/src/acp/tools.ts +209 -0
- package/src/auth.ts +175 -0
- package/src/cli/discover.ts +53 -0
- package/src/cli/mcptool.ts +133 -0
- package/src/cli/model-discovery.ts +71 -0
- package/src/cli/opencode-cursor.ts +1195 -0
- package/src/client/cursor-agent-child.ts +459 -0
- package/src/client/sdk-child.ts +550 -0
- package/src/client/simple.ts +293 -0
- package/src/commands/status.ts +39 -0
- package/src/index.ts +39 -0
- package/src/mcp/client-manager.ts +166 -0
- package/src/mcp/config.ts +169 -0
- package/src/mcp/tool-bridge.ts +133 -0
- package/src/models/config.ts +64 -0
- package/src/models/discovery.ts +105 -0
- package/src/models/index.ts +3 -0
- package/src/models/pricing.ts +196 -0
- package/src/models/sync.ts +247 -0
- package/src/models/types.ts +11 -0
- package/src/models/variants.ts +446 -0
- package/src/plugin-entry.ts +28 -0
- package/src/plugin-toggle.ts +81 -0
- package/src/plugin.ts +2802 -0
- package/src/provider/backend.ts +71 -0
- package/src/provider/boundary.ts +168 -0
- package/src/provider/passthrough-tracker.ts +38 -0
- package/src/provider/runtime-interception.ts +818 -0
- package/src/provider/tool-loop-guard.ts +644 -0
- package/src/provider/tool-schema-compat.ts +800 -0
- package/src/provider.ts +268 -0
- package/src/proxy/formatter.ts +60 -0
- package/src/proxy/handler.ts +29 -0
- package/src/proxy/incremental-prompt.ts +74 -0
- package/src/proxy/prompt-builder.ts +204 -0
- package/src/proxy/server.ts +207 -0
- package/src/proxy/session-resume.ts +312 -0
- package/src/proxy/tool-loop.ts +359 -0
- package/src/proxy/types.ts +13 -0
- package/src/services/toast-service.ts +81 -0
- package/src/streaming/ai-sdk-parts.ts +109 -0
- package/src/streaming/delta-tracker.ts +89 -0
- package/src/streaming/line-buffer.ts +44 -0
- package/src/streaming/openai-sse.ts +118 -0
- package/src/streaming/parser.ts +22 -0
- package/src/streaming/types.ts +158 -0
- package/src/tools/core/executor.ts +25 -0
- package/src/tools/core/registry.ts +27 -0
- package/src/tools/core/types.ts +31 -0
- package/src/tools/defaults.ts +954 -0
- package/src/tools/discovery.ts +140 -0
- package/src/tools/executors/cli.ts +59 -0
- package/src/tools/executors/local.ts +25 -0
- package/src/tools/executors/mcp.ts +39 -0
- package/src/tools/executors/sdk.ts +39 -0
- package/src/tools/index.ts +8 -0
- package/src/tools/registry.ts +34 -0
- package/src/tools/router.ts +123 -0
- package/src/tools/schema.ts +58 -0
- package/src/tools/skills/loader.ts +61 -0
- package/src/tools/skills/resolver.ts +21 -0
- package/src/tools/types.ts +29 -0
- package/src/types.ts +8 -0
- package/src/usage.ts +112 -0
- package/src/utils/binary.ts +71 -0
- package/src/utils/errors.ts +224 -0
- package/src/utils/logger.ts +191 -0
- package/src/utils/perf.ts +76 -0
|
@@ -0,0 +1,800 @@
|
|
|
1
|
+
import type { OpenAiToolCall } from "../proxy/tool-loop.js";
|
|
2
|
+
|
|
3
|
+
type JsonRecord = Record<string, unknown>;
|
|
4
|
+
|
|
5
|
+
const EDIT_COMPAT_REPAIR_ENABLED = process.env.CURSOR_ACP_EDIT_COMPAT_REPAIR !== "false";
|
|
6
|
+
const QUESTION_COMPAT_REPAIR_ENABLED = process.env.CURSOR_ACP_QUESTION_COMPAT_REPAIR !== "false";
|
|
7
|
+
|
|
8
|
+
// OpenCode's `question` tool caps option labels and the per-question header at
|
|
9
|
+
// 30 characters (see opencode question schema). Cursor-style AskQuestion
|
|
10
|
+
// payloads routinely exceed this, so we truncate when remapping.
|
|
11
|
+
const QUESTION_LABEL_MAX = 30;
|
|
12
|
+
|
|
13
|
+
const ARG_KEY_ALIASES = new Map<string, string>([
|
|
14
|
+
["filepath", "path"],
|
|
15
|
+
["filename", "path"],
|
|
16
|
+
["file", "path"],
|
|
17
|
+
["targetpath", "path"],
|
|
18
|
+
["directorypath", "path"],
|
|
19
|
+
["dir", "path"],
|
|
20
|
+
["folder", "path"],
|
|
21
|
+
["directory", "path"],
|
|
22
|
+
["targetdirectory", "path"],
|
|
23
|
+
["targetfile", "path"],
|
|
24
|
+
["globpattern", "pattern"],
|
|
25
|
+
["filepattern", "pattern"],
|
|
26
|
+
["searchpattern", "pattern"],
|
|
27
|
+
["includepattern", "include"],
|
|
28
|
+
["workingdirectory", "cwd"],
|
|
29
|
+
["workdir", "cwd"],
|
|
30
|
+
["currentdirectory", "cwd"],
|
|
31
|
+
["cmd", "command"],
|
|
32
|
+
["script", "command"],
|
|
33
|
+
["shellcommand", "command"],
|
|
34
|
+
["terminalcommand", "command"],
|
|
35
|
+
["contents", "content"],
|
|
36
|
+
["text", "content"],
|
|
37
|
+
["body", "content"],
|
|
38
|
+
["data", "content"],
|
|
39
|
+
["payload", "content"],
|
|
40
|
+
["streamcontent", "content"],
|
|
41
|
+
["recursive", "force"],
|
|
42
|
+
["oldstring", "old_string"],
|
|
43
|
+
["newstring", "new_string"],
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
export interface ToolSchemaValidationResult {
|
|
47
|
+
hasSchema: boolean;
|
|
48
|
+
ok: boolean;
|
|
49
|
+
missing: string[];
|
|
50
|
+
unexpected: string[];
|
|
51
|
+
typeErrors: string[];
|
|
52
|
+
repairHint?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ToolSchemaCompatResult {
|
|
56
|
+
toolCall: OpenAiToolCall;
|
|
57
|
+
normalizedArgs: JsonRecord;
|
|
58
|
+
originalArgs: JsonRecord;
|
|
59
|
+
originalArgKeys: string[];
|
|
60
|
+
normalizedArgKeys: string[];
|
|
61
|
+
collisionKeys: string[];
|
|
62
|
+
validation: ToolSchemaValidationResult;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function buildToolSchemaMap(tools: Array<unknown>): Map<string, unknown> {
|
|
66
|
+
const schemas = new Map<string, unknown>();
|
|
67
|
+
for (const rawTool of tools) {
|
|
68
|
+
const tool = isRecord(rawTool) ? rawTool : null;
|
|
69
|
+
if (!tool) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const fn = isRecord(tool.function) ? tool.function : tool;
|
|
73
|
+
const name = typeof fn.name === "string" ? fn.name.trim() : "";
|
|
74
|
+
if (!name) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (fn.parameters !== undefined) {
|
|
78
|
+
schemas.set(name, fn.parameters);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return schemas;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function applyToolSchemaCompat(
|
|
85
|
+
toolCall: OpenAiToolCall,
|
|
86
|
+
toolSchemaMap: Map<string, unknown>,
|
|
87
|
+
): ToolSchemaCompatResult {
|
|
88
|
+
const parsedArgs = parseArguments(toolCall.function.arguments);
|
|
89
|
+
const originalArgKeys = Object.keys(parsedArgs);
|
|
90
|
+
const { normalizedArgs, collisionKeys } = normalizeArgumentKeys(parsedArgs);
|
|
91
|
+
const toolSpecificArgs = normalizeToolSpecificArgs(toolCall.function.name, normalizedArgs);
|
|
92
|
+
const schema = toolSchemaMap.get(toolCall.function.name);
|
|
93
|
+
const sanitization = sanitizeArgumentsForSchema(toolSpecificArgs, schema);
|
|
94
|
+
const validation = validateToolArguments(
|
|
95
|
+
toolCall.function.name,
|
|
96
|
+
sanitization.args,
|
|
97
|
+
schema,
|
|
98
|
+
sanitization.unexpected,
|
|
99
|
+
{
|
|
100
|
+
originalArgs: parsedArgs,
|
|
101
|
+
writeSchema: toolSchemaMap.get("write"),
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const normalizedToolCall: OpenAiToolCall = {
|
|
106
|
+
...toolCall,
|
|
107
|
+
function: {
|
|
108
|
+
...toolCall.function,
|
|
109
|
+
arguments: JSON.stringify(sanitization.args),
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
toolCall: normalizedToolCall,
|
|
115
|
+
normalizedArgs: sanitization.args,
|
|
116
|
+
originalArgs: parsedArgs,
|
|
117
|
+
originalArgKeys,
|
|
118
|
+
normalizedArgKeys: Object.keys(sanitization.args),
|
|
119
|
+
collisionKeys,
|
|
120
|
+
validation,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function isFullFileShapedEditValidationFailure(
|
|
125
|
+
toolName: string,
|
|
126
|
+
args: JsonRecord,
|
|
127
|
+
validation: ToolSchemaValidationResult,
|
|
128
|
+
originalArgs: JsonRecord,
|
|
129
|
+
writeSchema?: unknown,
|
|
130
|
+
): boolean {
|
|
131
|
+
if (toolName.toLowerCase() !== "edit" || validation.ok) {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
return buildEditFullFileHint(args, validation.missing, validation.typeErrors, {
|
|
135
|
+
originalArgs,
|
|
136
|
+
writeSchema,
|
|
137
|
+
}) !== null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function buildWriteArguments(
|
|
141
|
+
filePath: string,
|
|
142
|
+
content: string,
|
|
143
|
+
writeSchema: unknown,
|
|
144
|
+
): JsonRecord {
|
|
145
|
+
if (!isRecord(writeSchema)) {
|
|
146
|
+
return { path: filePath, content };
|
|
147
|
+
}
|
|
148
|
+
const required = Array.isArray(writeSchema.required)
|
|
149
|
+
? writeSchema.required.filter((value): value is string => typeof value === "string")
|
|
150
|
+
: [];
|
|
151
|
+
if (required.includes("filePath")) {
|
|
152
|
+
return { filePath, content };
|
|
153
|
+
}
|
|
154
|
+
return { path: filePath, content };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Malformed full-file edit (path + body, no old_string) → write tool call when write is available. */
|
|
158
|
+
export function tryRerouteEditToWrite(
|
|
159
|
+
toolCall: OpenAiToolCall,
|
|
160
|
+
compat: ToolSchemaCompatResult,
|
|
161
|
+
allowedToolNames: Set<string>,
|
|
162
|
+
toolSchemaMap: Map<string, unknown>,
|
|
163
|
+
): OpenAiToolCall | null {
|
|
164
|
+
if (toolCall.function.name.toLowerCase() !== "edit") {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
if (!allowedToolNames.has("write") || !toolSchemaMap.has("write")) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const writeSchema = toolSchemaMap.get("write");
|
|
172
|
+
if (
|
|
173
|
+
!isFullFileShapedEditValidationFailure(
|
|
174
|
+
toolCall.function.name,
|
|
175
|
+
compat.normalizedArgs,
|
|
176
|
+
compat.validation,
|
|
177
|
+
compat.originalArgs,
|
|
178
|
+
writeSchema,
|
|
179
|
+
)
|
|
180
|
+
) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const filePath = typeof compat.normalizedArgs.path === "string" && compat.normalizedArgs.path.length > 0
|
|
185
|
+
? compat.normalizedArgs.path
|
|
186
|
+
: typeof compat.normalizedArgs.filePath === "string" && compat.normalizedArgs.filePath.length > 0
|
|
187
|
+
? compat.normalizedArgs.filePath
|
|
188
|
+
: null;
|
|
189
|
+
if (!filePath) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const content =
|
|
194
|
+
typeof compat.normalizedArgs.new_string === "string"
|
|
195
|
+
? compat.normalizedArgs.new_string
|
|
196
|
+
: typeof compat.normalizedArgs.newString === "string"
|
|
197
|
+
? compat.normalizedArgs.newString
|
|
198
|
+
: typeof compat.normalizedArgs.content === "string"
|
|
199
|
+
? compat.normalizedArgs.content
|
|
200
|
+
: null;
|
|
201
|
+
if (content === null) {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
...toolCall,
|
|
207
|
+
function: {
|
|
208
|
+
name: "write",
|
|
209
|
+
arguments: JSON.stringify(buildWriteArguments(filePath, content, writeSchema)),
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function parseArguments(rawArguments: string): JsonRecord {
|
|
215
|
+
try {
|
|
216
|
+
const parsed = JSON.parse(rawArguments);
|
|
217
|
+
if (isRecord(parsed)) {
|
|
218
|
+
return parsed;
|
|
219
|
+
}
|
|
220
|
+
return { value: parsed };
|
|
221
|
+
} catch {
|
|
222
|
+
return { value: rawArguments };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function normalizeArgumentKeys(args: JsonRecord): {
|
|
227
|
+
normalizedArgs: JsonRecord;
|
|
228
|
+
collisionKeys: string[];
|
|
229
|
+
} {
|
|
230
|
+
const normalizedArgs: JsonRecord = { ...args };
|
|
231
|
+
const collisionKeys: string[] = [];
|
|
232
|
+
|
|
233
|
+
for (const [rawKey, rawValue] of Object.entries(args)) {
|
|
234
|
+
const canonicalKey = resolveCanonicalArgKey(rawKey);
|
|
235
|
+
if (!canonicalKey || canonicalKey === rawKey) {
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const canonicalInOriginal = hasOwn(args, canonicalKey);
|
|
240
|
+
const canonicalInNormalized = hasOwn(normalizedArgs, canonicalKey);
|
|
241
|
+
if (canonicalInOriginal || canonicalInNormalized) {
|
|
242
|
+
collisionKeys.push(rawKey);
|
|
243
|
+
delete normalizedArgs[rawKey];
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
normalizedArgs[canonicalKey] = rawValue;
|
|
248
|
+
delete normalizedArgs[rawKey];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return { normalizedArgs, collisionKeys };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function resolveCanonicalArgKey(rawKey: string): string | null {
|
|
255
|
+
const token = rawKey.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
256
|
+
return ARG_KEY_ALIASES.get(token) ?? null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function normalizeToolSpecificArgs(toolName: string, args: JsonRecord): JsonRecord {
|
|
260
|
+
const normalizedToolName = toolName.toLowerCase();
|
|
261
|
+
if (normalizedToolName === "question" && QUESTION_COMPAT_REPAIR_ENABLED) {
|
|
262
|
+
return normalizeQuestionArgs(args);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (normalizedToolName === "bash") {
|
|
266
|
+
const normalized: JsonRecord = { ...args };
|
|
267
|
+
const normalizedCommand = normalizeBashCommand(normalized.command);
|
|
268
|
+
if (typeof normalizedCommand === "string" && normalizedCommand.trim().length > 0) {
|
|
269
|
+
normalized.command = normalizedCommand;
|
|
270
|
+
}
|
|
271
|
+
if (
|
|
272
|
+
normalized.cwd === undefined
|
|
273
|
+
&& typeof normalized.path === "string"
|
|
274
|
+
&& normalized.path.trim().length > 0
|
|
275
|
+
) {
|
|
276
|
+
normalized.cwd = normalized.path;
|
|
277
|
+
}
|
|
278
|
+
return normalized;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (normalizedToolName === "rm") {
|
|
282
|
+
const normalized: JsonRecord = { ...args };
|
|
283
|
+
if (typeof normalized.force === "string") {
|
|
284
|
+
const lowered = normalized.force.trim().toLowerCase();
|
|
285
|
+
if (lowered === "true" || lowered === "1" || lowered === "yes") {
|
|
286
|
+
normalized.force = true;
|
|
287
|
+
} else if (lowered === "false" || lowered === "0" || lowered === "no") {
|
|
288
|
+
normalized.force = false;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return normalized;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (normalizedToolName === "todowrite") {
|
|
295
|
+
if (!Array.isArray(args.todos)) {
|
|
296
|
+
return args;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const todos = args.todos.map((entry) => {
|
|
300
|
+
if (!isRecord(entry)) {
|
|
301
|
+
return entry;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const todo: JsonRecord = { ...entry };
|
|
305
|
+
if (typeof todo.status === "string") {
|
|
306
|
+
todo.status = normalizeTodoStatus(todo.status);
|
|
307
|
+
}
|
|
308
|
+
if (
|
|
309
|
+
todo.priority === undefined
|
|
310
|
+
|| todo.priority === null
|
|
311
|
+
|| (typeof todo.priority === "string" && todo.priority.trim().length === 0)
|
|
312
|
+
) {
|
|
313
|
+
todo.priority = "medium";
|
|
314
|
+
}
|
|
315
|
+
return todo;
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
...args,
|
|
320
|
+
todos,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (normalizedToolName === "write") {
|
|
325
|
+
const normalized: JsonRecord = { ...args };
|
|
326
|
+
|
|
327
|
+
// Some model variants confuse write/edit and send edit-style payload keys.
|
|
328
|
+
// Map them into canonical write arguments before schema validation/sanitization.
|
|
329
|
+
if (normalized.content === undefined && normalized.new_string !== undefined) {
|
|
330
|
+
const coerced = coerceToString(normalized.new_string);
|
|
331
|
+
if (coerced !== null) {
|
|
332
|
+
normalized.content = coerced;
|
|
333
|
+
}
|
|
334
|
+
delete normalized.new_string;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (normalized.content !== undefined && typeof normalized.content !== "string") {
|
|
338
|
+
const coerced = coerceToString(normalized.content);
|
|
339
|
+
if (coerced !== null) {
|
|
340
|
+
normalized.content = coerced;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return normalized;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (normalizedToolName !== "edit" || !EDIT_COMPAT_REPAIR_ENABLED) {
|
|
348
|
+
return args;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const repaired: JsonRecord = { ...args };
|
|
352
|
+
const hasStringNew = typeof repaired.new_string === "string";
|
|
353
|
+
const hasStringOld = typeof repaired.old_string === "string";
|
|
354
|
+
|
|
355
|
+
// Coerce non-string content/streamContent into a string before repair.
|
|
356
|
+
// Models frequently emit array-of-chunks (streamContent) or object payloads.
|
|
357
|
+
if (repaired.content !== undefined && typeof repaired.content !== "string") {
|
|
358
|
+
const coerced = coerceToString(repaired.content);
|
|
359
|
+
if (coerced !== null) {
|
|
360
|
+
repaired.content = coerced;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const content = repaired.content;
|
|
365
|
+
|
|
366
|
+
// Guarded compatibility repair for models that send full-content edit payloads.
|
|
367
|
+
if (!hasStringNew && typeof content === "string") {
|
|
368
|
+
repaired.new_string = content;
|
|
369
|
+
}
|
|
370
|
+
if (hasStringOld && repaired.old_string === "") {
|
|
371
|
+
delete repaired.old_string;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return repaired;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Maps a Cursor-style `AskQuestion` payload into OpenCode's `question` schema.
|
|
379
|
+
*
|
|
380
|
+
* Cursor emits: { title?, questions: [{ id?, prompt, options: [{ id?, label }], allow_multiple? }] }
|
|
381
|
+
* OpenCode wants: { questions: [{ question, header(<=30), options: [{ label(<=30), description }], multiple?, custom? }] }
|
|
382
|
+
*
|
|
383
|
+
* The transform is idempotent: payloads already shaped for OpenCode pass through
|
|
384
|
+
* unchanged apart from defensive truncation of over-long labels/headers.
|
|
385
|
+
*/
|
|
386
|
+
function normalizeQuestionArgs(args: JsonRecord): JsonRecord {
|
|
387
|
+
if (!Array.isArray(args.questions)) {
|
|
388
|
+
return args;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const topTitle = typeof args.title === "string" ? args.title : undefined;
|
|
392
|
+
|
|
393
|
+
const questions = args.questions.map((rawQuestion) => {
|
|
394
|
+
if (!isRecord(rawQuestion)) {
|
|
395
|
+
return rawQuestion;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const question: JsonRecord = { ...rawQuestion };
|
|
399
|
+
|
|
400
|
+
// prompt/text -> question
|
|
401
|
+
if (typeof question.question !== "string") {
|
|
402
|
+
if (typeof question.prompt === "string") {
|
|
403
|
+
question.question = question.prompt;
|
|
404
|
+
} else if (typeof question.text === "string") {
|
|
405
|
+
question.question = question.text;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
delete question.prompt;
|
|
409
|
+
delete question.text;
|
|
410
|
+
|
|
411
|
+
// header (<=30 chars). Prefer an explicit header, then the top-level title,
|
|
412
|
+
// then a truncation of the question itself so the field is never empty.
|
|
413
|
+
const headerSource =
|
|
414
|
+
typeof question.header === "string" && question.header.trim().length > 0
|
|
415
|
+
? question.header
|
|
416
|
+
: topTitle ?? (typeof question.question === "string" ? question.question : "");
|
|
417
|
+
question.header = truncate(headerSource, QUESTION_LABEL_MAX);
|
|
418
|
+
|
|
419
|
+
// allow_multiple -> multiple
|
|
420
|
+
if (question.multiple === undefined && typeof question.allow_multiple === "boolean") {
|
|
421
|
+
question.multiple = question.allow_multiple;
|
|
422
|
+
}
|
|
423
|
+
delete question.allow_multiple;
|
|
424
|
+
|
|
425
|
+
if (Array.isArray(question.options)) {
|
|
426
|
+
question.options = question.options.map((rawOption) => {
|
|
427
|
+
if (typeof rawOption === "string") {
|
|
428
|
+
return { label: truncate(rawOption, QUESTION_LABEL_MAX), description: rawOption };
|
|
429
|
+
}
|
|
430
|
+
if (!isRecord(rawOption)) {
|
|
431
|
+
return rawOption;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const option: JsonRecord = { ...rawOption };
|
|
435
|
+
const fullLabel =
|
|
436
|
+
typeof option.label === "string"
|
|
437
|
+
? option.label
|
|
438
|
+
: typeof option.title === "string"
|
|
439
|
+
? option.title
|
|
440
|
+
: typeof option.text === "string"
|
|
441
|
+
? option.text
|
|
442
|
+
: "";
|
|
443
|
+
|
|
444
|
+
// OpenCode requires a non-empty description; fall back to the full label.
|
|
445
|
+
if (typeof option.description !== "string" || option.description.trim().length === 0) {
|
|
446
|
+
option.description = fullLabel;
|
|
447
|
+
}
|
|
448
|
+
const labelSource = fullLabel.length > 0 ? fullLabel : String(option.description ?? "");
|
|
449
|
+
option.label = truncate(labelSource, QUESTION_LABEL_MAX);
|
|
450
|
+
|
|
451
|
+
// Cursor-only fields OpenCode does not understand.
|
|
452
|
+
delete option.id;
|
|
453
|
+
delete option.title;
|
|
454
|
+
delete option.text;
|
|
455
|
+
return option;
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Cursor scopes answers by per-question id; OpenCode keys by index/label.
|
|
460
|
+
delete question.id;
|
|
461
|
+
return question;
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
// OpenCode's `question` schema has no top-level title field.
|
|
465
|
+
return { questions };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function truncate(value: unknown, max: number): string {
|
|
469
|
+
const text = typeof value === "string" ? value.trim() : "";
|
|
470
|
+
return text.length > max ? text.slice(0, max) : text;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function normalizeBashCommand(value: unknown): string | null {
|
|
474
|
+
if (typeof value === "string") {
|
|
475
|
+
return value;
|
|
476
|
+
}
|
|
477
|
+
if (Array.isArray(value)) {
|
|
478
|
+
const parts = value
|
|
479
|
+
.map((entry) => (typeof entry === "string" ? entry : coerceToString(entry)))
|
|
480
|
+
.filter((entry): entry is string => typeof entry === "string" && entry.length > 0);
|
|
481
|
+
return parts.length > 0 ? parts.join(" ") : null;
|
|
482
|
+
}
|
|
483
|
+
if (isRecord(value)) {
|
|
484
|
+
const command = typeof value.command === "string" ? value.command : null;
|
|
485
|
+
const args = Array.isArray(value.args)
|
|
486
|
+
? value.args
|
|
487
|
+
.map((entry) => (typeof entry === "string" ? entry : coerceToString(entry)))
|
|
488
|
+
.filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
|
|
489
|
+
: [];
|
|
490
|
+
if (command && args.length > 0) {
|
|
491
|
+
return [command, ...args].join(" ");
|
|
492
|
+
}
|
|
493
|
+
if (command) {
|
|
494
|
+
return command;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function normalizeTodoStatus(status: string): string {
|
|
501
|
+
const normalized = status.trim().toLowerCase().replace(/[\s-]+/g, "_");
|
|
502
|
+
if (normalized === "todo_status_pending") {
|
|
503
|
+
return "pending";
|
|
504
|
+
}
|
|
505
|
+
if (normalized === "todo_status_inprogress" || normalized === "todo_status_in_progress") {
|
|
506
|
+
return "in_progress";
|
|
507
|
+
}
|
|
508
|
+
if (
|
|
509
|
+
normalized === "todo_status_done"
|
|
510
|
+
|| normalized === "todo_status_complete"
|
|
511
|
+
|| normalized === "todo_status_completed"
|
|
512
|
+
) {
|
|
513
|
+
return "completed";
|
|
514
|
+
}
|
|
515
|
+
if (normalized === "todo" || normalized === "pending") {
|
|
516
|
+
return "pending";
|
|
517
|
+
}
|
|
518
|
+
if (normalized === "inprogress" || normalized === "in_progress") {
|
|
519
|
+
return "in_progress";
|
|
520
|
+
}
|
|
521
|
+
if (normalized === "done" || normalized === "complete" || normalized === "completed") {
|
|
522
|
+
return "completed";
|
|
523
|
+
}
|
|
524
|
+
return status;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function sanitizeArgumentsForSchema(
|
|
528
|
+
args: JsonRecord,
|
|
529
|
+
schema: unknown,
|
|
530
|
+
): { args: JsonRecord; unexpected: string[] } {
|
|
531
|
+
if (!isRecord(schema)) {
|
|
532
|
+
return { args, unexpected: [] };
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (schema.additionalProperties !== false) {
|
|
536
|
+
return { args, unexpected: [] };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const properties = isRecord(schema.properties) ? schema.properties : {};
|
|
540
|
+
const propertyNames = new Set(Object.keys(properties));
|
|
541
|
+
const sanitized: JsonRecord = {};
|
|
542
|
+
const unexpected: string[] = [];
|
|
543
|
+
|
|
544
|
+
for (const [key, value] of Object.entries(args)) {
|
|
545
|
+
if (propertyNames.has(key)) {
|
|
546
|
+
sanitized[key] = value;
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
unexpected.push(key);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return { args: sanitized, unexpected };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
type ValidateToolArgumentsContext = {
|
|
556
|
+
originalArgs?: JsonRecord;
|
|
557
|
+
writeSchema?: unknown;
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
function validateToolArguments(
|
|
561
|
+
toolName: string,
|
|
562
|
+
args: JsonRecord,
|
|
563
|
+
schema: unknown,
|
|
564
|
+
unexpected: string[],
|
|
565
|
+
context: ValidateToolArgumentsContext = {},
|
|
566
|
+
): ToolSchemaValidationResult {
|
|
567
|
+
if (!isRecord(schema)) {
|
|
568
|
+
return {
|
|
569
|
+
hasSchema: false,
|
|
570
|
+
ok: true,
|
|
571
|
+
missing: [],
|
|
572
|
+
unexpected: [],
|
|
573
|
+
typeErrors: [],
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const properties = isRecord(schema.properties) ? schema.properties : {};
|
|
578
|
+
const required = Array.isArray(schema.required)
|
|
579
|
+
? schema.required.filter((value): value is string => typeof value === "string")
|
|
580
|
+
: [];
|
|
581
|
+
const missing = required.filter((key) => !hasOwn(args, key));
|
|
582
|
+
|
|
583
|
+
const typeErrors: string[] = [];
|
|
584
|
+
for (const [key, value] of Object.entries(args)) {
|
|
585
|
+
const propertySchema = properties[key];
|
|
586
|
+
if (!isRecord(propertySchema)) {
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
if (!matchesType(value, propertySchema.type)) {
|
|
590
|
+
if (propertySchema.type !== undefined) {
|
|
591
|
+
typeErrors.push(`${key}: expected ${String(propertySchema.type)}`);
|
|
592
|
+
}
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
if (
|
|
596
|
+
Array.isArray(propertySchema.enum)
|
|
597
|
+
&& !propertySchema.enum.some((candidate) => Object.is(candidate, value))
|
|
598
|
+
) {
|
|
599
|
+
typeErrors.push(`${key}: expected enum ${JSON.stringify(propertySchema.enum)}`);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const ok = missing.length === 0 && typeErrors.length === 0;
|
|
604
|
+
return {
|
|
605
|
+
hasSchema: true,
|
|
606
|
+
ok,
|
|
607
|
+
missing,
|
|
608
|
+
unexpected,
|
|
609
|
+
typeErrors,
|
|
610
|
+
repairHint: ok
|
|
611
|
+
? undefined
|
|
612
|
+
: buildRepairHint(toolName, args, missing, unexpected, typeErrors, context),
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function hadOldStringPropertyInPayload(args: JsonRecord): boolean {
|
|
617
|
+
for (const key of Object.keys(args)) {
|
|
618
|
+
const token = key.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
619
|
+
if (token === "oldstring") {
|
|
620
|
+
return true;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
return false;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function hasEditFilePath(args: JsonRecord): boolean {
|
|
627
|
+
const pathValue = args.path ?? args.filePath;
|
|
628
|
+
return typeof pathValue === "string" && pathValue.trim().length > 0;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function hasEditBody(args: JsonRecord): boolean {
|
|
632
|
+
const body = args.new_string ?? args.newString ?? args.content;
|
|
633
|
+
return typeof body === "string" && body.length > 0;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function writeToolExample(writeSchema: unknown): string {
|
|
637
|
+
if (!isRecord(writeSchema)) {
|
|
638
|
+
return "write with path and content";
|
|
639
|
+
}
|
|
640
|
+
const required = Array.isArray(writeSchema.required)
|
|
641
|
+
? writeSchema.required.filter((value): value is string => typeof value === "string")
|
|
642
|
+
: [];
|
|
643
|
+
if (required.includes("filePath")) {
|
|
644
|
+
return "write with filePath and content";
|
|
645
|
+
}
|
|
646
|
+
return "write with path and content";
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function buildEditFullFileHint(
|
|
650
|
+
args: JsonRecord,
|
|
651
|
+
missing: string[],
|
|
652
|
+
typeErrors: string[],
|
|
653
|
+
context: ValidateToolArgumentsContext,
|
|
654
|
+
): string | null {
|
|
655
|
+
if (typeErrors.length > 0) {
|
|
656
|
+
return null;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const missingOldStringOnly =
|
|
660
|
+
(missing.includes("old_string") || missing.includes("oldString"))
|
|
661
|
+
&& missing.every((key) => key === "old_string" || key === "oldString");
|
|
662
|
+
if (!missingOldStringOnly) {
|
|
663
|
+
return null;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const originalArgs = context.originalArgs ?? {};
|
|
667
|
+
if (hadOldStringPropertyInPayload(originalArgs)) {
|
|
668
|
+
return null;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (!hasEditFilePath(args) || !hasEditBody(args)) {
|
|
672
|
+
return null;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const example = writeToolExample(context.writeSchema);
|
|
676
|
+
return `For a full file body, use ${example} instead of edit without old_string`;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function buildRepairHint(
|
|
680
|
+
toolName: string,
|
|
681
|
+
args: JsonRecord,
|
|
682
|
+
missing: string[],
|
|
683
|
+
unexpected: string[],
|
|
684
|
+
typeErrors: string[],
|
|
685
|
+
context: ValidateToolArgumentsContext = {},
|
|
686
|
+
): string {
|
|
687
|
+
const fullFileHint = toolName.toLowerCase() === "edit"
|
|
688
|
+
? buildEditFullFileHint(args, missing, typeErrors, context)
|
|
689
|
+
: null;
|
|
690
|
+
if (fullFileHint) {
|
|
691
|
+
return fullFileHint;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const hints: string[] = [];
|
|
695
|
+
if (missing.length > 0) {
|
|
696
|
+
hints.push(`missing required: ${missing.join(", ")}`);
|
|
697
|
+
}
|
|
698
|
+
if (unexpected.length > 0) {
|
|
699
|
+
hints.push(`remove unsupported fields: ${unexpected.join(", ")}`);
|
|
700
|
+
}
|
|
701
|
+
if (typeErrors.length > 0) {
|
|
702
|
+
hints.push(`fix type errors: ${typeErrors.join("; ")}`);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
if (
|
|
706
|
+
toolName.toLowerCase() === "edit"
|
|
707
|
+
&& (missing.includes("old_string") || missing.includes("oldString") || missing.includes("new_string") || missing.includes("newString"))
|
|
708
|
+
) {
|
|
709
|
+
hints.push("edit requires path, old_string, and new_string");
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
return hints.join(" | ");
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function matchesType(value: unknown, schemaType: unknown): boolean {
|
|
716
|
+
if (schemaType === undefined) {
|
|
717
|
+
return true;
|
|
718
|
+
}
|
|
719
|
+
if (Array.isArray(schemaType)) {
|
|
720
|
+
return schemaType.some((entry) => matchesType(value, entry));
|
|
721
|
+
}
|
|
722
|
+
if (typeof schemaType !== "string") {
|
|
723
|
+
return true;
|
|
724
|
+
}
|
|
725
|
+
switch (schemaType) {
|
|
726
|
+
case "string":
|
|
727
|
+
return typeof value === "string";
|
|
728
|
+
case "number":
|
|
729
|
+
return typeof value === "number";
|
|
730
|
+
case "integer":
|
|
731
|
+
return typeof value === "number" && Number.isInteger(value);
|
|
732
|
+
case "boolean":
|
|
733
|
+
return typeof value === "boolean";
|
|
734
|
+
case "object":
|
|
735
|
+
return isRecord(value);
|
|
736
|
+
case "array":
|
|
737
|
+
return Array.isArray(value);
|
|
738
|
+
case "null":
|
|
739
|
+
return value === null;
|
|
740
|
+
default:
|
|
741
|
+
return true;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function coerceToString(value: unknown): string | null {
|
|
746
|
+
if (typeof value === "string") {
|
|
747
|
+
return value;
|
|
748
|
+
}
|
|
749
|
+
if (value === null || value === undefined) {
|
|
750
|
+
return null;
|
|
751
|
+
}
|
|
752
|
+
if (Array.isArray(value)) {
|
|
753
|
+
const parts: string[] = [];
|
|
754
|
+
for (const item of value) {
|
|
755
|
+
if (typeof item === "string") {
|
|
756
|
+
parts.push(item);
|
|
757
|
+
} else if (isRecord(item)) {
|
|
758
|
+
const text = typeof item.text === "string"
|
|
759
|
+
? item.text
|
|
760
|
+
: typeof item.content === "string"
|
|
761
|
+
? item.content
|
|
762
|
+
: typeof item.value === "string"
|
|
763
|
+
? item.value
|
|
764
|
+
: null;
|
|
765
|
+
if (text !== null) {
|
|
766
|
+
parts.push(text);
|
|
767
|
+
} else {
|
|
768
|
+
parts.push(JSON.stringify(item));
|
|
769
|
+
}
|
|
770
|
+
} else {
|
|
771
|
+
parts.push(String(item));
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
return parts.length > 0 ? parts.join("") : null;
|
|
775
|
+
}
|
|
776
|
+
if (isRecord(value)) {
|
|
777
|
+
if (typeof value.text === "string") {
|
|
778
|
+
return value.text;
|
|
779
|
+
}
|
|
780
|
+
if (typeof value.content === "string") {
|
|
781
|
+
return value.content;
|
|
782
|
+
}
|
|
783
|
+
if (typeof value.value === "string") {
|
|
784
|
+
return value.value;
|
|
785
|
+
}
|
|
786
|
+
return JSON.stringify(value);
|
|
787
|
+
}
|
|
788
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
789
|
+
return String(value);
|
|
790
|
+
}
|
|
791
|
+
return null;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function hasOwn(record: JsonRecord, key: string): boolean {
|
|
795
|
+
return Object.prototype.hasOwnProperty.call(record, key);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function isRecord(value: unknown): value is JsonRecord {
|
|
799
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
800
|
+
}
|