refactor(cli): split call argument parsing helpers

This commit is contained in:
Peter Steinberger 2026-03-03 00:37:38 +00:00
parent 0c3e8233de
commit 507bb6de95
3 changed files with 164 additions and 160 deletions

View File

@ -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<ReturnType<typeof parseCallExpressionFragment>>;
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'));
}

View File

@ -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(' '));
}

View File

@ -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<typeof parseCallExpressionFragment> | 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<typeof parseCallExpressionFragment> | 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;
}