├── docs ├── images │ ├── logo.png │ ├── ai_ask.png │ ├── run_v3.png │ ├── ai_explain.png │ ├── search_file.png │ ├── commands_list.png │ ├── commands_run.png │ ├── commands_show.png │ ├── directory_list.png │ ├── logo-darkmode.png │ ├── piggyback_repo.png │ └── friends-composer.png ├── development │ ├── index.md │ └── php-version.md ├── friends.md ├── secure-by-design.md ├── formats │ └── prompt.md ├── directory.md ├── commands │ ├── search_file.md │ ├── commands_list.md │ ├── commands.md │ ├── enrichment │ │ └── functions.md │ └── commands_run.md ├── roadmap.md ├── awesome.md ├── ai │ └── overview.md ├── directories │ └── directories.md └── creating-repository.md ├── .devops ├── constants-phpstan.php ├── postbox.php └── rector-php-74.php ├── src ├── Client │ └── Client.php ├── Adapter │ ├── BasicAdapter.php │ ├── Exception │ │ ├── RateLimitExceededException.php │ │ ├── RepositoryNotFoundException.php │ │ └── UnableToFetchRepositoryException.php │ ├── Loader │ │ ├── CachableLoader.php │ │ ├── WritableLoader.php │ │ ├── HttpAwareLoader.php │ │ ├── Loader.php │ │ ├── Decorator │ │ │ ├── WritableCacheDecorator.php │ │ │ └── CacheDecorator.php │ │ ├── LocalFileLoader.php │ │ ├── LoaderFactory.php │ │ └── HttpFileLoader.php │ ├── ClientAwareAdapter.php │ ├── ListAwareAdapter.php │ ├── Adapter.php │ ├── EditableAdapter.php │ ├── AdapterFactory.php │ ├── ManualAdapter.php │ └── GistAdapter.php ├── Enrichment │ └── EnrichFunction │ │ ├── CacheableFunction.php │ │ ├── StringEnrichFunction.php │ │ ├── String │ │ ├── DateStringFunction.php │ │ ├── EnvStringFunction.php │ │ ├── FunctionComposite.php │ │ └── BasicStringFunction.php │ │ ├── ExplodeEnrichFunction.php │ │ ├── Cache │ │ └── SimpleCache.php │ │ └── Explode │ │ ├── FunctionComposite.php │ │ ├── BasicExplodeFunction.php │ │ ├── DockerNamesStringFunction.php │ │ └── DockerImagesStringFunction.php ├── Repository │ ├── ToolAware.php │ ├── QuestionAware.php │ ├── StatusAwareRepository.php │ ├── EditableRepository.php │ ├── Loader │ │ ├── RepositoryLoader.php │ │ ├── CompositeLoader.php │ │ ├── LocalComposerRepositoryLoader.php │ │ ├── LocalPackageRepositoryLoader.php │ │ ├── LocalJsonRepositoryLoader.php │ │ └── LocalRepositoryLoader.php │ ├── ListAware.php │ ├── SearchAware.php │ ├── File │ │ └── EditableFileRepository.php │ ├── Repository.php │ └── Api │ │ └── EditableApiRepository.php ├── Command │ ├── Parameters │ │ ├── UndefinedParameter.php │ │ ├── Validation │ │ │ ├── SuccessfulValidationResult.php │ │ │ ├── Constraint │ │ │ │ ├── Constraint.php │ │ │ │ ├── NotEmptyConstraint.php │ │ │ │ ├── UrlConstraint.php │ │ │ │ ├── IntegerConstraint.php │ │ │ │ ├── IpConstraint.php │ │ │ │ ├── MacConstraint.php │ │ │ │ ├── EmailConstraint.php │ │ │ │ ├── IdentifierConstraint.php │ │ │ │ ├── File │ │ │ │ │ ├── FileExistsConstraint.php │ │ │ │ │ └── FileNotExistsConstraint.php │ │ │ │ └── ConstraintFactory.php │ │ │ └── ValidationResult.php │ │ ├── PasswordParameter.php │ │ ├── ParameterValue.php │ │ └── FileParameter.php │ ├── GistCommand.php │ ├── Tool │ │ └── Tool.php │ ├── Answer │ │ └── Answer.php │ ├── Prompt.php │ └── CommandFactory.php ├── Runner │ ├── Exception │ │ └── ToolNotFoundException.php │ └── CommandRunner.php ├── Logger │ ├── Logger.php │ ├── OutputLogger.php │ └── ForrestLogger.php ├── CliCommand │ ├── Directory │ │ ├── Exception │ │ │ ├── DirectoriesLoadException.php │ │ │ └── MultiException.php │ │ ├── ExportCommand.php │ │ ├── RemoveCommand.php │ │ ├── InstallCommand.php │ │ ├── DirectoryCommand.php │ │ └── ImportCommand.php │ ├── Command │ │ ├── ListCommand.php │ │ ├── HistoryCommand.php │ │ ├── ExplainCommand.php │ │ └── CommandCommand.php │ ├── Repository │ │ ├── RepositoryCommand.php │ │ ├── ListCommand.php │ │ ├── RemoveCommand.php │ │ ├── Command │ │ │ ├── RemoveCommand.php │ │ │ ├── AddCommand.php │ │ │ └── MoveAllCommand.php │ │ ├── CreateCommand.php │ │ └── RegisterCommand.php │ ├── Search │ │ ├── SearchCommand.php │ │ ├── ToolCommand.php │ │ ├── PatternCommand.php │ │ └── FileCommand.php │ ├── Ai │ │ ├── ExplainCommand.php │ │ └── AskCommand.php │ ├── RunCommand.php │ └── Forrest │ │ └── HelpCommand.php ├── History │ └── HistoryHandler.php ├── Config │ ├── Config.php │ ├── RecentParameterMemory.php │ └── ConfigFileHandler.php ├── Util │ ├── OSHelper.php │ └── OutputHelper.php └── Output │ └── RunHelper.php ├── phpstan.neon ├── .gitignore ├── box.json ├── config └── repository.yml ├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── LICENSE ├── tests ├── CliCommand │ └── Command │ │ └── ListCommandTest.php ├── Command │ ├── GistCommandTest.php │ └── CommandTest.php ├── Adapter │ ├── YamlAdapterTest.php │ └── GistAdapterTest.php ├── Repository │ └── RepositoryTest.php ├── History │ └── HistoryHandlerTest.php ├── commands │ └── tests.yml └── Functional │ └── RunCommandTest.php ├── composer.json └── bin └── forrest.php /docs/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/startwind/forrest/HEAD/docs/images/logo.png -------------------------------------------------------------------------------- /.devops/constants-phpstan.php: -------------------------------------------------------------------------------- 1 | paths([ 10 | __DIR__ . '/../src', 11 | __DIR__ . '/../vendor', 12 | __DIR__ . '/../bin' 13 | ]); 14 | 15 | $rectorConfig->sets([ 16 | DowngradeLevelSetList::DOWN_TO_PHP_74 17 | ]); 18 | }; 19 | -------------------------------------------------------------------------------- /src/Repository/EditableRepository.php: -------------------------------------------------------------------------------- 1 | directories; 12 | } 13 | 14 | public function setDirectories(array $directories): void 15 | { 16 | $this->directories = $directories; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Enrichment/EnrichFunction/String/EnvStringFunction.php: -------------------------------------------------------------------------------- 1 | values[$key] = $value; 12 | } 13 | 14 | public function get(string $key): mixed 15 | { 16 | return $this->values[$key]; 17 | } 18 | 19 | public function has(string $key): bool 20 | { 21 | return array_key_exists($key, $this->values); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Repository/Loader/RepositoryLoader.php: -------------------------------------------------------------------------------- 1 | exceptions[] = $exception; 15 | } 16 | 17 | public function getExceptions(): array 18 | { 19 | return $this->exceptions; 20 | } 21 | 22 | public function hasExceptions(): bool 23 | { 24 | return count($this->exceptions) > 0; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Command/GistCommand.php: -------------------------------------------------------------------------------- 1 | client->get($this->rawUrl); 24 | return (string)$response->getBody(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Command/Parameters/Validation/ValidationResult.php: -------------------------------------------------------------------------------- 1 | isValid = $isValid; 13 | $this->validationMessage = $validationMessage; 14 | } 15 | 16 | public function isValid(): bool 17 | { 18 | return $this->isValid; 19 | } 20 | 21 | public function getValidationMessage(): string 22 | { 23 | return $this->validationMessage; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /docs/directory.md: -------------------------------------------------------------------------------- 1 | # Repository Directory 2 | 3 | The Forrest Directory is a constantly growing collection of command repositories. Each repository can contain multiple commands. 4 | 5 | To see the latest list of officially supported repositories, use the following command line command. 6 | 7 | ```shell 8 | forrest directory:list 9 | ``` 10 | The output should look similar to this screenshot. 11 | 12 | ![Directory list](images/directory_list.png) 13 | 14 | ## Official Repositories 15 | 16 | - **Friends of Linux** - Some very helpful everyday Linux commands. 17 | - **Friends of WP** - Some very helpful WordPress commands. 18 | - **Friends of PHP** - Some very helpful PHP CLI commands. 19 | -------------------------------------------------------------------------------- /src/Command/Parameters/Validation/Constraint/NotEmptyConstraint.php: -------------------------------------------------------------------------------- 1 | applyFunction($string); 19 | } 20 | 21 | return $string; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /docs/commands/commands_list.md: -------------------------------------------------------------------------------- 1 | # `commands:list` 2 | 3 | List commands that are registered in the current Forrest configuration. 4 | 5 | By default only a subset of all Forrest commands are registered. To register more use the `directory:list` and `directory:install` command. 6 | 7 | ![search:file command](../images/commands_list.png) 8 | 9 | ## Arguments 10 | 11 | - `repositoy` [optional] : You can use this parameter to show only the commands of one specified repository. 12 | 13 | ## Examples 14 | 15 | List all commands that are currently registered in Forrest. 16 | ```shell 17 | forrest commands:list 18 | ``` 19 | 20 | List all commands that are part of the given repository. (`forrest-linux`). 21 | ```shell 22 | forrest commands:list forrest-linux 23 | ``` 24 | -------------------------------------------------------------------------------- /src/History/HistoryHandler.php: -------------------------------------------------------------------------------- 1 | historyFilename, $command . "\n", FILE_APPEND); 18 | } 19 | 20 | /** 21 | * Get the history entries as string[] 22 | */ 23 | public function getEntries(): array 24 | { 25 | if (!is_file($this->historyFilename)) { 26 | return []; 27 | } 28 | 29 | return file($this->historyFilename); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Repository/ListAware.php: -------------------------------------------------------------------------------- 1 | output = $output; 15 | } 16 | 17 | public function info($message): void 18 | { 19 | OutputHelper::writeInfoBox($this->output, $message); 20 | } 21 | 22 | public function warn($message): void 23 | { 24 | OutputHelper::writeWarningBox($this->output, $message); 25 | } 26 | 27 | public function error($message): void 28 | { 29 | OutputHelper::writeErrorBox($this->output, $message); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Repository/File/EditableFileRepository.php: -------------------------------------------------------------------------------- 1 | getAdapter(); 18 | $adapter->addCommand($command); 19 | } 20 | 21 | /** 22 | * @inheritDoc 23 | */ 24 | public function removeCommand(string $commandName): void 25 | { 26 | /** @var EditableAdapter $adapter */ 27 | $adapter = $this->getAdapter(); 28 | $adapter->removeCommand($commandName); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /docs/development/php-version.md: -------------------------------------------------------------------------------- 1 | ## Why we write PHP 8.2 code 2 | 3 | Yes, you are allowed to use the newest PHP versions and functionalities. For as it is important to write the best code and use all the syntactic sugar out there. 4 | 5 | ## Why we support PHP 7.4 6 | 7 | Yes, PHP 7.4 is not an officially supported version anymore. But we know that approx. 50 % of the web servers out there still run on that version. That means we are in a dilemma. 8 | 9 | Of course we are not. Thanks to [Rector](https://github.com/rectorphp/rector). Before we build our `PHAR` file we use this wonderful tool to "downgrade" our source code. After that it is compatible with PHP 7.4. So for the moment we have the best of both worlds. Our dev team can write PHP 8 code and all servers out there can run it. 10 | 11 | We will reconsider this decision from time to time and have a look at the server stats. But as long as you can read this markdown file we are compatible with PHP 7.4. 12 | -------------------------------------------------------------------------------- /src/Adapter/Loader/Decorator/WritableCacheDecorator.php: -------------------------------------------------------------------------------- 1 | loader; 26 | $loader->write($content); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/CliCommand/Command/ListCommand.php: -------------------------------------------------------------------------------- 1 | addArgument('repository', InputArgument::OPTIONAL, 'Show only this single repository', ''); 19 | } 20 | 21 | protected function doExecute(InputInterface $input, OutputInterface $output): int 22 | { 23 | $this->renderListCommand($input->getArgument('repository')); 24 | return SymfonyCommand::SUCCESS; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | 4 | on: 5 | - push 6 | 7 | jobs: 8 | phpunit: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - run: echo "The ${{ github.repository }} repository has been cloned to the runner." 13 | - uses: php-actions/composer@v6 14 | - name: run phpunit tests 15 | run: vendor/bin/phpunit tests --do-not-cache-result 16 | 17 | phpstan: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v3 21 | - uses: php-actions/composer@v6 22 | - uses: php-actions/phpstan@v3 23 | with: 24 | configuration: phpstan.neon 25 | level: 5 26 | 27 | validate-yaml: 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@v3 31 | - name: Run yamllint 32 | run: | 33 | find . -path \*/vendor -prune -false -o -name \*.y\*ml | 34 | xargs yamllint -d "{extends: relaxed, rules: {line-length: {max: 120}}}" || true 35 | -------------------------------------------------------------------------------- /src/Command/Parameters/ParameterValue.php: -------------------------------------------------------------------------------- 1 | key = $key; 19 | $this->value = $value; 20 | $this->type = $type; 21 | } 22 | 23 | /** 24 | * @return string 25 | */ 26 | public function getKey(): string 27 | { 28 | return $this->key; 29 | } 30 | 31 | /** 32 | * @return string 33 | */ 34 | public function getValue(): string 35 | { 36 | return $this->value; 37 | } 38 | 39 | /** 40 | * @return string 41 | */ 42 | public function getType(): string 43 | { 44 | return $this->type; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Repository/Repository.php: -------------------------------------------------------------------------------- 1 | */ 10 | private static array $adapters = [ 11 | GistAdapter::TYPE => GistAdapter::class, 12 | YamlAdapter::TYPE => YamlAdapter::class 13 | ]; 14 | 15 | public static function getAdapter(string $adapterType, array $config, Client $client): Adapter 16 | { 17 | if (!array_key_exists($adapterType, self::$adapters)) { 18 | throw new \RuntimeException("The adapter type $adapterType is not know. Allowed types are " . implode(', ', array_keys(self::$adapters)) . '.'); 19 | } 20 | 21 | $adapterClass = self::$adapters[$adapterType]; 22 | 23 | /** @var Adapter $adapter */ 24 | $adapter = call_user_func([$adapterClass, 'fromConfigArray'], $config, $client); 25 | 26 | if ($adapter instanceof ClientAwareAdapter) { 27 | $adapter->setClient($client); 28 | } 29 | 30 | return $adapter; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Adapter/Loader/LocalFileLoader.php: -------------------------------------------------------------------------------- 1 | filename = $filename; 12 | } 13 | 14 | /** 15 | * @inheritDoc 16 | */ 17 | public function load(): string 18 | { 19 | if (!file_exists($this->filename)) { 20 | throw new \RuntimeException('The mandatory file ("' . $this->filename . '") does not exist.'); 21 | } 22 | return file_get_contents($this->filename); 23 | } 24 | 25 | /** 26 | * @inheritDoc 27 | */ 28 | public static function fromConfigArray(array $config): Loader 29 | { 30 | return new self($config['file']); 31 | } 32 | 33 | public function assertHealth(): void 34 | { 35 | } 36 | 37 | /** 38 | * @inheritDoc 39 | */ 40 | public function write(string $content) 41 | { 42 | file_put_contents($this->filename, $content); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Command/Tool/Tool.php: -------------------------------------------------------------------------------- 1 | name = $name; 18 | $this->description = $description; 19 | } 20 | 21 | /** 22 | * @return string 23 | */ 24 | public function getSee(): string 25 | { 26 | return $this->see; 27 | } 28 | 29 | /** 30 | * @param string $see 31 | */ 32 | public function setSee(string $see): void 33 | { 34 | $this->see = $see; 35 | } 36 | 37 | /** 38 | * @return string 39 | */ 40 | public function getName(): string 41 | { 42 | return $this->name; 43 | } 44 | 45 | /** 46 | * @return string 47 | */ 48 | public function getDescription(): string 49 | { 50 | return $this->description; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Friends of WP 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/CliCommand/Command/HistoryCommand.php: -------------------------------------------------------------------------------- 1 | setAliases(['history']); 18 | } 19 | 20 | protected function doExecute(InputInterface $input, OutputInterface $output): int 21 | { 22 | $historyHandler = $this->getHistoryHandler(); 23 | 24 | $commands = $historyHandler->getEntries(); 25 | 26 | $output->writeln(''); 27 | 28 | $count = 1; 29 | 30 | foreach ($commands as $command) { 31 | $output->write($count . ' ' . $command); 32 | $count++; 33 | } 34 | 35 | return SymfonyCommand::SUCCESS; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/CliCommand/Command/ListCommandTest.php: -------------------------------------------------------------------------------- 1 | add(new ListCommand()); 20 | $command = $application->find('commands:list'); 21 | $this->commandTester = new CommandTester($command); 22 | } 23 | 24 | public function testExecute() 25 | { 26 | $this->commandTester->execute([]); 27 | $this->commandTester->assertCommandIsSuccessful(); 28 | 29 | $output = $this->commandTester->getDisplay(); 30 | 31 | $this->assertStringContainsString('forrest', $output); 32 | $this->assertStringContainsString('forrest run [command]', $output); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Repository/Loader/CompositeLoader.php: -------------------------------------------------------------------------------- 1 | loaders[$identifier] = $loader; 20 | } 21 | 22 | /** 23 | * @inheritDoc 24 | */ 25 | public function getIdentifiers(): array 26 | { 27 | $identifiers = []; 28 | 29 | foreach ($this->loaders as $loader) { 30 | $identifiers = array_merge($identifiers, $loader->getIdentifiers()); 31 | } 32 | 33 | return $identifiers; 34 | } 35 | 36 | /** 37 | * @inheritDoc 38 | */ 39 | public function enrich(RepositoryCollection $repositoryCollection): void 40 | { 41 | foreach ($this->loaders as $loader) { 42 | $loader->enrich($repositoryCollection); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/CliCommand/Repository/RepositoryCommand.php: -------------------------------------------------------------------------------- 1 | 'yaml', 24 | 'name' => $name, 25 | 'description' => $description, 26 | 'config' => [ 27 | 'file' => $repositoryFileName 28 | ] 29 | ]; 30 | 31 | $configHandler = $this->getConfigHandler(); 32 | 33 | $config = $configHandler->parseConfig(); 34 | $config->addRepository($identifier, $repoArray); 35 | $configHandler->dumpConfig($config); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Config/Config.php: -------------------------------------------------------------------------------- 1 | configArray[self::PARAM_REPOSITORIES][$key] = $repositoryConfig; 17 | } 18 | 19 | public function removeRepository(string $key): void 20 | { 21 | if (array_key_exists($key, $this->configArray[self::PARAM_REPOSITORIES])) { 22 | unset($this->configArray[self::PARAM_REPOSITORIES][$key]); 23 | } 24 | } 25 | 26 | public function getConfigArray(): array 27 | { 28 | return $this->configArray; 29 | } 30 | 31 | public function getDirectories(): array 32 | { 33 | if (!array_key_exists('directories', $this->configArray)) { 34 | return []; 35 | } 36 | 37 | return $this->configArray['directories']; 38 | } 39 | 40 | public function setDirectories(array $configArray): void 41 | { 42 | $this->configArray['directories'] = $configArray; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /docs/roadmap.md: -------------------------------------------------------------------------------- 1 | # Roadmap 2 | 3 | - Version 2.0.0 - **Data Powered and Context Sensitive** - In version 2.0.0 we focus in the commands and our database. This will be fed by other open source projects like [tldr](https://github.com/tldr-pages/) or [Warp Workflows](https://github.com/warpdotdev/workflows). As this database will already be very big we will adjust the usage of Forrest a little bit. The search will be the central service. As it will still be a lot of commands we know we also decided to implement a context-sensitive approach. For example if Forrest recognizes it was run within a PHP project, we will show all the PHP commands. It will also store the recent commands you called in this specific directory. 4 | 5 | 6 | - Version 3.0.0 - **Forrest Community** - In version 3.0.0 we focus on collaboration. We know that such a project is only successful when a lot of people use it. We want the the team and community feature to become more important. Sharing and limiting access to commands without manual effort and GitHub workarounds is key. 7 | 8 | 9 | - Version 4.0.0 - **Forrest AI** - Everybody talks about it and we will also implement it. If forrest does not find a suited command in the database it will use AI to suggest a possible one. 10 | -------------------------------------------------------------------------------- /tests/Command/GistCommandTest.php: -------------------------------------------------------------------------------- 1 | 'some prompt']; 19 | 20 | $mock = new MockHandler([ 21 | new Response(200, [], json_encode($response)), 22 | ]); 23 | 24 | $handlerStack = HandlerStack::create($mock); 25 | $client = new Client(['handler' => $handlerStack]); 26 | 27 | $gistCommand = new GistCommand( 28 | 'gistCommand', 29 | 'gistCommand description', 30 | 'https://some.raw.gist.url/forrest', 31 | $client 32 | ); 33 | 34 | $prompt = $gistCommand->getPrompt(); 35 | $this->assertJson($prompt); 36 | 37 | $request = $mock->getLastRequest(); 38 | $this->assertEquals('https://some.raw.gist.url/forrest', (string)$request->getUri()); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Enrichment/EnrichFunction/String/BasicStringFunction.php: -------------------------------------------------------------------------------- 1 | functionName == '') { 14 | throw new \RuntimeException('The function name must be set'); 15 | } 16 | 17 | $pattern = '#' . preg_quote(StringEnrichFunction::FUNCTION_LIMITER_START) . $this->functionName . '\((.*?)\)' . preg_quote(StringEnrichFunction::FUNCTION_LIMITER_END) . '#'; 18 | preg_match_all($pattern, $string, $matches); 19 | if (count($matches) > 0) { 20 | foreach ($matches[1] as $functionValue) { 21 | $string = str_replace(StringEnrichFunction::FUNCTION_LIMITER_START . $this->functionName . '(' . $functionValue . ')' . StringEnrichFunction::FUNCTION_LIMITER_END, $this->getValue($functionValue), $string); 22 | } 23 | } 24 | return $string; 25 | } 26 | 27 | abstract protected function getValue(string $value): string; 28 | } 29 | -------------------------------------------------------------------------------- /src/Enrichment/EnrichFunction/Explode/FunctionComposite.php: -------------------------------------------------------------------------------- 1 | applyFunction($string); 30 | } catch (\Exception $exception) { 31 | ForrestLogger::error($exception->getMessage()); 32 | continue; 33 | } 34 | 35 | if (is_array($result)) { 36 | return $result; 37 | } 38 | } 39 | 40 | return []; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tests/Adapter/YamlAdapterTest.php: -------------------------------------------------------------------------------- 1 | subject = new YamlAdapter(new LocalFileLoader('file.yaml')); 21 | } 22 | 23 | public function testGetType(): void 24 | { 25 | $this->assertEquals('yaml', $this->subject->getType()); 26 | } 27 | 28 | /** 29 | * @dataProvider yamlConfigProvider 30 | */ 31 | public function testConfigArray(array $config): void 32 | { 33 | $result = YamlAdapter::fromConfigArray($config, new Client()); 34 | $this->assertInstanceOf(YamlAdapter::class, $result); 35 | } 36 | 37 | public static function yamlConfigProvider(): array 38 | { 39 | return [ 40 | [['file' => 'yaml.file']], 41 | [['file' => 'yaml.file', 'somethingElse' => 'doesnt matter']], 42 | ]; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/CliCommand/Search/SearchCommand.php: -------------------------------------------------------------------------------- 1 | getHelper('question'); 18 | 19 | $command = OutputHelper::renderCommands( 20 | $this->getOutput(), 21 | $this->getInput(), 22 | $questionHelper, 23 | $commands, 24 | null, 25 | -1, 26 | true, 27 | $addAiOption 28 | ); 29 | 30 | if ($command === false) { 31 | return SymfonyCommand::FAILURE; 32 | } 33 | 34 | if ($command === true) { 35 | return true; 36 | } 37 | 38 | $this->getOutput()->writeln(''); 39 | 40 | return $this->runCommand($command, $values); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "minimum-stability": "dev", 3 | "autoload": { 4 | "psr-4": { 5 | "Startwind\\Forrest\\": "src/" 6 | } 7 | }, 8 | "autoload-dev": { 9 | "psr-4": { 10 | "Tests\\Startwind\\Forrest\\": "tests/" 11 | } 12 | }, 13 | "config": { 14 | "platform": { 15 | "php": "7.4" 16 | } 17 | }, 18 | "require": { 19 | "php": "~7.4", 20 | "symfony/console": "^5.4", 21 | "symfony/yaml": "^5.4", 22 | "guzzlehttp/guzzle": "^7.5", 23 | "consolidation/self-update": "2.x-dev", 24 | "cache/filesystem-adapter": "1.2.0", 25 | "symfony/process": "^5.4", 26 | "symfony/polyfill": "1.27.*", 27 | "rector/rector": "0.17.*" 28 | }, 29 | "require-dev": { 30 | "phpstan/phpstan": "1.11.x-dev", 31 | "phpunit/phpunit": "^9", 32 | "friendsofphp/php-cs-fixer": "dev-master", 33 | "squizlabs/php_codesniffer": "4.0.x-dev" 34 | }, 35 | "scripts": { 36 | "test": "vendor/bin/phpunit tests", 37 | "cs": "vendor/bin/php-cs-fixer fix --rules=@PSR12 .", 38 | "phpstan": "vendor/bin/phpstan analyse", 39 | "yamllint": "find . -path \\*/vendor -prune -false -o -name \\*.y\\*ml | xargs yamllint -d \"{extends: relaxed, rules: {line-length: {max: 120}}}\"", 40 | "fix": [ 41 | "@cs", 42 | "@phpstan", 43 | "@yamllint" 44 | ] 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/CliCommand/Repository/ListCommand.php: -------------------------------------------------------------------------------- 1 | enrichRepositories(); 19 | 20 | $rows = []; 21 | 22 | foreach ($this->getRepositoryCollection()->getRepositories() as $identifier => $repository) { 23 | $rows[] = [ 24 | $identifier, 25 | $repository->getName(), 26 | $repository->getDescription(), 27 | $repository instanceof EditableRepository ? 'x' : '', 28 | ]; 29 | } 30 | 31 | $headlines = ['Identifier', 'Name', 'Description', 'Writable']; 32 | 33 | OutputHelper::renderTable($output, $headlines, $rows); 34 | 35 | return SymfonyCommand::SUCCESS; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Repository/Loader/LocalComposerRepositoryLoader.php: -------------------------------------------------------------------------------- 1 | enrichWithFile( 30 | $repositoryCollection, 31 | self::COMPOSER_FILE, 32 | 'composer run ', 33 | self::DEFAULT_LOCAL_REPOSITORY_NAME, 34 | self::DEFAULT_LOCAL_DESCRIPTION, 35 | self::LOCAL_REPOSITORY_IDENTIFIER 36 | ); 37 | } 38 | 39 | public static function isApplicable(): bool 40 | { 41 | return (file_exists(LocalComposerRepositoryLoader::COMPOSER_FILE)); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Repository/Loader/LocalPackageRepositoryLoader.php: -------------------------------------------------------------------------------- 1 | enrichWithFile( 30 | $repositoryCollection, 31 | self::PACKAGE_FILE, 32 | 'npm run ', 33 | self::DEFAULT_LOCAL_REPOSITORY_NAME, 34 | self::DEFAULT_LOCAL_DESCRIPTION, 35 | self::LOCAL_REPOSITORY_IDENTIFIER 36 | ); 37 | } 38 | 39 | public static function isApplicable(): bool 40 | { 41 | return (file_exists(LocalPackageRepositoryLoader::PACKAGE_FILE)); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Command/Parameters/FileParameter.php: -------------------------------------------------------------------------------- 1 | fileFormats = $fileFormats; 19 | } 20 | 21 | /** 22 | * Return true if the given filename is compatible with this parameters 23 | * file formats. 24 | */ 25 | public function isCompatibleWithFiles(array $compatibleFilenames): bool 26 | { 27 | $normalizedCompatibleFilenames = []; 28 | 29 | foreach ($compatibleFilenames as $compatibleFilename) { 30 | $normalizedCompatibleFilenames[] = strtolower($compatibleFilename); 31 | } 32 | 33 | foreach ($this->fileFormats as $fileFormat) { 34 | foreach ($normalizedCompatibleFilenames as $normalizedCompatibleFilename) { 35 | if (str_contains($normalizedCompatibleFilename, $fileFormat)) { 36 | return true; 37 | } 38 | } 39 | } 40 | return false; 41 | } 42 | 43 | public function getType(): string 44 | { 45 | return self::TYPE; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Config/RecentParameterMemory.php: -------------------------------------------------------------------------------- 1 | memoryFile = $memoryFile; 17 | 18 | if (file_exists($memoryFile)) { 19 | $this->memories = json_decode(file_get_contents($memoryFile), true); 20 | } 21 | } 22 | 23 | public function addParameter(string $parameterIdentifier, string $value): void 24 | { 25 | $this->memories[$parameterIdentifier] = $value; 26 | } 27 | 28 | public function hasParameter(string $parameterIdentifier): bool 29 | { 30 | return array_key_exists($parameterIdentifier, $this->memories); 31 | } 32 | 33 | public function getParameter(string $parameterIdentifier): string 34 | { 35 | if (!$this->hasParameter($parameterIdentifier)) { 36 | throw new \RuntimeException('No parameter with identifier "' . $parameterIdentifier . '" found. Please use hasParameter before using this method.'); 37 | } 38 | 39 | return $this->memories[$parameterIdentifier]; 40 | } 41 | 42 | public function dump(): void 43 | { 44 | file_put_contents($this->memoryFile, json_encode($this->memories)); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Command/Answer/Answer.php: -------------------------------------------------------------------------------- 1 | command = CommandFactory::fromArray([ 22 | CommandFactory::CONFIG_FIELD_NAME => $name, 23 | CommandFactory::CONFIG_FIELD_DESCRIPTION => 'Answer to: ' . $question, 24 | CommandFactory::CONFIG_FIELD_PROMPT => $prompt 25 | ]); 26 | } else { 27 | $this->command = CommandFactory::fromArray($prompt); 28 | } 29 | $this->command->setFullyQualifiedIdentifier('forrest:' . $name); 30 | 31 | $this->question = $question; 32 | $this->answer = $answer; 33 | } 34 | 35 | public function getCommand(): Command 36 | { 37 | return $this->command; 38 | } 39 | 40 | public function getQuestion(): string 41 | { 42 | return $this->question; 43 | } 44 | 45 | public function getAnswer(): string 46 | { 47 | return $this->answer; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tests/Repository/RepositoryTest.php: -------------------------------------------------------------------------------- 1 | adapter = $this->createMock(ListAwareAdapter::class); 20 | $this->subject = new FileRepository($this->adapter, 'name', 'description'); 21 | 22 | parent::setUp(); // TODO: Change the autogenerated stub 23 | } 24 | 25 | public function testGetter(): void 26 | { 27 | $this->assertEquals('name', $this->subject->getName()); 28 | $this->assertEquals('description', $this->subject->getDescription()); 29 | // $this->assertSame($this->adapter, $this->subject->getAdapter()); 30 | 31 | $this->adapter->expects(once())->method('getCommands')->willReturn(['commands']); 32 | $this->assertEquals(['commands'], $this->subject->getCommands()); 33 | 34 | // $this->adapter->expects(once())->method('isEditable')->willReturn(true); 35 | // $this->assertTrue($this->subject->isEditable()); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Command/Parameters/Validation/Constraint/ConstraintFactory.php: -------------------------------------------------------------------------------- 1 | IntegerConstraint::class, 12 | 'identifier' => IdentifierConstraint::class, 13 | 'not-empty' => NotEmptyConstraint::class, 14 | 'url' => UrlConstraint::class, 15 | 'ip-address' => IpConstraint::class, 16 | 'mac-address' => MacConstraint::class, 17 | 'email' => EmailConstraint::class, 18 | # File constraints 19 | 'file-exists' => FileExistsConstraint::class, 20 | 'file-not-exists' => FileNotExistsConstraint::class, 21 | ]; 22 | 23 | public static function getConstraint(string $constraintName): string 24 | { 25 | $constraint = strtolower($constraintName); 26 | 27 | if (array_key_exists($constraint, self::$validConstraints)) { 28 | return self::$validConstraints[$constraint]; 29 | } else { 30 | throw new \RuntimeException('The given constraint "' . $constraintName . '" was not found. Valid constraints are: ' . implode(', ', array_keys(self::$validConstraints)) . '.'); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Repository/Loader/LocalJsonRepositoryLoader.php: -------------------------------------------------------------------------------- 1 | $script) { 29 | if (is_string($script)) { 30 | $command = new Command($name, $script, $commandPrefix . $name); 31 | $manualAdapter->addCommand($command); 32 | } 33 | } 34 | 35 | 36 | $repositoryCollection->addRepository($repositoryInventory, $repository); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tests/History/HistoryHandlerTest.php: -------------------------------------------------------------------------------- 1 | tmpFilename = tempnam('/tmp', 'forrest'); 15 | parent::setUp(); 16 | } 17 | 18 | public function testGetEntries(): void 19 | { 20 | $history = new HistoryHandler($this->tmpFilename); 21 | 22 | $expectedEntries = [ 23 | 'some', 24 | 'command', 25 | ]; 26 | 27 | foreach ($expectedEntries as $entry) { 28 | $history->addEntry($entry); 29 | } 30 | 31 | $fileContent = file_get_contents($this->tmpFilename); 32 | 33 | foreach ($expectedEntries as $expectedEntry) { 34 | $this->assertContains($expectedEntry . "\n", $history->getEntries()); 35 | $this->assertStringContainsString($expectedEntry, $fileContent); 36 | } 37 | } 38 | 39 | public function testWhenHistoryFileNotExists(): void 40 | { 41 | $historyHandler = new HistoryHandler('wrong-filename'); 42 | 43 | $history = $historyHandler->getEntries(); 44 | 45 | $this->assertEmpty($history); 46 | $this->assertFileDoesNotExist('wrong-filename'); 47 | } 48 | 49 | public function tearDown(): void 50 | { 51 | unlink($this->tmpFilename); 52 | parent::tearDown(); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Repository/Api/EditableApiRepository.php: -------------------------------------------------------------------------------- 1 | password = $password; 20 | } 21 | 22 | public function addCommand(Command $command): void 23 | { 24 | $response = $this->client->post( 25 | $this->endpoint . 'command/' . $command->getName(), 26 | [ 27 | RequestOptions::JSON => $command, 28 | RequestOptions::HEADERS => [ 29 | 'Authorization' => $this->password 30 | ], 31 | 'verify' => false 32 | ] 33 | ); 34 | 35 | $plainResponse = (string)$response->getBody(); 36 | $response = json_decode($plainResponse, true); 37 | 38 | if (!$response) { 39 | throw new \RuntimeException('The APIs response was not a valid JSON string. Body was: ' . $plainResponse); 40 | } 41 | 42 | if ($response['status'] == 'failure') { 43 | ForrestLogger::error($response['message']); 44 | } 45 | } 46 | 47 | public function removeCommand(string $commandName): void 48 | { 49 | // TODO: Implement removeCommand() method. 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Enrichment/EnrichFunction/Explode/BasicExplodeFunction.php: -------------------------------------------------------------------------------- 1 | functionName == '') { 17 | throw new \RuntimeException('The function name must be set'); 18 | } 19 | 20 | $pattern = '#' . preg_quote(ExplodeEnrichFunction::FUNCTION_LIMITER_START) . $this->functionName . '\((.*?)\)' . preg_quote(ExplodeEnrichFunction::FUNCTION_LIMITER_END) . '#'; 21 | 22 | preg_match_all($pattern, $string, $matches); 23 | 24 | if (count($matches) > 0) { 25 | foreach ($matches[1] as $functionValue) { 26 | $key = md5(get_class($this) . $functionValue); 27 | 28 | if (array_key_exists($key, $this->cachedValues)) { 29 | return $this->cachedValues[$key]; 30 | } 31 | 32 | $value = $this->getValue($functionValue); 33 | 34 | if ($this instanceof CacheableFunction) { 35 | $this->cachedValues[$key] = $value; 36 | } 37 | 38 | return $value; 39 | } 40 | } 41 | 42 | return $string; 43 | } 44 | 45 | abstract protected function getValue(string $value): array; 46 | } 47 | -------------------------------------------------------------------------------- /src/Util/OSHelper.php: -------------------------------------------------------------------------------- 1 | 'MacOS', 'version' => '']; 49 | } 50 | 51 | return ['name' => PHP_OS_FAMILY, 'version' => '']; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /docs/commands/commands.md: -------------------------------------------------------------------------------- 1 | # Forrest CLI Commands 2 | 3 | ## Commands 4 | 5 | - `commands:run` - Run a single command. [More information](commands_run.md) 6 | - `commands:list` - List all registered commands that can be run. [More information](commands_list.md) 7 | - `commands:explain` - Show a single command with all its details and steps that will be executed. It also adds a detailed explanation of the command. 8 | - `commands:history` - Show the recent commands that were executed. 9 | 10 | ## Search Commands 11 | 12 | - `search:file` - Shows a list of commands that fit the given file. [More information](search_file.md) 13 | - `search:pattern` - Shows a list of commands that fit the given pattern. 14 | - `search:tool` - Shows a list of commands that fit the given tool. 15 | 16 | 17 | ## Repository Commands 18 | 19 | - `repository:list` - Shows a list of all registered repositories. 20 | - `repository:create` - Creates a new repository and adds it (optional). 21 | - `repository:register` - Add an existing repository to Forrest. 22 | - `repository:remove` - Remove a specified installed repository. 23 | - `repository:command:add` - Add a new command to the given repository. 24 | - `repository:command:remoce` - Remove a command from the given repository. 25 | 26 | ## Directory Commands 27 | 28 | If you want to learn more about the directories and how to create your own private directory please read the chapter about "[Forrest Directories](../directories/directories.md)" 29 | 30 | 31 | - `directory:list` - List all repositories from the Forrest directory. 32 | - `directory:install` - Install a specified repository from the Forrest directory. 33 | - `directory:import` - Import an existing directory. 34 | - `directory:export` - Export an existing directory. 35 | -------------------------------------------------------------------------------- /src/CliCommand/Command/ExplainCommand.php: -------------------------------------------------------------------------------- 1 | addArgument('identifier', InputArgument::REQUIRED, 'The commands identifier.'); 20 | $this->setAliases(['explain']); 21 | } 22 | 23 | protected function doExecute(InputInterface $input, OutputInterface $output): int 24 | { 25 | $this->enrichRepositories(); 26 | 27 | $commandIdentifier = $input->getArgument('identifier'); 28 | 29 | $command = $this->getCommand($commandIdentifier); 30 | 31 | OutputHelper::writeInfoBox($output, [ 32 | 'Explanation of "' . $commandIdentifier . '":' 33 | ]); 34 | 35 | $output->writeln([$command->getPrompt(), ""]); 36 | 37 | $explanation = OutputHelper::indentText($command->getExplanation(), 2, 80, ' |'); 38 | 39 | $output->writeln($explanation); 40 | $output->writeln([ 41 | '', 42 | 'To run this command type: ', 43 | '', 44 | 'forrest run ' . $commandIdentifier, 45 | '', 46 | ]); 47 | 48 | 49 | return SymfonyCommand::SUCCESS; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Adapter/ManualAdapter.php: -------------------------------------------------------------------------------- 1 | commands; 25 | } 26 | 27 | public static function fromConfigArray(array $config, Client $client): Adapter 28 | { 29 | return new self(); 30 | } 31 | 32 | /** 33 | * @inheritDoc 34 | */ 35 | public function isEditable(): bool 36 | { 37 | return false; 38 | } 39 | 40 | /** 41 | * @inheritDoc 42 | */ 43 | public function addCommand(Command $command): void 44 | { 45 | $this->commands[$command->getName()] = $command; 46 | } 47 | 48 | /** 49 | * @inheritDoc 50 | */ 51 | public function removeCommand(string $commandName): void 52 | { 53 | unset($this->commands[$commandName]); 54 | } 55 | 56 | public function getCommand(string $identifier): Command 57 | { 58 | $commands = $this->getCommands(); 59 | 60 | if (!array_key_exists($identifier, $commands)) { 61 | throw new \RuntimeException('No command with name ' . $identifier . ' found.'); 62 | } 63 | 64 | return $commands[$identifier]; 65 | } 66 | 67 | /** 68 | * @inheritDoc 69 | */ 70 | public function assertHealth(): void 71 | { 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Logger/ForrestLogger.php: -------------------------------------------------------------------------------- 1 | info($message); 44 | } 45 | } 46 | 47 | public static function warn($message): void 48 | { 49 | if (self::$logLevel < self::LEVEL_WARN) { 50 | return; 51 | } 52 | foreach (self::$logger as $logger) { 53 | $logger->warn($message); 54 | } 55 | } 56 | 57 | public static function error($message): void 58 | { 59 | if (self::$logLevel < self::LEVEL_ERROR) { 60 | return; 61 | } 62 | foreach (self::$logger as $logger) { 63 | $logger->error($message); 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /docs/commands/enrichment/functions.md: -------------------------------------------------------------------------------- 1 | # Functions 2 | 3 | Functions are an easy way to enrich commands with dynamic data. 4 | 5 | > **_NOTE:_** If you are missing a function for your prompts feel free to create an issue, and we will try to implement it. 6 | 7 | ## `date()` 8 | 9 | The date function injects the current date in any needed format. As format we have chosen the [PHP date format](https://www.php.net/manual/en/datetime.format.php). 10 | 11 | ### Example 12 | 13 | This prompt will echo the current date in the `YYYY-MM-DD` format (e.g. 2025-01-20). 14 | 15 | ```yaml 16 | prompt: echo ${date(Y-m-d)} 17 | ``` 18 | 19 | 20 | ## `env()` 21 | 22 | The env function injects an environment variable from your system 23 | 24 | ### Example 25 | 26 | This example will add the `LOGNAME` from the system as a default value. 27 | 28 | ```yaml 29 | parameters: 30 | user_name: 31 | default: ${env(LOGNAME)} 32 | ``` 33 | 34 | ## `docker-names()` 35 | 36 | The `docker-names()` function will list all currently running docker containers by name. 37 | 38 | ### Example 39 | 40 | ```yaml 41 | commands: 42 | 'enum-explode': 43 | name: 'docker:ssh' 44 | description: 'Show prompt for login' 45 | runnable: false 46 | prompt: 'docker exec -it ${docker_name} /bin/bash' 47 | parameters: 48 | docker_name: 49 | enum: "${docker-names()}" 50 | ``` 51 | 52 | ## `docker-images()` 53 | 54 | The `docker-images()` function will list all currently running docker containers by image name. 55 | 56 | ### Example 57 | 58 | ```yaml 59 | commands: 60 | 'enum-explode': 61 | name: 'docker:ssh' 62 | description: 'Show prompt for login' 63 | runnable: false 64 | prompt: 'docker exec -it ${docker_image} /bin/bash' 65 | parameters: 66 | docker_image: 67 | enum: "${docker-images()}" 68 | ``` 69 | -------------------------------------------------------------------------------- /src/Enrichment/EnrichFunction/Explode/DockerNamesStringFunction.php: -------------------------------------------------------------------------------- 1 | &1", $output, $statusCode); 22 | 23 | if ($statusCode !== Command::SUCCESS) { 24 | if (str_contains($output[0], 'Is the docker daemon running?')) { 25 | ForrestLogger::warn('Docker daemon not running. Please start it to use the "docker-names" function.'); 26 | } else { 27 | ForrestLogger::warn($output[0]); 28 | } 29 | return []; 30 | } 31 | 32 | $names = []; 33 | 34 | foreach ($output as $containerJson) { 35 | $container = json_decode($containerJson, true); 36 | if ($container) { 37 | $names[] = $container['Names']; 38 | } 39 | } 40 | 41 | if (count($names) == 0) { 42 | ForrestLogger::warn('Currently there are no docker containers running. Please start one to use the "docker-names" function.'); 43 | } 44 | 45 | return $names; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Enrichment/EnrichFunction/Explode/DockerImagesStringFunction.php: -------------------------------------------------------------------------------- 1 | &1", $output, $statusCode); 22 | 23 | if ($statusCode !== Command::SUCCESS) { 24 | if (str_contains($output[0], 'Is the docker daemon running?')) { 25 | ForrestLogger::warn('Docker daemon not running. Please start it to use the "docker-names" function.'); 26 | } else { 27 | ForrestLogger::warn($output[0]); 28 | } 29 | return []; 30 | } 31 | 32 | $names = []; 33 | 34 | foreach ($output as $containerJson) { 35 | $container = json_decode($containerJson, true); 36 | if ($container) { 37 | $names[] = $container['Image']; 38 | } 39 | } 40 | 41 | if (count($names) == 0) { 42 | ForrestLogger::warn('Currently there are no docker containers running. Please start one to use the "docker-images" function.'); 43 | } 44 | 45 | return $names; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/CliCommand/Directory/ExportCommand.php: -------------------------------------------------------------------------------- 1 | addArgument('directory', InputArgument::OPTIONAL, 'The config string for the directory.'); 21 | } 22 | 23 | protected function doExecute(InputInterface $input, OutputInterface $output): int 24 | { 25 | $selectedDirectoryIdentifier = $input->getArgument('directory'); 26 | 27 | $directories = $this->getDirectoryConfigs(); 28 | 29 | if (!$selectedDirectoryIdentifier) { 30 | /** @var \Symfony\Component\Console\Helper\QuestionHelper $questionHandler */ 31 | $questionHandler = $this->getHelper('question'); 32 | $selectedDirectoryIdentifier = $questionHandler->ask($input, $output, new ChoiceQuestion('Which directory do you want to export? ', array_keys($directories))); 33 | } 34 | 35 | $selectedDirectory = $directories[$selectedDirectoryIdentifier]; 36 | 37 | OutputHelper::writeInfoBox($output, 'To import this repository on another machine please use this command:'); 38 | 39 | $output->writeln(' forrest ' . ImportCommand::COMMAND_NAME . ' ' . escapeshellarg($selectedDirectoryIdentifier) . ' ' . escapeshellarg(json_encode($selectedDirectory))); 40 | $output->writeln(''); 41 | 42 | return SymfonyCommand::SUCCESS; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/CliCommand/Repository/RemoveCommand.php: -------------------------------------------------------------------------------- 1 | addArgument('identifier', InputArgument::REQUIRED, 'The repositories identifier.'); 19 | } 20 | 21 | protected function isInstalled(string $identifier): bool 22 | { 23 | $installedIdentifiers = $this->getRepositoryLoader()->getIdentifiers(); 24 | return in_array($identifier, $installedIdentifiers); 25 | } 26 | 27 | protected function doExecute(InputInterface $input, OutputInterface $output): int 28 | { 29 | $this->initRepositoryLoader(); 30 | 31 | $identifier = $input->getArgument('identifier'); 32 | 33 | if (!$this->isInstalled($identifier)) { 34 | $this->renderErrorBox('The given repository "' . $identifier . '" is not installed.'); 35 | return SymfonyCommand::FAILURE; 36 | } 37 | 38 | $userConfigFile = $this->getUserConfigFile(); 39 | 40 | if (!file_exists($userConfigFile)) { 41 | return SymfonyCommand::SUCCESS; 42 | } 43 | 44 | $configHandler = $this->getConfigHandler(); 45 | $config = $configHandler->parseConfig(); 46 | $config->removeRepository($identifier); 47 | $configHandler->dumpConfig($config); 48 | 49 | $this->renderInfoBox('Successfully removed repository with identifier "' . $identifier . '".'); 50 | 51 | return SymfonyCommand::SUCCESS; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Adapter/Loader/Decorator/CacheDecorator.php: -------------------------------------------------------------------------------- 1 | loader = $loader; 22 | $this->cacheItemPool = $cacheItemPool; 23 | } 24 | 25 | public function setForceCacheFlush(): void 26 | { 27 | $this->forceCacheFlush = true; 28 | } 29 | 30 | /** 31 | * @throws \Psr\Cache\InvalidArgumentException 32 | */ 33 | public function load(): string 34 | { 35 | if ($this->loader instanceof CachableLoader) { 36 | $key = $this->loader->getCacheKey(); 37 | $item = $this->cacheItemPool->getItem($key); 38 | if ($item->isHit() && !$this->forceCacheFlush) { 39 | return $item->get(); 40 | } else { 41 | $content = $this->loader->load(); 42 | $item->set($content); 43 | $item->expiresAfter(self::TIME_TO_LIVE); 44 | $this->cacheItemPool->save($item); 45 | $this->forceCacheFlush = false; 46 | return $content; 47 | } 48 | } else { 49 | return $this->loader->load(); 50 | } 51 | } 52 | 53 | public static function fromConfigArray(array $config): Loader 54 | { 55 | throw new \RuntimeException('This is just a decorator and works only with an already initiated Loader.'); 56 | } 57 | 58 | public function assertHealth(): void 59 | { 60 | $this->loader->assertHealth(); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /tests/Command/CommandTest.php: -------------------------------------------------------------------------------- 1 | getPrompt(), $values); 28 | 29 | $this->assertEquals($expected, $finalPrompt->getFinalPrompt()); 30 | $this->assertEquals(md5($prompt), $command->getChecksum()); 31 | } 32 | 33 | public function testGetter(): void 34 | { 35 | $command = new Command( 36 | 'name', 37 | 'description', 38 | 'prompt' 39 | ); 40 | 41 | $this->assertEquals('name', $command->getName()); 42 | $this->assertEquals('description', $command->getDescription()); 43 | } 44 | 45 | public static function promptProvider(): array 46 | { 47 | return [ 48 | [[], '', ''], 49 | [[], 'a', 'a'], 50 | [[], '${a}', '${a}'], 51 | [[new ParameterValue('a', 'b', Parameter::TYPE)], '${a}', 'b'], 52 | [[new ParameterValue('a', 'b', Parameter::TYPE), new ParameterValue('b', 'c', Parameter::TYPE)], '${a} is ${b}', 'b is c'], 53 | [[new ParameterValue('a', 'b', Parameter::TYPE), new ParameterValue('b', 'c', Parameter::TYPE)], '${a} is ${c}', 'b is ${c}'], 54 | [[new ParameterValue('a', 'b', Parameter::TYPE), new ParameterValue('b', 'c', Parameter::TYPE)], '${a}: ${b}, ${c}', 'b: c, ${c}'] 55 | ]; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Adapter/Loader/LoaderFactory.php: -------------------------------------------------------------------------------- 1 | PrivateGitHubLoader::class, 18 | 'localFile' => LocalFileLoader::class, 19 | 'httpFile' => HttpFileLoader::class 20 | ]; 21 | 22 | /** 23 | * Create a loader depending on a given configuration array. 24 | */ 25 | public static function create($config, Client $client): Loader 26 | { 27 | if (!array_key_exists($config['type'], self::$loaders)) { 28 | throw new \RuntimeException('No YAML loader found with the identifier "' . $config['loader']['type'] . '". Known types are ' . implode(', ', array_keys(self::$loaders)) . '.'); 29 | } 30 | 31 | $loaderClass = self::$loaders[$config['type']]; 32 | 33 | /** @var Loader $loader */ 34 | $loader = call_user_func([$loaderClass, 'fromConfigArray'], $config['config']); 35 | 36 | if ($loader instanceof HttpAwareLoader) { 37 | $loader->setClient($client); 38 | } 39 | 40 | return self::decorateWithCache($loader); 41 | } 42 | 43 | private static function decorateWithCache(Loader $loader): CacheDecorator 44 | { 45 | $filesystemAdapter = new Local(self::CACHE_DIR); 46 | $filesystem = new Filesystem($filesystemAdapter); 47 | $pool = new FilesystemCachePool($filesystem); 48 | 49 | if ($loader instanceof WritableLoader) { 50 | $loader = new WritableCacheDecorator($loader, $pool); 51 | } else { 52 | $loader = new CacheDecorator($loader, $pool); 53 | } 54 | 55 | return $loader; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /tests/Adapter/GistAdapterTest.php: -------------------------------------------------------------------------------- 1 | subject = new GistAdapter('username', $prefix); 24 | 25 | $gistResponse = [ 26 | [ 27 | 'description' => $prefix . 'some description', 28 | 'files' => [ 29 | ['filename' => 'gist1', 'raw_url' => 'raw1'], 30 | ['filename' => 'gist1', 'raw_url' => 'raw2'], 31 | ['filename' => 'gist1', 'raw_url' => 'raw3'], 32 | ], 33 | ], 34 | ]; 35 | 36 | $mock = new MockHandler([ 37 | new Response(200, [], json_encode($gistResponse)), 38 | ]); 39 | 40 | $handlerStack = HandlerStack::create($mock); 41 | $client = new Client(['handler' => $handlerStack]); 42 | 43 | $this->subject->setClient($client); 44 | } 45 | 46 | public function testGetType(): void 47 | { 48 | $this->assertEquals('gist', $this->subject->getType()); 49 | } 50 | 51 | public function testGetCommands(): void 52 | { 53 | $commands = $this->subject->getCommands(); 54 | 55 | $this->assertCount(3, $commands); 56 | 57 | foreach ($commands as $command) { 58 | $this->assertInstanceOf(GistCommand::class, $command); 59 | $this->assertStringStartsWith('gist', $command->getName()); 60 | $this->assertStringContainsString('description', $command->getDescription()); 61 | $this->assertStringNotContainsString('prefix', $command->getDescription()); 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /docs/commands/commands_run.md: -------------------------------------------------------------------------------- 1 | # `commands:run` 2 | 3 | This command runs the actual scripts. 4 | 5 | ![search:file command](../images/commands_run.png) 6 | 7 | ## Parameters 8 | 9 | Commands in Forrest can have placeholders in it. Those are used to be more flexible when creating and using them. Parameters are escaped like this `php -S localhost:${port}`. If you want to learn more about parameters please have a look at our [`Yaml format`](../formats/yaml-format.md). 10 | 11 | When running a command again we will suggest the recent parameter. This way it is easier to use the tool repeatedly. 12 | 13 | ## Options 14 | 15 | - `--force`: If this parameter is added Forrest will not ask for confirmation before running the command. As this can be very insecure (some commands come directly from the cloud) the force option can only be applied if the command did not change since the last run. 16 | 17 | 18 | - `--parameters`, `-p`: Commands can come with placeholders in scripts. If so the user is asked before running them for the actual values. This comes in handy when manually using the tool. When it is used y the system e.g. `crontab` the approach will fail. This is why it is possible to prefill the parameters via the CLI already. The given value must be `json` encoded. Example: `-p '{"dir_to_search_in":".", "number_of_days":"12"}'`. 19 | 20 | ## Examples 21 | 22 | This command will run a find request and ask the user for the directory to search in and the age of the files. Afterwards it will ask for permission to run. 23 | 24 | ```shell 25 | forrest run forrest-linux:files:find:older 26 | ``` 27 | 28 | This command will skip the question for permission and will run the command immediately. 29 | 30 | ```shell 31 | forrest run forrest-linux:files:find:older --force 32 | ``` 33 | 34 | This command will prefill the parameters/placeholders and will just ask for permission to run. 35 | 36 | ```shell 37 | forrest run forrest-linux:files:find:older -p '{"dir_to_search_in":".", "number_of_days":"12"}' 38 | ``` 39 | 40 | This command will prefill the parameters/placeholders and will run immediately. 41 | 42 | ```shell 43 | forrest run forrest-linux:files:find:older -p '{"dir_to_search_in":".", "number_of_days":"12"}' --force 44 | ``` 45 | -------------------------------------------------------------------------------- /src/Config/ConfigFileHandler.php: -------------------------------------------------------------------------------- 1 | checksumFilename)) { 19 | $checksums = json_decode(file_get_contents($this->checksumFilename), true); 20 | if (!$checksums) { 21 | $checksums = []; 22 | } 23 | } else { 24 | $checksums = []; 25 | } 26 | 27 | $checksums[$this->getChecksumIdentifier($command, $repositoryIdentifier)] = $command->getChecksum(); 28 | file_put_contents($this->checksumFilename, json_encode($checksums)); 29 | } 30 | 31 | public function hasChecksumChanged(Command $command, string $repositoryIdentifier): bool 32 | { 33 | if (!file_exists($this->checksumFilename)) { 34 | return true; 35 | } 36 | 37 | $checksums = json_decode(file_get_contents($this->checksumFilename), true); 38 | $checksumIdentifier = $this->getChecksumIdentifier($command, $repositoryIdentifier); 39 | 40 | if (!array_key_exists($checksumIdentifier, $checksums)) { 41 | return true; 42 | } 43 | 44 | return $checksums[$this->getChecksumIdentifier($command, $repositoryIdentifier)] !== $command->getChecksum(); 45 | } 46 | 47 | private function getChecksumIdentifier(Command $command, string $repositoryIdentifier): string 48 | { 49 | return $repositoryIdentifier . $command->getName(); 50 | } 51 | 52 | public function dumpConfig(Config $config): void 53 | { 54 | file_put_contents($this->configFilename, Yaml::dump($config->getConfigArray(), 4)); 55 | } 56 | 57 | public function parseConfig(): Config 58 | { 59 | $configArray = Yaml::parse(file_get_contents($this->configFilename)); 60 | return new Config($configArray); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /docs/awesome.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | # Forrest is awesome 5 | 6 | Forrest seems to be a complex tool and very technical. That is wrong. But the easiest way to explain the awesomeness of this tool is by providing some real world and every day examples. 7 | 8 | ## Remember **your** commands 9 | 10 | As a developer everybody has some commands that are needed from time to time. Hopefully they are still in history when needed. If not, we have to look into our cheat sheet. 11 | 12 | ```shell 13 | forrest commands:list my-local-commands 14 | ``` 15 | 16 | This prompt will show all your locally stored commands that you added before. 17 | 18 | ## Remember your teams commands 19 | 20 | Repositories that contain your commands are not limited to your local machine. You can just put them into your (private) GitHub repository for example. Those can then be added by every team member. And booom. Shared commands. The best thing is, that everytime you add a new command, the whole team benefits. 21 | 22 | ```shell 23 | forrest repository:register https://raw.githubusercontent.com/my-project/forrest-repository/main/devops.yml 24 | ``` 25 | 26 | ## Remember all commands 27 | 28 | Yours and your teams commands may be very individual, but there are a lot of common commands out there everybody benefits from. Let it be the Linux find command in all its variation. We want Forrest to be the largest collection of those commands. 29 | 30 | As this list is very long we implemented a search on it. 31 | 32 | ````shell 33 | forrest search:pattern "decompress tar file" 34 | ```` 35 | 36 | ## Find out what you can do 37 | 38 | This is our teams favourite command. We call it the reverse search and it's unique. It's a very easy way find all commands registered for a given file type. So for example if you have a `tar` archive and don't know what to do with it or hpw to unpack just run: 39 | 40 | ````shell 41 | forrest search:file archive.tar 42 | ```` 43 | 44 | The tool will list all registered commands for this file. Including decompressing it. 45 | 46 | ## Future ready 47 | 48 | These examples are already pretty awesome. [But the future will bring more](roadmap.md). 49 | -------------------------------------------------------------------------------- /src/Command/Prompt.php: -------------------------------------------------------------------------------- 1 | values = $values; 25 | 26 | $this->finalPrompt = $plainPrompt; 27 | $this->securePrompt = $plainPrompt; 28 | 29 | foreach ($values as $value) { 30 | $this->finalPrompt = str_replace(self::PARAMETER_PREFIX . $value->getKey() . self::PARAMETER_POSTFIX, $value->getValue(), $this->finalPrompt); 31 | 32 | if ($value->getType() == PasswordParameter::TYPE) { 33 | $staredPassword = str_repeat('*', strlen($value->getValue())); 34 | $this->securePrompt = str_replace(self::PARAMETER_PREFIX . $value->getKey() . self::PARAMETER_POSTFIX, $staredPassword, $this->securePrompt); 35 | } else { 36 | $this->securePrompt = str_replace(self::PARAMETER_PREFIX . $value->getKey() . self::PARAMETER_POSTFIX, $value->getValue(), $this->securePrompt); 37 | } 38 | } 39 | 40 | $function = new FunctionComposite(); 41 | $this->finalPrompt = $function->applyFunction($this->finalPrompt); 42 | $this->securePrompt = $function->applyFunction($this->securePrompt); 43 | } 44 | 45 | /** 46 | * @return string 47 | */ 48 | public function getFinalPrompt(): string 49 | { 50 | return $this->finalPrompt; 51 | } 52 | 53 | /** 54 | * @return ParameterValue[] 55 | */ 56 | public function getValues(): array 57 | { 58 | return $this->values; 59 | } 60 | 61 | /** 62 | * This function returns the prompt but hides passwords. 63 | */ 64 | public function getSecurePrompt(): string 65 | { 66 | return $this->securePrompt; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release new Version 3 | 4 | on: 5 | release: 6 | types: [created] 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Get the version 14 | id: get_version 15 | run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/} 16 | 17 | - name: echo release 18 | run: echo $RELEASE 19 | env: 20 | RELEASE: ${{ steps.get_version.outputs.VERSION }} 21 | 22 | - name: Checkout 23 | uses: actions/checkout@v3 24 | 25 | - name: Find and Replace 26 | uses: jacobtomlinson/gha-find-replace@master 27 | with: 28 | exclude: "vendor/" 29 | find: "##FORREST_VERSION##" 30 | replace: ${{ steps.get_version.outputs.VERSION }} 31 | 32 | - name: Setup PHP 33 | uses: shivammathur/setup-php@v2 34 | with: 35 | php-version: '8.1' 36 | ini-values: phar.readonly=0 37 | tools: composer 38 | coverage: none 39 | 40 | - name: Install Composer dependencies 41 | uses: ramsey/composer-install@v2 42 | with: 43 | composer-options: "--ignore-platform-reqs --no-dev" 44 | 45 | - name: Downgrade to php 7.4 46 | run: | 47 | vendor/bin/rector -c .devops/rector-php-74.php 48 | 49 | - name: Remove rector 50 | run: | 51 | rm -rf vendor/rector 52 | 53 | - name: Remove phpstan 54 | run: | 55 | rm -rf vendor/phpstan 56 | 57 | - name: Download box.phar 58 | run: | 59 | wget https://github.com/box-project/box/releases/download/4.3.8/box.phar -P /tmp/ 60 | 61 | - name: Run box builder 62 | run: | 63 | php /tmp/box.phar compile 64 | 65 | - name: Remove version check from composer in phar file 66 | run: | 67 | php .devops/postbox.php 68 | 69 | - name: Move phar file to temp directory 70 | run: | 71 | mv bin/forrest.phar /tmp 72 | 73 | - name: Upload binaries to release 74 | uses: svenstaro/upload-release-action@v2 75 | with: 76 | repo_token: ${{ secrets.REPO_TOKEN }} 77 | file: /tmp/forrest.phar 78 | asset_name: ${{ matrix.asset_name }} 79 | tag: ${{ github.ref }} 80 | -------------------------------------------------------------------------------- /docs/ai/overview.md: -------------------------------------------------------------------------------- 1 | # Artificial Intelligence on the CLI 2 | 3 | The Forrest command database is one of the biggest on the market and includes the top commands of "every" important tool on the command line out there. Nevertheless, there are command we do not know or where it is hard describe what they do so the search can't find them. In this case we now use AI to find that command. 4 | 5 | ## `how` 6 | 7 | One day we had the idea that it would be great of you are able to ask the AI directory from the command line. Just like asking a questions. And guess what? We just did it. You can create an alias for the AI commands by running the init command: 8 | 9 | ```shell 10 | forrest run forrest:init 11 | ``` 12 | 13 | Now you can use the `how` command like this: 14 | 15 | ```shell 16 | how to delete a file 17 | 18 | how to find a file named wp-config.php 19 | 20 | how can I add a new authotized key 21 | 22 | how to install Apache2 23 | ``` 24 | 25 | 26 | 27 | ## `ai:ask` 28 | 29 | This command will take your question as an argument and will answer it providing a short description and an runnable command. Forrest will enrich the question with all needed information like the operating system the question was asked on. 30 | 31 | ### Example 32 | 33 | ```shell 34 | forrest ai:ask "How do I delete a directory?" 35 | ``` 36 | 37 | At first the Forrest SaaS will try to find a command in our database that matches your question. If no answer is found we asked our AI system to answer. But we do we not always ask the AI? Commands in our database are maintained. We know what they do and we are sure the are used correct. When it comes to AI answers you never can be 100 % sure. That is why me also maintain a list of dangerous commands. If one of those commands are part of the answer we send an extra warning before running it. 38 | 39 | The reuslt of the example is this answer: 40 | 41 | ![ai:ask](../images/ai_ask.png) 42 | 43 | ## `ai:explain` 44 | 45 | The explain command is our second AI-powered command. It is used to explain the user commands that they found, and they want to have more information about them. 46 | 47 | ### Example 48 | 49 | ```shell 50 | forrest ai:explain "rm -r /var/www" 51 | ``` 52 | 53 | This command ask out AI to explain what the command will do. The answer will be: 54 | 55 | ![ai:explain](../images/ai_explain.png) 56 | -------------------------------------------------------------------------------- /tests/commands/tests.yml: -------------------------------------------------------------------------------- 1 | repository: 2 | name: 'test commands' 3 | description: 'For tests' 4 | identifier: forrest-dev-tests 5 | commands: 6 | test-yaml-default-fromEnv: 7 | name: 'test:yaml:default:fromEnv' 8 | description: 'Use an env variable as default value' 9 | prompt: 'echo ${user_name}' 10 | parameters: 11 | user_name: { default: '${env(LOGNAME)}' } 12 | test-tool-not-exists: 13 | name: 'test:tool:not-exists' 14 | description: 'Run a tool that is not installed on the machine' 15 | prompt: 'sls -lah' 16 | test_date_function: 17 | name: 'test:command:with-date' 18 | description: 'Use a date in the prompt' 19 | prompt: 'echo ${date(Y-m-d)}' 20 | 'parameters:password': 21 | name: 'parameters:password' 22 | description: 'Check password handling' 23 | prompt: 'ls ${password}' 24 | parameters: 25 | password: 26 | type: forrest_password 27 | 'enum-key-values': 28 | name: 'parameters:enum:with-key' 29 | description: 'Check password handling' 30 | prompt: 'echo ${enum}' 31 | parameters: 32 | enum: 33 | enum: 34 | eins: one 35 | zwei: two 36 | drei: three 37 | 'enum-explode': 38 | name: 'parameters:enum:with-explode' 39 | description: 'Check password handling' 40 | runnable: false 41 | prompt: 'docker exec -it ${docker_name} /bin/bash' 42 | parameters: 43 | docker_name: 44 | enum: "${docker-names()}" 45 | 46 | ## CONSTRAINTS 47 | 'constraints:identifier': 48 | name: 'constraints:identifier' 49 | description: 'Check for identifier constraint' 50 | runnable: false 51 | prompt: 'echo ${identifier}' 52 | parameters: 53 | identifier: 54 | constraints: 55 | - identifier 56 | 57 | "parameter:enum:custom": 58 | name: parameter:enum:custom 59 | description: Select custom field in enum 60 | prompt: echo "${enum}" 61 | parameters: 62 | enum: 63 | enum-allow-custom: true 64 | enum: 65 | - 'wordpress' 66 | - 'wp' 67 | 68 | "parameter:prefix-suffix": 69 | name: parameter:prefix-suffix 70 | description: Add a prefix and suffix to a parameter 71 | prompt: echo "${parameter}" 72 | parameters: 73 | parameter: 74 | prefix: "prefix " 75 | suffix: " suffix" 76 | 77 | -------------------------------------------------------------------------------- /docs/directories/directories.md: -------------------------------------------------------------------------------- 1 | # Forrest Directories 2 | 3 | Directories are an easy way to share commands and even collections of commands easily. This can be private within a 4 | team, but also public with others. 5 | 6 | ## How to work with directories 7 | 8 | A directory contains a list of repositories which then contain a list of commands. A user can activate a repository 9 | after importing the directory and immediately use the commands. 10 | 11 | Everybody can create its own directory by putting a YAML file on an place that is reachable for everybody who should be 12 | able to use it. This can also be a private GitHub repository. 13 | 14 | ### Example of a directory file 15 | 16 | ```yaml 17 | repositories: 18 | devops-my-team: 19 | adapter: yaml 20 | name: 'my-team-devops' 21 | description: 'Private devops command for the team' 22 | config: 23 | loader: 24 | type: github 25 | config: 26 | repository: forrest-directory 27 | user: leankoala-gmbh 28 | file: monitor.yml 29 | token: ghp_**** 30 | ``` 31 | 32 | After creating such a `directory.yml` file you are able to register it. If it is brand new you have to add it by hand to 33 | the `.forrest/config.yml` file in your home dir. 34 | 35 | ### `.forrest/config.yml` with registered directory. 36 | 37 | If the directory is reachable via a public URL you can simply use the `url` parameter otherwise we recommend a loader. 38 | 39 | ```yaml 40 | directories: 41 | forrest: 42 | url: 'https://raw.githubusercontent.com/startwind/forrest-directory/main/directory.yml' 43 | 360monitoring: 44 | loader: 45 | type: github 46 | config: 47 | repository: forrest-directory 48 | user: leankoala-gmbh 49 | file: directory.yml 50 | token: github_**** 51 | ``` 52 | 53 | If the configuration was successfully updated you can type `forrrest directory:list`. You should see now the new 54 | repositories that are registered in the directory. 55 | 56 | If you know want to share the directory with others use the `forrrest directory:export` command. It will create a command line prompt that will import it on other machines. 57 | 58 | ## Commands 59 | 60 | - `directory:list` - List all directories including all connected repositories. 61 | - `directory:import` - Import an existing directory. 62 | - `directory:export` - Export an existing directory. 63 | -------------------------------------------------------------------------------- /src/Adapter/Loader/HttpFileLoader.php: -------------------------------------------------------------------------------- 1 | filename = $filename; 21 | } 22 | 23 | /** 24 | * @inheritDoc 25 | */ 26 | public function load(): string 27 | { 28 | try { 29 | $response = $this->client->get($this->filename); 30 | } catch (ClientException $exception) { 31 | if ($exception->getResponse()->getStatusCode() === 404) { 32 | throw new RepositoryNotFoundException("The given repository (" . $this->filename . ") can't be found."); 33 | } else { 34 | throw $exception; 35 | } 36 | } catch (ServerException $exception) { 37 | throw new UnableToFetchRepositoryException('Unable to fetch data from repository due to server errors.'); 38 | } 39 | return (string)$response->getBody(); 40 | } 41 | 42 | /** 43 | * @inheritDoc 44 | */ 45 | public function setClient(Client $client): void 46 | { 47 | $this->client = $client; 48 | } 49 | 50 | /** 51 | * @inheritDoc 52 | */ 53 | public static function fromConfigArray(array $config): Loader 54 | { 55 | return new self($config['file']); 56 | } 57 | 58 | public function assertHealth(): void 59 | { 60 | if (self::$offline) { 61 | throw new \RuntimeException('Cannot connect to the internet. Please check if your computer is online.'); 62 | } 63 | 64 | try { 65 | $this->client->get('https://www.example.com'); 66 | } catch (\Exception $exception) { 67 | self::$offline = true; 68 | throw new \RuntimeException('Cannot connect to the internet. Please check if your computer is online.'); 69 | } 70 | } 71 | 72 | public function getCacheKey(): string 73 | { 74 | return md5($this->filename); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/CliCommand/Search/ToolCommand.php: -------------------------------------------------------------------------------- 1 | addArgument('tool', InputArgument::REQUIRED, 'The tool name you want to search for.'); 24 | $this->addOption('force', null, InputOption::VALUE_NONE, 'Run the command without asking for permission.'); 25 | 26 | $this->setAliases(['tool']); 27 | } 28 | 29 | protected function doExecute(InputInterface $input, OutputInterface $output): int 30 | { 31 | OutputHelper::renderHeader($output); 32 | 33 | $this->enrichRepositories(); 34 | 35 | $tool = $input->getArgument('tool'); 36 | 37 | $this->renderInfoBox('This is a list of commands that match the given tool.'); 38 | 39 | $commands = $this->getRepositoryCollection()->searchByTools([$tool]); 40 | 41 | $toolInformation = $this->getRepositoryCollection()->getToolInformation($tool); 42 | 43 | if (count($toolInformation) > 0) { 44 | $output->writeln([' Information about "' . $tool . '":', '']); 45 | 46 | foreach ($toolInformation as $repo => $information) { 47 | $output->writeln(\Startwind\Forrest\Util\OutputHelper::indentText($information->getDescription(), 0, 100, ' | ')); 48 | if ($see = $information->getSee()) { 49 | $output->writeln(['', ' For more information visit: ' . $see . '', '']); 50 | } 51 | } 52 | 53 | $output->writeln(''); 54 | } 55 | 56 | if (empty($commands)) { 57 | $this->renderErrorBox('No commands found that match the given tool.'); 58 | return SymfonyCommand::FAILURE; 59 | } 60 | 61 | return $this->runFromCommands($commands); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/CliCommand/Ai/ExplainCommand.php: -------------------------------------------------------------------------------- 1 | addArgument('prompt', InputArgument::IS_ARRAY, 'The prompt you want to have explained.'); 22 | $this->setAliases(['explain']); 23 | 24 | parent::configure(); 25 | } 26 | 27 | protected function doExecute(InputInterface $input, OutputInterface $output): int 28 | { 29 | $this->enrichRepositories(); 30 | 31 | \Startwind\Forrest\Output\OutputHelper::renderHeader($output); 32 | 33 | $prompt = implode(' ', $input->getArgument('prompt')); 34 | 35 | OutputHelper::writeInfoBox($output, [ 36 | 'Explanation of: "' . $prompt . '"' 37 | ]); 38 | 39 | $answers = $this->getRepositoryCollection()->explain($prompt); 40 | 41 | foreach ($answers as $repositoryName => $repoAnswers) { 42 | foreach ($repoAnswers as $answer) { 43 | /** @var Answer $answer */ 44 | $output->writeln(OutputHelper::indentText($this->formatCliText($answer->getAnswer()))); 45 | } 46 | } 47 | 48 | $output->writeln(['', '']); 49 | 50 | return SymfonyCommand::SUCCESS; 51 | } 52 | 53 | private function formatCliText(string $text): string 54 | { 55 | preg_match_all('#```shell((.|\n)*?)```#', $text, $matches); 56 | 57 | if (count($matches[1]) == 1) { 58 | $shell = $matches[1][0]; 59 | 60 | $shellNew = implode("\n", OutputHelper::indentText(trim($shell), 2, 100, ' | ')); 61 | 62 | $text = str_replace($matches[0][0], $shellNew, $text); 63 | } 64 | 65 | preg_match_all('#`(.*)`#', $text, $matches); 66 | 67 | if (count($matches[1]) > 0) { 68 | foreach ($matches[0] as $key => $match) { 69 | $text = str_replace($match, '' . $matches[1][$key] . '', $text); 70 | } 71 | } 72 | 73 | return $text; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Util/OutputHelper.php: -------------------------------------------------------------------------------- 1 | ', ''); 15 | } 16 | 17 | /** 18 | * Show a red output box with a error message. 19 | */ 20 | public static function writeErrorBox(OutputInterface $output, string|array $message): void 21 | { 22 | self::writeMessage($output, $message, '', ''); 23 | } 24 | 25 | /** 26 | * Show a red output box with a warning message. 27 | */ 28 | public static function writeWarningBox(OutputInterface $output, string|array $message): void 29 | { 30 | self::writeMessage($output, $message, '', ''); 31 | } 32 | 33 | public static function writeMessage(OutputInterface $output, string|array $message, string $prefix = '', string $postfix = ''): void 34 | { 35 | $maxLength = 0; 36 | 37 | $messages = self::prepareMessages($message); 38 | 39 | foreach ($messages as $singleMessage) { 40 | $maxLength = max($maxLength, strlen($singleMessage)); 41 | } 42 | 43 | $output->writeln(""); 44 | 45 | foreach ($messages as $singleMessage) { 46 | $output->writeln($prefix . " " . self::getPreparedMessage($singleMessage, $maxLength, 2) . $postfix); 47 | } 48 | 49 | $output->writeln(""); 50 | } 51 | 52 | private static function prepareMessages(string|array $message): array 53 | { 54 | if (!is_array($message)) { 55 | $message = [$message]; 56 | } 57 | 58 | array_unshift($message, ''); 59 | $message[] = ''; 60 | 61 | return $message; 62 | } 63 | 64 | /** 65 | * Add whitespaces to the message of needed to fit to the box. 66 | */ 67 | private static function getPreparedMessage(string $message, int $maxLength, int $additionalSpaces = 0): string 68 | { 69 | return $message . str_repeat(' ', $maxLength - strlen($message) + $additionalSpaces); 70 | } 71 | 72 | public static function indentText(string $text, int $indent = 2, int $width = 100, $prefix = ''): array 73 | { 74 | $wrapped = explode("\n", wordwrap($text, $width)); 75 | 76 | $result = []; 77 | 78 | foreach ($wrapped as $line) { 79 | $result[] = $prefix . str_repeat(' ', $indent) . $line; 80 | } 81 | 82 | return $result; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/CliCommand/Directory/RemoveCommand.php: -------------------------------------------------------------------------------- 1 | addArgument('directory', InputArgument::OPTIONAL, 'The config string for the directory.'); 24 | } 25 | 26 | protected function doExecute(InputInterface $input, OutputInterface $output): int 27 | { 28 | $selectedDirectoryIdentifier = $input->getArgument('directory'); 29 | 30 | $directories = $this->getDirectoryConfigs(); 31 | $removableDirectories = $directories; 32 | 33 | if ($selectedDirectoryIdentifier == DirectoryCommand::MASTER_DIRECTORY_KEY) { 34 | OutputHelper::writeErrorBox($output, 'You are not allowed to remove the Forrest master directory.'); 35 | return SymfonyCommand::FAILURE; 36 | } 37 | 38 | unset($removableDirectories[DirectoryCommand::MASTER_DIRECTORY_KEY]); 39 | 40 | if (!$selectedDirectoryIdentifier) { 41 | /** @var \Symfony\Component\Console\Helper\QuestionHelper $questionHandler */ 42 | $questionHandler = $this->getHelper('question'); 43 | $selectedDirectoryIdentifier = $questionHandler->ask($input, $output, new ChoiceQuestion('Which directory do you want to export? ', array_keys($removableDirectories))); 44 | } 45 | 46 | if (!array_key_exists($selectedDirectoryIdentifier, $directories)) { 47 | OutputHelper::writeErrorBox($output, 'No directory with identifier "' . $selectedDirectoryIdentifier . '" found.'); 48 | return SymfonyCommand::FAILURE; 49 | } 50 | 51 | unset($directories[$selectedDirectoryIdentifier]); 52 | 53 | $config = $this->getConfigHandler()->parseConfig(); 54 | 55 | $config->setDirectories($directories); 56 | 57 | $this->getConfigHandler()->dumpConfig($config); 58 | 59 | OutputHelper::writeInfoBox($output, "Successfully removed directory with identifier " . $selectedDirectoryIdentifier . "."); 60 | 61 | return SymfonyCommand::SUCCESS; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Repository/Loader/LocalRepositoryLoader.php: -------------------------------------------------------------------------------- 1 | localCommandsFile = $localCommandsFile; 33 | 34 | $config = Yaml::parse(file_get_contents($localCommandsFile)); 35 | 36 | if (array_key_exists('repository', $config)) { 37 | if (array_key_exists(self::FIELD_NAME, $config['repository'])) { 38 | $this->name = $config['repository'][self::FIELD_NAME]; 39 | } else { 40 | $this->name = self::DEFAULT_LOCAL_REPOSITORY_NAME; 41 | } 42 | 43 | if (array_key_exists(self::FIELD_DESCRIPTION, $config['repository'])) { 44 | $this->description = $config['repository'][self::FIELD_DESCRIPTION] . ' (' . self::DEFAULT_LOCAL_DESCRIPTION . ')'; 45 | } else { 46 | $this->description = ucfirst(self::DEFAULT_LOCAL_DESCRIPTION); 47 | } 48 | 49 | if (array_key_exists(self::FIELD_IDENTIFIER, $config['repository'])) { 50 | $this->identifier = $config['repository'][self::FIELD_IDENTIFIER]; 51 | } else { 52 | $this->identifier = self::LOCAL_REPOSITORY_IDENTIFIER; 53 | } 54 | } 55 | } 56 | 57 | /** 58 | * @inheritDoc 59 | */ 60 | public function getIdentifiers(): array 61 | { 62 | return [$this->identifier]; 63 | } 64 | 65 | /** 66 | * @inheritDoc 67 | */ 68 | public function enrich(RepositoryCollection $repositoryCollection) 69 | { 70 | $adapter = new YamlAdapter(new LocalFileLoader($this->localCommandsFile)); 71 | 72 | $repository = new FileRepository($adapter, $this->name, $this->description, true); 73 | 74 | $repositoryCollection->addRepository($this->identifier, $repository); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/CliCommand/Directory/InstallCommand.php: -------------------------------------------------------------------------------- 1 | addArgument('identifier', InputArgument::REQUIRED, 'The repositories identifier'); 21 | } 22 | 23 | protected function isInstalled(string $identifier): bool 24 | { 25 | $installedIdentifiers = $this->getRepositoryLoader()->getIdentifiers(); 26 | return in_array($identifier, $installedIdentifiers); 27 | } 28 | 29 | protected function doExecute(InputInterface $input, OutputInterface $output): int 30 | { 31 | $this->initRepositoryLoader(); 32 | 33 | $directories = $this->getDirectories(); 34 | $identifier = $input->getArgument('identifier'); 35 | 36 | $repositories = []; 37 | 38 | foreach ($directories as $directory) { 39 | $repositories = array_merge($repositories, $directory['repositories']); 40 | } 41 | 42 | if (!array_key_exists($identifier, $repositories)) { 43 | $this->renderErrorBox('No repository with identifier "' . $identifier . '" found.'); 44 | return SymfonyCommand::FAILURE; 45 | } 46 | 47 | $repoToInstall = $repositories[$identifier]; 48 | 49 | if ($this->isInstalled($identifier)) { 50 | $this->renderErrorBox('The given repository "' . $identifier . '" is already installed.'); 51 | return SymfonyCommand::FAILURE; 52 | } 53 | 54 | $userConfigFile = $this->getUserConfigFile(); 55 | 56 | if (!file_exists($userConfigFile)) { 57 | $this->renderErrorBox('Unable to create config file "' . $userConfigFile . '". This is needed for adding a new repository.'); 58 | return SymfonyCommand::FAILURE; 59 | } 60 | 61 | $configHandler = $this->getConfigHandler(); 62 | 63 | $config = $configHandler->parseConfig(); 64 | $config->addRepository($identifier, $repoToInstall); 65 | $configHandler->dumpConfig($config); 66 | 67 | $this->renderInfoBox('Successfully installed new repository. Use commands:list to see new commands.'); 68 | 69 | return SymfonyCommand::SUCCESS; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/CliCommand/Repository/Command/RemoveCommand.php: -------------------------------------------------------------------------------- 1 | addArgument('commandName', InputArgument::REQUIRED, 'The name of the command you want to remove'); 25 | } 26 | 27 | protected function doExecute(InputInterface $input, OutputInterface $output): int 28 | { 29 | $this->enrichRepositories(); 30 | 31 | $identifier = $input->getArgument('commandName'); 32 | 33 | $repositoryIdentifier = RepositoryCollection::getRepositoryIdentifier($identifier); 34 | 35 | $repository = $this->getRepositoryCollection()->getRepository($repositoryIdentifier); 36 | 37 | if (!$repository instanceof EditableRepository) { 38 | throw new \RuntimeException('The given repository "' . $repositoryIdentifier . '" is read-only.'); 39 | } 40 | 41 | OutputHelper::writeWarningBox($output, 'Removing ' . $identifier . '. Please notice that this removing can not be undone.'); 42 | 43 | /** @var QuestionHelper $questionHelper */ 44 | $questionHelper = $this->getHelper('question'); 45 | 46 | $remove = $questionHelper->ask($input, $output, new ConfirmationQuestion(' Are you sure you want to remove the command? (y/n) ', false)); 47 | 48 | if (!$remove) { 49 | return Command::FAILURE; 50 | } 51 | 52 | $commandName = RepositoryCollection::getCommandName($identifier); 53 | 54 | try { 55 | $repository->removeCommand($commandName); 56 | } catch (\Exception $exception) { 57 | OutputHelper::writeErrorBox($output, 'Unable to remove command from "' . $repositoryIdentifier . '". ' . $exception->getMessage()); 58 | return Command::FAILURE; 59 | } 60 | 61 | OutputHelper::writeInfoBox($output, 'Successfully removed "' . $identifier . '" from "' . $repositoryIdentifier . '".'); 62 | 63 | return Command::SUCCESS; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /bin/forrest.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | setName('Forrest - Package manager for CLI scripts'); 22 | $application->setVersion(FORREST_VERSION); 23 | 24 | # AI 25 | $application->add(new \Startwind\Forrest\CliCommand\Ai\AskCommand()); 26 | $application->add(new \Startwind\Forrest\CliCommand\Ai\ExplainCommand()); 27 | 28 | # Command Commands 29 | $application->add(new \Startwind\Forrest\CliCommand\Command\ListCommand()); 30 | $application->add(new \Startwind\Forrest\CliCommand\Command\RunCommand()); 31 | $application->add(new \Startwind\Forrest\CliCommand\Command\ExplainCommand()); 32 | $application->add(new \Startwind\Forrest\CliCommand\Command\HistoryCommand()); 33 | 34 | # Repository 35 | $application->add(new \Startwind\Forrest\CliCommand\Repository\ListCommand()); 36 | $application->add(new \Startwind\Forrest\CliCommand\Repository\CreateCommand()); 37 | $application->add(new \Startwind\Forrest\CliCommand\Repository\RegisterCommand()); 38 | $application->add(new \Startwind\Forrest\CliCommand\Repository\RemoveCommand()); 39 | 40 | # Repository Command 41 | $application->add(new \Startwind\Forrest\CliCommand\Repository\Command\AddCommand()); 42 | $application->add(new \Startwind\Forrest\CliCommand\Repository\Command\RemoveCommand()); 43 | $application->add(new \Startwind\Forrest\CliCommand\Repository\Command\MoveAllCommand()); 44 | 45 | 46 | # Directory 47 | $application->add(new \Startwind\Forrest\CliCommand\Directory\ListCommand()); 48 | $application->add(new \Startwind\Forrest\CliCommand\Directory\InstallCommand()); 49 | $application->add(new \Startwind\Forrest\CliCommand\Directory\ImportCommand()); 50 | $application->add(new \Startwind\Forrest\CliCommand\Directory\ExportCommand()); 51 | $application->add(new \Startwind\Forrest\CliCommand\Directory\RemoveCommand()); 52 | 53 | # Search 54 | $application->add(new \Startwind\Forrest\CliCommand\Search\FileCommand()); 55 | $application->add(new \Startwind\Forrest\CliCommand\Search\PatternCommand()); 56 | $application->add(new \Startwind\Forrest\CliCommand\Search\ToolCommand()); 57 | 58 | # Forrest 59 | $application->add(new \Startwind\Forrest\CliCommand\Forrest\HelpCommand()); 60 | 61 | # Others 62 | if (!str_contains(FORREST_VERSION, '##FORREST_VERSION')) { 63 | $application->add(new SelfUpdateCommand(FORREST_NAME, FORREST_VERSION, "startwind/forrest")); 64 | } 65 | 66 | $application->setDefaultCommand(\Startwind\Forrest\CliCommand\Forrest\HelpCommand::COMMAND_NAME); 67 | 68 | $application->run(); 69 | -------------------------------------------------------------------------------- /src/CliCommand/Directory/DirectoryCommand.php: -------------------------------------------------------------------------------- 1 | 20 | * @throws \Startwind\Forrest\CliCommand\Directory\Exception\DirectoriesLoadException 21 | */ 22 | protected function getDirectories(): array 23 | { 24 | $directoryConfigs = $this->getDirectoryConfigs(); 25 | 26 | $directories = []; 27 | 28 | $directoriesLoadException = new DirectoriesLoadException(); 29 | 30 | foreach ($directoryConfigs as $key => $directoryConfig) { 31 | try { 32 | $content = $this->loadDirectory($directoryConfig); 33 | } catch (\Exception $exception) { 34 | $directoriesLoadException->addException(new \RuntimeException('Directory error (' . $key . '): ' . $exception->getMessage())); 35 | continue; 36 | } 37 | $directories[$key] = $content; 38 | } 39 | 40 | if ($directoriesLoadException->hasExceptions()) { 41 | $directoriesLoadException->setDirectories($directories); 42 | throw $directoriesLoadException; 43 | } 44 | 45 | return $directories; 46 | } 47 | 48 | protected function loadDirectory(array $directoryConfig) 49 | { 50 | $client = $this->getClient(); 51 | 52 | if (array_key_exists('url', $directoryConfig)) { 53 | $response = $client->get($directoryConfig['url']); 54 | return Yaml::parse($response->getBody()); 55 | } elseif (array_key_exists('loader', $directoryConfig)) { 56 | $loader = LoaderFactory::create($directoryConfig['loader'], $client); 57 | if ($loader instanceof HttpAwareLoader) { 58 | $loader->setClient($client); 59 | } 60 | return Yaml::parse($loader->load()); 61 | } else { 62 | throw new \RuntimeException('The directory configuration needs to have an url or loader defined.'); 63 | } 64 | } 65 | 66 | protected function getDirectoryConfigs(): array 67 | { 68 | $configHandler = $this->getConfigHandler(); 69 | $config = $configHandler->parseConfig(); 70 | 71 | $directoryConfigs = $config->getDirectories(); 72 | 73 | return array_merge([self::MASTER_DIRECTORY_KEY => ['url' => self::MASTER_DIRECTORY_URL]], $directoryConfigs); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/CliCommand/Directory/ImportCommand.php: -------------------------------------------------------------------------------- 1 | addArgument('identifier', InputArgument::REQUIRED, 'The config string for the directory.'); 23 | $this->addArgument('directoryConfig', InputArgument::REQUIRED, 'The config string for the directory.'); 24 | } 25 | 26 | protected function doExecute(InputInterface $input, OutputInterface $output): int 27 | { 28 | $identifier = $input->getArgument('identifier'); 29 | $rawConfig = $input->getArgument('directoryConfig'); 30 | $config = json_decode($rawConfig, true); 31 | 32 | if (!$config) { 33 | throw new \RuntimeException('The directory config seems to be broken.'); 34 | } 35 | 36 | if (!is_array($config)) { 37 | throw new \RuntimeException('The directory config is not an array. Please check the json encoded string.'); 38 | } 39 | 40 | $existingConfigs = $this->getDirectoryConfigs(); 41 | 42 | if (array_key_exists($identifier, $existingConfigs)) { 43 | OutputHelper::writeErrorBox($output, [ 44 | 'A directory with the name ' . $identifier . ' already exists. ', 45 | 'Please remove it before importing the new one.' 46 | ]); 47 | return SymfonyCommand::FAILURE; 48 | } 49 | 50 | try { 51 | $this->validateConfig($config); 52 | } catch (\Exception $exception) { 53 | OutputHelper::writeErrorBox($output, [ 54 | 'Ooops, something went wrong: ', 55 | $exception->getMessage() 56 | ]); 57 | return SymfonyCommand::FAILURE; 58 | } 59 | 60 | $existingConfigs[$identifier] = $config; 61 | 62 | $config = $this->getConfigHandler()->parseConfig(); 63 | 64 | $config->setDirectories($existingConfigs); 65 | 66 | $this->getConfigHandler()->dumpConfig($config); 67 | 68 | OutputHelper::writeInfoBox($output, [ 69 | 'Successfully imported new directory.', 70 | 'Use forrest directory:list ' . $identifier . ' to show the new repositories.' 71 | ]); 72 | 73 | return SymfonyCommand::SUCCESS; 74 | } 75 | 76 | protected function validateConfig(array $config) 77 | { 78 | $this->loadDirectory($config); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Runner/CommandRunner.php: -------------------------------------------------------------------------------- 1 | historyHandler = $historyHandler; 22 | } 23 | 24 | /** 25 | * Return a string array with all the commands. This is needed for multi line 26 | * commands. 27 | */ 28 | public static function stringToMultilinePrompt(string $string): array 29 | { 30 | $commands = explode("\n", $string); 31 | 32 | if ($commands[count($commands) - 1] == '') { 33 | unset($commands[count($commands) - 1]); 34 | } 35 | 36 | return $commands; 37 | } 38 | 39 | /** 40 | * Run a single command line. 41 | */ 42 | public function execute(OutputInterface $output, string $prompt, bool $checkForExistence = true, $storeInHistory = true): int 43 | { 44 | if ($checkForExistence && !self::isToolInstalled($prompt, $tool)) { 45 | throw new ToolNotFoundException($tool); 46 | } 47 | 48 | if ($storeInHistory) { 49 | $this->historyHandler->addEntry($prompt); 50 | } 51 | 52 | $process = Process::fromShellCommandline($prompt); 53 | 54 | $process->run(function (string $pipe, string $outputString) use ($output) { 55 | if ($pipe == Process::OUT) { 56 | $output->write($outputString); 57 | } elseif ($pipe == Process::ERR) { 58 | $output->write("" . $outputString . ""); 59 | } else { 60 | $output->write("" . $outputString . ""); 61 | } 62 | }); 63 | 64 | return $process->getExitCode(); 65 | } 66 | 67 | /** 68 | * Return true if the tool is installed. 69 | */ 70 | public static function isToolInstalled(string $prompt, &$command): bool 71 | { 72 | $command = self::extractToolFromPrompt($prompt); 73 | exec('which ' . $command, $output, $resultCode); 74 | return $resultCode == SymfonyCommand::SUCCESS; 75 | } 76 | 77 | /** 78 | * Get the tool name from a prompt. 79 | * 80 | * It also removes sudo and other "prefix" tools. 81 | */ 82 | public static function extractToolFromPrompt(string $prompt): string 83 | { 84 | $parts = explode(' ', $prompt); 85 | 86 | $command = array_shift($parts); 87 | 88 | while (in_array($command, self::$prefixCommands) && !empty($parts)) { 89 | $command = array_shift($parts); 90 | } 91 | 92 | return $command; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/CliCommand/Command/CommandCommand.php: -------------------------------------------------------------------------------- 1 | getOutput(); 17 | 18 | OutputHelper::renderHeader($output); 19 | 20 | $this->enrichRepositories(); 21 | 22 | $maxLength = 0; 23 | 24 | if ($repository) { 25 | $repositories = [$repository => $this->getRepositoryCollection()->getRepository($repository)]; 26 | } else { 27 | $repositories = $this->getRepositoryCollection()->getRepositories(); 28 | } 29 | 30 | foreach ($repositories as $repoIdentifier => $repository) { 31 | if ($repository instanceof ListAware) { 32 | try { 33 | foreach ($repository->getCommands(false) as $command) { 34 | $maxLength = max($maxLength, strlen(RepositoryCollection::createUniqueCommandName($repoIdentifier, $command))); 35 | } 36 | } catch (\Exception $exception) { 37 | unset($repositories[$repoIdentifier]); 38 | $this->renderErrorBox([ 39 | 'Unable to fetch commands from ' . $repoIdentifier . '. ' . $exception->getMessage(), 40 | ]); 41 | $output->writeln(''); 42 | } 43 | } 44 | } 45 | 46 | $output->writeln([ 47 | 'Usage:', 48 | '', 49 | ' forrest run [command]', 50 | '', 51 | ]); 52 | 53 | foreach ($repositories as $repoIdentifier => $repository) { 54 | if (!$repository instanceof ListAware) { 55 | continue; 56 | } 57 | 58 | if (!$repository->hasCommands()) { 59 | continue; 60 | } 61 | 62 | if ($repository->isSpecial()) { 63 | $this->renderWarningBox($repository->getName() . ' (' . $repoIdentifier . ')'); 64 | $output->writeln([' ' . $repository->getDescription(), '']); 65 | } else { 66 | $output->writeln([ 67 | '', 68 | '' . $repository->getName() . ' (' . $repoIdentifier . ')', 69 | '', 70 | ]); 71 | } 72 | 73 | /** @var \Symfony\Component\Console\Helper\QuestionHelper $questionHelper */ 74 | $questionHelper = $this->getHelper('question'); 75 | 76 | OutputHelper::renderCommands($output, $this->getInput(), $questionHelper, $repository->getCommands(false), $repoIdentifier, $maxLength); 77 | } 78 | 79 | $output->writeln(''); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/CliCommand/RunCommand.php: -------------------------------------------------------------------------------- 1 | getCommand($command); 24 | } else { 25 | $commandIdentifier = $command->getFullyQualifiedIdentifier(); 26 | } 27 | 28 | $repositoryIdentifier = RepositoryCollection::getRepositoryIdentifier($commandIdentifier); 29 | 30 | /** @var QuestionHelper $questionHelper */ 31 | $questionHelper = $this->getHelper('question'); 32 | 33 | $promptHelper = new PromptHelper($this->getInput(), $this->getOutput(), $questionHelper, $this->getRecentParameterMemory()); 34 | 35 | $prompt = $promptHelper->askForPrompt($command, $userParameters); 36 | 37 | $promptHelper->showFinalPrompt($prompt); 38 | 39 | $runHelper = new RunHelper($this->getInput(), $this->getOutput(), $questionHelper, $this->getConfigHandler(), $this->getHistoryHandler()); 40 | 41 | $force = $this->getInput()->getOption('force'); 42 | 43 | if (!$runHelper->handleRunnable($command, $prompt->getFinalPrompt())) { 44 | return SymfonyCommand::SUCCESS; 45 | } 46 | 47 | if (!$runHelper->handleForceOption($force, $command, $repositoryIdentifier)) { 48 | return SymfonyCommand::FAILURE; 49 | } 50 | 51 | if (!$runHelper->confirmRun($force)) { 52 | return SymfonyCommand::FAILURE; 53 | } 54 | 55 | $this->getOutput()->writeln(''); 56 | 57 | try { 58 | $exitCode = $runHelper->executeCommand($this->getOutput(), $command, $prompt); 59 | } catch (ToolNotFoundException $exception) { 60 | if ($command->getFullyQualifiedIdentifier()) { 61 | $this->getRepositoryCollection()->pushStatus($command->getFullyQualifiedIdentifier(), StatusAwareRepository::STATUS_FAILURE); 62 | } 63 | $this->renderErrorBox($exception->getMessage()); 64 | return SymfonyCommand::FAILURE; 65 | } 66 | 67 | $this->getOutput()->writeln(''); 68 | 69 | if ($exitCode == SymfonyCommand::SUCCESS) { 70 | $this->getOutput()->writeln('Command ran successfully.'); 71 | } else { 72 | $this->getOutput()->writeln('' . $exitCode . ' Command did not run successfully.'); 73 | } 74 | 75 | $this->getConfigHandler()->persistChecksum($command, $repositoryIdentifier); 76 | $this->getRecentParameterMemory()->dump(); 77 | 78 | $this->getRepositoryCollection()->pushStatus($command->getFullyQualifiedIdentifier(), StatusAwareRepository::STATUS_SUCCESS); 79 | 80 | return SymfonyCommand::SUCCESS; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /tests/Functional/RunCommandTest.php: -------------------------------------------------------------------------------- 1 | add(new RegisterCommand()); 18 | $command = $application->find(RegisterCommand::NAME); 19 | $commandTester = new CommandTester($command); 20 | $commandTester->execute(['repositoryFileName' => __DIR__ . '/../commands/tests.yml']); 21 | } 22 | 23 | /** 24 | * @dataProvider inputProvider 25 | */ 26 | public function testExecute(string $commandIdentifier, bool $isSuccessful, array $expectedOutputs, array $inputs = []) 27 | { 28 | $application = new Application(); 29 | 30 | $application->add(new RunCommand()); 31 | 32 | $command = $application->find(RunCommand::NAME); 33 | 34 | $commandTester = new CommandTester($command); 35 | 36 | if (count($inputs) > 0) { 37 | $commandTester->setInputs($inputs); 38 | } 39 | 40 | $commandTester->execute(['argument' => [$commandIdentifier]]); 41 | $output = $commandTester->getDisplay(); 42 | 43 | if ($isSuccessful) { 44 | $commandTester->assertCommandIsSuccessful(); 45 | } else { 46 | $this->assertEquals(1, $commandTester->getStatusCode()); 47 | } 48 | 49 | foreach ($expectedOutputs as $expectedOutput) { 50 | $this->assertStringContainsString($expectedOutput, $output); 51 | } 52 | } 53 | 54 | public static function inputProvider(): array 55 | { 56 | return [ 57 | # command with date() function 58 | ['forrest-dev-tests:test:command:with-date', true, ['echo ' . date('Y-m-d'), 'Are you sure you want to run that command'], ['y']], 59 | # command with non-installed tool 60 | ['forrest-dev-tests:test:tool:not-exists', false, ['Are you sure you want to run that command', "The tool \"sls\" is not installed"], ['y']], 61 | # command with replaced password 62 | ['forrest-dev-tests:parameters:password', true, ['ls ${password}', 'ls ****', 'Are you sure you want to run that command'], [1234, 'y']], 63 | # command with kay-value enum 64 | ['forrest-dev-tests:parameters:enum:with-key', true, ['echo ${enum}', '[0] eins', 'one'], [0, 'y']], 65 | # command with parameter validation (constraint) - success 66 | ['forrest-dev-tests:constraints:identifier', true, ['echo ${identifier}', 'Select value for identifier'], ['a1b2c3', 'y']], 67 | # command with parameter validation (constraint) - failure 68 | ['forrest-dev-tests:constraints:identifier', false, ['echo ${identifier}', 'Select value for identifier'], ['a1 b2c3', 'y']], 69 | # works only locally ['forrest-dev-tests:parameters:enum:with-explode', true, ['[0]'], [0, 'y']], 70 | 71 | # command with custom enum 72 | ['forrest-dev-tests:parameter:enum:custom', true, ['echo "nils langner"', 'echo "${enum}"', 'Select value for enum'], [0, 'nils langner', 'y']], 73 | 74 | # command with pre- and suffix 75 | ['forrest-dev-tests:parameter:prefix-suffix', true, ['prefix 123 suffix', 'echo "${parameter}"'], ['123', 'y']], 76 | 77 | ]; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/CliCommand/Forrest/HelpCommand.php: -------------------------------------------------------------------------------- 1 | writeln(''); 23 | 24 | $output->writeln([ 25 | 'Usage:', 26 | '', 27 | ' forrest run [command | file | pattern]', 28 | '', 29 | 'Common Forrest Commands:', 30 | '', 31 | ' forrest run [command] Run a command by name. This is used when the user already knows what to do.', 32 | ' To show a list of all locally stored commands (these are only a small set ', 33 | ' of the overall available commands) run forrest commands:list.', 34 | '', 35 | ' Example: forrest run forrest:linux:files:find:name', 36 | '', 37 | ' forrest run [file] There are a lot of commands that are connected to a special file type.', 38 | ' This Forrest command will take an existing file or directory as argument and ', 39 | ' will then return all commands that are connected to this file type.', 40 | '', 41 | ' Example: forrest run wordpress.zip', 42 | '', 43 | ' forrest run [tool] If only a single word is used as an argument Forrest assumes that it is the name', 44 | ' of a tool. It will return all commands that are found for this command line tool.', 45 | '', 46 | ' Example: forrest run symfony', 47 | '', 48 | ' forrest run [pattern] When the run command is used with a pattern as an argument it is used as', 49 | ' full text search in the background. It will return all commands that have the ', 50 | ' given pattern in their name or description.', 51 | '', 52 | ' Example: forrest run install mysql-cli', 53 | '', 54 | '', 55 | 'Additional Forrest Commands:', 56 | '', 57 | ' forrest list Show all available Forrest commands (many of them are not listed here).', 58 | ' forrest commands:list Show all custom commands (might be empty in the beginning).', 59 | ' forrest history Show the recent commands that were run by forrest.', 60 | ' forrest tool [tool] Show all commands that are connected to the given tool.', 61 | '', 62 | ]); 63 | 64 | // $output->writeln(' ' . $commandIdentifier . '' . $spaces . $command->getDescription()); 65 | 66 | return SymfonyCommand::SUCCESS; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/CliCommand/Repository/CreateCommand.php: -------------------------------------------------------------------------------- 1 | addArgument('repositoryFileName', InputArgument::REQUIRED, 'The filename of new repository.'); 23 | } 24 | 25 | protected function doExecute(InputInterface $input, OutputInterface $output): int 26 | { 27 | $content = []; 28 | 29 | $repositoryFileName = $input->getArgument('repositoryFileName'); 30 | 31 | $output->writeln(''); 32 | 33 | /** @var QuestionHelper $questionHelper */ 34 | $questionHelper = $this->getHelper('question'); 35 | 36 | if (file_exists($repositoryFileName)) { 37 | $overwrite = $questionHelper->ask($input, $output, new ConfirmationQuestion('File already exists. Do you want to overwrite it? [y/n] ', false)); 38 | if (!$overwrite) { 39 | $this->renderErrorBox('No repository created. File already exists.'); 40 | return SymfonyCommand::FAILURE; 41 | } 42 | } 43 | 44 | $name = $questionHelper->ask($input, $output, new Question('Name of the repository [default: "local commands"]: ', 'local commands')); 45 | $identifierSuggestion = $this->getIdentifierSuggestion($name); 46 | $description = $questionHelper->ask($input, $output, new Question('Description of the repository [default: "Commands for local usage"]: ', 'Commands for local usage')); 47 | $identifier = $questionHelper->ask($input, $output, new Question('Identifier of the repository [default: "' . $identifierSuggestion . '"]: ', $identifierSuggestion)); 48 | 49 | $content['repository'] = [ 50 | 'name' => $name, 51 | 'description' => $description, 52 | 'identifier' => $identifier 53 | ]; 54 | 55 | $content['commands'] = [ 56 | 'my-unique-command-name' => [ 57 | 'name' => 'foo:bar', 58 | 'description' => 'Command description', 59 | 'prompt' => 'ls -lah' 60 | ] 61 | ]; 62 | 63 | $this->saveRepositoryFile($repositoryFileName, $content); 64 | 65 | $this->renderInfoBox('Repository file "' . $repositoryFileName . '" successfully created.'); 66 | 67 | $register = $questionHelper->ask($input, $output, new ConfirmationQuestion('Do you already want to register the repository? [y/n] ', false)); 68 | 69 | if ($register) { 70 | $this->registerRepository($identifier, $name, $description, $repositoryFileName); 71 | $this->renderInfoBox('Repository file "' . $repositoryFileName . '" successfully registered.'); 72 | } 73 | 74 | return SymfonyCommand::SUCCESS; 75 | } 76 | 77 | /** 78 | * Store the repo file but make sure the directory exists. 79 | */ 80 | private function saveRepositoryFile(string $filename, array $content): void 81 | { 82 | $dir = dirname($filename); 83 | if (!is_dir($dir)) { 84 | mkdir($dir, 0777, true); 85 | } 86 | file_put_contents($filename, Yaml::dump($content, 4)); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/CliCommand/Repository/RegisterCommand.php: -------------------------------------------------------------------------------- 1 | addArgument('repositoryFileName', InputArgument::REQUIRED, 'The filename of the repository.'); 25 | } 26 | 27 | private function repositoryFileExists(string $repositoryFileName): bool 28 | { 29 | if (str_contains($repositoryFileName, '://')) { 30 | $client = $this->getClient(); 31 | try { 32 | $client->get($repositoryFileName); 33 | } catch (\Exception $exception) { 34 | return false; 35 | } 36 | return true; 37 | } else { 38 | return file_exists($repositoryFileName); 39 | } 40 | } 41 | 42 | protected function doExecute(InputInterface $input, OutputInterface $output): int 43 | { 44 | $this->initRepositoryLoader(); 45 | 46 | $repositoryFileName = $input->getArgument('repositoryFileName'); 47 | 48 | if (!$this->repositoryFileExists($repositoryFileName)) { 49 | $this->renderErrorBox('File "' . $repositoryFileName . '" not found.'); 50 | return SymfonyCommand::FAILURE; 51 | } 52 | 53 | $newRepoContent = Yaml::parse(file_get_contents($repositoryFileName)); 54 | 55 | $defaultName = 'Local Repository'; 56 | $defaultDescription = 'This repository contains all commands needed for local stuff.'; 57 | $defaultIdentifier = ''; 58 | 59 | if (array_key_exists('repository', $newRepoContent)) { 60 | if (array_key_exists('name', $newRepoContent['repository'])) { 61 | $defaultName = $newRepoContent['repository']['name']; 62 | } 63 | if (array_key_exists('description', $newRepoContent['repository'])) { 64 | $defaultDescription = $newRepoContent['repository']['description']; 65 | } 66 | if (array_key_exists('identifier', $newRepoContent['repository'])) { 67 | $defaultIdentifier = $newRepoContent['repository']['identifier']; 68 | } 69 | } 70 | 71 | /** @var QuestionHelper $questionHelper */ 72 | $questionHelper = $this->getHelper('question'); 73 | 74 | $name = $questionHelper->ask($input, $output, new Question('Name of the repository [default: ' . $defaultName . ']: ', $defaultName)); 75 | 76 | if ($name != $defaultName) { 77 | $defaultIdentifier = $this->getIdentifierSuggestion($name); 78 | } 79 | 80 | $description = $questionHelper->ask($input, $output, new Question('Description of the repository: [default: ' . $defaultDescription . ']: ', $defaultDescription)); 81 | $identifier = $questionHelper->ask($input, $output, new Question('Identifier of the repository [default: ' . $defaultIdentifier . ']: ', $defaultIdentifier)); 82 | 83 | $this->registerRepository($identifier, $name, $description, $repositoryFileName); 84 | $this->renderInfoBox('Repository file "' . $repositoryFileName . '" successfully registered.'); 85 | 86 | return SymfonyCommand::SUCCESS; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/CliCommand/Search/PatternCommand.php: -------------------------------------------------------------------------------- 1 | addArgument('pattern', InputArgument::IS_ARRAY, 'The pattern you want to search for.'); 28 | $this->addOption('force', null, InputOption::VALUE_NONE, 'Run the command without asking for permission.'); 29 | $this->addOption('score', 's', InputOption::VALUE_OPTIONAL, 'The minimal search score.', 10); 30 | 31 | $this->setAliases(['ask', 'pattern']); 32 | } 33 | 34 | protected function doExecute(InputInterface $input, OutputInterface $output): int 35 | { 36 | OutputHelper::renderHeader($output); 37 | 38 | $minScore = $input->getOption('score'); 39 | 40 | $this->enrichRepositories(); 41 | 42 | $pattern = $input->getArgument('pattern'); 43 | 44 | if (count($pattern) == 1 && str_contains($pattern[0], ' ')) { 45 | $pattern = explode(' ', $pattern[0]); 46 | } 47 | 48 | $this->renderInfoBox('This is a list of commands that match the given pattern. Sorted by relevance.'); 49 | 50 | $commands = $this->getRepositoryCollection()->searchByPattern($pattern); 51 | 52 | $filteredCommands = []; 53 | $perfectCommands = []; 54 | 55 | foreach ($commands as $key => $command) { 56 | 57 | // var_dump($command->getName() . ' - ' . $command->getScore()); 58 | 59 | if ($command->getScore() > $minScore) { 60 | $filteredCommands[$key] = $command; 61 | } 62 | 63 | if ($command->getScore() > self::PERFECT_SCORE) { 64 | $perfectCommands[$key] = $command; 65 | } 66 | } 67 | 68 | if (count($filteredCommands) == 0) { 69 | $filteredCommands = $commands; 70 | } 71 | 72 | if (count($perfectCommands) > 0) { 73 | $filteredCommands = $perfectCommands; 74 | } 75 | 76 | if (empty($filteredCommands)) { 77 | $this->renderErrorBox('No commands found that match the given pattern.'); 78 | return Command::FAILURE; 79 | } 80 | 81 | $result = $this->runFromCommands($filteredCommands, [], true); 82 | 83 | if ($result !== true) { 84 | return $result; 85 | } 86 | 87 | $this->renderInfoBox('We are asking the Forrest AI...'); 88 | 89 | return $this->runAiAskCommand($pattern); 90 | } 91 | 92 | /** 93 | * The run command can also be applied to a file. This is a shortcut for the 94 | * search:file symfony console command. 95 | */ 96 | private function runAiAskCommand(array $patterns): int 97 | { 98 | $arguments = [ 99 | 'question' => $patterns, 100 | '--silent' => true 101 | ]; 102 | 103 | $fileArguments = new ArrayInput($arguments); 104 | $fileCommand = $this->getApplication()->find(AskCommand::COMMAND_NAME); 105 | 106 | return $fileCommand->run($fileArguments, $this->getOutput()); 107 | } 108 | 109 | } 110 | -------------------------------------------------------------------------------- /src/Adapter/GistAdapter.php: -------------------------------------------------------------------------------- 1 | client = $client; 35 | } 36 | 37 | /** 38 | * @inheritDoc 39 | */ 40 | public function getType(): string 41 | { 42 | return self::TYPE; 43 | } 44 | 45 | /** 46 | * Return the raw gists from 47 | * 48 | * @throws GuzzleException 49 | */ 50 | private function getRawGists(string $username): array 51 | { 52 | if (empty($this->rawGist)) { 53 | try { 54 | $response = $this->client->get(sprintf(self::GIST_URL, $username)); 55 | } catch (\Exception $exception) { 56 | if (str_contains($exception->getMessage(), 'rate limit exceeded')) { 57 | throw new RateLimitExceededException('Gist API rate limit exceeded.'); 58 | } else { 59 | throw $exception; 60 | } 61 | } 62 | // @todo: validate json before decode and serialize to an object 63 | $this->rawGist = json_decode((string)$response->getBody(), true); 64 | } 65 | 66 | return $this->rawGist; 67 | } 68 | 69 | /** 70 | * @inheritDoc 71 | */ 72 | public function getCommands(bool $withParameters = true): array 73 | { 74 | $gists = $this->getRawGists($this->username); 75 | 76 | $commands = []; 77 | 78 | foreach ($gists as $gist) { 79 | if (str_starts_with($gist['description'], $this->prefix)) { 80 | foreach ($gist['files'] as $file) { 81 | $name = $file['filename']; 82 | $description = str_replace($this->prefix, '', $gist['description']); 83 | $rawUrl = $file[self::GIST_FIELD_RAW_URL]; 84 | $commands[] = new GistCommand($name, $description, $rawUrl, $this->client); 85 | } 86 | } 87 | } 88 | 89 | return $commands; 90 | } 91 | 92 | /** 93 | * @inheritDoc 94 | */ 95 | public static function fromConfigArray(array $config, Client $client): Adapter 96 | { 97 | $adapter = new self($config['username'], $config['prefix']); 98 | $adapter->setClient($client); 99 | 100 | return $adapter; 101 | } 102 | 103 | public function getCommand(string $identifier): Command 104 | { 105 | $commands = $this->getCommands(true); 106 | foreach ($commands as $command) { 107 | if ($command->getName() == $identifier) { 108 | return $command; 109 | } 110 | } 111 | 112 | throw new \RuntimeException('No command with name ' . $identifier . ' found.'); 113 | } 114 | 115 | /** 116 | * @inheritDoc 117 | */ 118 | public function assertHealth(): void 119 | { 120 | try { 121 | $this->client->get('https://api.github.com'); 122 | } catch (\Exception $exception) { 123 | throw new \RuntimeException('Cannot connect to the github API. Please check if your computer is online.'); 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/CliCommand/Repository/Command/AddCommand.php: -------------------------------------------------------------------------------- 1 | setAliases(['add']); 24 | } 25 | 26 | protected function doExecute(InputInterface $input, OutputInterface $output): int 27 | { 28 | $this->renderInfoBox([ 29 | 'Create a new command. If you want to create more complex commands', 30 | 'please use a text editor/IDE and edit the YAML file manually.', 31 | '', 32 | 'Please select a repository you want to add a command to. Only ', 33 | 'editable repositories are shown.' 34 | ]); 35 | 36 | $this->enrichRepositories(); 37 | 38 | $repositories = $this->getRepositoryCollection()->getRepositories(); 39 | 40 | $rows = []; 41 | $editableRepositories = []; 42 | $count = 0; 43 | 44 | foreach ($repositories as $repository) { 45 | if ($repository instanceof EditableRepository) { 46 | $count++; 47 | $editableRepositories[$count] = $repository; 48 | $rows[] = [ 49 | $count, 50 | $repository->getName(), 51 | $repository->getDescription() 52 | ]; 53 | } 54 | } 55 | 56 | if ($count == 0) { 57 | $this->renderErrorBox('No editable repository found. Please create one using the repository:create command.'); 58 | return SymfonyCommand::FAILURE; 59 | } 60 | 61 | OutputHelper::renderTable($output, ['ID', 'Name', 'Description'], $rows); 62 | 63 | $output->writeln(''); 64 | 65 | /** @var QuestionHelper $questionHelper */ 66 | $questionHelper = $this->getHelper('question'); 67 | 68 | $repoId = $this->chooseRepository($output, $input, $questionHelper, $count); 69 | 70 | $repo = $editableRepositories[$repoId]; 71 | 72 | $output->writeln(''); 73 | 74 | $commandName = $this->askQuestion('Command name [example: "files:find"]: '); 75 | $commandDescription = $this->askQuestion('Command description: '); 76 | $commandPrompt = $this->askQuestion('Command prompt: '); 77 | 78 | $repo->addCommand(new Command($commandName, $commandDescription, $commandPrompt)); 79 | 80 | $this->renderInfoBox('Successfully added a new command.'); 81 | 82 | return SymfonyCommand::SUCCESS; 83 | } 84 | 85 | /** 86 | * Choose a repository that is editable. 87 | */ 88 | private function chooseRepository(OutputInterface $output, InputInterface $input, QuestionHelper $questionHelper, int $count): int 89 | { 90 | $repoId = 0; 91 | 92 | while ($repoId == 0) { 93 | $repoId = $questionHelper->ask($input, $output, new Question('Which repository do you want to edit [1-' . $count . ']? ')); 94 | if ((int)$repoId < 0 || $repoId > $count) { 95 | $output->writeln('The ID must be between 1 and ' . $count . '. Please chose again: '); 96 | $repoId = 0; 97 | } 98 | } 99 | 100 | return (int)$repoId; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Command/CommandFactory.php: -------------------------------------------------------------------------------- 1 | setPlainCommandArray($commandConfig); 35 | 36 | if (array_key_exists(self::CONFIG_FIELD_OUTPUT, $commandConfig)) { 37 | $command->setOutputFormat($commandConfig[self::CONFIG_FIELD_OUTPUT]); 38 | } 39 | 40 | if (array_key_exists(self::CONFIG_FIELD_ALLOWED_IN_HISTORY, $commandConfig) && $commandConfig[self::CONFIG_FIELD_ALLOWED_IN_HISTORY] === false) { 41 | $command->setAllowedInHistory(false); 42 | } 43 | 44 | if (array_key_exists(self::CONFIG_FIELD_PARAMETERS, $commandConfig)) { 45 | $parameterConfig = $commandConfig[self::CONFIG_FIELD_PARAMETERS]; 46 | } else { 47 | $parameterConfig = []; 48 | } 49 | 50 | 51 | if (array_key_exists(self::CONFIG_FIELD_SCORE, $commandConfig)) { 52 | $command->setScore((float)$commandConfig[self::CONFIG_FIELD_SCORE]); 53 | } 54 | 55 | if (is_string($parameterConfig)) { 56 | throw new \RuntimeException('The configuration is malformed. Array expected but "' . $parameterConfig . '" found.'); 57 | } 58 | 59 | if ($withParameters) { 60 | $command->setParameters(self::createParameters($prompt, $parameterConfig)); 61 | } 62 | 63 | return $command; 64 | } 65 | 66 | /** 67 | * Create the parameter objects from the array. 68 | * 69 | * @return Parameter[] 70 | */ 71 | private static function createParameters(string $prompt, array $parameterConfig): array 72 | { 73 | $parameterNames = self::extractParametersFromPrompt($prompt); 74 | 75 | $parameters = []; 76 | 77 | foreach ($parameterNames as $parameterName) { 78 | if (array_key_exists($parameterName, $parameterConfig)) { 79 | $config = $parameterConfig[$parameterName]; 80 | } else { 81 | $config = []; 82 | } 83 | $parameters[$parameterName] = ParameterFactory::create($config); 84 | } 85 | 86 | return $parameters; 87 | } 88 | 89 | /** 90 | * Use regular expressions to extract the parameters from the prompt. 91 | */ 92 | private static function extractParametersFromPrompt(string $prompt): array 93 | { 94 | preg_match_all('^\${[a-zA-Z_:\x7f-\xff][a-zA-Z0-9_:\x7f-\xff\/]*}^', $prompt, $matches); 95 | 96 | $parameters = []; 97 | 98 | foreach ($matches[0] as $match) { 99 | $parameters[] = str_replace(Parameter::PARAMETER_PREFIX, '', str_replace(Parameter::PARAMETER_POSTFIX, '', $match)); 100 | } 101 | 102 | return $parameters; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/CliCommand/Ai/AskCommand.php: -------------------------------------------------------------------------------- 1 | addArgument('question', InputArgument::IS_ARRAY, 'The question you want to have answered.', []); 25 | $this->addOption('force', null, InputOption::VALUE_NONE, 'Run the command without asking for permission.'); 26 | $this->addOption('silent', null, InputOption::VALUE_NONE, 'Run the command without explanation.'); 27 | 28 | parent::configure(); 29 | } 30 | 31 | protected function doExecute(InputInterface $input, OutputInterface $output): int 32 | { 33 | $this->enrichRepositories(); 34 | 35 | $silent = $input->getOption('silent'); 36 | 37 | if (!$silent) { 38 | \Startwind\Forrest\Output\OutputHelper::renderHeader($output); 39 | } 40 | 41 | $aiQuestion = trim(implode(' ', $input->getArgument('question'))); 42 | 43 | if ($aiQuestion == 'how') { 44 | OutputHelper::writeErrorBox($output, ["Please provide a question."]); 45 | return SymfonyCommand::FAILURE; 46 | } 47 | 48 | if ($aiQuestion && !str_ends_with($aiQuestion, '?')) { 49 | $aiQuestion = $aiQuestion . '?'; 50 | } 51 | 52 | /** @var \Symfony\Component\Console\Helper\QuestionHelper $questionHelper */ 53 | $questionHelper = $this->getHelper('question'); 54 | 55 | if (!$aiQuestion) { 56 | OutputHelper::writeInfoBox($output, ["Hi, I am Forrest, your AI command line helper. How can I help you?"]); 57 | $output->writeln(''); 58 | $aiQuestion = $questionHelper->ask($input, $output, new Question(' Your question: ')); 59 | $output->writeln(['', '']); 60 | } else { 61 | if (!$silent) { 62 | OutputHelper::writeInfoBox($output, [ 63 | "Question: " . ucfirst($aiQuestion) 64 | ]); 65 | } 66 | } 67 | 68 | $answers = $this->getRepositoryCollection()->ask($aiQuestion); 69 | 70 | foreach ($answers as $repoAnswers) { 71 | foreach ($repoAnswers as $answer) { 72 | /** @var Answer $answer */ 73 | $output->writeln(OutputHelper::indentText($this->formatCliText($answer->getAnswer()))); 74 | $command = $answer->getCommand(); 75 | 76 | if ($command->getPrompt()) { 77 | return $this->runCommand($answer->getCommand(), []); 78 | } 79 | } 80 | } 81 | 82 | return SymfonyCommand::SUCCESS; 83 | } 84 | 85 | private function formatCliText(string $text): string 86 | { 87 | preg_match_all('#```shell((.|\n)*?)```#', $text, $matches); 88 | 89 | if (count($matches[1]) == 1) { 90 | $shell = $matches[1][0]; 91 | 92 | $shellNew = implode("\n", OutputHelper::indentText(trim($shell), 2, 100, ' | ')); 93 | 94 | $text = str_replace($matches[0][0], $shellNew, $text); 95 | } 96 | 97 | preg_match_all('#`(.*)`#', $text, $matches); 98 | 99 | if (count($matches[1]) > 0) { 100 | foreach ($matches[0] as $key => $match) { 101 | $text = str_replace($match, '' . $matches[1][$key] . '', $text); 102 | } 103 | } 104 | 105 | return $text; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/CliCommand/Search/FileCommand.php: -------------------------------------------------------------------------------- 1 | addArgument('filename', InputArgument::REQUIRED, 'The filename you want to get commands for.'); 27 | $this->addArgument('pattern', InputArgument::OPTIONAL, 'Filter the results for a given pattern.'); 28 | 29 | $this->addOption('force', null, InputOption::VALUE_NONE, 'Run the command without asking for permission.'); 30 | 31 | $this->setAliases(['file']); 32 | } 33 | 34 | protected function doExecute(InputInterface $input, OutputInterface $output): int 35 | { 36 | OutputHelper::renderHeader($output); 37 | 38 | $this->enrichRepositories(); 39 | 40 | $filename = $input->getArgument('filename'); 41 | $pattern = $input->getArgument('pattern'); 42 | 43 | /** @var QuestionHelper $questionHelper */ 44 | $questionHelper = $this->getHelper('question'); 45 | 46 | if (!file_exists($filename)) { 47 | $this->renderErrorBox('File not found.'); 48 | return SymfonyCommand::FAILURE; 49 | } 50 | 51 | $filenames = [ 52 | basename($filename), 53 | pathinfo($filename, PATHINFO_EXTENSION) 54 | ]; 55 | 56 | if (is_dir($filename)) { 57 | $filenames[] = FileParameter::DIRECTORY; 58 | } 59 | 60 | $fileCommands = $this->getRepositoryCollection()->searchByFile($filenames); 61 | 62 | if ($pattern) { 63 | foreach ($fileCommands as $key => $fileCommand) { 64 | if (str_contains(strtolower($fileCommand->getName()), strtolower($pattern))) { 65 | continue; 66 | } 67 | if (str_contains(strtolower($fileCommand->getDescription()), strtolower($pattern))) { 68 | continue; 69 | } 70 | unset($fileCommands[$key]); 71 | } 72 | } 73 | 74 | $this->renderInfoBox('This is a list of commands that are applicable to the given file or file type.'); 75 | 76 | if (empty($fileCommands)) { 77 | $this->renderErrorBox('No commands found that match this file type.'); 78 | return SymfonyCommand::FAILURE; 79 | } 80 | 81 | $command = OutputHelper::renderCommands( 82 | $output, 83 | $input, 84 | $questionHelper, 85 | $fileCommands, 86 | null, 87 | -1, 88 | true 89 | ); 90 | 91 | if ($command === false) { 92 | return SymfonyCommand::FAILURE; 93 | } 94 | 95 | $output->writeln(''); 96 | 97 | $values = [$this->getParameterIdentifier($command, $filenames) => $filename]; 98 | 99 | return $this->runCommand($command, $values); 100 | } 101 | 102 | /** 103 | * Return the identifier of the parameter that fits the filename. 104 | */ 105 | private function getParameterIdentifier(Command $command, array $filenames): string 106 | { 107 | foreach ($command->getParameters() as $identifier => $parameter) { 108 | if ($parameter instanceof FileParameter) { 109 | if ($parameter->isCompatibleWithFiles($filenames)) { 110 | return $identifier; 111 | } 112 | } 113 | } 114 | throw new \RuntimeException('No parameter found that excepts the given file name.'); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/Output/RunHelper.php: -------------------------------------------------------------------------------- 1 | input = $input; 38 | $this->output = $output; 39 | $this->questionHelper = $questionHelper; 40 | $this->configHandler = $configHandler; 41 | $this->historyHandler = $historyHandler; 42 | } 43 | 44 | public function handleForceOption(bool $force, Command $command, string $repositoryIdentifier): bool 45 | { 46 | if (!$force) { 47 | return true; 48 | } 49 | 50 | $hasChanged = $this->configHandler->hasChecksumChanged($command, $repositoryIdentifier); 51 | 52 | if ($hasChanged) { 53 | return $this->questionHelper->ask($this->input, $this->output, new ConfirmationQuestion(' The signature of the command has changed since you last run it. Do you confirm to still run it? [y/n] ', false)); 54 | } else { 55 | return true; 56 | } 57 | } 58 | 59 | public function handleRunnable(Command $command, string $finalPrompt): bool 60 | { 61 | if (!$command->isRunnable()) { 62 | $copied = OSHelper::copyToClipboard($finalPrompt); 63 | 64 | if ($copied) { 65 | $clipboardText = " It was copied to your clipboard."; 66 | } else { 67 | $clipboardText = ""; 68 | } 69 | 70 | OutputHelper::writeWarningBox($this->output, [ 71 | 'This command was marked as not runnable by Forrest.' . $clipboardText 72 | ]); 73 | 74 | return false; 75 | } else { 76 | return true; 77 | } 78 | } 79 | 80 | public function confirmRun(bool $force): bool 81 | { 82 | if (!$force) { 83 | return $this->questionHelper->ask($this->input, $this->output, new ConfirmationQuestion(' Are you sure you want to run that command? [y/n] ', false)); 84 | } 85 | 86 | return true; 87 | } 88 | 89 | /** 90 | * Run every single command in the executable command. 91 | */ 92 | public function executeCommand(OutputInterface $output, Command $actualCommand, Prompt $prompt): int 93 | { 94 | $commands = CommandRunner::stringToMultilinePrompt($prompt->getFinalPrompt()); 95 | 96 | $commandRunner = new CommandRunner($this->historyHandler); 97 | 98 | foreach ($commands as $command) { 99 | if (count($commands) > 1) { 100 | if (PHP_VERSION >= 8) { 101 | OutputHelper::writeMessage($output, 'Running: ' . $command, '', ''); 102 | } else { 103 | OutputHelper::writeInfoBox($output, 'Running: ' . $command); 104 | } 105 | } 106 | $exitCode = $commandRunner->execute( 107 | $output, 108 | $command, 109 | true, 110 | $actualCommand->isAllowedInHistory() 111 | ); 112 | 113 | if ($exitCode != SymfonyCommand::SUCCESS) { 114 | return $exitCode; 115 | } 116 | } 117 | 118 | return SymfonyCommand::SUCCESS; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /docs/creating-repository.md: -------------------------------------------------------------------------------- 1 | # Creating a custom Repository 2 | 3 | Commands are divided into repositories in Forrest. We recommend that these are structured thematically and thus also grouped. This facilitates later administration and use. 4 | 5 | When creating local repositories we recommend to choose our [YAML format](formats/yaml-format.md). 6 | 7 | If you want to see examples of YAML file please visit the [official Forrest GitHub Repository](https://github.com/startwind/forrest-directory/tree/main/repositories) and browse through the files. 8 | 9 | ## Local Repository 10 | 11 | In the beginning, you should always start with a local and therefore private repository. Here, you can create and manage your own commands without affecting other users. 12 | 13 | For the creation, Forrest provides the appropriate commands. 14 | 15 | ```shell 16 | forrest repository:create ~/local-forrest.yml 17 | ``` 18 | 19 | Once the repository is created, you can register it. Normally you will be asked if you want to add it right after the creation. In most cases this makes sense. 20 | 21 | Newly created repositories always come with a default command that can be customized or deleted. Their content looks similar to this: 22 | 23 | ```yaml 24 | repository: 25 | name: 'local commands' 26 | description: 'Commands for local usage' 27 | identifier: local-commands 28 | 29 | commands: 30 | my-unique-command-name: 31 | name: 'foo:bar' 32 | description: 'Command description' 33 | prompt: 'ls -lah' 34 | ``` 35 | 36 | For adding new commands please have a look at the [yaml format description](formats/yaml-format.md). 37 | 38 | If you did not register the repository on creation you can use the `repository:register` command. 39 | 40 | ````shell 41 | forrest repository:create ~/local-forrest.yml 42 | ```` 43 | 44 | ## Remote Repository 45 | 46 | If you want to share a repository with the commands it contains with colleagues and others, it makes sense to store this repository on a server that is accessible to everyone. 47 | 48 | The easiest way, and the one we recommend, is to do this via GitHub. In principle, it is sufficient to upload the locally created repository here. Instead of a local file, you then specify the `raw url`. 49 | 50 | This looks similar to this: 51 | `https://raw.githubusercontent.com/startwind/forrest-directory/main/repositories/friends-of-linux.yml` 52 | 53 | In this case the command line to register that repository is: 54 | 55 | ```shell 56 | forrest repository:create https://raw.githubusercontent.com/startwind/forrest-directory/main/repositories/friends-of-linux.yml 57 | ``` 58 | 59 | Afterwards everybody who know the URL can use the commands. 60 | 61 | ## Private Remote Repository (GitHub) 62 | 63 | Most of the commands we use on a daily basis are not private, but in most jobs there are some that are. This is why we introduced the private repositories that are stored in the users GitHub account. Creating those repos it pretty straight forward. Just create a `private` GitHub repository and put a [repository yaml file](formats/yaml-format.md) in there. 64 | 65 | Afterwards you have to add your repository to the Forrest config file which is located in your home dir. 66 | 67 | ```yaml 68 | # ~/.forrest/config.yml 69 | 70 | repositories: 71 | gist-private: 72 | adapter: yaml 73 | name: 'private commands' 74 | description: 'Commands that contain private/secret information' 75 | config: 76 | loader: 77 | type: github 78 | config: 79 | repository: forrest-directory 80 | user: startwind 81 | file: monitoring-repo.yml 82 | token: ghp_************************************ 83 | ``` 84 | 85 | ## Piggyback Repository 86 | 87 | Forrest is trying to become the standard for easy management and use of command line tools. For this reason there are the piggyback repositories. Each open source project has the possibility to put a file named `.forrest.yml` in its root directory. As soon as Forrest is started in the same directory, the commands located there are immediately added to the list of all commands. Nothing has to be installed. 88 | 89 | ![Piggyback repo](images/piggyback_repo.png) 90 | 91 | If you have an open source project your own and want the use Forrest just [create a ticket](https://github.com/startwind/forrest/issues) and we will help you with your piggyback file. 92 | 93 | ### Example 94 | 95 | ```yaml 96 | repository: 97 | identifier: "360-monitoring" 98 | name: 360 Monitoring by WebPros 99 | description: The most important commands to hand 360 Monitoring via CLI 100 | commands: 101 | findInFiles: 102 | name: "monitor:add" 103 | description: "Add a new website to the 360 Monitoring" 104 | prompt: "app" 105 | ``` 106 | -------------------------------------------------------------------------------- /src/CliCommand/Repository/Command/MoveAllCommand.php: -------------------------------------------------------------------------------- 1 | addArgument('sourceRepository', InputArgument::REQUIRED, 'The identifier of the repository you want to move the command.'); 28 | $this->addArgument('destinationRepository', InputArgument::REQUIRED, 'The identifier of the repository you want to move the command.'); 29 | $this->addOption('removeAfterMove', 'r', InputOption::VALUE_NONE, 'The identifier of the repository you want to move the command.'); 30 | $this->addOption('prefix', 'p', InputOption::VALUE_OPTIONAL, 'The identifier of the repository you want to move the command.', ''); 31 | } 32 | 33 | protected function doExecute(InputInterface $input, OutputInterface $output): int 34 | { 35 | $this->enrichRepositories(); 36 | 37 | /** @var QuestionHelper $questionHelper */ 38 | $questionHelper = $this->getHelper('question'); 39 | 40 | $destinationRepositoryName = $input->getArgument('destinationRepository'); 41 | $sourceRepositoryName = $input->getArgument('sourceRepository'); 42 | 43 | $sourceRepository = $this->getRepositoryCollection()->getRepository($sourceRepositoryName); 44 | $destinationRepository = $this->getRepositoryCollection()->getRepository($destinationRepositoryName); 45 | 46 | if (!$sourceRepository instanceof ListAware) { 47 | throw new \RuntimeException('The given repository "' . $destinationRepositoryName . '" is not listable.'); 48 | } 49 | 50 | if (!$destinationRepository instanceof EditableRepository) { 51 | throw new \RuntimeException('The given repository "' . $destinationRepositoryName . '" is read-only.'); 52 | } 53 | 54 | $commands = $sourceRepository->getCommands(); 55 | 56 | if (count($commands) == 0) { 57 | OutputHelper::writeInfoBox($output, 'No commands found in "' . $sourceRepositoryName . '". Cancelling move command.'); 58 | return Command::SUCCESS; 59 | } 60 | 61 | OutputHelper::writeInfoBox($output, 'We are moving the following commands to the "' . $destinationRepositoryName . '" repository.'); 62 | 63 | \Startwind\Forrest\Output\OutputHelper::renderCommands($output, $input, $questionHelper, $commands); 64 | 65 | $output->writeln(''); 66 | 67 | $move = $questionHelper->ask($input, $output, new ConfirmationQuestion(' Are you sure you want to move these ' . count($commands) . ' command? (y/n) ', false)); 68 | 69 | if (!$move) { 70 | return Command::FAILURE; 71 | } 72 | 73 | $prefix = $input->getOption('prefix'); 74 | 75 | if ($prefix) { 76 | $prefix = $prefix . RepositoryCollection::COMMAND_SEPARATOR; 77 | } 78 | 79 | $progressBar = new ProgressBar($output, count($commands)); 80 | 81 | $output->writeln(''); 82 | 83 | foreach ($commands as $command) { 84 | $progressBar->advance(); 85 | 86 | $command->setName($prefix . $command->getName()); 87 | $progressBar->setMessage('Moving ' . $prefix . $command->getName()); 88 | 89 | $destinationRepository->addCommand($command); 90 | 91 | if ($sourceRepository instanceof EditableRepository) { 92 | if ($input->getOption('removeAfterMove')) { 93 | $sourceRepository->removeCommand($command->getName()); 94 | } 95 | } 96 | } 97 | 98 | $progressBar->finish(); 99 | $output->writeln(''); 100 | 101 | OutputHelper::writeInfoBox($output, 'Successfully moved ' . count($commands) . ' commands to "' . $destinationRepositoryName . '".'); 102 | 103 | return Command::SUCCESS; 104 | } 105 | } 106 | --------------------------------------------------------------------------------