Repository: palmetto/palm-cli (language: python)

Issue:
Feature/plugin configs
<!-- Thanks for sending a pull request! Please make sure you click the link above to view the contribution guidelines, then fill out the blanks below. -->
## Pull request checklist

Before submitting your PR, please review the following checklist:

- [x] Consider adding a unit test if your PR resolves an issue.
- [x] All new and existing tests pass locally (`palm test`)
- [x] Lint (`palm lint`) has passed locally and any fixes were made for failures
- [x] Docs have been reviewed and added / updated if needed (for bug fixes / features)

### Breaking changes

- [ ] Check if this pull request introduces a breaking change

## What does this implement/fix? Explain your changes.

Adds the concept of configuration to plugins, this has become a necessary tool for plugins like palm-dbt where we want to enable configuration of certain features. See: https://github.com/palmetto/palm-dbt/pull/63

While it is entirely possible to implement configs for each plugin, it makes a lot more sense to provide an optional plugin config mechanism for configuration in palm. This will ensure consistency in implementations, and make the process of using those configs much nicer.

### How does it work?

- If a plugin specifies a config, palm will automatically load that config _at run time_.
- If the config does not exist, palm will call the abstract `set` method specified by the plugin to guide users through configuration. Implementors will need to define the set method for their plugin config.
- Once a config exists, it is made available to commands via `environment.plugin_config('plugin_name')`
- Plugin configs are validated using Pydantic models, these are defined by the plugin implementor. Invalid plugin configs will raise an error.

## Does this close any currently open issues?

Unblocks the changes in https://github.com/palmetto/palm-dbt/pull/63 - though a re-write of that config will be necessary.

## Any other comments?

I still need to write docs around this, here's a quick code example of what a plugin config might look like in the wild:

```python
class DbtConfigModel(BaseModel):
    local_artifacts: str
    prod_artifacts: str


class DbtPluginConfig(BasePluginConfig):
    def __init__(self):
        super().__init__("dbt", DbtConfigModel)

    def set(self) -> dict:
        config = {
            'local_artifacts': click.prompt(
                "Where are your local artifacts?", type=str
            ),
            'prod_artifacts': click.prompt("Where are your prod artifacts?", type=str),
        }
        self.write(config)
        return config


dbtPlugin = BasePlugin(
    name="dbt", command_dir=Path(__file__).parent / "commands", config=DbtPluginConfig()
)
```

## Where has this been tested?

**Operating System:** macOs

**Platform:** python3.9

[WIP] Refactor to support running palm outside of an initialized git project

<!-- Thanks for sending a pull request! Please make sure you click the link above to view the contribution guidelines, then fill out the blanks below. -->
## Pull request checklist

Before submitting your PR, please review the following checklist:

- [ ] Consider adding a unit test if your PR resolves an issue.
- [ ] All new and existing tests pass locally (`palm test`)
- [ ] Lint (`palm lint`) has passed locally and any fixes were made for failures
- [ ] Docs have been reviewed and added / updated if needed (for bug fixes / features)

### Breaking changes

- [ ] Check if this pull request introduces a breaking change

## What does this implement/fix? Explain your changes.

* Use discover_repository to allow us to raise and handle a custom exception in the case we are outside of a git repo
* Change the implementation of branch detection so that we have proper control over when that check is executed
* Update the implementation of palm_config.plugins to be a list with setters
* Implement a bare-bones 'setup' plugin as initial POC for the functionality

## Does this close any currently open issues?
#12 

## Any other comments?
This is a WIP PR, still to be done:

- [ ] implement `new_machine`
- [ ] implement `palm clone`
- [ ] document onboarding 'system' plugin commands
- [ ] add tests around new functionality

## Where has this been tested?

**Operating System:** Mac OS



Discussion / hints:

What is the status of this PR?
> What is the status of this PR?

This was a spike/RFC and thus far I've had no feedback on the approach. I think it is probably something we want to pursue, and I still quite like the direction this was going, but it's definitely a WIP
This work was re-done in #83 - conflicts here were too gnarly and I wanted to re-do some pieces of the implementation

Fix patch:
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index fc60ed6..051c5f4 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1,4 +1,4 @@
 # see https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners for configs
 
 # review PRs
-*    @ethanknox-palmetto @emekdahl-palmetto @murphymoulds-palmetto @mrogers-palmetto @jakeberesford-palmetto @cgaunt-palmetto @omoorepalmetto
+*    @jakeberesford-palmetto @murphymoulds-palmetto @mariahjrogers @evandegler-palmetto @omoorepalmetto
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index 1a14f8a..3953afb 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -2,4 +2,6 @@ blank_issues_enabled: false
 contact_links:
   - name: Palm Core Team
     url: data-analytics-team@palmetto.com
-    about: Please report security vulnerabilities to this email address, not in public issues.
+    about: |
+     Please report security vulnerabilities to this email address,
+     not in public issues.
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 5cfb299..d8920c9 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -44,11 +44,11 @@ jobs:
         # If you wish to specify custom queries, you can do so here or in a config file.
         # By default, queries listed here will override any specified in a config file.
         # Prefix the list here with "+" to use these queries and those in the config file.
-        
+
         # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
         queries: security-extended,security-and-quality
 
-        
+
     # Autobuild attempts to build any compiled languages  (C/C++, C#, or Java).
     # If this step fails, then you should remove it and run the build manually (see below)
     - name: Autobuild
@@ -57,7 +57,7 @@ jobs:
     # ℹ️ Command-line programs to run using the OS shell.
     # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
 
-    #   If the Autobuild fails above, remove it and uncomment the following three lines. 
+    #   If the Autobuild fails above, remove it and uncomment the following three lines.
     #   modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
 
     # - run: |
diff --git a/.github/workflows/no-merge.yaml b/.github/workflows/no-merge.yaml
index 3de4e0e..c13711a 100644
--- a/.github/workflows/no-merge.yaml
+++ b/.github/workflows/no-merge.yaml
@@ -13,4 +13,4 @@ jobs:
         with:
           mode: exactly
           count: 0
-          labels: "no merge"
\ No newline at end of file
+          labels: "no merge"
diff --git a/.github/workflows/pull_request.yaml b/.github/workflows/pull_request.yaml
index 5882ce6..20f5504 100644
--- a/.github/workflows/pull_request.yaml
+++ b/.github/workflows/pull_request.yaml
@@ -12,10 +12,10 @@ jobs:
     strategy:
       matrix:
         python-version:
-          - 3.7
-          - 3.8
-          - 3.9
-          - 3.10.4
+          - "3.7"
+          - "3.8"
+          - "3.9"
+          - "3.10"
     steps:
       - uses: actions/checkout@v2
       - name: Install Python 3
@@ -37,4 +37,4 @@ jobs:
       - uses: psf/black@stable
         with:
           options: '--check --diff --skip-string-normalization --exclude="\.tpl\.py"'
-          version: '22.3.0'
\ No newline at end of file
+          version: '22.3.0'
diff --git a/.github/workflows/pypi-deploy.yaml b/.github/workflows/pypi-deploy.yaml
new file mode 100644
index 0000000..4de944f
--- /dev/null
+++ b/.github/workflows/pypi-deploy.yaml
@@ -0,0 +1,90 @@
+name: Pypi Deploy
+
+on:
+  push:
+    # Sequence of patterns matched against refs/tags
+    tags:
+      - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
+    branches:
+      - main
+
+jobs:
+  pypi:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v3
+        with:
+          fetch-depth: 0
+
+      - name: Get version number
+        id: get_version
+        run: |
+          echo ::set-output name=VERSION::$(python3 setup.py --version)
+
+      - name: Check version matches tag
+        run: |
+          if [ "${{ steps.get_version.outputs.VERSION }}" != "${GITHUB_REF#refs/tags/v}" ]; then
+            echo "Version number in setup.py does not match tag"
+            exit 1
+          fi
+
+      - name: conventional changelog action
+        id: changelog
+        # https://github.com/TriPSs/conventional-changelog-action
+        uses: TriPSs/conventional-changelog-action@latest
+        with:
+          github-token: "${{ secrets.GITHUB_TOKEN }}"
+          skip-git-pull: "true"
+          skip-version-file: "true"
+          output-file: "false"
+
+      - name: Build
+        if: check_version
+        run: |
+          python3 -m pip install --upgrade pip && \
+          pip install -r dev-requirements.txt && \
+          python3 setup.py sdist bdist_wheel
+
+      - name: Create Release
+        id: create_release
+        if: build
+        uses: actions/create-release@v1
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        with:
+          tag_name: ${{ github.ref }}
+          name: Release ${{ github.ref }}
+          body: ${{steps.changelog.outputs.clean_changelog}}
+          draft: false
+          prerelease: false
+
+      - name: Upload tar to release
+        id: upload-release-tar
+        if: create_release
+        uses: actions/upload-release-asset@v1
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        with:
+          upload_url: ${{ steps.create_release.outputs.upload_url }}
+          asset_path: ./dist/palm-${{steps.get_version.outputs.VERSION}}.tar.gz
+          asset_name: palm--${{steps.get_version.outputs.VERSION}}.tar.gz
+          asset_content_type: application/tar+gzip
+
+      - name: Upload wheel to release
+        id: upload-release-wheel
+        if: create_release
+        uses: actions/upload-release-asset@v1
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        with:
+          upload_url: ${{ steps.create_release.outputs.upload_url }}
+          asset_path: ./dist/palm-${{steps.get_version.outputs.VERSION}}-py3-none-any.whl
+          asset_name: palm--${{steps.get_version.outputs.VERSION}}-py3-none-any.whl
+          asset_content_type: application/wheel
+
+      - name: Publish package
+        if: upload-release-tar
+        uses: pypa/gh-action-pypi-publish@release/v1
+        with:
+          password: ${{ secrets.PYPI_API_TOKEN }}
\ No newline at end of file
diff --git a/.palm/cmd_docs.py b/.palm/cmd_docs.py
index 2c12f37..828f7af 100644
--- a/.palm/cmd_docs.py
+++ b/.palm/cmd_docs.py
@@ -1,18 +1,19 @@
-import click
-from pathlib import Path
 import webbrowser
+from pathlib import Path
+
+import click
 
 CODE_DOCS_URI = "http://localhost:8989"
 
 
-@click.command('docs')
-@click.pass_context
-def cli(ctx):
+@click.command("docs")
+@click.pass_obj
+def cli(environment):
     """Generates internal readthedocs for palm and serves them"""
 
     click.echo(f"Launching palm-cli readthedocs at {CODE_DOCS_URI}...")
