Skip to content
This repository was archived by the owner on Mar 15, 2026. It is now read-only.

Commit a3de7b0

Browse files
wip
1 parent 7812bb2 commit a3de7b0

File tree

16 files changed

+1388
-63
lines changed

16 files changed

+1388
-63
lines changed

docker-compose.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ services:
77
volumes:
88
- .:/var/www
99
- mise_data:/opt/mise
10+
- cursor_agent:/tmp/container-home/.local
1011
environment:
1112
# Redirect cache and config directories to /tmp to avoid polluting the mounted project directory
1213
HOME: /tmp/container-home
@@ -32,6 +33,7 @@ services:
3233
volumes:
3334
- .:/var/www
3435
- mise_data:/opt/mise
36+
- cursor_agent:/tmp/container-home/.local
3537
environment:
3638
HOME: /tmp/container-home
3739
XDG_CACHE_HOME: /tmp/container-home/.cache
@@ -96,3 +98,5 @@ volumes:
9698
name: etfs_${ETFS_PROJECT_NAME}_mariadb_data
9799
mise_data:
98100
name: etfs_${ETFS_PROJECT_NAME}_mise_data
101+
cursor_agent:
102+
name: etfs_${ETFS_PROJECT_NAME}_cursor_agent

src/ImplementationAgent/Domain/Enum/ImplementationOutcome.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ enum ImplementationOutcome: string
99
case PrCreated = 'pr_created';
1010
case Error = 'error';
1111

12+
private const string GITHUB_PR_URL_PATTERN = '#https://github\.com/[^/]+/[^/]+/pull/\d+#';
13+
1214
public static function fromAgentOutput(bool $agentSuccess, string $resultText): self
1315
{
1416
if (!$agentSuccess) {
@@ -19,15 +21,28 @@ public static function fromAgentOutput(bool $agentSuccess, string $resultText):
1921
return self::PrCreated;
2022
}
2123

24+
if (self::extractPrUrl($resultText) !== null) {
25+
return self::PrCreated;
26+
}
27+
2228
return self::Error;
2329
}
2430

31+
/**
32+
* Extracts a GitHub PR URL from the result text. First checks for the
33+
* explicit `PR_URL:` marker, then falls back to scanning for any
34+
* `github.com/.../pull/N` URL in the text.
35+
*/
2536
public static function extractPrUrl(string $resultText): ?string
2637
{
2738
if (preg_match('/PR_URL:\s*(\S+)/', $resultText, $matches) === 1) {
2839
return $matches[1];
2940
}
3041

42+
if (preg_match(self::GITHUB_PR_URL_PATTERN, $resultText, $matches) === 1) {
43+
return $matches[0];
44+
}
45+
3146
return null;
3247
}
3348
}

