@ask-llm/plugin 0.13.0 → 0.15.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.
@@ -4,11 +4,14 @@ import { StringEnum } from "@earendil-works/pi-ai";
4
4
  import { executeTool as executeAntigravityTool } from "@ask-llm/antigravity-mcp/register";
5
5
  import { executeTool as executeCodexTool } from "@ask-llm/codex-mcp/register";
6
6
  import { executeTool as executeGeminiTool } from "@ask-llm/gemini-mcp/register";
7
+ import { executeTool as executeGrokTool } from "@ask-llm/grok-mcp/register";
7
8
  import { executeTool as executeOllamaTool } from "@ask-llm/ollama-mcp/register";
9
+ import { CURSOR_PROVIDERS, executeCursorAgent } from "@ask-llm/mcp/cursor";
8
10
  import { Type } from "typebox";
9
11
 
10
- const providerNames = ["codex", "gemini", "ollama", "antigravity"] as const;
12
+ const providerNames = ["codex", "gemini", "grok", "ollama", "antigravity"] as const;
11
13
  type ProviderName = (typeof providerNames)[number];
14
+ const cursorProviderNames = CURSOR_PROVIDERS;
12
15
 
13
16
  type CanonicalResult =
14
17
  | string
@@ -24,6 +27,7 @@ type CanonicalExecute = (
24
27
  const executors: Record<ProviderName, { tool: string; execute: CanonicalExecute }> = {
25
28
  codex: { tool: "ask-codex", execute: executeCodexTool as CanonicalExecute },
26
29
  gemini: { tool: "ask-gemini", execute: executeGeminiTool as CanonicalExecute },
30
+ grok: { tool: "ask-grok", execute: executeGrokTool as CanonicalExecute },
27
31
  ollama: { tool: "ask-ollama", execute: executeOllamaTool as CanonicalExecute },
28
32
  antigravity: { tool: "ask-antigravity", execute: executeAntigravityTool as CanonicalExecute },
29
33
  };
@@ -48,6 +52,12 @@ const geminiSchema = Type.Object({
48
52
  model: Type.Optional(Type.String({ minLength: 1 })),
49
53
  sessionId: Type.Optional(Type.String()),
50
54
  });
55
+ const grokSchema = Type.Object({
56
+ prompt,
57
+ model: Type.Optional(Type.String({ minLength: 1 })),
58
+ harness: Type.Optional(StringEnum(["xai-api", "grok-cli"] as const)),
59
+ reasoningEffort: Type.Optional(StringEnum(["low", "medium", "high", "xhigh"] as const)),
60
+ });
51
61
  const ollamaSchema = Type.Object({
52
62
  prompt,
53
63
  model: Type.Optional(Type.String({ minLength: 1 })),
@@ -61,21 +71,40 @@ const antigravitySchema = Type.Object({
61
71
  const providerOptionSchemas = {
62
72
  codex: Type.Omit(codexSchema, ["prompt"]),
63
73
  gemini: Type.Omit(geminiSchema, ["prompt"]),
74
+ grok: Type.Omit(grokSchema, ["prompt"]),
64
75
  ollama: Type.Omit(ollamaSchema, ["prompt"]),
65
76
  antigravity: Type.Omit(antigravitySchema, ["prompt"]),
66
77
  };
67
78
 
79
+ const cursorAgentSchema = Type.Object({
80
+ prompt,
81
+ provider: StringEnum(cursorProviderNames, {
82
+ description:
83
+ "Canonical provider family of the Cursor model (claude, codex, gemini, grok); verified against the requested and CLI-reported model ID.",
84
+ }),
85
+ model: Type.String({
86
+ minLength: 1,
87
+ description:
88
+ "Exact ID from agent --list-models; echoed back as `model`, with the CLI display label in `reportedModel`. Auto and other noncanonical IDs are refused.",
89
+ }),
90
+ includeDirs: Type.Optional(relativeDirs),
91
+ sessionId: Type.Optional(
92
+ Type.String({ description: "Prior Cursor conversation ID to resume; omit first, then reuse the returned ID." }),
93
+ ),
94
+ });
95
+
68
96
  const askMultiSchema = Type.Object({
69
97
  prompt,
70
98
  providers: Type.Array(StringEnum(providerNames), {
71
99
  minItems: 2,
72
- maxItems: 4,
73
- description: "Two to four unique providers. Results preserve this input order.",
100
+ maxItems: 5,
101
+ description: "Two to five unique providers. Results preserve this input order.",
74
102
  }),
75
103
  options: Type.Optional(
76
104
  Type.Object({
77
105
  codex: Type.Optional(providerOptionSchemas.codex),
78
106
  gemini: Type.Optional(providerOptionSchemas.gemini),
107
+ grok: Type.Optional(providerOptionSchemas.grok),
79
108
  ollama: Type.Optional(providerOptionSchemas.ollama),
80
109
  antigravity: Type.Optional(providerOptionSchemas.antigravity),
81
110
  }),
@@ -128,7 +157,7 @@ type ProgressUpdate = {
128
157
  details: Record<string, unknown>;
129
158
  };
130
159
 
131
- function progressForwarder(onUpdate: ((result: ProgressUpdate) => void) | undefined, provider: ProviderName) {
160
+ function progressForwarder(onUpdate: ((result: ProgressUpdate) => void) | undefined, provider: string) {
132
161
  return onUpdate
133
162
  ? (text: string) => {
134
163
  const output = bounded(text);
@@ -178,6 +207,14 @@ export function registerProviderTools(pi: ExtensionAPI): void {
178
207
  parameters: geminiSchema,
179
208
  provider: "gemini",
180
209
  });
210
+ registerProviderTool(pi, {
211
+ name: "ask-grok",
212
+ label: "Ask Grok",
213
+ description:
214
+ "Consult Grok through Ask LLM's canonical xAI API executor. Requires XAI_API_KEY and may incur metered API charges; no billing changes or model fallback are performed. Output is bounded to Pi's 50KB/2000-line limits.",
215
+ parameters: grokSchema,
216
+ provider: "grok",
217
+ });
181
218
  registerProviderTool(pi, {
182
219
  name: "ask-ollama",
183
220
  label: "Ask Ollama",
@@ -195,11 +232,43 @@ export function registerProviderTools(pi: ExtensionAPI): void {
195
232
  provider: "antigravity",
196
233
  });
197
234
 
235
+ pi.registerTool({
236
+ name: "ask-cursor-agent",
237
+ label: "Ask via Cursor Agent",
238
+ description:
239
+ "Use Cursor Agent as a model-neutral read-only harness. Provider (claude, codex, gemini, grok) and exact model ID are separate and must agree; Auto or noncanonical catalog IDs are refused. Prompts above 16KB are piped over stdin. Requires an authenticated Cursor CLI and may consume included usage or on-demand spend; no spend settings or fallback are changed.",
240
+ parameters: cursorAgentSchema,
241
+ async execute(_toolCallId, params, signal, onUpdate) {
242
+ const result = await executeCursorAgent({
243
+ prompt: params.prompt,
244
+ provider: params.provider,
245
+ model: params.model,
246
+ includeDirs: params.includeDirs,
247
+ sessionId: params.sessionId,
248
+ signal,
249
+ onProgress: progressForwarder(onUpdate, params.provider),
250
+ });
251
+ const text = bounded(result.response);
252
+ return {
253
+ content: [{ type: "text", text: text.text }],
254
+ details: {
255
+ provider: result.provider,
256
+ harness: result.harness,
257
+ model: result.model,
258
+ reportedModel: result.reportedModel,
259
+ sessionId: result.sessionId,
260
+ askLlmUsage: result.usage,
261
+ outputTruncated: text.truncated,
262
+ },
263
+ };
264
+ },
265
+ });
266
+
198
267
  pi.registerTool({
199
268
  name: "ask-multi",
200
269
  label: "Ask Multiple Providers",
201
270
  description:
202
- "Send exactly the same prompt to two to four Ask LLM providers concurrently. Dispatch is deterministic and bounded; results preserve provider input order and report every failure instead of silently dropping it.",
271
+ "Send exactly the same prompt to two to five Ask LLM providers concurrently. Dispatch is deterministic and bounded; results preserve provider input order and report every failure instead of silently dropping it.",
203
272
  parameters: askMultiSchema,
204
273
  async execute(_toolCallId, params, signal, onUpdate) {
205
274
  const unique = [...new Set(params.providers)];
@@ -14,6 +14,23 @@ import { platform } from "node:process";
14
14
 
15
15
  export const IS_WINDOWS = platform === "win32";
16
16
 
17
+ export function quoteArgsForWindows(args) {
18
+ return args.map((arg) => {
19
+ if (arg.includes(" ") || arg.includes('"') || arg.includes("&") || arg.includes("|") || arg.includes("^")) {
20
+ return `"${arg.replace(/"/g, '\\"')}"`;
21
+ }
22
+ return arg;
23
+ });
24
+ }
25
+
26
+ export function prepareCommandInvocation(args, options, runtimePlatform = platform) {
27
+ const isWindows = runtimePlatform === "win32";
28
+ return {
29
+ args: isWindows ? quoteArgsForWindows(args) : args,
30
+ options: { ...options, shell: isWindows },
31
+ };
32
+ }
33
+
17
34
  export function terminateProcessTree(child, signal) {
18
35
  if (!child || typeof child.pid !== "number" || child.killed || child.exitCode !== null) {
19
36
  return;
@@ -0,0 +1,339 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn, spawnSync } from "node:child_process";
4
+ import { resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { prepareCommandInvocation } from "./lib/process.mjs";
7
+
8
+ export const ASK_CODEX_PACKAGE = "@ask-llm/codex-mcp";
9
+ export const ASK_CODEX_TOOL = "ask-codex";
10
+ export const SOL_MODEL = "gpt-5.6-sol";
11
+ export const TERRA_MODEL = "gpt-5.6-terra";
12
+
13
+ const scriptPath = fileURLToPath(import.meta.url);
14
+ const quotaSignals = [
15
+ "rate_limit_exceeded",
16
+ "quota_exceeded",
17
+ "429",
18
+ "insufficient_quota",
19
+ "out of credits",
20
+ "spend cap",
21
+ "usage limit",
22
+ ];
23
+
24
+ export function isAskCodexToolName(name) {
25
+ return name === ASK_CODEX_TOOL || /^mcp__.+__ask-codex$/.test(name);
26
+ }
27
+
28
+ export function isAskCodexRegistration(server) {
29
+ if (!server || typeof server !== "object") return false;
30
+ const command = typeof server.command === "string" ? server.command : "";
31
+ const args = Array.isArray(server.args) ? server.args.filter((arg) => typeof arg === "string") : [];
32
+ const commandLine = typeof server.commandLine === "string" ? server.commandLine : "";
33
+ return (
34
+ /(?:^|[/\\])ask-codex-mcp(?:\.cmd|\.exe)?$/.test(command) ||
35
+ args.includes(ASK_CODEX_PACKAGE) ||
36
+ /(?:^|\s)@ask-llm\/codex-mcp(?:@[^\s]+)?(?:\s|$)/.test(commandLine) ||
37
+ /(?:^|[/\\])ask-codex-mcp(?:\.cmd|\.exe)?(?:\s|$)/.test(commandLine)
38
+ );
39
+ }
40
+
41
+ export function parseClaudeMcpList(output) {
42
+ const servers = {};
43
+ for (const line of output.split(/\r?\n/)) {
44
+ const separator = line.indexOf(": ");
45
+ if (separator <= 0) continue;
46
+ const name = line.slice(0, separator).trim();
47
+ const details = line.slice(separator + 2).trim();
48
+ const statusSeparator = details.search(/ - (?=(?:✔|✘|!|⏸|cached\b|not configured\b|disabled\b))/i);
49
+ servers[name] = {
50
+ commandLine: statusSeparator === -1 ? details : details.slice(0, statusSeparator).trim(),
51
+ status: statusSeparator === -1 ? "" : details.slice(statusSeparator + 3).trim(),
52
+ };
53
+ }
54
+ return servers;
55
+ }
56
+
57
+ export function readActiveMcpServers({
58
+ command = process.env.CLAUDE_BIN || "claude",
59
+ execute = spawnSync,
60
+ platform = process.platform,
61
+ contextArgs = [],
62
+ } = {}) {
63
+ const invocation = prepareCommandInvocation(
64
+ [...contextArgs, "mcp", "list"],
65
+ {
66
+ cwd: process.cwd(),
67
+ encoding: "utf8",
68
+ windowsHide: true,
69
+ },
70
+ platform,
71
+ );
72
+ const result = execute(command, invocation.args, invocation.options);
73
+ if (result.error || result.status !== 0) {
74
+ const detail = result.error?.message || result.stderr?.trim() || `exited ${result.status}`;
75
+ throw new Error(`Unable to inspect active Claude MCP registrations: ${detail}.`);
76
+ }
77
+ return parseClaudeMcpList(result.stdout || "");
78
+ }
79
+
80
+ function expectedToolName(serverName) {
81
+ return `mcp__${serverName.replaceAll(":", "_")}__${ASK_CODEX_TOOL}`;
82
+ }
83
+
84
+ function isAvailableMcpServer(server) {
85
+ const status = typeof server?.status === "string" ? server.status.trim() : "";
86
+ return !status || /^✔\s*Connected\b/i.test(status) || /^cached\b.*\bconnects on first use\b/i.test(status);
87
+ }
88
+
89
+ export function classifySolReviewTransport({
90
+ availableTools = [],
91
+ mcpServers = {},
92
+ cliPath = "",
93
+ inventoryError = null,
94
+ mcpFailed = false,
95
+ }) {
96
+ if (inventoryError) {
97
+ const reason = `Ask LLM Codex MCP availability could not be determined because the active Claude MCP inventory could not be inspected: ${inventoryError}`;
98
+ const remediation = "Run `claude mcp list`, resolve the inventory failure, then fully restart Claude Code.";
99
+ if (!cliPath) {
100
+ return {
101
+ state: "inventory-unavailable",
102
+ transport: null,
103
+ toolName: null,
104
+ diagnostic: `${reason} The explicit CLI fallback is also unavailable.`,
105
+ remediation: `${remediation} Install the fallback with \`npm install -g @openai/codex\` if needed.`,
106
+ fallbackDisclosure: null,
107
+ };
108
+ }
109
+ return {
110
+ state: "inventory-unavailable",
111
+ transport: "cli",
112
+ toolName: null,
113
+ diagnostic: reason,
114
+ remediation,
115
+ fallbackDisclosure: `Transport disclosure: ${reason} Running the review through the explicit \`codex exec\` CLI fallback without claiming MCP registration or availability; validated findings will be relayed unchanged.`,
116
+ };
117
+ }
118
+
119
+ const registrations = Object.entries(mcpServers).filter(([, server]) => isAskCodexRegistration(server));
120
+ const availableRegistrations = registrations.filter(([, server]) => isAvailableMcpServer(server));
121
+ const registeredToolNames = new Set(availableRegistrations.map(([name]) => expectedToolName(name)));
122
+ const toolName = availableTools.find((name) => isAskCodexToolName(name) && registeredToolNames.has(name));
123
+ if (toolName && !mcpFailed) {
124
+ return {
125
+ state: "preferred",
126
+ transport: "mcp",
127
+ toolName,
128
+ diagnostic: `Ask LLM Codex transport available as ${toolName}.`,
129
+ remediation: null,
130
+ fallbackDisclosure: null,
131
+ };
132
+ }
133
+
134
+ const registered = registrations.length > 0;
135
+ const state = registered || mcpFailed ? "unavailable" : "missing-registration";
136
+ const remediation =
137
+ registered || mcpFailed
138
+ ? "Run `npx -y @ask-llm/mcp doctor`, inspect `/mcp`, then fully restart Claude Code."
139
+ : "Run `claude mcp add --scope user codex -- npx -y @ask-llm/codex-mcp`, fully restart Claude Code, then verify with `/mcp`.";
140
+ const unavailableRegistration = registrations.find(([, server]) => !isAvailableMcpServer(server));
141
+ const reason = mcpFailed
142
+ ? "Ask LLM Codex MCP invocation failed in this session, so the preferred transport is unavailable."
143
+ : unavailableRegistration
144
+ ? `Ask LLM Codex MCP is registered, but active inventory reports it unavailable: ${unavailableRegistration[1].status}.`
145
+ : registered
146
+ ? "Ask LLM Codex MCP is registered, but its `ask-codex` tool is unavailable in this session."
147
+ : "Ask LLM Codex MCP registration is missing from this Claude Code installation.";
148
+
149
+ if (!cliPath) {
150
+ return {
151
+ state,
152
+ transport: null,
153
+ toolName: null,
154
+ diagnostic: `${reason} The explicit CLI fallback is also unavailable.`,
155
+ remediation: `${remediation} Install the fallback with \`npm install -g @openai/codex\` if needed.`,
156
+ fallbackDisclosure: null,
157
+ };
158
+ }
159
+
160
+ return {
161
+ state,
162
+ transport: "cli",
163
+ toolName: null,
164
+ diagnostic: reason,
165
+ remediation,
166
+ fallbackDisclosure: `Transport disclosure: ${reason} Running the review through the explicit \`codex exec\` CLI fallback; validated findings will be relayed unchanged.`,
167
+ };
168
+ }
169
+
170
+ export function codexFallbackArgs(model) {
171
+ return [
172
+ "exec",
173
+ "-m",
174
+ model,
175
+ "-c",
176
+ 'model_reasoning_effort="high"',
177
+ "-s",
178
+ "read-only",
179
+ "--ignore-user-config",
180
+ "--ignore-rules",
181
+ "--skip-git-repo-check",
182
+ ];
183
+ }
184
+
185
+ export function executeCodex({ command, model, prompt, spawnProcess = spawn, platform = process.platform }) {
186
+ return new Promise((resolveRun) => {
187
+ const invocation = prepareCommandInvocation(
188
+ codexFallbackArgs(model),
189
+ {
190
+ cwd: process.cwd(),
191
+ env: process.env,
192
+ stdio: ["pipe", "pipe", "pipe"],
193
+ windowsHide: true,
194
+ },
195
+ platform,
196
+ );
197
+ const child = spawnProcess(command, invocation.args, invocation.options);
198
+ let stdout = "";
199
+ let stderr = "";
200
+ child.stdout.setEncoding("utf8");
201
+ child.stderr.setEncoding("utf8");
202
+ child.stdout.on("data", (chunk) => {
203
+ stdout += chunk;
204
+ });
205
+ child.stderr.on("data", (chunk) => {
206
+ stderr += chunk;
207
+ });
208
+ child.on("error", (error) => {
209
+ resolveRun({ code: 127, stdout, stderr: `${stderr}${error.message}` });
210
+ });
211
+ child.on("close", (code) => {
212
+ resolveRun({ code: code ?? 1, stdout, stderr });
213
+ });
214
+ child.stdin.on("error", () => {});
215
+ child.stdin.end(prompt);
216
+ });
217
+ }
218
+
219
+ export async function runCliFallback({
220
+ prompt,
221
+ command = process.env.ASK_CODEX_BIN || "codex",
222
+ fallbackModel = process.env.ASK_CODEX_FALLBACK_MODEL || TERRA_MODEL,
223
+ execute = executeCodex,
224
+ }) {
225
+ const primary = await execute({ command, model: SOL_MODEL, prompt });
226
+ if (primary.code === 0) {
227
+ return { response: primary.stdout, diagnostics: primary.stderr, model: SOL_MODEL, fellBack: false };
228
+ }
229
+
230
+ const primaryOutput = `${primary.stderr}\n${primary.stdout}`;
231
+ const normalizedPrimaryOutput = primaryOutput.toLowerCase();
232
+ const quotaFailure = quotaSignals.some((signal) => normalizedPrimaryOutput.includes(signal));
233
+ if (!quotaFailure || fallbackModel === SOL_MODEL) {
234
+ throw new Error(primaryOutput.trim() || `codex exec exited ${primary.code}`);
235
+ }
236
+
237
+ const fallback = await execute({ command, model: fallbackModel, prompt });
238
+ if (fallback.code !== 0) {
239
+ const fallbackOutput = `${fallback.stderr}\n${fallback.stdout}`.trim();
240
+ throw new Error(`Sol review failed and ${fallbackModel} fallback also failed: ${fallbackOutput}`);
241
+ }
242
+
243
+ return {
244
+ response: fallback.stdout,
245
+ diagnostics: fallback.stderr,
246
+ model: fallbackModel,
247
+ fellBack: true,
248
+ };
249
+ }
250
+
251
+ function parseArgs(args) {
252
+ const parsed = {
253
+ tools: [],
254
+ cliPath: "",
255
+ mcpList: null,
256
+ fallback: false,
257
+ mcpFailed: false,
258
+ claudeContextArgs: [],
259
+ };
260
+ const claudeContextValueFlags = new Set(["--plugin-dir", "--mcp-config", "--settings", "--setting-sources"]);
261
+ for (let index = 0; index < args.length; index += 1) {
262
+ const arg = args[index];
263
+ if (arg === "--fallback") parsed.fallback = true;
264
+ else if (arg === "--mcp-failed") parsed.mcpFailed = true;
265
+ else if (arg === "--tool") parsed.tools.push(args[++index] ?? "");
266
+ else if (arg === "--cli-path") parsed.cliPath = args[++index] ?? "";
267
+ else if (arg === "--mcp-list") parsed.mcpList = args[++index] ?? "";
268
+ else if (arg === "--strict-mcp-config") parsed.claudeContextArgs.push(arg);
269
+ else if (claudeContextValueFlags.has(arg)) {
270
+ const value = args[++index];
271
+ if (value === undefined) throw new Error(`Missing value for ${arg}`);
272
+ parsed.claudeContextArgs.push(arg, value);
273
+ } else throw new Error(`Unknown argument: ${arg}`);
274
+ }
275
+ return parsed;
276
+ }
277
+
278
+ function readMcpServers(parsed) {
279
+ if (parsed.mcpList !== null) {
280
+ return { mcpServers: parseClaudeMcpList(parsed.mcpList), inventoryError: null };
281
+ }
282
+ try {
283
+ return {
284
+ mcpServers: readActiveMcpServers({ contextArgs: parsed.claudeContextArgs }),
285
+ inventoryError: null,
286
+ };
287
+ } catch (error) {
288
+ return {
289
+ mcpServers: {},
290
+ inventoryError: error instanceof Error ? error.message : String(error),
291
+ };
292
+ }
293
+ }
294
+
295
+ async function main() {
296
+ const parsed = parseArgs(process.argv.slice(2));
297
+ const { mcpServers, inventoryError } = readMcpServers(parsed);
298
+ const decision = classifySolReviewTransport({
299
+ availableTools: parsed.tools,
300
+ mcpServers,
301
+ cliPath: parsed.cliPath,
302
+ inventoryError,
303
+ mcpFailed: parsed.mcpFailed,
304
+ });
305
+
306
+ if (parsed.fallback) {
307
+ if (decision.transport !== "cli") {
308
+ if (decision.transport === "mcp") {
309
+ throw new Error(`Ask LLM Codex MCP is available as ${decision.toolName}; CLI fallback was not started.`);
310
+ }
311
+ throw new Error(`${decision.diagnostic} ${decision.remediation}`);
312
+ }
313
+ process.stderr.write(`${decision.fallbackDisclosure}\nRemediation: ${decision.remediation}\n`);
314
+ let prompt = "";
315
+ process.stdin.setEncoding("utf8");
316
+ for await (const chunk of process.stdin) prompt += chunk;
317
+ if (!prompt.trim()) throw new Error("Sol review CLI fallback requires a prompt on stdin.");
318
+ const result = await runCliFallback({ prompt, command: parsed.cliPath });
319
+ process.stderr.write(
320
+ `Transport disclosure: review ran through codex exec (${result.model}, high effort, read-only).\n`,
321
+ );
322
+ if (result.fellBack) {
323
+ process.stderr.write(`Model fallback disclosure: Sol hit quota; review completed on ${result.model}.\n`);
324
+ }
325
+ if (result.diagnostics) process.stderr.write(result.diagnostics);
326
+ process.stdout.write(result.response);
327
+ return;
328
+ }
329
+
330
+ process.stdout.write(`${JSON.stringify(decision)}\n`);
331
+ if (!decision.transport) process.exitCode = 1;
332
+ }
333
+
334
+ if (resolve(process.argv[1] || "") === scriptPath) {
335
+ main().catch((error) => {
336
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
337
+ process.exitCode = 1;
338
+ });
339
+ }