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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/generate-spec-cursor-stdin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@redocly/cli': patch
---

Fixed the `cursor` AI provider of the `generate-spec` command sending only the instructions to the model: the current Cursor CLI ignores piped stdin when a prompt argument is present, so the operation to refine never reached it.
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ function stubChildProcess(stdout: string) {
stdout: EventEmitter;
stderr: EventEmitter;
stdin: EventEmitter & { end: (input: string) => void };
pipedInput?: string;
};
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.stdin = Object.assign(new EventEmitter(), {
end: () => {
end: (input: string) => {
child.pipedInput = input;
child.stdout.emit('data', Buffer.from(stdout));
child.emit('close', 0);
},
Expand All @@ -26,12 +28,13 @@ function stubChildProcess(stdout: string) {
}

describe('runProvider', () => {
let child: ReturnType<typeof stubChildProcess>;

beforeEach(() => {
vi.mocked(spawn).mockClear();
// The stub is not a full ChildProcess; only the pieces runCommand touches.
vi.mocked(spawn).mockReturnValue(
stubChildProcess('refined output') as unknown as ReturnType<typeof spawn>
);
child = stubChildProcess('refined output');
vi.mocked(spawn).mockReturnValue(child as unknown as ReturnType<typeof spawn>);
});

it('runs the claude CLI isolated from the local configuration', async () => {
Expand All @@ -58,6 +61,7 @@ describe('runProvider', () => {
env: expect.objectContaining({ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' }),
})
);
expect(child.pipedInput).toBe('user prompt');
});

it('runs the codex CLI without MCP servers and project docs', async () => {
Expand All @@ -80,15 +84,17 @@ describe('runProvider', () => {
],
expect.objectContaining({ cwd: '/tmp/generate-spec-empty' })
);
expect(child.pipedInput).toBe('sys prompt\n\nuser prompt');
});

it('runs the cursor CLI with the workspace trusted', async () => {
it('runs the cursor CLI with the whole prompt piped through stdin', async () => {
await runProvider('cursor', { system: 'sys prompt', user: 'user prompt' });

expect(spawn).toHaveBeenCalledWith(
'cursor-agent',
['-p', '--output-format', 'text', '--trust', 'sys prompt'],
['-p', '--output-format', 'text', '--trust'],
expect.objectContaining({ cwd: '/tmp/generate-spec-empty' })
);
expect(child.pipedInput).toBe('sys prompt\n\nuser prompt');
});
});
10 changes: 5 additions & 5 deletions packages/cli/src/commands/generate-spec/ai/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,21 +161,21 @@ async function runCursor(request: ProviderRequest): Promise<ProviderResult> {
if (request.model) {
args.push('--model', request.model);
}
// The instructions go in as the prompt argument; the piped stdin is
// attached as context, mirroring how the claude provider splits the two.
args.push(request.system);
// The Cursor CLI ignores piped stdin when a prompt argument is present,
// so the whole prompt goes through stdin, as with the codex provider.
const prompt = `${request.system}\n\n${request.user}`;

// The Cursor CLI originally installed a "cursor-agent" binary and later
// renamed it to "agent"; accept either.
let result;
try {
result = await runCommand('cursor-agent', args, request.user);
result = await runCommand('cursor-agent', args, prompt);
} catch (error) {
if (!(error instanceof CliNotFoundError)) {
throw error;
}
try {
result = await runCommand('agent', args, request.user);
result = await runCommand('agent', args, prompt);
} catch (fallbackError) {
if (fallbackError instanceof CliNotFoundError) {
throw new CliNotFoundError(
Expand Down
Loading