-    exit_code, out, err = ctx.obj.run_on_host(
-        'docker-compose run -d --rm --service-ports palm_docs'
+    exit_code, out, err = environment.run_on_host(
+        "docker-compose run -d --rm --service-ports palm_docs"
     )
     if exit_code > 0:
         if "port is already allocated" in err:
diff --git a/.palm/config.yaml b/.palm/config.yaml
index 0620857..2953990 100644
--- a/.palm/config.yaml
+++ b/.palm/config.yaml
@@ -1,1 +1,1 @@
-image_name: palm
\ No newline at end of file
+image_name: palm
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..3dedfe7
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,20 @@
+minimum_pre_commit_version: "2.12"
+repos:
+  - repo: https://github.com/adrienverge/yamllint.git
+    rev: v1.17.0
+    hooks:
+    - id: yamllint
+      args: [-d relaxed]
+      verbose: true
+      exclude: .github/workflows/*
+  - repo: https://github.com/timothycrosley/isort
+    rev: 5.10.1
+    hooks:
+    - id: isort
+      additional_dependencies: [toml]
+      exclude: \.tpl\.py
+  - repo: https://github.com/psf/black
+    rev: 22.3.0
+    hooks:
+    - id: black
+      exclude: \.tpl\.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d212e5f..6993e92 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,44 +1,73 @@
-# Palm CLI Changelog
+# Changelog
 
-## 2.4.1
+All notable changes to this project will be documented in this file.
 
-> 2022-06-08
+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).
 
-### Improvements
+## Unreleased
+
+## [2.5.0]
+
+### Added
+
+- **Global palm** allows palm to run outside of initialized git projects.
+  This feature opens up many new possibilities for automation with palm,
+  including project scaffolding with cookiecutter, machine setup, and
+  global plugins that can do just about anything!
+- **Project scaffolding** with cookiecutter. Palm now includes cookiecutter
+  for scaffolding new projects. Why would you use palm for this, instead of
+  using cookiecutter directly? Because with palm, you're able to configure a set
+  of default cookiecutter templates, which helps keep projects consistent.
+  Every developer, on every team, should use the same set of templates!
+- **Plugin Configs** enable plugins to define a configuration object which is
+  persisted in the .palm/config.yml. Configurations are defined by the plugin
+  and made available to each command via the palm environment. The next release
+  of palm-dbt will include a working example of Plugin Configuration!
+- **Pre-commit** Added pre-commit hooks to palm to improve contributor
+  experience.
+- **Continuous deployment** via github actions workflow.
+- **Additional Dependencies** palm-cli now depends on cookiecutter >= 2.0 for
+  project scaffolding via `palm new` and pydantic >= 1.9 for Plugin Configs.
+
+### Changed
+
+- **linting & cleanup** as a result of adding more tools in out pre-commit
+  hooks, we also cleaned up some poorly formatted files in the project!.
+- **Use pass_obj** all core commands now use `@click.pass_obj` to provide the
+  palm environment to the command, rather than the more convoluted
+  `@click.pass_context()`
+
+## [2.4.1] - 2022-06-08
+
+### Changed
 
 - **Improved layout for palm --help**: Palm help now outputs the command list in
 groups based on the plugin the command originates from! This is particularly helpful
 when working in a project that defines uses plugins, defines it's own commands,
 and you have global plugins installed!
 
-### Bug fixes
+### Fixed
 
 - Fixed a path issue for windows users when using code_gen or palm init
 - Fixes a potential security vulnerability when upgrading plugins via 
 `palm plugin update`
 - Fixes a number of other small issues detected by CodeQL static anlysis
 
-## 2.4.0
+## [2.4.0] - 2022-04-10
 
-> 2022-04-10
-
-### Features
+### Added
 
 - **Run docker commands without /bin/bash** - Added optional arguments to
 environment.run_in_docker allows developers to execute commands in their docker
 containers without prepending them with /bin/bash -c. This is useful & necessary
 for images with a specific entrypoint
-
-### Improvements
-
-- **Added documentation** New documentation for:
+- **New documentation** for:
   - Global palm configuration
   - Shell autocomplete
   - Upgrading palm
 
-## 2.3.0
-
-> 2022-03-28
+## [2.3.0] - 2022-03-28
 
 
 Features:
@@ -51,15 +80,13 @@ Improvements
 - **Startup speed** changed the implementation of checking docker-compose is installed, this fixes intermittent slowdowns on startup
 - **Dependency upgrades**: Click updated to v8.1 and up, Black updated to v22.3.0
 
-## 2.2.0
-
-> 2022-02.24
+## [2.2.0] - 2022-02.24
 
 Python Version Support:
 
 - **Dropped support for python3.6** Python 3.6 is end of life and supporting it started to cause issues with dependent packages. We are dropping support for python 3.6 in palm v2.2.0
 
-### Features
+### Added
 
 - **Global config & Global plugins** Palm now supports installing plugins globally!
 After updating to palm 2.2.0, the first time you run `palm ...` a new global config
@@ -68,7 +95,7 @@ make them available to all of your repositories! This feature was developed to
 support a Palmetto internal `palm-workflow` plugin, which is used to manage our
 dev workflow in Trello.
 
-### Bug Fixes
+### Fixed
 
 - **pygit2 version pin adjusted**: The upper version pin on pygit2 was removed to resolve an error
 installing palm on newer versions of MacOS. Note that libgit2 is still required, 
@@ -78,29 +105,25 @@ and more friendly error messaging.
 a deprecated method in markupsafe, markupsafe removed that method which caused an error when
 installing palm. We upgraded to the latest version of jinja to resolve this issue and get the latest jinja features.
 
-## 2.1.1
-
-> 2022-02-07
+## [2.1.1] - 2022-02-07
 
-### Bug fixes
+### Fixed
 
 - Resolve a template issue when running `palm plugin new` 
 
-### Improvements
+### Changed
 
 - Command templates now use click's @pass_obj decorator and pass the `environment`
 argument, this is a cleaner API for interacting with the Palm.Environment class
 - CI now runs against multiple python versions
 - Black version is pinned for consistency between local & CI runs
 
-## 2.1.0
-
-> 2021-12-01
+## [2.1.0] - 2021-12-01
 
 Our first minor release on v2 includes containerization, support for older Python versions, 
 and added logo and brand assets!
 
-### Features
+### Added
 
 - **Containerize**: NEW command added for containerization of Python projects with `palm containerize`
 - **Shell**: NEW command to shell into the project container and execute arbitrary commands
@@ -111,35 +134,29 @@ to run on your local machine, standardizing the interface around Python's `subpr
 and version agnostic support (well, version agnostic >= 3.6.9).
 - **Logo/Branding**: We have a logo!! It is 90's retro and it is cool. Also added branding guidelines
 
-### Improvements
+### Changed
 
 - **Command availability**: Added `lint` and `test` commands to the `palm` core plugin
 - **Workflow**: New Github Actions workflow to lint contributions to the project on new Pull Request
 - **Backwards compatibility**: Added backwards compatibility support for older Python versions through v3.6.9
 - **Documentation**: Added new docs and examples of `palm` use cases and impact
 
-## 2.0.2
-
-> 2021-11-22
+## [2.0.2] - 2021-11-22
 
 We acquired the name `palm` in pypi! This patch version is just renaming the library
 and updating installation instructions.
 
-## 2.0.1
-
-> 11/10/2021
+## [2.0.1] - 11/10/2021
 
 Updates to configuration and package name due to an unexpected naming conflict in
 pypi
 
-## 2.0.0
-
-> 2021-11-10
+## [2.0.0] - 2021-11-10
 
 This major version marks our first open-source release, and thus the first version
 with a changelog entry!
 
-### Features
+### Added
 
 - **Plugins** commands for Palm CLI are now largely driven by a plugin architecture.
 All commands in Palm are made available by one of several plugins. The core
@@ -158,7 +175,7 @@ includes standard documentation for OSS projects, e.g. Contribution guide, code
 and this changelog!
 - **Test suite** Palm now has a good baseline of test coverage!
 
-### Improvements
+### Changed
 
 - **General cleanup** - Code was reformatted for PEP8 compliance
 - **Naming** - Renamed `run_in_shell` to `run_in_docker`, run_in_shell will be
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 5f98831..f200557 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -2,9 +2,17 @@
 
 First off, thanks for taking the time to contribute! ❤️
 
-All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉
-
-> And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about:
+All types of contributions are encouraged and valued. See the
+[Table of Contents](#table-of-contents) for different ways to help and details
+about how this project handles them. Please make sure to read the relevant
+section before making your contribution. It will make it a lot easier for
+us maintainers and smooth out the experience for all involved. The community
+looks forward to your contributions. 🎉
+
+> And if you like the project, but just don't have time to contribute, that's
+> fine. There are other easy ways to support the project and show your
+> appreciation, which we would also be very happy about:
+>
 > - Star the project
 > - Tweet about it
 > - Refer this project in your project's readme
@@ -21,7 +29,6 @@ All types of contributions are encouraged and valued. See the [Table of Contents
 - [Styleguides](#styleguides)
   - [Commit Messages](#commit-messages)
 
-
 ## Code of Conduct
 
 This project and everyone participating in it is governed by the
@@ -29,95 +36,160 @@ This project and everyone participating in it is governed by the
 By participating, you are expected to uphold this code. Please report unacceptable behavior
 to <data-analytics-team@palmetto.com>.
 
-
 ## I Have a Question
 
-> If you want to ask a question, we assume that you have read the available [Documentation](https://readthedocs.org/projects/palmetto-palm-cli/).
+> If you want to ask a question, we assume that you have read the available
+> [Documentation](https://readthedocs.org/projects/palmetto-palm-cli/).
 
-Before you ask a question, it is best to search for existing [Issues](https://github.com/palmetto/palm-cli/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue. It is also advisable to search the internet for answers first.
+Before you ask a question, it is best to search for existing
+[Issues](https://github.com/palmetto/palm-cli/issues) that might help you. In
+case you have found a suitable issue and still need clarification, you can
+write your question in this issue. It is also advisable to search the internet
+for answers first.
 
-If you then still feel the need to ask a question and need clarification, we recommend the following:
+If you then still feel the need to ask a question and need clarification, we
+recommend the following:
 
 - Open an [Issue](https://github.com/palmetto/palm-cli/issues/new).
 - Provide as much context as you can about what you're running into.
-- Provide project and platform versions (nodejs, npm, etc), depending on what seems relevant.
+- Provide project and platform versions (nodejs, npm, etc), depending on
+  what seems relevant.
 
 We will then take care of the issue as soon as possible.
 
 ## I Want To Contribute
 
-### Legal Notice 
-> When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project license.
+### Legal Notice
+
+> When contributing to this project, you must agree that you have authored
+> 100% of the content, that you have the necessary rights to the content and
+> that the content you contribute may be provided under the project license.
 
 ### Reporting Bugs
 
 #### Before Submitting a Bug Report
 
-A good bug report shouldn't leave others needing to chase you up for more information. Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. Please complete the following steps in advance to help us fix any potential bug as fast as possible.
+A good bug report shouldn't leave others needing to chase you up for more
+information. Therefore, we ask you to investigate carefully, collect
+information and describe the issue in detail in your report. Please complete
+the following steps in advance to help us fix any potential bug as fast as
+possible.
 
 - Make sure that you are using the latest version.
-- Determine if your bug is really a bug and not an error on your side e.g. using incompatible environment components/versions (Make sure that you have read the [documentation](https://readthedocs.org/projects/palmetto-palm-cli/). If you are looking for support, you might want to check [this section](#i-have-a-question)).
-- To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker](https://github.com/palmetto/palm-cli/issues?q=label%3Abug).
-- Also make sure to search the internet (including Stack Overflow) to see if users outside of the GitHub community have discussed the issue.
+- Determine if your bug is really a bug and not an error on your side e.g.
+  using incompatible environment components/versions (Make sure that you have
+  read the [documentation](https://readthedocs.org/projects/palmetto-palm-cli/).
+  If you are looking for support, you might want to check
+  [this section](#i-have-a-question)).
+- To see if other users have experienced (and potentially already solved) the
+  same issue you are having, check if there is not already a bug report
+  existing for your bug or error in the
+  [bug tracker](https://github.com/palmetto/palm-cli/issues?q=label%3Abug).
+- Also make sure to search the internet (including Stack Overflow) to see if
+  users outside of the GitHub community have discussed the issue.
 - Collect information about the bug:
   - Stack trace (Traceback)
   - OS, Platform and Version (Windows, Linux, macOS, x86, ARM)
-  - Version of the interpreter, compiler, SDK, runtime environment, package manager, depending on what seems relevant.
+  - Version of the interpreter, compiler, SDK, runtime environment,
+    package manager, depending on what seems relevant.
   - Possibly your input and the output
-  - Can you reliably reproduce the issue? And can you also reproduce it with older versions?
+  - Can you reliably reproduce the issue? And can you also reproduce it with
+    older versions?
 
 #### How Do I Submit a Good Bug Report?
 
-> You must never report security related issues, vulnerabilities or bugs to the issue tracker, or elsewhere in public. Instead sensitive bugs must be sent by email to <data-analytics-team@palmetto.com>.
+> You must never report security related issues, vulnerabilities or bugs to the
+issue tracker, or elsewhere in public. Instead sensitive bugs must be sent by
+email to <data-analytics-team@palmetto.com>.
 
-We use GitHub issues to track bugs and errors. If you run into an issue with the project:
+We use GitHub issues to track bugs and errors. If you run into an issue with
+the project:
 
-- Open an [Issue](https://github.com/palmetto/palm-cli/issues/new). (Since we can't be sure at this point whether it is a bug or not, we ask you not to talk about a bug yet and not to label the issue.)
+- Open an [Issue](https://github.com/palmetto/palm-cli/issues/new). (Since we
+  can't be sure at this point whether it is a bug or not, we ask you not to
+  talk about a bug yet and not to label the issue.)
 - Explain the behavior you would expect and the actual behavior.
-- Please provide as much context as possible and describe the *reproduction steps* that someone else can follow to recreate the issue on their own. This usually includes your code. For good bug reports you should isolate the problem and create a reduced test case.
+- Please provide as much context as possible and describe the
+  _reproduction steps_ that someone else can follow to recreate the issue on
+  their own. This usually includes your code. For good bug reports you should
+  isolate the problem and create a reduced test case.
 - Provide the information you collected in the previous section.
 
 Once it's filed:
 
 - The project team will label the issue accordingly.
-- A team member will try to reproduce the issue with your provided steps. If there are no reproduction steps or no obvious way to reproduce the issue, the team will ask you for those steps and mark the issue as `needs-repro`. Bugs with the `needs-repro` tag will not be addressed until they are reproduced.
-- If the team is able to reproduce the issue, it will be marked `needs-fix`, as well as possibly other tags (such as `critical`), and the issue will be left to be [implemented by someone](#your-first-code-contribution).
-
-
+- A team member will try to reproduce the issue with your provided steps. If
+  there are no reproduction steps or no obvious way to reproduce the issue, the
+  team will ask you for those steps and mark the issue as `needs-repro`. Bugs
+  with the `needs-repro` tag will not be addressed until they are reproduced.
+- If the team is able to reproduce the issue, it will be marked `needs-fix`, as
+  well as possibly other tags (such as `critical`), and the issue will be left
+  to be [implemented by someone](#your-first-code-contribution).
 
 ### Suggesting Enhancements
 
-This section guides you through submitting an enhancement suggestion for Palm CLI, **including completely new features and minor improvements to existing functionality**. Following these guidelines will help maintainers and the community to understand your suggestion and find related suggestions.
+This section guides you through submitting an enhancement suggestion for
+Palm CLI, **including completely new features and minor improvements to
+existing functionality**. Following these guidelines will help maintainers and
+the community to understand your suggestion and find related suggestions.
 
 #### Before Submitting an Enhancement
 
 - Make sure that you are using the latest version.
-- Read the [documentation](https://readthedocs.org/projects/palmetto-palm-cli/) carefully and find out if the functionality is already covered, maybe by an individual configuration.
-- Perform a [search](https://github.com/palmetto/palm-cli/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one.
-- Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Keep in mind that we want features that will be useful to the majority of our users and not just a small subset. If you're just targeting a minority of users, consider writing an add-on/plugin library.
+- Read the [documentation](https://readthedocs.org/projects/palmetto-palm-cli/)
+  carefully and find out if the functionality is already covered, maybe by an
+  individual configuration.
+- Perform a [search](https://github.com/palmetto/palm-cli/issues) to see if the
+  enhancement has already been suggested. If it has, add a comment to the
+  existing issue instead of opening a new one.
+- Find out whether your idea fits with the scope and aims of the project. It's
+  up to you to make a strong case to convince the project's developers of the
+  merits of this feature. Keep in mind that we want features that will be
+  useful to the majority of our users and not just a small subset. If you're
+  just targeting a minority of users, consider writing an add-on/plugin library.
 
 #### How Do I Submit a Good Enhancement Suggestion?
 
-Enhancement suggestions are tracked as [GitHub issues](https://github.com/palmetto/palm-cli/issues).
+Enhancement suggestions are tracked as
+[GitHub issues](https://github.com/palmetto/palm-cli/issues).
 
 - Use a **clear and descriptive title** for the issue to identify the suggestion.
 - Use the feature request template as a framework for your suggestion.
-- Provide a **step-by-step description of the suggested enhancement** in as many details as possible.
-- **Describe the current behavior** and **explain which behavior you expected to see instead** and why. At this point you can also tell which alternatives do not work for you.
-- You may want to **include screenshots and animated GIFs** which help you demonstrate the steps or point out the part which the suggestion is related to. You can use [this tool](https://www.cockos.com/licecap/) to record GIFs on macOS and Windows, and [this tool](https://github.com/colinkeenan/silentcast) or [this tool](https://github.com/GNOME/byzanz) on Linux.
-- **Explain why this enhancement would be useful** to most Palm CLI users. You may also want to point out the other projects that solved it better and which could serve as inspiration.
-
+- Provide a **step-by-step description of the suggested enhancement** in as many
+  details as possible.
+- **Describe the current behavior** and **explain which behavior you expected
+  to see instead** and why. At this point you can also tell which alternatives
+  do not work for you.
+- You may want to **include screenshots and animated GIFs** which help you
+  demonstrate the steps or point out the part which the suggestion is related
+  to. You can use [this tool](https://www.cockos.com/licecap/) to record GIFs
+  on macOS and Windows, and
+  [this tool](https://github.com/colinkeenan/silentcast)
+  or [this tool](https://github.com/GNOME/byzanz) on Linux.
+- **Explain why this enhancement would be useful** to most Palm CLI users.
+  You may also want to point out the other projects that solved it better and
+  which could serve as inspiration.
 
 ### Your First Code Contribution
 
-- Pick an open issue to work on, assign the issue to yourself, and let us know that you intend to contribute!
+- Pick an open issue to work on, assign the issue to yourself, and let us know
+  that you intend to contribute!
 - Create a fork of the Palm CLI repo.
 - Implement your solution and commit your code following the styleguide below.
 - Create a Pull Request against the `develop` branch of the Palm CLI repo.
-- Respond to comments and make requested changes to your open PR as soon as you can.
+- Respond to comments and make requested changes to your open PR as soon as you
+  can.
 - Congratulations, and thank you for your contribution!
 
 ## Styleguides
+
 ### Commit Messages
 
-Commit messages should follow the [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) style.
+Commit messages should follow the
+[conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) style.
+
+### Code formatting
+
+Code should be formatted using black and isort, pre-commit hooks are configured
+to do this automatically. Please ensure you have installed pre-commit for this
+project before committing changes.
diff --git a/README.md b/README.md
index a1c9764..cee5ed7 100644
--- a/README.md
+++ b/README.md
@@ -1,51 +1,54 @@
-## Palm CLI: The extensible CLI at your fingertips
+# Palm CLI: The extensible CLI at your fingertips
 
-_Palm_ is a universal CLI developed to improve the life and work of data professionals. 
+_Palm_ is a universal CLI developed to improve the life and work of data professionals.
 
 [Palm CLI documentation](https://palm-cli.readthedocs.io/en/latest/)
 
-### Installing Palm
+## Installing Palm
 
-```
+```bash
 pip install palm
 ```
 
-*note for mac users*: if you get this warning:
-```
+**note for mac users**: if you get this warning:
+
+```bash
   WARNING: The script palm is installed in '/Users/yourname/Library/Python/3.8/bin' which is not on PATH.
   Consider adding this directory to PATH or, if you prefer to suppress this warning, use --no-warn-script-location.
-  ```
-you will need to add `'/Users/yourname/Library/Python/3.8/bin'` to your path for `palm` to work. 
+```
+
+you will need to add `'/Users/yourname/Library/Python/3.8/bin'` to your path for `palm` to work.
 
 you can do that with this command:
-```
+
+```bash
 echo "\nexport PATH=$PATH:/Users/yourname/Library/Python/3.8/bin\n" >> ~/.zprofile
 ```
 
-#### Upgrading palm
+### Upgrading palm
 
 To upgrade palm to the latest version, use `pip install palm -U`
 
-#### Requirements
+### Requirements
 
 1. You will need [Docker](https://docs.docker.com/get-docker/)
    You can check to see if you already have it with `docker --version`
 
-2. You will need [Python3](https://www.python.org/downloads/) 
+2. You will need [Python3](https://www.python.org/downloads/)
    You can check to see if you already have it with `python3 --version`
 
-#### Developing palm
+### Developing palm
 
-**If you have the repo on your computer** 
-This will install whatever version you have checked out at the moment.
+**If you have the repo on your computer** This will install whatever version you have checked out at the moment.
 
-```
+```bash
 cd path/to/this/repo &&
 python3 -m pip uninstall palm || true # for new installs
-python3 -m pip install . 
+python3 -m pip install .
 ```
 
 You can verify the install with
-```
+
+```bash
 palm --help
 ```
diff --git a/docker-compose.yml b/docker-compose.yml
index 553ef18..62e156e 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,17 +1,17 @@
 version: "3.5"
 services:
-    palm:
-        image: palm_development
-        build:
-            context: .
-            dockerfile: Dockerfile
-        volumes:
-            - /var/run/docker.sock:/var/run/docker.sock
-            - ./:/app
-    palm_docs:
-        image: sphinxdoc/sphinx
-        volumes:
-        - ./docs:/docs
-        entrypoint: ./entrypoint.sh
-        ports:
-        - "8989:8000"
+  palm:
+    image: palm_development
+    build:
+      context: .
+      dockerfile: Dockerfile
+    volumes:
+      - /var/run/docker.sock:/var/run/docker.sock
+      - ./:/app
+  palm_docs:
+    image: sphinxdoc/sphinx
+    volumes:
+      - ./docs:/docs
+    entrypoint: ./entrypoint.sh
+    ports:
+      - "8989:8000"
diff --git a/docs/source/branding/index.rst b/docs/source/branding/index.rst
index c65ba41..f183469 100644
--- a/docs/source/branding/index.rst
+++ b/docs/source/branding/index.rst
@@ -11,27 +11,47 @@ Brand Assets
 ============
 .. toctree::
    :titlesonly:
-   
+
    logos
 
-Palm is empowering and inclusive by design, enabling Developers and Engineers 
-With diverse skill sets to create and evolve software without homogeneous programming know-how. With palm, contributors can focus on being experts in a way that generates the most value, and skip “sweating the small stuff” of rote interface memorization.
-We are proud of what palm is and what it represents. These branding guidelines and associated assets are part of that representation and are intended to enhance and promote the public integrity of the project. 
+Palm is empowering and inclusive by design, enabling Developers and Engineers
+With diverse skill sets to create and evolve software without homogeneous
+programming know-how. With palm, contributors can focus on being experts in a
+way that generates the most value, and skip “sweating the small stuff” of rote
+interface memorization.
+We are proud of what palm is and what it represents. These branding guidelines
+and associated assets are part of that representation and are intended to
+enhance and promote the public integrity of the project.
 
-In plain words, we are proud of palm, and we want these branding guidelines and assets to be used freely in an open and positive way. 
+In plain words, we are proud of palm, and we want these branding guidelines
+and assets to be used freely in an open and positive way.
 
 Basic Use Guidelines
 ================
 
-**1.1 Capitalization**: 
-    Palm is an `improper (or common) <https://en.wiktionary.org/wiki/common_noun#English>`_ noun, and should follow appropriate grammar rules. Most notably, palm is spelled with a capital “P” (Palm) as the first word in a sentence, and with a lowercase “p” (palm) otherwise.
+**1.1 Capitalization**:
+    Palm is an
+    `improper (or common) <https://en.wiktionary.org/wiki/common_noun#English>`_
+    noun, and should follow appropriate grammar rules. Most notably, palm is
+    spelled with a capital “P” (Palm) as the first word in a sentence,
+    and with a lowercase “p” (palm) otherwise.
 
 Logo Usage
 =========
 
-Palm :doc:`logos` can be used anywhere palm software is incorporated or used as part of a project or workflow, referenced, compared or combined. Basically, if you are using or discussing palm software, we encourage you to use palm logos as you see fit. Palm logos cannot be used for projects or as part of media unrelated to palm - for example, please do not open a nightclub named “Palm” and use our logo. If you are unsure if your use violates these guidelines, drop us a line at data-analytics-team@palmetto.com and we will be happy to advise. 
+Palm :doc:`logos` can be used anywhere palm software is incorporated or used
+as part of a project or workflow, referenced, compared or combined. Basically,
+if you are using or discussing palm software, we encourage you to use palm
+logos as you see fit. Palm logos cannot be used for projects or as part of
+media unrelated to palm - for example, please do not open a nightclub named
+“Palm” and use our logo. If you are unsure if your use violates these
+guidelines, drop us a line at data-analytics-team@palmetto.com and we will be
+happy to advise.
 
 Swag
 ====
 
-We are excited that you are excited about palm! so feel free to create t-shirts, hats, parade floats etc with the palm logo for personal or team use. If you intend to sell palm-branded merchandise for profit, please drop us a line at data-analytics-team@palmetto.com so we can discuss. 
\ No newline at end of file
+We are excited that you are excited about palm! so feel free to create
+t-shirts, hats, parade floats etc with the palm logo for personal or team use.
+If you intend to sell palm-branded merchandise for profit, please drop us a
+line at data-analytics-team@palmetto.com so we can discuss.
\ No newline at end of file
diff --git a/docs/source/code-generation.rst b/docs/source/code-generation.rst
index 6b2b950..e7a26fd 100644
--- a/docs/source/code-generation.rst
+++ b/docs/source/code-generation.rst
@@ -2,11 +2,11 @@
 Code generation
 ===============
 
-Palm includes a set of code generation commands that allow you to generate code 
-for your project, automating repetitive boiler plating tasks, and making your 
+Palm includes a set of code generation commands that allow you to generate code
+for your project, automating repetitive boiler plating tasks, and making your
 codebase more consistent.
 
-Code generation in palm is powered by `Jinja2 <https://jinja2docs.readthedocs.io/en/stable/>`_ 
+Code generation in palm is powered by `Jinja2 <https://jinja2docs.readthedocs.io/en/stable/>`_
 and `PyYAML <https://pyyaml.org/>`_.
 
 Basics
@@ -16,16 +16,16 @@ To use Palm code generation you will need:
 
 1. A directory of templates. We recommended you make a subdirectory within your
    project's .palm directory.
-2. A YAML configuration file called ``template-config.yaml`` - this is a 
+2. A YAML configuration file called ``template-config.yaml`` - this is a
    configuration file that describes how to generate code.
-3. A palm command which is decorated with the ``@click.pass_context`` decorator and
-   calls ``ctx.obj.generate(template_path, output_path, replacements)``.
+3. A palm command which is decorated with the ``@click.pass_obj`` decorator and
+   calls ``environment.generate(template_path, output_path, replacements)``.
 
 For full documentation of the generator see ``palm/code_generator.py`` in the Palm
-CLI source code
+CLI source code.
 
 Template config
-===============
+---------------
 
 The ``template-config.yaml`` file is a fundamental piece of code generation with palm.
 It describes the directory structure we want to create, and where each of our templates
@@ -42,7 +42,7 @@ in your config file.
 Example template config
 
 .. code:: yaml
-   
+
    directories:
      - "{{model_name}}"
    files:
@@ -53,10 +53,39 @@ The ``replacements`` dict allows this template-config to be used to create a dir
 containing an appropriately named sql file and a yaml file.
 
 Gotchas
-=======
+-------
 
 Generating code is awesome, but there are gotchas to be aware of.
 
 1. Generating code that contains jinja is a pain, all jinja expressions must be
-   provided as replacements in the template, to prevent jinja from trying to 
+   provided as replacements in the template, to prevent jinja from trying to
    evaluate them during code generation.
+
+New Projects with Cookiecutter
+=============================
+
+Palm includes a `palm new` command which uses `cookiecutter <https://cookiecutter.readthedocs.io/en/1.7.2/>`_
+to generate new projects. This is a great way to get started on a new project, and
+is the recommended way to start a new project.
+
+Default cookiecutter templates
+------------------------------
+
+Palm allows you to configure a set of default cookiecutter templates, which can be
+used to generate new projects. To configure a default cookiecutter template, add
+a ``default_cookiecutters`` dictionary to your global palm config file
+(``~/.pam/config.yaml``). The key is a shorthand name for the template, and the value
+is the cookiecutter template url. For example:
+
+.. code:: yaml
+
+   default_cookiecutters:
+     dbt: 'https://github.com/datacoves/cookiecutter-dbt'
+
+You can then use ``palm new -p dbt`` to generate a new dbt project using the
+cookiecutter template.
+
+**Recommendation**: Sharing a global set of default cookiecutter templates is a great
+way to standardize your organization's approach to new projects. We recommend you
+share a set of default cookiecutter templates across your organization, and
+document them in your organization's documentation system.
diff --git a/docs/source/commands.rst b/docs/source/commands.rst
index 2a6bcce..aaac1a0 100644
--- a/docs/source/commands.rst
+++ b/docs/source/commands.rst
@@ -2,11 +2,11 @@
 Commands
 ========
 
-Commands are python scripts that are executed by Palm CLI. They allow you to run complex 
+Commands are python scripts that are executed by Palm CLI. They allow you to run complex
 tasks in a repeatable way, with a simple interface, and ensure everyone on your
 team is working with the same tools.
 
-Palm commands import the `click <https://click.palletsprojects.com/en/8.0.x/>`_ 
+Palm commands import the `click <https://click.palletsprojects.com/en/8.0.x/>`_
 CLI library, which is a core dependency of palm. Familiarity with the click library
 is recommended for developing your own commands, many examples can be found in the
 palm-cli repository.
@@ -29,9 +29,9 @@ based on the order of the plugins in the ``.palm/config.yaml`` file, and the nam
 of project plugins.
 
 For example, if the ``palm-dbt`` plugin defined a command named ``build`` it would
-override the core ``palm build`` command. This is because the commands from the 
-``palm-dbt`` plugin are added after the core commands. If you installed a second 
-(ficticious) plugin called ``palm-builder`` which also defined a ``build`` command, 
+override the core ``palm build`` command. This is because the commands from the
+``palm-dbt`` plugin are added after the core commands. If you installed a second
+(ficticious) plugin called ``palm-builder`` which also defined a ``build`` command,
 the order of overriding would be determined by the order of plugins in your project's
 ``.palm/config.yaml`` file. Finally, if you define a ``build`` command in your project,
 as a repo command, it would override all other definitions of the ``build`` command.
@@ -57,8 +57,8 @@ if you have multiple commands relating to a single tool, you can group them toge
 within a command group. This allows you to easily see all the commands for a tool
 when you run ``palm <tool name> --help``.
 
-Command groups are a construct provided by click - for more information, see the 
-`click documentation on commands and groups <https://click.palletsprojects.com/en/8.0.x/commands/>`_ 
+Command groups are a construct provided by click - for more information, see the
+`click documentation on commands and groups <https://click.palletsprojects.com/en/8.0.x/commands/>`_
 
 Writing your own commands
 =========================
@@ -67,9 +67,9 @@ To simplify the process of writing your own commands and command groups,
 Palm ships with some scaffolding commands. These commands will generate boilerplate
 code for you, allowing you to focus on writing your command's functionality.
 
-- To scaffold a single command you can run 
+- To scaffold a single command you can run
   ``palm scaffold command --name <command-name>``
-- To scaffold a command group you can run 
+- To scaffold a command group you can run
   ``palm scaffold group --group <group-name> --command <command-name>``
 
 Once you have scaffolded your command or command group. You can edit the generated
@@ -88,8 +88,8 @@ Command Syntax
 
 - The ``cli`` function should be decorated with either the ``@click.command``
   decorator or the ``@click.group`` decorator.
-- The ``cli`` function can optionally be decorated with ``@click.pass_context`` and
-  accept a ``ctx`` argument, which is a click context. The ctx.obj is a useful 
+- The ``cli`` function can optionally be decorated with ``@click.pass_obj`` and
+  accept an ``environment`` argument, which is a click context object. The ``environment`` is a useful
   Environment provided by palm that enables you to perform complex operations, like
   running in docker containers, generating code, etc.
 - For commands within a command group, each command _must_ be decorated with the
@@ -102,40 +102,40 @@ Common patterns and important notes
 
 **Run in docker**:
 
-The global ``run_in_docker`` function is used to execute a command in the docker 
+The global ``run_in_docker`` function is used to execute a command in the docker
 container for the current project. This is used in many palm commands. This function
-is provided via the palm context. If you want to use ``run_in_docker`` in your 
-own command, ensure you use the ``@click.pass_context`` decorator for your command, 
-then use ``ctx.obj.run_in_docker(command)``.
+is provided via the palm context. If you want to use ``run_in_docker`` in your
+own command, ensure you use the ``@click.pass_obj`` decorator for your command,
+then use ``environment.run_in_docker(command)``.
 
 **Run on Host**:
 
 Palm provides a simple interface for running shell commands directly on your machine via
-the context (similarly to how ``run_in_docker`` is accessed, via ``ctx.obj``). We highly
+the context (similarly to how ``run_in_docker`` is accessed, via ``environment``). We highly
 recommend using ``run_on_host`` over rolling your own subprocess commands.
 
-.. warning:: 
+.. warning::
 
   **Why Not Use subprocess?**
 
-  The prime directive of palm is to give all your developers an identical interface and 
+  The prime directive of palm is to give all your developers an identical interface and
   experience, regardless of environment. Different versions of python running on different
   operating systems can behave differently when calling ``subprocess``; palm normalizes this
-  behavior in ```ctx.obj.run_on_host``. 
+  behavior in ```environment.run_on_host``.
 
 **Importing code**:
 
-When writing "repo" commands in your project, you will not be able to use 
-conventional relative imports in your commands, as the command is executed in 
+When writing "repo" commands in your project, you will not be able to use
+conventional relative imports in your commands, as the command is executed in
 the context of palm. If you need to share logic between commands, or import code
-from your project, you must do this with the ``ctx.obj.import_module`` function. 
+from your project, you must do this with the ``environment.import_module`` function.
 This function is provided via the palm context and uses importlib to ensure
 your shared code is imported from the correct location at run time.
 
 **Examples**:
 
 Maybe you want a command that kicks off a slow-building container
-as a background process, but you want to see it complete before moving it back. 
+as a background process, but you want to see it complete before moving it back.
 That could look something like this:
 
 .. code:: python
@@ -143,17 +143,17 @@ That could look something like this:
   ## ./palm/cmd_slow_starter.py
   ...
   @click.command('slow_starter')
-  @click.pass_context
-  def cli(ctx):
+  @click.pass_obj
+  def cli(environment):
       """Starts the container as daemon, watches the logs, then exits"""
-      ctx.run_on_host("docker-compose run -d super_slow_starting_django_app",
+      environment.run_on_host("docker-compose run -d super_slow_starting_django_app",
                            check=True)
-        
+
       ## this is where we watch, pseudo-blocking
       building_logs = str()
       while "Starting local webserver via runserver on port 8080..." \
         not in building_logs:
-          logs, _, _ = ctx.run_on_host("docker-compose logs static_app")
+          logs, _, _ = environment.run_on_host("docker-compose logs static_app")
             if logs != building_logs:
                 building_logs = logs
                 click.echo(logs)
diff --git a/docs/source/conf.py b/docs/source/conf.py
index 8a53b96..bb3ba15 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -17,12 +17,12 @@
 
 # -- Project information -----------------------------------------------------
 
-project = 'palm-cli'
-copyright = '2021, Palmetto Data Team'
-author = 'Palmetto Data Team'
+project = "palm-cli"
+copyright = "2021, Palmetto Data Team"
+author = "Palmetto Data Team"
 
 # The full version, including alpha/beta/rc tags
-release = '2.4.1'
+release = "2.5.0"
 
 
 # -- General configuration ---------------------------------------------------
@@ -33,7 +33,7 @@
 extensions = []
 
 # Add any paths that contain templates here, relative to this directory.
-templates_path = ['_templates']
+templates_path = ["_templates"]
 
 # List of patterns, relative to source directory, that match files and
 # directories to ignore when looking for source files.
@@ -46,11 +46,11 @@
 # The theme to use for HTML and HTML Help pages.  See the documentation for
 # a list of builtin themes.
 #
-html_theme = 'sphinx_rtd_theme'
+html_theme = "sphinx_rtd_theme"
 html_logo = "_static/img/svg/logo-subheader-white.svg"
 html_theme_options = {"logo_only": True}
 
 # Add any paths that contain custom static files (such as style sheets) here,
 # relative to this directory. They are copied after the builtin static files,
 # so a file named "default.css" will overwrite the builtin "default.css".
-html_static_path = ['_static']
+html_static_path = ["_static"]
diff --git a/docs/source/containerization.rst b/docs/source/containerization.rst
index ea5b86a..cc767b3 100644
--- a/docs/source/containerization.rst
+++ b/docs/source/containerization.rst
@@ -19,13 +19,13 @@ Palm ships with basic containerization for Python projects.
 
 **Before you start**
 
-- Make sure your project is initialized with ``palm init``, the image_name 
+- Make sure your project is initialized with ``palm init``, the image_name
   configuration will be used by the containerization tool.
 - You should ensure you have a .env file in your project root directory. This file
   should include the environment variables you need to run your project. It should
   also be added to your .gitignore file.
 - You need to be using requirements.txt or poetry.lock to manage your dependencies.
-  If you are using a different package management system, please open an issue on 
+  If you are using a different package management system, please open an issue on
   Github and we will consider adding support for it.
 
 Use ``palm containerize`` to set up your project with Docker.
@@ -39,13 +39,13 @@ How containerization works
 Palm containerization generates the following files in your project root directory:
 
 - Dockerfile: The Dockerfile used to build your project
-- docker-compose.yml: Docker compose is used to load the .env file and volume 
-  mount your project code so that you can make changes without having to rebuild 
+- docker-compose.yml: Docker compose is used to load the .env file and volume
+  mount your project code so that you can make changes without having to rebuild
   or restart the container.
-- scripts/entrypoint.sh: This script is used to run your project. It is executed 
-  by the Dockerfile as it's entrypoint. This is where your dependencies are 
-  installed. Note: we do not recommend executing your project code directly in 
-  the entrypoint as this limits the flexibility of your container, instead use 
+- scripts/entrypoint.sh: This script is used to run your project. It is executed
+  by the Dockerfile as it's entrypoint. This is where your dependencies are
+  installed. Note: we do not recommend executing your project code directly in
+  the entrypoint as this limits the flexibility of your container, instead use
   palm commands to run your project, this allows you to determine what each instance
   of the container is doing (run, test, etc.).
 
@@ -58,14 +58,14 @@ for Python projects. For projects using a specific framework, or a different lan
 we recommend that you implement your own containerization solution. To do this,
 you will need to:
 
-- Create a new containerizer command in your project 
+- Create a new containerizer command in your project
   (``palm scaffold command --name containerize``).
-- Duplicate the templates from ``palm/plugins/core/templates/containerize`` and 
-  add them to your project. Make the necessary changes to support your 
+- Duplicate the templates from ``palm/plugins/core/templates/containerize`` and
+  add them to your project. Make the necessary changes to support your
   containerization needs.
-- Subclass the ``Containerizer`` class and override the ``run()`` and 
+- Subclass the ``Containerizer`` class and override the ``run()`` and
   ``package_manager()`` methods.
-- Open up cmd_containerize.py and implement any logic + Call YourSubclass.run() 
+- Open up cmd_containerize.py and implement any logic + Call YourSubclass.run()
   in your command.
 
 To see an example of custom containerization, see ``cmd_containerize.py`` in the
diff --git a/docs/source/contributing.rst b/docs/source/contributing.rst
new file mode 100644
index 0000000..c4ce510
--- /dev/null
+++ b/docs/source/contributing.rst
@@ -0,0 +1,22 @@
+============
+CONTRIBUTING
+============
+
+Contributions are welcome! Please see the `CONTRIBUTING.md`_ file for
+details.
+
+Development environment
+=======================
+
+To set up a development environment, you can use the provided
+`Dockerfile`_ to build a Docker image. This image contains all the
+dependencies needed to run the tests and build the documentation.
+
+Pre-commit
+==========
+
+This project uses `pre-commit`_ to run a set of checks before each
+commit. To install the pre-commit hooks, run::
+
+    pre-commit install
+
diff --git a/docs/source/high-level-features.rst b/docs/source/high-level-features.rst
index 8726c52..1ae274d 100644
--- a/docs/source/high-level-features.rst
+++ b/docs/source/high-level-features.rst
@@ -5,10 +5,10 @@ High-level Features
 Run in docker
 =============
 
-Palm and docker are like peas in a pod. Docker containers allow us to run our 
+Palm and docker are like peas in a pod. Docker containers allow us to run our
 commands in a sandboxed environment that is isolated from the rest of the system
-and is a close reflection of our production and CI environments. Docker ensures 
-that everyone on your team is using a consistent OS and set of dependencies. 
+and is a close reflection of our production and CI environments. Docker ensures
+that everyone on your team is using a consistent OS and set of dependencies.
 We're not here to sell you on Docker, and we don't expect you to be a Docker pro,
 but you will need Docker in order to work with Palm.
 
@@ -16,9 +16,9 @@ but you will need Docker in order to work with Palm.
 Local commands
 ==============
 
-Palm allows you to define commands within each of your projects, and then share 
-them across your team. Once you have set up your project to use Palm, you can 
-create new commands in the ``.palm`` directory, add these to version control and 
+Palm allows you to define commands within each of your projects, and then share
+them across your team. Once you have set up your project to use Palm, you can
+create new commands in the ``.palm`` directory, add these to version control and
 they will be available to everyone on your team when they run ``palm``.
 
 See the :doc:`commands` section for more information.
@@ -26,9 +26,9 @@ See the :doc:`commands` section for more information.
 Code generation
 ===============
 
-Palm includes code generation functionality, powered by jinja. Code generation 
+Palm includes code generation functionality, powered by jinja. Code generation
 allows you to automate repetitive boiler plating tasks, and keep your codebase
-consistent. Code generation is driven by the ``ctx.obj.generate()`` function.
+consistent. Code generation is driven by the ``environment.generate()`` function.
 
 See the :doc:`code-generation` section for more information.
 
diff --git a/docs/source/index.rst b/docs/source/index.rst
index ec5b7e4..3e43025 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -12,10 +12,10 @@ Palm CLI Documentation
 What is Palm?
 =============
 
-- A highly configurable, extensible, and flexible CLI framework for data 
-  professionals. 
+- A highly configurable, extensible, and flexible CLI framework for data
+  professionals.
 - A layer of abstraction across your stack, to help with context shifting.
-- A simple mechanism for sharing abstraction and automation across your 
+- A simple mechanism for sharing abstraction and automation across your
   engineering teams.
 - A tool for building and managing your own CLI commands.
 - Built with python and click
@@ -33,11 +33,12 @@ Contents
    plugins
    containerization
    branding/index
-   
+   contributing
+
 Usage
 -----
 
-Check out the :doc:`usage` section for further information, including how to 
+Check out the :doc:`usage` section for further information, including how to
 :ref:`installation` the project.
 
 .. note::
diff --git a/docs/source/introduction/viewpoint.rst b/docs/source/introduction/viewpoint.rst
index 98570ca..1daa83b 100644
--- a/docs/source/introduction/viewpoint.rst
+++ b/docs/source/introduction/viewpoint.rst
@@ -103,15 +103,16 @@ what if the fix was automated? Enter palm.
 
    #.palm/cmd_up.py
 
-   def cmd_up(ctx):
+   @click.pass_obj
+   def cmd_up(environment):
        “”” starts the compose stack”””
        echo(“Starting docker stack…”)
-       exit_code, out, err = ctx.docker_up(capture_output=True)
+       exit_code, out, err = environment.docker_up(capture_output=True)
        cryptic_message_indicating_no_memory = “ERROR: unable to read file app/conf: file does not exist or access denied”
        if cryptic_message_indicating_no_memory in err:
            red_echo(“Docker may be out of memory, cleaning up first…”)
-           ctx.docker_full_clean()
-           ctx.docker_up(bubble_error=True)
+           environment.docker_full_clean()
+           environment.docker_up(bubble_error=True)
            green_echo(“Docker stack started.”)       
 	
 Now and forever, your developers will see this when they run out of memory:
diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst
index 323d3f2..7f5615f 100644
--- a/docs/source/plugins.rst
+++ b/docs/source/plugins.rst
@@ -2,9 +2,9 @@
 Plugins
 =======
 
-Palm plugins extend the functionality of the CLI. They usually add new commands 
-that are specific to a particular platform or framework. A plugin could also 
-share organization-specific functionality across multiple projects, or provide 
+Palm plugins extend the functionality of the CLI. They usually add new commands
+that are specific to a particular platform or framework. A plugin could also
+share organization-specific functionality across multiple projects, or provide
 a common base for a set of commands.
 
 Core plugins
@@ -49,7 +49,7 @@ Configuring Global Plugins
 
 You can also configure plugins that are not specific to a project. This is
 done by adding the name of the plugin to the ``~/.palm/config.yaml`` file in
-the user's home directory. This global configuration file will be created the 
+the user's home directory. This global configuration file will be created the
 first time palm is run.
 
 Example global plugin configuration
@@ -64,7 +64,7 @@ Example global plugin configuration
 Using plugin commands
 =====================
 
-Once you have installed and configured a plugin, it's commands should be available 
+Once you have installed and configured a plugin, it's commands should be available
 for use within the project. To confirm this and to explore the available commands
 run ``palm --help`` to list the available commands in your project.
 
@@ -87,3 +87,22 @@ Or, maybe you have a set of commands you want to share with other people?
 Writing a plugin is a great way to contribute to the Palm CLI ecosystem.
 
 Check out the :doc:'write-a-plugin' section to learn how to write your own plugin.
+
+Plugin config
+=============
+
+Some plugins require configuration to work. This configuration is stored in the
+``.palm/config.yaml`` file in the project directory. The configuration for each
+plugin is stored under the ``plugin_config`` key at the root of the config file.
+
+Example plugin config
+
+.. code:: yaml
+
+  plugin_config:
+    dbt:
+      prod_artifacts: path/to/artifacts/
+      target: /path/to/target/
+
+Plugin config is loaded into the plugin's ``config`` attribute. Commands can
+access this config by calling ``environment.plugin_config(plugin_name)``.
\ No newline at end of file
diff --git a/docs/source/usage.rst b/docs/source/usage.rst
index 8d944b4..e29220b 100644
--- a/docs/source/usage.rst
+++ b/docs/source/usage.rst
@@ -28,7 +28,7 @@ help text for commands in the current project
 System Requirements
 ===================
 
-Palm is designed to be OS agnostic and should work on Windows, Mac OS X, and 
+Palm is designed to be OS agnostic and should work on Windows, Mac OS X, and
 common Linux distributions.
 
 Palm requires the following software to be installed and running on your
@@ -36,7 +36,7 @@ device:
 
 1. `Docker <https://docs.docker.com/get-docker/>`_
    You can check to see if you already have it with ``docker --version``
-2. `Python3 <https://www.python.org/downloads/>`_ 
+2. `Python3 <https://www.python.org/downloads/>`_
    You can check to see if you already have it with ``python3 --version``
 
 
@@ -58,7 +58,7 @@ To verify that the installation was successful, run ``palm --version``.
   WARNING: The script palm is installed in '/Users/yourname/Library/Python/3.8/bin' which is not on PATH.
   Consider adding this directory to PATH or, if you prefer to suppress this warning, use --no-warn-script-location.
 
-you will need to add ``'/Users/yourname/Library/Python/3.8/bin'`` to your path for 
+you will need to add ``'/Users/yourname/Library/Python/3.8/bin'`` to your path for
 ``palm`` to work. You can do that with one of these commands (depending on your
 shell of choice):
 
@@ -93,7 +93,7 @@ automatically created at ``~/.palm/config.yaml`` and contains the following opti
 Shell Completion
 ================
 
-To enable autocomplete for palm commands, add one of the following shell-specific 
+To enable autocomplete for palm commands, add one of the following shell-specific
 lines to your shell's profile. Once added, either source your profile or start
 a new shell session.
 
diff --git a/docs/source/write-a-plugin.rst b/docs/source/write-a-plugin.rst
index bb4499c..e50dbc8 100644
--- a/docs/source/write-a-plugin.rst
+++ b/docs/source/write-a-plugin.rst
@@ -2,7 +2,7 @@
 Write a plugin
 ==============
 
-Hello there, friend! Thank you in advance for your interest in writing a plugin for 
+Hello there, friend! Thank you in advance for your interest in writing a plugin for
 Palm CLI! If you have questions or want to discuss your ideas, please feel free to
 contact the Core team at <data-analytics-team@palmetto.com>
 
@@ -12,7 +12,7 @@ Getting started
 
 Palm includes a command to generate a plugin skeleton. You can use it to get started.
 
-1. cd into an active project - this is necessary at the moment as palm commands 
+1. cd into an active project - this is necessary at the moment as palm commands
    are not currently available outside of an initialized git repository.
 2. Run ``palm plugin new --name <plugin-name>`` and follow the prompts.
 3. Your new plugin skeleton is created and ready for you to add commands!
@@ -25,8 +25,63 @@ so that you can test it out.  To do this, run the following command from
 your plugin's root directory:
 
 .. code:: bash
-  
+
   python3 -m pip install .
 
-**Note**: Due to the way plugins are used by palm, you will need to re-install 
+**Note**: Due to the way plugins are used by palm, you will need to re-install
 the plugin every time you want to test changes to the plugin
+
+Creating a Plugin Config
+========================
+
+If you want to make your plugin configurable, you will need to create a plugin
+config object and provide it to the plugin constructor.
+
+The plugin config object:
+- must be a subclass of ``palm.plugins.base_plugin_config.BasePluginConfig``
+- must define a ``set`` method which returns a dictionary of config values
+- must be initialized with a model object, using the pydantic ``BaseModel``,
+  which defines the config schema and will be used to validate the config values.
+
+Here is an example of a plugin config object and how to use it in a plugin:
+
+.. code:: python
+
+  import click
+  from palm.plugins.base import BasePlugin
+  from palm.plugins.base_plugin_config import BasePluginConfig
+  from pydantic import BaseModel
+
+  # Define the config schema
+  class MyPluginConfigModel(BaseModel):
+      my_config_value: str
+
+  # Define the config object
+  class MyPluginConfig(BasePluginConfig):
+      def __init__(self):
+          super().__init__('my_plugin', MyPluginConfigModel)
+
+      def set(self):
+          return {
+              "my_config_value": click.prompt("Please enter a value for my_config_value", type=str)
+          }
+
+  # Create the plugin, providing the config object
+  my_plugin = BasePlugin(name='my_plugin', config=MyPluginConfig())
+
+Using Plugin Config values
+--------------------------
+
+To use the config values in your plugin's commands, a ``plugin_config`` method
+is provided on the environment object. This method takes the name of the plugin
+and returns the config values for that plugin.
+
+Here is an example of how to use the plugin config values in a plugin command:
+
+.. code:: python
+
+  @click.command()
+  @click.pass_obj
+  def my_command(environment):
+      plugin_config = env.plugin_config('my_plugin')
+      click.echo(f"my_config_value is {plugin_config.my_config_value}")
diff --git a/palm/cli.py b/palm/cli.py
index babe292..125f823 100644
--- a/palm/cli.py
+++ b/palm/cli.py
@@ -1,14 +1,15 @@
-import sys
-import click
 import importlib
 import os
+import sys
+from typing import Any, Callable, List, Optional
+
+import click
 import pkg_resources
-from typing import Optional, Any, Callable, List
 
 from .environment import Environment
 from .palm_config import PalmConfig
 from .plugin_manager import PluginManager
-from .utils import is_cmd_file, cmd_name_from_file, run_on_host
+from .utils import cmd_name_from_file, is_cmd_file, run_on_host
 
 CONTEXT_SETTINGS = dict(auto_envvar_prefix="PALM")
 
@@ -27,9 +28,9 @@ def __init__(
         result_callback: Optional[Callable[..., Any]] = None,
         **attrs: Any,
     ) -> None:
-        try:
-            palm_config.validate_branch()
-        except SystemExit as e:
+        if not palm_config.is_valid_branch():
+            msg = f"You are currently on protected branch {palm_config.branch}. For your safety Palm will not run!"
+            click.secho(msg, fg="red")
             sys.exit(1)
         self.palm = palm_config
         self.plugin_manager = plugin_manager_instance
@@ -57,7 +58,7 @@ def list_commands(self, ctx) -> List[str]:
         dedupe = set(cmds)
         cmds = list(dedupe)
         cmds.sort()
-        project_excluded_commands = self.palm.config.get('excluded_commands', [])
+        project_excluded_commands = self.palm.config.get("excluded_commands", [])
         cmds = filter(lambda x: x not in project_excluded_commands, cmds)
         return cmds
 
@@ -108,10 +109,10 @@ def get_command(self, ctx, cmd_name: str) -> click.Command:
             mod = importlib.util.module_from_spec(spec)
             spec.loader.exec_module(mod)
         except ImportError as error:
-            click.secho(f'Import error: {error}', fg="red")
+            click.secho(f"Import error: {error}", fg="red")
             return
         except FileNotFoundError:
-            click.secho('Command not found, check spelling!', fg="red")
+            click.secho("Command not found, check spelling!", fg="red")
             return
         return mod.cli
 
@@ -120,7 +121,7 @@ def get_version():
     try:
         version = pkg_resources.require("palm")[0].version
     except pkg_resources.DistributionNotFound:
-        version = 'unknown'
+        version = "unknown"
     return version
 
 
diff --git a/palm/code_generator.py b/palm/code_generator.py
index cc80889..96d5c81 100644
--- a/palm/code_generator.py
+++ b/palm/code_generator.py
@@ -1,6 +1,7 @@
-from jinja2 import Environment, FileSystemLoader
 from pathlib import Path
+
 import yaml
+from jinja2 import Environment, FileSystemLoader
 
 
 class CodeGenerator:
@@ -50,11 +51,11 @@ def run(self) -> str:
             for key in file_item:
                 template = key
                 destination = env.from_string(file_item[key]).render(self.replacements)
-                print(f'Generating {template} to {destination}')
+                print(f"Generating {template} to {destination}")
                 t = env.get_template(template)
                 templated_contents = t.render(self.replacements)
 
-                with open(Path(Path.cwd(), self.target_path, destination), 'w') as fh:
+                with open(Path(Path.cwd(), self.target_path, destination), "w") as fh:
                     fh.write(templated_contents)
 
         return "Generated successfully"
diff --git a/palm/containerizer.py b/palm/containerizer.py
index 4f3ca13..51216a9 100644
--- a/palm/containerizer.py
+++ b/palm/containerizer.py
@@ -1,11 +1,13 @@
+import re
+import sys
 from abc import ABC, abstractmethod
-from typing import Dict, Optional
 from pathlib import Path
-import re
+from typing import Dict, Optional
+
 import click
-import sys
-from palm.palm_exceptions import AbortPalm
+
 import palm.project_setup_utils as psu
+from palm.palm_exceptions import AbortPalm
 
 
 class Containerizer(ABC):
@@ -68,7 +70,7 @@ def has_containerization(self) -> bool:
         Returns:
             bool: true if Dockerfile or docker-compose.yaml exists
         """
-        containerization_files = ['docker-compose.yaml', 'Dockerfile']
+        containerization_files = ["docker-compose.yaml", "Dockerfile"]
 
         for file in containerization_files:
             if Path(file).exists():
@@ -89,7 +91,7 @@ class PythonContainerizer(Containerizer):
     """Containerizer for Python projects"""
 
     def __init__(
-        self, ctx, template_dir: Path, python_version: Optional[str] = '3.8'
+        self, ctx, template_dir: Path, python_version: Optional[str] = "3.8"
     ) -> None:
         """PythonContainerizer constructor
 
@@ -101,7 +103,7 @@ def __init__(
         self.project_name = ctx.obj.palm.image_name
         self.template_dir = template_dir
         self.python_version = python_version
-        self.package_manager = ''
+        self.package_manager = ""
 
     def run(self) -> None:
         """Run the containerizer"""
diff --git a/palm/environment.py b/palm/environment.py
index 9a1098c..b58576e 100644
--- a/palm/environment.py
+++ b/palm/environment.py
@@ -1,12 +1,15 @@
-from typing import Optional, Tuple
 import importlib
 from pathlib import Path
-from typing import Optional, List, Tuple
+from typing import List, Optional, Tuple
+
 import click
-from palm.utils import run_on_host
+from pydantic import BaseModel
+
 from palm.plugin_manager import PluginManager
-from .palm_config import PalmConfig
+from palm.utils import run_on_host
+
 from .code_generator import CodeGenerator
+from .palm_config import PalmConfig
 
 
 class Environment:
@@ -29,7 +32,7 @@ def run_in_docker(
         """
         click.secho(f"Executing command `{cmd}` in compose...", fg="yellow")
 
-        docker_cmd = ['docker-compose run --service-ports --rm']
+        docker_cmd = ["docker-compose run --service-ports --rm"]
         docker_cmd.extend(self._build_env_vars(env_vars))
         docker_cmd.append(self.palm.image_name)
         if no_bin_bash:
@@ -37,9 +40,9 @@ def run_in_docker(
         else:
             docker_cmd.append(f'/bin/bash -c "{cmd}" ')
 
-        ex_code, _, _ = run_on_host(' '.join(docker_cmd))
+        ex_code, _, _ = run_on_host(" ".join(docker_cmd))
         if ex_code == 0:
-            return (True, 'Success! Palm completed with exit code 0')
+            return (True, "Success! Palm completed with exit code 0")
         return (False, f"Fail! Palm exited with code {ex_code}")
 
     def run_in_shell(self, cmd: str, env_vars: Optional[dict] = {}):
@@ -48,7 +51,7 @@ def run_in_shell(self, cmd: str, env_vars: Optional[dict] = {}):
         deprecation_msg = (
             "DEPRECATION: run_in_shell has been renamed to "
             "`run_in_docker` and will be removed in a future version. "
-            "Please update your commands to use ctx.obj.run_in_docker"
+            "Please update your commands to use environment.run_in_docker"
         )
         click.secho(deprecation_msg, fg="yellow")
         success, msg = self.run_in_docker(cmd, env_vars)
@@ -84,17 +87,42 @@ def import_module(self, module_name: str, module_path: Path):
             mod = importlib.util.module_from_spec(spec)
             spec.loader.exec_module(mod)
         except ImportError as error:
-            click.secho(f'Import error: {error}', fg="red")
+            click.secho(f"Import error: {error}", fg="red")
             return None
         return mod
 
     def generate(
         self, template_path: Path, target_path: Path, replacements: dict
     ) -> str:
+        """Runs a template through the code generator
+
+        Args:
+            template_path (Path): Path to the directory containing the template
+            target_path (Path): Path to the directory where the generated code will be written
+            replacements (dict): Dict of replacements to make in the template
+
+        Returns:
+            str: The path to the generated code
+        """
         return CodeGenerator(template_path, target_path, replacements).run()
 
     def _build_env_vars(self, env_vars: dict) -> List[str]:
         env_vars_list = []
         for key in env_vars.keys():
-            env_vars_list.append(f'-e {key.upper()}={env_vars[key]}')
+            env_vars_list.append(f"-e {key.upper()}={env_vars[key]}")
         return env_vars_list
+
+    def plugin_config(self, plugin_name: str) -> Optional[BaseModel]:
+        """Returns the config for a plugin
+
+        Args:
+            plugin_name (str): The name of the plugin
+
+        Returns:
+            Optional[BaseModel]: The config for the plugin, or None if the plugin
+            is not found or does not have a config
+        """
+        plugin = self.plugin_manager.plugins.get(plugin_name)
+        if plugin and plugin.config:
+            return plugin.config.get()
+        return None
diff --git a/palm/palm_config.py b/palm/palm_config.py
index 7caabfc..ec6a4d6 100644
--- a/palm/palm_config.py
+++ b/palm/palm_config.py
@@ -1,9 +1,14 @@
 from pathlib import Path
-from typing import Optional, List
-from pygit2 import Repository
-from click import secho
-from deepmerge import always_merger
+from typing import Optional, List, Union
 import yaml
+from xmlrpc.client import Boolean
+from pygit2 import Repository, discover_repository, GitError
+
+from click import secho, echo
+from deepmerge import always_merger
+from pygit2 import Repository
+
+from .palm_exceptions import NoRepositoryError
 
 
 class PalmConfig:
@@ -15,13 +20,29 @@ class PalmConfig:
         project_root: The root path object if not cwd
     """
 
+    project_root: Optional["Path"]
+    config: dict = {}
+    repo: Optional[Repository] = None
     branch: str = None
+    plugins: List[str] = []
 
     def __init__(self, project_path: Optional["Path"] = Path.cwd()):
         self.project_root = project_path
+        self._setup()
+
+    def _setup(self):
+        """Setup the config"""
         self.config = self._get_config()
-        self.repo = self._get_repo()
-        self.branch = self._get_current_branch()
+        try:
+            self.repo = self._get_repo()
+        except NoRepositoryError:
+            secho('No git repository found, running in global mode', fg='yellow')
+            self.repo = None
+        if self.repo:
+            self.branch = self._get_current_branch()
+            self._use_repo_plugins()
+        else:
+            self._use_global_plugins()
 
     def _get_repo(self) -> Repository:
         """Gets the repo object.
@@ -29,35 +50,55 @@ def _get_repo(self) -> Repository:
         Returns:
             Repository: repo object
         """
-        return Repository(str(self.project_root))
+        path = discover_repository(str(self.project_root))
 
-    def _get_current_branch(self) -> str:
-        return self.repo.head.shorthand
+        if not path:
+            raise NoRepositoryError("No git repository found in the current directory")
 
-    def _get_config(self) -> object:
+        return Repository(path)
+
+    def _get_current_branch(self) -> Union[str, None]:
+        """Gets the current branch name.
+
+        Returns:
+            Union[str, None]: branch name, or None if not in a repo
+        """
+        if self.repo:
+            try:
+                return self.repo.head.shorthand
+            except GitError as e:
+                secho('Error finding an active branch.', fg='red')
+                echo(
+                    'If this is a new repo, you should make an initial commit before using palm.\n'
+                )
+                raise e
+
+        return None
+
+    def _get_config(self) -> dict:
         """Gets both global and repo configs, merging them together.
 
         Returns:
-            object: dict of merged global and repo configs
+            dict: dict of merged global and repo configs
         """
         return always_merger.merge(self._get_global_config(), self._get_repo_config())
 
-    def _get_repo_config(self) -> object:
+    def _get_repo_config(self) -> dict:
         """Gets the repo config, reading yaml and returning a dict.
         If the config does not exist, prompt the user to create it.
 
         Returns:
-            object: dict of repo config, or empty dict if no config
+            dict: dict of repo config, or empty dict if no config
         """
-        config_path = self.project_root / '.palm' / 'config.yaml'
+        config_path = self.project_root / ".palm" / "config.yaml"
         if not config_path.exists():
             secho(
-                'No palm config found in .palm/config.yml, please run \'palm scaffold config\'',
-                fg='yellow',
+                "No palm config found in .palm/config.yml, please run 'palm scaffold config'",
+                fg="yellow",
             )
             secho(
-                'Some palm commands may not work correctly without palm config',
-                fg='yellow',
+                "Some palm commands may not work correctly without palm config \n",
+                fg="yellow",
             )
             return {}
 
@@ -73,7 +114,7 @@ def _get_global_config(self, global_config_path: Optional[Path] = None) -> objec
         Returns:
             object: dict of global config
         """
-        config_path = global_config_path or Path().home() / '.palm' / 'config.yaml'
+        config_path = global_config_path or Path().home() / ".palm" / "config.yaml"
         if not config_path.exists():
             self._create_global_config_file(config_path)
 
@@ -85,16 +126,35 @@ def _create_global_config_file(self, config_path) -> None:
         default_config = {
             'plugins': [],
             'excluded_commands': [],
+            'default_cookiecutters': {},
         }
         config_path.write_text(yaml.dump(default_config))
 
-    def validate_branch(self) -> None:
-        """Raises SystemExit if branch is protected."""
-        if self.branch not in self.protected_branches:
-            return
-        msg = f"You are currently on protected branch {self.branch}. For your safety Palm will not run!"
-        secho(msg, fg="red")
-        raise SystemExit(msg)
+    def _use_repo_plugins(self):
+        """Use the default plugins - core, plugins, and repo defined commands"""
+        core_plugins = ['core']
+        plugins_from_config = self.config.get('plugins') or []
+        # The order here defines the order in which commands will be overridden
+        # Plugins on the right will override plugins on the left!
+        self.plugins = core_plugins + plugins_from_config + ['repo']
+
+    def _use_global_plugins(self):
+        """Use the setup plugins - when palm is used outside of a git repo"""
+        global_plugins = ['setup']
+        plugins_from_config = self.config.get('plugins') or []
+
+        self.plugins = global_plugins + plugins_from_config
+
+    def is_valid_branch(self) -> Boolean:
+        """Validate the current branch against the config
+
+        Return:
+            Boolean: True if the branch is valid, False if not
+        """
+        if self.repo and self.branch in self.protected_branches:
+            return False
+
+        return True
 
     @property
     def protected_branches(self) -> List[Optional[str]]:
@@ -103,11 +163,11 @@ def protected_branches(self) -> List[Optional[str]]:
         Returns:
              list[Optional[str]]: list of branch names e.g ['main', 'master']
         """
-        return self.config.get('protected_branches') or []
+        return self.config.get("protected_branches") or []
 
     @property
     def project_root_snake_case(self):
-        return self.project_root.name.replace('-', '_')
+        return self.project_root.name.replace("-", "_")
 
     @property
     def image_name(self) -> str:
@@ -119,11 +179,3 @@ def image_name(self) -> str:
             str: Name of docker image to use
         """
         return self.config.get('image_name') or self.project_root_snake_case
-
-    @property
-    def plugins(self) -> list:
-        core_plugins = ['core']
-        plugins_from_config = self.config.get('plugins') or []
-        # The order here defines the order in which commands will be overridden
-        # Plugins on the right will override plugins on the left!
-        return core_plugins + plugins_from_config + ['repo']
diff --git a/palm/palm_exceptions.py b/palm/palm_exceptions.py
index d20692a..c6cd9dc 100644
--- a/palm/palm_exceptions.py
+++ b/palm/palm_exceptions.py
@@ -1,2 +1,20 @@
 class AbortPalm(Exception):
     pass
+
+
+class NoRepositoryError(Exception):
+    """
+    Raised when a git repository is not found.
+    """
+
+    pass
+
+
+class InvalidConfigError(Exception):
+    """
+    Raised when the config is invalid.
+    """
+
+    def __init__(self, message: str = "Invalid config"):
+        self.message = message
+        super().__init__(self.message)
diff --git a/palm/plugin_manager.py b/palm/plugin_manager.py
index a98467b..ed5fa8c 100644
--- a/palm/plugin_manager.py
+++ b/palm/plugin_manager.py
@@ -1,8 +1,10 @@
 import importlib
 from typing import List
-from .plugins.base import BasePlugin as Plugin
+
 from click import secho
 
+from .plugins.base import BasePlugin as Plugin
+
 
 class PluginManager:
     def __init__(self) -> None:
@@ -31,11 +33,11 @@ def load_plugin(self, plugin_name: str) -> Plugin:
             Plugin: Plugin instance
         """
         try:
-            module: Plugin = importlib.import_module('.' + plugin_name, 'palm.plugins')
+            module: Plugin = importlib.import_module("." + plugin_name, "palm.plugins")
         except ModuleNotFoundError as e:
-            if e.name == 'palm.plugins.' + plugin_name:
-                secho(f'Could not find plugin: {plugin_name}!', fg='red')
-            secho(f'Error importing plugin: {e}', fg='red')
+            if e.name == "palm.plugins." + plugin_name:
+                secho(f"Could not find plugin: {plugin_name}!", fg="red")
+            secho(f"Error importing plugin: {e}", fg="red")
             raise
 
         plugin = module.Plugin
@@ -46,8 +48,9 @@ def load_plugin(self, plugin_name: str) -> Plugin:
 
     def extend_plugin_command_mapping(self, plugin_name: str) -> None:
         """Merges the plugin commands to the PluginManager plugin_command_dict
-        Note that this is using dict merging. In the event of 2 plugins having the
-        same command name, the second command will supercede the first, replacing it.
+        Note that this is using dict merging. In the event of 2 plugins having
+        the same command name, the second command will supercede the first,
+        replacing it.
 
         Args:
             plugin_name (str): name of the plugin
diff --git a/palm/plugins/base.py b/palm/plugins/base.py
index c0e48a6..bb900d5 100644
--- a/palm/plugins/base.py
+++ b/palm/plugins/base.py
@@ -1,8 +1,10 @@
 import importlib
-from typing import List, Optional, Tuple
 from pathlib import Path
+from typing import List, Optional, Tuple
 from urllib.parse import urlparse
-from palm.utils import is_cmd_file, cmd_name_from_file, run_on_host
+
+from palm.utils import cmd_name_from_file, is_cmd_file, run_on_host
+from palm.plugins.base_plugin_config import BasePluginConfig
 
 
 class BasePlugin:
@@ -12,6 +14,7 @@ def __init__(
         command_dir: Path,
         version: Optional[str] = "unknown",
         package_location: Optional[str] = None,
+        config: Optional[BasePluginConfig] = None,
         **kwargs,
     ) -> None:
         """Initialize a plugin.
@@ -28,6 +31,7 @@ def __init__(
         self.version = version
         self.package_location = package_location
         self.__dict__.update(kwargs)
+        self.config = config
 
     def all_commands(self) -> List:
         """Get the commands for a plugin.
@@ -37,7 +41,7 @@ def all_commands(self) -> List:
         """
         command_list = []
 
-        for path in self.command_dir.glob('*.py'):
+        for path in self.command_dir.glob("*.py"):
             if is_cmd_file(path.name):
                 command_list.append(cmd_name_from_file(path.name))
 
@@ -61,7 +65,7 @@ def get_command(self, command_name: str) -> importlib.machinery.ModuleSpec:
         Returns:
             importlib.ModuleSpec: ModuleSpec for the command
         """
-        command_path = self.command_dir / f'cmd_{command_name}.py'
+        command_path = self.command_dir / f"cmd_{command_name}.py"
         return importlib.util.spec_from_file_location(command_name, command_path)
 
     def update(self) -> Tuple[bool, str]:
@@ -71,11 +75,11 @@ def update(self) -> Tuple[bool, str]:
             Tuple[bool, str]: Tuple of ('success', 'message')
         """
         if self.package_location is None:
-            return (False, 'This plugin does not support upgrading via palm')
+            return (False, "This plugin does not support upgrading via palm")
 
         package_url = urlparse(self.package_location)
-        if package_url.hostname == 'github.com':
-            install_url = f'git+{self.package_location}'
+        if package_url.hostname == "github.com":
+            install_url = f"git+{self.package_location}"
         else:  # This should be a pypi package name
             install_url = self.package_location
 
@@ -85,4 +89,4 @@ def update(self) -> Tuple[bool, str]:
 
         for cmd in (uninstall_cmd, upgrade_cmd):
             _, _, _ = run_on_host(cmd, check=True)
-        return (True, 'Plugin upgraded successfully')
+        return (True, "Plugin upgraded successfully")
diff --git a/palm/plugins/base_plugin_config.py b/palm/plugins/base_plugin_config.py
new file mode 100644
index 0000000..9f48028
--- /dev/null
+++ b/palm/plugins/base_plugin_config.py
@@ -0,0 +1,101 @@
+from abc import ABC, abstractmethod
+import click
+from pathlib import Path
+import yaml
+from pydantic import BaseModel, ValidationError
+
+from palm.palm_exceptions import InvalidConfigError
+
+
+class BasePluginConfig(ABC):
+    def __init__(self, plugin_name: str, model: BaseModel):
+        self.plugin_name = plugin_name
+        self.config_path = Path.cwd() / ".palm" / f"config.yaml"
+        self.model = model
+
+    @abstractmethod
+    def set(self) -> dict:
+        """Setter for plugin config
+
+        This method should be implemented by the plugin to set the config
+        for the plugin. It should return True if the config was set successfully,
+        and False if it was not.
+
+        Returns:
+            dict: The config that was set
+        """
+        pass
+
+    def update(self) -> bool:
+        """Updates the config file with configuration provided by the set method
+
+        Returns:
+            bool: True if the config was updated, False if it was not
+        """
+        try:
+            config = self.set()
+            self.validate(config)
+            self._write(config)
+        except InvalidConfigError as e:
+            click.secho(str(e), fg="red")
+            return False
+        except Exception as e:
+            click.secho(str(e), fg="red")
+            return False
+        return True
+
+    def get(self) -> BaseModel:
+        if not self.config_path.exists():
+            click.secho(
+                f"Config file not found at {self.config_path}, run palm init", fg="red"
+            )
+            return {}
+
+        config_dict = self._read()
+        return self.model(**config_dict)
+
+    def validate(self, config: dict) -> bool:
+        """Validates the plugin config against the pydantic model
+
+        Args:
+            config (dict): The config to validate
+
+        Returns:
+            bool: Returns True if the config is valid, Raises InvalidConfigError if not
+        """
+        try:
+            self.model(**config)
+            return True
+        except ValidationError as e:
+            msg = f"Invalid config for plugin {self.plugin_name}: {e}"
+            raise InvalidConfigError(msg)
+
+    def _read(self) -> dict:
+        palm_config = yaml.load(self.config_path.read_text(), Loader=yaml.FullLoader)
+        plugin_config = palm_config.get('plugin_config', {}).get(self.plugin_name, {})
+
+        if not plugin_config:
+            plugin_config = self.update()
+
+        if not self.validate(plugin_config):
+            raise InvalidConfigError
+
+        return plugin_config
+
+    def _write(self, config: dict):
+        if not self.config_path.exists():
+            click.secho(
+                f"Config file not found at {self.config_path}, run palm init", fg="red"
+            )
+            return
+
+        palm_config = yaml.load(self.config_path.read_text(), Loader=yaml.FullLoader)
+        if not 'plugin_config' in palm_config.keys():
+            palm_config['plugin_config'] = {}
+
+        if not self.validate(config):
+            click.secho("Invalid config, not writing to config file", fg="red")
+            raise InvalidConfigError
+
+        palm_config['plugin_config'][self.plugin_name] = config
+        self.config_path.write_text(yaml.dump(palm_config))
diff --git a/palm/plugins/core/commands/cmd_build.py b/palm/plugins/core/commands/cmd_build.py
index 171fc0a..8845da9 100644
--- a/palm/plugins/core/commands/cmd_build.py
+++ b/palm/plugins/core/commands/cmd_build.py
@@ -1,6 +1,7 @@
-import click
 import subprocess
 
+import click
+
 
 @click.command("build")
 def cli():
diff --git a/palm/plugins/core/commands/cmd_containerize.py b/palm/plugins/core/commands/cmd_containerize.py
index 5b1eefb..a702b15 100644
--- a/palm/plugins/core/commands/cmd_containerize.py
+++ b/palm/plugins/core/commands/cmd_containerize.py
@@ -1,5 +1,7 @@
-import click
 from pathlib import Path
+
+import click
+
 from palm.containerizer import PythonContainerizer
 
 
@@ -12,6 +14,7 @@
 )
 @click.pass_context
 def cli(ctx, version: str):
-    template_dir = Path(Path(__file__).parents[1], "templates") / "containerize"
+    all_templates_dir = Path(Path(__file__).parents[1], "templates")
+    template_dir = all_templates_dir / "containerize"
     PythonContainerizer(ctx, template_dir, version).run()
     click.secho(f"Containerized {ctx.obj.palm.image_name}", fg="green")
diff --git a/palm/plugins/core/commands/cmd_init.py b/palm/plugins/core/commands/cmd_init.py
index d252403..b37d32a 100644
--- a/palm/plugins/core/commands/cmd_init.py
+++ b/palm/plugins/core/commands/cmd_init.py
@@ -1,9 +1,11 @@
+from pathlib import Path
 from typing import Optional
+
 import click
-from pathlib import Path
+
 from palm.plugins.core.create_files import *
 
-palm_target_dir = f'{Path.cwd()}/.palm'
+palm_target_dir = f"{Path.cwd()}/.palm"
 templates_dir = Path(Path(__file__).parents[1], "templates").resolve()
 
 
@@ -19,9 +21,9 @@
 @click.option(
     "-c", "--commands", multiple=True, help="List of command names to scaffold"
 )
-@click.pass_context
+@click.pass_obj
 def cli(
-    ctx,
+    environment,
     image_name: Optional[str],
     plugins: Optional[tuple],
     protected_branches: Optional[tuple],
@@ -35,22 +37,22 @@ def cli(
     based on the options provided
     Creates command files based on the --commands option
     """
-    template_dir = Path(Path(__file__).parents[1], "templates") / 'command'
+    template_dir = Path(Path(__file__).parents[1], "templates") / "command"
 
     if Path(palm_target_dir).exists():
         click.secho("Palm is already initialized", fg="red")
         return
 
-    Path('.palm').mkdir()
-    Path('.palm/__init__.py').touch()
+    Path(".palm").mkdir()
+    Path(".palm/__init__.py").touch()
 
     for command in commands:
-        click.echo(f'Adding template for {command}...')
-        create_command(ctx, command, template_dir, palm_target_dir)
+        click.echo(f"Adding template for {command}...")
+        create_command(environment, command, template_dir, palm_target_dir)
 
     if not image_name:
-        image_name = ctx.obj.palm.image_name
+        image_name = environment.palm.image_name
 
     create_config(palm_target_dir, image_name, plugins, protected_branches)
 
-    click.secho('Success! Project initialized with Palm CLI', fg='green')
+    click.secho("Success! Project initialized with Palm CLI", fg="green")
diff --git a/palm/plugins/core/commands/cmd_lint.py b/palm/plugins/core/commands/cmd_lint.py
index 4a36524..6ed1258 100644
--- a/palm/plugins/core/commands/cmd_lint.py
+++ b/palm/plugins/core/commands/cmd_lint.py
@@ -1,8 +1,10 @@
 import click
 
 
-@click.command('lint')
-@click.pass_context
-def cli(ctx):
+@click.command("lint")
+@click.pass_obj
+def cli(environment):
     """lint the codebase with black"""
-    ctx.obj.run_in_docker('black --skip-string-normalization --exclude="\.tpl\.py" .')
+    environment.run_in_docker(
+        'black --skip-string-normalization --exclude="\.tpl\.py" .'
+    )
diff --git a/palm/plugins/core/commands/cmd_override.py b/palm/plugins/core/commands/cmd_override.py
index eaeb895..f0a8d22 100644
--- a/palm/plugins/core/commands/cmd_override.py
+++ b/palm/plugins/core/commands/cmd_override.py
@@ -1,28 +1,29 @@
-import click
 import shutil
 from pathlib import Path
 
+import click
+
 
-@click.command('override')
-@click.option('--name', multiple=False, required=True, help='Name of the command')
+@click.command("override")
+@click.option("--name", multiple=False, required=True, help="Name of the command")
 @click.pass_obj
 def cli(environment, name: str):
     """Override a command in the current project"""
     spec = environment.plugin_manager.command_spec(name)
     origin_path = Path(spec.origin)
     file_name = origin_path.name
-    target_path = Path(environment.palm.project_root, '.palm', file_name)
+    target_path = Path(environment.palm.project_root, ".palm", file_name)
 
     if target_path.exists():
-        click.secho('Command already exists in project, skipping', fg='red')
+        click.secho("Command already exists in project, skipping", fg="red")
         return
 
     if not target_path.parent.exists():
         click.secho(
             "palm is not initialized in this project, please run 'palm init' first",
-            fg='red',
+            fg="red",
         )
         return
 
     shutil.copy(origin_path, target_path)
-    click.secho(f"{name} command overridden to {target_path}", fg='green')
+    click.secho(f"{name} command overridden to {target_path}", fg="green")
diff --git a/palm/plugins/core/commands/cmd_plugin.py b/palm/plugins/core/commands/cmd_plugin.py
index 649102f..7a53f5c 100644
--- a/palm/plugins/core/commands/cmd_plugin.py
+++ b/palm/plugins/core/commands/cmd_plugin.py
@@ -1,5 +1,6 @@
-from typing import Optional
 from pathlib import Path
+from typing import Optional
+
 import click
 
 
@@ -14,8 +15,8 @@ def cli():
 @click.option(
     "--author-email", multiple=False, help="Email address of the plugin author"
 )
-@click.pass_context
-def new(ctx, name: str, author: Optional[str], author_email: Optional[str]):
+@click.pass_obj
+def new(environment, name: str, author: Optional[str], author_email: Optional[str]):
     """
     Generate a new plugin
     """
@@ -28,10 +29,11 @@ def new(ctx, name: str, author: Optional[str], author_email: Optional[str]):
     if name.startswith("palm-"):
         name = name[5:]
 
-    # TODO: Currently, this generator has to be run from withing an existing project
-    # This isn't ideal, but it works. In the future I'd like to move this command
-    # out of the core plugin and into a separate system-wide plugin.
-    default_target_dir = Path(Path.cwd().parent, f'palm-{name}')
+    # TODO: Currently, this generator has to be run from withing an existing
+    # project. This isn't ideal, but it works. In the future I'd like to move
+    # this command out of the core plugin and into a separate
+    # system-wide plugin.
+    default_target_dir = Path(Path.cwd().parent, f"palm-{name}")
     target_dir = click.prompt(
         "Where do you want to create the plugin?", default=default_target_dir
     )
@@ -46,22 +48,22 @@ def new(ctx, name: str, author: Optional[str], author_email: Optional[str]):
             return
         target_dir.mkdir(parents=True)
 
-    template_path = Path(Path(__file__).parents[1], "templates") / 'plugin'
+    template_path = Path(Path(__file__).parents[1], "templates") / "plugin"
     replacements = {
-        'plugin_name': name,
-        'plugin_class_name': f"{name.title().replace('_', '')}Plugin",
-        'author': author,
-        'author_email': author_email,
+        "plugin_name": name,
+        "plugin_class_name": f"{name.title().replace('_', '')}Plugin",
+        "author": author,
+        "author_email": author_email,
     }
 
-    ctx.obj.generate(template_path, target_dir, replacements)
-    click.secho(f'{name} plugin created in {target_dir}', fg='green')
+    environment.generate(template_path, target_dir, replacements)
+    click.secho(f"{name} plugin created in {target_dir}", fg="green")
 
 
 @cli.command()
 @click.option("--name", multiple=False, help="Name of the plugin")
-@click.pass_context
-def versions(ctx, name: Optional[str]):
+@click.pass_obj
+def versions(environment, name: Optional[str]):
     """
     Output plugin versions
 
@@ -70,14 +72,15 @@ def versions(ctx, name: Optional[str]):
 
     if name:
         try:
-            plugins = [ctx.obj.plugin_manager.plugins[name]]
+            plugins = [environment.plugin_manager.plugins[name]]
         except KeyError:
-            click.secho(f"Plugin {name} not installed in this project", fg='red')
+            click.secho(f"Plugin {name} not installed in this project", fg="red")
             return
     else:
-        plugins = list(ctx.obj.plugin_manager.plugins.values())
+        plugins = list(environment.plugin_manager.plugins.values())
 
-    # Do not display core or repo 'plugins' since those are versioned with palm core
+    # Do not display core or repo 'plugins'
+    # since those are versioned with palm core
     excluded_plugins = ["core", "repo"]
     plugins = [p for p in plugins if p.name not in excluded_plugins]
 
@@ -91,8 +94,8 @@ def versions(ctx, name: Optional[str]):
 
 @cli.command()
 @click.option("--name", multiple=False, required=True, help="Name of the plugin")
-@click.pass_context
-def update(ctx, name: Optional[str]):
+@click.pass_obj
+def update(environment, name: Optional[str]):
     """
     Update a plugin
 
@@ -100,17 +103,39 @@ def update(ctx, name: Optional[str]):
     """
     excluded_plugins = ["core", "repo"]
     if name in excluded_plugins:
-        click.secho(f"Plugin {name} is a core plugin and cannot be updated", fg='red')
+        click.secho(f"Plugin {name} is a core plugin and cannot be updated", fg="red")
 
     try:
-        plugin = ctx.obj.plugin_manager.plugins[name]
+        plugin = environment.plugin_manager.plugins[name]
     except KeyError:
-        click.secho(f"Plugin {name} not installed in this project", fg='red')
+        click.secho(f"Plugin {name} not installed in this project", fg="red")
         return
 
     click.echo(f"Updating {plugin.name}...")
     success, message = plugin.update()
     if success:
-        click.secho(f"Plugin {plugin.name} updated successfully", fg='green')
+        click.secho(f"Plugin {plugin.name} updated successfully", fg="green")
+    else:
+        click.secho(f"Plugin {plugin.name} update failed: {message}", fg="red")
+
+
+@cli.command()
+@click.option("--name", multiple=False, required=True, help="Name of the plugin")
+@click.pass_obj
+def configure(environment, name: Optional[str]):
+    """
+    Update a plugin
+
+    If the name option is provided, update the specified plugin
+    """
+    try:
+        plugin = environment.plugin_manager.plugins[name]
+    except KeyError:
+        click.secho(f"Plugin {name} not installed in this project", fg="red")
+        return
+
+    success = plugin.config.update()
+    if success:
+        click.secho(f"Plugin {plugin.name} configured successfully", fg="green")
     else:
-        click.secho(f"Plugin {plugin.name} update failed: {message}", fg='red')
+        click.secho(f"Plugin {plugin.name} configuration failed", fg="red")
diff --git a/palm/plugins/core/commands/cmd_scaffold.py b/palm/plugins/core/commands/cmd_scaffold.py
index fc3dec9..c122b89 100644
--- a/palm/plugins/core/commands/cmd_scaffold.py
+++ b/palm/plugins/core/commands/cmd_scaffold.py
@@ -1,12 +1,14 @@
-from typing import Optional, List
-import click
 from pathlib import Path
+from typing import List, Optional
+
+import click
+
 from palm.plugins.core.create_files import *
 
-palm_target_dir = f'{Path.cwd()}/.palm'
+palm_target_dir = f"{Path.cwd()}/.palm"
 
 
-@click.group(help='Scaffold new palm commands')
+@click.group(help="Scaffold new palm commands")
 def cli():
     pass
 
@@ -19,13 +21,13 @@ def cli():
     required=True,
     help="Name of the command(s) you are adding",
 )
-@click.pass_context
-def command(ctx, name: List[str]):
+@click.pass_obj
+def command(environment, name: List[str]):
     template_dir = Path(Path(__file__).parents[1], "templates") / 'command'
     """Add a new palm command to the current repo"""
     for command in name:
-        create_command(ctx, command, template_dir, palm_target_dir)
-        click.secho(f'{command} command created in {palm_target_dir}', fg='green')
+        create_command(environment, command, template_dir, palm_target_dir)
+        click.secho(f"{command} command created in {palm_target_dir}", fg="green")
 
 
 @cli.command()
@@ -39,20 +41,20 @@ def command(ctx, name: List[str]):
     required=True,
     help="Name of the commands within the command group",
 )
-@click.pass_context
-def group(ctx, group: str, command: List[str]):
+@click.pass_obj
+def group(environment, group: str, command: List[str]):
     """Add a new palm command group to the current repo"""
-    template_path = Path(Path(__file__).parents[1], "templates") / 'command_group'
+    template_path = Path(Path(__file__).parents[1], "templates") / "command_group"
     replacements = {
-        'group': group,
-        'commands': command,
+        "group": group,
+        "commands": command,
     }
 
-    ctx.obj.generate(template_path, palm_target_dir, replacements)
-    click.secho(f'{group} command group created in {palm_target_dir}', fg='green')
+    environment.generate(template_path, palm_target_dir, replacements)
+    click.secho(f"{group} command group created in {palm_target_dir}", fg="green")
 
 
-@cli.command('config')
+@cli.command("config")
 @click.option("-i", "--image-name", multiple=False, help="Name of your docker image")
 @click.option("-p", "--plugins", multiple=True, help="List of plugins you want to use")
 @click.option(
@@ -61,14 +63,14 @@ def group(ctx, group: str, command: List[str]):
     multiple=True,
     help="List of branches you do not want to run palm on",
 )
-@click.pass_context
+@click.pass_obj
 def config(
-    ctx,
+    environment,
     image_name: Optional[str],
     plugins: Optional[tuple],
     protected_branches: Optional[tuple],
 ):
     """Generate a base .palm/config for existing projects"""
-    image_name = image_name or ctx.obj.palm.image_name
+    image_name = image_name or environment.palm.image_name
     create_config(palm_target_dir, image_name, plugins, protected_branches)
-    click.secho('Palm config created!', fg='green')
+    click.secho("Palm config created!", fg="green")
diff --git a/palm/plugins/core/commands/cmd_shell.py b/palm/plugins/core/commands/cmd_shell.py
index 2e55141..91a8b7d 100644
--- a/palm/plugins/core/commands/cmd_shell.py
+++ b/palm/plugins/core/commands/cmd_shell.py
@@ -1,8 +1,8 @@
 import click
 
 
-@click.command('shell')
-@click.pass_context
-def cli(ctx):
+@click.command("shell")
+@click.pass_obj
+def cli(environment):
     """Bash into your docker image to run arbitrary commands"""
-    ctx.obj.run_in_docker('bash')
+    environment.run_in_docker("bash")
diff --git a/palm/plugins/core/commands/cmd_update.py b/palm/plugins/core/commands/cmd_update.py
index 7b97fcf..b28712d 100644
--- a/palm/plugins/core/commands/cmd_update.py
+++ b/palm/plugins/core/commands/cmd_update.py
@@ -1,5 +1,6 @@
-import subprocess
 import shlex
+import subprocess
+
 import click
 
 
diff --git a/palm/plugins/core/core_plugin.py b/palm/plugins/core/core_plugin.py
index 8234f3d..80cf767 100644
--- a/palm/plugins/core/core_plugin.py
+++ b/palm/plugins/core/core_plugin.py
@@ -1,7 +1,8 @@
 from pathlib import Path
+
 from palm.plugins.base import BasePlugin
 
 CorePlugin = BasePlugin(
-    name='core',
-    command_dir=Path(__file__).parent / 'commands',
+    name="core",
+    command_dir=Path(__file__).parent / "commands",
 )
diff --git a/palm/plugins/core/create_files.py b/palm/plugins/core/create_files.py
index 90772e5..bd49296 100644
--- a/palm/plugins/core/create_files.py
+++ b/palm/plugins/core/create_files.py
@@ -1,5 +1,6 @@
 from pathlib import Path
 from typing import List, Optional
+
 import yaml
 
 
@@ -9,22 +10,22 @@ def create_config(
     plugins: Optional[List] = [],
     protected_branches: Optional[List] = [],
 ):
-    config_path = f'{palm_dir}/config.yaml'
+    config_path = f"{palm_dir}/config.yaml"
     base_config = {
-        'image_name': image_name,
-        'plugins': plugins,
-        'protected_branches': protected_branches,
+        "image_name": image_name,
+        "plugins": plugins,
+        "protected_branches": protected_branches,
     }
 
-    with open(config_path, 'w') as file:
+    with open(config_path, "w") as file:
         yaml.safe_dump(base_config, file)
 
 
-def create_command(ctx, command: str, template_dir: Path, target_dir: Path):
+def create_command(environment, command: str, template_dir: Path, target_dir: Path):
     """Creates a new cmd file from template"""
 
     replacements = {
-        'command': command,
+        "command": command,
     }
 
-    ctx.obj.generate(template_dir, target_dir, replacements)
+    environment.generate(template_dir, target_dir, replacements)
diff --git a/palm/plugins/core/templates/command/template-config.yaml b/palm/plugins/core/templates/command/template-config.yaml
index dd656b8..34be41e 100644
--- a/palm/plugins/core/templates/command/template-config.yaml
+++ b/palm/plugins/core/templates/command/template-config.yaml
@@ -1,2 +1,2 @@
 files:
-    - new_cmd.tpl.py: 'cmd_{{command}}.py'
\ No newline at end of file
+  - new_cmd.tpl.py: 'cmd_{{command}}.py'
diff --git a/palm/plugins/core/templates/command_group/template-config.yaml b/palm/plugins/core/templates/command_group/template-config.yaml
index 2baad74..59b96a3 100644
--- a/palm/plugins/core/templates/command_group/template-config.yaml
+++ b/palm/plugins/core/templates/command_group/template-config.yaml
@@ -1,2 +1,2 @@
 files:
-    - new_cmd_group.tpl.py: 'cmd_{{group}}.py'
\ No newline at end of file
+  - new_cmd_group.tpl.py: 'cmd_{{group}}.py'
diff --git a/palm/plugins/core/templates/containerize/docker-compose.yaml b/palm/plugins/core/templates/containerize/docker-compose.yaml
index b453e77..0bb3896 100644
--- a/palm/plugins/core/templates/containerize/docker-compose.yaml
+++ b/palm/plugins/core/templates/containerize/docker-compose.yaml
@@ -8,4 +8,4 @@ services:
     volumes:
       - ./:/app
     env_file:
-      - .env
\ No newline at end of file
+      - .env
diff --git a/palm/plugins/core/templates/containerize/template-config.yaml b/palm/plugins/core/templates/containerize/template-config.yaml
index a734cc0..fabd8c0 100644
--- a/palm/plugins/core/templates/containerize/template-config.yaml
+++ b/palm/plugins/core/templates/containerize/template-config.yaml
@@ -3,4 +3,4 @@ directories:
 files:
   - Dockerfile.txt: "Dockerfile"
   - docker-compose.yaml: "docker-compose.yaml"
-  - entrypoint.sh.txt: "scripts/entrypoint.sh"
\ No newline at end of file
+  - entrypoint.sh.txt: "scripts/entrypoint.sh"
diff --git a/palm/plugins/core/templates/plugin/template-config.yaml b/palm/plugins/core/templates/plugin/template-config.yaml
index 1107395..c21c19f 100644
--- a/palm/plugins/core/templates/plugin/template-config.yaml
+++ b/palm/plugins/core/templates/plugin/template-config.yaml
@@ -1,11 +1,11 @@
 directories:
-- palm
-- palm/plugins
-- palm/plugins/{{plugin_name}}
-- palm/plugins/{{plugin_name}}/commands
+  - palm
+  - palm/plugins
+  - palm/plugins/{{plugin_name}}
+  - palm/plugins/{{plugin_name}}/commands
 files:
-    - plugin_init.tpl.py: 'palm/plugins/{{plugin_name}}/__init__.py'
-    - plugin_template.tpl.py: 'palm/plugins/{{plugin_name}}/{{plugin_name}}_plugin.py'
-    - empty_file.py: 'palm/plugins/{{plugin_name}}/commands/__init__.py'
-    - empty_file.py: 'README.md'
-    - setup.tpl.py: 'setup.py'
\ No newline at end of file
+  - plugin_init.tpl.py: 'palm/plugins/{{plugin_name}}/__init__.py'
+  - plugin_template.tpl.py: 'palm/plugins/{{plugin_name}}/{{plugin_name}}_plugin.py'
+  - empty_file.py: 'palm/plugins/{{plugin_name}}/commands/__init__.py'
+  - empty_file.py: 'README.md'
+  - setup.tpl.py: 'setup.py'
diff --git a/palm/plugins/repo/repo_plugin.py b/palm/plugins/repo/repo_plugin.py
index 4ab89c7..185b2d1 100644
--- a/palm/plugins/repo/repo_plugin.py
+++ b/palm/plugins/repo/repo_plugin.py
@@ -1,7 +1,8 @@
 from pathlib import Path
+
 from palm.plugins.base import BasePlugin
 
 RepoPlugin = BasePlugin(
-    name='repo',
-    command_dir=Path.cwd() / '.palm',
+    name="repo",
+    command_dir=Path.cwd() / ".palm",
 )
diff --git a/palm/plugins/setup/__init__.py b/palm/plugins/setup/__init__.py
new file mode 100644
index 0000000..09e9acf
--- /dev/null
+++ b/palm/plugins/setup/__init__.py
@@ -0,0 +1,3 @@
+from .setup_plugin import SetupPlugin
+
+Plugin = SetupPlugin
diff --git a/palm/plugins/setup/commands/cmd_new.py b/palm/plugins/setup/commands/cmd_new.py
new file mode 100644
index 0000000..0b12e1f
--- /dev/null
+++ b/palm/plugins/setup/commands/cmd_new.py
@@ -0,0 +1,56 @@
+import click
+import sys
+from pathlib import Path
+
+
+@click.command("new")
+@click.option(
+    '--global-template', '-g', help='Name of a global cookecutter template to use'
+)
+@click.option('--template', '-t', help='Cookie cutter template to use')
+@click.option('--list', '-l', is_flag=True, help='List global cookiecutter templates')
+@click.pass_obj
+def cli(environment, global_template: str, template: str, list: bool = False):
+    """
+    Create a new project with cookiecutter.
+
+    Cookiecutter is a command-line utility that creates projects from
+    cookiecutters (project templates).
+
+    This command will create a new project, in a new directory. You should
+    run this command from the directory where you want the new project to
+    be created.
+
+    Global templates are defined in your global palm config file.
+    See the docs for more information:
+    https://palm-cli.readthedocs.io/en/latest/code-generation.html#new-projects-with-cookiecutter
+    """
+    if list:
+        list_templates(environment)
+        sys.exit(0)
+
+    if global_template:
+        default_cookiecutters = environment.palm.config.get('default_cookiecutters', {})
+        template = default_cookiecutters.get(global_template)
+
+    if not template:
+        click.secho(f'No template defined for type {global_template}', fg='red')
+        template = click.prompt('Please enter a cookiecutter template url')
+
+    click.secho(f'Creating new {template} project', fg='green')
+
+    ex_code, _, _ = environment.run_on_host(f'cookiecutter {template}')
+
+    if ex_code == 0:
+        click.secho(f'Project created!', fg='green')
+    else:
+        click.secho(f'Failed to create project', fg='red')
+        sys.exit(1)
+
+
+def list_templates(environment):
+    """List global cookiecutter templates"""
+    default_cookiecutters = environment.palm.config.get('default_cookiecutters', {})
+    click.secho('Global cookiecutter templates:', fg='green')
+    for key in default_cookiecutters.keys():
+        click.echo(f'  {key}: {default_cookiecutters[key]}')
diff --git a/palm/plugins/setup/setup_plugin.py b/palm/plugins/setup/setup_plugin.py
new file mode 100644
index 0000000..064f7db
--- /dev/null
+++ b/palm/plugins/setup/setup_plugin.py
@@ -0,0 +1,7 @@
+from pathlib import Path
+from palm.plugins.base import BasePlugin
+
+SetupPlugin = BasePlugin(
+    name='setup',
+    command_dir=Path(__file__).parent / 'commands',
+)
diff --git a/palm/project_setup_utils.py b/palm/project_setup_utils.py
index 3006fdb..88fb6f0 100644
--- a/palm/project_setup_utils.py
+++ b/palm/project_setup_utils.py
@@ -1,6 +1,8 @@
 import os
 from pathlib import Path
+
 import click
+
 from palm.palm_exceptions import AbortPalm
 
 
diff --git a/palm/utils.py b/palm/utils.py
index 83aefd0..15d6a9e 100644
--- a/palm/utils.py
+++ b/palm/utils.py
@@ -1,6 +1,6 @@
-from typing import Optional, Tuple
 import subprocess
 from sys import version_info
+from typing import Optional, Tuple
 
 
 class UnsupportedVersion(Exception):
@@ -29,7 +29,7 @@ def run_on_host(
      - errors in the shell are NOT bubbled up by default
     Args:
         cmd: the command to run.
-        check: if True raise a `CalledProcessError<https://docs.python.org/3.7/library/subprocess.html#subprocess.CalledProcessError>`_
+        check: if True raise a CalledProcessError
         capture_output: if True, stdout and stderr will be suppressed
     Returns:
         Tuple: status code, stdout, stderr
diff --git a/requirements.txt b/requirements.txt
index ce234dd..243e9bc 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,4 +2,6 @@ pygit2 >= 1.5
 pyyaml >= 5.0, < 5.5
 click >=8.1, <9.0
 jinja2 >= 3.0
-deepmerge >=1.0.0
\ No newline at end of file
+deepmerge >=1.0.0
+cookiecutter >= 2.0
+pydantic >= 1.9
\ No newline at end of file
diff --git a/setup.py b/setup.py
index 549caad..e791619 100644
--- a/setup.py
+++ b/setup.py
@@ -1,6 +1,6 @@
+import sys
 from distutils.core import setup
 from pathlib import Path
-import sys
 
 # require version of setuptools that supports find_namespace_packages
 from setuptools import setup
@@ -9,7 +9,7 @@
     from setuptools import find_namespace_packages
 except ImportError:
     # the user has a downlevel version of setuptools.
-    print('Error: palm requires setuptools v40.1.0 or higher.')
+    print("Error: palm requires setuptools v40.1.0 or higher.")
     print(
         'Please upgrade setuptools with "pip install --upgrade setuptools" and try again'
     )
@@ -17,28 +17,28 @@
 
 # User-friendly description from README.md
 this_directory = Path(__file__).parent
-long_description = Path(this_directory, 'README.md').read_text()
+long_description = Path(this_directory, "README.md").read_text()
 
 setup(
-    name='palm',
-    version='2.4.1',  # Don't forget to bump the version in docs/source/conf.py too!
-    description='Palm CLI',
+    name="palm",
+    version="2.5.0",  # Don't forget to bump the version in docs/source/conf.py too!
+    description="Palm CLI",
     long_description=long_description,
-    long_description_content_type='text/markdown',
-    author='Palmetto - Data & Analytics team',
-    author_email='data-analytics-team@palmetto.com',
-    url='https://github.com/palmetto/palm-cli',
+    long_description_content_type="text/markdown",
+    author="Palmetto - Data & Analytics team",
+    author_email="data-analytics-team@palmetto.com",
+    url="https://github.com/palmetto/palm-cli",
     # Packages to include into the distribution
-    packages=find_namespace_packages(include=['palm', 'palm*']),
-    package_data={'': ['*.yaml', '*.txt']},
-    entry_points='''
+    packages=find_namespace_packages(include=["palm", "palm*"]),
+    package_data={"": ["*.yaml", "*.txt"]},
+    entry_points="""
     [console_scripts]
     palm=palm.cli:cli
-  ''',
-    license='Apache License 2.0',
+  """,
+    license="Apache License 2.0",
     install_requires=Path("requirements.txt").read_text().splitlines(),
     classifiers=[
-        'Development Status :: 4 - Beta',
-        'License :: OSI Approved :: Apache Software License',
+        "Development Status :: 4 - Beta",
+        "License :: OSI Approved :: Apache Software License",
     ],
 )


Test patch:
diff --git a/palm/plugins/core/commands/cmd_test.py b/palm/plugins/core/commands/cmd_test.py
index 97c8b5b..150de9f 100644
--- a/palm/plugins/core/commands/cmd_test.py
+++ b/palm/plugins/core/commands/cmd_test.py
@@ -1,9 +1,9 @@
 import click
 
 
-@click.command('test')
-@click.pass_context
-def cli(ctx):
+@click.command("test")
+@click.pass_obj
+def cli(environment):
     """Run tests for your application (pytest)"""
     click.echo("test command running!")
-    ctx.obj.run_in_docker('pytest', {'PALM_TEST': True})
+    environment.run_in_docker("pytest", {"PALM_TEST": True})
diff --git a/palm/plugins/test_internal/commands/cmd_run.py b/palm/plugins/test_internal/commands/cmd_run.py
index cac3e2d..65f3228 100644
--- a/palm/plugins/test_internal/commands/cmd_run.py
+++ b/palm/plugins/test_internal/commands/cmd_run.py
@@ -4,4 +4,4 @@
 @click.command("run")
 def cli():
     """no-op command used for testing only..."""
-    click.secho('completed', fg='green')
+    click.secho("completed", fg="green")
diff --git a/palm/plugins/test_internal/test_internal_plugin.py b/palm/plugins/test_internal/test_internal_plugin.py
index 67f1e39..387a281 100644
--- a/palm/plugins/test_internal/test_internal_plugin.py
+++ b/palm/plugins/test_internal/test_internal_plugin.py
@@ -1,10 +1,11 @@
 from pathlib import Path
+
 from palm.plugins.base import BasePlugin
 
 # This plugin is intended for use in testing palm-cli.
 # It is not intended to be used in production.
 
 TestInternalPlugin = BasePlugin(
-    name='test_internal',
-    command_dir=Path(__file__).parent / 'commands',
+    name="test_internal",
+    command_dir=Path(__file__).parent / "commands",
 )
diff --git a/tests/conftest.py b/tests/conftest.py
index 499484f..35aa2d4 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,53 +1,59 @@
-import pytest
-from unittest import mock
+import shutil
+import sys
+import zipfile
 from pathlib import Path
-import yaml
+from unittest import mock
+from pydantic import BaseModel
+
 import pygit2
-import shutil
-from palm.plugins.base import BasePlugin
-from palm.plugin_manager import PluginManager
+import pytest
+import yaml
+
+
 from palm.environment import Environment
 from palm.palm_config import PalmConfig
-import sys
+from palm.plugin_manager import PluginManager
+from palm.plugins.base import BasePlugin
+from palm.plugins.base_plugin_config import BasePluginConfig
 
-sys.modules['palm.plugins.mock'] = mock.Mock()
+sys.modules["palm.plugins.mock"] = mock.Mock()
 
 
 def test_command():
     return """import click
 
 @click.command('foo')
-@click.pass_context
-def cli(ctx):
+@click.pass_obj
+def cli(environment):
     print('test')
 """
 
 
-def mock_plugin(name, path):
+def mock_plugin(name, path, config=None):
     plugin_dir = path / name
-    commands_dir = plugin_dir / 'commands'
+    commands_dir = plugin_dir / "commands"
     commands_dir.mkdir(parents=True)
-    with open(commands_dir / 'cmd_foo.py', 'w') as f:
+    with open(commands_dir / "cmd_foo.py", "w") as f:
         f.write(test_command())
 
-    return BasePlugin(name, commands_dir)
+    return BasePlugin(name, commands_dir, config=config)
 
 
 # TODO: parametrize the test_plugin fixture and remove second_test_plugin
 @pytest.fixture
 def test_plugin(tmp_path):
-    return mock_plugin('mock', tmp_path)
+    return mock_plugin("mock", tmp_path)
 
 
 @pytest.fixture
 def second_test_plugin(tmp_path):
-    return mock_plugin('mock_two', tmp_path)
+    return mock_plugin("mock_two", tmp_path)
 
 
 def mock_plugin_manager(tmp_path):
     pm = PluginManager()
-    pm.plugins = {'mock': mock_plugin('mock', tmp_path)}
-    pm.extend_plugin_command_mapping('mock')
+    pm.plugins = {"mock": mock_plugin("mock", tmp_path)}
+    pm.extend_plugin_command_mapping("mock")
 
     return pm
 
@@ -57,8 +63,26 @@ def plugin_manager(tmp_path):
     return mock_plugin_manager(tmp_path)
 
 
-def mock_current_branch(self):
-    return 'test'
+class TestConfigModel(BaseModel):
+    value: str
+
+
+class MockPluginConfig(BasePluginConfig):
+    def __init__(self, plugin_name, model, config):
+        self.config = config
+        super().__init__(plugin_name, model)
+
+    def set(self) -> dict:
+        return self.config
+
+
+@pytest.fixture
+def mock_plugin_config(
+    config: dict,
+    plugin_name: str = 'test',
+    model: BasePluginConfig = TestConfigModel,
+):
+    return MockPluginConfig(plugin_name, model, config)
 
 
 def mock_repository(tmp_path):
@@ -68,69 +92,77 @@ def __init__(self, name, tmp_path):
             self.tmp_path = tmp_path
 
         def __enter__(self):
-            path = Path(__file__).parent / 'data' / self.name
+            path = Path(__file__).parent / "data" / self.name
             temp_repo_path = Path(self.tmp_path) / path.stem
-            if path.suffix == '.git':
+            if path.suffix == '.zip':
+                with zipfile.ZipFile(path) as zipf:
+                    zipf.extractall(self.tmp_path)
+            elif path.suffix == ".git":
                 shutil.copytree(path, temp_repo_path)
             else:
-                raise ValueError(f'Unexpected {path.suffix} extension')
+                raise ValueError(f"Unexpected {path.suffix} extension")
 
             return temp_repo_path
 
         def __exit__(self, exc_type, exc_value, traceback):
             pass
 
-    with TemporaryRepository('testrepo.git', tmp_path) as path:
-        yield pygit2.Repository(path)
+    # Note barerepo head is set to test branch
+    with TemporaryRepository("barerepo.zip", tmp_path) as path:
+        return pygit2.Repository(path)
 
 
 def write_config_to_path(path, config):
     path.parent.mkdir(parents=True)
-    with open(path, 'w') as f:
+    with open(path, "w") as f:
         yaml.dump(config, f)
 
 
 # TODO: parametrize the palm_config fixture for different configs
 @pytest.fixture
 def no_palm_config(tmp_path, monkeypatch):
-    monkeypatch.setattr(PalmConfig, '_get_repo', mock_repository)
-    monkeypatch.setattr(PalmConfig, '_get_current_branch', mock_current_branch)
+    monkeypatch.setattr(PalmConfig, "_get_repo", lambda self: mock_repository(tmp_path))
+
+    return PalmConfig(Path(tmp_path))
+
+
+@pytest.fixture
+def no_repo_palm_config(tmp_path, monkeypatch):
+    monkeypatch.setattr(PalmConfig, '_get_repo', lambda self: None)
 
     return PalmConfig(Path(tmp_path))
 
 
 @pytest.fixture
 def palm_config(tmp_path, monkeypatch):
-    palm_config_path = tmp_path / '.palm' / 'config.yaml'
+    palm_config_path = tmp_path / ".palm" / "config.yaml"
     mock_config = {
-        'image_name': 'palm-test',
-        'plugins': ['mock'],
-        'protected_branches': ['main'],
+        "image_name": "palm-test",
+        "plugins": ["mock"],
+        "protected_branches": ["main"],
     }
     write_config_to_path(palm_config_path, mock_config)
-    monkeypatch.setattr(PalmConfig, '_get_repo', mock_repository)
-    monkeypatch.setattr(PalmConfig, '_get_current_branch', mock_current_branch)
+    monkeypatch.setattr(PalmConfig, "_get_repo", lambda self: mock_repository(tmp_path))
     return PalmConfig(Path(tmp_path))
 
 
 @pytest.fixture
 def palm_config_protected(tmp_path, monkeypatch):
-    palm_config_path = tmp_path / '.palm' / 'config.yaml'
-    mock_config = {'image_name': '', 'plugins': [], 'protected_branches': ['main']}
+    palm_config_path = tmp_path / ".palm" / "config.yaml"
+    mock_config = {"image_name": "", "plugins": [], "protected_branches": ["main"]}
     write_config_to_path(palm_config_path, mock_config)
 
     def mock_current_branch(self):
-        return 'main'
+        return "main"
 
-    monkeypatch.setattr(PalmConfig, '_get_repo', mock_repository)
-    monkeypatch.setattr(PalmConfig, '_get_current_branch', mock_current_branch)
+    monkeypatch.setattr(PalmConfig, "_get_repo", lambda self: mock_repository(tmp_path))
+    monkeypatch.setattr(PalmConfig, "_get_current_branch", mock_current_branch)
     return PalmConfig(Path(tmp_path))
 
 
 @pytest.fixture
 def environment(tmp_path, monkeypatch):
-    monkeypatch.setattr(PalmConfig, '_get_repo', mock_repository)
-    monkeypatch.setattr(PalmConfig, '_get_current_branch', mock_current_branch)
+    monkeypatch.setattr(PalmConfig, "_get_repo", lambda self: mock_repository(tmp_path))
     pm = mock_plugin_manager(tmp_path)
     config = PalmConfig(Path(tmp_path))
     return Environment(pm, config)
diff --git a/tests/data/barerepo.zip b/tests/data/barerepo.zip
new file mode 100644
index 0000000..6a716ae
Binary files /dev/null and b/tests/data/barerepo.zip differ
diff --git a/tests/unit/test_base_plugin.py b/tests/unit/test_base_plugin.py
index 03c5141..3bd1b67 100644
--- a/tests/unit/test_base_plugin.py
+++ b/tests/unit/test_base_plugin.py
@@ -2,18 +2,18 @@
 
 
 def test_all_commands(test_plugin):
-    assert test_plugin.all_commands() == ['foo']
+    assert test_plugin.all_commands() == ["foo"]
 
 
 def test_get_command(test_plugin):
-    result = test_plugin.get_command('foo')
+    result = test_plugin.get_command("foo")
 
     assert isinstance(result, ModuleSpec)
-    assert result.name == 'foo'
+    assert result.name == "foo"
 
 
 def test_command_map(test_plugin):
     result = test_plugin.command_map()
 
     assert len(result.keys()) == 1
-    assert result['foo'] == 'mock'
+    assert result["foo"] == "mock"
diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py
index b9c5fd2..3af1cda 100644
--- a/tests/unit/test_cli.py
+++ b/tests/unit/test_cli.py
@@ -10,7 +10,7 @@ def use_palm_dir():
     """Palm runner with a .palm directory"""
     runner = CliRunner()
     with runner.isolated_filesystem():
-        Path('.palm').mkdir()
+        Path(".palm").mkdir()
 
         yield runner
 
@@ -25,12 +25,12 @@ def use_empty_dir():
 def test_cli_has_core_and_repo_plugins_by_default():
     """Test no-op run command"""
     plugins = cli.plugin_manager.plugins
-    assert list(plugins.keys()) == ['core', 'repo']
+    assert list(plugins.keys()) == ["core", "repo"]
 
 
 def test_run_command():
     """Test no-op run command"""
-    cli.plugin_manager.load_plugin('test_internal')
+    cli.plugin_manager.load_plugin("test_internal")
     result = CliRunner().invoke(cli, ["run"])
     assert result.exit_code == 0
     assert "completed" in result.output
@@ -43,7 +43,7 @@ def test_run_command():
 def test_command_from_palm_dir(use_palm_dir):
     runner = use_palm_dir
     result = runner.invoke(cli, ["test"])
-    assert Path('.palm', 'cmd_test.py').exists()
+    assert Path(".palm", "cmd_test.py").exists()
     assert result.exit_code == 0
     assert "test command running!" in result.output
 
@@ -56,12 +56,12 @@ def test_palm_init(use_empty_dir):
     result = runner.invoke(cli, ["init", "--commands", "run", "-c", "test"])
     assert result.exit_code == 0
     assert "Success! Project initialized with Palm CLI" in result.output
-    assert Path('.palm').exists()
-    assert Path('.palm').is_dir()
+    assert Path(".palm").exists()
+    assert Path(".palm").is_dir()
     assert "Adding template for run" in result.output
     assert "Adding template for test" in result.output
-    assert Path('.palm', 'cmd_run.py').exists()
-    assert Path('.palm', 'cmd_test.py').exists()
+    assert Path(".palm", "cmd_run.py").exists()
+    assert Path(".palm", "cmd_test.py").exists()
 
 
 # Palm Scaffold tests
@@ -72,7 +72,7 @@ def test_scaffold_new_command(use_palm_dir):
     result = runner.invoke(cli, ["scaffold", "command", "--name", "test"])
     assert result.exit_code == 0
     assert "test command created in" in result.output
-    target_path = Path('.palm', 'cmd_test.py')
+    target_path = Path(".palm", "cmd_test.py")
     assert target_path.exists()
     assert "@click.command('test')" in target_path.read_text()
 
@@ -84,7 +84,7 @@ def test_scaffold_new_command_group(use_palm_dir):
     )
     assert result.exit_code == 0
     assert f"groupname command group created in" in result.output
-    target_path = Path('.palm', 'cmd_groupname.py')
+    target_path = Path(".palm", "cmd_groupname.py")
     assert target_path.exists()
     assert "@click.group()" in target_path.read_text()
     assert "def test(environment):" in target_path.read_text()
@@ -109,11 +109,11 @@ def test_scaffold_config(use_palm_dir):
             "main",
         ],
     )
-    config_path = Path('.palm', 'config.yaml')
+    config_path = Path(".palm", "config.yaml")
     assert result.exit_code == 0
     assert f"Palm config created!" in result.output
     assert config_path.exists()
     config = yaml.safe_load(config_path.read_text())
-    assert config['image_name'] == 'testing'
-    assert config['plugins'] == ['test', 'test-two']
-    assert config['protected_branches'] == ['master', 'main']
+    assert config["image_name"] == "testing"
+    assert config["plugins"] == ["test", "test-two"]
+    assert config["protected_branches"] == ["master", "main"]
diff --git a/tests/unit/test_code_generator.py b/tests/unit/test_code_generator.py
index 4a40d89..44ad8d0 100644
--- a/tests/unit/test_code_generator.py
+++ b/tests/unit/test_code_generator.py
@@ -1,12 +1,13 @@
-from palm.code_generator import CodeGenerator
 import yaml
 
+from palm.code_generator import CodeGenerator
+
 
 def setup_templates(path):
-    template_path = path / 'templates'
+    template_path = path / "templates"
     template_path.mkdir(parents=True)
 
-    with open(template_path / 'template.py', 'w') as f:
+    with open(template_path / "template.py", "w") as f:
         f.write(
             """def foo():
             print(f'Hello {{name}}')
@@ -14,13 +15,13 @@ def setup_templates(path):
         )
 
     template_config = {
-        'directories': ['{{dirname}}'],
-        'files': [{'template.py': '{{dirname}}/generated_{{filename}}.py'}],
+        "directories": ["{{dirname}}"],
+        "files": [{"template.py": "{{dirname}}/generated_{{filename}}.py"}],
     }
-    with open(template_path / 'template-config.yaml', 'w') as f:
+    with open(template_path / "template-config.yaml", "w") as f:
         yaml.dump(template_config, f)
 
-    target_path = path / 'target'
+    target_path = path / "target"
     target_path.mkdir(parents=True)
 
     return template_path, target_path
@@ -31,19 +32,19 @@ def test_get_config(tmp_path):
     codegen = CodeGenerator(template_path, target_path, {})
     config = codegen.get_config()
 
-    assert config['directories'] == ['{{dirname}}']
-    assert type(config['files']) == list
-    assert config['files'][0] == {
-        'template.py': '{{dirname}}/generated_{{filename}}.py'
+    assert config["directories"] == ["{{dirname}}"]
+    assert type(config["files"]) == list
+    assert config["files"][0] == {
+        "template.py": "{{dirname}}/generated_{{filename}}.py"
     }
 
 
 def test_run_generator(tmp_path):
     template_path, target_path = setup_templates(tmp_path)
-    replacements = {'name': 'Murphy Moulds', 'dirname': 'da_team', 'filename': 'murphy'}
+    replacements = {"name": "Murphy Moulds", "dirname": "da_team", "filename": "murphy"}
     codegen = CodeGenerator(template_path, target_path, replacements)
     result = codegen.run()
-    expected_file = target_path / 'da_team' / 'generated_murphy.py'
+    expected_file = target_path / "da_team" / "generated_murphy.py"
 
     assert result == "Generated successfully"
     assert expected_file.exists()
diff --git a/tests/unit/test_containerizer.py b/tests/unit/test_containerizer.py
index 815846f..d5b090f 100644
--- a/tests/unit/test_containerizer.py
+++ b/tests/unit/test_containerizer.py
@@ -1,5 +1,6 @@
 import os
 from pathlib import Path
+
 from palm.containerizer import Containerizer
 
 
@@ -16,7 +17,7 @@ def test_project_name_matches_palm_config_image_name(tmp_path, environment):
     ctx = MockContext(obj=environment)
     c = Containerizer(ctx, tmp_path)
 
-    assert c.project_name == 'test_project_name_matches_palm0'
+    assert c.project_name == "test_project_name_matches_palm0"
 
 
 def test_check_has_containerization(tmp_path, environment):
@@ -25,7 +26,7 @@ def test_check_has_containerization(tmp_path, environment):
     result = Containerizer(ctx, tmp_path).has_containerization()
 
     assert result == False
-    Path('Dockerfile').touch()
+    Path("Dockerfile").touch()
     result = Containerizer(ctx, tmp_path).has_containerization()
     assert result
 
@@ -36,31 +37,31 @@ def test_has_env(tmp_path, environment):
     result = Containerizer(ctx, tmp_path).has_env()
     assert result == False
 
-    Path('.env').touch()
+    Path(".env").touch()
     result = Containerizer(ctx, tmp_path).has_env()
     assert result
 
 
 def test_generate(tmp_path, environment):
     templates_dir = (
-        Path(__file__).parents[2] / 'palm/plugins/core/templates/containerize'
+        Path(__file__).parents[2] / "palm/plugins/core/templates/containerize"
     )
     ctx = MockContext(obj=environment)
     c = Containerizer(ctx, templates_dir)
     c.generate(tmp_path, {})
 
-    assert Path(tmp_path, 'Dockerfile').exists()
-    assert Path(tmp_path, 'docker-compose.yaml').exists()
-    assert Path(tmp_path, 'scripts/entrypoint.sh').exists()
+    assert Path(tmp_path, "Dockerfile").exists()
+    assert Path(tmp_path, "docker-compose.yaml").exists()
+    assert Path(tmp_path, "scripts/entrypoint.sh").exists()
 
 
 def test_generated_entrypoint_is_executable(tmp_path, environment):
     templates_dir = (
-        Path(__file__).parents[2] / 'palm/plugins/core/templates/containerize'
+        Path(__file__).parents[2] / "palm/plugins/core/templates/containerize"
     )
     ctx = MockContext(obj=environment)
     c = Containerizer(ctx, templates_dir)
     c.generate(tmp_path, {})
 
-    entrypoint = Path(tmp_path, 'scripts/entrypoint.sh')
+    entrypoint = Path(tmp_path, "scripts/entrypoint.sh")
     assert os.access(entrypoint, os.X_OK)
diff --git a/tests/unit/test_core_plugin.py b/tests/unit/test_core_plugin.py
index 92f7bd6..ee2fcc1 100644
--- a/tests/unit/test_core_plugin.py
+++ b/tests/unit/test_core_plugin.py
@@ -1,6 +1,7 @@
 from pathlib import Path
-from palm.plugins.base import BasePlugin
+
 import palm.plugins.core as core_plugin
+from palm.plugins.base import BasePlugin
 
 
 def test_plugin_defined():
@@ -12,7 +13,7 @@ def test_plugin_is_instance_of_base_plugin():
 
 
 def test_plugin_name_is_core():
-    assert core_plugin.Plugin.name == 'core'
+    assert core_plugin.Plugin.name == "core"
 
 
 def test_core_plugin_command_dir():
@@ -20,17 +21,17 @@ def test_core_plugin_command_dir():
     assert isinstance(cmd_dir, Path)
     assert cmd_dir.exists()
     assert cmd_dir.is_dir()
-    assert cmd_dir.name == 'commands'
+    assert cmd_dir.name == "commands"
 
 
 def test_core_plugin_commands():
     cmds = core_plugin.Plugin.all_commands()
     assert isinstance(cmds, list)
     assert len(cmds) > 0
-    assert 'build' in cmds
-    assert 'plugin' in cmds
-    assert 'scaffold' in cmds
-    assert 'update' in cmds
+    assert "build" in cmds
+    assert "plugin" in cmds
+    assert "scaffold" in cmds
+    assert "update" in cmds
 
 
 def test_core_plugin_command_map():
@@ -38,4 +39,4 @@ def test_core_plugin_command_map():
     assert isinstance(cmd_map, dict)
     assert len(cmd_map.values()) > 0
     for val in cmd_map.values():
-        assert val == 'core'
+        assert val == "core"
diff --git a/tests/unit/test_create_files.py b/tests/unit/test_create_files.py
index e8db20c..7244a96 100644
--- a/tests/unit/test_create_files.py
+++ b/tests/unit/test_create_files.py
@@ -1,5 +1,7 @@
-import yaml
 from pathlib import Path
+
+import yaml
+
 from palm.plugins.core.create_files import *
 
 
@@ -9,31 +11,31 @@ def __init__(self, **kwargs):
 
 
 def test_create_config(tmp_path):
-    create_config(tmp_path, 'test-image', ['test'], ['main'])
-    result = yaml.safe_load(Path(tmp_path, 'config.yaml').read_text())
+    create_config(tmp_path, "test-image", ["test"], ["main"])
+    result = yaml.safe_load(Path(tmp_path, "config.yaml").read_text())
 
-    assert result['image_name'] == 'test-image'
-    assert result['plugins'] == ['test']
-    assert result['protected_branches'] == ['main']
+    assert result["image_name"] == "test-image"
+    assert result["plugins"] == ["test"]
+    assert result["protected_branches"] == ["main"]
 
 
 def test_create_command(tmp_path, environment):
-    target_dir = Path(tmp_path / 'target')
+    target_dir = Path(tmp_path / "target")
     target_dir.mkdir()
-    template_dir = Path(tmp_path / 'templates')
+    template_dir = Path(tmp_path / "templates")
     template_dir.mkdir()
-    with open(template_dir / 'new_cmd.tpl.py', 'a') as f:
-        f.write('{{command}}')
-    template_config = {'files': [{'new_cmd.tpl.py': 'cmd_{{command}}.py'}]}
-    with open(template_dir / 'template-config.yaml', 'a') as f:
+    with open(template_dir / "new_cmd.tpl.py", "a") as f:
+        f.write("{{command}}")
+    template_config = {"files": [{"new_cmd.tpl.py": "cmd_{{command}}.py"}]}
+    with open(template_dir / "template-config.yaml", "a") as f:
         f.write(yaml.dump(template_config))
 
     ctx = MockContext(obj=environment)
 
-    create_command(ctx, 'testing', template_dir, target_dir)
+    create_command(ctx.obj, "testing", template_dir, target_dir)
 
-    cmd_file = Path(target_dir / 'cmd_testing.py')
+    cmd_file = Path(target_dir / "cmd_testing.py")
     assert cmd_file.exists()
 
     contents = cmd_file.read_text()
-    assert 'testing' == contents
+    assert "testing" == contents
diff --git a/tests/unit/test_environment.py b/tests/unit/test_environment.py
index 40ec068..413bee9 100644
--- a/tests/unit/test_environment.py
+++ b/tests/unit/test_environment.py
@@ -1,40 +1,43 @@
-import yaml
-from pathlib import Path
+from unittest import mock
 import subprocess
+from pathlib import Path
+
+import yaml
+
 from palm.code_generator import CodeGenerator
 
 
 def test_run_in_docker(environment, monkeypatch):
     class MockCompletedProcess:
         returncode = 0
-        stdout = b'tested'
-        stderr = b''
+        stdout = b"tested"
+        stderr = b""
 
     monkeypatch.setattr(
-        subprocess, 'run', lambda *args, **kwargs: MockCompletedProcess()
+        subprocess, "run", lambda *args, **kwargs: MockCompletedProcess()
     )
-    success, msg = environment.run_in_docker('test')
+    success, msg = environment.run_in_docker("test")
     assert success is True
-    assert msg == 'Success! Palm completed with exit code 0'
+    assert msg == "Success! Palm completed with exit code 0"
 
 
 def test_import_module(environment):
-    module_name = 'mock_import'
-    module_path = Path(__file__).parent / 'mock_import.py'
+    module_name = "mock_import"
+    module_path = Path(__file__).parent / "mock_import.py"
     result = environment.import_module(module_name, module_path)
 
-    assert hasattr(result, 'main')
+    assert hasattr(result, "main")
     assert result.main() is True
 
 
 def test_generate_calls_code_generator_run(environment, monkeypatch, tmp_path):
-    monkeypatch.setattr(CodeGenerator, 'run', lambda x: True)
+    monkeypatch.setattr(CodeGenerator, "run", lambda x: True)
 
     target_path = tmp_path
-    template_path = tmp_path / 'templates'
+    template_path = tmp_path / "templates"
     template_path.mkdir()
     template_config = {}
-    with open(template_path / 'template-config.yaml', 'w') as f:
+    with open(template_path / "template-config.yaml", "w") as f:
         f.write(yaml.dump(template_config))
 
     replacements = {}
@@ -43,7 +46,12 @@ def test_generate_calls_code_generator_run(environment, monkeypatch, tmp_path):
 
 
 def test_build_env_vars(environment):
-    env_vars = {'palm_env': 'test'}
+    env_vars = {"palm_env": "test"}
     result = environment._build_env_vars(env_vars)
 
-    assert result == ['-e PALM_ENV=test']
+    assert result == ["-e PALM_ENV=test"]
+
+
+def test_get_plugin_config_no_config(environment):
+    result = environment.plugin_config("mock")
+    assert result == None
diff --git a/tests/unit/test_palm_cli.py b/tests/unit/test_palm_cli.py
index be6ac2d..09c6ac7 100644
--- a/tests/unit/test_palm_cli.py
+++ b/tests/unit/test_palm_cli.py
@@ -1,6 +1,8 @@
-import pytest
 from unittest import mock
+
+import pytest
 from click import HelpFormatter
+
 from palm.cli import PalmCLI
 
 
@@ -25,13 +27,13 @@ def get_short_help_str(self, limit):
 def test_format_commands_adds_headings_for_plugins(mock_help_formatter, monkeypatch):
     """Test command formatting"""
     PalmCLIInstance = PalmCLI()
-    monkeypatch.setattr(PalmCLIInstance, 'list_commands', lambda x: ['test'])
+    monkeypatch.setattr(PalmCLIInstance, "list_commands", lambda x: ["test"])
     m = mock.Mock()
     monkeypatch.setattr(mock_help_formatter, "write_heading", m)
     ctx = {}
     PalmCLIInstance.format_commands(ctx, mock_help_formatter)
     # Test command exists in the core plugin
-    m.assert_called_once_with('Core')
+    m.assert_called_once_with("Core")
 
 
 def test_format_commands_properly_outputs_command_help(
@@ -39,29 +41,29 @@ def test_format_commands_properly_outputs_command_help(
 ):
     """Test command formatting"""
     PalmCLIInstance = PalmCLI()
-    monkeypatch.setattr(PalmCLIInstance, 'list_commands', lambda x: ['test'])
+    monkeypatch.setattr(PalmCLIInstance, "list_commands", lambda x: ["test"])
     m = mock.Mock()
     monkeypatch.setattr(mock_help_formatter, "write_dl", m)
     ctx = {}
     PalmCLIInstance.format_commands(ctx, mock_help_formatter)
     m.assert_called()
-    expected_command_dl = ('test', 'Run tests for your application (pytest)')
+    expected_command_dl = ("test", "Run tests for your application (pytest)")
     m.assert_called_with([expected_command_dl])
 
 
 def test_format_commands_handles_multiple_groups(mock_help_formatter, monkeypatch):
     PalmCLIInstance = PalmCLI()
 
-    monkeypatch.setattr(PalmCLIInstance, 'list_commands', lambda x: ['test', 'foo'])
+    monkeypatch.setattr(PalmCLIInstance, "list_commands", lambda x: ["test", "foo"])
     monkeypatch.setattr(
-        PalmCLIInstance, 'get_command', lambda ctx, cmd_name: MockCommand(cmd_name)
+        PalmCLIInstance, "get_command", lambda ctx, cmd_name: MockCommand(cmd_name)
     )
     plugin_manager = PalmCLIInstance.plugin_manager
     mock_plugin_dict = {
-        'test': 'core',
-        'foo': 'bar',
+        "test": "core",
+        "foo": "bar",
     }
-    monkeypatch.setattr(plugin_manager, 'plugin_command_dict', mock_plugin_dict)
+    monkeypatch.setattr(plugin_manager, "plugin_command_dict", mock_plugin_dict)
 
     m = mock.Mock()
     monkeypatch.setattr(mock_help_formatter, "write_heading", m)
diff --git a/tests/unit/test_palm_config.py b/tests/unit/test_palm_config.py
index 17e65f9..bd1dd85 100644
--- a/tests/unit/test_palm_config.py
+++ b/tests/unit/test_palm_config.py
@@ -1,15 +1,14 @@
-import pytest
 import yaml
 
 # Test defaults - no configuration provided in no_palm_config fixture
 
 
 def test_image_name_default(no_palm_config):
-    assert no_palm_config.image_name == 'test_image_name_default0'
+    assert no_palm_config.image_name == "test_image_name_default0"
 
 
 def test_plugins_default(no_palm_config):
-    assert no_palm_config.plugins == ['core', 'repo']
+    assert no_palm_config.plugins == ["core", "repo"]
 
 
 # Configuration provided in palm_config fixture
@@ -20,38 +19,49 @@ def test_has_config(palm_config):
 
 
 def test_image_name_config(palm_config):
-    assert palm_config.image_name == 'palm-test'
+    assert palm_config.image_name == "palm-test"
 
 
 def test_plugins_installed(palm_config):
-    assert palm_config.plugins == ['core', 'mock', 'repo']
+    assert palm_config.plugins == ["core", "mock", "repo"]
 
 
 def test_reads_protected_branches(palm_config):
-    assert palm_config.protected_branches == ['main']
+    assert palm_config.protected_branches == ["main"]
 
 
-def test_raises_on_protected_branches(palm_config_protected):
-    with pytest.raises(SystemExit):
-        palm_config_protected.validate_branch()
+# Validate branches
+
+
+def test_validate_branch_returns_false_on_protected_branches(palm_config_protected):
+    result = palm_config_protected.is_valid_branch()
+    assert result is False
+
+
+def test_validate_branch_returns_true_when_no_repo(no_repo_palm_config):
+    assert no_repo_palm_config.is_valid_branch() is True
 
 
 # Global config
 
 
 def test_create_global_config_file(no_palm_config, tmp_path):
-    config_path = tmp_path / '.palm' / 'config.yaml'
+    config_path = tmp_path / ".palm" / "config.yaml"
     no_palm_config._create_global_config_file(config_path)
 
     assert config_path.exists()
-    global_config = yaml.load(config_path.read_text())
-    assert global_config.keys() == {'plugins', 'excluded_commands'}
+    global_config = yaml.safe_load(config_path.read_text())
+    assert global_config.keys() == {
+        'default_cookiecutters',
+        'plugins',
+        'excluded_commands',
+    }
 
 
 def test_get_global_config(no_palm_config, tmp_path):
-    config_path = tmp_path / '.palm' / 'config.yaml'
+    config_path = tmp_path / ".palm" / "config.yaml"
     no_palm_config._create_global_config_file(config_path)
-    test_config = {'plugins': ['foo'], 'excluded_commands': ['bar']}
+    test_config = {"plugins": ["foo"], "excluded_commands": ["bar"]}
     config_path.write_text(yaml.dump(test_config))
 
     result = no_palm_config._get_global_config(config_path)
@@ -59,10 +69,18 @@ def test_get_global_config(no_palm_config, tmp_path):
 
 
 def test_get_config_merges_repo_and_global(no_palm_config, monkeypatch):
-    repo_config = {'plugins': ['foo'], 'excluded_commands': ['bar']}
-    global_config = {'plugins': ['baz'], 'excluded_commands': ['qux']}
-    monkeypatch.setattr(no_palm_config, '_get_repo_config', lambda: repo_config)
-    monkeypatch.setattr(no_palm_config, '_get_global_config', lambda: global_config)
+    repo_config = {"plugins": ["foo"], "excluded_commands": ["bar"]}
+    global_config = {"plugins": ["baz"], "excluded_commands": ["qux"]}
+    monkeypatch.setattr(no_palm_config, "_get_repo_config", lambda: repo_config)
+    monkeypatch.setattr(no_palm_config, "_get_global_config", lambda: global_config)
 
     result = no_palm_config._get_config()
     assert result == {'plugins': ['baz', 'foo'], 'excluded_commands': ['qux', 'bar']}
+
+
+# Global plugins
+
+
+def test_global_plugins_are_loaded_without_repo(no_repo_palm_config, monkeypatch):
+    plugins = no_repo_palm_config.plugins
+    assert plugins == ['setup']
diff --git a/tests/unit/test_plugin_config.py b/tests/unit/test_plugin_config.py
new file mode 100644
index 0000000..058da55
--- /dev/null
+++ b/tests/unit/test_plugin_config.py
@@ -0,0 +1,44 @@
+import pytest
+import yaml
+
+from pathlib import Path
+from palm.palm_exceptions import InvalidConfigError
+
+
+@pytest.mark.parametrize("config", [{}])
+def test_base_config_init(mock_plugin_config):
+    config = mock_plugin_config
+    assert config.plugin_name == "test"
+    assert config.config_path == Path.cwd() / ".palm" / "config.yaml"
+
+
+@pytest.mark.parametrize("config", [{"value": "foo"}])
+def test_base_config_validation(mock_plugin_config):
+    config = mock_plugin_config
+    assert config.validate({"value": "test"}) == True
+
+    with pytest.raises(InvalidConfigError):
+        config.validate({})
+
+
+@pytest.mark.parametrize("config", [{}])
+def test_base_config_write(mock_plugin_config, tmp_path, monkeypatch):
+    config = mock_plugin_config
+    config_path = tmp_path / "config.yaml"
+    # Set some config to prevent error when writing
+    config_path.write_text(yaml.dump({'image_name': 'palm-test'}))
+    monkeypatch.setattr(config, "config_path", config_path)
+    config._write({"value": "foo"})
+
+    config = yaml.safe_load(config_path.read_text())
+    assert config["plugin_config"]["test"]["value"] == "foo"
+
+
+@pytest.mark.parametrize("config", [{}])
+def test_get_config(mock_plugin_config, tmp_path, monkeypatch):
+    config = mock_plugin_config
+    config_path = tmp_path / "config.yaml"
+    config_path.write_text(yaml.dump({'plugin_config': {'test': {'value': 'foo'}}}))
+    monkeypatch.setattr(config, "config_path", config_path)
+
+    assert config.get() == {"value": "foo"}
diff --git a/tests/unit/test_plugin_manager.py b/tests/unit/test_plugin_manager.py
index a3147a2..fbec523 100644
--- a/tests/unit/test_plugin_manager.py
+++ b/tests/unit/test_plugin_manager.py
@@ -1,46 +1,47 @@
-from unittest import mock
 from importlib.machinery import ModuleSpec
+from unittest import mock
+
 from palm.plugins.base import BasePlugin
 
 
 def test_load_plugins_calls_load_plugin(plugin_manager, monkeypatch):
     m = mock.Mock()
     monkeypatch.setattr(plugin_manager, "load_plugin", m)
-    plugin_manager.load_plugins(['one', 'two', 'three'])
+    plugin_manager.load_plugins(["one", "two", "three"])
     m.assert_called()
 
 
 def test_load_plugin(plugin_manager):
-    plugin_manager.load_plugin('test_internal')
+    plugin_manager.load_plugin("test_internal")
 
-    assert plugin_manager.plugins['test_internal']
-    assert isinstance(plugin_manager.plugins['test_internal'], BasePlugin)
-    assert plugin_manager.is_plugin_command('run')
-    assert plugin_manager.plugin_command_dict['run'] == 'test_internal'
+    assert plugin_manager.plugins["test_internal"]
+    assert isinstance(plugin_manager.plugins["test_internal"], BasePlugin)
+    assert plugin_manager.is_plugin_command("run")
+    assert plugin_manager.plugin_command_dict["run"] == "test_internal"
 
 
 def test_extend_command_mapping(plugin_manager, second_test_plugin):
-    plugin_manager.plugins['mock_two'] = second_test_plugin
-    assert plugin_manager.plugin_command_dict['foo'] == 'mock'
+    plugin_manager.plugins["mock_two"] = second_test_plugin
+    assert plugin_manager.plugin_command_dict["foo"] == "mock"
 
     # Plugin mock_two overrides the command def for 'foo'
-    plugin_manager.extend_plugin_command_mapping('mock_two')
-    assert plugin_manager.plugin_command_dict['foo'] == 'mock_two'
+    plugin_manager.extend_plugin_command_mapping("mock_two")
+    assert plugin_manager.plugin_command_dict["foo"] == "mock_two"
 
 
 def test_is_plugin_command(plugin_manager):
-    assert plugin_manager.is_plugin_command('foo')
-    assert not plugin_manager.is_plugin_command('bar')
+    assert plugin_manager.is_plugin_command("foo")
+    assert not plugin_manager.is_plugin_command("bar")
 
 
 def test_command_spec_returns_module_spec(plugin_manager):
-    result = plugin_manager.command_spec('foo')
+    result = plugin_manager.command_spec("foo")
 
     assert isinstance(result, ModuleSpec)
-    assert result.name == 'foo'
+    assert result.name == "foo"
 
 
 def test_plugin_command_list(plugin_manager):
     command_list = plugin_manager.plugin_command_list
     assert len(command_list) == 1
-    assert 'foo' in command_list
+    assert "foo" in command_list
diff --git a/tests/unit/test_plugin_new.py b/tests/unit/test_plugin_new.py
index 153f859..306457f 100644
--- a/tests/unit/test_plugin_new.py
+++ b/tests/unit/test_plugin_new.py
@@ -1,6 +1,7 @@
-import yaml
 from pathlib import Path
 
+import yaml
+
 path_suffix = "palm/plugins/core/templates/plugin/"
 plugin_dir = Path.cwd() / path_suffix
 
diff --git a/tests/unit/test_project_setup_utils.py b/tests/unit/test_project_setup_utils.py
index 7faa078..3c33b3d 100644
--- a/tests/unit/test_project_setup_utils.py
+++ b/tests/unit/test_project_setup_utils.py
@@ -1,6 +1,8 @@
 import os
 from pathlib import Path
+
 import click
+
 import palm.project_setup_utils as project_setup_utils
 
 
@@ -8,19 +10,19 @@ def test_has_env(tmp_path):
     os.chdir(tmp_path)
     assert not project_setup_utils.has_env()
 
-    Path('.env').touch()
+    Path(".env").touch()
     assert project_setup_utils.has_env()
 
 
 def test_optionally_create_env(tmp_path, monkeypatch):
-    monkeypatch.setattr(click, 'confirm', lambda x: True)
+    monkeypatch.setattr(click, "confirm", lambda x: True)
     os.chdir(tmp_path)
     project_setup_utils.optionally_create_env()
-    assert Path('.env').exists()
+    assert Path(".env").exists()
 
 
 def test_make_executable(tmp_path):
     os.chdir(tmp_path)
-    Path('file.sh').touch()
-    project_setup_utils.make_executable('file.sh')
-    assert os.access('file.sh', os.X_OK)
+    Path("file.sh").touch()
+    project_setup_utils.make_executable("file.sh")
+    assert os.access("file.sh", os.X_OK)
diff --git a/tests/unit/test_python_containerizer.py b/tests/unit/test_python_containerizer.py
index bc299ff..d6b3b67 100644
--- a/tests/unit/test_python_containerizer.py
+++ b/tests/unit/test_python_containerizer.py
@@ -1,6 +1,8 @@
 import os
-import pytest
 from pathlib import Path
+
+import pytest
+
 from palm.containerizer import PythonContainerizer
 
 
@@ -21,10 +23,10 @@ def test_detect_package_manager(tmp_path, environment):
     with pytest.raises(Exception):
         pc.detect_package_manager()
 
-    Path('poetry.lock').touch()
-    assert pc.detect_package_manager() == 'poetry'
-    Path('requirements.txt').touch()
-    assert pc.detect_package_manager() == 'pip3'
+    Path("poetry.lock").touch()
+    assert pc.detect_package_manager() == "poetry"
+    Path("requirements.txt").touch()
+    assert pc.detect_package_manager() == "pip3"
 
 
 def test_has_requirements_txt(tmp_path, environment):
@@ -33,7 +35,7 @@ def test_has_requirements_txt(tmp_path, environment):
     c = PythonContainerizer(ctx, tmp_path)
 
     assert not c.has_requirements_txt()
-    Path('requirements.txt').touch()
+    Path("requirements.txt").touch()
     assert c.has_requirements_txt()
 
 
@@ -43,35 +45,35 @@ def test_has_poetry(tmp_path, environment):
     c = PythonContainerizer(ctx, tmp_path)
 
     assert not c.has_poetry()
-    Path('poetry.lock').touch()
+    Path("poetry.lock").touch()
     assert c.has_poetry()
 
 
 def test_run(tmp_path, environment):
     templates_dir = (
-        Path(__file__).parents[2] / 'palm/plugins/core/templates/containerize'
+        Path(__file__).parents[2] / "palm/plugins/core/templates/containerize"
     )
 
     os.chdir(tmp_path)
-    Path('.env').touch()
-    Path('requirements.txt').touch()
+    Path(".env").touch()
+    Path("requirements.txt").touch()
     ctx = MockContext(obj=environment)
     c = PythonContainerizer(ctx, templates_dir)
     c.run()
 
-    assert Path(tmp_path, 'Dockerfile').exists()
-    assert Path(tmp_path, 'docker-compose.yaml').exists()
-    assert Path(tmp_path, 'scripts/entrypoint.sh').exists()
+    assert Path(tmp_path, "Dockerfile").exists()
+    assert Path(tmp_path, "docker-compose.yaml").exists()
+    assert Path(tmp_path, "scripts/entrypoint.sh").exists()
 
 
 def test_validate_python_version(tmp_path, environment):
     ctx = MockContext(obj=environment)
     default_version_pc = PythonContainerizer(ctx, tmp_path)
     assert default_version_pc.validate_python_version()
-    valid_version_pc = PythonContainerizer(ctx, tmp_path, '3.9')
+    valid_version_pc = PythonContainerizer(ctx, tmp_path, "3.9")
     assert valid_version_pc.validate_python_version()
 
-    invalid_version_pc = PythonContainerizer(ctx, tmp_path, '2.8')
+    invalid_version_pc = PythonContainerizer(ctx, tmp_path, "2.8")
     assert not invalid_version_pc.validate_python_version()
-    invalid_value_pc = PythonContainerizer(ctx, tmp_path, 'foo')
+    invalid_value_pc = PythonContainerizer(ctx, tmp_path, "foo")
     assert not invalid_value_pc.validate_python_version()
diff --git a/tests/unit/test_repo_plugin.py b/tests/unit/test_repo_plugin.py
index 186a759..dcf8213 100644
--- a/tests/unit/test_repo_plugin.py
+++ b/tests/unit/test_repo_plugin.py
@@ -1,6 +1,7 @@
 from pathlib import Path
-from palm.plugins.base import BasePlugin
+
 import palm.plugins.repo as repo_plugin
+from palm.plugins.base import BasePlugin
 
 
 def test_plugin_defined():
@@ -12,7 +13,7 @@ def test_plugin_is_instance_of_base_plugin():
 
 
 def test_plugin_name_is_core():
-    assert repo_plugin.Plugin.name == 'repo'
+    assert repo_plugin.Plugin.name == "repo"
 
 
 def test_repo_plugin_command_dir():
@@ -20,4 +21,4 @@ def test_repo_plugin_command_dir():
     assert isinstance(cmd_dir, Path)
     assert cmd_dir.exists()
     assert cmd_dir.is_dir()
-    assert cmd_dir.name == '.palm'
+    assert cmd_dir.name == ".palm"
diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py
index 284d656..2d85616 100644
--- a/tests/unit/test_utils.py
+++ b/tests/unit/test_utils.py
@@ -2,14 +2,14 @@
 
 
 def test_is_cmd_file():
-    assert is_cmd_file('foo') is False
-    assert is_cmd_file('cmd_foo.py') is True
-    assert is_cmd_file('cmd_foo_bar.py') is True
+    assert is_cmd_file("foo") is False
+    assert is_cmd_file("cmd_foo.py") is True
+    assert is_cmd_file("cmd_foo_bar.py") is True
 
 
 def test_cmd_name_from_file():
-    assert cmd_name_from_file('cmd_foo.py') == 'foo'
-    assert cmd_name_from_file('cmd_foo_bar.py') == 'foo_bar'
+    assert cmd_name_from_file("cmd_foo.py") == "foo"
+    assert cmd_name_from_file("cmd_foo_bar.py") == "foo_bar"
 
 
 def test_run_on_host_happy_path():
@@ -17,7 +17,7 @@ def test_run_on_host_happy_path():
     success, out, err = run_on_host(cmd, capture_output=True)
     assert success == 0
     assert out == "hello world!\n"
-    assert err == ''
+    assert err == ""
 
 
 def test_run_on_host_without_capture():