Skip to content
Merged
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@ Grok CLI models are trained to use Cursor-style coding tools. This extension inc

- File tools: `Read`, `Write`, `StrReplace`, `Edit`, `Delete`, and `LS`
- Search tools: `Grep` and `Glob`
- Web search: `WebSearch` only when [pi-web-access](https://www.npmjs.com/package/pi-web-access) is installed (`pi install npm:pi-web-access`); it delegates to that extension’s `web_search`
- Terminal tool: `Shell`

When the active model is **grok-cli** and pi-web-access is installed, `web_search` is removed from the active tool set and blocked if invoked; use `WebSearch` instead. If pi-web-access is not installed, `WebSearch` is not registered and nothing changes for web search. Other providers keep using `web_search` from pi-web-access when that extension is installed.

The shims also normalize common Cursor/Grok argument shapes, such as `contents` for writes, `glob_pattern` for file search, `glob_filter` for grep filters, and `old_string`/`new_string` or `oldText`/`newText` for exact replacements. This keeps agentic coding workflows moving instead of failing on tool schema mismatches.

## Requirements
Expand Down
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,13 @@
"peerDependencies": {
"@earendil-works/pi-ai": "*",
"@earendil-works/pi-coding-agent": "*",
"@earendil-works/pi-tui": "*"
"@earendil-works/pi-tui": "*",
"pi-web-access": "*"
},
"peerDependenciesMeta": {
"pi-web-access": {
"optional": true
}
},
"devDependencies": {
"@biomejs/biome": "2.4.16",
Expand Down
28 changes: 19 additions & 9 deletions src/provider/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import { getBaseUrl, type XaiOAuthCredentials } from '../auth/oauth.js';
import { type GrokCliModelConfig, resolveModels } from '../models/catalog.js';
import { sanitizePayload } from '../payload/sanitize.js';
import { registerGrokTools } from '../tools/register.js';
import {
bindLivePiWebAccess,
ensureWebSearchDelegate,
isPiWebAccessInstalled,
} from '../tools/webSearchDelegate.js';
import { loadQuotaCache } from './quota.js';
import { registerStatusCommand } from './status.js';
import { streamGrokCli } from './stream.js';
Expand Down Expand Up @@ -69,6 +74,20 @@ export default function registerGrokCli(pi: ExtensionAPI) {

registerGrokTools(pi);

pi.on('session_start', async (_event, ctx) => {
if (process.env.GROK_CLI_OAUTH_TOKEN) {
ctx.ui.notify(
'[pi-grok-cli] Using GROK_CLI_OAUTH_TOKEN bypass — no auto-refresh, no model discovery',
'warning',
);
}

if (!isPiWebAccessInstalled()) return;

bindLivePiWebAccess(pi);
await ensureWebSearchDelegate(pi);
});

pi.on('before_provider_request', (event, ctx) => {
if (ctx.model?.provider !== 'grok-cli') return;

Expand All @@ -78,13 +97,4 @@ export default function registerGrokCli(pi: ExtensionAPI) {
});

registerStatusCommand(pi);

if (process.env.GROK_CLI_OAUTH_TOKEN) {
pi.on('session_start', async (_event, ctx) => {
ctx.ui.notify(
'[pi-grok-cli] Using GROK_CLI_OAUTH_TOKEN bypass — no auto-refresh, no model discovery',
'warning',
);
});
}
}
33 changes: 30 additions & 3 deletions src/provider/toolScope.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,40 @@
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
import { GROK_TOOL_NAMES } from '../tools/register.js';
import {
GROK_SUPPRESSED_TOOL_NAMES,
GROK_TOOL_NAMES_FOR_SCOPE,
grokToolsToActivate,
} from '../tools/register.js';

const preservedSuppressedTools = new WeakMap<object, string[]>();

export function syncGrokTools(
pi: Pick<ExtensionAPI, 'getActiveTools' | 'setActiveTools'>,
provider: string | undefined,
) {
const currentTools = pi.getActiveTools();
const baseTools = currentTools.filter((toolName) => !GROK_TOOL_NAMES.includes(toolName));
const nextTools = provider === 'grok-cli' ? [...baseTools, ...GROK_TOOL_NAMES] : baseTools;
const baseTools = currentTools.filter(
(toolName) =>
!GROK_TOOL_NAMES_FOR_SCOPE.includes(toolName as (typeof GROK_TOOL_NAMES_FOR_SCOPE)[number]),
);
const suppressedTools = baseTools.filter((toolName) =>
GROK_SUPPRESSED_TOOL_NAMES.includes(toolName as (typeof GROK_SUPPRESSED_TOOL_NAMES)[number]),
);
if (suppressedTools.length > 0) preservedSuppressedTools.set(pi, suppressedTools);

const nextTools =
provider === 'grok-cli'
? [
...baseTools.filter((toolName) => !suppressedTools.includes(toolName)),
...grokToolsToActivate(),
]
: [
...baseTools,
...(preservedSuppressedTools.get(pi) ?? []).filter(
(toolName) => !baseTools.includes(toolName),
),
];

if (provider !== 'grok-cli') preservedSuppressedTools.delete(pi);

if (
currentTools.length === nextTools.length &&
Expand Down
19 changes: 17 additions & 2 deletions src/tools/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
import { registerFileTools } from './files.js';
import { registerSearchTools } from './search.js';
import { registerShellTool } from './shell.js';
import { registerWebSearchTool } from './webSearch.js';
import { isPiWebAccessInstalled } from './webSearchDelegate.js';

export const GROK_TOOL_NAMES = [
/** Grok/Cursor shims always registered by this extension (excludes optional WebSearch). */
export const GROK_SHIM_TOOL_NAMES = [
'Grep',
'Glob',
'LS',
Expand All @@ -13,9 +16,21 @@ export const GROK_TOOL_NAMES = [
'Edit',
'Delete',
'Shell',
];
] as const;

/** All shim names used when reconciling the active tool set (includes optional WebSearch). */
export const GROK_TOOL_NAMES_FOR_SCOPE = [...GROK_SHIM_TOOL_NAMES, 'WebSearch'] as const;

export const GROK_SUPPRESSED_TOOL_NAMES = ['web_search'] as const;

export function grokToolsToActivate() {
const names: string[] = [...GROK_SHIM_TOOL_NAMES];
if (isPiWebAccessInstalled()) names.push('WebSearch');
return names;
}

export function registerGrokTools(pi: ExtensionAPI) {
if (isPiWebAccessInstalled()) registerWebSearchTool(pi);
registerSearchTools(pi);
registerFileTools(pi);
registerShellTool(pi);
Expand Down
2 changes: 2 additions & 0 deletions src/tools/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export function registerShellTool(pi: ExtensionAPI) {
const err = error as {
code?: unknown;
message?: string;
signal?: unknown;
stdout?: string;
stderr?: string;
};
Expand All @@ -132,6 +133,7 @@ export function registerShellTool(pi: ExtensionAPI) {
details: {
exitCode,
command: params.command,
...(typeof err.signal === 'string' ? { signal: err.signal } : {}),
},
};
}
Expand Down
168 changes: 168 additions & 0 deletions src/tools/webSearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { StringEnum, Type } from '@earendil-works/pi-ai';
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
import { Text } from '@earendil-works/pi-tui';
import { renderRunning, text } from './rendering.js';
import {
ensureWebSearchDelegate,
getWebSearchDelegate,
getWebSearchLoadError,
PI_WEB_SEARCH_TOOL,
} from './webSearchDelegate.js';

const WEB_SEARCH_DESCRIPTION =
'Search the web using Perplexity AI, Exa, or Gemini. Returns an AI-synthesized answer with source citations. For comprehensive research, prefer queries (plural) with 2-4 varied angles over a single query — each query gets its own synthesized answer, so varying phrasing and scope gives much broader coverage. When includeContent is true, full page content is fetched in the background. Searches auto-open the interactive browser curator and stream results live; set workflow to "none" to skip curation. Provider auto-selects: Exa (direct API with key, MCP fallback without), else Perplexity (needs key), else Gemini API (needs key), else Gemini Web (needs a supported Chromium-based browser login).';

const WebSearchParams = Type.Object({
query: Type.Optional(
Type.String({
description:
"Single search query. For research tasks, prefer 'queries' with multiple varied angles instead.",
}),
),
queries: Type.Optional(
Type.Array(Type.String(), {
description:
'Multiple queries searched in sequence, each returning its own synthesized answer. Prefer this for research — vary phrasing, scope, and angle across 2-4 queries to maximize coverage.',
}),
),
numResults: Type.Optional(
Type.Number({ description: 'Results per query (default: 5, max: 20)' }),
),
includeContent: Type.Optional(Type.Boolean({ description: 'Fetch full page content (async)' })),
recencyFilter: Type.Optional(
StringEnum(['day', 'week', 'month', 'year'], { description: 'Filter by recency' }),
),
domainFilter: Type.Optional(
Type.Array(Type.String(), {
description: 'Limit to domains (prefix with - to exclude)',
}),
),
provider: Type.Optional(
StringEnum(['auto', 'perplexity', 'gemini', 'exa'], {
description: 'Search provider (default: auto)',
}),
),
workflow: Type.Optional(
StringEnum(['none', 'summary-review'], {
description:
'Search workflow mode: none = no curator, summary-review = open curator with auto summary draft (default)',
}),
),
});

function normalizeQueryList(raw: unknown[]): string[] {
return raw
.filter((q): q is string => typeof q === 'string')
.map((q) => q.trim())
.filter((q) => q.length > 0);
}

function queryListFromArgs(args: Record<string, unknown>) {
const raw: unknown[] = Array.isArray(args.queries)
? args.queries
: args.query !== undefined
? [args.query]
: [];
return normalizeQueryList(raw);
}

function missingDelegateMessage() {
const reason =
getWebSearchLoadError() ??
'pi-web-access web_search delegate not available. Install with: pi install npm:pi-web-access';
return {
content: [
{
type: 'text' as const,
text: `WebSearch requires pi-web-access: ${reason}`,
},
],
details: { error: reason },
};
}

export function registerWebSearchTool(pi: ExtensionAPI) {
pi.registerTool({
name: 'WebSearch',
label: 'Web Search',
description: WEB_SEARCH_DESCRIPTION,
promptSnippet:
'Use for web research questions. Prefer {queries:[...]} with 2-4 varied angles over a single query for broader coverage.',
parameters: WebSearchParams,

async execute(toolCallId, params, signal, onUpdate, ctx) {
await ensureWebSearchDelegate(pi);
const delegate = getWebSearchDelegate();
if (!delegate) return missingDelegateMessage();
const normalizedParams = { ...(params as Record<string, unknown>) };
if (Array.isArray(normalizedParams.queries)) {
normalizedParams.queries = normalizeQueryList(normalizedParams.queries);
}
if (typeof normalizedParams.query === 'string') {
const query = normalizedParams.query.trim();
if (query) normalizedParams.query = query;
if (!query) delete normalizedParams.query;
}
return delegate(toolCallId, normalizedParams, signal, onUpdate, ctx);
},

renderCall(args, theme) {
const queryList = queryListFromArgs(args as Record<string, unknown>);
if (queryList.length === 0) {
return text(
theme.fg('toolTitle', theme.bold('WebSearch ')) + theme.fg('error', '(no query)'),
);
}
if (queryList.length === 1) {
const q = queryList[0];
const display = q.length > 60 ? `${q.slice(0, 57)}...` : q;
return text(
theme.fg('toolTitle', theme.bold('WebSearch ')) + theme.fg('accent', `"${display}"`),
);
}
const lines = [
theme.fg('toolTitle', theme.bold('WebSearch ')) +
theme.fg('accent', `${queryList.length} queries`),
];
for (const q of queryList.slice(0, 5)) {
const display = q.length > 50 ? `${q.slice(0, 47)}...` : q;
lines.push(theme.fg('muted', ` "${display}"`));
}
if (queryList.length > 5) {
lines.push(theme.fg('muted', ` ... and ${queryList.length - 5} more`));
}
return new Text(lines.join('\n'), 0, 0);
},

renderResult(result, { expanded, isPartial }, theme) {
const running = renderRunning(isPartial);
if (running) return running;

const details = result.details as { error?: string; totalResults?: number } | undefined;
if (details?.error) {
return text(theme.fg('error', `Error: ${details.error}`));
}

const summary =
typeof details?.totalResults === 'number'
? theme.fg('success', `${details.totalResults} sources`)
: theme.fg('success', 'search complete');

if (!expanded) return text(summary);

const textContent = result.content.find((c) => c.type === 'text')?.text ?? '';
const preview = textContent.length > 800 ? `${textContent.slice(0, 800)}...` : textContent;
return new Text(`${summary}\n${theme.fg('dim', preview)}`, 0, 0);
},
});

pi.on('tool_call', (event, ctx) => {
if (ctx.model?.provider !== 'grok-cli') return;
if (event.toolName !== PI_WEB_SEARCH_TOOL) return;
return {
block: true,
reason:
'web_search is disabled for Grok CLI; use WebSearch instead (same behavior as pi-web-access web_search).',
};
});
}
Loading
Loading