Repository: pablosnt/rekono-cli (language: python)

Issue:
Add link to Ko-fi donation platform to the README



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/code-style.yml b/.github/workflows/code-style.yml
index 187b3fa..317b73f 100644
--- a/.github/workflows/code-style.yml
+++ b/.github/workflows/code-style.yml
@@ -8,38 +8,39 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: Checkout
-        if: github.event_name == 'pull_request'
-        uses: actions/checkout@v2
+        uses: actions/checkout@v3
         with:
           fetch-depth: 0
-          ref: refs/pull/${{ github.event.pull_request.number }}/merge
-      
-      - name: Checkout
-        if: github.event_name != 'pull_request'
-        uses: actions/checkout@v2
       
       - name: Flake8 check
-        uses: valentijnscholten/flake8-your-pr@master
+        uses: julianwachholz/flake8-action@v2
+        with:
+          checkName: "flake8"
+          path: ./src
+          config: ./src/.flake8
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 
   mypy:
     runs-on: ubuntu-latest
+    defaults:
+      run:
+        working-directory: ./src
     steps:
       - name: Checkout
-        uses: actions/checkout@v2
+        uses: actions/checkout@v3
       
-      - uses: actions/setup-python@v2
+      - uses: actions/setup-python@v4
         with:
           python-version: '3.7'
       
       - name: Install Python dependencies
         run: |
           python -m pip install -U pip
+          python -m pip install mypy==0.931
           python -m pip install -r requirements.txt
-
-      - name: Install MyPy
-        run: python3 -m pip install mypy==0.931
       
       - name: MyPy check
-        run: mypy --namespace-packages --package rekono --install-types --non-interactive
+        run: |
+          mypy --namespace-packages --package rekono --install-types --non-interactive
+          mypy --namespace-packages --package tests --install-types --non-interactive
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 1b93727..c982b07 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -9,13 +9,16 @@ jobs:
     name: Publish Release
     runs-on: ubuntu-latest
     environment: Release
+    defaults:
+      run:
+        working-directory: ./src
     steps:
       - name: Checkout
         uses: actions/checkout@v3
         with:
-          ref: main
+          fetch-depth: 0
 
-      - uses: actions/setup-python@v2
+      - uses: actions/setup-python@v4
         with:
           python-version: '3.7'
 
diff --git a/.github/workflows/security-sast.yml b/.github/workflows/security-sast.yml
new file mode 100644
index 0000000..4162d29
--- /dev/null
+++ b/.github/workflows/security-sast.yml
@@ -0,0 +1,38 @@
+name: SAST
+on:
+  workflow_dispatch:
+  pull_request:
+    paths:
+      - '.github/workflows/**'
+      - 'src/rekono/**'
+
+jobs:
+  semgrep:
+    name: Semgrep
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v3
+        with:
+          fetch-depth: 0
+      
+      - name: Setup Python 3
+        uses: actions/setup-python@v4
+        with:
+          python-version: 3.7
+
+      - name: Install Semgrep
+        run: pip install semgrep
+      
+      - name: Scan code
+        run: semgrep --config=auto --error --json -o semgrep_code.json src/rekono/
+      
+      - name: Scan workflows
+        run: semgrep --config=auto --error --json -o semgrep_cicd.json .github/workflows/
+      
+      - name: Upload Semgrep report as GitHub artifact
+        uses: actions/upload-artifact@v3
+        with:
+          name: Semgrep
+          path: semgrep_*.json
+          if-no-files-found: warn
\ No newline at end of file
diff --git a/.github/workflows/security-secrets.yml b/.github/workflows/security-secrets.yml
index 68a2c66..d927198 100644
--- a/.github/workflows/security-secrets.yml
+++ b/.github/workflows/security-secrets.yml
@@ -9,15 +9,15 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: Checkout
-        uses: actions/checkout@v2
+        uses: actions/checkout@v3
 
       - name: Setup Python 3
-        uses: actions/setup-python@v2
+        uses: actions/setup-python@v4
         with:
           python-version: 3.7
 
       - name: Install detect-secrets
-        run: pip install detect-secrets==1.2.0
+        run: pip install detect-secrets==1.4.0
 
       - name: Scan
         id: scan
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 643f837..927b820 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -9,7 +9,7 @@ repos:
             require_serial: true
             verbose: true
     - repo: https://github.com/Yelp/detect-secrets
-      rev: v1.2.0
+      rev: v1.4.0
       hooks:
           - id: detect-secrets
             args: ["--baseline", ".secrets.baseline"]
@@ -17,3 +17,4 @@ repos:
       rev: 4.0.1
       hooks:
           - id: flake8
