Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions apps/cli/ai/tests/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,4 +360,89 @@ describe( 'Studio AI MCP tools', () => {
'--post_content=Hello world',
] );
} );

it( 'keeps flags after quoted post_content out of the page content', async () => {
vi.mocked( isServerRunning ).mockResolvedValue( {
name: 'site-123',
pmId: 1,
status: 'online',
pid: 1234,
} );
vi.mocked( sendWpCliCommand ).mockResolvedValue( {
stdout: '123',
stderr: '',
exitCode: 0,
} );

await getTool( 'wp_cli' ).handler(
{
nameOrPath: 'My Site',
command:
'post create --post_type=page --post_title="About" --post_content="Hello world" --porcelain',
} as never,
null
);

expect( sendWpCliCommand ).toHaveBeenCalledWith( 'site-123', [
'post',
'create',
'--post_type=page',
'--post_title=About',
'--post_content=Hello world',
'--porcelain',
] );
} );

it( 'keeps porcelain after empty quoted post_content out of the page content', async () => {
vi.mocked( isServerRunning ).mockResolvedValue( {
name: 'site-123',
pmId: 1,
status: 'online',
pid: 1234,
} );
vi.mocked( sendWpCliCommand ).mockResolvedValue( {
stdout: '123',
stderr: '',
exitCode: 0,
} );

await getTool( 'wp_cli' ).handler(
{
nameOrPath: 'My Site',
command: 'post create --post_type=page --post_title="About" --post_content="" --porcelain',
} as never,
null
);

expect( sendWpCliCommand ).toHaveBeenCalledWith( 'site-123', [
'post',
'create',
'--post_type=page',
'--post_title=About',
'--post_content=',
'--porcelain',
] );
} );

it( 'rejects typographic dash options before dispatching to WP-CLI', async () => {
vi.mocked( isServerRunning ).mockResolvedValue( {
name: 'site-123',
pmId: 1,
status: 'online',
pid: 1234,
} );

const result = await getTool( 'wp_cli' ).handler(
{
nameOrPath: 'My Site',
command:
'post create --post_type=page --post_title="About" --post_content="Hello world" –porcelain',
} as never,
null
);

expect( sendWpCliCommand ).not.toHaveBeenCalled();
expect( result.isError ).toBe( true );
expect( getTextContent( result ) ).toContain( 'typographic dash' );
} );
} );
55 changes: 47 additions & 8 deletions apps/cli/ai/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,24 +92,58 @@ function stripMatchingOuterQuotes( value: string ): string {
return value;
}

function splitCommandArgs( command: string ): string[] {
function splitPostContentCommandArgs( command: string, postContentIndex: number ): string[] {
const postContentMarker = '--post_content=';
const postContentIndex = command.indexOf( postContentMarker );
const prefix = command.slice( 0, postContentIndex ).trim();
const postContentTail = command.slice( postContentIndex + postContentMarker.length ).trim();
const prefixArgs = splitBasicCommandArgs( prefix );

if ( ! postContentTail ) {
return [ ...prefixArgs, postContentMarker ];
}

const quote = postContentTail[ 0 ];
if ( quote === '"' || quote === "'" ) {
const closingQuoteIndex = postContentTail.indexOf( quote, 1 );
if ( closingQuoteIndex !== -1 ) {
const postContent = postContentTail.slice( 1, closingQuoteIndex );
const suffix = postContentTail.slice( closingQuoteIndex + 1 ).trim();
return [
...prefixArgs,
`${ postContentMarker }${ postContent }`,
...splitBasicCommandArgs( suffix ),
];
}
}

// Large block content is commonly emitted without shell quoting and should
// be treated as a single literal argument that consumes the rest of the command.
if ( postContentIndex !== -1 ) {
const prefix = command.slice( 0, postContentIndex ).trim();
const postContent = stripMatchingOuterQuotes(
command.slice( postContentIndex + postContentMarker.length ).trim()
);
return [
...prefixArgs,
`${ postContentMarker }${ stripMatchingOuterQuotes( postContentTail ) }`,
];
}

return [ ...splitBasicCommandArgs( prefix ), `${ postContentMarker }${ postContent }` ];
function splitCommandArgs( command: string ): string[] {
const postContentMarker = '--post_content=';
const postContentIndex = command.indexOf( postContentMarker );

if ( postContentIndex !== -1 ) {
return splitPostContentCommandArgs( command, postContentIndex );
}

return splitBasicCommandArgs( command );
}

function getUnsupportedWpCliOptionMessage( args: string[] ): string | null {
const unsupportedOption = args.find( ( arg ) => /^[\u2010-\u2015]\S+/.test( arg ) );
if ( ! unsupportedOption ) {
return null;
}

return `Unsupported WP-CLI option "${ unsupportedOption }": use ASCII hyphens, for example "--porcelain", not a typographic dash.`;
}

async function findSiteByName( name: string ): Promise< SiteData | undefined > {
const config = await readCliConfig();
return config.sites.find( ( site ) => site.name.toLowerCase() === name.toLowerCase() );
Expand Down Expand Up @@ -518,6 +552,11 @@ const runWpCliTool = tool(
}

const wpCliArgs = splitCommandArgs( args.command );
const unsupportedOptionMessage = getUnsupportedWpCliOptionMessage( wpCliArgs );
if ( unsupportedOptionMessage ) {
return errorResult( unsupportedOptionMessage );
}

const unsupportedPostContentMessage = getUnsupportedWpCliPostContentMessage( wpCliArgs );
if ( unsupportedPostContentMessage ) {
return errorResult( unsupportedPostContentMessage );
Expand Down
Loading