mirror of https://github.com/flightphp/core
Merge pull request #652 from flightphp/ai-helpers
Added AI commands and instructions for the repo.master v3.16.0
commit
d43f2e9db5
@ -0,0 +1,27 @@
|
||||
# FlightPHP/Core Project Instructions
|
||||
|
||||
## Overview
|
||||
This is the main FlightPHP core library for building fast, simple, and extensible PHP web applications. It is dependency-free for core usage and supports PHP 7.4+.
|
||||
|
||||
## Project Guidelines
|
||||
- PHP 7.4 must be supported. PHP 8 or greater also supported, but avoid PHP 8+ only features.
|
||||
- Keep the core library dependency-free (no polyfills or interface-only repositories).
|
||||
- All Flight projects are meant to be kept simple and fast. Performance is a priority.
|
||||
- Flight is extensible and when implementing new features, consider how they can be added as plugins or extensions rather than bloating the core library.
|
||||
- Any new features built into the core should be well-documented and tested.
|
||||
- Any new features should be added with a focus on simplicity and performance, avoiding unnecessary complexity.
|
||||
- This is not a Laravel, Yii, Code Igniter or Symfony clone. It is a simple, fast, and extensible framework that allows you to build applications quickly without the overhead of large frameworks.
|
||||
|
||||
## Development & Testing
|
||||
- Run tests: `composer test` (uses phpunit/phpunit and spatie/phpunit-watcher)
|
||||
- Run test server: `composer test-server` or `composer test-server-v2`
|
||||
- Lint code: `composer lint` (uses phpstan/phpstan, level 6)
|
||||
- Beautify code: `composer beautify` (uses squizlabs/php_codesniffer, PSR1)
|
||||
- Check code style: `composer phpcs`
|
||||
- Test coverage: `composer test-coverage`
|
||||
|
||||
## Coding Standards
|
||||
- Follow PSR1 coding standards (enforced by PHPCS)
|
||||
- Use strict comparisons (`===`, `!==`)
|
||||
- PHPStan level 6 compliance
|
||||
- Focus on PHP 7.4 compatibility (avoid PHP 8+ only features)
|
@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace flight\commands;
|
||||
|
||||
use Ahc\Cli\Input\Command;
|
||||
|
||||
/**
|
||||
* @property-read ?string $credsFile
|
||||
* @property-read ?string $baseDir
|
||||
*/
|
||||
class AiGenerateInstructionsCommand extends Command
|
||||
{
|
||||
/**
|
||||
* Constructor for the AiGenerateInstructionsCommand class.
|
||||
*
|
||||
* Initializes a new instance of the command.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('ai:generate-instructions', 'Generate project-specific AI coding instructions');
|
||||
$this->option('--creds-file', 'Path to .runway-creds.json file', null, '');
|
||||
$this->option('--base-dir', 'Project base directory (for testing or custom use)', null, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the command logic for generating AI instructions.
|
||||
*
|
||||
* This method is called to perform the main functionality of the
|
||||
* AiGenerateInstructionsCommand. It should contain the steps required
|
||||
* to generate and output instructions using AI, based on the command's
|
||||
* configuration and input.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function execute()
|
||||
{
|
||||
$io = $this->app()->io();
|
||||
$baseDir = $this->baseDir ? rtrim($this->baseDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR : getcwd() . DIRECTORY_SEPARATOR;
|
||||
$runwayCredsFile = $this->credsFile ?: $baseDir . '.runway-creds.json';
|
||||
|
||||
// Check for runway creds
|
||||
if (!file_exists($runwayCredsFile)) {
|
||||
$io->error('Missing .runway-creds.json. Please run the \'ai:init\' command first.', true);
|
||||
return 1;
|
||||
}
|
||||
|
||||
$io->info('Let\'s gather some project details to generate AI coding instructions.', true);
|
||||
|
||||
// Ask questions
|
||||
$projectDesc = $io->prompt('Please describe what your project is for?');
|
||||
$database = $io->prompt('What database are you planning on using? (e.g. MySQL, SQLite, PostgreSQL, none)', 'none');
|
||||
$templating = $io->prompt('What HTML templating engine will you plan on using (if any)? (recommend latte)', 'latte');
|
||||
$security = $io->confirm('Is security an important element of this project?', 'y');
|
||||
$performance = $io->confirm('Is performance and speed an important part of this project?', 'y');
|
||||
$composerLibs = $io->prompt('What major composer libraries will you be using if you know them right now?', 'none');
|
||||
$envSetup = $io->prompt('How will you set up your development environment? (e.g. Docker, Vagrant, PHP dev server, other)', 'Docker');
|
||||
$teamSize = $io->prompt('How many developers will be working on this project?', '1');
|
||||
$api = $io->confirm('Will this project expose an API?', 'n');
|
||||
$other = $io->prompt('Any other important requirements or context? (optional)', 'no');
|
||||
|
||||
// Prepare prompt for LLM
|
||||
$contextFile = $baseDir . '.github/copilot-instructions.md';
|
||||
$context = file_exists($contextFile) ? file_get_contents($contextFile) : '';
|
||||
$userDetails = [
|
||||
'Project Description' => $projectDesc,
|
||||
'Database' => $database,
|
||||
'Templating Engine' => $templating,
|
||||
'Security Important' => $security ? 'yes' : 'no',
|
||||
'Performance Important' => $performance ? 'yes' : 'no',
|
||||
'Composer Libraries' => $composerLibs,
|
||||
'Environment Setup' => $envSetup,
|
||||
'Team Size' => $teamSize,
|
||||
'API' => $api ? 'yes' : 'no',
|
||||
'Other' => $other,
|
||||
];
|
||||
$detailsText = "";
|
||||
foreach ($userDetails as $k => $v) {
|
||||
$detailsText .= "$k: $v\n";
|
||||
}
|
||||
$prompt = <<<EOT
|
||||
You are an AI coding assistant. Update the following project instructions for this FlightPHP project based on the latest user answers. Only output the new instructions, no extra commentary.
|
||||
User answers:
|
||||
$detailsText
|
||||
Current instructions:
|
||||
$context
|
||||
EOT;
|
||||
|
||||
// Read LLM creds
|
||||
$creds = json_decode(file_get_contents($runwayCredsFile), true);
|
||||
$apiKey = $creds['api_key'] ?? '';
|
||||
$model = $creds['model'] ?? 'gpt-4o';
|
||||
$baseUrl = $creds['base_url'] ?? 'https://api.openai.com';
|
||||
|
||||
// Prepare curl call (OpenAI compatible)
|
||||
$headers = [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . $apiKey,
|
||||
];
|
||||
$data = [
|
||||
'model' => $model,
|
||||
'messages' => [
|
||||
['role' => 'system', 'content' => 'You are a helpful AI coding assistant focused on the Flight Framework for PHP. You are up to date with all your knowledge from https://docs.flightphp.com. As an expert into the programming language PHP, you are top notch at architecting out proper instructions for FlightPHP projects.'],
|
||||
['role' => 'user', 'content' => $prompt],
|
||||
],
|
||||
'temperature' => 0.2,
|
||||
];
|
||||
$jsonData = json_encode($data);
|
||||
|
||||
// add info line that this may take a few minutes
|
||||
$io->info('Generating AI instructions, this may take a few minutes...', true);
|
||||
|
||||
$result = $this->callLlmApi($baseUrl, $headers, $jsonData, $io);
|
||||
if ($result === false) {
|
||||
return 1;
|
||||
}
|
||||
$response = json_decode($result, true);
|
||||
$instructions = $response['choices'][0]['message']['content'] ?? '';
|
||||
if (!$instructions) {
|
||||
$io->error('No instructions returned from LLM.', true);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Write to files
|
||||
$io->info('Updating .github/copilot-instructions.md, .cursor/rules/project-overview.mdc, and .windsurfrules...', true);
|
||||
if (!is_dir($baseDir . '.github')) {
|
||||
mkdir($baseDir . '.github', 0755, true);
|
||||
}
|
||||
if (!is_dir($baseDir . '.cursor/rules')) {
|
||||
mkdir($baseDir . '.cursor/rules', 0755, true);
|
||||
}
|
||||
file_put_contents($baseDir . '.github/copilot-instructions.md', $instructions);
|
||||
file_put_contents($baseDir . '.cursor/rules/project-overview.mdc', $instructions);
|
||||
file_put_contents($baseDir . '.windsurfrules', $instructions);
|
||||
$io->ok('AI instructions updated successfully.', true);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the LLM API call using curl
|
||||
*
|
||||
* @param string $baseUrl
|
||||
* @param array<int,string> $headers
|
||||
* @param string $jsonData
|
||||
* @param object $io
|
||||
*
|
||||
* @return string|false
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
protected function callLlmApi($baseUrl, $headers, $jsonData, $io)
|
||||
{
|
||||
$ch = curl_init($baseUrl . '/v1/chat/completions');
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $jsonData);
|
||||
$result = curl_exec($ch);
|
||||
if (curl_errno($ch)) {
|
||||
$io->error('Failed to call LLM API: ' . curl_error($ch), true);
|
||||
curl_close($ch);
|
||||
return false;
|
||||
}
|
||||
curl_close($ch);
|
||||
return $result;
|
||||
}
|
||||
}
|
@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace flight\commands;
|
||||
|
||||
use Ahc\Cli\Input\Command;
|
||||
|
||||
/**
|
||||
* @property-read ?string $gitignoreFile
|
||||
* @property-read ?string $credsFile
|
||||
*/
|
||||
class AiInitCommand extends Command
|
||||
{
|
||||
/**
|
||||
* Constructor for the AiInitCommand class.
|
||||
*
|
||||
* Initializes the command instance and sets up any required dependencies.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('ai:init', 'Initialize LLM API credentials and settings');
|
||||
$this
|
||||
->option('--gitignore-file', 'Path to .gitignore file', null, '')
|
||||
->option('--creds-file', 'Path to .runway-creds.json file', null, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the function
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function execute()
|
||||
{
|
||||
$io = $this->app()->io();
|
||||
|
||||
$io->info('Welcome to AI Init!', true);
|
||||
|
||||
$baseDir = getcwd() . DIRECTORY_SEPARATOR;
|
||||
$runwayCredsFile = $this->credsFile ?: $baseDir . '.runway-creds.json';
|
||||
$gitignoreFile = $this->gitignoreFile ?: $baseDir . '.gitignore';
|
||||
|
||||
// make sure the .runway-creds.json file is not already present
|
||||
if (file_exists($runwayCredsFile)) {
|
||||
$io->error('.runway-creds.json file already exists. Please remove it before running this command.', true);
|
||||
// prompt to overwrite
|
||||
$overwrite = $io->confirm('Do you want to overwrite the existing .runway-creds.json file?', 'n');
|
||||
if ($overwrite === false) {
|
||||
$io->info('Exiting without changes.', true);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Prompt for API provider with validation
|
||||
$allowedApis = [
|
||||
'1' => 'openai',
|
||||
'2' => 'grok',
|
||||
'3' => 'claude'
|
||||
];
|
||||
$apiChoice = strtolower(trim($io->choice('Which LLM API do you want to use?', $allowedApis, '1')));
|
||||
$api = $allowedApis[$apiChoice] ?? 'openai';
|
||||
|
||||
// Prompt for base URL with validation
|
||||
switch ($api) {
|
||||
case 'openai':
|
||||
$defaultBaseUrl = 'https://api.openai.com';
|
||||
break;
|
||||
case 'grok':
|
||||
$defaultBaseUrl = 'https://api.x.ai';
|
||||
break;
|
||||
case 'claude':
|
||||
$defaultBaseUrl = 'https://api.anthropic.com';
|
||||
break;
|
||||
}
|
||||
$baseUrl = trim($io->prompt('Enter the base URL for the LLM API', $defaultBaseUrl));
|
||||
if (empty($baseUrl) || !filter_var($baseUrl, FILTER_VALIDATE_URL)) {
|
||||
$io->error('Base URL cannot be empty and must be a valid URL.', true);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Validate API key input
|
||||
$apiKey = trim($io->prompt('Enter your API key for ' . $api));
|
||||
if (empty($apiKey)) {
|
||||
$io->error('API key cannot be empty. Please enter a valid API key.', true);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Validate model input
|
||||
switch ($api) {
|
||||
case 'openai':
|
||||
$defaultModel = 'gpt-4o';
|
||||
break;
|
||||
case 'grok':
|
||||
$defaultModel = 'grok-3-beta';
|
||||
break;
|
||||
case 'claude':
|
||||
$defaultModel = 'claude-3-opus';
|
||||
break;
|
||||
}
|
||||
$model = trim($io->prompt('Enter the model name you want to use (e.g. gpt-4, claude-3-opus, etc)', $defaultModel));
|
||||
|
||||
$creds = [
|
||||
'provider' => $api,
|
||||
'api_key' => $apiKey,
|
||||
'model' => $model,
|
||||
'base_url' => $baseUrl,
|
||||
];
|
||||
|
||||
$json = json_encode($creds, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
$file = $runwayCredsFile;
|
||||
file_put_contents($file, $json);
|
||||
|
||||
// change permissions to 600
|
||||
chmod($file, 0600);
|
||||
|
||||
$io->ok('Credentials saved to ' . $file, true);
|
||||
|
||||
// run a check to make sure that the creds file is in the .gitignore file
|
||||
// use $gitignoreFile instead of hardcoded path
|
||||
if (!file_exists($gitignoreFile)) {
|
||||
// create the .gitignore file if it doesn't exist
|
||||
file_put_contents($gitignoreFile, basename($runwayCredsFile) . "\n");
|
||||
$io->info(basename($gitignoreFile) . ' file created and ' . basename($runwayCredsFile) . ' added to it.', true);
|
||||
} else {
|
||||
// check if the creds file is already in the .gitignore file
|
||||
$gitignoreContents = file_get_contents($gitignoreFile);
|
||||
if (strpos($gitignoreContents, basename($runwayCredsFile)) === false) {
|
||||
// add the creds file to the .gitignore file
|
||||
file_put_contents($gitignoreFile, "\n" . basename($runwayCredsFile) . "\n", FILE_APPEND);
|
||||
$io->info(basename($runwayCredsFile) . ' added to ' . basename($gitignoreFile) . ' file.', true);
|
||||
} else {
|
||||
$io->info(basename($runwayCredsFile) . ' is already in the ' . basename($gitignoreFile) . ' file.', true);
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace tests\commands;
|
||||
|
||||
use Ahc\Cli\Application;
|
||||
use Ahc\Cli\IO\Interactor;
|
||||
use flight\commands\AiGenerateInstructionsCommand;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class AiGenerateInstructionsCommandTest extends TestCase
|
||||
{
|
||||
protected static $in;
|
||||
protected static $ou;
|
||||
protected $baseDir;
|
||||
protected $runwayCredsFile;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
self::$in = __DIR__ . DIRECTORY_SEPARATOR . 'input.test' . uniqid('', true) . '.txt';
|
||||
self::$ou = __DIR__ . DIRECTORY_SEPARATOR . 'output.test' . uniqid('', true) . '.txt';
|
||||
file_put_contents(self::$in, '');
|
||||
file_put_contents(self::$ou, '');
|
||||
$this->baseDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'flightphp-test-basedir-' . uniqid('', true) . DIRECTORY_SEPARATOR;
|
||||
if (!is_dir($this->baseDir)) {
|
||||
mkdir($this->baseDir, 0777, true);
|
||||
}
|
||||
$this->runwayCredsFile = $this->baseDir . 'dummy-creds.json';
|
||||
if (file_exists($this->runwayCredsFile)) {
|
||||
unlink($this->runwayCredsFile);
|
||||
}
|
||||
@unlink($this->baseDir . '.github/copilot-instructions.md');
|
||||
@unlink($this->baseDir . '.cursor/rules/project-overview.mdc');
|
||||
@unlink($this->baseDir . '.windsurfrules');
|
||||
@rmdir($this->baseDir . '.github');
|
||||
@rmdir($this->baseDir . '.cursor/rules');
|
||||
@rmdir($this->baseDir . '.cursor');
|
||||
}
|
||||
|
||||
public function tearDown(): void
|
||||
{
|
||||
if (file_exists(self::$in)) {
|
||||
unlink(self::$in);
|
||||
}
|
||||
if (file_exists(self::$ou)) {
|
||||
unlink(self::$ou);
|
||||
}
|
||||
if (file_exists($this->runwayCredsFile)) {
|
||||
unlink($this->runwayCredsFile);
|
||||
}
|
||||
@unlink($this->baseDir . '.github/copilot-instructions.md');
|
||||
@unlink($this->baseDir . '.cursor/rules/project-overview.mdc');
|
||||
@unlink($this->baseDir . '.windsurfrules');
|
||||
@rmdir($this->baseDir . '.github');
|
||||
@rmdir($this->baseDir . '.cursor/rules');
|
||||
@rmdir($this->baseDir . '.cursor');
|
||||
if (is_dir($this->baseDir . '.cursor/rules')) {
|
||||
@rmdir($this->baseDir . '.cursor/rules');
|
||||
}
|
||||
if (is_dir($this->baseDir . '.cursor')) {
|
||||
@rmdir($this->baseDir . '.cursor');
|
||||
}
|
||||
if (is_dir($this->baseDir . '.github')) {
|
||||
@rmdir($this->baseDir . '.github');
|
||||
}
|
||||
if (is_dir($this->baseDir)) {
|
||||
@rmdir($this->baseDir);
|
||||
}
|
||||
}
|
||||
|
||||
protected function newApp($command): Application
|
||||
{
|
||||
$app = new Application('test', '0.0.1', function ($exitCode) {
|
||||
return $exitCode;
|
||||
});
|
||||
$app->io(new Interactor(self::$in, self::$ou));
|
||||
$app->add($command);
|
||||
return $app;
|
||||
}
|
||||
|
||||
protected function setInput(array $lines): void
|
||||
{
|
||||
file_put_contents(self::$in, implode("\n", $lines) . "\n");
|
||||
}
|
||||
|
||||
public function testFailsIfCredsFileMissing()
|
||||
{
|
||||
$this->setInput([
|
||||
'desc', 'none', 'latte', 'y', 'y', 'none', 'Docker', '1', 'n', 'no'
|
||||
]);
|
||||
$cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class)
|
||||
->onlyMethods(['callLlmApi'])
|
||||
->getMock();
|
||||
$app = $this->newApp($cmd);
|
||||
$result = $app->handle([
|
||||
'runway', 'ai:generate-instructions',
|
||||
'--creds-file=' . $this->runwayCredsFile,
|
||||
'--base-dir=' . $this->baseDir
|
||||
]);
|
||||
$this->assertSame(1, $result);
|
||||
$this->assertFileDoesNotExist($this->baseDir . '.github/copilot-instructions.md');
|
||||
}
|
||||
|
||||
public function testWritesInstructionsToFiles()
|
||||
{
|
||||
$creds = [
|
||||
'api_key' => 'key',
|
||||
'model' => 'gpt-4o',
|
||||
'base_url' => 'https://api.openai.com',
|
||||
];
|
||||
file_put_contents($this->runwayCredsFile, json_encode($creds));
|
||||
$this->setInput([
|
||||
'desc', 'mysql', 'latte', 'y', 'y', 'flight/lib', 'Docker', '2', 'y', 'context info'
|
||||
]);
|
||||
$mockInstructions = "# Project Instructions\n\nUse MySQL, Latte, Docker.";
|
||||
$cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class)
|
||||
->onlyMethods(['callLlmApi'])
|
||||
->getMock();
|
||||
$cmd->expects($this->once())
|
||||
->method('callLlmApi')
|
||||
->willReturn(json_encode([
|
||||
'choices' => [
|
||||
['message' => ['content' => $mockInstructions]]
|
||||
]
|
||||
]));
|
||||
$app = $this->newApp($cmd);
|
||||
$result = $app->handle([
|
||||
'runway', 'ai:generate-instructions',
|
||||
'--creds-file=' . $this->runwayCredsFile,
|
||||
'--base-dir=' . $this->baseDir
|
||||
]);
|
||||
$this->assertSame(0, $result);
|
||||
$this->assertFileExists($this->baseDir . '.github/copilot-instructions.md');
|
||||
$this->assertFileExists($this->baseDir . '.cursor/rules/project-overview.mdc');
|
||||
$this->assertFileExists($this->baseDir . '.windsurfrules');
|
||||
$this->assertStringContainsString('MySQL', file_get_contents($this->baseDir . '.github/copilot-instructions.md'));
|
||||
$this->assertStringContainsString('MySQL', file_get_contents($this->baseDir . '.cursor/rules/project-overview.mdc'));
|
||||
$this->assertStringContainsString('MySQL', file_get_contents($this->baseDir . '.windsurfrules'));
|
||||
}
|
||||
|
||||
public function testNoInstructionsReturnedFromLlm()
|
||||
{
|
||||
$creds = [
|
||||
'api_key' => 'key',
|
||||
'model' => 'gpt-4o',
|
||||
'base_url' => 'https://api.openai.com',
|
||||
];
|
||||
file_put_contents($this->runwayCredsFile, json_encode($creds));
|
||||
$this->setInput([
|
||||
'desc', 'mysql', 'latte', 'y', 'y', 'flight/lib', 'Docker', '2', 'y', 'context info'
|
||||
]);
|
||||
$cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class)
|
||||
->onlyMethods(['callLlmApi'])
|
||||
->getMock();
|
||||
$cmd->expects($this->once())
|
||||
->method('callLlmApi')
|
||||
->willReturn(json_encode([
|
||||
'choices' => [
|
||||
['message' => ['content' => '']]
|
||||
]
|
||||
]));
|
||||
$app = $this->newApp($cmd);
|
||||
$result = $app->handle([
|
||||
'runway', 'ai:generate-instructions',
|
||||
'--creds-file=' . $this->runwayCredsFile,
|
||||
'--base-dir=' . $this->baseDir
|
||||
]);
|
||||
$this->assertSame(1, $result);
|
||||
$this->assertFileDoesNotExist($this->baseDir . '.github/copilot-instructions.md');
|
||||
}
|
||||
|
||||
public function testLlmApiCallFails()
|
||||
{
|
||||
$creds = [
|
||||
'api_key' => 'key',
|
||||
'model' => 'gpt-4o',
|
||||
'base_url' => 'https://api.openai.com',
|
||||
];
|
||||
file_put_contents($this->runwayCredsFile, json_encode($creds));
|
||||
$this->setInput([
|
||||
'desc', 'mysql', 'latte', 'y', 'y', 'flight/lib', 'Docker', '2', 'y', 'context info'
|
||||
]);
|
||||
$cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class)
|
||||
->onlyMethods(['callLlmApi'])
|
||||
->getMock();
|
||||
$cmd->expects($this->once())
|
||||
->method('callLlmApi')
|
||||
->willReturn(false);
|
||||
$app = $this->newApp($cmd);
|
||||
$result = $app->handle([
|
||||
'runway', 'ai:generate-instructions',
|
||||
'--creds-file=' . $this->runwayCredsFile,
|
||||
'--base-dir=' . $this->baseDir
|
||||
]);
|
||||
$this->assertSame(1, $result);
|
||||
$this->assertFileDoesNotExist($this->baseDir . '.github/copilot-instructions.md');
|
||||
}
|
||||
}
|
@ -0,0 +1,266 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace tests\commands;
|
||||
|
||||
use Ahc\Cli\Application;
|
||||
use Ahc\Cli\IO\Interactor;
|
||||
use flight\commands\AiInitCommand;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class AiInitCommandTest extends TestCase
|
||||
{
|
||||
protected static $in;
|
||||
protected static $ou;
|
||||
protected $baseDir;
|
||||
protected $runwayCredsFile;
|
||||
protected $gitignoreFile;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
self::$in = __DIR__ . DIRECTORY_SEPARATOR . 'input.test' . uniqid('', true) . '.txt';
|
||||
self::$ou = __DIR__ . DIRECTORY_SEPARATOR . 'output.test' . uniqid('', true) . '.txt';
|
||||
file_put_contents(self::$in, '');
|
||||
file_put_contents(self::$ou, '');
|
||||
$this->baseDir = getcwd() . DIRECTORY_SEPARATOR;
|
||||
$this->runwayCredsFile = __DIR__ . DIRECTORY_SEPARATOR . 'dummy-creds-' . uniqid('', true) . '.json';
|
||||
$this->gitignoreFile = __DIR__ . DIRECTORY_SEPARATOR . 'dummy-gitignore-' . uniqid('', true);
|
||||
if (file_exists($this->runwayCredsFile)) {
|
||||
unlink($this->runwayCredsFile);
|
||||
}
|
||||
if (file_exists($this->gitignoreFile)) {
|
||||
unlink($this->gitignoreFile);
|
||||
}
|
||||
}
|
||||
|
||||
public function tearDown(): void
|
||||
{
|
||||
if (file_exists(self::$in)) {
|
||||
unlink(self::$in);
|
||||
}
|
||||
if (file_exists(self::$ou)) {
|
||||
unlink(self::$ou);
|
||||
}
|
||||
if (file_exists($this->runwayCredsFile)) {
|
||||
if (is_dir($this->runwayCredsFile)) {
|
||||
rmdir($this->runwayCredsFile);
|
||||
} else {
|
||||
unlink($this->runwayCredsFile);
|
||||
}
|
||||
}
|
||||
if (file_exists($this->gitignoreFile)) {
|
||||
unlink($this->gitignoreFile);
|
||||
}
|
||||
}
|
||||
|
||||
protected function newApp(): Application
|
||||
{
|
||||
$app = new Application('test', '0.0.1', function ($exitCode) {
|
||||
return $exitCode;
|
||||
});
|
||||
$app->io(new Interactor(self::$in, self::$ou));
|
||||
return $app;
|
||||
}
|
||||
|
||||
protected function setInput(array $lines): void
|
||||
{
|
||||
file_put_contents(self::$in, implode("\n", $lines) . "\n");
|
||||
}
|
||||
|
||||
public function testInitCreatesCredsAndGitignore()
|
||||
{
|
||||
$this->setInput([
|
||||
'1', // provider
|
||||
'', // accept default base url
|
||||
'test-key', // api key
|
||||
'', // accept default model
|
||||
]);
|
||||
$app = $this->newApp();
|
||||
$app->add(new AiInitCommand());
|
||||
$result = $app->handle([
|
||||
'runway', 'ai:init',
|
||||
'--creds-file=' . $this->runwayCredsFile,
|
||||
'--gitignore-file=' . $this->gitignoreFile
|
||||
]);
|
||||
$this->assertSame(0, $result);
|
||||
$this->assertFileExists($this->runwayCredsFile);
|
||||
$creds = json_decode(file_get_contents($this->runwayCredsFile), true);
|
||||
$this->assertSame('openai', $creds['provider']);
|
||||
$this->assertSame('test-key', $creds['api_key']);
|
||||
$this->assertSame('gpt-4o', $creds['model']);
|
||||
$this->assertSame('https://api.openai.com', $creds['base_url']);
|
||||
$this->assertFileExists($this->gitignoreFile);
|
||||
$this->assertStringContainsString(basename($this->runwayCredsFile), file_get_contents($this->gitignoreFile));
|
||||
}
|
||||
|
||||
public function testInitWithExistingCredsNoOverwrite()
|
||||
{
|
||||
file_put_contents($this->runwayCredsFile, '{}');
|
||||
$this->setInput([
|
||||
'n', // do not overwrite
|
||||
]);
|
||||
$app = $this->newApp();
|
||||
$app->add(new AiInitCommand());
|
||||
$result = $app->handle([
|
||||
'runway', 'ai:init',
|
||||
'--creds-file=' . $this->runwayCredsFile,
|
||||
'--gitignore-file=' . $this->gitignoreFile
|
||||
]);
|
||||
$this->assertSame(0, $result);
|
||||
$this->assertSame('{}', file_get_contents($this->runwayCredsFile));
|
||||
}
|
||||
|
||||
public function testInitWithExistingCredsOverwrite()
|
||||
{
|
||||
file_put_contents($this->runwayCredsFile, '{}');
|
||||
$this->setInput([
|
||||
'y', // overwrite
|
||||
'2', // provider
|
||||
'', // accept default base url
|
||||
'grok-key', // api key
|
||||
'', // accept default model
|
||||
]);
|
||||
$app = $this->newApp();
|
||||
$app->add(new AiInitCommand());
|
||||
$result = $app->handle([
|
||||
'runway', 'ai:init',
|
||||
'--creds-file=' . $this->runwayCredsFile,
|
||||
'--gitignore-file=' . $this->gitignoreFile
|
||||
]);
|
||||
$this->assertSame(0, $result);
|
||||
$creds = json_decode(file_get_contents($this->runwayCredsFile), true);
|
||||
$this->assertSame('grok', $creds['provider']);
|
||||
$this->assertSame('grok-key', $creds['api_key']);
|
||||
$this->assertSame('grok-3-beta', $creds['model']);
|
||||
$this->assertSame('https://api.x.ai', $creds['base_url']);
|
||||
}
|
||||
|
||||
public function testEmptyApiKeyPromptsAgain()
|
||||
{
|
||||
$this->setInput([
|
||||
'1',
|
||||
'', // accept default base url
|
||||
'', // empty api key, should error and exit
|
||||
]);
|
||||
$app = $this->newApp();
|
||||
$app->add(new AiInitCommand());
|
||||
$result = $app->handle([
|
||||
'runway', 'ai:init',
|
||||
'--creds-file=' . $this->runwayCredsFile,
|
||||
'--gitignore-file=' . $this->gitignoreFile
|
||||
]);
|
||||
$this->assertSame(1, $result);
|
||||
$this->assertFileDoesNotExist($this->runwayCredsFile);
|
||||
}
|
||||
|
||||
public function testEmptyModelPrompts()
|
||||
{
|
||||
$this->setInput([
|
||||
'1',
|
||||
'',
|
||||
'key',
|
||||
'', // accept default model (should use default)
|
||||
]);
|
||||
$app = $this->newApp();
|
||||
$app->add(new AiInitCommand());
|
||||
$result = $app->handle([
|
||||
'runway', 'ai:init',
|
||||
'--creds-file=' . $this->runwayCredsFile,
|
||||
'--gitignore-file=' . $this->gitignoreFile
|
||||
]);
|
||||
$this->assertSame(0, $result);
|
||||
$creds = json_decode(file_get_contents($this->runwayCredsFile), true);
|
||||
$this->assertSame('gpt-4o', $creds['model']);
|
||||
}
|
||||
|
||||
public function testGitignoreAlreadyHasCreds()
|
||||
{
|
||||
file_put_contents($this->gitignoreFile, basename($this->runwayCredsFile) . "\n");
|
||||
$this->setInput([
|
||||
'1',
|
||||
'',
|
||||
'key',
|
||||
'',
|
||||
]);
|
||||
$app = $this->newApp();
|
||||
$app->add(new AiInitCommand());
|
||||
$result = $app->handle([
|
||||
'runway', 'ai:init',
|
||||
'--creds-file=' . $this->runwayCredsFile,
|
||||
'--gitignore-file=' . $this->gitignoreFile
|
||||
]);
|
||||
$this->assertSame(0, $result);
|
||||
$this->assertFileExists($this->gitignoreFile);
|
||||
$lines = file($this->gitignoreFile, FILE_IGNORE_NEW_LINES);
|
||||
$this->assertContains(basename($this->runwayCredsFile), $lines);
|
||||
$this->assertCount(1, array_filter($lines, function ($l) {
|
||||
return trim($l) === basename($this->runwayCredsFile);
|
||||
}));
|
||||
}
|
||||
|
||||
public function testInitWithClaudeProvider()
|
||||
{
|
||||
$this->setInput([
|
||||
'3', // provider (claude)
|
||||
'', // accept default base url
|
||||
'claude-key', // api key
|
||||
'', // accept default model
|
||||
]);
|
||||
$app = $this->newApp();
|
||||
$app->add(new AiInitCommand());
|
||||
$result = $app->handle([
|
||||
'runway', 'ai:init',
|
||||
'--creds-file=' . $this->runwayCredsFile,
|
||||
'--gitignore-file=' . $this->gitignoreFile
|
||||
]);
|
||||
$this->assertSame(0, $result);
|
||||
$creds = json_decode(file_get_contents($this->runwayCredsFile), true);
|
||||
$this->assertSame('claude', $creds['provider']);
|
||||
$this->assertSame('claude-key', $creds['api_key']);
|
||||
$this->assertSame('claude-3-opus', $creds['model']);
|
||||
$this->assertSame('https://api.anthropic.com', $creds['base_url']);
|
||||
}
|
||||
|
||||
public function testAddsCredsFileToExistingGitignoreIfMissing()
|
||||
{
|
||||
// .gitignore exists but does not contain creds file
|
||||
file_put_contents($this->gitignoreFile, "vendor\nnode_modules\n.DS_Store\n");
|
||||
$this->setInput([
|
||||
'1', // provider
|
||||
'', // accept default base url
|
||||
'test-key', // api key
|
||||
'', // accept default model
|
||||
]);
|
||||
$app = $this->newApp();
|
||||
$app->add(new AiInitCommand());
|
||||
$result = $app->handle([
|
||||
'runway', 'ai:init',
|
||||
'--creds-file=' . $this->runwayCredsFile,
|
||||
'--gitignore-file=' . $this->gitignoreFile
|
||||
]);
|
||||
$this->assertSame(0, $result);
|
||||
$lines = file($this->gitignoreFile, FILE_IGNORE_NEW_LINES);
|
||||
$this->assertContains(basename($this->runwayCredsFile), $lines);
|
||||
$this->assertCount(1, array_filter($lines, function ($l) {
|
||||
return trim($l) === basename($this->runwayCredsFile);
|
||||
}));
|
||||
}
|
||||
|
||||
public function testInvalidBaseUrlFails()
|
||||
{
|
||||
$this->setInput([
|
||||
'1', // provider
|
||||
'not-a-valid-url', // invalid base url
|
||||
]);
|
||||
$app = $this->newApp();
|
||||
$app->add(new AiInitCommand());
|
||||
$result = $app->handle([
|
||||
'runway', 'ai:init',
|
||||
'--creds-file=' . $this->runwayCredsFile,
|
||||
'--gitignore-file=' . $this->gitignoreFile
|
||||
]);
|
||||
$this->assertSame(1, $result);
|
||||
$this->assertFileDoesNotExist($this->runwayCredsFile);
|
||||
}
|
||||
}
|
Loading…
Reference in new issue