src/ImplementationAgent/Infrastructure/Handler/ImplementIssueHandler.php

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,19 @@
2020
#[AsMessageHandler]
2121
readonly class ImplementIssueHandler
2222
{
23+
private const string OUTPUT_FORMAT_INSTRUCTIONS = <<<'INSTRUCTIONS'
24+
## Required Output Format
25+
26+
After you have created the pull request, you MUST end your response with these markers, each on its own line:
27+
28+
```
29+
[PR_CREATED]
30+
PR_URL: https://github.com/owner/repo/pull/123
31+
```
32+
33+
Replace the URL with the actual pull request URL. The `[PR_CREATED]` marker and `PR_URL:` line MUST appear at the very end of your response.
34+
INSTRUCTIONS;
35+
2336
public function __construct(
2437
private ProductConfigFacadeInterface $productConfigFacade,
2538
private GithubIntegrationFacadeInterface $githubIntegrationFacade,
@@ -151,11 +164,17 @@ private function buildPrompt(GithubIssueDto $issueDto, array $comments): string
151164
return sprintf(
152165
"[TASK:IMPLEMENT]\n"
153166
. 'Implement the planned changes for this issue. Create a feature branch, '
154-
. "make the changes according to the plan, and open a pull request.\n\n"
155-
. "## Issue: %s\n\n%s%s",
167+
. "make the changes according to the plan, and open a pull request.\n"
168+
. 'The pull request body MUST contain the text `Closes #%d` so that '
169+
. "merging the PR automatically closes the issue.\n\n"
170+
. "## Issue #%d: %s\n\n%s%s"
171+
. "\n\n%s",
172+
$issueDto->number,
173+
$issueDto->number,
156174
$issueDto->title,
157175
$issueDto->body,
158176
$commentSection,
177+
self::OUTPUT_FORMAT_INSTRUCTIONS,
159178
);
160179
}
161180

@@ -195,7 +214,7 @@ private function handlePrCreated(string $resultText, string $githubUrl, string $
195214

196215
private function handleError(string $resultText, string $githubUrl, string $githubToken, int $issueNumber): void
197216
{
198-
$cleanedText = trim(str_replace('[PR_CREATED]', '', $resultText));
217+
$cleanedText = $this->stripMarkers($resultText);
199218

200219
$this->githubIntegrationFacade->postIssueComment(
201220
$githubUrl,
@@ -211,6 +230,14 @@ private function handleError(string $resultText, string $githubUrl, string $gith
211230
]);
212231
}
213232

233+
private function stripMarkers(string $text): string
234+
{
235+
$cleaned = str_replace('[PR_CREATED]', '', $text);
236+
$cleaned = preg_replace('/PR_URL:\s*\S+/', '', $cleaned) ?? $cleaned;
237+
238+
return trim($cleaned);
239+
}
240+
214241
private function applyErrorLabels(string $githubUrl, string $githubToken, int $issueNumber): void
215242
{
216243
$this->githubIntegrationFacade->removeLabelFromIssue($githubUrl, $githubToken, $issueNumber, GithubLabel::ImplementationOngoing);
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\LlmIntegration\Infrastructure\Service;
6+
7+
use Psr\Log\LoggerInterface;
8+
use RuntimeException;
9+
use Symfony\Component\Process\Process;
10+
11+
class CursorCliAgentBinaryManager
12+
{
13+
private const string INSTALL_COMMAND = 'curl https://cursor.com/install -fsS | bash';
14+
15+
/** @var list<string> */
16+
private const array WELL_KNOWN_PATHS = [
17+
'%s/.local/bin/agent',
18+
'/usr/local/bin/agent',
19+
];
20+
21+
private ?string $resolvedPath = null;
22+
23+
public function __construct(
24+
private readonly LoggerInterface $logger,
25+
) {
26+
}
27+
28+
public function findOrInstall(): string
29+
{
30+
if ($this->resolvedPath !== null) {
31+
return $this->resolvedPath;
32+
}
33+
34+
$path = $this->findBinary();
35+
36+
if ($path !== null) {
37+
$this->resolvedPath = $path;
38+
39+
return $path;
40+
}
41+
42+
$this->logger->info('[AgentBinary] Binary not found, installing...');
43+
$this->installBinary();
44+
45+
$path = $this->findBinary();
46+
47+
if ($path === null) {
48+
throw new RuntimeException(
49+
'Cursor CLI Agent binary not found after installation. '
50+
. 'Tried well-known paths and PATH lookup.',
51+
);
52+
}
53+
54+
$this->resolvedPath = $path;
55+
56+
return $path;
57+
}
58+
59+
public function ensureCliConfig(): void
60+
{
61+
$home = $this->getHome();
62+
$configDir = $home . '/.cursor';
63+
$configPath = $configDir . '/cli-config.json';
64+
65+
if (file_exists($configPath)) {
66+
$this->logger->debug('[AgentBinary] CLI config already exists', ['path' => $configPath]);
67+
68+
return;
69+
}
70+
71+
if (!is_dir($configDir)) {
72+
mkdir($configDir, 0o755, true);
73+
}
74+
75+
$config = [
76+
'version' => 1,
77+
'editor' => ['vimMode' => false],
78+
'permissions' => [
79+
'allow' => [
80+
'Shell(*)',
81+
'Read(**)',
82+
'Write(**)',
83+
'WebFetch(*)',
84+
'Mcp(*:*)',
85+
],
86+
'deny' => [],
87+
],
88+
'attribution' => [
89+
'attributeCommitsToAgent' => false,
90+
'attributePRsToAgent' => false,
91+
],
92+
];
93+
94+
file_put_contents(
95+
$configPath,
96+
json_encode($config, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n",
97+
);
98+
99+
$this->logger->info('[AgentBinary] Created CLI config', ['path' => $configPath]);
100+
}
101+
102+
private function findBinary(): ?string
103+
{
104+
$home = $this->getHome();
105+
106+
foreach (self::WELL_KNOWN_PATHS as $template) {
107+
$path = sprintf($template, $home);
108+
109+
if (is_file($path) && is_executable($path)) {
110+
$this->logger->info('[AgentBinary] Found binary at well-known path', ['path' => $path]);
111+
112+
return $path;
113+
}
114+
}
115+
116+
$process = new Process(['which', 'agent']);
117+
$process->setTimeout(5);
118+
$process->run();
119+
120+
if ($process->isSuccessful()) {
121+
$path = trim($process->getOutput());
122+
123+
if ($path !== '' && is_file($path) && is_executable($path)) {
124+
$this->logger->info('[AgentBinary] Found binary via PATH', ['path' => $path]);
125+
126+
return $path;
127+
}
128+
}
129+
130+
return null;
131+
}
132+
133+
private function installBinary(): void
134+
{
135+
$process = Process::fromShellCommandline(self::INSTALL_COMMAND);
136+
$process->setTimeout(120);
137+
$process->run();
138+
139+
if (!$process->isSuccessful()) {
140+
throw new RuntimeException(
141+
'Failed to install Cursor CLI Agent: '
142+
. $process->getErrorOutput(),
143+
);
144+
}
145+
146+
$this->logger->info('[AgentBinary] Installation completed', [
147+
'stdout' => trim($process->getOutput()),
148+
]);
149+
}
150+
151+
private function getHome(): string
152+
{
153+
$home = getenv('HOME');
154+
155+
if ($home === false || $home === '') {
156+
return '/tmp/container-home';
157+
}
158+
159+
return $home;
160+
}
161+
}

src/LlmIntegration/Infrastructure/Service/CursorCliAgentService.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77
use App\LlmIntegration\Infrastructure\Service\Dto\CursorCliAgentRunResult;
88
use Psr\Log\LoggerInterface;
9-
use RuntimeException;
109
use Symfony\Component\DependencyInjection\Attribute\Autowire;
1110

1211
class CursorCliAgentService implements CursorCliAgentServiceInterface
@@ -15,6 +14,7 @@ public function __construct(
1514
#[Autowire('%env(bool:SIMULATE_LLMS)%')]
1615
private readonly bool $simulateLlms,
1716
private readonly SimulatedCursorCliAgentDriver $simulatedDriver,
17+
private readonly RealCursorCliAgentDriver $realDriver,
1818
private readonly LoggerInterface $logger,
1919
) {
2020
}
@@ -31,8 +31,8 @@ public function run(
3131
return $this->simulatedDriver->run($prompt, $workingDirectory, $githubToken);
3232
}
3333

34-
throw new RuntimeException(
35-
'Real Cursor CLI agent execution is not yet implemented. Set SIMULATE_LLMS=1 to use the simulated driver.',
36-
);
34+
$this->logger->info('Using real Cursor CLI Agent');
35+
36+
return $this->realDriver->run($prompt, $workingDirectory, $cursorApiKey, $githubToken);
3737
}
3838
}

0 commit comments

Comments
 (0)