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": {
"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,21 +62,22 @@
"sort-packages": true
},
"scripts": {
"test": "vendor/bin/phpunit-watcher watch",
"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",
@ -92,4 +93,4 @@
"replace": {
"mikecao/flight": "2.0.2"
}
}
}

@ -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,16 +5,15 @@ 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
{
protected static $in;
protected static $ou;
protected $baseDir;
protected $runwayCredsFile;
public function setUp(): void
{
@ -26,16 +25,6 @@ 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
@ -46,27 +35,19 @@ class AiGenerateInstructionsCommandTest extends TestCase
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
@ -74,7 +55,7 @@ class AiGenerateInstructionsCommandTest extends TestCase
$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;
}
@ -84,22 +65,51 @@ class AiGenerateInstructionsCommandTest extends TestCase
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([
'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()
@ -109,14 +119,28 @@ class AiGenerateInstructionsCommandTest extends TestCase
'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,17 +150,14 @@ 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()
@ -146,13 +167,27 @@ class AiGenerateInstructionsCommandTest extends TestCase
'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,12 +197,10 @@ 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()
@ -177,23 +210,83 @@ class AiGenerateInstructionsCommandTest extends TestCase
'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