refactor(cli): split call argument parsing helpers
This commit is contained in:
parent
0c3e8233de
commit
507bb6de95
61
src/cli/call-argument-expression.ts
Normal file
61
src/cli/call-argument-expression.ts
Normal 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'));
|
||||
}
|
||||
95
src/cli/call-argument-values.ts
Normal file
95
src/cli/call-argument-values.ts
Normal 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(' '));
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user