Fix AI commands to use new runway config syntax

pull/671/head
n0nag0n 13 hours ago
parent 095a46663f
commit 009f2f9bad

@ -44,7 +44,7 @@
"require-dev": { "require-dev": {
"ext-pdo_sqlite": "*", "ext-pdo_sqlite": "*",
"flightphp/container": "^1.0", "flightphp/container": "^1.0",
"flightphp/runway": "^0.2.3 || ^1.0", "flightphp/runway": "^1.2",
"league/container": "^4.2", "league/container": "^4.2",
"level-2/dice": "^4.0", "level-2/dice": "^4.0",
"phpstan/extension-installer": "^1.4", "phpstan/extension-installer": "^1.4",
@ -62,21 +62,22 @@
"sort-packages": true "sort-packages": true
}, },
"scripts": { "scripts": {
"test": "vendor/bin/phpunit-watcher watch", "test": "phpunit",
"test-watcher": "phpunit-watcher watch",
"test-ci": "phpunit", "test-ci": "phpunit",
"test-coverage": "rm -f clover.xml && XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html=coverage --coverage-clover=clover.xml && vendor/bin/coverage-check clover.xml 100", "test-coverage": "rm -f clover.xml && XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html=coverage --coverage-clover=clover.xml && vendor/bin/coverage-check clover.xml 100",
"test-server": "echo \"Running Test Server\" && php -S localhost:8000 -t tests/server/", "test-server": "echo \"Running Test Server\" && php -S localhost:8000 -t tests/server/",
"test-server-v2": "echo \"Running Test Server\" && php -S localhost:8000 -t tests/server-v2/", "test-server-v2": "echo \"Running Test Server\" && php -S localhost:8000 -t tests/server-v2/",
"test-coverage:win": "del clover.xml && phpunit --coverage-html=coverage --coverage-clover=clover.xml && coverage-check clover.xml 100", "test-coverage:win": "del clover.xml && phpunit --coverage-html=coverage --coverage-clover=clover.xml && coverage-check clover.xml 100",
"test-performance": [ "test-performance": [
"echo \"Running Performance Tests...\"", "echo \"Running Performance Tests...\"",
"php -S localhost:8077 -t tests/performance/ > /dev/null 2>&1 & echo $! > server.pid", "php -S localhost:8077 -t tests/performance/ > /dev/null 2>&1 & echo $! > server.pid",
"sleep 2", "sleep 2",
"bash tests/performance/performance_tests.sh", "bash tests/performance/performance_tests.sh",
"kill `cat server.pid`", "kill `cat server.pid`",
"rm server.pid", "rm server.pid",
"echo \"Performance Tests Completed.\"" "echo \"Performance Tests Completed.\""
], ],
"lint": "phpstan --no-progress --memory-limit=256M -cphpstan.neon", "lint": "phpstan --no-progress --memory-limit=256M -cphpstan.neon",
"beautify": "phpcbf --standard=phpcs.xml", "beautify": "phpcbf --standard=phpcs.xml",
"phpcs": "phpcs --standard=phpcs.xml -n", "phpcs": "phpcs --standard=phpcs.xml -n",
@ -92,4 +93,4 @@
"replace": { "replace": {
"mikecao/flight": "2.0.2" "mikecao/flight": "2.0.2"
} }
} }

