diff --git a/src/cli/call-argument-expression.ts b/src/cli/call-argument-expression.ts new file mode 100644 index 0000000..b4a914f --- /dev/null +++ b/src/cli/call-argument-expression.ts @@ -0,0 +1,61 @@ +import { parseCallExpressionFragment } from './call-expression-parser.js'; +import { CliUsageError } from './errors.js'; +import { splitHttpToolSelector } from './http-utils.js'; + +type ParsedCallExpression = NonNullable>; + +export function parseLeadingCallExpression(rawToken: string): ParsedCallExpression | null { + try { + return extractHttpCallExpression(rawToken) ?? parseCallExpressionFragment(rawToken); + } catch (error) { + throw buildCallExpressionUsageError(error); + } +} + +function extractHttpCallExpression(raw: string): ParsedCallExpression | null { + const trimmed = raw.trim(); + const openParen = trimmed.indexOf('('); + const prefix = openParen === -1 ? trimmed : trimmed.slice(0, openParen); + const split = splitHttpToolSelector(prefix); + if (!split) { + return null; + } + if (openParen === -1) { + return { server: split.baseUrl, tool: split.tool, args: {} }; + } + if (!trimmed.endsWith(')')) { + throw new Error('Function-call syntax requires a closing ) character.'); + } + const argsPortion = trimmed.slice(openParen); + const parsed = parseCallExpressionFragment(`${split.tool}${argsPortion}`); + if (!parsed) { + return { server: split.baseUrl, tool: split.tool, args: {} }; + } + return { + server: split.baseUrl, + tool: split.tool, + args: parsed.args, + positionalArgs: parsed.positionalArgs ?? [], + }; +} + +function buildCallExpressionUsageError(error: unknown): CliUsageError { + const reason = + error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : JSON.stringify(error ?? 'Unknown error'); + const lines = [ + 'Unable to parse function-style call.', + `Reason: ${reason}`, + '', + 'Examples:', + ' mcporter \'context7.resolve-library-id(libraryName: "react")\'', + ' mcporter \'context7.resolve-library-id("react")\'', + ' mcporter context7.resolve-library-id libraryName=react', + '', + 'Tip: wrap the entire expression in single quotes so the shell preserves parentheses and commas.', + ]; + return new CliUsageError(lines.join('\n')); +} diff --git a/src/cli/call-argument-values.ts b/src/cli/call-argument-values.ts new file mode 100644 index 0000000..b7f682c --- /dev/null +++ b/src/cli/call-argument-values.ts @@ -0,0 +1,95 @@ +export type CoercionMode = 'default' | 'raw-strings' | 'none'; + +export interface ParsedKeyValueToken { + key: string; + rawValue: string; + consumed: number; +} + +export function parseKeyValueToken(token: string, nextToken: string | undefined): ParsedKeyValueToken | undefined { + const eqIndex = token.indexOf('='); + if (eqIndex !== -1) { + const key = token.slice(0, eqIndex); + const rawValue = token.slice(eqIndex + 1); + if (!key) { + return undefined; + } + return { key, rawValue, consumed: 1 }; + } + + const colonIndex = token.indexOf(':'); + if (colonIndex !== -1) { + const key = token.slice(0, colonIndex); + const remainder = token.slice(colonIndex + 1); + if (!key) { + return undefined; + } + if (remainder.length > 0) { + return { key, rawValue: remainder, consumed: 1 }; + } + if (nextToken !== undefined) { + return { key, rawValue: nextToken, consumed: 2 }; + } + warnMissingNamedArgumentValue(key); + return { key, rawValue: '', consumed: 1 }; + } + + return undefined; +} + +export function coerceValue(value: string, coercionMode: CoercionMode = 'default'): unknown { + const trimmed = value.trim(); + if (trimmed === '') { + return ''; + } + if (coercionMode === 'none') { + return trimmed; + } + if (trimmed === 'true' || trimmed === 'false') { + return trimmed === 'true'; + } + if (trimmed === 'null' || trimmed === 'none') { + return null; + } + if (coercionMode === 'default' && !Number.isNaN(Number(trimmed)) && trimmed === `${Number(trimmed)}`) { + return Number(trimmed); + } + if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) { + try { + return JSON.parse(trimmed); + } catch { + return trimmed; + } + } + return trimmed; +} + +export function shouldPromoteSelectorToCommand(selector: string): boolean { + const trimmed = selector.trim(); + if (!trimmed) { + return false; + } + if (/\s/.test(trimmed)) { + return true; + } + if (/^(?:\.{1,2}\/|~\/|\/)/.test(trimmed)) { + return true; + } + if (/^[A-Za-z]:\\/.test(trimmed) || trimmed.startsWith('\\\\')) { + return true; + } + return false; +} + +function warnMissingNamedArgumentValue(key: string): void { + const hint = + key === 'command' ? `Example: mcporter call iterm-mcp.write_to_terminal --args '{"command":"echo hi"}'` : undefined; + const lines = [ + `[mcporter] Argument '${key}' was provided without a value.`, + `Wrap the entire key/value pair in quotes (e.g., 'command: "echo hi"') or use --args with JSON.`, + ]; + if (hint) { + lines.push(hint); + } + console.warn(lines.join(' ')); +} diff --git a/src/cli/call-arguments.ts b/src/cli/call-arguments.ts index b50ab3e..fc50789 100644 --- a/src/cli/call-arguments.ts +++ b/src/cli/call-arguments.ts @@ -1,8 +1,12 @@ import type { EphemeralServerSpec } from './adhoc-server.js'; -import { parseCallExpressionFragment } from './call-expression-parser.js'; +import { parseLeadingCallExpression } from './call-argument-expression.js'; +import { + type CoercionMode, + coerceValue, + parseKeyValueToken, + shouldPromoteSelectorToCommand, +} from './call-argument-values.js'; import { extractEphemeralServerFlags } from './ephemeral-flags.js'; -import { CliUsageError } from './errors.js'; -import { splitHttpToolSelector } from './http-utils.js'; import { consumeOutputFormat } from './output-format.js'; import type { OutputFormat } from './output-utils.js'; import { consumeTimeoutFlag } from './timeouts.js'; @@ -21,8 +25,6 @@ export interface CallArgsParseResult { saveImagesDir?: string; } -type CoercionMode = 'default' | 'raw-strings' | 'none'; - interface FlagParseState { coercionMode: CoercionMode; } @@ -79,19 +81,7 @@ export function parseCallArguments(args: string[]): CallArgsParseResult { if (positional.length > 0) { const rawToken = positional[0] ?? ''; - let callExpression: ReturnType | null = null; - try { - callExpression = extractHttpCallExpression(rawToken); - } catch (error) { - throw buildCallExpressionUsageError(error); - } - if (!callExpression) { - try { - callExpression = parseCallExpressionFragment(rawToken); - } catch (error) { - throw buildCallExpressionUsageError(error); - } - } + const callExpression = parseLeadingCallExpression(rawToken); if (callExpression) { positional.shift(); callExpressionProvidedServer = Boolean(callExpression.server); @@ -254,145 +244,3 @@ function consumeFlagValue(args: string[], index: number, token: string, missingV } throw new Error(missingValueMessage ?? `Flag '${token}' requires a value.`); } - -interface ParsedKeyValueToken { - key: string; - rawValue: string; - consumed: number; -} - -function parseKeyValueToken(token: string, nextToken: string | undefined): ParsedKeyValueToken | undefined { - const eqIndex = token.indexOf('='); - if (eqIndex !== -1) { - const key = token.slice(0, eqIndex); - const rawValue = token.slice(eqIndex + 1); - if (!key) { - return undefined; - } - return { key, rawValue, consumed: 1 }; - } - - const colonIndex = token.indexOf(':'); - if (colonIndex !== -1) { - const key = token.slice(0, colonIndex); - const remainder = token.slice(colonIndex + 1); - if (!key) { - return undefined; - } - if (remainder.length > 0) { - return { key, rawValue: remainder, consumed: 1 }; - } - if (nextToken !== undefined) { - return { key, rawValue: nextToken, consumed: 2 }; - } - warnMissingNamedArgumentValue(key); - return { key, rawValue: '', consumed: 1 }; - } - - return undefined; -} - -function warnMissingNamedArgumentValue(key: string): void { - const hint = - key === 'command' ? `Example: mcporter call iterm-mcp.write_to_terminal --args '{"command":"echo hi"}'` : undefined; - const lines = [ - `[mcporter] Argument '${key}' was provided without a value.`, - `Wrap the entire key/value pair in quotes (e.g., 'command: "echo hi"') or use --args with JSON.`, - ]; - if (hint) { - lines.push(hint); - } - console.warn(lines.join(' ')); -} - -function extractHttpCallExpression(raw: string): ReturnType | null { - const trimmed = raw.trim(); - const openParen = trimmed.indexOf('('); - const prefix = openParen === -1 ? trimmed : trimmed.slice(0, openParen); - const split = splitHttpToolSelector(prefix); - if (!split) { - return null; - } - if (openParen === -1) { - return { server: split.baseUrl, tool: split.tool, args: {} }; - } - if (!trimmed.endsWith(')')) { - throw new Error('Function-call syntax requires a closing ) character.'); - } - const argsPortion = trimmed.slice(openParen); - const parsed = parseCallExpressionFragment(`${split.tool}${argsPortion}`); - if (!parsed) { - return { server: split.baseUrl, tool: split.tool, args: {} }; - } - return { - server: split.baseUrl, - tool: split.tool, - args: parsed.args, - positionalArgs: parsed.positionalArgs ?? [], - }; -} - -function coerceValue(value: string, coercionMode: CoercionMode = 'default'): unknown { - const trimmed = value.trim(); - if (trimmed === '') { - return ''; - } - if (coercionMode === 'none') { - return trimmed; - } - if (trimmed === 'true' || trimmed === 'false') { - return trimmed === 'true'; - } - if (trimmed === 'null' || trimmed === 'none') { - return null; - } - if (coercionMode === 'default' && !Number.isNaN(Number(trimmed)) && trimmed === `${Number(trimmed)}`) { - return Number(trimmed); - } - if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) { - try { - return JSON.parse(trimmed); - } catch { - return trimmed; - } - } - return trimmed; -} - -function buildCallExpressionUsageError(error: unknown): CliUsageError { - const reason = - error instanceof Error - ? error.message - : typeof error === 'string' - ? error - : JSON.stringify(error ?? 'Unknown error'); - const lines = [ - 'Unable to parse function-style call.', - `Reason: ${reason}`, - '', - 'Examples:', - ' mcporter \'context7.resolve-library-id(libraryName: "react")\'', - ' mcporter \'context7.resolve-library-id("react")\'', - ' mcporter context7.resolve-library-id libraryName=react', - '', - 'Tip: wrap the entire expression in single quotes so the shell preserves parentheses and commas.', - ]; - return new CliUsageError(lines.join('\n')); -} - -function shouldPromoteSelectorToCommand(selector: string): boolean { - const trimmed = selector.trim(); - if (!trimmed) { - return false; - } - if (/\s/.test(trimmed)) { - return true; - } - if (/^(?:\.{1,2}\/|~\/|\/)/.test(trimmed)) { - return true; - } - if (/^[A-Za-z]:\\/.test(trimmed) || trimmed.startsWith('\\\\')) { - return true; - } - return false; -}