├── 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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 |
--------------------------------------------------------------------------------