Merge pull request #671 from flightphp/update-ai-commands

Fix AI commands to use new runway config syntax
pull/673/head v3.17.3
n0nag0n 13 hours ago committed by GitHub
commit f6baf2a8d2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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,6 +62,7 @@
}, },
"scripts": { "scripts": {
"test": "phpunit", "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/",

@ -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,
@ -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,19 +5,16 @@ 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 {
{
self::$in = __DIR__ . DIRECTORY_SEPARATOR . 'input.test' . uniqid('', true) . '.txt'; self::$in = __DIR__ . DIRECTORY_SEPARATOR . 'input.test' . uniqid('', true) . '.txt';
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, '');
@ -26,97 +23,118 @@ 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 {
{
if (file_exists(self::$in)) { if (file_exists(self::$in)) {
unlink(self::$in); unlink(self::$in);
} }
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');
@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'); protected function recursiveRmdir($dir) {
if (!is_dir($dir)) {
return;
} }
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 {
{
$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;
} }
protected function setInput(array $lines): void protected function setInput(array $lines): void {
{
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) {
// only setAccessible if php 8 or php 7.4
if (PHP_VERSION_ID < 80100) {
$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() {
{
$creds = [ $creds = [
'api_key' => 'key', 'api_key' => 'key',
'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,33 +144,43 @@ 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() {
{
$creds = [ $creds = [
'api_key' => 'key', 'api_key' => 'key',
'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,38 +190,94 @@ 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() {
{
$creds = [ $creds = [
'api_key' => 'key', 'api_key' => 'key',
'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', [
'provider' => 'openai',
'api_key' => 'test-key',
'model' => 'gpt-5',
'base_url' => 'https://api.openai.com',
]);
$app = $this->newApp($cmd);
$result = $app->handle(['runway', 'ai:init']);
$this->assertSame(0, $result); $this->assertSame(0, $result);
$this->assertFileExists($this->runwayCredsFile); $this->assertStringContainsString('Credentials saved', file_get_contents(self::$ou));
$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() public function testInitWithGrokProvider()
{ {
file_put_contents($this->runwayCredsFile, '{}');
$this->setInput([ $this->setInput([
'n', // do not overwrite '2', // provider (grok)
]);
$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 '', // 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', [
$this->assertSame(0, $result); 'provider' => 'grok',
$creds = json_decode(file_get_contents($this->runwayCredsFile), true); 'api_key' => 'grok-key',
$this->assertSame('grok', $creds['provider']); 'model' => 'grok-4.1-fast-non-reasoning',
$this->assertSame('grok-key', $creds['api_key']); 'base_url' => 'https://api.x.ai',
$this->assertSame('grok-3-beta', $creds['model']); ]);
$this->assertSame('https://api.x.ai', $creds['base_url']); $app = $this->newApp($cmd);
} $result = $app->handle(['runway', 'ai:init']);
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); $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