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 12 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": {
"ext-pdo_sqlite": "*",
"flightphp/container": "^1.0",
"flightphp/runway": "^0.2.3 || ^1.0",
"flightphp/runway": "^1.2",
"league/container": "^4.2",
"level-2/dice": "^4.0",
"phpstan/extension-installer": "^1.4",
@ -62,20 +62,21 @@
},
"scripts": {
"test": "phpunit",
"test-watcher": "phpunit-watcher watch",
"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-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-coverage:win": "del clover.xml && phpunit --coverage-html=coverage --coverage-clover=clover.xml && coverage-check clover.xml 100",
"test-performance": [
"echo \"Running Performance Tests...\"",
"php -S localhost:8077 -t tests/performance/ > /dev/null 2>&1 & echo $! > server.pid",
"sleep 2",
"bash tests/performance/performance_tests.sh",
"kill `cat server.pid`",
"rm server.pid",
"echo \"Performance Tests Completed.\""
],
"test-performance": [
"echo \"Running Performance Tests...\"",
"php -S localhost:8077 -t tests/performance/ > /dev/null 2>&1 & echo $! > server.pid",
"sleep 2",
"bash tests/performance/performance_tests.sh",
"kill `cat server.pid`",
"rm server.pid",
"echo \"Performance Tests Completed.\""
],
"lint": "phpstan --no-progress --memory-limit=256M -cphpstan.neon",
"beautify": "phpcbf --standard=phpcs.xml",
"phpcs": "phpcs --standard=phpcs.xml -n",

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

@ -4,24 +4,23 @@ declare(strict_types=1);
namespace flight\commands;
use Ahc\Cli\Input\Command;
/**
* @property-read ?string $credsFile
* @property-read ?string $configFile
* @property-read ?string $baseDir
*/
class AiGenerateInstructionsCommand extends Command
class AiGenerateInstructionsCommand extends AbstractBaseCommand
{
/**
* Constructor for the AiGenerateInstructionsCommand class.
*
* 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');
$this->option('--creds-file', 'Path to .runway-creds.json file', null, '');
$this->option('--base-dir', 'Project base directory (for testing or custom use)', null, '');
parent::__construct('ai:generate-instructions', 'Generate project-specific AI coding instructions', $config);
$this->option('--config-file', 'Path to .runway-config.json file (deprecated, use config.php instead)', null, '');
}
/**
@ -37,12 +36,19 @@ class AiGenerateInstructionsCommand extends Command
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);
if (empty($this->config['runway'])) {
$configFile = $this->configFile;
$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;
}
@ -61,8 +67,8 @@ class AiGenerateInstructionsCommand extends Command
$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) : '';
$contextFile = $this->projectRoot . '.github/copilot-instructions.md';
$context = file_exists($contextFile) === true ? file_get_contents($contextFile) : '';
$userDetails = [
'Project Description' => $projectDesc,
'Database' => $database,
@ -80,7 +86,7 @@ class AiGenerateInstructionsCommand extends Command
$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.
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:
$detailsText
Current instructions:
@ -88,10 +94,10 @@ class AiGenerateInstructionsCommand extends Command
EOT; // phpcs:ignore
// 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';
$creds = $runwayConfig['ai'];
$apiKey = $creds['api_key'];
$model = $creds['model'];
$baseUrl = $creds['base_url'];
// Prepare curl call (OpenAI compatible)
$headers = [
@ -123,16 +129,20 @@ class AiGenerateInstructionsCommand extends Command
}
// 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);
$io->info('Updating .github/copilot-instructions.md, .cursor/rules/project-overview.mdc, .gemini/GEMINI.md and .windsurfrules...', true);
if (!is_dir($this->projectRoot . '.github')) {
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')) {
mkdir($baseDir . '.cursor/rules', 0755, true);
if (!is_dir($this->projectRoot . '.gemini')) {
mkdir($this->projectRoot . '.gemini', 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);
file_put_contents($this->projectRoot . '.github/copilot-instructions.md', $instructions);
file_put_contents($this->projectRoot . '.cursor/rules/project-overview.mdc', $instructions);
file_put_contents($this->projectRoot . '.gemini/GEMINI.md', $instructions);
file_put_contents($this->projectRoot . '.windsurfrules', $instructions);
$io->ok('AI instructions updated successfully.', true);
return 0;
}

@ -4,25 +4,22 @@ declare(strict_types=1);
namespace flight\commands;
use Ahc\Cli\Input\Command;
/**
* @property-read ?string $gitignoreFile
* @property-read ?string $credsFile
* @property-read ?string $credsFile Deprecated, use config.php instead
*/
class AiInitCommand extends Command
class AiInitCommand extends AbstractBaseCommand
{
/**
* Constructor for the AiInitCommand class.
*
* 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');
$this
->option('--gitignore-file', 'Path to .gitignore file', null, '')
->option('--creds-file', 'Path to .runway-creds.json file', null, '');
parent::__construct('ai:init', 'Initialize LLM API credentials and settings', $config);
$this->option('--creds-file', 'Path to .runway-creds.json file (deprecated, use config.php instead)', null, '');
}
/**
@ -36,21 +33,6 @@ class AiInitCommand extends Command
$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',
@ -88,50 +70,26 @@ class AiInitCommand extends Command
// Validate model input
switch ($api) {
case 'openai':
$defaultModel = 'gpt-4o';
$defaultModel = 'gpt-5';
break;
case 'grok':
$defaultModel = 'grok-3-beta';
$defaultModel = 'grok-4.1-fast-non-reasoning';
break;
case 'claude':
$defaultModel = 'claude-3-opus';
$defaultModel = 'claude-sonnet-4-5';
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,
'api_key' => $apiKey,
'model' => $model,
'base_url' => $baseUrl,
];
$this->setRunwayConfigValue('ai', $runwayAiConfig);
$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);
}
}
$io->ok('Credentials saved to app/config/config.php', true);
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;
use Ahc\Cli\Application;
use Ahc\Cli\IO\Interactor;
use flight\commands\AiGenerateInstructionsCommand;
use PHPUnit\Framework\TestCase;
use tests\classes\NoExitInteractor;
class AiGenerateInstructionsCommandTest extends TestCase
{
class AiGenerateInstructionsCommandTest extends TestCase {
protected static $in;
protected static $ou;
protected $baseDir;
protected $runwayCredsFile;
public function setUp(): void
{
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, '');
@ -26,97 +23,118 @@ class AiGenerateInstructionsCommandTest extends TestCase
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
{
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');
$this->recursiveRmdir($this->baseDir);
}
protected function recursiveRmdir($dir) {
if (!is_dir($dir)) {
return;
}
if (is_dir($this->baseDir)) {
@rmdir($this->baseDir);
$files = array_diff(scandir($dir), ['.', '..']);
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) {
return $exitCode;
});
$app->io(new Interactor(self::$in, self::$ou));
$app->io(new NoExitInteractor(self::$in, self::$ou));
$app->add($command);
return $app;
}
protected function setInput(array $lines): void
{
protected function setInput(array $lines): void {
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([
'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)
->setConstructorArgs([['runway' => ['dummy' => true]]])
->onlyMethods(['callLlmApi'])
->getMock();
$this->setProjectRoot($cmd, $this->baseDir);
$app = $this->newApp($cmd);
$result = $app->handle([
'runway', 'ai:generate-instructions',
'--creds-file=' . $this->runwayCredsFile,
'--base-dir=' . $this->baseDir
'runway',
'ai:generate-instructions',
]);
$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 = [
'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'
'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)
->setConstructorArgs([
[
'runway' => ['ai' => $creds]
]
])
->onlyMethods(['callLlmApi'])
->getMock();
$this->setProjectRoot($cmd, $this->baseDir);
$cmd->expects($this->once())
->method('callLlmApi')
->willReturn(json_encode([
@ -126,33 +144,43 @@ class AiGenerateInstructionsCommandTest extends TestCase
]));
$app = $this->newApp($cmd);
$result = $app->handle([
'runway', 'ai:generate-instructions',
'--creds-file=' . $this->runwayCredsFile,
'--base-dir=' . $this->baseDir
'runway',
'ai:generate-instructions',
]);
$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 . '.gemini/GEMINI.md');
$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 = [
'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'
'desc',
'mysql',
'latte',
'y',
'y',
'flight/lib',
'Docker',
'2',
'y',
'context info'
]);
$cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class)
->setConstructorArgs([
[
'runway' => ['ai' => $creds]
]
])
->onlyMethods(['callLlmApi'])
->getMock();
$this->setProjectRoot($cmd, $this->baseDir);
$cmd->expects($this->once())
->method('callLlmApi')
->willReturn(json_encode([
@ -162,38 +190,94 @@ class AiGenerateInstructionsCommandTest extends TestCase
]));
$app = $this->newApp($cmd);
$result = $app->handle([
'runway', 'ai:generate-instructions',
'--creds-file=' . $this->runwayCredsFile,
'--base-dir=' . $this->baseDir
'runway',
'ai:generate-instructions',
]);
$this->assertSame(1, $result);
$this->assertFileDoesNotExist($this->baseDir . '.github/copilot-instructions.md');
}
public function testLlmApiCallFails()
{
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'
'desc',
'mysql',
'latte',
'y',
'y',
'flight/lib',
'Docker',
'2',
'y',
'context info'
]);
$cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class)
->setConstructorArgs([
[
'runway' => ['ai' => $creds]
]
])
->onlyMethods(['callLlmApi'])
->getMock();
$this->setProjectRoot($cmd, $this->baseDir);
$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
'runway',
'ai:generate-instructions',
]);
$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 $ou;
protected $baseDir;
protected $runwayCredsFile;
protected $gitignoreFile;
public function setUp(): void
{
@ -23,15 +20,6 @@ class AiInitCommandTest extends TestCase
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
@ -42,24 +30,15 @@ class AiInitCommandTest extends TestCase
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
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;
}
@ -68,135 +47,55 @@ class AiInitCommandTest extends TestCase
file_put_contents(self::$in, implode("\n", $lines) . "\n");
}
public function testInitCreatesCredsAndGitignore()
public function testInitSavesCreds()
{
$this->setInput([
'1', // provider
'1', // provider (openai)
'', // 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
]);
$cmd = $this->getMockBuilder(AiInitCommand::class)
->setConstructorArgs([[]])
->onlyMethods(['setRunwayConfigValue'])
->getMock();
$cmd->expects($this->once())
->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('{}', 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([
'y', // overwrite
'2', // provider
'2', // provider (grok)
'', // 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
]);
$cmd = $this->getMockBuilder(AiInitCommand::class)
->setConstructorArgs([[]])
->onlyMethods(['setRunwayConfigValue'])
->getMock();
$cmd->expects($this->once())
->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);
$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()
@ -207,44 +106,41 @@ class AiInitCommandTest extends TestCase
'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
]);
$cmd = $this->getMockBuilder(AiInitCommand::class)
->setConstructorArgs([[]])
->onlyMethods(['setRunwayConfigValue'])
->getMock();
$cmd->expects($this->once())
->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);
$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([
'1', // provider
'1',
'', // 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);
}));
'', // empty api key
]);
$cmd = $this->getMockBuilder(AiInitCommand::class)
->setConstructorArgs([[]])
->onlyMethods(['setRunwayConfigValue'])
->getMock();
$cmd->expects($this->never())
->method('setRunwayConfigValue');
$app = $this->newApp($cmd);
$result = $app->handle(['runway', 'ai:init']);
// Since $io->error(..., true) exits, Ahc\Cli will return the exit code.
// If it exits with 1, it should be 1.
$this->assertSame(1, $result);
}
public function testInvalidBaseUrlFails()
@ -253,14 +149,14 @@ class AiInitCommandTest extends TestCase
'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
]);
$cmd = $this->getMockBuilder(AiInitCommand::class)
->setConstructorArgs([[]])
->onlyMethods(['setRunwayConfigValue'])
->getMock();
$cmd->expects($this->never())
->method('setRunwayConfigValue');
$app = $this->newApp($cmd);
$result = $app->handle(['runway', 'ai:init']);
$this->assertSame(1, $result);
$this->assertFileDoesNotExist($this->runwayCredsFile);
}
}

Loading…
Cancel
Save