@ -621,7 +621,7 @@ class Engine
$response->write(ob_get_clean()); $response->write(ob_get_clean());
} }
// Run any before middlewares // Run any after middlewares
if (count($route->middleware) > 0) { if (count($route->middleware) > 0) {
// process the middleware in reverse order now // process the middleware in reverse order now
$atLeastOneMiddlewareFailed = $this->processMiddleware($route, 'after'); $atLeastOneMiddlewareFailed = $this->processMiddleware($route, 'after');

@ -4,24 +4,23 @@ declare(strict_types=1);
namespace flight\commands; namespace flight\commands;
use Ahc\Cli\Input\Command;
/** /**
* @property-read ?string $credsFile * @property-read ?string $configFile
* @property-read ?string $baseDir * @property-read ?string $baseDir
*/ */
class AiGenerateInstructionsCommand extends Command class AiGenerateInstructionsCommand extends AbstractBaseCommand
{ {
/** /**
* Constructor for the AiGenerateInstructionsCommand class. * Constructor for the AiGenerateInstructionsCommand class.
* *
* Initializes a new instance of the command. * Initializes a new instance of the command.
*
* @param array<string,mixed> $config Config from config.php
*/ */
public function __construct() public function __construct(array $config)
{ {
parent::__construct('ai:generate-instructions', 'Generate project-specific AI coding instructions'); parent::__construct('ai:generate-instructions', 'Generate project-specific AI coding instructions', $config);
$this->option('--creds-file', 'Path to .runway-creds.json file', null, ''); $this->option('--config-file', 'Path to .runway-config.json file (deprecated, use config.php instead)', null, '');
$this->option('--base-dir', 'Project base directory (for testing or custom use)', null, '');
} }
/** /**
@ -37,12 +36,19 @@ class AiGenerateInstructionsCommand extends Command
public function execute() public function execute()
{ {
$io = $this->app()->io(); $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 (empty($this->config['runway'])) {
if (!file_exists($runwayCredsFile)) { $configFile = $this->configFile;
$io->error('Missing .runway-creds.json. Please run the \'ai:init\' command first.', true); $io = $this->app()->io();
$io->warn('The --config-file option is deprecated. Move your config values to the \'runway\' key in the config.php file for configuration.', true);
$runwayConfig = json_decode(file_get_contents($configFile), true) ?? [];
} else {
$runwayConfig = $this->config['runway'];
}
// Check for runway creds ai
if (empty($runwayConfig['ai'])) {
$io->error('Missing AI configuration. Please run the \'ai:init\' command first.', true);
return 1; return 1;
} }
@ -61,8 +67,8 @@ class AiGenerateInstructionsCommand extends Command
$other = $io->prompt('Any other important requirements or context? (optional)', 'no'); $other = $io->prompt('Any other important requirements or context? (optional)', 'no');
// Prepare prompt for LLM // Prepare prompt for LLM
$contextFile = $baseDir . '.github/copilot-instructions.md'; $contextFile = $this->projectRoot . '.github/copilot-instructions.md';
$context = file_exists($contextFile) ? file_get_contents($contextFile) : ''; $context = file_exists($contextFile) === true ? file_get_contents($contextFile) : '';
$userDetails = [ $userDetails = [
'Project Description' => $projectDesc, 'Project Description' => $projectDesc,
'Database' => $database, 'Database' => $database,
@ -80,7 +86,7 @@ class AiGenerateInstructionsCommand extends Command
$detailsText .= "$k: $v\n"; $detailsText .= "$k: $v\n";
} }
$prompt = <<<EOT $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. You are an AI coding assistant. Update the following project instructions for this Flight PHP project based on the latest user answers. Only output the new instructions, no extra commentary.
User answers: User answers:
$detailsText $detailsText
Current instructions: Current instructions:
@ -88,10 +94,10 @@ class AiGenerateInstructionsCommand extends Command
EOT; // phpcs:ignore EOT; // phpcs:ignore
// Read LLM creds // Read LLM creds
$creds = json_decode(file_get_contents($runwayCredsFile), true); $creds = $runwayConfig['ai'];
$apiKey = $creds['api_key'] ?? ''; $apiKey = $creds['api_key'];
$model = $creds['model'] ?? 'gpt-4o'; $model = $creds['model'];
$baseUrl = $creds['base_url'] ?? 'https://api.openai.com'; $baseUrl = $creds['base_url'];
// Prepare curl call (OpenAI compatible) // Prepare curl call (OpenAI compatible)
$headers = [ $headers = [
@ -123,16 +129,20 @@ class AiGenerateInstructionsCommand extends Command
} }
// Write to files // Write to files
$io->info('Updating .github/copilot-instructions.md, .cursor/rules/project-overview.mdc, and .windsurfrules...', true); $io->info('Updating .github/copilot-instructions.md, .cursor/rules/project-overview.mdc, .gemini/GEMINI.md and .windsurfrules...', true);
if (!is_dir($baseDir . '.github')) { if (!is_dir($this->projectRoot . '.github')) {
mkdir($baseDir . '.github', 0755, true); mkdir($this->projectRoot . '.github', 0755, true);
}
if (!is_dir($this->projectRoot . '.cursor/rules')) {
mkdir($this->projectRoot . '.cursor/rules', 0755, true);
} }
if (!is_dir($baseDir . '.cursor/rules')) { if (!is_dir($this->projectRoot . '.gemini')) {
mkdir($baseDir . '.cursor/rules', 0755, true); mkdir($this->projectRoot . '.gemini', 0755, true);
} }
file_put_contents($baseDir . '.github/copilot-instructions.md', $instructions); file_put_contents($this->projectRoot . '.github/copilot-instructions.md', $instructions);
file_put_contents($baseDir . '.cursor/rules/project-overview.mdc', $instructions); file_put_contents($this->projectRoot . '.cursor/rules/project-overview.mdc', $instructions);
file_put_contents($baseDir . '.windsurfrules', $instructions); file_put_contents($this->projectRoot . '.gemini/GEMINI.md', $instructions);
file_put_contents($this->projectRoot . '.windsurfrules', $instructions);
$io->ok('AI instructions updated successfully.', true); $io->ok('AI instructions updated successfully.', true);
return 0; return 0;
} }

@ -4,25 +4,22 @@ declare(strict_types=1);
namespace flight\commands; namespace flight\commands;
use Ahc\Cli\Input\Command;
/** /**
* @property-read ?string $gitignoreFile * @property-read ?string $credsFile Deprecated, use config.php instead
* @property-read ?string $credsFile
*/ */
class AiInitCommand extends Command class AiInitCommand extends AbstractBaseCommand
{ {
/** /**
* Constructor for the AiInitCommand class. * Constructor for the AiInitCommand class.
* *
* Initializes the command instance and sets up any required dependencies. * Initializes the command instance and sets up any required dependencies.
*
* @param array<string,mixed> $config Config from config.php
*/ */
public function __construct() public function __construct(array $config)
{ {
parent::__construct('ai:init', 'Initialize LLM API credentials and settings'); parent::__construct('ai:init', 'Initialize LLM API credentials and settings', $config);
$this $this->option('--creds-file', 'Path to .runway-creds.json file (deprecated, use config.php instead)', null, '');
->option('--gitignore-file', 'Path to .gitignore file', null, '')
->option('--creds-file', 'Path to .runway-creds.json file', null, '');
} }
/** /**
@ -36,21 +33,6 @@ class AiInitCommand extends Command
$io->info('Welcome to AI Init!', true); $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 // Prompt for API provider with validation
$allowedApis = [ $allowedApis = [
'1' => 'openai', '1' => 'openai',
@ -88,50 +70,26 @@ class AiInitCommand extends Command
// Validate model input // Validate model input
switch ($api) { switch ($api) {
case 'openai': case 'openai':
$defaultModel = 'gpt-4o'; $defaultModel = 'gpt-5';
break; break;
case 'grok': case 'grok':
$defaultModel = 'grok-3-beta'; $defaultModel = 'grok-4.1-fast-non-reasoning';
break; break;
case 'claude': case 'claude':
$defaultModel = 'claude-3-opus'; $defaultModel = 'claude-sonnet-4-5';
break; break;
} }
$model = trim($io->prompt('Enter the model name you want to use (e.g. gpt-4, claude-3-opus, etc)', $defaultModel)); $model = trim($io->prompt('Enter the model name you want to use (e.g. gpt-5, claude-sonnet-4-5, etc)', $defaultModel));
$creds = [ $runwayAiConfig = [
'provider' => $api, 'provider' => $api,
'api_key' => $apiKey, 'api_key' => $apiKey,
'model' => $model, 'model' => $model,
'base_url' => $baseUrl, 'base_url' => $baseUrl,
]; ];
$this->setRunwayConfigValue('ai', $runwayAiConfig);
$json = json_encode($creds, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); $io->ok('Credentials saved to app/config/config.php', true);
$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; return 0;
} }

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace tests\classes;
use Ahc\Cli\IO\Interactor;
class NoExitInteractor extends Interactor
{
public function error(string $text, bool $exit = false): self
{
$this->writer()->error($text, 0);
return $this;
}
public function warn(string $text, bool $exit = false): self
{
$this->writer()->warn($text, 0);
return $this;
}
}

@ -5,16 +5,15 @@ declare(strict_types=1);
namespace tests\commands; namespace tests\commands;
use Ahc\Cli\Application; use Ahc\Cli\Application;
use Ahc\Cli\IO\Interactor;
use flight\commands\AiGenerateInstructionsCommand; use flight\commands\AiGenerateInstructionsCommand;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use tests\classes\NoExitInteractor;
class AiGenerateInstructionsCommandTest extends TestCase class AiGenerateInstructionsCommandTest extends TestCase
{ {
protected static $in; protected static $in;
protected static $ou; protected static $ou;
protected $baseDir; protected $baseDir;
protected $runwayCredsFile;
public function setUp(): void public function setUp(): void
{ {
@ -26,16 +25,6 @@ class AiGenerateInstructionsCommandTest extends TestCase
if (!is_dir($this->baseDir)) { if (!is_dir($this->baseDir)) {
mkdir($this->baseDir, 0777, true); 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 public function tearDown(): void
@ -46,27 +35,19 @@ class AiGenerateInstructionsCommandTest extends TestCase
if (file_exists(self::$ou)) { if (file_exists(self::$ou)) {
unlink(self::$ou); unlink(self::$ou);
} }
if (file_exists($this->runwayCredsFile)) { $this->recursiveRmdir($this->baseDir);
unlink($this->runwayCredsFile); }
}
@unlink($this->baseDir . '.github/copilot-instructions.md'); protected function recursiveRmdir($dir)
@unlink($this->baseDir . '.cursor/rules/project-overview.mdc'); {
@unlink($this->baseDir . '.windsurfrules'); if (!is_dir($dir)) {
@rmdir($this->baseDir . '.github'); return;
@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)) { $files = array_diff(scandir($dir), ['.', '..']);
@rmdir($this->baseDir); foreach ($files as $file) {
(is_dir("$dir/$file")) ? $this->recursiveRmdir("$dir/$file") : unlink("$dir/$file");
} }
return rmdir($dir);
} }
protected function newApp($command): Application protected function newApp($command): Application
@ -74,7 +55,7 @@ class AiGenerateInstructionsCommandTest extends TestCase
$app = new Application('test', '0.0.1', function ($exitCode) { $app = new Application('test', '0.0.1', function ($exitCode) {
return $exitCode; return $exitCode;
}); });
$app->io(new Interactor(self::$in, self::$ou)); $app->io(new NoExitInteractor(self::$in, self::$ou));
$app->add($command); $app->add($command);
return $app; return $app;
} }
@ -84,22 +65,51 @@ class AiGenerateInstructionsCommandTest extends TestCase
file_put_contents(self::$in, implode("\n", $lines) . "\n"); file_put_contents(self::$in, implode("\n", $lines) . "\n");
} }
public function testFailsIfCredsFileMissing() protected function setProjectRoot($command, $path)
{
$reflection = new \ReflectionClass(get_class($command));
$property = null;
$currentClass = $reflection;
while ($currentClass && !$property) {
try {
$property = $currentClass->getProperty('projectRoot');
} catch (\ReflectionException $e) {
$currentClass = $currentClass->getParentClass();
}
}
if ($property) {
$property->setAccessible(true);
$property->setValue($command, $path);
}
}
public function testFailsIfAiConfigMissing()
{ {
$this->setInput([ $this->setInput([
'desc', 'none', 'latte', 'y', 'y', 'none', 'Docker', '1', 'n', 'no' 'desc',
'none',
'latte',
'y',
'y',
'none',
'Docker',
'1',
'n',
'no'
]); ]);
// Provide 'runway' with dummy data to avoid deprecated configFile logic
$cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class) $cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class)
->setConstructorArgs([['runway' => ['dummy' => true]]])
->onlyMethods(['callLlmApi']) ->onlyMethods(['callLlmApi'])
->getMock(); ->getMock();
$this->setProjectRoot($cmd, $this->baseDir);
$app = $this->newApp($cmd); $app = $this->newApp($cmd);
$result = $app->handle([ $result = $app->handle([
'runway', 'ai:generate-instructions', 'runway',
'--creds-file=' . $this->runwayCredsFile, 'ai:generate-instructions',
'--base-dir=' . $this->baseDir
]); ]);
$this->assertSame(1, $result); $this->assertSame(1, $result);
$this->assertFileDoesNotExist($this->baseDir . '.github/copilot-instructions.md'); $this->assertStringContainsString('Missing AI configuration', file_get_contents(self::$ou));
} }
public function testWritesInstructionsToFiles() public function testWritesInstructionsToFiles()
@ -109,14 +119,28 @@ class AiGenerateInstructionsCommandTest extends TestCase
'model' => 'gpt-4o', 'model' => 'gpt-4o',
'base_url' => 'https://api.openai.com', 'base_url' => 'https://api.openai.com',
]; ];
file_put_contents($this->runwayCredsFile, json_encode($creds));
$this->setInput([ $this->setInput([
'desc', 'mysql', 'latte', 'y', 'y', 'flight/lib', 'Docker', '2', 'y', 'context info' 'desc',
'mysql',
'latte',
'y',
'y',
'flight/lib',
'Docker',
'2',
'y',
'context info'
]); ]);
$mockInstructions = "# Project Instructions\n\nUse MySQL, Latte, Docker."; $mockInstructions = "# Project Instructions\n\nUse MySQL, Latte, Docker.";
$cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class) $cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class)
->setConstructorArgs([
[
'runway' => ['ai' => $creds]
]
])
->onlyMethods(['callLlmApi']) ->onlyMethods(['callLlmApi'])
->getMock(); ->getMock();
$this->setProjectRoot($cmd, $this->baseDir);
$cmd->expects($this->once()) $cmd->expects($this->once())
->method('callLlmApi') ->method('callLlmApi')
->willReturn(json_encode([ ->willReturn(json_encode([
@ -126,17 +150,14 @@ class AiGenerateInstructionsCommandTest extends TestCase
])); ]));
$app = $this->newApp($cmd); $app = $this->newApp($cmd);
$result = $app->handle([ $result = $app->handle([
'runway', 'ai:generate-instructions', 'runway',
'--creds-file=' . $this->runwayCredsFile, 'ai:generate-instructions',
'--base-dir=' . $this->baseDir
]); ]);
$this->assertSame(0, $result); $this->assertSame(0, $result);
$this->assertFileExists($this->baseDir . '.github/copilot-instructions.md'); $this->assertFileExists($this->baseDir . '.github/copilot-instructions.md');
$this->assertFileExists($this->baseDir . '.cursor/rules/project-overview.mdc'); $this->assertFileExists($this->baseDir . '.cursor/rules/project-overview.mdc');
$this->assertFileExists($this->baseDir . '.gemini/GEMINI.md');
$this->assertFileExists($this->baseDir . '.windsurfrules'); $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() public function testNoInstructionsReturnedFromLlm()
@ -146,13 +167,27 @@ class AiGenerateInstructionsCommandTest extends TestCase
'model' => 'gpt-4o', 'model' => 'gpt-4o',
'base_url' => 'https://api.openai.com', 'base_url' => 'https://api.openai.com',
]; ];
file_put_contents($this->runwayCredsFile, json_encode($creds));
$this->setInput([ $this->setInput([
'desc', 'mysql', 'latte', 'y', 'y', 'flight/lib', 'Docker', '2', 'y', 'context info' 'desc',
'mysql',
'latte',
'y',
'y',
'flight/lib',
'Docker',
'2',
'y',
'context info'
]); ]);
$cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class) $cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class)
->setConstructorArgs([
[
'runway' => ['ai' => $creds]
]
])
->onlyMethods(['callLlmApi']) ->onlyMethods(['callLlmApi'])
->getMock(); ->getMock();
$this->setProjectRoot($cmd, $this->baseDir);
$cmd->expects($this->once()) $cmd->expects($this->once())
->method('callLlmApi') ->method('callLlmApi')
->willReturn(json_encode([ ->willReturn(json_encode([
@ -162,12 +197,10 @@ class AiGenerateInstructionsCommandTest extends TestCase
])); ]));
$app = $this->newApp($cmd); $app = $this->newApp($cmd);
$result = $app->handle([ $result = $app->handle([
'runway', 'ai:generate-instructions', 'runway',
'--creds-file=' . $this->runwayCredsFile, 'ai:generate-instructions',
'--base-dir=' . $this->baseDir
]); ]);
$this->assertSame(1, $result); $this->assertSame(1, $result);
$this->assertFileDoesNotExist($this->baseDir . '.github/copilot-instructions.md');
} }
public function testLlmApiCallFails() public function testLlmApiCallFails()
@ -177,23 +210,83 @@ class AiGenerateInstructionsCommandTest extends TestCase
'model' => 'gpt-4o', 'model' => 'gpt-4o',
'base_url' => 'https://api.openai.com', 'base_url' => 'https://api.openai.com',
]; ];
file_put_contents($this->runwayCredsFile, json_encode($creds));
$this->setInput([ $this->setInput([
'desc', 'mysql', 'latte', 'y', 'y', 'flight/lib', 'Docker', '2', 'y', 'context info' 'desc',
'mysql',
'latte',
'y',
'y',
'flight/lib',
'Docker',
'2',
'y',
'context info'
]); ]);
$cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class) $cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class)
->setConstructorArgs([
[
'runway' => ['ai' => $creds]
]
])
->onlyMethods(['callLlmApi']) ->onlyMethods(['callLlmApi'])
->getMock(); ->getMock();
$this->setProjectRoot($cmd, $this->baseDir);
$cmd->expects($this->once()) $cmd->expects($this->once())
->method('callLlmApi') ->method('callLlmApi')
->willReturn(false); ->willReturn(false);
$app = $this->newApp($cmd); $app = $this->newApp($cmd);
$result = $app->handle([ $result = $app->handle([
'runway', 'ai:generate-instructions', 'runway',
'--creds-file=' . $this->runwayCredsFile, 'ai:generate-instructions',
'--base-dir=' . $this->baseDir
]); ]);
$this->assertSame(1, $result); $this->assertSame(1, $result);
$this->assertFileDoesNotExist($this->baseDir . '.github/copilot-instructions.md'); }
public function testUsesDeprecatedConfigFile()
{
$creds = [
'ai' => [
'api_key' => 'key',
'model' => 'gpt-4o',
'base_url' => 'https://api.openai.com',
]
];
$configFile = $this->baseDir . 'old-config.json';
file_put_contents($configFile, 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.";
// runway key is MISSING from config to trigger deprecated logic
$cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class)
->setConstructorArgs([[]])
->onlyMethods(['callLlmApi'])
->getMock();
$this->setProjectRoot($cmd, $this->baseDir);
$cmd->expects($this->once())
->method('callLlmApi')
->willReturn(json_encode([
'choices' => [
['message' => ['content' => $mockInstructions]]
]
]));
$app = $this->newApp($cmd);
$result = $app->handle([
'runway',
'ai:generate-instructions',
'--config-file=' . $configFile
]);
$this->assertSame(0, $result);
$this->assertStringContainsString('The --config-file option is deprecated', file_get_contents(self::$ou));
$this->assertFileExists($this->baseDir . '.github/copilot-instructions.md');
} }
} }

@ -13,9 +13,6 @@ class AiInitCommandTest extends TestCase
{ {
protected static $in; protected static $in;
protected static $ou; protected static $ou;
protected $baseDir;
protected $runwayCredsFile;
protected $gitignoreFile;
public function setUp(): void public function setUp(): void
{ {
@ -23,15 +20,6 @@ class AiInitCommandTest extends TestCase
self::$ou = __DIR__ . DIRECTORY_SEPARATOR . 'output.test' . uniqid('', true) . '.txt'; self::$ou = __DIR__ . DIRECTORY_SEPARATOR . 'output.test' . uniqid('', true) . '.txt';
file_put_contents(self::$in, ''); file_put_contents(self::$in, '');
file_put_contents(self::$ou, ''); 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 public function tearDown(): void
@ -42,24 +30,15 @@ class AiInitCommandTest extends TestCase
if (file_exists(self::$ou)) { if (file_exists(self::$ou)) {
unlink(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 protected function newApp($command): Application
{ {
$app = new Application('test', '0.0.1', function ($exitCode) { $app = new Application('test', '0.0.1', function ($exitCode) {
return $exitCode; return $exitCode;
}); });
$app->io(new Interactor(self::$in, self::$ou)); $app->io(new Interactor(self::$in, self::$ou));
$app->add($command);
return $app; return $app;
} }
@ -68,135 +47,55 @@ class AiInitCommandTest extends TestCase
file_put_contents(self::$in, implode("\n", $lines) . "\n"); file_put_contents(self::$in, implode("\n", $lines) . "\n");
} }
public function testInitCreatesCredsAndGitignore() public function testInitSavesCreds()
{ {
$this->setInput([ $this->setInput([
'1', // provider '1', // provider (openai)
'', // accept default base url '', // accept default base url
'test-key', // api key 'test-key', // api key
'', // accept default model '', // accept default model
]); ]);
$app = $this->newApp(); $cmd = $this->getMockBuilder(AiInitCommand::class)
$app->add(new AiInitCommand()); ->setConstructorArgs([[]])
$result = $app->handle([ ->onlyMethods(['setRunwayConfigValue'])
'runway', 'ai:init', ->getMock();
'--creds-file=' . $this->runwayCredsFile, $cmd->expects($this->once())
'--gitignore-file=' . $this->gitignoreFile ->method('setRunwayConfigValue')
]); ->with('ai', [
$this->assertSame(0, $result); 'provider' => 'openai',
$this->assertFileExists($this->runwayCredsFile); 'api_key' => 'test-key',
$creds = json_decode(file_get_contents($this->runwayCredsFile), true); 'model' => 'gpt-5',
$this->assertSame('openai', $creds['provider']); 'base_url' => 'https://api.openai.com',
$this->assertSame('test-key', $creds['api_key']); ]);
$this->assertSame('gpt-4o', $creds['model']); $app = $this->newApp($cmd);
$this->assertSame('https://api.openai.com', $creds['base_url']); $result = $app->handle(['runway', 'ai:init']);
$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(0, $result);
$this->assertSame('{}', file_get_contents($this->runwayCredsFile)); $this->assertStringContainsString('Credentials saved', file_get_contents(self::$ou));
} }
public function testInitWithExistingCredsOverwrite() public function testInitWithGrokProvider()
{ {
file_put_contents($this->runwayCredsFile, '{}');
$this->setInput([ $this->setInput([
'y', // overwrite '2', // provider (grok)
'2', // provider
'', // accept default base url '', // accept default base url
'grok-key', // api key 'grok-key', // api key
'', // accept default model '', // accept default model
]); ]);
$app = $this->newApp(); $cmd = $this->getMockBuilder(AiInitCommand::class)
$app->add(new AiInitCommand()); ->setConstructorArgs([[]])
$result = $app->handle([ ->onlyMethods(['setRunwayConfigValue'])
'runway', 'ai:init', ->getMock();
'--creds-file=' . $this->runwayCredsFile, $cmd->expects($this->once())
'--gitignore-file=' . $this->gitignoreFile ->method('setRunwayConfigValue')
]); ->with('ai', [
'provider' => 'grok',
'api_key' => 'grok-key',
'model' => 'grok-4.1-fast-non-reasoning',
'base_url' => 'https://api.x.ai',
]);
$app = $this->newApp($cmd);
$result = $app->handle(['runway', 'ai:init']);
$this->assertSame(0, $result); $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() public function testInitWithClaudeProvider()
@ -207,44 +106,41 @@ class AiInitCommandTest extends TestCase
'claude-key', // api key 'claude-key', // api key
'', // accept default model '', // accept default model
]); ]);
$app = $this->newApp(); $cmd = $this->getMockBuilder(AiInitCommand::class)
$app->add(new AiInitCommand()); ->setConstructorArgs([[]])
$result = $app->handle([ ->onlyMethods(['setRunwayConfigValue'])
'runway', 'ai:init', ->getMock();
'--creds-file=' . $this->runwayCredsFile, $cmd->expects($this->once())
'--gitignore-file=' . $this->gitignoreFile ->method('setRunwayConfigValue')
]); ->with('ai', [
'provider' => 'claude',
'api_key' => 'claude-key',
'model' => 'claude-sonnet-4-5',
'base_url' => 'https://api.anthropic.com',
]);
$app = $this->newApp($cmd);
$result = $app->handle(['runway', 'ai:init']);
$this->assertSame(0, $result); $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() public function testEmptyApiKeyFails()
{ {
// .gitignore exists but does not contain creds file
file_put_contents($this->gitignoreFile, "vendor\nnode_modules\n.DS_Store\n");
$this->setInput([ $this->setInput([
'1', // provider '1',
'', // accept default base url '', // accept default base url
'test-key', // api key '', // empty api key
'', // accept default model ]);
]); $cmd = $this->getMockBuilder(AiInitCommand::class)
$app = $this->newApp(); ->setConstructorArgs([[]])
$app->add(new AiInitCommand()); ->onlyMethods(['setRunwayConfigValue'])
$result = $app->handle([ ->getMock();
'runway', 'ai:init', $cmd->expects($this->never())
'--creds-file=' . $this->runwayCredsFile, ->method('setRunwayConfigValue');
'--gitignore-file=' . $this->gitignoreFile $app = $this->newApp($cmd);
]); $result = $app->handle(['runway', 'ai:init']);
$this->assertSame(0, $result); // Since $io->error(..., true) exits, Ahc\Cli will return the exit code.
$lines = file($this->gitignoreFile, FILE_IGNORE_NEW_LINES); // If it exits with 1, it should be 1.
$this->assertContains(basename($this->runwayCredsFile), $lines); $this->assertSame(1, $result);
$this->assertCount(1, array_filter($lines, function ($l) {
return trim($l) === basename($this->runwayCredsFile);
}));
} }
public function testInvalidBaseUrlFails() public function testInvalidBaseUrlFails()
@ -253,14 +149,14 @@ class AiInitCommandTest extends TestCase
'1', // provider '1', // provider
'not-a-valid-url', // invalid base url 'not-a-valid-url', // invalid base url
]); ]);
$app = $this->newApp(); $cmd = $this->getMockBuilder(AiInitCommand::class)
$app->add(new AiInitCommand()); ->setConstructorArgs([[]])
$result = $app->handle([ ->onlyMethods(['setRunwayConfigValue'])
'runway', 'ai:init', ->getMock();
'--creds-file=' . $this->runwayCredsFile, $cmd->expects($this->never())
'--gitignore-file=' . $this->gitignoreFile ->method('setRunwayConfigValue');
]); $app = $this->newApp($cmd);
$result = $app->handle(['runway', 'ai:init']);
$this->assertSame(1, $result); $this->assertSame(1, $result);
$this->assertFileDoesNotExist($this->runwayCredsFile);
} }
} }

Loading…
Cancel
Save