+            args: ["--config", "src/.flake8", "src/rekono/", "src/tests/"]
diff --git a/.secrets.baseline b/.secrets.baseline
index c8bc309..fdc1f95 100644
--- a/.secrets.baseline
+++ b/.secrets.baseline
@@ -1,5 +1,5 @@
 {
-  "version": "1.2.0",
+  "version": "1.4.0",
   "plugins_used": [
     {
       "name": "ArtifactoryDetector"
@@ -72,6 +72,10 @@
     {
       "path": "detect_secrets.filters.allowlist.is_line_allowlisted"
     },
+    {
+      "path": "detect_secrets.filters.common.is_baseline_file",
+      "filename": ".secrets.baseline"
+    },
     {
       "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
       "min_level": 2
@@ -110,11 +114,11 @@
         "type": "Secret Keyword",
         "filename": ".github/workflows/security-secrets.yml",
         "hashed_secret": "108b310facc1a193833fc2971fd83081f775ea0c",
-        "is_secret": false,
         "is_verified": false,
-        "line_number": 24
+        "line_number": 24,
+        "is_secret": false
       }
     ]
   },
-  "generated_at": "2022-03-26T13:41:59Z"
+  "generated_at": "2023-01-28T13:02:57Z"
 }
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e9b2fce..2e51ad1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,17 @@ All notable changes to this project will be documented in this file.
 
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [2.0.0] - 2023-05-07
+
+### Added
+
+- [**BREAKING**] Remove `install`, `update`, `uninstall` and `services` commands (https://github.com/pablosnt/rekono-cli/issues/34)
+- [**BREAKING**] Remove support for Rekono basic authentication (https://github.com/pablosnt/rekono-cli/issues/38)
+- [**BREAKING**] Change `rekono` command by `rekono-cli` command (https://github.com/pablosnt/rekono-cli/issues/46)
+- Optimize, improve, clean and test source code (https://github.com/pablosnt/rekono-cli/issues/36)
+- Specific commands to manage specific Rekono entities (https://github.com/pablosnt/rekono-cli/issues/40)
+
+
 ## [1.1.0] - 2023-01-11
 
 ### Added
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ed73f5c..3639e78 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,33 +1,62 @@
-Thank you for making Rekono greater.
+**Thank you for making Rekono greater!**
+
+
+## Branches
+
+**Create Pull Requests to the `develop` branch of this project**. All the Pull Requests should be reviewed and approved before been merged and after that, your code will be included in the next release.
+
+```mermaid
+gitGraph
+    commit
+    commit tag: "1.0.0"
+    branch develop
+    checkout develop
+    commit
+    commit
+    branch feature/new-contribution
+    checkout feature/new-contribution
+    commit
+    checkout develop
+    merge feature/new-contribution
+    commit
+    checkout main
+    merge develop tag: "1.1.0"
+```
+
 
-## Issues
 
-You can create different kinds of [Issues](https://github.com/pablosnt/rekono-cli/issues/new/choose) to report bugs, request new features or ask for help.
+## Development environment
 
-Please, don't report security vulnerabilities in GitHub Issues. See our [Security Policy](https://github.com/pablosnt/rekono-cli/security/policy).
+You can follow the [from source installation](https://github.com/pablosnt/rekono-cli#from-source) installation guide to prepare your development environment.
 
 
-## Contributing to Rekono
+### Unit Tests
 
-You can create Pull Requests to the `develop` branch of this project. All the Pull Requests should be reviewed and approved before been merged. After that, your code will be included on the next Rekono release.
+Unit tests can be executed using this command:
 
-In this section you can see how to achieve that and the things that you should to take into account.
+```
+# pwd: src/
+coverage run -m pytest
+```
 
-### Development environment
+New Rekono contributions should tested using unit tests.
 
-You can follow the [`From Source`](https://github.com/pablosnt/rekono-cli#from-source) installation guide to prepare your development environment.
 
 ### CI/CD
 
 This project has the following checks in _Continuous Integration_:
 
-1. `Code style`: check the source code style using the tools `mypy`, `flake8` and `eslint`.
+1. `Code style`: check the source code style using `mypy` and `flake8`.
 
-2. `SCA`: check the project dependencies to find libraries with known vulnerabilities. Software Composition Analysis.
+2. `SAST`: scan source code using `semgrep` to find vulnerabilities. This is a Static Application Security Testing.
 
-3. `Secrets scanning`: check the source code to find leaked passwords, tokens or other credentials that could be exposed in the GitHub repository.
+3. `Secrets scanning`: check the source code using `detect-secrets` to find leaked passwords, tokens or other credentials that could be exposed.
 
-**All CI/CD checks should be passed before merging any Pull Request**, so it's advised to install the pre-commit hooks in your local repositories using this commands:
+4. `Unit testing`: check if the project works executing the unit tests.
+
+5. `Snyk`: check the project dependencies to find libraries with known vulnerabilities. This is a Software Composition Analysis (SCA).
+
+**All CI/CD checks should be passed before merging any Pull Request**, so it's advised to install the pre-commit hooks in your local repositories to check your changes before commit them:
 
 ```
 # pwd: root directory
@@ -35,9 +64,12 @@ python3 -m pip install pre-commit
 pre-commit install
 ```
 
+
 ### Way of Code
 
 There are some guidelines to keep the code clean and ensure the correct working of the application:
 
-- Comment your code, specially to document the classes and methods.
+- Keep code style
+- Comment your code, specially classes and methods.
+- Make unit tests for all your code to ensure its correct working.
 - Don't include code vulnerabilities or vulnerable libraries.
diff --git a/MANIFEST.in b/MANIFEST.in
deleted file mode 100644
index 691204b..0000000
--- a/MANIFEST.in
+++ /dev/null
@@ -1,4 +0,0 @@
-include README.md
-include LICENSE.txt
-include requirements.txt
-include rekono/services/templates/rekono.service
\ No newline at end of file
diff --git a/README.md b/README.md
index 8465ebf..0becbb1 100644
--- a/README.md
+++ b/README.md
@@ -1,52 +1,51 @@
 <p align="center">
-  <a href="https://snyk.io/test/github/pablosnt/rekono-cli" alt="SCA">
+  <a href="https://snyk.io/test/github/pablosnt/rekono-cli" alt="SCA" target="_blank">
     <img src="https://badgen.net/snyk/pablosnt/rekono-cli?label=Vulnerabilities&labelColor=black&icon=https://snyk.io/wp-content/uploads/patch-white.svg">
   </a>
-  <a href="https://github.com/pablosnt/rekono-cli/actions/workflows/security-secrets.yml" alt="Secrets scanning">
+  <a href="https://github.com/pablosnt/rekono-cli/actions/workflows/security-secrets.yml" alt="Secrets scanning" target="_blank">
     <img src="https://github.com/pablosnt/rekono-cli/actions/workflows/security-secrets.yml/badge.svg"/>
   </a>
-  <a href="https://github.com/pablosnt/rekono-cli/actions/workflows/code-style.yml" alt="Code style">
+  <a href="https://github.com/pablosnt/rekono-cli/actions/workflows/code-style.yml" alt="Code style" target="_blank">
     <img src="https://github.com/pablosnt/rekono-cli/actions/workflows/code-style.yml/badge.svg"/>
   </a>
-  <a href="https://discord.gg/Zyduu5C7M3">
+  <a href="https://discord.gg/Zyduu5C7M3" target="_blank">
     <img src="https://img.shields.io/badge/Discord-Join-black?style=social&logo=discord"/>
   </a>
+  <a href="https://ko-fi.com/pablosnt" target="_blank">
+    <img src="https://ko-fi.com/img/githubbutton_sm.svg" width="160"/>
+  </a>
+  <a href="https://www.buymeacoffee.com/pablosnt" target="_blank">
+    <img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" width="75"/>
+  </a>
 </p>
 
-# <p align="center"><img src="assets/logo-black.png" width="500"/></p>
-
-This is a Command Line Interface to manage the [Rekono](https://github.com/pablosnt/rekono) platform:
+# <p align="center"><img src="https://raw.githubusercontent.com/pablosnt/rekono/main/rekono/frontend/public/static/logo-black.png" width="500"/></p>
 
-- Make HTTP requests to the Rekono API REST
-- Installation of Rekono in personal Linux environments
-- Management of the Rekono services in personal Linux environments
+Command Line Interface to make requests to the [Rekono](https://github.com/pablosnt/rekono) API REST.
 
 
 ## Usage
 
-|Command|Description|
-|-------|-----------|
-|`api delete <endpoint>`|HTTP DELETE request to Rekono API|
-|`api get <endpoint> --parameter <key>=<value>`|HTTP GET request to Rekono API|
-|`api post <endpoint> --data <data>`|HTTP POST request to Rekono API|
-|`api put <endpoint> --data <data>`|HTTP PUT request to Rekono API|
-|`install`|Install Rekono in the system|
-|`install --all-tools`|Install Rekono and all supported tools|
-|`update`|Update Rekono to the last version|
-|`uninstall`|Uninstall Rekono from your system|
-|`services start --execution-workers <N>`|Start all Rekono services with N executions workers (3 by default)|
-|`services stop`|Stop all Rekono services|
-|`services restart`|Restart all Rekono services|
+<img width="507" alt="usage" src="https://user-images.githubusercontent.com/69458381/224380037-19638197-75dc-457c-b5aa-7dcc1f9d1a4d.png">
+
+> Rekono API documentation is available in `/api/schema/swagger-ui.html` and `/api/schema/redoc/` of Rekono instances
+
+## Library usage
 
-> :warning: Commands to manage Rekono installation are only advised for local and personal usage in Linux environements. Otherwise [Docker](https://github.com/pablosnt/rekono#docker) is advised.
+Rekono CLI can be also used as Python 3 library, so that it's possible to create custom Rekono scripts. For example, with the following code it's possible to create a Rekono client to make custom API requests:
+
+```python
+from rekono.client.api import Rekono
+client = Rekono(url='https://127.0.0.1', token='my secret api token')           # Create Rekono client
+response = client.get('/api/tools/1/')                                          # GET request to get tool with ID 1
+```
 
-You can reach Rekono in http://127.0.0.1:3000/
 
 ## Installation
 
 ### PIP
 
-```
+```bash
 pip3 install rekono-cli
 ```
 
@@ -57,44 +56,52 @@ pip3 install rekono-cli
 
 2. Install the dependencies:
 
-    ```
-    pip3 install -r requirements.txt
+    ```bash
+    pip3 install -r src/requirements.txt
     ```
 
 3. Execute the CLI:
 
-    ```
-    python3 rekono/main.py --help
+    ```bash
+    python3 src/rekono/main.py --help
     ```
 
 
 ## Configuration
 
-You can use the following environment variables to configure the Rekono CLI:
+You can use the `REKONO_TOKEN` environment variable to configure the API token for Rekono authentication.
 
-- `REKONO_HOME`: Directory for Rekono installation. By default, Rekono will be installed in `/opt/rekono`
-- `REKONO_URL`: Rekono URL in format `<schema>://<host>` to be used in API requests
-- `REKONO_TOKEN`: API token for Rekono API authentication
 
-Check the [Rekono configuration](https://github.com/pablosnt/rekono/wiki/Configuration) for more information.
+## Reach Us
 
-
-## Support
-
-You can reach us on:
+You can get support, ask questions, solve doubts or solve problems using:
 
 <p>
-  <a href="https://github.com/pablosnt/rekono-cli/issues/new?labels=help+wanted%2C+question&template=support.md" alt="GitHub Issue">
-    <img src="https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png" width="64"/>
+  <a href="https://github.com/pablosnt/rekono/issues/new?labels=help+wanted%2C+question&template=support.md" alt="GitHub Issue">
+    <img src="https://github.com/fluidicon.png" width="64"/>
   </a>
   <a href="https://discord.gg/Zyduu5C7M3" alt="Discord">
     <img src="https://assets-global.website-files.com/6257adef93867e50d84d30e2/636e0a69f118df70ad7828d4_icon_clyde_blurple_RGB.svg" width="64"/>
   </a>
+  <a href="mailto:rekono.project@gmail.com" alt="Mail">
+    <img src="https://www.gstatic.com/images/branding/product/2x/gmail_2020q4_512dp.png" width="64"/>
+  </a>
+</p>
+
+Rekono is an open source project that we really love to maintain and it's absolutely our pleasure, but we would like to offer the possibility of supporting Rekono's development via donations. At the moment, the project only needs its maintainer's time to stay up to date with new features and fix bugs. However, in the future, it could need more expensive resources like hosting, new web pages for documentation, the inclusion of premium hacking tools, etc. With the help received from our supporters, Rekono will be able to grow fastly and have the resources that it deserves. Of course, you can use the donations just to appreciate our work. Thank you for your help!
+
+<p>
+  <a href="https://ko-fi.com/pablosnt" target="_blank">
+    <img src="https://storage.ko-fi.com/cdn/brandasset/kofi_s_tag_white.png" width="130"/>
+  </a>
+  <a href="https://www.buymeacoffee.com/pablosnt" target="_blank">
+    <img src="https://img.buymeacoffee.com/button-api/?text=Buy me a coffee&emoji=&slug=pablosnt&button_colour=FFDD00&font_colour=000000&font_family=Cookie&outline_colour=000000&coffee_colour=ffffff"/>
+  </a>
 </p>
 
-If you need more specific help, you can also mail rekono.project@gmail.com.
+> The main differences between them are that BuyMeACoffe charges fees over each donation while Ko-fi doesn't and Ko-fi supports PayPal payments while BuyMeACoffe doesn't
 
 
 ## License
 
-Rekono is licensed under the [GNU GENERAL PUBLIC LICENSE Version 3](./LICENSE.md)
\ No newline at end of file
+Rekono is licensed under the [GNU GENERAL PUBLIC LICENSE Version 3](../LICENSE.md)
diff --git a/assets/logo-black.png b/assets/logo-black.png
deleted file mode 100644
index e3f9552..0000000
Binary files a/assets/logo-black.png and /dev/null differ
diff --git a/mypy.ini b/mypy.ini
deleted file mode 100644
index 7c93b67..0000000
--- a/mypy.ini
+++ /dev/null
@@ -1,4 +0,0 @@
-[mypy]
-files = rekono/**
-; Mypy fails due to some external imports without hints
-ignore_missing_imports = True
\ No newline at end of file
diff --git a/rekono/api/__init__.py b/rekono/api/__init__.py
deleted file mode 100644
index d96b2b7..0000000
--- a/rekono/api/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-'''Rekono API Rest.'''
\ No newline at end of file
diff --git a/rekono/api/commands.py b/rekono/api/commands.py
deleted file mode 100644
index 610fb88..0000000
--- a/rekono/api/commands.py
+++ /dev/null
@@ -1,114 +0,0 @@
-import json
-from getpass import getpass
-from typing import List, Optional, Tuple
-
-import click
-from rekono.api.requests import request
-from rekono.config import API_TOKEN, API_URL
-
-import requests
-
-
-def get_url_and_token() -> Tuple[str, Optional[str]]:
-    '''Get URL and API token to make Rekono API requests.
-
-    Returns:
-        Tuple[str, str]: Rekono URL and API token.
-    '''
-    api_url = API_URL
-    api_token = API_TOKEN
-    if not api_token:
-        api_url = input('API URL: ')                                            # Ask user for API URL if needed
-        if api_token:
-            click.echo()
-    if not api_token:
-        username = input('Username: ')                                          # Ask user for Rekono username
-        password = getpass('Password: ')                                        # Ask user for Rekono password
-        click.echo()
-        response = request(                                                     # Get API token using Rekono credentials
-            'post', api_url, '/api/api-token/',
-            data='{' + f'"username": "{username}", "password": "{password}"' + '}'
-        )
-        if response.status_code == 200:
-            api_token = response.json().get('token')
-    return api_url, api_token
-
-
-def display_api_response(response: requests.Response) -> None:
-    '''Display Rekono API responses.
-
-    Args:
-        response (requests.Response): HTTP response
-    '''
-    if response.json():
-        text = json.dumps(response.json(), indent=4)                            # Parse response body as JSON
-    else:
-        text = response.text if response.text else 'No response content'        # Plain response body
-    click.echo(text)
-
-
-@click.group('api', help='Make requests to Rekono API REST')
-def api():
-    '''Rekono API requests.'''
-    pass
-
-
-@api.command('get', help='HTTP GET request to Rekono API')
-@click.argument('endpoint', type=str, nargs=1)
-@click.option(
-    '-p', '--parameter', 'query_parameters', type=str, multiple=True,
-    required=False, help='HTTP query parameter. Format <parameter>=<value>'
-)
-def get(endpoint: str, query_parameters: List[str]):
-    '''Make a HTTP GET request to Rekono API.
-
-    Args:
-        endpoint (str): Endpoint to call
-        query_parameters (List[str]): Query parameters to send
-    '''
-    api_url, api_token = get_url_and_token()
-    response = request('get', api_url, endpoint, api_token=api_token, params=query_parameters)
-    display_api_response(response)
-
-
-@api.command('post', help='HTTP POST request to Rekono API')
-@click.argument('endpoint', type=str, nargs=1)
-@click.option('-d', '--data', 'data', type=str, required=False, help='HTTP body data. JSON format')
-def post(endpoint: str, data: str):
-    '''Make a HTTP POST request to Rekono API.
-
-    Args:
-        endpoint (str): Endpoint to call
-        data (str): Body data to send
-    '''
-    api_url, api_token = get_url_and_token()
-    response = request('post', api_url, endpoint, api_token=api_token, data=data)
-    display_api_response(response)
-
-
-@api.command('put', help='HTTP PUT request to Rekono API')
-@click.argument('endpoint', type=str, nargs=1)
-@click.option('-d', '--data', 'data', type=str, required=False, help='HTTP body data. JSON format')
-def put(endpoint: str, data: str):
-    '''Make a HTTP PUT request to Rekono API.
-
-    Args:
-        endpoint (str): Endpoint to call
-        data (str): Body data to send
-    '''
-    api_url, api_token = get_url_and_token()
-    response = request('put', api_url, endpoint, api_token=api_token, data=data)
-    display_api_response(response)
-
-
-@api.command('delete', help='HTTP DELETE request to Rekono API')
-@click.argument('endpoint', type=str, nargs=1)
-def delete(endpoint: str):
-    '''Make a HTTP DELETE request to Rekono API.
-
-    Args:
-        endpoint (str): Endpoint to call
-    '''
-    api_url, api_token = get_url_and_token()
-    response = request('delete', api_url, endpoint, api_token=api_token)
-    display_api_response(response)
diff --git a/rekono/api/requests.py b/rekono/api/requests.py
deleted file mode 100644
index bd5cd15..0000000
--- a/rekono/api/requests.py
+++ /dev/null
@@ -1,85 +0,0 @@
-import json
-import sys
-from typing import Any, Dict, List
-
-import click
-
-import requests
-
-
-def get_url(url: str, endpoint: str) -> str:
-    '''Get Rekono URL from base URL and endpoint after sanitization.
-
-    Args:
-        url (str): Rekono base URL
-        endpoint (str): Rekono endpoint
-
-    Returns:
-        str: Rekono full URL
-    '''
-    if url.endswith('/'):
-        url = url[:-1]                                                          # Remove latest slash from base URL
-    prefix = '/api'
-    if not endpoint.startswith(prefix):
-        if not endpoint.startswith('/'):                                        # Add initial slash to the endpoint
-            endpoint = f'/{endpoint}'
-        endpoint = prefix + endpoint                                            # Add /api prefix to the endpoint
-    if not endpoint.endswith('/'):
-        endpoint = f'{endpoint}/'                                               # Add final slash to the endpoint
-    return url + endpoint
-
-
-def get_query_parameters(query_parameters: List[str]) -> Dict[str, Any]:
-    '''Get query parameters to send in HTTP request.
-
-    Args:
-        query_parameters (List[str]): Query parameters to send
-
-    Returns:
-        Dict[str, Any]: Query parameters in dict format
-    '''
-    parameters = {}
-    for query_parameter in query_parameters:
-        if '=' not in query_parameter:                                          # Invalid parameter
-            click.echo(f'Invalid query parameter: {query_parameter}')
-            sys.exit(1)
-        parsed = query_parameter.split('=')
-        parameters[parsed[0]] = parsed[1]                                       # Add query parameter
-    return parameters
-
-
-def request(
-    method: str,
-    url: str,
-    endpoint: str,
-    api_token: str = None,
-    params: List[str] = None,
-    data: str = None
-) -> requests.Response:
-    '''Make a request to the Rekono API.
-
-    Args:
-        method (str): HTTP method to use
-        url (str): Rekono base URL
-        endpoint (str): Rekono endpoint to call
-        api_token (str, optional): Rekono API token for authentication. Defaults to None.
-        params (List[str], optional): Query parameters to send. Defaults to None.
-        data (str, optional): Body data to send. Defaults to None.
-
-    Returns:
-        requests.Response: _description_
-    '''
-    headers = {
-        'Accept': 'application/json',
-        'Content-Type': 'application/json'
-    }
-    if api_token:
-        headers['Authorization'] = f'Token {api_token}'                         # Add header for authentication
-    response = requests.request(
-        method=method.lower() if method.lower() in ['get', 'post', 'put', 'delete'] else 'get',
-        url=get_url(url, endpoint),
-        params=get_query_parameters(params) if params else None,
-        data=data,
-        headers=headers
-    )
-    return response
diff --git a/rekono/config.py b/rekono/config.py
deleted file mode 100644
index 77515bf..0000000
--- a/rekono/config.py
+++ /dev/null
@@ -1,24 +0,0 @@
-import os
-
-from rekono.environment import REKONO_HOME, REKONO_TOKEN, REKONO_URL
-
-'''Rekono CLI configuration.'''
-
-REKONO_HOME_DIRECTORY = os.getenv(REKONO_HOME, '/opt/rekono')                   # Rekono home directory
-REKONO_GIT_REPOSITORY = 'https://github.com/pablosnt/rekono.git'                # Rekono git repository
-
-API_URL = os.getenv(REKONO_URL, 'http://127.0.0.1:8000')
-API_TOKEN = os.getenv(REKONO_TOKEN)                                             # Rekono API token
-
-# Default configuration
-DB_DATABASE = 'rekono'
-DB_USER = 'rekono'
-RQ_USER = 'rekono'
-CMSEEK_DIR = '/usr/share/cmseek'
-LOG4J_SCAN_DIR = '/opt/log4j-scan'
-SPRING4SHELL_SCAN_DIR = '/opt/spring4shell-scan'
-GITTOOLS_DIR = '/opt/GitTools'
-
-# System configuration
-REKONO_USER = 'rekono'
-SYSTEMD_SERVICES = '/etc/systemd/system'
diff --git a/rekono/environment.py b/rekono/environment.py
deleted file mode 100644
index 6306483..0000000
--- a/rekono/environment.py
+++ /dev/null
@@ -1,5 +0,0 @@
-'''Environment variables used by Rekono CLI.'''
-
-REKONO_HOME = 'REKONO_HOME'
-REKONO_URL = 'REKONO_URL'
-REKONO_TOKEN = 'REKONO_TOKEN'
diff --git a/rekono/installation/__init__.py b/rekono/installation/__init__.py
deleted file mode 100644
index f82323f..0000000
--- a/rekono/installation/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-'''Rekono installation.'''
diff --git a/rekono/installation/check.py b/rekono/installation/check.py
deleted file mode 100644
index d6a0f32..0000000
--- a/rekono/installation/check.py
+++ /dev/null
@@ -1,17 +0,0 @@
-import os
-
-from rekono.config import REKONO_HOME_DIRECTORY, SYSTEMD_SERVICES
-from rekono.services.services import (BACKEND, EMAILS, EXECUTIONS, FINDINGS,
-                                      FRONTEND, TASKS, TELEGRAM)
-
-
-def check_rekono_installation() -> bool:
-    '''Check if Rekono is already installed or not.
-
-    Returns:
-        bool: Indicate if Rekono is installed or not.
-    '''
-    is_installed = os.path.isdir(REKONO_HOME_DIRECTORY)
-    for service in [BACKEND, FRONTEND, TELEGRAM, TASKS, EXECUTIONS, FINDINGS, EMAILS]:
-        is_installed = is_installed and os.path.isfile(os.path.join(SYSTEMD_SERVICES, f'rekono-{service}.service'))
-    return is_installed
diff --git a/rekono/installation/commands.py b/rekono/installation/commands.py
deleted file mode 100644
index 9352c98..0000000
--- a/rekono/installation/commands.py
+++ /dev/null
@@ -1,159 +0,0 @@
-import os
-import subprocess
-import sys
-
-import click
-from rekono.config import REKONO_HOME_DIRECTORY
-from rekono.installation.check import check_rekono_installation
-from rekono.installation.configuration import create_config_file
-from rekono.installation.dependencies import (drop_rekono_database,
-                                              install_backend,
-                                              install_frontend,
-                                              install_postgresql, install_rq,
-                                              install_vue)
-from rekono.installation.management import manage_command
-from rekono.installation.tools import (configure_tools, install_resources,
-                                       install_tools)
-from rekono.services.commands import restart, start, stop
-from rekono.services.manager import (create_rekono_services,
-                                     remove_rekono_services)
-from rekono.utils.linux.apt import apt_install, apt_update
-from rekono.utils.linux.check import check_system
-from rekono.utils.linux.systemctl import (count_running_services,
-                                          reload_systemctl, systemctl_command)
-from rekono.utils.linux.users import create_rekono_user, remove_rekono_user
-from rekono.utils.source_code import download_source_code
-
-
-@click.command('install', help='Install Rekono on the system')
-@click.option(
-    '-a', '--all-tools', 'all_tools', is_flag=True,
-    help='Install all tools and resources supported by Rekono'
-)
-@click.pass_context
-def install(ctx: click.Context, all_tools: bool):
-    '''Install Rekono on the system.
-
-    Args:
-        ctx (click.Context): Click context to be able to call other Click commands
-        all_tools (bool): Indicate if all tools supported by Rekono should be installed or not
-    '''
-    check_system()                                                              # Check if it is a Linux system
-    if check_rekono_installation():                                             # Check if Rekono is already installed
-        click.echo(click.style('Rekono is already installed on the system', fg='green'))
-        sys.exit(0)
-    if os.path.isdir(REKONO_HOME_DIRECTORY):                                    # Rekono directory already exists
-        click.echo(click.style(
-            f'{REKONO_HOME_DIRECTORY} already exists. Please, set the directory where Rekono '
-            'should be downloaded, using the environment variable "REKONO_HOME"', fg='red'
-        ), err=True)
-        sys.exit(1)
-    create_rekono_user()                                                        # Create Rekono system user
-    click.echo('Downloading Rekono source code')
-    download_source_code()                                                      # Download Rekono source code
-    click.echo()
-    click.echo('Installing Rekono dependencies')
-    apt_update()                                                                # Update APT sources
-    click.echo('[+] PostgreSQL')
-    db_password = install_postgresql()                                          # Install PostgreSQL
-    click.echo('[+] Redis')
-    install_rq()                                                                # Install Redis Queue
-    click.echo('[+] Vue')
-    install_vue()                                                               # Install Vue
-    click.echo('[+] Other packages')
-    apt_install(['libpq-dev', 'python3-dev', 'libmagic1'])                      # Install other dependencies
-    click.echo('[+] Rekono backend dependencies')
-    install_backend()                                                           # Install backend dependencies
-    click.echo('[+] Rekono frontend dependencies')
-    install_frontend()                                                          # Install frontend dependencies
-    click.echo()
-    click.echo('Configuring Rekono')
-    click.echo("Don't worry if you can't configure some items, press ENTER")
-    create_config_file(db_password)                                             # Create Rekono configuration
-    click.echo()
-    manage_command('migrate')                                                   # Migrate Rekono database
-    click.echo()
-    click.echo('Creation first Rekono user')
-    manage_command('createsuperuser')                                           # Create Rekono superuser
-    if all_tools:
-        click.echo()
-        click.echo('Installing all supported tools')
-        install_tools()                                                         # Install all supported tools
-        configure_tools()                                                       # Configure supported tools
-        click.echo('Installing all supported resources')
-        install_resources()                                                     # Install resources by default
-    click.echo()
-    click.echo('Creating systemd services for Rekono')
-    create_rekono_services()                                                    # Create Rekono services
-    reload_systemctl()                                                          # Reload Systemctl daemon
-    click.echo()
-    click.echo(click.style('Installation completed!', fg='green'))
-    click.echo()
-    answer = input('Do you want to start Rekono services? [Y/N]: ')
-    if answer.lower() in ['y', 'yes']:
-        answer = input('How many execution workers do you need? [3]: ')
-        try:
-            execution_workers = int(answer)
-        except ValueError:
-            execution_workers = 3
-        ctx.invoke(start, executors=execution_workers)                          # Start Rekono services
-
-
-@click.command('update', help='Update Rekono installation with the latest version')
-@click.pass_context
-def update(ctx: click.Context):
-    '''Update Rekono installation with the latest version.
-
-    Args:
-        ctx (click.Context): Click context to be able to call other Click commands
-    '''
-    check_system()                                                              # Check if it is a Linux system
-    if not check_rekono_installation():                                         # Check if Rekono is installed
-        click.echo(
-            click.style('Rekono is not installed on the system. Please, run the command install', fg='red'), err=True
-        )
-        sys.exit(1)
-    click.echo('Downloading Rekono source code')
-    download_source_code()                                                      # Download Rekono source code
-    click.echo()
-    click.echo('Updating Rekono dependencies')
-    click.echo('[+] Rekono backend dependencies')
-    install_backend()                                                           # Update backend dependencies
-    click.echo('[+] Rekono frontend dependencies')
-    install_frontend()                                                          # Update frontend dependencies
-    manage_command('frontend')                                                  # Configure Rekono frontend
-    click.echo()
-    click.echo('Updating database')
-    systemctl_command('start', 'postgresql')                                    # Start PostgreSQL
-    manage_command('migrate')                                                   # Migrate Rekono database
-    click.echo()
-    if count_running_services('rekono-') > 0:
-        click.echo('Restarting services')
-        ctx.invoke(restart)                                                     # Restart Rekono services
-        click.echo()
-    click.echo(click.style('Rekono has been updated!', fg='green'))
-
-
-@click.command('uninstall', help='Uninstall Rekono from the system')
-@click.pass_context
-def uninstall(ctx: click.Context):
-    '''Uninstall Rekono from the system.
-
-    Args:
-        ctx (click.Context): Click context to be able to call other Click commands
-    '''
-    check_system()                                                              # Check if it is a Linux system
-    if check_rekono_installation():
-        click.echo('Stopping Rekono services')
-        ctx.invoke(stop)                                                        # Stop Rekono services
-    click.echo('Removing Rekono services')
-    remove_rekono_user()                                                        # Remove Rekono user
-    remove_rekono_services()                                                    # Remove Rekono systemctl services
-    reload_systemctl()                                                          # Reload systemctl daemon
-    click.echo('Removing Rekono home directory')
-    if os.path.isdir(REKONO_HOME_DIRECTORY):
-        subprocess.run(['sudo', 'rm', '-R', REKONO_HOME_DIRECTORY, '-f'], capture_output=True)
-    click.echo('Removing Rekono database')
-    drop_rekono_database()                                                      # Remove Rekono database
-    click.echo()
-    click.echo(click.style('Rekono has been uninstalled. Bye!', fg='green'))
diff --git a/rekono/installation/configuration.py b/rekono/installation/configuration.py
deleted file mode 100644
index 873e98d..0000000
--- a/rekono/installation/configuration.py
+++ /dev/null
@@ -1,81 +0,0 @@
-import os
-from getpass import getpass
-from typing import List
-
-import yaml
-from rekono.config import (CMSEEK_DIR, DB_DATABASE, DB_USER, GITTOOLS_DIR,
-                           LOG4J_SCAN_DIR, REKONO_HOME_DIRECTORY,
-                           SPRING4SHELL_SCAN_DIR)
-
-
-def create_config_file(db_password: str) -> None:
-    '''Create Rekono configuration file.
-
-    Args:
-        db_password (str): Database password for Rekono user
-    '''
-    config = {                                                                  # Prepare configuration file
-        'frontend': {
-            'url': 'http://127.0.0.1:3000'
-        },
-        'security': {
-            'allowed-hosts': [
-                '127.0.0.1',
-                'localhost',
-                '::1'
-            ],
-        },
-        'database': {
-            'name': DB_DATABASE,
-            'user': DB_USER,
-            'password': db_password,
-            'host': '127.0.0.1',
-            'port': 5432
-        },
-        'rq': {
-            'host': '127.0.0.1',
-            'port': 6379
-        },
-        'email': {
-            'host': input('SMTP host: '),
-            'port': input('SMTP port: '),
-            'user': input('SMTP user: '),
-            'password': getpass('SMTP password: '),
-            'tls': True
-        },
-        'tools': {
-            'cmseek': {
-                'directory': CMSEEK_DIR
-            },
-            'log4j-scan': {
-                'directory': LOG4J_SCAN_DIR
-            },
-            'spring4shell-scan': {
-                'directory': SPRING4SHELL_SCAN_DIR
-            },
-            'gittools': {
-                'directory': GITTOOLS_DIR
-            }
-        }
-    }
-    with open(os.path.join(REKONO_HOME_DIRECTORY, 'config.yaml'), 'w') as config_file:
-        yaml.safe_dump(config, config_file)
-
-
-def check_configuration(env: str, config_path: List[str]) -> bool:
-    '''Check if a Rekono configuration items is configured or not.
-
-    Args:
-        env (str): Environment variable to check
-        config_path (List[str]): Path to the item in the configuration file
-
-    Returns:
-        bool: Indicate if the item is configured or not
-    '''
-    with open(os.path.join(REKONO_HOME_DIRECTORY, 'config.yaml'), 'r') as config_file:
-        config = yaml.safe_load(config_file)                                    # Get configuration from file
-    for key in config_path:                                                     # Browse the configuration path
-        config = config.get(key)
-        if not config:
-            break
-    return bool(os.getenv(env, config))                                         # Check if item is configured or not
diff --git a/rekono/installation/dependencies.py b/rekono/installation/dependencies.py
deleted file mode 100644
index bb31353..0000000
--- a/rekono/installation/dependencies.py
+++ /dev/null
@@ -1,87 +0,0 @@
-import os
-import random
-import string
-import subprocess
-import sys
-
-import click
-from rekono.config import (DB_DATABASE, DB_USER, REKONO_HOME_DIRECTORY,
-                           REKONO_USER)
-from rekono.utils.linux.apt import apt_install
-from rekono.utils.linux.systemctl import systemctl_command
-from rekono.utils.linux.utils import check_installation
-
-
-def install_postgresql() -> str:
-    '''Install PostgreSQL in the system.
-
-    Returns:
-        str: PostgreSQL password for the default rekono user
-    '''
-    if not check_installation('psql'):                                          # Not installed yet
-        apt_install(['postgresql'])                                             # Install PostgreSQL
-    systemctl_command('start', 'postgresql')                                    # Start PostgreSQL
-    password = ''.join(                                                         # Generate random password
-        random.choice(string.ascii_uppercase + string.ascii_lowercase + string.digits) for _ in range(20)
-    )
-    postgresql_query(f'CREATE USER {DB_USER} WITH ENCRYPTED PASSWORD \'{password}\';')    # Create Rekono user
-    postgresql_query(f'CREATE DATABASE {DB_DATABASE};')                         # Create Rekono database
-    # Grant permissions for the Rekono user
-    postgresql_query(f'GRANT ALL PRIVILEGES ON DATABASE {DB_DATABASE} TO {DB_USER};')
-    return password
-
-
-def drop_rekono_database() -> None:
-    '''Drop Rekono database and user from PostgreSQL.'''
-    systemctl_command('start', 'postgresql')                                    # Start PostgreSQL
-    postgresql_query(f'DROP DATABASE {DB_DATABASE};')                           # Drop Rekono database
-    postgresql_query(f'DROP USER {DB_USER};')                                   # Drop Rekono user
-
-
-def postgresql_query(query: str) -> None:
-    '''Run SQL query against PostgreSQL.
-
-    Args:
-        query (str): SQL query to execute
-    '''
-    subprocess.run(['sudo', '-u', 'postgres', 'psql', '-c', query], capture_output=True)
-
-
-def install_rq() -> None:
-    '''Install Redis Queue in the system.'''
-    if not check_installation('redis-server'):                                  # Not installed yet
-        apt_install(['redis-server'])                                           # Install Redis Queue
-
-
-def install_vue() -> None:
-    '''Install Vue in the system.'''
-    if not check_installation('npm'):                                           # NPM not installed yet
-        apt_install(['nodejs', 'npm'])                                          # Install Node
-    if not check_installation('vue'):                                           # Vue not installed yet
-        process = subprocess.run(['npm', 'install', '-g', '@vue/cli'], capture_output=True)     # Install Vue
-        if process.returncode != 0:                                                # Error during installation
-            click.echo(click.style('Error during Vue installation', fg='red'), err=True)
-            sys.exit(10)
-
-
-def install_backend() -> None:
-    '''Install backend dependencies.'''
-    process = subprocess.run([
-        sys.executable, '-m', 'pip', 'install', '-q', '-r',
-        os.path.join(REKONO_HOME_DIRECTORY, 'requirements.txt')
-    ])
-    if process.returncode != 0:                                                 # Error during installation
-        click.echo(click.style('Error during backend installation', fg='red'), err=True)
-        sys.exit(10)
-
-
-def install_frontend() -> None:
-    '''Install frontend dependencies.'''
-    process = subprocess.run(
-        ['sudo', '-u', REKONO_USER, 'npm', 'install', '.'],
-        capture_output=True,
-        cwd=os.path.join(REKONO_HOME_DIRECTORY, 'rekono', 'frontend')
-    )
-    if process.returncode != 0:                                                 # Error during installation
-        click.echo(click.style('Error during frontend installation', fg='red'), err=True)
-        sys.exit(10)
diff --git a/rekono/installation/management.py b/rekono/installation/management.py
deleted file mode 100644
index 3549632..0000000
--- a/rekono/installation/management.py
+++ /dev/null
@@ -1,17 +0,0 @@
-import os
-import subprocess
-import sys
-
-from rekono.config import REKONO_HOME_DIRECTORY, REKONO_USER
-
-
-def manage_command(command: str) -> None:
-    '''Execute Django command.
-
-    Args:
-        command (str): Command to run
-    '''
-    subprocess.run(
-        ['sudo', '-u', REKONO_USER, sys.executable, 'manage.py', command],
-        cwd=os.path.join(REKONO_HOME_DIRECTORY, 'rekono')
-    )
diff --git a/rekono/installation/tools.py b/rekono/installation/tools.py
deleted file mode 100644
index 995bff2..0000000
--- a/rekono/installation/tools.py
+++ /dev/null
@@ -1,54 +0,0 @@
-import os
-import shutil
-import subprocess
-import sys
-
-from git import Repo
-from rekono.config import GITTOOLS_DIR, LOG4J_SCAN_DIR, SPRING4SHELL_SCAN_DIR
-from rekono.utils.linux.apt import apt_install
-
-
-def install_tools() -> None:
-    '''Install all tools supported by Rekono.'''
-    apt_install(
-        [
-            'nmap', 'dirsearch', 'theharvester', 'nikto', 'sslscan', 'sslyze',
-            'cmseek', 'zaproxy', 'exploitdb', 'metasploit-framework',
-            'emailharvester', 'joomscan', 'gitleaks', 'smbmap', 'nuclei',
-            'gobuster'
-        ],
-        required=False
-    )
-    subprocess.run([
-        'sudo', sys.executable, '-m', 'pip', 'install', '-q', 'emailfinder', 'ssh-audit'
-    ])
-    for git_repository, directory in [
-        ('https://github.com/fullhunt/log4j-scan', LOG4J_SCAN_DIR),
-        ('https://github.com/fullhunt/spring4shell-scan.git', SPRING4SHELL_SCAN_DIR),
-        ('https://github.com/internetwache/GitTools.git', GITTOOLS_DIR),
-    ]:
-        if not os.path.isdir(directory):
-            subprocess.run(['sudo', 'mkdir', directory])                        # Create tool directory
-            subprocess.run(['sudo', 'chmod', '-R', '777', directory])           # Change tool directory permissions
-            Repo.clone_from(git_repository, directory)
-    for requirements in [
-        os.path.join(LOG4J_SCAN_DIR, 'requirements.txt'),
-        os.path.join(SPRING4SHELL_SCAN_DIR, 'requirements.txt'),
-    ]:
-        if os.path.isfile(requirements):
-            subprocess.run([                                                    # Install Python dependencies
-                'sudo', sys.executable, '-m', 'pip', 'install', '-q', '-r', requirements
-            ])
-
-
-def configure_tools() -> None:
-    '''Configure all tools supported by Rekono.'''
-    nmap = shutil.which('nmap')
-    if nmap:
-        # Set capabilities to nmap
-        subprocess.run(['sudo', 'setcap', 'cap_net_raw,cap_net_admin,cap_net_bind_service+eip', nmap])
-
-
-def install_resources() -> None:
-    '''Install all resources used by Rekono.'''
-    apt_install(['seclists', 'dirb'])
diff --git a/rekono/main.py b/rekono/main.py
deleted file mode 100644
index 47e5055..0000000
--- a/rekono/main.py
+++ /dev/null
@@ -1,25 +0,0 @@
-import click
-
-from rekono import VERSION
-from rekono.api.commands import api
-from rekono.installation.commands import install, uninstall, update
-from rekono.services.commands import services
-
-
-@click.group()
-@click.version_option(version=VERSION, message='%(version)s')
-def rekono():
-    '''Rekono CLI.'''
-    pass
-
-
-# Add Rekono commands
-rekono.add_command(install)
-rekono.add_command(update)
-rekono.add_command(uninstall)
-rekono.add_command(services)
-rekono.add_command(api)
-
-
-if __name__ == '__main__':
-    rekono()
diff --git a/rekono/services/__init__.py b/rekono/services/__init__.py
deleted file mode 100644
index 6a7d260..0000000
--- a/rekono/services/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-'''Rekono services management.'''
diff --git a/rekono/services/commands.py b/rekono/services/commands.py
deleted file mode 100644
index 33cf229..0000000
--- a/rekono/services/commands.py
+++ /dev/null
@@ -1,59 +0,0 @@
-import sys
-
-import click
-
-from rekono.installation.check import check_rekono_installation
-from rekono.services.manager import rekono_services_command
-from rekono.services.services import EXECUTIONS
-from rekono.utils.linux.check import check_system
-from rekono.utils.linux.systemctl import (count_running_services,
-                                          systemctl_command)
-
-
-@click.group('services', help='Manage Rekono systemctl services')
-def services():
-    '''Manage Rekono systemctl services.'''
-    check_system()                                                              # Check if it is a Linux system
-    if not check_rekono_installation():                                         # Check if Rekono is installed
-        click.echo(
-            click.style('Rekono is not installed on the system. Please, run the command install', fg='red'), err=True
-        )
-        sys.exit(1)
-
-
-@services.command('start', help='Start Rekono services')
-@click.option(
-    '-e', '--execution-workers', 'executors',
-    type=int, required=False, default=3,
-    help='Number of workers for executions queue'
-)
-def start(executors: int):
-    '''Start all Rekono services.
-
-    Args:
-        executors (int): Number of workers for executions queue.
-    '''
-    if executors < 0:
-        click.echo(click.style('Number of workers should be greater than zero', fg='red'), err=True)
-        sys.exit(100)
-    elif executors == 0:
-        click.echo(
-            click.style('Number of workers is zero so executions are disabled', fg='orange')
-        )
-    systemctl_command('start', 'postgresql')                                    # Start PostgreSQL
-    systemctl_command('start', 'redis-server')                                  # Start Redis
-    rekono_services_command('start', executors)
-
-
-@services.command('stop', help='Stop Rekono services')
-def stop():
-    '''Stop all Rekono services.'''
-    executors = count_running_services(f'rekono-{EXECUTIONS}')
-    rekono_services_command('stop', executors)
-
-
-@services.command('restart', help='Restart Rekono services')
-def restart():
-    '''Restart all Rekono services.'''
-    executors = count_running_services(f'rekono-{EXECUTIONS}')
-    rekono_services_command('restart', executors)
diff --git a/rekono/services/manager.py b/rekono/services/manager.py
deleted file mode 100644
index bb1ca0d..0000000
--- a/rekono/services/manager.py
+++ /dev/null
@@ -1,62 +0,0 @@
-import os
-import shutil
-import subprocess
-import sys
-
-from rekono.config import REKONO_HOME_DIRECTORY, REKONO_USER, SYSTEMD_SERVICES
-from rekono.services.services import (BACKEND, EMAILS, EXECUTIONS, FINDINGS,
-                                      FRONTEND, TASKS, TELEGRAM)
-from rekono.utils.linux.systemctl import systemctl_command
-
-current_directory = os.path.dirname(os.path.realpath(__file__))
-templates = os.path.join(current_directory, 'templates')
-
-
-def create_rekono_services() -> None:
-    '''Create all Rekono systemctl services.'''
-    with open(os.path.join(templates, 'rekono.service'), 'r') as service:
-        template = service.read()                                               # Read service template
-    django = os.path.join(REKONO_HOME_DIRECTORY, 'rekono')
-    vue = os.path.join(django, 'frontend')
-    manage_py = f'{sys.executable} {os.path.join(django, "manage.py")}'
-    for name, description, working_directory, command in [
-        (BACKEND, 'Rekono backend', django, f'{manage_py} runserver'),
-        (FRONTEND, 'Rekono frontend', vue, f'{shutil.which("npm")} run serve'),
-        (TELEGRAM, 'Rekono Telegram bot', django, f'{manage_py} telegram_bot'),
-        (TASKS, 'Rekono tasks worker', django, f'{manage_py} rqworker --with-scheduler tasks-queue'),
-        (EXECUTIONS, 'Rekono executions worker number %i', django, f'{manage_py} rqworker executions-queue'),
-        (FINDINGS, 'Rekono findings worker', django, f'{manage_py} rqworker findings-queue'),
-        (EMAILS, 'Rekono emails worker', django, f'{manage_py} rqworker emails-queue')
-    ]:
-        temppath = os.path.join(templates, f'rekono-{name}.service')
-        with open(temppath, 'w') as temp:
-            temp.write(template.format(                                         # Add service data
-                description=description,
-                user=REKONO_USER,
-                working_directory=working_directory,
-                command=command
-            ))
-        subprocess.run(['sudo', 'mv', temppath, SYSTEMD_SERVICES])
-
-
-def remove_rekono_services() -> None:
-    '''Remove all Rekono systemctl services.'''
-    for service in [BACKEND, FRONTEND, TELEGRAM, TASKS, EXECUTIONS, FINDINGS, EMAILS]:
-        if os.path.isfile(os.path.join(SYSTEMD_SERVICES, f'rekono-{service}.service')):
-            subprocess.run(
-                ['sudo', 'rm', os.path.join(SYSTEMD_SERVICES, f'rekono-{service}.service')], capture_output=True
-            )
-
-
-def rekono_services_command(command: str, executors: int) -> None:
-    '''Execute systemctl command to all Rekono services.
-
-    Args:
-        command (str): Systemctl command
-        executors (int): Number of instances afected for executions worker service
-    '''
-    services = [BACKEND, FRONTEND, TELEGRAM, TASKS, FINDINGS, EMAILS]
-    for number in range(executors):
-        services.append(f'{EXECUTIONS}{number + 1}')                            # Add executions worker instances
-    for service in services:
-        systemctl_command(command, f'rekono-{service}')
diff --git a/rekono/services/services.py b/rekono/services/services.py
deleted file mode 100644
index 320b3f7..0000000
--- a/rekono/services/services.py
+++ /dev/null
@@ -1,7 +0,0 @@
-BACKEND = 'backend'
-FRONTEND = 'frontend'
-TELEGRAM = 'telegram'
-TASKS = 'tasks-worker'
-EXECUTIONS = 'executions-worker@'
-FINDINGS = 'findings-worker'
-EMAILS = 'emails-worker'
diff --git a/rekono/services/templates/rekono.service b/rekono/services/templates/rekono.service
deleted file mode 100644
index 305429d..0000000
--- a/rekono/services/templates/rekono.service
+++ /dev/null
@@ -1,17 +0,0 @@
-[Unit]
-Description={description}
-
-[Service]
-Type=simple
-User={user}
-Group={user}
-WorkingDirectory={working_directory}
-Environment=LANG=en_US.UTF-8
-Environment=LC_ALL=en_US.UTF-8
-Environment=LC_LANG=en_US.UTF-8
-Environment=NODE_OPTIONS=--openssl-legacy-provider
-ExecStart={command}
-ExecReload=/bin/kill -s HUP $MAINPID
-ExecStop=/bin/kill -s TERM $MAINPID
-PrivateTmp=true
-Restart=always
diff --git a/rekono/utils/__init__.py b/rekono/utils/__init__.py
deleted file mode 100644
index f7d2821..0000000
--- a/rekono/utils/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-'''Rekono CLI utils.'''
diff --git a/rekono/utils/linux/__init__.py b/rekono/utils/linux/__init__.py
deleted file mode 100644
index 22ef3b3..0000000
--- a/rekono/utils/linux/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-'''System utils.'''
diff --git a/rekono/utils/linux/apt.py b/rekono/utils/linux/apt.py
deleted file mode 100644
index 5482e86..0000000
--- a/rekono/utils/linux/apt.py
+++ /dev/null
@@ -1,26 +0,0 @@
-import subprocess
-import sys
-from typing import List
-
-import click
-
-
-def apt_update() -> None:
-    '''Update APT sources.'''
-    subprocess.run(['sudo', 'apt', 'update'], capture_output=True)
-
-
-def apt_install(packages: List[str], required: bool = True) -> None:
-    '''Install APT packages.
-
-    Args:
-        packages (List[str]): Packages to install
-        required (bool, optional): Indicate if packages are required or not. Defaults to True.
-    '''
-    command = ['sudo', 'apt', 'install']
-    command.extend(packages)
-    command.append('-y')
-    process = subprocess.run(command, capture_output=True)
-    if process.returncode != 0 and required:                                    # Required packages
-        click.echo(click.style(f'Error during {" ".join(packages)} installation', fg='red'), err=True)
-        sys.exit(10)
diff --git a/rekono/utils/linux/check.py b/rekono/utils/linux/check.py
deleted file mode 100644
index edd369a..0000000
--- a/rekono/utils/linux/check.py
+++ /dev/null
@@ -1,17 +0,0 @@
-import platform
-import sys
-
-import click
-
-
-def check_system() -> None:
-    '''Check if the system is Linux.'''
-    if platform.system().lower() != 'linux':
-        click.echo(
-            click.style('Rekono installation and services management only work in Linux systems', fg='red'), err=True
-        )
-        click.echo(
-            'For this system, you can follow this steps to deploy '
-            'Rekono in Docker: https://github.com/pablosnt/rekono#docker'
-        )
-        sys.exit(1)
diff --git a/rekono/utils/linux/systemctl.py b/rekono/utils/linux/systemctl.py
deleted file mode 100644
index 135d369..0000000
--- a/rekono/utils/linux/systemctl.py
+++ /dev/null
@@ -1,36 +0,0 @@
-import subprocess
-
-
-def reload_systemctl() -> None:
-    '''Reload systemctl daemon.'''
-    subprocess.run(['sudo', 'systemctl', 'daemon-reload'], capture_output=True)
-
-
-def systemctl_command(command: str, service: str) -> None:
-    '''Execute systemctl command to one service.
-
-    Args:
-        command (str): Systemctl command
-        service (str): Systemctl service
-    '''
-    subprocess.run(['sudo', 'systemctl', command, service], capture_output=True)
-
-
-def count_running_services(service: str) -> int:
-    '''Count number of running executions for one service at the moment.
-
-    Args:
-        service (str): Systemctl service to count his executions
-
-    Returns:
-        int: Number of running executions
-    '''
-    process = subprocess.run(
-        ['sudo', 'systemctl', 'list-units', '--type=service', '--state=running'],
-        capture_output=True
-    )
-    count = 0
-    if process.returncode == 0 and process.stdout:
-        services = [line.strip() for line in process.stdout.decode().split('\n') if line.strip().startswith(service)]
-        count = len(services)
-    return count
diff --git a/rekono/utils/linux/users.py b/rekono/utils/linux/users.py
deleted file mode 100644
index 4088689..0000000
--- a/rekono/utils/linux/users.py
+++ /dev/null
@@ -1,20 +0,0 @@
-import subprocess
-
-from rekono.config import REKONO_HOME_DIRECTORY, REKONO_USER
-
-
-def create_rekono_user() -> None:
-    '''Create Rekono local user to execute the Rekono services.'''
-    subprocess.run(
-        ['sudo', 'useradd', '-r', REKONO_USER, '--shell=/usr/sbin/nologin', f'--home={REKONO_HOME_DIRECTORY}'],
-        capture_output=True
-    )
-    subprocess.run(
-        ['sudo', 'chown', '-R', f'{REKONO_USER}:{REKONO_USER}', REKONO_HOME_DIRECTORY],
-        capture_output=True
-    )
-
-
-def remove_rekono_user() -> None:
-    '''Remove Rekono local user.'''
-    subprocess.run(['sudo', 'userdel', REKONO_USER], capture_output=True)
diff --git a/rekono/utils/linux/utils.py b/rekono/utils/linux/utils.py
deleted file mode 100644
index 0235931..0000000
--- a/rekono/utils/linux/utils.py
+++ /dev/null
@@ -1,13 +0,0 @@
-import shutil
-
-
-def check_installation(executable: str) -> bool:
-    '''Check if executable is installed or not.
-
-    Args:
-        executable (str): Executable to check
-
-    Returns:
-        bool: Indicate if executable is installed or not
-    '''
-    return shutil.which(executable) is not None
diff --git a/rekono/utils/source_code.py b/rekono/utils/source_code.py
deleted file mode 100644
index 15560da..0000000
--- a/rekono/utils/source_code.py
+++ /dev/null
@@ -1,29 +0,0 @@
-import os
-import shutil
-import subprocess
-import tempfile
-
-from git import Repo
-
-from rekono.config import (REKONO_GIT_REPOSITORY, REKONO_HOME_DIRECTORY,
-                           REKONO_USER)
-
-
-def download_source_code() -> None:
-    '''Download Rekono source code from GitHub.'''
-    temp = tempfile.mkdtemp()                                                   # Create temporal directory
-    Repo.clone_from(REKONO_GIT_REPOSITORY, temp)                                # Clone Rekono in the temporal directory
-    if not os.path.isdir(REKONO_HOME_DIRECTORY):
-        subprocess.run(['sudo', 'mkdir', REKONO_HOME_DIRECTORY])                # Create Rekono directory
-        subprocess.run(['sudo', 'chmod', '-R', '777', REKONO_HOME_DIRECTORY])   # Change Rekono directory permissions
-    rekono = os.path.join(REKONO_HOME_DIRECTORY, 'rekono')
-    if os.path.isdir(rekono):                                                   # Check if Rekono subdirectory exists
-        subprocess.run(['sudo', 'rm', '-R', rekono, '-f'], capture_output=True)     # Remove Rekono subdirectory
-    shutil.move(os.path.join(temp, 'rekono'), rekono)                           # Save Rekono subdirectory
-    requirements = os.path.join(REKONO_HOME_DIRECTORY, 'requirements.txt')
-    if os.path.isfile(requirements):                                            # Check if requirements.txt exists
-        os.remove(requirements)                                                 # Remove requirements.txt
-    shutil.move(os.path.join(temp, 'requirements.txt'), REKONO_HOME_DIRECTORY)  # Save requirements.txt
-    shutil.rmtree(temp, ignore_errors=True)                                     # Remove temporal directory
-    # Change owner of the Rekono home directory
-    subprocess.run(['sudo', 'chown', '-R', f'{REKONO_USER}:{REKONO_USER}', REKONO_HOME_DIRECTORY])
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index 1b407de..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1,4 +0,0 @@
-click==8.0.4
-GitPython==3.1.27
-PyYAML==6.0
-requests==2.28.1
\ No newline at end of file
diff --git a/.flake8 b/src/.flake8
similarity index 59%
rename from .flake8
rename to src/.flake8
index 79fa917..f9e67f7 100644
--- a/.flake8
+++ b/src/.flake8
@@ -1,6 +1,6 @@
 [flake8]
-exclude = .git,__pycache__,venv/*
+exclude=.git,__pycache__,venv/*
 ignore=W504
 ; W504: Disallow line break after binary operator (and, or, etc.). Inconsistency with W503
-max-line-length = 120
-max-complexity = 10
\ No newline at end of file
+max-line-length=120
+max-complexity=10
\ No newline at end of file
diff --git a/src/MANIFEST.in b/src/MANIFEST.in
new file mode 100644
index 0000000..a7b0dc4
--- /dev/null
+++ b/src/MANIFEST.in
@@ -0,0 +1,4 @@
+include ../README.md
+include ../CHANGELOG.md
+include ../LICENSE.txt
+include requirements.txt
\ No newline at end of file
diff --git a/rekono/__init__.py b/src/rekono/__init__.py
similarity index 51%
rename from rekono/__init__.py
rename to src/rekono/__init__.py
index 96e9ba9..c70ec3a 100644
--- a/rekono/__init__.py
+++ b/src/rekono/__init__.py
@@ -1,3 +1,3 @@
 '''Rekono CLI.'''
 
-VERSION = '1.1.0'
+VERSION = '2.0.0'
diff --git a/src/rekono/client/__init__.py b/src/rekono/client/__init__.py
new file mode 100644
index 0000000..054cb2e
--- /dev/null
+++ b/src/rekono/client/__init__.py
@@ -0,0 +1,1 @@
+'''Rekono API client.'''
diff --git a/src/rekono/client/api.py b/src/rekono/client/api.py
new file mode 100644
index 0000000..c54eab0
--- /dev/null
+++ b/src/rekono/client/api.py
@@ -0,0 +1,193 @@
+'''Rekono API.'''
+
+import json
+import os
+from typing import Any, Callable, Dict, List, Optional, Union
+
+import requests
+from requests.adapters import HTTPAdapter, Retry
+from requests.exceptions import RetryError, Timeout
+from requests.models import Response
+
+from rekono.client.exceptions import AuthenticationError, AuthorizationError
+
+
+class Rekono:
+    '''Rekono API.'''
+
+    def __init__(
+        self,
+        url: str,
+        token: str,
+        headers: Optional[Dict[str, str]] = None,
+        verify: bool = True
+    ) -> None:
+        '''Rekono API client constructor.
+
+        Args:
+            url (str): Base Rekono URL.
+            token (str): API token for Rekono authentication. Defaults to None.
+            headers (Optional[Dict[str, str]], optional): Extra HTTP request headers. Defaults to None.
+            verify (bool, optional): Indicates if TLS verification should be performed or not. Defaults to True.
+
+        Raises:
+            AuthenticationError: Authentication error during basic authentication attempt.
+        '''
+        self.url = url
+        self.token = token
+        self.headers = headers or {}
+        self.headers.update({'Authorization': f'Token {self.token}'})           # Configure HTTP request headers
+        self.verify = verify
+        self.session = requests.Session()                                       # Configure retries of HTTP requests
+        retries = Retry(total=5, backoff_factor=0.1, status_forcelist=[429, 500, 502, 503, 504])
+        self.session.mount(self.url, HTTPAdapter(max_retries=retries))
+
+    def _get_endpoint(self, endpoint: str) -> str:
+        '''Get valid Rekono endpoint from the provided value.
+
+        Args:
+            endpoint (str): Endpoint provided.
+
+        Returns:
+            str: Valid Rekono endpoint.
+        '''
+        if not endpoint.startswith('/api/'):                                    # If endpoint doesn't start by /api/
+            if endpoint.startswith('/'):
+                endpoint = endpoint[1:]
+            endpoint = '/api/' + endpoint                                       # Add /api/ prefix to the endpoint
+        if not endpoint.endswith('/'):                                          # If endpoint doesn't end by slash
+            endpoint += '/'                                                     # Add slash suffix to the endpoint
+        return endpoint
+
+    def _request(
+        self,
+        method: Callable,
+        endpoint: str,
+        parameters: Optional[Dict[str, Any]] = None,
+        body: Optional[str] = None,
+        files: Optional[Dict[str, Any]] = None
+    ) -> Response:
+        '''Perform HTTP request to Rekono API.
+
+        Args:
+            method (Callable): HTTP method to use.
+            endpoint (str): Endpoint to call.
+            parameters (Optional[Dict[str, Any]], optional): Query parameters to send. Defaults to None.
+            body (Optional[str], optional): Body to send. Defaults to None.
+            files (Optional[Dict[str, Any]], optional): Files to send. Defaults to None.
+
+        Raises:
+            AuthenticationError: Unauthenticated, API token is invalid
+            AuthorizationError: Unauthorized, user hasn't required permissions.
+
+        Returns:
+            Response: HTTP response.
+        '''
+        if files:
+            self.headers.pop('Content-Type', None)                              # If files provided, remove Content-Type
+        else:
+            self.headers['Content-Type'] = 'application/json'                   # If not, set application/json
+        url = self.url + self._get_endpoint(endpoint)                           # Prepare URL to call
+        try:
+            # First attempt
+            response = method(url, params=parameters, data=body, files=files, headers=self.headers, verify=self.verify)
+        except (ConnectionError, RetryError, Timeout):                          # Unexpected error during HTTP request
+            # Second attempt
+            response = method(url, params=parameters, data=body, files=files, headers=self.headers, verify=self.verify)
+        if response.status_code == 401:                                         # Unauthenticated
+            raise AuthenticationError(response)
+        elif response.status_code == 403:                                       # Access Denied
+            raise AuthorizationError(response)
+        return response
+
+    def get(
+        self,
+        endpoint: str,
+        parameters: Optional[Dict[str, Any]] = None,
+        pagination: bool = False
+    ) -> Union[List[Response], Response]:
+        '''GET request to Rekono API.
+
+        Args:
+            endpoint (str): Endpoint to call.
+            parameters (Optional[Dict[str, Any]], optional): Query parameters to send. Defaults to {}.
+            pagination (bool, optional): Enables iteration over all API pages. Defaults to False.
+
+        Raises:
+            AuthenticationError: Unauthenticated, API token is invalid
+            AuthorizationError: Unauthorizated, user doesn't have required permissions.
+
+        Returns:
+            Union[List[Response], Response]: HTTP responses if pagination is enabled or one HTTP response if not.
+        '''
+        parameters = parameters or {}
+        if not pagination:                                                      # Pagination is disabled
+            return self._request(self.session.get, endpoint, parameters=parameters)     # Perform only one GET request
+        page = 1                                                                # Start page number
+        size = 100                                                              # Default page size (max is 1000)
+        count = 101                                                             # Initial fake count value
+        responses = []
+        while page * size < count:
+            parameters.update({'page': page, 'size': size})                     # Set pagination parameters
+            response = self._request(self.session.get, endpoint, parameters=parameters)     # Paginated GET request
+            responses.append(response)                                          # Save HTTP response
+            body = response.json()
+            if body and 'count' in body:
+                count = body.get('count', 0)                                    # Update count value
+                page += 1                                                       # Increase page number
+            else:
+                break
+        return responses
+
+    def post(self, endpoint: str, body: Optional[str] = None, filepath: Optional[str] = None) -> Response:
+        '''POST request to Rekono API.
+
+        Args:
+            endpoint (str): Endpoint to call.
+            body (Optional[str], optional): Body to send. Defaults to None.
+            filepath (Optional[str], optional): File to send. Defaults to None.
+
+        Raises:
+            AuthenticationError: Unauthenticated, API token is invalid
+            AuthorizationError: Unauthorizated, user doesn't have required permissions.
+
+        Returns:
+            Response: HTTP response.
+        '''
+        if filepath and os.path.isfile(filepath):                               # If filepath is provided
+            with open(filepath, 'rb') as file:                                  # Read file
+                return self._request(                                           # Perform POST request
+                    self.session.post, endpoint, body=json.loads(body) if body else None, files={'file': file}
+                )
+        return self._request(self.session.post, endpoint, body=body)            # Perform POST request without files
+
+    def put(self, endpoint: str, body: Optional[str] = None) -> Response:
+        '''PUT request to Rekono API.
+
+        Args:
+            endpoint (str): Endpoint to call.
+            body (Optional[str], optional): Body to send. Defaults to None.
+
+        Raises:
+            AuthenticationError: Unauthenticated, API token is invalid
+            AuthorizationError: Unauthorizated, user doesn't have required permissions.
+
+        Returns:
+            Response: HTTP response.
+        '''
+        return self._request(self.session.put, endpoint, body=body)             # Perform PUT request
+
+    def delete(self, endpoint: str) -> Response:
+        '''DELETE request to Rekono API.
+
+        Args:
+            endpoint (str): Endpoint to call.
+
+        Raises:
+            AuthenticationError: Unauthenticated, API token is invalid
+            AuthorizationError: Unauthorizated, user doesn't have required permissions.
+
+        Returns:
+            Response: HTTP response.
+        '''
+        return self._request(self.session.delete, endpoint)                     # Perform DELETE request
diff --git a/src/rekono/client/enums.py b/src/rekono/client/enums.py
new file mode 100644
index 0000000..0d4c948
--- /dev/null
+++ b/src/rekono/client/enums.py
@@ -0,0 +1,48 @@
+'''Rekono enums.'''
+
+from enum import Enum
+
+
+class AuthenticationType(Enum):
+    '''Supported authentication types.'''
+
+    BASIC = 'Basic'
+    BEARER = 'Bearer'
+    COOKIE = 'Cookie'
+    DIGEST = 'Digest'
+    JWT = 'JWT'
+    NTLM = 'NTLM'
+
+
+class UserRole(Enum):
+    '''User role names.'''
+
+    ADMIN = 'Admin'
+    AUDITOR = 'Auditor'
+    READER = 'Reader'
+
+
+class IntensityRank(Enum):
+    '''Intensity ranks.'''
+
+    SNEAKY = 'Sneaky'
+    LOW = 'Low'
+    NORMAL = 'Normal'
+    HARD = 'Hard'
+    INSANE = 'Insane'
+
+
+class TimeUnit(Enum):
+    '''Time units supported for Task scheduling and repeating configuration.'''
+
+    MINUTES = 'Minutes'
+    HOURS = 'Hours'
+    DAYS = 'Days'
+    WEEKS = 'Weeks'
+
+
+class WordlistType(Enum):
+    '''Wordlist type names.'''
+
+    ENDPOINT = 'Endpoint'
+    SUBDOMAIN = 'Subdomain'
diff --git a/src/rekono/client/exceptions.py b/src/rekono/client/exceptions.py
new file mode 100644
index 0000000..fd36e84
--- /dev/null
+++ b/src/rekono/client/exceptions.py
@@ -0,0 +1,54 @@
+'''Rekono exceptions.'''
+
+from typing import IO, Optional
+
+import click
+from click.exceptions import ClickException
+from requests.models import Response
+
+
+class RekonoException(ClickException):
+    '''Base exception for Rekono client'''
+
+    def __init__(self, message: str, response: Response) -> None:
+        '''Exception constructor.
+
+        Args:
+            message (str): Error message.
+            response (Response): Http response that causes the error.
+        '''
+        self.response = response
+        self.message = message
+        super().__init__(message)
+
+    def show(self, file: Optional[IO] = None) -> None:
+        '''Show exception message using click.
+
+        Args:
+            file (Optional[IO], optional): Defaults to None.
+        '''
+        click.echo(click.style(self.message, fg='red'), err=True, color=True)
+
+
+class AuthenticationError(RekonoException):
+    '''Authentication error during Rekono API request.'''
+
+    def __init__(self, response: Response) -> None:
+        '''Exception constructor.
+
+        Args:
+            response (Response): Http response that causes the error.
+        '''
+        super().__init__('Unauthorized: Invalid Rekono API token', response)
+
+
+class AuthorizationError(RekonoException):
+    '''Authorization error during Rekono API request.'''
+
+    def __init__(self, response: Response) -> None:
+        '''Exception constructor.
+
+        Args:
+            response (Response): Http response that causes the error.
+        '''
+        super().__init__('Unauthorized: User hasn\'t required permissions to perform this action', response)
diff --git a/src/rekono/commands/__init__.py b/src/rekono/commands/__init__.py
new file mode 100644
index 0000000..b6e1f1e
--- /dev/null
+++ b/src/rekono/commands/__init__.py
@@ -0,0 +1,1 @@
+'''Rekono CLI commands.'''
diff --git a/src/rekono/commands/api.py b/src/rekono/commands/api.py
new file mode 100644
index 0000000..6fd9fc3
--- /dev/null
+++ b/src/rekono/commands/api.py
@@ -0,0 +1,10 @@
+'''Command to make custom Rekono API requests.'''
+
+import click
+
+from rekono.framework.commands.api import ApiCommand
+
+
+@click.group('api', cls=ApiCommand, help='Make custom Rekono API requests')
+def api():
+    '''Make custom Rekono API requests.'''
diff --git a/src/rekono/commands/authentications.py b/src/rekono/commands/authentications.py
new file mode 100644
index 0000000..e551254
--- /dev/null
+++ b/src/rekono/commands/authentications.py
@@ -0,0 +1,33 @@
+'''CLI command to manage Authentication entities.'''
+
+import click
+
+from rekono.client.enums import AuthenticationType
+from rekono.framework.commands.entity import EntityCommand
+
+
+class AuthenticationsCommand(EntityCommand):
+    '''CLI command to manage Authentication entities.'''
+
+    help_messages = {                                                           # Help messages for each command
+        'get': 'Get all wordlists or one if ID is provided',
+        'create': 'Create wordlist',
+        'update': 'Update wordlist',
+        'delete': 'Delete wordlist',
+    }
+    entity_options = [                                                          # Specific options for post and put
+        click.option('-p', '--target-port', 'target_port', required=True, type=int, help='Related target port ID'),
+        click.option('-n', '--name', 'name', required=True, type=str, help='Authentication name'),
+        click.option('-c', '--credential', 'credential', required=True, type=str, help='Authentication secret value'),
+        click.option(
+            '-t', '--type', 'auth_type',
+            required=False, default=AuthenticationType.BASIC.value,
+            type=click.Choice([t.value for t in AuthenticationType]),
+            help='Authentication type'
+        )
+    ]
+
+
+@click.group('authentications', cls=AuthenticationsCommand, help='Manage target authentications')
+def authentications():
+    '''Manage target authentications.'''
diff --git a/src/rekono/commands/configurations.py b/src/rekono/commands/configurations.py
new file mode 100644
index 0000000..9e4e066
--- /dev/null
+++ b/src/rekono/commands/configurations.py
@@ -0,0 +1,19 @@
+'''CLI command to manage Configuration entities.'''
+
+import click
+
+from rekono.framework.commands.entity import EntityCommand
+
+
+class ConfigurationsCommand(EntityCommand):
+    '''CLI command to manage Configuration entities.'''
+
+    commands = ['get']                                                          # CLI commands
+    help_messages = {                                                           # Help messages for each command
+        'get': 'Get all configurations or one if ID is provided',
+    }
+
+
+@click.group('configurations', cls=ConfigurationsCommand, help='Get configurations')
+def configurations():
+    '''Get configurations.'''
diff --git a/src/rekono/commands/executions.py b/src/rekono/commands/executions.py
new file mode 100644
index 0000000..6d4588f
--- /dev/null
+++ b/src/rekono/commands/executions.py
@@ -0,0 +1,19 @@
+'''CLI command to manage Execution entities.'''
+
+import click
+
+from rekono.framework.commands.entity import EntityCommand
+
+
+class ExecutionsCommand(EntityCommand):
+    '''CLI command to manage Execution entities.'''
+
+    commands = ['get']                                                          # CLI commands
+    help_messages = {                                                           # Help messages for each command
+        'get': 'Get all executions or one if ID is provided',
+    }
+
+
+@click.group('executions', cls=ExecutionsCommand, help='Get executions')
+def executions():
+    '''Get executions'''
diff --git a/src/rekono/commands/findings.py b/src/rekono/commands/findings.py
new file mode 100644
index 0000000..5799f61
--- /dev/null
+++ b/src/rekono/commands/findings.py
@@ -0,0 +1,72 @@
+'''CLI command to manage findings entities.'''
+
+import click
+
+from rekono.framework.commands.entity import EntityCommand
+
+
+class FindingsCommand(EntityCommand):
+    '''CLI command to manage findings entities.'''
+
+    commands = ['get', 'enable', 'disable']                                     # CLI commands
+    commands_mapping = {                                                        # Mapping between commands and methods
+        'get': 'get_entity',
+        'disable': 'delete_entity',
+    }
+    help_messages = {                                                           # Help messages for each command
+        'get': 'Get all findings or one if ID is provided',
+        'enable': 'Enable finding',
+        'disable': 'Disable finding',
+    }
+
+
+@click.group('credentials', cls=FindingsCommand, help='Manage credentials')
+def credentials():
+    '''Manage credentials.'''
+
+
+@click.group('exploits', cls=FindingsCommand, help='Manage exploits')
+def exploits():
+    '''Manage exploits.'''
+
+
+@click.group('hosts', cls=FindingsCommand, help='Manage hosts')
+def hosts():
+    '''Manage hosts.'''
+
+
+@click.group('paths', cls=FindingsCommand, help='Manage paths')
+def paths():
+    '''Manage paths.'''
+
+
+@click.group('ports', cls=FindingsCommand, help='Manage ports')
+def ports():
+    '''Manage ports.'''
+
+
+@click.group('technologies', cls=FindingsCommand, help='Manage technologies')
+def technologies():
+    '''Manage technologies.'''
+
+
+@click.group('vulnerabilities', cls=FindingsCommand, help='Manage vulnerabilities')
+def vulnerabilities():
+    '''Manage vulnerabilities.'''
+
+
+class OSINTCommand(FindingsCommand):
+    '''CLI command to manage OSINT entities.'''
+
+    commands = ['get', 'enable', 'disable', 'target']                           # Supported CLI commands
+    help_messages = {                                                           # Help messages for each command
+        'get': 'Get all findings or one if ID is provided',
+        'enable': 'Enable finding',
+        'disable': 'Disable finding',
+        'target': 'Create target from OSINT data',
+    }
+
+
+@click.group('osint', cls=OSINTCommand, help='Manage OSINT findings')
+def osint():
+    '''Manage OSINT findings.'''
diff --git a/src/rekono/commands/processes.py b/src/rekono/commands/processes.py
new file mode 100644
index 0000000..27d8878
--- /dev/null
+++ b/src/rekono/commands/processes.py
@@ -0,0 +1,27 @@
+'''CLI command to manage Process entities.'''
+
+import click
+
+from rekono.framework.commands.entity import EntityCommand
+from rekono.framework.options import tags_option
+
+
+class ProcessesCommand(EntityCommand):
+    '''CLI command to manage Process entities.'''
+
+    help_messages = {                                                           # Help messages for each command
+        'get': 'Get all processes or one if ID is provided',
+        'create': 'Create process',
+        'update': 'Update process',
+        'delete': 'Delete process'
+    }
+    entity_options = [                                                          # Specific options for post and put
+        click.option('-n', '--name', 'name', required=True, type=str, help='Process name'),
+        click.option('-d', '--description', 'description', required=True, type=str, help='Process description'),
+        tags_option
+    ]
+
+
+@click.group('processes', cls=ProcessesCommand, help='Manage processes')
+def processes():
+    '''Manage processes.'''
diff --git a/src/rekono/commands/profile.py b/src/rekono/commands/profile.py
new file mode 100644
index 0000000..188c6fc
--- /dev/null
+++ b/src/rekono/commands/profile.py
@@ -0,0 +1,92 @@
+'''CLI command to manage user profile.'''
+
+import json
+from typing import List
+
+import click
+
+from rekono.framework.commands.entity import EntityCommand
+from rekono.framework.options import json_option
+
+
+class ProfileCommand(EntityCommand):
+    '''CLI command to manage user profile.'''
+
+    commands = ['get', 'telegram']                                              # CLI commands
+    commands_mapping = {                                                        # Mapping between commands and methods
+        'get': 'get_profile',
+        'telegram': 'link_telegram_bot'
+    }
+    help_messages = {                                                           # Help messages for each command
+        'get': 'Get current user profile',
+        'telegram': 'Link Telegram bot to current user'
+    }
+
+    @staticmethod
+    @click.command
+    @click.pass_context
+    @json_option
+    def get_profile(
+        ctx: click.Context,
+        url: str,
+        headers: List[str],
+        no_verify: bool,
+        show_headers: bool,
+        only_show_status_code: bool,
+        quiet: bool,
+        json_output: str
+    ) -> None:
+        '''Get current user profile.
+
+        Args:
+            ctx (click.Context): Click context.
+            url (str): Rekono base URL.
+            headers (List[str]): HTTP headers to send in key=value format.
+            no_verify (bool): Disable TLS validation.
+            show_headers (bool): Display HTTP response headers.
+            only_show_status_code (bool): Just display HTTP response status code.
+            quiet (bool): Don't display anything from response.
+            json_output (str): Filepath to the JSON file where content should be saved.
+        '''
+        ctx.invoke(
+            ProfileCommand.get, endpoint='/api/profile/',
+            url=url, headers=headers, no_verify=no_verify, parameters=[], pagination=False,
+            show_headers=show_headers, only_show_status_code=only_show_status_code, quiet=quiet, json_output=json_output
+        )
+
+    @staticmethod
+    @click.command
+    @click.pass_context
+    @click.option('-t', '--token', 'token', required=True, type=str, help='Token provided by Telegram bot')
+    def link_telegram_bot(
+        ctx: click.Context,
+        token: str,
+        url: str,
+        headers: List[str],
+        no_verify: bool,
+        show_headers: bool,
+        only_show_status_code: bool,
+        quiet: bool
+    ) -> None:
+        '''Link user account to Telegram chat.
+
+        Args:
+            ctx (click.Context): Click context.
+            token (str): Temporal token provided by Rekono bot.
+            url (str): Rekono base URL.
+            headers (List[str]): HTTP headers to send in key=value format.
+            no_verify (bool): Disable TLS validation.
+            show_headers (bool): Display HTTP response headers.
+            only_show_status_code (bool): Just display HTTP response status code.
+            quiet (bool): Don't display anything from response.
+        '''
+        ctx.invoke(
+            ProfileCommand.post, endpoint='/api/profile/telegram-token/',
+            url=url, headers=headers, no_verify=no_verify, body=json.dumps({'otp': token}),
+            show_headers=show_headers, only_show_status_code=only_show_status_code, quiet=quiet, json_output=None
+        )
+
+
+@click.group('profile', cls=ProfileCommand, help='Manage user profile')
+def profile():
+    '''Manage user profile.'''
diff --git a/src/rekono/commands/projects.py b/src/rekono/commands/projects.py
new file mode 100644
index 0000000..70b6d5d
--- /dev/null
+++ b/src/rekono/commands/projects.py
@@ -0,0 +1,112 @@
+'''CLI command to manage Project entities.'''
+
+import json
+from typing import List
+
+import click
+
+from rekono.framework.arguments import id_mandatory_argument
+from rekono.framework.commands.entity import EntityCommand
+from rekono.framework.options import tags_option
+
+
+class ProjectsCommand(EntityCommand):
+    '''CLI command to manage Project entities.'''
+
+    commands = ['get', 'create', 'update', 'delete', 'add-member', 'remove-member']                 # CLI commands
+    commands_mapping = {                                                        # Mapping between commands and methods
+        'get': 'get_entity',
+        'create': 'post_entity',
+        'update': 'put_entity',
+        'delete': 'delete_entity',
+        'add-member': 'add_member',
+        'remove-member': 'remove_member'
+    }
+    help_messages = {                                                           # Help messages for each command
+        'get': 'Get all projects or one if ID is provided',
+        'create': 'Create project',
+        'update': 'Update project',
+        'delete': 'Delete project',
+        'add-member': 'Add member to project',
+        'remove-member': 'Remove member from project'
+    }
+    entity_options = [                                                          # Specific options for post and put
+        click.option('-n', '--name', 'name', required=True, type=str, help='Project name'),
+        click.option('-d', '--description', 'description', required=True, type=str, help='Project description'),
+        tags_option
+    ]
+
+    @staticmethod
+    @click.command
+    @click.pass_context
+    @id_mandatory_argument
+    @click.option('-u', '--user', 'user', required=True, type=int, help='User ID')
+    def add_member(
+        ctx: click.Context,
+        id: int,
+        user: int,
+        url: str,
+        headers: List[str],
+        no_verify: bool,
+        show_headers: bool,
+        only_show_status_code: bool,
+        quiet: bool
+    ) -> None:
+        '''Add user as member to project.
+
+        Args:
+            ctx (click.Context): Click context.
+            id (int): Project ID to remove user from.
+            user (int): User ID to add to the project as member.
+            url (str): Rekono base URL.
+            headers (List[str]): HTTP headers to send in key=value format.
+            no_verify (bool): Disable TLS validation.
+            show_headers (bool): Display HTTP response headers.
+            only_show_status_code (bool): Just display HTTP response status code.
+            quiet (bool): Don't display anything from response.
+        '''
+        ctx.invoke(
+            ProjectsCommand.post, endpoint=f'/api/projects/{id}/members/',
+            url=url, headers=headers, no_verify=no_verify, body=json.dumps({'user': user}),
+            show_headers=show_headers, only_show_status_code=only_show_status_code, quiet=quiet
+        )
+
+    @staticmethod
+    @click.command
+    @click.pass_context
+    @id_mandatory_argument
+    @click.option('-u', '--user', 'user', required=True, type=int, help='User ID')
+    def remove_member(
+        ctx: click.Context,
+        id: int,
+        user: int,
+        url: str,
+        headers: List[str],
+        no_verify: bool,
+        show_headers: bool,
+        only_show_status_code: bool,
+        quiet: bool
+    ) -> None:
+        '''Remove member from project.
+
+        Args:
+            ctx (click.Context): Click context.
+            id (int): Project ID to remove user from.
+            user (int): User ID to add to the project as member.
+            url (str): Rekono base URL.
+            headers (List[str]): HTTP headers to send in key=value format.
+            no_verify (bool): Disable TLS validation.
+            show_headers (bool): Display HTTP response headers.
+            only_show_status_code (bool): Just display HTTP response status code.
+            quiet (bool): Don't display anything from response.
+        '''
+        ctx.invoke(
+            ProjectsCommand.delete, endpoint=f'/api/projects/{id}/members/{user}/',
+            url=url, headers=headers, no_verify=no_verify,
+            show_headers=show_headers, only_show_status_code=only_show_status_code, quiet=quiet
+        )
+
+
+@click.group('projects', cls=ProjectsCommand, help='Manage projects')
+def projects():
+    '''Manage projects.'''
diff --git a/src/rekono/commands/settings.py b/src/rekono/commands/settings.py
new file mode 100644
index 0000000..eb21700
--- /dev/null
+++ b/src/rekono/commands/settings.py
@@ -0,0 +1,57 @@
+'''CLI command to manage system settings.'''
+
+from typing import List
+
+import click
+
+from rekono.framework.commands.entity import EntityCommand
+from rekono.framework.options import json_option
+
+
+class SettingsCommand(EntityCommand):
+    '''CLI command to manage system settings.'''
+
+    commands = ['get']                                                          # CLI commands
+    commands_mapping = {                                                        # Mapping between commands and methods
+        'get': 'get_settings'
+    }
+    help_messages = {                                                           # Help messages for each command
+        'get': 'Get system settings'
+    }
+
+    @staticmethod
+    @click.command
+    @click.pass_context
+    @json_option
+    def get_settings(
+        ctx: click.Context,
+        url: str,
+        headers: List[str],
+        no_verify: bool,
+        show_headers: bool,
+        only_show_status_code: bool,
+        quiet: bool,
+        json_output: str
+    ) -> None:
+        '''Get current system settings.
+
+        Args:
+            ctx (click.Context): Click context.
+            url (str): Rekono base URL.
+            headers (List[str]): HTTP headers to send in key=value format.
+            no_verify (bool): Disable TLS validation.
+            show_headers (bool): Display HTTP response headers.
+            only_show_status_code (bool): Just display HTTP response status code.
+            quiet (bool): Don't display anything from response.
+            json_output (str): Filepath to the JSON file where content should be saved.
+        '''
+        ctx.invoke(
+            SettingsCommand.get, endpoint='/api/system/1/',
+            url=url, headers=headers, no_verify=no_verify, parameters=[], pagination=False,
+            show_headers=show_headers, only_show_status_code=only_show_status_code, quiet=quiet, json_output=json_output
+        )
+
+
+@click.group('settings', cls=SettingsCommand, help='Get system settings')
+def settings():
+    '''Get system settings.'''
diff --git a/src/rekono/commands/steps.py b/src/rekono/commands/steps.py
new file mode 100644
index 0000000..7031ef7
--- /dev/null
+++ b/src/rekono/commands/steps.py
@@ -0,0 +1,27 @@
+'''CLI command to manage Step entities.'''
+
+import click
+
+from rekono.framework.commands.entity import EntityCommand
+
+
+class StepsCommand(EntityCommand):
+    '''CLI command to manage Step entities.'''
+
+    help_messages = {                                                           # Help messages for each command
+        'get': 'Get all steps or one if ID is provided',
+        'create': 'Create step',
+        'update': 'Update step',
+        'delete': 'Delete step',
+    }
+    entity_options = [                                                          # Specific options for post and put
+        click.option('-p', '--process', 'process', required=True, type=int, help='Process ID'),
+        click.option('-t', '--tool', 'tool_id', required=True, type=int, help='Tool ID'),
+        click.option('-c', '--configuration', 'configuration_id', required=True, type=int, help='Configuration ID'),
+        click.option('--priority', 'priority', required=False, default=1, type=int, help='Step priority within process')
+    ]
+
+
+@click.group('steps', cls=StepsCommand, help='Manage steps')
+def steps():
+    '''Manage steps.'''
diff --git a/src/rekono/commands/target_ports.py b/src/rekono/commands/target_ports.py
new file mode 100644
index 0000000..ee03608
--- /dev/null
+++ b/src/rekono/commands/target_ports.py
@@ -0,0 +1,25 @@
+'''CLI command to manage TargetPort entities.'''
+
+import click
+
+from rekono.framework.commands.entity import EntityCommand
+
+
+class TargetPortsCommand(EntityCommand):
+    '''CLI command to manage TargetPort entities.'''
+
+    commands = ['get', 'create', 'delete']                                      # CLI commands
+    help_messages = {                                                           # Help messages for each command
+        'get': 'Get all target ports or one if ID is provided',
+        'create': 'Create target port',
+        'delete': 'Delete target port',
+    }
+    entity_options = [                                                          # Specific options for post and put
+        click.option('-t', '--target', 'target', required=True, type=int, help='Target ID'),
+        click.option('-p', '--port', 'port', required=True, type=int, help='Port number')
+    ]
+
+
+@click.group('target-ports', cls=TargetPortsCommand, help='Manage target ports')
+def target_ports():
+    '''Manage target ports.'''
diff --git a/src/rekono/commands/targets.py b/src/rekono/commands/targets.py
new file mode 100644
index 0000000..995f9c5
--- /dev/null
+++ b/src/rekono/commands/targets.py
@@ -0,0 +1,30 @@
+'''CLI command to manage Target entities.'''
+
+import click
+
+from rekono.framework.commands.entity import EntityCommand
+
+
+class TargetsCommand(EntityCommand):
+    '''CLI command to manage Target entities.'''
+
+    commands = ['get', 'create', 'delete']                                      # CLI commands
+    help_messages = {                                                           # Help messages for each command
+        'get': 'Get all targets or one if ID is provided',
+        'create': 'Create target',
+        'delete': 'Delete target',
+    }
+    entity_options = [                                                          # Specific options for post and put
+        click.option('-p', '--project', 'project', required=True, type=int, help='Project ID'),
+        click.option('-t', '--target', 'target', required=True, type=str, help='Target address'),
+        click.option(
+            '-d', '--dd-engagement', 'defectdojo_engagement_id',
+            required=False, default=None, type=int,
+            help='Engagement ID in Defect-Dojo'
+        )
+    ]
+
+
+@click.group('targets', cls=TargetsCommand, help='Manage targets')
+def targets():
+    '''Manage targets.'''
diff --git a/src/rekono/commands/tasks.py b/src/rekono/commands/tasks.py
new file mode 100644
index 0000000..55f0288
--- /dev/null
+++ b/src/rekono/commands/tasks.py
@@ -0,0 +1,111 @@
+'''CLI command to manage Task entities.'''
+
+import json
+from typing import Any, List
+
+import click
+
+from rekono.client.enums import IntensityRank, TimeUnit
+from rekono.framework.commands.entity import EntityCommand
+from rekono.framework.options import json_option
+
+
+class TasksCommand(EntityCommand):
+    '''CLI command to manage Task entities.'''
+
+    commands = ['get', 'create', 'cancel', 'repeat']                            # CLI commands
+    commands_mapping = {                                                        # Mapping between commands and methods
+        'get': 'get_entity',
+        'create': 'post_entity',
+        'cancel': 'delete_entity'
+    }
+    help_messages = {                                                           # Help messages for each command
+        'get': 'Get all tasks or one if ID is provided',
+        'create': 'Create task',
+        'cancel': 'Cancel task execution',
+    }
+    entity_options = [                                                          # Specific options for post and put
+        click.option('-t', '--target', 'target_id', required=True, type=int, help='Process ID'),
+        click.option('-p', '--process', 'process_id', required=False, default=None, type=int, help='Process ID'),
+        click.option('--tool', 'tool_id', required=False, default=None, type=int, help='Tool ID'),
+        click.option(
+            '-c', '--configuration', 'configuration_id',
+            required=False, default=None,
+            type=int, help='Configuration ID'
+        ),
+        click.option(
+            '-i', '--intensity', 'intensity_rank',
+            required=False, default=IntensityRank.NORMAL.value,
+            type=click.Choice([t.value for t in IntensityRank]),
+            help='Intensity rank'
+        ),
+        click.option(
+            '-sat', '--scheduled-at', 'scheduled_at',
+            required=False, type=click.DateTime(formats=['%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M:%S']),
+            help='Exactly time to scheduled task at'
+        ),
+        click.option(
+            '-sin', '--scheduled-in', 'scheduled_in',
+            required=False, type=int, help='Schedule task to some time later'
+        ),
+        click.option(
+            '-stu', '--scheduled-time-unit', 'scheduled_time_unit',
+            required=False, type=click.Choice([t.value for t in TimeUnit]),
+            help='Time unit to apply in scheduling'
+        ),
+        click.option(
+            '-rin', '--repeat-in', 'repeat_in',
+            required=False, type=int, help='Repeat task periodically after some time'
+        ),
+        click.option(
+            '-rtu', '--repeat-time-unit', 'repeat_time_unit',
+            required=False, type=click.Choice([t.value for t in TimeUnit]),
+            help='Time unit to apply in repeating'
+        ),
+        click.option('-w', '--wordlist', 'wordlists', multiple=True, required=False, type=int, help='Wordlist ID')
+    ]
+
+    @staticmethod
+    @click.command
+    @click.pass_context
+    @json_option
+    def post_entity(
+        ctx: click.Context,
+        url: str,
+        headers: List[str],
+        no_verify: bool,
+        show_headers: bool,
+        only_show_status_code: bool,
+        quiet: bool,
+        json_output: str,
+        **kwargs: Any
+    ) -> None:
+        '''POST request to create specific entity via Rekono API.
+
+        Args:
+            ctx (click.Context): Click context.
+            url (str): Rekono base URL.
+            headers (List[str]): HTTP headers to send in key=value format.
+            no_verify (bool): Disable TLS validation.
+            show_headers (bool): Display HTTP response headers.
+            only_show_status_code (bool): Just display HTTP response status code.
+            quiet (bool): Don't display anything from response.
+            json_output (str): Filepath to the JSON file where content should be saved.
+            kwargs (Any): Variable fields that will be sent as body
+        '''
+        for no_null_field in ['process_id', 'tool_id', 'configuration_id']:
+            if no_null_field in kwargs and not kwargs.get(no_null_field):       # Empty value
+                kwargs.pop(no_null_field)                                       # Remove empty values from body data
+        # Set scheduled_at field as ISO format
+        kwargs['scheduled_at'] = kwargs['scheduled_at'].astimezone().isoformat() if kwargs.get('scheduled_at') else None
+        ctx.invoke(
+            TasksCommand.post, endpoint='/api/tasks/',
+            url=url, headers=headers, no_verify=no_verify, body=json.dumps(kwargs),
+            show_headers=show_headers, only_show_status_code=only_show_status_code,
+            quiet=quiet, json_output=json_output
+        )
+
+
+@click.group('tasks', cls=TasksCommand, help='Manage tasks')
+def tasks():
+    '''Manage tasks.'''
diff --git a/src/rekono/commands/tools.py b/src/rekono/commands/tools.py
new file mode 100644
index 0000000..c90d00b
--- /dev/null
+++ b/src/rekono/commands/tools.py
@@ -0,0 +1,19 @@
+'''CLI command to manage Tool entities.'''
+
+import click
+
+from rekono.framework.commands.entity import EntityCommand
+
+
+class ToolsCommand(EntityCommand):
+    '''CLI command to manage Tool entities.'''
+
+    commands = ['get']                                                          # CLI commands
+    help_messages = {                                                           # Help messages for each command
+        'get': 'Get all tools or one if ID is provided',
+    }
+
+
+@click.group('tools', cls=ToolsCommand, help='Get tools')
+def tools():
+    '''Get tools.'''
diff --git a/src/rekono/commands/users.py b/src/rekono/commands/users.py
new file mode 100644
index 0000000..74b6f36
--- /dev/null
+++ b/src/rekono/commands/users.py
@@ -0,0 +1,117 @@
+'''CLI command to manage User entities.'''
+
+import json
+from typing import List
+
+import click
+
+from rekono.client.enums import UserRole
+from rekono.framework.arguments import id_mandatory_argument
+from rekono.framework.commands.entity import EntityCommand
+from rekono.framework.options import json_option
+
+user_role_option = click.option(                                                # Role CLI option
+    '-r', '--role', 'role',
+    required=False, default=UserRole.READER.value,
+    type=click.Choice([t.value for t in UserRole]),
+    help='User role'
+)
+
+
+class UsersCommand(EntityCommand):
+    '''CLI command to manage User entities.'''
+
+    commands = ['get', 'delete', 'enable', 'role', 'invite']                    # CLI commands
+    commands_mapping = {                                                        # Mapping between commands and methods
+        'get': 'get_entity',
+        'delete': 'delete_entity',
+        'role': 'update_role',
+        'invite': 'invite'
+    }
+    help_messages = {                                                           # Help messages for each command
+        'get': 'Get all users or one if ID is provided',
+        'delete': 'Delete user',
+        'role': 'Update user role',
+        'invite': 'Invite new user'
+    }
+
+    @staticmethod
+    @click.command
+    @click.pass_context
+    @id_mandatory_argument
+    @user_role_option
+    @json_option
+    def update_role(
+        ctx: click.Context,
+        id: int,
+        role: str,
+        url: str,
+        headers: List[str],
+        no_verify: bool,
+        show_headers: bool,
+        only_show_status_code: bool,
+        quiet: bool,
+        json_output: str
+    ) -> None:
+        '''Update user role.
+
+        Args:
+            ctx (click.Context): Click context.
+            id (int): User ID to update.
+            role (str): Role to asssin to the user.
+            url (str): Rekono base URL.
+            headers (List[str]): HTTP headers to send in key=value format.
+            no_verify (bool): Disable TLS validation.
+            show_headers (bool): Display HTTP response headers.
+            only_show_status_code (bool): Just display HTTP response status code.
+            quiet (bool): Don't display anything from response.
+            json_output (str): Filepath to the JSON file where content should be saved.
+        '''
+        ctx.invoke(
+            UsersCommand.put, endpoint=f'/api/users/{id}/role/',
+            url=url, headers=headers, no_verify=no_verify, body=json.dumps({'role': role}),
+            show_headers=show_headers, only_show_status_code=only_show_status_code, quiet=quiet, json_output=json_output
+        )
+
+    @staticmethod
+    @click.command
+    @click.pass_context
+    @click.option('-e', '--email', 'email', required=True, type=str, help='User email to send invitation')
+    @user_role_option
+    @json_option
+    def invite(
+        ctx: click.Context,
+        email: str,
+        role: str,
+        url: str,
+        headers: List[str],
+        no_verify: bool,
+        show_headers: bool,
+        only_show_status_code: bool,
+        quiet: bool,
+        json_output: str
+    ) -> None:
+        '''Invite new user.
+
+        Args:
+            ctx (click.Context): Click context.
+            email (str): User email to send invitation to.
+            role (str): Role to asssin to the user.
+            url (str): Rekono base URL.
+            headers (List[str]): HTTP headers to send in key=value format.
+            no_verify (bool): Disable TLS validation.
+            show_headers (bool): Display HTTP response headers.
+            only_show_status_code (bool): Just display HTTP response status code.
+            quiet (bool): Don't display anything from response.
+            json_output (str): Filepath to the JSON file where content should be saved.
+        '''
+        ctx.invoke(
+            UsersCommand.post, endpoint='/api/users/invite/',
+            url=url, headers=headers, no_verify=no_verify, body=json.dumps({'email': email, 'role': role}),
+            show_headers=show_headers, only_show_status_code=only_show_status_code, quiet=quiet, json_output=json_output
+        )
+
+
+@click.group('users', cls=UsersCommand, help='Manage users')
+def users():
+    '''Manage users.'''
diff --git a/src/rekono/commands/wordlists.py b/src/rekono/commands/wordlists.py
new file mode 100644
index 0000000..ade101f
--- /dev/null
+++ b/src/rekono/commands/wordlists.py
@@ -0,0 +1,73 @@
+'''CLI command to manage Wordlist entities.'''
+
+import json
+from typing import Any, List
+
+import click
+
+from rekono.client.enums import WordlistType
+from rekono.framework.commands.entity import EntityCommand
+from rekono.framework.options import json_option
+
+
+class WordlistsCommand(EntityCommand):
+    '''CLI command to manage Wordlist entities.'''
+
+    help_messages = {                                                           # Help messages for each command
+        'get': 'Get all wordlists or one if ID is provided',
+        'create': 'Create wordlist',
+        'update': 'Update wordlist',
+        'delete': 'Delete wordlist',
+    }
+    entity_options = [                                                          # Specific options for post and put
+        click.option('-n', '--name', 'name', required=True, type=str, help='Wordlist name'),
+        click.option(
+            '-t', '--type', 'type',
+            required=False, default=WordlistType.ENDPOINT.value,
+            type=click.Choice([t.value for t in WordlistType]),
+            help='Wordlist name'
+        )
+    ]
+
+    @staticmethod
+    @click.command
+    @click.pass_context
+    @click.option('-f', '--file', 'filepath', required=True, type=click.Path(exists=True), help='Wordlists file')
+    @json_option
+    def post_entity(
+        ctx: click.Context,
+        url: str,
+        headers: List[str],
+        no_verify: bool,
+        show_headers: bool,
+        only_show_status_code: bool,
+        quiet: bool,
+        json_output: str,
+        **kwargs: Any
+    ) -> None:
+        '''POST request to create specific entity via Rekono API.
+
+        Args:
+            ctx (click.Context): Click context.
+            filepath (str): Filepath to upload as wordlist file.
+            url (str): Rekono base URL.
+            headers (List[str]): HTTP headers to send in key=value format.
+            no_verify (bool): Disable TLS validation.
+            show_headers (bool): Display HTTP response headers.
+            only_show_status_code (bool): Just display HTTP response status code.
+            quiet (bool): Don't display anything from response.
+            json_output (str): Filepath to the JSON file where content should be saved.
+            kwargs (Any): Variable fields that will be sent as body
+        '''
+        filepath = kwargs.pop('filepath')
+        ctx.invoke(
+            WordlistsCommand.post, endpoint='/api/wordlists/',
+            url=url, headers=headers, no_verify=no_verify, body=json.dumps(kwargs),
+            show_headers=show_headers, only_show_status_code=only_show_status_code,
+            quiet=quiet, json_output=json_output, filepath=filepath
+        )
+
+
+@click.group('wordlists', cls=WordlistsCommand, help='Manage wordlists')
+def wordlists():
+    '''Manage wordlists.'''
diff --git a/src/rekono/framework/__init__.py b/src/rekono/framework/__init__.py
new file mode 100644
index 0000000..f4376ca
--- /dev/null
+++ b/src/rekono/framework/__init__.py
@@ -0,0 +1,1 @@
+'''Rekono CLI framework that includes base commands, options and arguments.'''
diff --git a/src/rekono/framework/arguments.py b/src/rekono/framework/arguments.py
new file mode 100644
index 0000000..4c817ba
--- /dev/null
+++ b/src/rekono/framework/arguments.py
@@ -0,0 +1,9 @@
+'''Definition of base CLI arguments used by multiple commands.'''
+
+import click
+
+endpoint_argument = click.argument('endpoint', type=str, nargs=1)               # Endpoint argument
+
+id_optional_argument = click.argument('id', type=int, nargs=1, required=False)  # Id optional argument
+
+id_mandatory_argument = click.argument('id', type=int, nargs=1)                 # Id mandatory argument
diff --git a/src/rekono/framework/commands/__init__.py b/src/rekono/framework/commands/__init__.py
new file mode 100644
index 0000000..e726e01
--- /dev/null
+++ b/src/rekono/framework/commands/__init__.py
@@ -0,0 +1,1 @@
+'''Definition of base CLI commands extended by multiple commands.'''
diff --git a/src/rekono/framework/commands/api.py b/src/rekono/framework/commands/api.py
new file mode 100644
index 0000000..896e0b2
--- /dev/null
+++ b/src/rekono/framework/commands/api.py
@@ -0,0 +1,173 @@
+'''Base Rekono CLI command to make API requests.'''
+
+from typing import List
+
+import click
+
+from rekono.framework.arguments import endpoint_argument
+from rekono.framework.commands.command import RekonoCliCommand
+from rekono.framework.options import (all_pages_option, body_option,
+                                      file_option, headers_option, json_option,
+                                      no_verify_option, parameters_option,
+                                      quiet_option, show_headers_option,
+                                      show_status_code_option, url_option)
+
+
+class ApiCommand(RekonoCliCommand):
+    '''Base Rekono CLI command to make API requests.'''
+
+    commands = ['get', 'post', 'put', 'delete']                                 # List of supported commands
+    commands_mapping = {                                                        # Mapping between commands and methods
+        'get': 'get',
+        'post': 'post',
+        'put': 'put',
+        'delete': 'delete'
+    }
+    help_messages = {                                                           # Help messages for each command
+        'get': 'GET request to Rekono API',
+        'post': 'POST request to Rekono API',
+        'put': 'PUT request to Rekono API',
+        'delete': 'DELETE request to Rekono API'
+    }
+    api_options = [url_option, headers_option, no_verify_option]                # API options for all commands
+    display_options = [show_headers_option, show_status_code_option, quiet_option]  # Display options for all commands
+
+    @staticmethod
+    @click.command
+    @endpoint_argument
+    @parameters_option
+    @all_pages_option
+    @json_option
+    def get(
+        endpoint: str,
+        url: str,
+        headers: List[str],
+        no_verify: bool,
+        parameters: List[str],
+        pagination: bool,
+        show_headers: bool,
+        only_show_status_code: bool,
+        quiet: bool,
+        json_output: str
+    ):
+        '''GET request to Rekono API.
+
+        Args:
+            endpoint (str): Endpoint to call.
+            url (str): Rekono base URL.
+            headers (List[str]): HTTP headers to send in key=value format.
+            parameters (List[str]): HTTP query parameters to send in key=value format.
+            no_verify (bool): Disable TLS validation.
+            pagination (bool): Enable iteration over all API pages.
+            show_headers (bool): Display HTTP response headers.
+            only_show_status_code (bool): Just display HTTP response status code.
+            quiet (bool): Don't display anything from response.
+            json_output (str): Filepath to the JSON file where content should be saved.
+        '''
+        client = ApiCommand._rekono_factory(url, no_verify, headers)
+        response_or_responses = client.get(
+            ApiCommand._get_endpoint(endpoint),
+            parameters=ApiCommand._parse_key_value_params(parameters),
+            pagination=pagination
+        )
+        responses = response_or_responses if isinstance(response_or_responses, list) else [response_or_responses]
+        ApiCommand._display_responses(responses, show_headers, only_show_status_code, quiet)
+        ApiCommand._save_output(responses, json_output)
+
+    @staticmethod
+    @click.command
+    @endpoint_argument
+    @body_option
+    @file_option
+    @json_option
+    def post(
+        endpoint: str,
+        url: str,
+        headers: List[str],
+        no_verify: bool,
+        body: str,
+        filepath: str,
+        show_headers: bool,
+        only_show_status_code: bool,
+        quiet: bool,
+        json_output: str
+    ):
+        '''POST request to Rekono API.
+
+        Args:
+            endpoint (str): Endpoint to call.
+            url (str): Rekono base URL.
+            headers (List[str]): HTTP headers to send in key=value format.
+            no_verify (bool): Disable TLS validation.
+            body (str): HTTP body to send in JSON format.
+            filepath (click.Path): File to upload.
+            show_headers (bool): Display HTTP response headers.
+            only_show_status_code (bool): Just display HTTP response status code.
+            quiet (bool): Don't display anything from response.
+            json_output (str): Filepath to the JSON file where content should be saved.
+        '''
+        client = ApiCommand._rekono_factory(url, no_verify, headers)
+        response = client.post(ApiCommand._get_endpoint(endpoint), ApiCommand._get_body(body), filepath)
+        ApiCommand._display_responses([response], show_headers, only_show_status_code, quiet)
+        ApiCommand._save_output([response], json_output)
+
+    @staticmethod
+    @click.command
+    @endpoint_argument
+    @body_option
+    @json_option
+    def put(
+        endpoint: str,
+        url: str,
+        headers: List[str],
+        no_verify: bool,
+        body: str,
+        show_headers: bool,
+        only_show_status_code: bool,
+        quiet: bool,
+        json_output: str
+    ):
+        '''PUT request to Rekono API.
+
+        Args:
+            endpoint (str): Endpoint to call.
+            url (str): Rekono base URL.
+            headers (List[str]): HTTP headers to send in key=value format.
+            no_verify (bool): Disable TLS validation.
+            body (str): HTTP body to send in JSON format.
+            show_headers (bool): Display HTTP response headers.
+            only_show_status_code (bool): Just display HTTP response status code.
+            quiet (bool): Don't display anything from response.
+            json_output (str): Filepath to the JSON file where content should be saved.
+        '''
+        client = ApiCommand._rekono_factory(url, no_verify, headers)
+        response = client.put(ApiCommand._get_endpoint(endpoint), ApiCommand._get_body(body))
+        ApiCommand._display_responses([response], show_headers, only_show_status_code, quiet)
+        ApiCommand._save_output([response], json_output)
+
+    @staticmethod
+    @click.command
+    @endpoint_argument
+    def delete(
+        endpoint: str,
+        url: str,
+        headers: List[str],
+        no_verify: bool,
+        show_headers: bool,
+        only_show_status_code: bool,
+        quiet: bool
+    ):
+        '''DELETE request to Rekono API.
+
+        Args:
+            endpoint (str): Endpoint to call.
+            url (str): Rekono base URL.
+            headers (List[str]): HTTP headers to send in key=value format.
+            no_verify (bool): Disable TLS validation.
+            show_headers (bool): Display HTTP response headers.
+            only_show_status_code (bool): Just display HTTP response status code.
+            quiet (bool): Don't display anything from response.
+        '''
+        client = ApiCommand._rekono_factory(url, no_verify, headers)
+        response = client.delete(ApiCommand._get_endpoint(endpoint))
+        ApiCommand._display_responses([response], show_headers, only_show_status_code, quiet)
diff --git a/src/rekono/framework/commands/command.py b/src/rekono/framework/commands/command.py
new file mode 100644
index 0000000..29c1cd5
--- /dev/null
+++ b/src/rekono/framework/commands/command.py
@@ -0,0 +1,236 @@
+'''Base features for Rekono CLI command.'''
+
+import json
+import os
+import sys
+from typing import Any, Callable, Dict, List, Optional, Union, cast
+from urllib.parse import urlparse
+
+import click
+import requests
+from requests.models import Response
+
+from rekono.client.api import Rekono
+
+
+class RekonoCliCommand(click.MultiCommand):
+    '''Base features for Rekono CLI command.'''
+
+    # Environment variables
+    api_token_env = 'REKONO_TOKEN'                                              # Environment variable to set API token
+    backend_url_env = 'REKONO_URL'                                              # Environment variable to set backend
+    # Initialization of variables
+    commands: List[str] = []                                                    # List of supported commands
+    commands_mapping: Dict[str, str] = {}                                       # Mapping between commands and methods
+    default_mapping: Optional[str] = None                                       # Default method if mapping not found
+    help_messages: Dict[str, str] = {}                                          # Help messages for each command
+    api_options: List[Callable] = []                                            # API options for all commands
+    display_options: List[Callable] = []                                        # Display options for all commands
+    entity_options: List[Callable] = []                                         # Specific options for post and put
+
+    def list_commands(self, ctx: click.Context) -> List[str]:
+        '''Return list of CLI commands.
+
+        Args:
+            ctx (click.Context): Click context.
+
+        Returns:
+            List[str]: List of CLI commands.
+        '''
+        return self.commands
+
+    def get_command(self, ctx: click.Context, cmd_name: str) -> Optional[click.Command]:
+        '''Get CLI command by name.
+
+        Args:
+            ctx (click.Context): Click context.
+            cmd_name (str): Command name.
+
+        Returns:
+            Optional[click.Command]: Click command.
+        '''
+        related_command_method = self.commands_mapping.get(cmd_name, self.default_mapping)      # Get mapped method
+        if cmd_name in self.commands and related_command_method and hasattr(self, related_command_method):
+            command: click.Command = getattr(self, related_command_method)      # Get command method
+            command.help = self.help_messages.get(cmd_name)                     # Set help message
+            if related_command_method in ['post_entity', 'put_entity']:         # POST or PUT entity request
+                self._apply_command_options(command, self.entity_options)       # Set entity options
+            self._apply_command_options(command, self.api_options)              # Set base API options
+            self._apply_command_options(command, self.display_options)          # Set base display options
+            return command
+        return None
+
+    def _apply_command_options(self, command: Callable, options: List[Callable]) -> Callable:
+        '''Apply multiple options to specific command.
+
+        Args:
+            command (Callable): Command to configure
+            options (List[Callable]): List of options to apply
+
+        Returns:
+            Callable: Configured command
+        '''
+        for option in options:
+            command = option(command)
+        return command
+
+    @classmethod
+    def _get_url(cls, url: str) -> str:
+        '''Get valid Rekono base URL from the user provided value.
+
+        Args:
+            url (str): User provided URL.
+
+        Returns:
+            str: Valid Rekono URL.
+        '''
+        parser = urlparse(url)                                                  # Parse provided URL
+        if not parser.netloc:                                                   # Invalid URL
+            click.echo(click.style('URL is invalid', fg='red'), err=True, color=True)
+            url = click.prompt('URL', type=str)                                 # Ask user for base URL
+            return cls._get_url(url)                                            # Retry URL validation
+        return f'{parser.scheme or "https"}://{parser.netloc}'
+
+    @staticmethod
+    def _parse_key_value_params(items: List[str]) -> Dict[str, str]:
+        '''Parse key=value options.
+
+        Args:
+            items (List[str]): List of key=value values.
+
+        Returns:
+            Dict[str, str]: Dictionary with all keys and values.
+        '''
+        data = {}
+        for item in items or []:
+            if '=' in item:                                                     # Check separator
+                key, value = item.split('=', 1)                                 # Get key and value
+                data[key.strip()] = value.strip()
+        return data
+
+    @staticmethod
+    def _get_body(body: Optional[str]) -> Optional[str]:
+        '''Validate body value to be sent via HTTP.
+
+        Args:
+            body (Optional[str]): Body value.
+
+        Returns:
+            Optional[str]: Validated body value.
+        '''
+        if body:
+            try:
+                json.loads(body)                                                # Try to parse body value
+            except json.decoder.JSONDecodeError:                                # Invalid body value
+                click.echo(click.style('Invalid JSON format for body value', fg='red'), err=True, color=True)
+                sys.exit(1)
+        return body
+
+    @staticmethod
+    def _get_endpoint(endpoint: str) -> str:
+        '''Get valid endpoint value without parameters or other URL information.
+
+        Args:
+            endpoint (str): Endpoint value provided by user.
+
+        Returns:
+            str: Valid endpoint value.
+        '''
+        return urlparse(endpoint).path
+
+    @classmethod
+    def _rekono_factory(cls, url: str, no_verify: bool = False, headers: List[str] = []) -> Rekono:
+        '''Create Rekono client entity.
+
+        Args:
+            url (str): Base Rekono URL.
+            no_verify (bool, optional): Disable TLS validation. Defaults to False.
+            headers (List[str], optional): Extra HTTP request headers. Defaults to [].
+
+        Returns:
+            Rekono: Rekono API client.
+        '''
+        token = os.getenv(cls.api_token_env)                                    # Get API token from environment
+        if not token:                                                           # API token is not provided
+            token = click.prompt('API token', type=str, hide_input=True)        # Ask for API token
+        return Rekono(                                                          # Create Rekono API client
+            cls._get_url(url),                                                  # Get valid Rekono URL
+            token=cast(str, token),
+            headers=cls._parse_key_value_params(headers),                       # Get HTTP headers
+            verify=not no_verify
+        )
+
+    @staticmethod
+    def _get_data_from_responses(responses: List[Response]) -> Union[List[Dict[str, Any]], Dict[str, Any]]:
+        '''Get data from Rekono API responses.
+
+        Args:
+            responses (List[Response]): List of Rekono API responses.
+
+        Returns:
+            Union[List[Dict[str, Any]], Dict[str, Any]]: Data returned by Rekono.
+        '''
+        content = []
+        for response in responses:                                              # For each response
+            try:
+                body = response.json()                                          # Parse JSON body from response
+            except requests.exceptions.JSONDecodeError:
+                continue
+            if isinstance(body, dict):                                          # Response body is a dictionary
+                body = body.get('results', body)                                # Get results field if it exists
+            if isinstance(body, list):                                          # Content is a list
+                content.extend(body)                                            # Save content
+            elif isinstance(body, dict):                                        # Content is a dictionary
+                if len(responses) == 1:                                         # Only one response
+                    return body                                                 # Return response content
+                content.append(body)                                            # Save content
+        return content
+
+    @classmethod
+    def _display_responses(
+        cls,
+        responses: List[Response],
+        show_headers: bool,
+        only_show_status_code: bool,
+        quiet: bool
+    ) -> None:
+        '''Display Rekono API responses via standard output.
+
+        Args:
+            responses (List[Response]): Rekono API responses.
+            show_headers (bool): Display HTTP response headers.
+            only_show_status_code (bool): Only display status code from HTTP response.
+            quiet (bool): Don't display anything from response.
+        '''
+        if quiet:                                                               # No content should be displayed
+            return
+        if only_show_status_code or show_headers:                               # Headers or status should be displayed
+            for response in responses:                                          # For each response
+                if only_show_status_code:                                       # Just display status code
+                    click.echo(response.status_code)
+                elif show_headers:                                              # Show response headers
+                    click.echo()
+                    # Display HTTP request and response summary
+                    click.echo(f'{response.request.method} {response.request.path_url} {response.status_code}')
+                    for header, value in response.headers.items():
+                        click.echo(f'{header}: {value}')                        # Display HTTP response headers
+                    click.echo()
+                    data = cls._get_data_from_responses([response])             # Get content from response
+                    click.echo(json.dumps(data, ensure_ascii=True, indent=4))   # Display content via standard output
+        else:                                                                   # Standard display options
+            data = cls._get_data_from_responses(responses)                      # Get content from responses
+            click.echo(json.dumps(data, ensure_ascii=True, indent=4))           # Display content via standard output
+
+    @classmethod
+    def _save_output(cls, responses: List[Response], filepath: Optional[str]) -> None:
+        '''Save responses content in JSON file.
+
+        Args:
+            responses (List[Response]): Rekono API responses.
+            filepath (Optional[str]): Filepath to the JSON file where content should be saved.
+        '''
+        if not filepath:                                                        # JSON filepath isn't provided
+            return
+        data = cls._get_data_from_responses(responses)                          # Get data from responses
+        with open(filepath, 'w', encoding='utf-8') as file:                     # Open JSON file
+            json.dump(data, file, ensure_ascii=True, indent=4)                  # Write content in JSON file
diff --git a/src/rekono/framework/commands/entity.py b/src/rekono/framework/commands/entity.py
new file mode 100644
index 0000000..7fefdbd
--- /dev/null
+++ b/src/rekono/framework/commands/entity.py
@@ -0,0 +1,210 @@
+'''Base Rekono CLI command to make specific entity operations using API.'''
+
+import json
+from typing import Any, List, Optional, cast
+
+import click
+
+from rekono.framework.arguments import (id_mandatory_argument,
+                                        id_optional_argument)
+from rekono.framework.commands.api import ApiCommand
+from rekono.framework.options import json_option
+
+
+class EntityCommand(ApiCommand):
+    '''Base Rekono CLI command to make specific entity operations using API.'''
+
+    commands = ['get', 'create', 'update', 'delete']                            # CLI commands
+    commands_mapping = {                                                        # Mapping between commands and methods
+        'get': 'get_entity',
+        'create': 'post_entity',
+        'update': 'put_entity',
+        'delete': 'delete_entity',
+    }
+    default_mapping = 'extra_post_entity'                                       # Default method if mapping not found
+
+    @staticmethod
+    @click.command
+    @click.pass_context
+    @id_optional_argument
+    @json_option
+    def get_entity(
+        ctx: click.Context,
+        id: Optional[int],
+        url: str,
+        headers: List[str],
+        no_verify: bool,
+        show_headers: bool,
+        only_show_status_code: bool,
+        quiet: bool,
+        json_output: str
+    ):
+        '''GET request to retrieve specific entities via Rekono API.
+
+        Args:
+            ctx (click.Context): Click context.
+            id (Optional[int]): Entity Id to retrieve.
+            url (str): Rekono base URL.
+            headers (List[str]): HTTP headers to send in key=value format.
+            no_verify (bool): Disable TLS validation.
+            show_headers (bool): Display HTTP response headers.
+            only_show_status_code (bool): Just display HTTP response status code.
+            quiet (bool): Don't display anything from response.
+            json_output (str): Filepath to the JSON file where content should be saved.
+        '''
+        parameters = {
+            'endpoint': f'/api/{cast(click.Context, ctx.parent).info_name}/',
+            'url': url,
+            'headers': headers,
+            'no_verify': no_verify,
+            'parameters': [],
+            'pagination': True,
+            'show_headers': show_headers,
+            'only_show_status_code': only_show_status_code,
+            'quiet': quiet,
+            'json_output': json_output
+        }
+        if id:
+            parameters.update({
+                'endpoint': f'/api/{cast(click.Context, ctx.parent).info_name}/{id}/',
+                'pagination': False
+            })
+        ctx.invoke(EntityCommand.get, **parameters)
+
+    @staticmethod
+    @click.command
+    @click.pass_context
+    @json_option
+    def post_entity(
+        ctx: click.Context,
+        url: str,
+        headers: List[str],
+        no_verify: bool,
+        show_headers: bool,
+        only_show_status_code: bool,
+        quiet: bool,
+        json_output: str,
+        **kwargs: Any
+    ) -> None:
+        '''POST request to create specific entity via Rekono API.
+
+        Args:
+            ctx (click.Context): Click context.
+            url (str): Rekono base URL.
+            headers (List[str]): HTTP headers to send in key=value format.
+            no_verify (bool): Disable TLS validation.
+            show_headers (bool): Display HTTP response headers.
+            only_show_status_code (bool): Just display HTTP response status code.
+            quiet (bool): Don't display anything from response.
+            json_output (str): Filepath to the JSON file where content should be saved.
+            kwargs (Any): Variable fields that will be sent as body
+        '''
+        ctx.invoke(
+            EntityCommand.post, endpoint=f'/api/{cast(click.Context, ctx.parent).info_name}/',
+            url=url, headers=headers, no_verify=no_verify, body=json.dumps(kwargs),
+            show_headers=show_headers, only_show_status_code=only_show_status_code, quiet=quiet,
+            json_output=json_output
+        )
+
+    @staticmethod
+    @click.command
+    @click.pass_context
+    @id_mandatory_argument
+    @json_option
+    def put_entity(
+        ctx: click.Context,
+        id: int,
+        url: str,
+        headers: List[str],
+        no_verify: bool,
+        show_headers: bool,
+        only_show_status_code: bool,
+        quiet: bool,
+        json_output: str,
+        **kwargs: Any
+    ) -> None:
+        '''PUT request to update specific entity via Rekono API.
+
+        Args:
+            ctx (click.Context): Click context.
+            id (int): Entity Id to updae.
+            url (str): Rekono base URL.
+            headers (List[str]): HTTP headers to send in key=value format.
+            no_verify (bool): Disable TLS validation.
+            show_headers (bool): Display HTTP response headers.
+            only_show_status_code (bool): Just display HTTP response status code.
+            quiet (bool): Don't display anything from response.
+            json_output (str): Filepath to the JSON file where content should be saved.
+            kwargs (Any): Variable fields that will be sent as body.
+        '''
+        ctx.invoke(
+            EntityCommand.put, endpoint=f'/api/{cast(click.Context, ctx.parent).info_name}/{id}/',
+            url=url, headers=headers, no_verify=no_verify, body=json.dumps(kwargs),
+            show_headers=show_headers, only_show_status_code=only_show_status_code, quiet=quiet,
+            json_output=json_output
+        )
+
+    @staticmethod
+    @click.command
+    @click.pass_context
+    @id_mandatory_argument
+    def delete_entity(
+        ctx: click.Context,
+        id: int,
+        url: str,
+        headers: List[str],
+        no_verify: bool,
+        show_headers: bool,
+        only_show_status_code: bool,
+        quiet: bool
+    ):
+        '''DELETE request to remove specific entity via Rekono API.
+
+        Args:
+            ctx (click.Context): Click context.
+            id (int): Entity Id to remove.
+            url (str): Rekono base URL.
+            headers (List[str]): HTTP headers to send in key=value format.
+            no_verify (bool): Disable TLS validation.
+            show_headers (bool): Display HTTP response headers.
+            only_show_status_code (bool): Just display HTTP response status code.
+            quiet (bool): Don't display anything from response.
+        '''
+        ctx.invoke(
+            EntityCommand.delete, endpoint=f'/api/{cast(click.Context, ctx.parent).info_name}/{id}/',
+            url=url, headers=headers, no_verify=no_verify,
+            show_headers=show_headers, only_show_status_code=only_show_status_code, quiet=quiet
+        )
+
+    @staticmethod
+    @click.command
+    @click.pass_context
+    @id_mandatory_argument
+    def extra_post_entity(
+        ctx: click.Context,
+        id: int,
+        url: str,
+        headers: List[str],
+        no_verify: bool,
+        show_headers: bool,
+        only_show_status_code: bool,
+        quiet: bool
+    ) -> None:
+        '''POST request to make some operation against specific entity via Rekono API.
+
+        Args:
+            ctx (click.Context): Click context.
+            id (int): Entity Id to updae.
+            url (str): Rekono base URL.
+            headers (List[str]): HTTP headers to send in key=value format.
+            no_verify (bool): Disable TLS validation.
+            show_headers (bool): Display HTTP response headers.
+            only_show_status_code (bool): Just display HTTP response status code.
+            quiet (bool): Don't display anything from response.
+        '''
+        ctx.invoke(
+            EntityCommand.post, endpoint=f'/api/{cast(click.Context, ctx.parent).info_name}/{id}/{ctx.info_name}/',
+            url=url, headers=headers, no_verify=no_verify, body=None,
+            show_headers=show_headers, only_show_status_code=only_show_status_code, quiet=quiet,
+            json_output=None
+        )
diff --git a/src/rekono/framework/options.py b/src/rekono/framework/options.py
new file mode 100644
index 0000000..2ad3bed
--- /dev/null
+++ b/src/rekono/framework/options.py
@@ -0,0 +1,83 @@
+'''Definition of base CLI options used by multiple commands.'''
+
+import click
+
+from rekono.framework.commands.command import RekonoCliCommand
+
+url_option = click.option(                                                      # URL option
+    '-u', '--url', 'url',
+    type=str, required=False,
+    envvar=RekonoCliCommand.backend_url_env,
+    default='http://127.0.0.1:8000',
+    help='Base URL to the Rekono backend'
+)
+
+headers_option = click.option(                                                  # Extra request headers option
+    '-h', '--header', 'headers',
+    multiple=True, type=str,
+    required=False, default=[],
+    help='HTTP header to send in format "<key>=value"'
+)
+
+no_verify_option = click.option(                                                # Option to disable TLS verification
+    '--no-verify', 'no_verify',
+    is_flag=True, default=False,
+    help='Disable TLS verification'
+)
+
+parameters_option = click.option(                                               # Request parameters option
+    '-p', '--parameter', 'parameters',
+    multiple=True, type=str,
+    required=False, default=[],
+    help='HTTP parameter to send in format "<key>=value"'
+)
+
+body_option = click.option(                                                     # Request body option
+    '-b', '--body', 'body',
+    type=str, required=False,
+    help='HTTP body to send in JSON'
+)
+
+file_option = click.option(                                                     # Filepath to upload option
+    '-f', '--file', 'filepath',
+    type=click.Path(exists=True),
+    required=False, default=None,
+    help='File to upload'
+)
+
+all_pages_option = click.option(                                                # Option to iterate over all API pages
+    '-a', '--all-pages', 'pagination',
+    is_flag=True, default=False,
+    help='Perform pagination over all pages'
+)
+
+show_headers_option = click.option(                                             # Option to show response headers
+    '-s', '--show-headers', 'show_headers',
+    is_flag=True, default=False,
+    help='Show response headers'
+)
+
+show_status_code_option = click.option(                                         # Option to only show response status
+    '--status-code', 'only_show_status_code',
+    is_flag=True, default=False,
+    help='Only show response status code'
+)
+
+quiet_option = click.option(                                                    # Option to don't show anything
+    '--quiet', 'quiet',
+    is_flag=True, default=False,
+    help='Don\'t show anything from response'
+)
+
+json_option = click.option(                                                     # JSON output option
+    '-j', '--json', 'json_output',
+    type=str, required=False,
+    help='Save response data in JSON file'
+)
+
+tags_option = click.option(                                                     # Tags option used by multiple commands
+    '-t', '--tag', 'tags',
+    multiple=True, type=str,
+    required=False, default=[],
+    help='Related tags'
+)
diff --git a/src/rekono/main.py b/src/rekono/main.py
new file mode 100644
index 0000000..6371fd7
--- /dev/null
+++ b/src/rekono/main.py
@@ -0,0 +1,59 @@
+'''Main Rekono CLI exectuable.'''
+
+import click
+
+from rekono import VERSION
+from rekono.commands.api import api
+from rekono.commands.authentications import authentications
+from rekono.commands.configurations import configurations
+from rekono.commands.executions import executions
+from rekono.commands.findings import (credentials, exploits, hosts, osint,
+                                      paths, ports, technologies,
+                                      vulnerabilities)
+from rekono.commands.processes import processes
+from rekono.commands.profile import profile
+from rekono.commands.projects import projects
+from rekono.commands.settings import settings
+from rekono.commands.steps import steps
+from rekono.commands.target_ports import target_ports
+from rekono.commands.targets import targets
+from rekono.commands.tasks import tasks
+from rekono.commands.tools import tools
+from rekono.commands.users import users
+from rekono.commands.wordlists import wordlists
+
+
+@click.group()
+@click.version_option(version=VERSION, message='%(version)s')
+def rekono():
+    '''Rekono CLI.'''
+
+
+# Add CLI commands
+rekono.add_command(api)
+rekono.add_command(authentications)
+rekono.add_command(configurations)
+rekono.add_command(credentials)
+rekono.add_command(executions)
+rekono.add_command(exploits)
+rekono.add_command(hosts)
+rekono.add_command(osint)
+rekono.add_command(paths)
+rekono.add_command(ports)
+rekono.add_command(processes)
+rekono.add_command(profile)
+rekono.add_command(projects)
+rekono.add_command(settings)
+rekono.add_command(steps)
+rekono.add_command(target_ports)
+rekono.add_command(targets)
+rekono.add_command(tasks)
+rekono.add_command(technologies)
+rekono.add_command(tools)
+rekono.add_command(users)
+rekono.add_command(vulnerabilities)
+rekono.add_command(wordlists)
+
+
+if __name__ == '__main__':
+    rekono()
diff --git a/src/requirements.txt b/src/requirements.txt
new file mode 100644
index 0000000..a335fc4
--- /dev/null
+++ b/src/requirements.txt
@@ -0,0 +1,4 @@
+click==8.1.3
+coverage==7.1.0
+pytest==7.2.1
+requests==2.28.2
\ No newline at end of file
diff --git a/setup.py b/src/setup.py
similarity index 81%
rename from setup.py
rename to src/setup.py
index 24e98a2..b3be773 100644
--- a/setup.py
+++ b/src/setup.py
@@ -6,7 +6,7 @@
 from rekono import VERSION
 
 current_directory = os.path.dirname(os.path.realpath(__file__))
-with open(os.path.join(current_directory, 'README.md'), 'r') as readme:
+with open(os.path.join(current_directory, '..', 'README.md'), 'r') as readme:
     long_description = readme.read()
 
 with open(os.path.join(current_directory, 'requirements.txt'), 'r') as requirements:
@@ -14,9 +14,9 @@
 
 setup(
     name='rekono-cli',
-    packages=find_packages(),
+    packages=find_packages(exclude=['tests']),
     version=VERSION,
-    description='CLI to manage Rekono',
+    description='CLI to make requests to Rekono API',
     long_description=long_description,
     long_description_content_type='text/markdown',
     author='Pablo Santiago López',
@@ -27,7 +27,7 @@
     python_requires='>=3.7',
     entry_points='''
         [console_scripts]
-        rekono=rekono.main:rekono
+        rekono-cli=rekono.main:rekono
     ''',
     project_urls={
         'Rekono': 'https://github.com/pablosnt/rekono',


Test patch:
diff --git a/.github/workflows/unit-testing.yml b/.github/workflows/unit-testing.yml
new file mode 100644
index 0000000..b2c53f4
--- /dev/null
+++ b/.github/workflows/unit-testing.yml
@@ -0,0 +1,35 @@
+name: Unit testing
+on:
+  workflow_dispatch:
+  pull_request:
+    paths:
+      - 'src/rekono/**'
+      - 'src/tests/**'
+      - 'src/requirements.txt'
+
+env:
+  REQUIRED_COVERAGE: 95
+
+jobs:
+  unit-testing:
+    name: Unit testing
+    runs-on: ubuntu-latest
+    defaults:
+      run:
+        working-directory: ./src
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v3
+
+      - uses: actions/setup-python@v4
+        with:
+          python-version: '3.9'
+
+      - name: Install Python dependencies
+        run: python3 -m pip install -r requirements.txt
+
+      - name: Run unit tests
+        run: coverage run -m pytest
+      
+      - name: Check coverage
+        run: coverage report -m --skip-covered --omit="tests/*,rekono/client/*" --fail-under=$REQUIRED_COVERAGE
diff --git a/src/tests/__init__.py b/src/tests/__init__.py
new file mode 100644
index 0000000..52ede6b
--- /dev/null
+++ b/src/tests/__init__.py
@@ -0,0 +1,1 @@
+'''Unit tests for Rekono CLI.'''
diff --git a/src/tests/framework.py b/src/tests/framework.py
new file mode 100644
index 0000000..716b96e
--- /dev/null
+++ b/src/tests/framework.py
@@ -0,0 +1,108 @@
+'''Framework for unit testing of Rekono CLI.'''
+
+import json
+import os
+from typing import Any, Dict, List, Optional, Union
+from unittest import TestCase, mock
+
+import click
+from click.testing import CliRunner
+
+from rekono.main import rekono
+from tests.mock import RekonoMock
+
+
+class RekonoCommandTest(TestCase):
+    '''Framework for unit testing of Rekono CLI.'''
+
+    testing_filepath = 'test_json_export.json'                                  # Temporal file used for testing
+    unit_tests: List[Dict[str, Any]] = []                                       # List of unit tests to execute
+
+    def _cli(
+        self,
+        arguments: List[str],
+        output: Optional[str] = None,
+        exit_code: int = 0,
+        invalid_url: bool = False,
+        input_values: bool = True
+    ) -> None:
+        '''Test Rekono CLI command.
+
+        Args:
+            arguments (List[str]): Command arguments
+            output (Optional[str], optional): Expected output. Defaults to None.
+            exit_code (int, optional): Expected HTTP status code. Defaults to 0.
+            json_file (Optional[str], optional): JSON file where the output is saved. Defaults to None.
+            invalid_url (bool, optional): Test command with invalid URL option. Defaults to False.
+        '''
+        if not self.unit_tests:
+            return
+        runner = CliRunner()                                                    # Create CLI runner for testing
+        input_value = None
+        prefix = ''
+        if input_values:
+            input_value = 'test\n'                                              # Input value with API token
+            prefix = 'API token: \n'                                            # Prefix for expected output
+            if invalid_url:                                                     # Invalid URL is enabled
+                input_value += f'{RekonoMock.url}\n'                            # Add URL as input value
+                # Add invalid URL message to output
+                prefix += f'{click.style("URL is invalid", fg="red")}\nURL: {RekonoMock.url}\n'
+        result = runner.invoke(rekono, arguments, input=input_value)            # Invoke CLI command
+        terminal_output = prefix + (f'{output}\n' if output else '')            # Expected output
+        self.assertEqual(exit_code, result.exit_code)                           # Check exit code
+        self.assertEqual(terminal_output, result.output)                        # Check terminal output
+        if os.path.isfile(self.testing_filepath):                               # If JSON file exists
+            with open(self.testing_filepath, 'r', encoding='utf-8') as file:    # Open file to check its content
+                self.assertEqual(output, file.read())                           # Check file content
+            os.remove(self.testing_filepath)                                    # Remove temporal testing file
+
+    @mock.patch('rekono.framework.commands.command.Rekono', RekonoMock)
+    def test_rekono_cli(self) -> None:
+        '''Execute configured unit tests.'''
+        for test in self.unit_tests or []:
+            self._cli(
+                test.get('arguments', []),
+                test.get('output'),
+                test.get('exit_code', 0),
+                test.get('invalid_url', False),
+                test.get('input_values', True)
+            )
+
+    @classmethod
+    def _json_body(cls, content: Union[Dict[str, Any], List[Dict[str, Any]]]) -> str:
+        '''Create JSON body from content.
+
+        Args:
+            content (Union[Dict[str, Any], List[Dict[str, Any]]]): Content to include in JSON body.
+
+        Returns:
+            str: JSON body.
+        '''
+        return json.dumps(content, ensure_ascii=True, indent=4)
+
+    @classmethod
+    def _expected_output_with_headers(
+        cls,
+        method: str,
+        status_code: int,
+        response_headers: Dict[str, Any],
+        expected_output: Union[Dict[str, Any], List[Dict[str, Any]]]
+    ) -> str:
+        '''Get expected output with HTTP response headers.
+
+        Args:
+            method (str): HTTP method used in request.
+            status_code (int): Expected status code.
+            response_headers (Dict[str, Any]): Expected HTTP response headers.
+            expected_output (_type_, optional): Expected HTTP response body.
+
+        Returns:
+            str: Expected output with HTTP response headers.
+        '''
+        # Add request and response summary
+        expected_output_with_headers = f'\n{method.upper()} /api/entities/ {status_code}\n'
+        for key, value in response_headers.items():
+            expected_output_with_headers += f'{key}: {value}\n'                 # Add header values
+        expected_output_with_headers += '\n'
+        expected_output_with_headers += cls._json_body(expected_output)         # Add expected body
+        return expected_output_with_headers
diff --git a/src/tests/mock.py b/src/tests/mock.py
new file mode 100644
index 0000000..db848ce
--- /dev/null
+++ b/src/tests/mock.py
@@ -0,0 +1,109 @@
+'''Rekono API client mock.'''
+
+import json
+from typing import Any, Dict, List, Optional, Union
+
+from requests.models import Request, Response
+from requests.structures import CaseInsensitiveDict
+
+
+class RekonoMock:
+    '''Rekono API client mock.'''
+
+    data = {                                                                    # Default body data
+        'id': 1,
+        'name': 'rekono',
+        'description': 'test'
+    }
+    url = 'https://rekono.test'                                                 # Rekono base URL for testing
+    headers = {                                                                 # HTTP headers for testing
+        'Header': 'Value'
+    }
+
+    def __init__(self, *args: Any, **kwargs: Any) -> None:
+        '''Mock constructor for Rekono API client.'''
+
+    def _response_factory(
+        self,
+        method: str,
+        status_code: int,
+        content: Optional[Union[Dict[str, Any], List[Dict[str, Any]]]] = None
+    ) -> Response:
+        '''Create moked responses for Rekono API.
+
+        Args:
+            method (str): HTTP method used in the HTTP request.
+            status_code (int): Response status code.
+            content (Optional[Union[Dict[str, Any], List[Dict[str, Any]]]], optional): Response content. Defaults to None.  # noqa: E501
+
+        Returns:
+            Response: HTTP response.
+        '''
+        response = Response()                                                   # Build HTTP response
+        response.request = Request(method=method, url=self.url + '/api/entities/').prepare()    # Set related request
+        response.status_code = status_code                                      # Set response status code
+        response.headers = CaseInsensitiveDict(self.headers)                    # Set response headers
+        response._content = json.dumps(content, ensure_ascii=True, indent=4).encode() if content else None  # Set body
+        return response
+
+    def get(self, *args: Any, **kwargs: Any) -> Union[Response, List[Response]]:
+        '''Mock GET request to Rekono API.
+
+        Returns:
+            Response: HTTP response.
+        '''
+        if kwargs.get('pagination', False):
+            return self.get_paginated_entities(*args, **kwargs)                 # Return paginated mock value
+        return self._response_factory('GET', 200, self.data)                    # Return standard response
+
+    def get_multiple_entities(self, *args: Any, **kwargs: Any) -> Response:
+        '''Mock GET request to Rekono API with multiple items.
+
+        Returns:
+            Response: HTTP response.
+        '''
+        return self._response_factory('GET', 200, [self.data, self.data, self.data])
+
+    def get_paginated_entities(self, *args: Any, **kwargs: Any) -> List[Response]:
+        '''Mock GET request to Rekono API with pagination.
+
+        Returns:
+            List[Response]: List of HTTP responses.
+        '''
+        return [
+            self._response_factory('GET', 200, self.data),
+            self._response_factory('GET', 200, self.data),
+            self._response_factory('GET', 200, self.data)
+        ]
+
+    def post(self, *args: Any, **kwargs: Any) -> Response:
+        '''Mock POST request to Rekono API.
+
+        Returns:
+            Response: HTTP response.
+        '''
+        return self._response_factory('POST', 201, self.data)
+
+    def post_empty_response(self, *args: Any, **kwargs: Any) -> Response:
+        '''Mock POST request to Rekono API with empty response body.
+
+        Returns:
+            Response: HTTP response.
+        '''
+        return self._response_factory('POST', 201)
+
+    def put(self, *args: Any, **kwargs: Any) -> Response:
+        '''Mock PUT request to Rekono API.
+
+        Returns:
+            Response: HTTP response.
+        '''
+        return self._response_factory('PUT', 200, self.data)
+
+    def delete(self, *args: Any, **kwargs: Any) -> Response:
+        '''Mock DELETE request to Rekono API.
+
+        Returns:
+            Response: HTTP response.
+        '''
+        return self._response_factory('DELETE', 204)
diff --git a/src/tests/test_api.py b/src/tests/test_api.py
new file mode 100644
index 0000000..71623a6
--- /dev/null
+++ b/src/tests/test_api.py
@@ -0,0 +1,84 @@
+'''Test "api" CLI command.'''
+
+import json
+from unittest import mock
+
+import click
+
+from tests.framework import RekonoCommandTest
+from tests.mock import RekonoMock
+
+
+class ApiTest(RekonoCommandTest):
+    '''Test "api" CLI command.'''
+
+    unit_tests = [                                                              # List of unit tests to execute
+        {
+            'arguments': ['api', 'get', '-u', RekonoMock.url, '-p', 'key1=value1', '--no-verify', 'entities/1'],
+            'output': RekonoCommandTest._json_body(RekonoMock.data)
+        },
+        {
+            'arguments': ['api', 'get', '--all-pages', '--no-verify', 'entities'],
+            'output': RekonoCommandTest._json_body([RekonoMock.data, RekonoMock.data, RekonoMock.data])
+        },
+        {
+            'arguments': ['api', 'get', '-u', 'invalidurl', 'entities/1'],
+            'output': RekonoCommandTest._json_body(RekonoMock.data),
+            'invalid_url': True
+        },
+        {
+            'arguments': ['api', 'get', '--show-headers', 'entities/1'],
+            'output': RekonoCommandTest._expected_output_with_headers('GET', 200, RekonoMock.headers, RekonoMock.data)
+        },
+        {
+            'arguments': ['api', 'get', '--status-code', 'entities/1'],
+            'output': '200'
+        },
+        {
+            'arguments': ['api', 'get', '--quiet', 'entities/1']
+        },
+        {
+            'arguments': ['api', 'get', '--json', RekonoCommandTest.testing_filepath, 'entities/1'],
+            'output': RekonoCommandTest._json_body(RekonoMock.data)
+        },
+        {
+            'arguments': [
+                'api', 'post', '-b', json.dumps(RekonoMock.data), '-h', 'key1=value1', '--no-verify', 'entities'
+            ],
+            'output': RekonoCommandTest._json_body(RekonoMock.data)
+        },
+        {
+            'arguments': ['api', 'post', '-b', 'invalid JSON value', 'entities'],
+            'output': click.style('Invalid JSON format for body value', fg='red'),
+            'exit_code': 1
+        },
+        {
+            'arguments': [
+                'api', 'put', '-b', json.dumps(RekonoMock.data), '-h', 'key1=value1', '--no-verify', 'entities/1'
+            ],
+            'output': RekonoCommandTest._json_body(RekonoMock.data)
+        },
+        {
+            'arguments': ['api', 'delete', '-h', 'key1=value1', '-h', 'key2=value2', '--no-verify', 'projects/1'],
+            'output': RekonoCommandTest._json_body([])
+        },
+        {
+            'arguments': ['api', 'notfound'],
+            'output': (
+                'Usage: rekono api [OPTIONS] COMMAND [ARGS]...\n'
+                'Try \'rekono api --help\' for help.\n\n'
+                'Error: No such command \'notfound\'.'
+            ),
+            'exit_code': 2,
+            'input_values': False
+        }
+    ]
+
+    @mock.patch('rekono.framework.commands.command.Rekono.get', RekonoMock.get_multiple_entities)
+    @mock.patch('rekono.framework.commands.command.Rekono', RekonoMock)
+    def test_get_multiple_entities(self) -> None:
+        '''Test "api get" command with multiple entities as response.'''
+        self._cli(
+            ['api', 'get', '-h', 'key1=value1', '-h', 'key2=value2', '--no-verify', 'entities'],
+            self._json_body([RekonoMock.data, RekonoMock.data, RekonoMock.data])
+        )
diff --git a/src/tests/test_configurations.py b/src/tests/test_configurations.py
new file mode 100644
index 0000000..590f24c
--- /dev/null
+++ b/src/tests/test_configurations.py
@@ -0,0 +1,19 @@
+'''Test "configurations" CLI command.'''
+
+from tests.framework import RekonoCommandTest
+from tests.mock import RekonoMock
+
+
+class ConfigurationsTest(RekonoCommandTest):
+    '''Test "configurations" CLI command.'''
+
+    unit_tests = [                                                              # List of unit tests to execute
+        {
+            'arguments': ['configurations', 'get', '1'],
+            'output': RekonoCommandTest._json_body(RekonoMock.data)
+        },
+        {
+            'arguments': ['configurations', 'get'],
+            'output': RekonoCommandTest._json_body([RekonoMock.data, RekonoMock.data, RekonoMock.data])
+        }
+    ]
diff --git a/src/tests/test_executions.py b/src/tests/test_executions.py
new file mode 100644
index 0000000..5d0e6f3
--- /dev/null
+++ b/src/tests/test_executions.py
@@ -0,0 +1,19 @@
+'''Test "executions" CLI command.'''
+
+from tests.framework import RekonoCommandTest
+from tests.mock import RekonoMock
+
+
+class ExecutionsTest(RekonoCommandTest):
+    '''Test "executions" CLI command.'''
+
+    unit_tests = [                                                              # List of unit tests to execute
+        {
+            'arguments': ['executions', 'get', '1'],
+            'output': RekonoCommandTest._json_body(RekonoMock.data)
+        },
+        {
+            'arguments': ['executions', 'get'],
+            'output': RekonoCommandTest._json_body([RekonoMock.data, RekonoMock.data, RekonoMock.data])
+        }
+    ]
diff --git a/src/tests/test_findings.py b/src/tests/test_findings.py
new file mode 100644
index 0000000..07ae220
--- /dev/null
+++ b/src/tests/test_findings.py
@@ -0,0 +1,71 @@
+'''Test findings CLI command.'''
+
+from unittest import mock
+
+from tests.framework import RekonoCommandTest
+from tests.mock import RekonoMock
+
+
+class FindingsTest(RekonoCommandTest):
+    '''Test findings CLI command.'''
+
+    @mock.patch('rekono.framework.commands.command.Rekono', RekonoMock)
+    def test_get_one_finding(self) -> None:
+        '''Test to get one finding by ID.'''
+        self._cli([self.__class__.__name__.lower().replace('test', ''), 'get', '1'], self._json_body(RekonoMock.data))
+
+    @mock.patch('rekono.framework.commands.command.Rekono', RekonoMock)
+    def test_get_multiple_findings(self) -> None:
+        '''Test to get multiple findings.'''
+        self._cli(
+            [self.__class__.__name__.lower().replace('test', ''), 'get'],
+            self._json_body([RekonoMock.data, RekonoMock.data, RekonoMock.data])
+        )
+
+    @mock.patch('rekono.framework.commands.command.Rekono', RekonoMock)
+    def test_disable_finding(self) -> None:
+        '''Test to disable one finding.'''
+        self._cli([self.__class__.__name__.lower().replace('test', ''), 'disable', '1'], self._json_body([]))
+
+    @mock.patch('rekono.framework.commands.command.Rekono.post', RekonoMock.post_empty_response)
+    @mock.patch('rekono.framework.commands.command.Rekono', RekonoMock)
+    def test_enable_finding(self) -> None:
+        '''Test to enable one finding.'''
+        self._cli([self.__class__.__name__.lower().replace('test', ''), 'enable', '1'], self._json_body([]))
+
+
+class CredentialsTest(FindingsTest):
+    '''Test "credentials" CLI command.'''
+
+
+class ExploitsTest(FindingsTest):
+    '''Test "exploits" CLI command.'''
+
+
+class HostsTest(FindingsTest):
+    '''Test "hosts" CLI command.'''
+
+
+class PortsTest(FindingsTest):
+    '''Test "ports" CLI command.'''
+
+
+class PathsTest(FindingsTest):
+    '''Test "paths" CLI command.'''
+
+
+class TechnologiesTest(FindingsTest):
+    '''Test "technologoies" CLI command.'''
+
+
+class VulnerabilitiesTest(FindingsTest):
+    '''Test "vulnerabilities" CLI command.'''
+
+
+class OSINTTest(FindingsTest):
+    '''Test "osint" CLI command.'''
+
+    @mock.patch('rekono.framework.commands.command.Rekono', RekonoMock)
+    def test_target_creation(self) -> None:
+        '''Test to create one target from OSINT data.'''
+        self._cli(['osint', 'target', '1'], self._json_body(RekonoMock.data))
diff --git a/src/tests/test_profile.py b/src/tests/test_profile.py
new file mode 100644
index 0000000..32329df
--- /dev/null
+++ b/src/tests/test_profile.py
@@ -0,0 +1,19 @@
+'''Test "profile" CLI command.'''
+
+from tests.framework import RekonoCommandTest
+from tests.mock import RekonoMock
+
+
+class ProfileTest(RekonoCommandTest):
+    '''Test "profile" CLI command.'''
+
+    unit_tests = [                                                              # List of unit tests to execute
+        {
+            'arguments': ['profile', 'get'],
+            'output': RekonoCommandTest._json_body(RekonoMock.data)
+        },
+        {
+            'arguments': ['profile', 'telegram', '--token', 'telegramotp'],
+            'output': RekonoCommandTest._json_body(RekonoMock.data)
+        }
+    ]
diff --git a/src/tests/test_projects.py b/src/tests/test_projects.py
new file mode 100644
index 0000000..e505bde
--- /dev/null
+++ b/src/tests/test_projects.py
@@ -0,0 +1,43 @@
+'''Test "projects" CLI command.'''
+
+from unittest import mock
+
+from tests.framework import RekonoCommandTest
+from tests.mock import RekonoMock
+
+
+class ProjectsTest(RekonoCommandTest):
+    '''Test "projects" CLI command.'''
+
+    unit_tests = [                                                              # List of unit tests to execute
+        {
+            'arguments': ['projects', 'get', '1'],
+            'output': RekonoCommandTest._json_body(RekonoMock.data)
+        },
+        {
+            'arguments': ['projects', 'get'],
+            'output': RekonoCommandTest._json_body([RekonoMock.data, RekonoMock.data, RekonoMock.data])
+        },
+        {
+            'arguments': ['projects', 'create', '--name', 'Process', '--description', 'Test', '--tag', 'test'],
+            'output': RekonoCommandTest._json_body(RekonoMock.data)
+        },
+        {
+            'arguments': ['projects', 'update', '1', '--name', 'Process', '--description', 'Test', '--tag', 'test'],
+            'output': RekonoCommandTest._json_body(RekonoMock.data)
+        },
+        {
+            'arguments': ['projects', 'delete', '1'],
+            'output': RekonoCommandTest._json_body([])
+        },
+        {
+            'arguments': ['projects', 'remove-member', '1', '--user', '1'],
+            'output': RekonoCommandTest._json_body([])
+        }
+    ]
+
+    @mock.patch('rekono.framework.commands.command.Rekono.post', RekonoMock.post_empty_response)
+    @mock.patch('rekono.framework.commands.command.Rekono', RekonoMock)
+    def test_add_member(self) -> None:
+        '''Test to add a new project member.'''
+        self._cli(['projects', 'add-member', '1', '--user', '1'], self._json_body([]))
diff --git a/src/tests/test_settings.py b/src/tests/test_settings.py
new file mode 100644
index 0000000..2c8f5ae
--- /dev/null
+++ b/src/tests/test_settings.py
@@ -0,0 +1,15 @@
+'''Test "settings" CLI command.'''
+
+from tests.framework import RekonoCommandTest
+from tests.mock import RekonoMock
+
+
+class SettingsTest(RekonoCommandTest):
+    '''Test "settings" CLI command.'''
+
+    unit_tests = [                                                              # List of unit tests to execute
+        {
+            'arguments': ['settings', 'get'],
+            'output': RekonoCommandTest._json_body(RekonoMock.data)
+        }
+    ]
diff --git a/src/tests/test_tasks.py b/src/tests/test_tasks.py
new file mode 100644
index 0000000..9e8718b
--- /dev/null
+++ b/src/tests/test_tasks.py
@@ -0,0 +1,58 @@
+'''Test "tasks" CLI command.'''
+
+from tests.framework import RekonoCommandTest
+from tests.mock import RekonoMock
+
+
+class TasksTest(RekonoCommandTest):
+    '''Test "tasks" CLI command.'''
+
+    unit_tests = [                                                              # List of unit tests to execute
+        {
+            'arguments': ['tasks', 'get', '1'],
+            'output': RekonoCommandTest._json_body(RekonoMock.data)
+        },
+        {
+            'arguments': ['tasks', 'get'],
+            'output': RekonoCommandTest._json_body([RekonoMock.data, RekonoMock.data, RekonoMock.data])
+        },
+        {
+            'arguments': ['tasks', 'create', '--target', '1', '--process', '1', '--intensity', 'Normal'],
+            'output': RekonoCommandTest._json_body(RekonoMock.data)
+        },
+        {
+            'arguments': [
+                'tasks', 'create', '--target', '1', '--tool', '1', '--configuration', '1', '--intensity', 'Hard'
+            ],
+            'output': RekonoCommandTest._json_body(RekonoMock.data)
+        },
+        {
+            'arguments': [
+                'tasks', 'create', '--target', '1', '--process', '1', '--intensity', 'Normal',
+                '--scheduled-at', '2000-01-01 01:00:00', '--wordlist', '1'
+            ],
+            'output': RekonoCommandTest._json_body(RekonoMock.data)
+        },
+        {
+            'arguments': [
+                'tasks', 'create', '--target', '1', '--process', '1', '--intensity', 'Normal',
+                '--scheduled-in', '5', '--scheduled-time-unit', 'Hours'
+            ],
+            'output': RekonoCommandTest._json_body(RekonoMock.data)
+        },
+        {
+            'arguments': [
+                'tasks', 'create', '--target', '1', '--process', '1', '--intensity', 'Normal',
+                '--repeat-in', '5', '--repeat-time-unit', 'Days'
+            ],
+            'output': RekonoCommandTest._json_body(RekonoMock.data)
+        },
+        {
+            'arguments': ['tasks', 'cancel', '1'],
+            'output': RekonoCommandTest._json_body([])
+        },
+        {
+            'arguments': ['tasks', 'repeat', '1'],
+            'output': RekonoCommandTest._json_body(RekonoMock.data)
+        }
+    ]
diff --git a/src/tests/test_tools.py b/src/tests/test_tools.py
new file mode 100644
index 0000000..2e9feeb
--- /dev/null
+++ b/src/tests/test_tools.py
@@ -0,0 +1,19 @@
+'''Test "tools" CLI command.'''
+
+from tests.framework import RekonoCommandTest
+from tests.mock import RekonoMock
+
+
+class ToolsTest(RekonoCommandTest):
+    '''Test "tools" CLI command.'''
+
+    unit_tests = [                                                              # List of unit tests to execute
+        {
+            'arguments': ['tools', 'get', '1'],
+            'output': RekonoCommandTest._json_body(RekonoMock.data)
+        },
+        {
+            'arguments': ['tools', 'get'],
+            'output': RekonoCommandTest._json_body([RekonoMock.data, RekonoMock.data, RekonoMock.data])
+        }
+    ]
diff --git a/src/tests/test_users.py b/src/tests/test_users.py
new file mode 100644
index 0000000..980e167
--- /dev/null
+++ b/src/tests/test_users.py
@@ -0,0 +1,31 @@
+'''Test "users" CLI command.'''
+
+from tests.framework import RekonoCommandTest
+from tests.mock import RekonoMock
+
+
+class UsersTest(RekonoCommandTest):
+    '''Test "users" CLI command.'''
+
+    unit_tests = [                                                              # List of unit tests to execute
+        {
+            'arguments': ['users', 'get', '1'],
+            'output': RekonoCommandTest._json_body(RekonoMock.data)
+        },
+        {
+            'arguments': ['users', 'get'],
+            'output': RekonoCommandTest._json_body([RekonoMock.data, RekonoMock.data, RekonoMock.data])
+        },
+        {
+            'arguments': ['users', 'delete', '1'],
+            'output': RekonoCommandTest._json_body([])
+        },
+        {
+            'arguments': ['users', 'role', '1', '--role', 'Reader'],
+            'output': RekonoCommandTest._json_body(RekonoMock.data)
+        },
+        {
+            'arguments': ['users', 'invite', '--email', 'newuser@rekono.com', '--role', 'Reader'],
+            'output': RekonoCommandTest._json_body(RekonoMock.data)
+        }
+    ]
diff --git a/src/tests/test_wordlists.py b/src/tests/test_wordlists.py
new file mode 100644
index 0000000..ae64d6a
--- /dev/null
+++ b/src/tests/test_wordlists.py
@@ -0,0 +1,29 @@
+'''Test "wordlists" CLI command.'''
+
+import os
+
+from tests.framework import RekonoCommandTest
+from tests.mock import RekonoMock
+
+
+class WordlistsTest(RekonoCommandTest):
+    '''Test "wordlists" CLI command.'''
+
+    unit_tests = [                                                              # List of unit tests to execute
+        {
+            'arguments': ['wordlists', 'get', '1'],
+            'output': RekonoCommandTest._json_body(RekonoMock.data)
+        },
+        {
+            'arguments': ['wordlists', 'get'],
+            'output': RekonoCommandTest._json_body([RekonoMock.data, RekonoMock.data, RekonoMock.data])
+        },
+        {
+            'arguments': ['wordlists', 'create', '-n', 'Wordlist', '-t', 'Endpoint', '-f', os.path.realpath(__file__)],
+            'output': RekonoCommandTest._json_body(RekonoMock.data)
+        },
+        {
+            'arguments': ['wordlists', 'delete', '1'],
+            'output': RekonoCommandTest._json_body([])
+        }
+    ]