diff --git a/.gitignore b/.gitignore
index 3210b7f..d7cbcaf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
 dist/
 .golangci-lint/
 mango
+mh
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
index 96b43d1..6e8c9e0 100644
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -1,7 +1,8 @@
 version: 2
 
 builds:
-  - env:
+  - id: mango
+    env:
       - CGO_ENABLED=0
     goos:
       - linux
@@ -11,6 +12,17 @@ builds:
       - -X github.com/tjhop/mango/internal/version.Commit={{ .Commit }}
     binary: mango
     main: './cmd/mango'
+  - id: mh
+    env:
+      - CGO_ENABLED=0
+    goos:
+      - linux
+    ldflags:
+      - -X github.com/tjhop/mango/internal/version.BuildDate={{ .CommitDate }}
+      - -X github.com/tjhop/mango/internal/version.Version={{ .Version }}
+      - -X github.com/tjhop/mango/internal/version.Commit={{ .Commit }}
+    binary: mh
+    main: './cmd/mh'
 gomod:
   proxy: true
   mod: mod
diff --git a/Dockerfile b/Dockerfile
index 07d17b4..7f8d2c1 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -5,5 +5,6 @@ FROM cgr.dev/chainguard/busybox:latest
 COPY --from=certs /etc/ssl/certs /etc/ssl/certs
 
 COPY mango /usr/bin/mango
+COPY mh /usr/bin/mh
 ENTRYPOINT ["/usr/bin/mango"]
 CMD ["--inventory.path", "/opt/mango/inventory"]
diff --git a/Dockerfile-testbox-arch b/Dockerfile-testbox-arch
index 111d072..72ab440 100644
--- a/Dockerfile-testbox-arch
+++ b/Dockerfile-testbox-arch
@@ -20,6 +20,7 @@ RUN pacman -Syu --needed --noconfirm && \
 
 # setup mango
 COPY ./mango /usr/bin/mango
+COPY ./mh /usr/bin/mh
 COPY ./packaging/systemd/mango.service /etc/systemd/system/
 COPY ./test/mockup/services/mango/test-flags.conf /etc/systemd/system/mango.service.d/test-flags.conf
 RUN systemctl enable mango.service
diff --git a/Dockerfile-testbox-ubuntu b/Dockerfile-testbox-ubuntu
index f449b5d..f9bc33f 100644
--- a/Dockerfile-testbox-ubuntu
+++ b/Dockerfile-testbox-ubuntu
@@ -21,6 +21,7 @@ RUN apt-get update && \
 
 # setup mango
 COPY ./mango /usr/bin/mango
+COPY ./mh /usr/bin/mh
 COPY ./packaging/systemd/mango.service /etc/systemd/system/
 COPY ./test/mockup/services/mango/test-flags.conf /etc/systemd/system/mango.service.d/test-flags.conf
 RUN systemctl enable mango.service
diff --git a/Makefile b/Makefile
index 367e1cd..10335c8 100644
--- a/Makefile
+++ b/Makefile
@@ -24,12 +24,19 @@ lint:
 	mkdir -p ${GOLANGCILINT_CACHE} || true
 	podman run --rm -v ${CURDIR}:/app -v ${GOLANGCILINT_CACHE}:/root/.cache -w /app docker.io/golangci/golangci-lint:latest golangci-lint run -v
 
-## binary:		build a binary
-binary: fmt tidy lint
-	goreleaser build --clean --single-target --snapshot --output .
+## build-mango		build the `mango` configuration management server
+build-mango: fmt tidy lint
+	goreleaser build --clean --single-target --snapshot --output . --id "mango"
 
-## build:			alias for `binary`
-build: binary
+## build-mh		build `mh`, the helper tool for mango
+build-mh: fmt tidy lint
+	goreleaser build --clean --single-target --snapshot --output . --id "mh"
+
+## build:			alias for `build-mango build-mh`
+build: build-mango build-mh
+
+## binary:		alias for `build`
+binary: build
 
 ## container: 		build container image with binary
 container: binary
@@ -45,7 +52,7 @@ podman: container
 docker: container
 
 ## test-container:	build test containers with binary for testing purposes
-test-container: binary container
+test-container: binary
 	podman image build -t "mango-test-ubuntu" -f Dockerfile-testbox-ubuntu .
 	podman image build -t "mango-test-arch" -f Dockerfile-testbox-arch .
 
diff --git a/README.md b/README.md
index b112ff4..6abecce 100644
--- a/README.md
+++ b/README.md
@@ -28,7 +28,8 @@ While packages are built for several systems, there are currently no plans to at
 
 ## Usage
 
-### Binary Usage
+### `mango`
+#### Binary Usage
 
 ```bash
 mango --inventory.path /path/to/inventory
@@ -61,11 +62,11 @@ https://www.vim.org/iccf/
 https://www.iccf.nl/
 ```
 
-### Container Usage
+#### Container Usage
 
 Since `mango` is intended to be run on the system it is managing and thus requires access to the host system, if you must run `mango` as a container, you may want to use the `--privileged` flag.
 
-```
+```bash
 # docker works too, but podman is wonderful
 podman run \
 -v /path/to/inventory:/opt/mango/inventory \
@@ -73,6 +74,89 @@ podman run \
 ghcr.io/tjhop/mango
 ```
 
+### `mango-helper`
+`mango-helper` is a helper utility that ships with `mango` to make it easier to interact with various aspects of `mango`:
+
+```bash
+mh -h
+Mango Helper is a utility tool to aid in working with mango
+
+Usage:
+  mh [command]
+
+Available Commands:
+  completion  Generate the autocompletion script for the specified shell
+  help        Help about any command
+  inventory   Command to interact with mango inventory
+  mango       Command to interact with a running mango server
+
+Flags:
+  -h, --help                    help for mh
+  -l, --logging.level string    Logging level may be one of: [debug, info, warning, error] (default "info")
+      --logging.output string   Logging format may be one of: [logfmt, json] (default "logfmt")
+  -v, --version                 version for mh
+
+Use "mh [command] --help" for more information about a command.
+```
+
+The `mh inventory` command has several subcommands available to assist in working with the mango inventory:
+
+```bash
+
+mh inventory -h
+Command to interact with the mango inventory, such as initializing skeleton inventory directory structures
+
+Usage:
+  mh inventory [command]
+
+Aliases:
+  inventory, inv
+
+Available Commands:
+  directive   Command to interact with mango directives in the inventory
+  group       Command to interact with mango groups in the inventory
+  host        Command to interact with mango hosts in the inventory
+  init        Create an empty inventory
+  module      Command to interact with mango modules in the inventory
+  role        Command to interact with mango roles in the inventory
+
+Flags:
+      --enrolled-only           Only return modules that the provided host is enrolled for
+  -h, --help                    help for inventory
+      --hostname string         (Requires root) Custom hostname to use [default is system hostname]
+  -i, --inventory.path string   Path to mango configuration inventory
+
+Global Flags:
+  -l, --logging.level string    Logging level may be one of: [debug, info, warning, error] (default "info")
+      --logging.output string   Logging format may be one of: [logfmt, json] (default "logfmt")
+
+Use "mh inventory [command] --help" for more information about a command.
+```
+
+The `mh mango` command has further subcommands available to interact with a running mango server:
+
+```bash
+mh mango -h
+Command to interact with a running mango server, including interacting with pprofs, metrics, etc
+
+Usage:
+  mh mango [command]
+
+Available Commands:
+  metrics     Command to simplify metrics interactions for mango
+  pprof       Command to simplify pprof interactions for mango
+
+Flags:
+      --address string   Address of the running mango server (default "127.0.0.1:9555")
+  -h, --help             help for mango
+
+Global Flags:
+  -l, --logging.level string    Logging level may be one of: [debug, info, warning, error] (default "info")
+      --logging.output string   Logging format may be one of: [logfmt, json] (default "logfmt")
+
+Use "mh mango [command] --help" for more information about a command.
+```
+
 ## Configuration Management
 
 `Mango` is intended to be run as a daemon on the system that it will be managing.
@@ -81,21 +165,22 @@ ghcr.io/tjhop/mango
 
 ### Inventory
 `Mango`'s inventory is based on [aviary.sh's](https://github.com/frameable/aviary.sh) inventory.
-Initially, `mango` will be an aviary.sh-compatible daemon, with configurations written as scripts/executables.
+A detailed explanation of the differences between `mango` and `aviary.sh`, as well as a detailed explanation of each component/file in the mango inventory, can be found below.
 
 #### Inventory Setup
-Please see [aviary.sh's documentation on inventory setup](https://github.com/frameable/aviary.sh#inventory-setup) for more information.
+An inventory can be created using the companion `mango-helper` tool that ships with mango releases/builds:
 
-```
+```bash
 mkdir inventory
 cd inventory
-mkdir {groups,hosts,modules,roles,directives}
-touch {groups,hosts,modules,roles,directives}/.gitkeep
+mh inventory init
 git init
 git add .
 git commit -m "initial commit"
 ```
 
+The `mh inventory` command also has other utility functions to make working with the inventory easier. See [mango-helper](#mango-helper) for more details.
+
 *NOTE*: While `aviary.sh`'s inventory system is designed to work with bash
 scripts, it's possible to write a module in any language. Mango treats module
 test scripts as optional (yet recommended), and module/host variables are
diff --git a/cmd/mh/directive.go b/cmd/mh/directive.go
new file mode 100644
index 0000000..eaf1cee
--- /dev/null
+++ b/cmd/mh/directive.go
@@ -0,0 +1,87 @@
+package main
+
+import (
+	"fmt"
+	"log/slog"
+	"path/filepath"
+
+	"github.com/spf13/cobra"
+	"github.com/spf13/viper"
+)
+
+var (
+	dirCmd = &cobra.Command{
+		Use:     "directive",
+		Aliases: []string{"directives"},
+		Short:   "Command to interact with mango directives in the inventory",
+		Long:    "Command to interact with mango directive, including adding, deleting, etc",
+	}
+
+	dirAddCmd = &cobra.Command{
+		Use:     "add",
+		Aliases: addCmdAliases,
+		Short:   "Create an empty directive with the provided name",
+		Long: "Command to add a new directive by adding a new directory with the given" +
+			" name and creating empty directive files to bootstrap",
+		Args: cobra.ExactArgs(1),
+		Run:  directiveAdd,
+	}
+
+	dirDeleteCmd = &cobra.Command{
+		Use:     "delete",
+		Aliases: delCmdAliases,
+		Short:   "Delete the directive with the provided name",
+		Long:    "Command to delete a directive by recursively removing it from the inventory",
+		Args:    cobra.ExactArgs(1),
+		Run:     directiveDelete,
+	}
+
+	dirListCmd = &cobra.Command{
+		Use:     "list",
+		Aliases: listCmdAliases,
+		Short:   "List directives in the inventory",
+		Long:    "List directives in the inventory",
+		Args:    cobra.ExactArgs(0),
+		Run:     directiveList,
+	}
+)
+
+func init() {
+	inventoryCmd.AddCommand(dirCmd)
+	dirCmd.AddCommand(dirAddCmd)
+	dirCmd.AddCommand(dirDeleteCmd)
+	dirCmd.AddCommand(dirListCmd)
+}
+
+func directiveAdd(cmd *cobra.Command, args []string) {
+	dirName := args[0]
+	logger := slog.Default().With("component", "directive", "directive", dirName)
+
+	dirDir := filepath.Join(viper.GetString("inventory.path"), "directives")
+	dirPath := filepath.Join(dirDir, dirName)
+
+	if err := inventoryAddFile(dirPath); err != nil {
+		logger.Warn("Error creating directive file", "err", err, "file", dirPath)
+	} else {
+		logger.Debug("Created directive file", "file", dirPath)
+	}
+}
+
+func directiveDelete(cmd *cobra.Command, args []string) {
+	dirName := args[0]
+	logger := slog.Default().With("component", "directive", "directive", dirName)
+
+	dirDir := filepath.Join(viper.GetString("inventory.path"), "directives")
+	dirPath := filepath.Join(dirDir, dirName)
+
+	if err := inventoryRemoveAll(dirPath); err != nil {
+		logger.Warn("Error deleting directive", "err", err)
+	}
+}
+
+func directiveList(cmd *cobra.Command, args []string) {
+	inv := loadInventory()
+	for _, d := range inv.GetDirectives() {
+		fmt.Println(d.String())
+	}
+}
diff --git a/cmd/mh/group.go b/cmd/mh/group.go
new file mode 100644
index 0000000..a253eca
--- /dev/null
+++ b/cmd/mh/group.go
@@ -0,0 +1,113 @@
+package main
+
+import (
+	"fmt"
+	"log/slog"
+	"path/filepath"
+
+	"github.com/tjhop/mango/internal/inventory"
+
+	"github.com/spf13/cobra"
+	"github.com/spf13/viper"
+)
+
+var (
+	groupCmd = &cobra.Command{
+		Use:     "group",
+		Aliases: []string{"groups"},
+		Short:   "Command to interact with mango groups in the inventory",
+		Long:    "Command to interact with mango group, including adding, deleting, etc",
+	}
+
+	groupAddCmd = &cobra.Command{
+		Use:     "add",
+		Aliases: addCmdAliases,
+		Short:   "Create an empty group with the provided name",
+		Long: "Command to add a new group by adding a new directory with the given" +
+			" name and creating empty group files to bootstrap",
+		Args: cobra.ExactArgs(1),
+		Run:  groupAdd,
+	}
+
+	groupDeleteCmd = &cobra.Command{
+		Use:     "delete",
+		Aliases: delCmdAliases,
+		Short:   "Delete the group with the provided name",
+		Long:    "Command to delete a group by recursively removing it from the inventory",
+		Args:    cobra.ExactArgs(1),
+		Run:     groupDelete,
+	}
+
+	groupListCmd = &cobra.Command{
+		Use:     "list",
+		Aliases: listCmdAliases,
+		Short:   "List groups in the inventory",
+		Long:    "List groups in the inventory",
+		Args:    cobra.ExactArgs(0),
+		Run:     groupList,
+	}
+)
+
+func init() {
+	inventoryCmd.AddCommand(groupCmd)
+	groupCmd.AddCommand(groupAddCmd)
+	groupCmd.AddCommand(groupDeleteCmd)
+	groupCmd.AddCommand(groupListCmd)
+}
+
+func groupAdd(cmd *cobra.Command, args []string) {
+	groupName := args[0]
+	logger := slog.Default().With("component", "group", "group", groupName)
+
+	groupDir := filepath.Join(viper.GetString("inventory.path"), "groups")
+	groupPath := filepath.Join(groupDir, groupName)
+
+	if err := inventoryAddDir(groupPath); err != nil {
+		logger.Warn("Error initializing group", "err", err)
+	}
+
+	for _, gFile := range inventory.ValidGroupFiles {
+		file := filepath.Join(groupPath, gFile)
+		if err := inventoryAddFile(file); err != nil {
+			logger.Warn("Error creating group file", "err", err, "file", file)
+		} else {
+			logger.Debug("Created group file", "file", file)
+		}
+	}
+
+	for _, gDir := range inventory.ValidGroupDirs {
+		dir := filepath.Join(groupPath, gDir)
+		if err := inventoryAddDir(dir); err != nil {
+			logger.Warn("Error initializing group", "err", err, "dir", dir)
+		} else {
+			logger.Debug("Created group directory", "dir", dir)
+		}
+	}
+}
+
+func groupDelete(cmd *cobra.Command, args []string) {
+	groupName := args[0]
+	logger := slog.Default().With("component", "group", "group", groupName)
+
+	groupDir := filepath.Join(viper.GetString("inventory.path"), "groups")
+	groupPath := filepath.Join(groupDir, groupName)
+
+	if err := inventoryRemoveAll(groupPath); err != nil {
+		logger.Warn("Error deleting group", "err", err)
+	}
+}
+
+func groupList(cmd *cobra.Command, args []string) {
+	var groups []inventory.Group
+	inv := loadInventory()
+
+	if viper.GetBool("enrolled-only") && inv.IsEnrolled() {
+		groups = inv.GetGroupsForSelf()
+	} else {
+		groups = inv.GetGroups()
+	}
+
+	for _, g := range groups {
+		fmt.Println(g.String())
+	}
+}
diff --git a/cmd/mh/host.go b/cmd/mh/host.go
new file mode 100644
index 0000000..4c65183
--- /dev/null
+++ b/cmd/mh/host.go
@@ -0,0 +1,114 @@
+package main
+
+import (
+	"fmt"
+	"log/slog"
+	"path/filepath"
+
+	"github.com/spf13/cobra"
+	"github.com/spf13/viper"
+
+	"github.com/tjhop/mango/internal/inventory"
+)
+
+var (
+	hostCmd = &cobra.Command{
+		Use:     "host",
+		Aliases: []string{"hosts"},
+		Short:   "Command to interact with mango hosts in the inventory",
+		Long:    "Command to interact with mango host, including adding, deleting, etc",
+	}
+
+	hostAddCmd = &cobra.Command{
+		Use:     "add",
+		Aliases: addCmdAliases,
+		Short:   "Create an empty host with the provided name",
+		Long: "Command to add a new host by adding a new directory with the given" +
+			" name and creating empty host files to bootstrap",
+		Args: cobra.ExactArgs(1),
+		Run:  hostAdd,
+	}
+
+	hostDeleteCmd = &cobra.Command{
+		Use:     "delete",
+		Aliases: delCmdAliases,
+		Short:   "Delete the host with the provided name",
+		Long:    "Command to delete a host by recursively removing it from the inventory",
+		Args:    cobra.ExactArgs(1),
+		Run:     hostDelete,
+	}
+
+	hostListCmd = &cobra.Command{
+		Use:     "list",
+		Aliases: listCmdAliases,
+		Short:   "List hosts in the inventory",
+		Long:    "List hosts in the inventory",
+		Args:    cobra.ExactArgs(0),
+		Run:     hostList,
+	}
+)
+
+func init() {
+	inventoryCmd.AddCommand(hostCmd)
+	hostCmd.AddCommand(hostAddCmd)
+	hostCmd.AddCommand(hostDeleteCmd)
+	hostCmd.AddCommand(hostListCmd)
+}
+
+func hostAdd(cmd *cobra.Command, args []string) {
+	hostName := args[0]
+	logger := slog.Default().With("component", "host", "host", hostName)
+
+	hostDir := filepath.Join(viper.GetString("inventory.path"), "hosts")
+	hostPath := filepath.Join(hostDir, hostName)
+
+	if err := inventoryAddDir(hostPath); err != nil {
+		logger.Warn("Error initializing host", "err", err)
+	}
+
+	for _, hFile := range inventory.ValidHostFiles {
+		file := filepath.Join(hostPath, hFile)
+		if err := inventoryAddFile(file); err != nil {
+			logger.Warn("Error creating host file", "err", err, "file", file)
+		} else {
+			logger.Debug("Created host file", "file", file)
+		}
+	}
+
+	for _, hDir := range inventory.ValidHostDirs {
+		dir := filepath.Join(hostPath, hDir)
+		if err := inventoryAddDir(dir); err != nil {
+			logger.Warn("Error initializing host", "err", err, "dir", dir)
+		} else {
+			logger.Debug("Created host directory", "dir", dir)
+		}
+	}
+}
+
+func hostDelete(cmd *cobra.Command, args []string) {
+	hostName := args[0]
+	logger := slog.Default().With("component", "host", "host", hostName)
+
+	hostDir := filepath.Join(viper.GetString("inventory.path"), "hosts")
+	hostPath := filepath.Join(hostDir, hostName)
+
+	if err := inventoryRemoveAll(hostPath); err != nil {
+		logger.Warn("Error deleting host", "err", err)
+	}
+}
+
+func hostList(cmd *cobra.Command, args []string) {
+	var hosts []inventory.Host
+	inv := loadInventory()
+
+	if viper.GetBool("enrolled-only") && inv.IsEnrolled() {
+		host, _ := inv.GetHost(inv.GetHostname())
+		hosts = append(hosts, host)
+	} else {
+		hosts = inv.GetHosts()
+	}
+
+	for _, h := range hosts {
+		fmt.Println(h.String())
+	}
+}
diff --git a/cmd/mh/inventory.go b/cmd/mh/inventory.go
new file mode 100644
index 0000000..4da3347
--- /dev/null
+++ b/cmd/mh/inventory.go
@@ -0,0 +1,71 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	"log/slog"
+	"path/filepath"
+
+	"github.com/spf13/cobra"
+	"github.com/spf13/viper"
+
+	"github.com/tjhop/mango/internal/inventory"
+)
+
+var (
+	inventoryDirectories = []string{"groups", "hosts", "modules", "roles", "directives"}
+
+	inventoryCmd = &cobra.Command{
+		Use:     "inventory",
+		Aliases: []string{"inv"},
+		Short:   "Command to interact with mango inventory",
+		Long:    "Command to interact with the mango inventory, such as initializing skeleton inventory directory structures",
+	}
+
+	invInitCmd = &cobra.Command{
+		Use:     "init",
+		Aliases: []string{"create", "new"},
+		Short:   "Create an empty inventory",
+		Long:    "Command to initialize a skeleton directory structure for use with mango inventory",
+		Args:    cobra.ExactArgs(1),
+		Run:     inventoryInit,
+	}
+)
+
+func loadInventory() *inventory.Inventory {
+	logger := slog.Default().With("component", "inventory")
+	inventoryPath := viper.GetString("inventory.path")
+	hostname := viper.GetString("hostname")
+
+	inv := inventory.NewInventory(inventoryPath, hostname)
+	logger.Debug("Created new inventory", "inventory_path", inventoryPath, "hostname", hostname)
+	inv.Reload(context.Background(), logger)
+	return inv
+}
+
+func init() {
+	inventoryCmdFlagSet := inventoryCmd.PersistentFlags()
+	inventoryCmdFlagSet.StringP("inventory.path", "i", "", "Path to mango configuration inventory")
+	inventoryCmdFlagSet.String("hostname", "", "(Requires root) Custom hostname to use [default is system hostname]")
+	if err := viper.BindPFlags(inventoryCmdFlagSet); err != nil {
+		panic(fmt.Errorf("Error binding flags for command <%s>: %s", "inventory", err))
+	}
+	rootCmd.AddCommand(inventoryCmd)
+
+	inventoryCmd.AddCommand(invInitCmd)
+}
+
+func inventoryInit(cmd *cobra.Command, args []string) {
+	logger := slog.Default().With("component", "inventory")
+
+	// attempt to make all directories and place a `.gitkeep` file inside them
+	inventoryPath := viper.GetString("inventory.path")
+	for _, inventoryDir := range inventoryDirectories {
+		dir := filepath.Join(inventoryPath, inventoryDir)
+		if err := inventoryAddDir(dir); err != nil {
+			logger.Warn("Error initializing inventory", "err", err)
+		} else {
+			logger.Debug("Created inventory directory", "dir", dir)
+		}
+	}
+}
diff --git a/cmd/mh/main.go b/cmd/mh/main.go
new file mode 100644
index 0000000..94c12fa
--- /dev/null
+++ b/cmd/mh/main.go
@@ -0,0 +1,87 @@
+package main
+
+import (
+	"fmt"
+	"log/slog"
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
+
+	"github.com/tjhop/mango/internal/version"
+
+	"github.com/spf13/cobra"
+	"github.com/spf13/viper"
+)
+
+var (
+	logLevel = &slog.LevelVar{}
+	rootCmd  = &cobra.Command{
+		Use:     "mh",
+		Short:   "Mango Helper -- Tool to work with Mango",
+		Long:    "Mango Helper is a utility tool to aid in working with mango",
+		Version: version.Print(os.Args[0]),
+	}
+)
+
+func init() {
+	rootCmdFlagSet := rootCmd.PersistentFlags()
+	rootCmdFlagSet.StringP("logging.level", "l", "info", "Logging level may be one of: [debug, info, warning, error]")
+	rootCmdFlagSet.String("logging.output", "logfmt", "Logging format may be one of: [logfmt, json]")
+	if err := viper.BindPFlags(rootCmdFlagSet); err != nil {
+		panic(fmt.Errorf("Error binding flags for command <%s>: %s", "mh", err))
+	}
+
+	logHandlerOpts := &slog.HandlerOptions{
+		Level:     logLevel,
+		AddSource: true,
+		ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
+			key := a.Key
+			switch key {
+			case slog.SourceKey:
+				src, _ := a.Value.Any().(*slog.Source)
+				a.Value = slog.StringValue(filepath.Base(src.File) + ":" + strconv.Itoa(src.Line))
+			default:
+			}
+
+			return a
+		},
+	}
+
+	// parse log output format from flag, create root logger with default configs
+	var logger *slog.Logger
+	logOutputFormat := strings.ToLower(viper.GetString("logging.output"))
+	if logOutputFormat == "json" {
+		logger = slog.New(slog.NewJSONHandler(os.Stdout, logHandlerOpts))
+	} else {
+		logger = slog.New(slog.NewTextHandler(os.Stdout, logHandlerOpts))
+	}
+
+	// parse log level from flag
+	logLevelFlagVal := strings.ToLower(viper.GetString("logging.level"))
+	switch logLevelFlagVal {
+	case "":
+		logLevel.Set(slog.LevelInfo)
+		logger.Warn("Log level flag not set, defaulting to <info> level")
+	case "info": // default is info, we're good
+	case "warn":
+		logLevel.Set(slog.LevelWarn)
+	case "debug":
+		logLevel.Set(slog.LevelDebug)
+	case "error":
+		logLevel.Set(slog.LevelError)
+	default:
+		logLevel.Set(slog.LevelInfo)
+		logger.Warn("Failed to parse log level from flag, defaulting to <info> level",
+			slog.String("err", "Unsupported log level"),
+			slog.String("log_level", logLevelFlagVal),
+		)
+	}
+}
+
+func main() {
+	if err := rootCmd.Execute(); err != nil {
+		slog.Error("Error running root cobra command", "err", err)
+		os.Exit(1)
+	}
+}
diff --git a/cmd/mh/mango.go b/cmd/mh/mango.go
new file mode 100644
index 0000000..f27e5a6
--- /dev/null
+++ b/cmd/mh/mango.go
@@ -0,0 +1,27 @@
+package main
+
+import (
+	"fmt"
+
+	"github.com/spf13/cobra"
+	"github.com/spf13/viper"
+)
+
+var (
+	defaultMangoAddr = "127.0.0.1:9555"
+
+	mangoCmd = &cobra.Command{
+		Use:   "mango",
+		Short: "Command to interact with a running mango server",
+		Long:  "Command to interact with a running mango server, including interacting with pprofs, metrics, etc",
+	}
+)
+
+func init() {
+	mangoCmdFlagSet := mangoCmd.PersistentFlags()
+	mangoCmdFlagSet.String("address", defaultMangoAddr, "Address of the running mango server")
+	if err := viper.BindPFlags(mangoCmdFlagSet); err != nil {
+		panic(fmt.Errorf("Error binding flags for command <%s>: %s", "mango", err))
+	}
+	rootCmd.AddCommand(mangoCmd)
+}
diff --git a/cmd/mh/module.go b/cmd/mh/module.go
new file mode 100644
index 0000000..1c67de3
--- /dev/null
+++ b/cmd/mh/module.go
@@ -0,0 +1,118 @@
+package main
+
+import (
+	"fmt"
+	"log/slog"
+	"path/filepath"
+
+	"github.com/spf13/cobra"
+	"github.com/spf13/viper"
+	"github.com/tjhop/mango/internal/inventory"
+)
+
+var (
+	moduleCmd = &cobra.Command{
+		Use:     "module",
+		Aliases: []string{"mod", "modules"},
+		Short:   "Command to interact with mango modules in the inventory",
+		Long:    "Command to interact with mango module, including adding, deleting, etc",
+	}
+
+	modAddCmd = &cobra.Command{
+		Use:     "add",
+		Aliases: addCmdAliases,
+		Short:   "Create an empty module with the provided name",
+		Long: "Command to add a new module by adding a new directory with the given" +
+			" name and creating empty module files to bootstrap",
+		Args: cobra.ExactArgs(1),
+		Run:  moduleAdd,
+	}
+
+	modDeleteCmd = &cobra.Command{
+		Use:     "delete",
+		Aliases: delCmdAliases,
+		Short:   "Delete the module with the provided name",
+		Long:    "Command to delete a module by recursively removing it from the inventory",
+		Args:    cobra.ExactArgs(1),
+		Run:     moduleDelete,
+	}
+
+	modListCmd = &cobra.Command{
+		Use:     "list",
+		Aliases: listCmdAliases,
+		Short:   "List modules in the inventory",
+		Long:    "Command to list modules in the inventory",
+		Args:    cobra.ExactArgs(0),
+		Run:     moduleList,
+	}
+)
+
+func init() {
+	inventoryCmd.AddCommand(moduleCmd)
+	moduleCmd.AddCommand(modAddCmd)
+	moduleCmd.AddCommand(modDeleteCmd)
+
+	modListCmdFlagSet := inventoryCmd.PersistentFlags()
+	modListCmdFlagSet.Bool("enrolled-only", false, "Only return modules that the provided host is enrolled for")
+	if err := viper.BindPFlags(modListCmdFlagSet); err != nil {
+		panic(fmt.Errorf("Error binding flags for command <%s>: %s", "inventory", err))
+	}
+	moduleCmd.AddCommand(modListCmd)
+}
+
+func moduleAdd(cmd *cobra.Command, args []string) {
+	modName := args[0]
+	logger := slog.Default().With("component", "module", "module", modName)
+
+	modDir := filepath.Join(viper.GetString("inventory.path"), "modules")
+	modPath := filepath.Join(modDir, modName)
+
+	if err := inventoryAddDir(modPath); err != nil {
+		logger.Warn("Error initializing module", "err", err)
+	}
+
+	for _, mFile := range inventory.ValidModuleFiles {
+		file := filepath.Join(modPath, mFile)
+		if err := inventoryAddFile(file); err != nil {
+			logger.Warn("Error creating module file", "err", err, "file", file)
+		} else {
+			logger.Debug("Created module file", "file", file)
+		}
+	}
+
+	for _, mDir := range inventory.ValidModuleDirs {
+		dir := filepath.Join(modPath, mDir)
+		if err := inventoryAddDir(dir); err != nil {
+			logger.Warn("Error initializing module", "err", err, "dir", dir)
+		} else {
+			logger.Debug("Created module directory", "dir", dir)
+		}
+	}
+}
+
+func moduleDelete(cmd *cobra.Command, args []string) {
+	modName := args[0]
+	logger := slog.Default().With("component", "module", "module", modName)
+
+	modDir := filepath.Join(viper.GetString("inventory.path"), "modules")
+	modPath := filepath.Join(modDir, modName)
+
+	if err := inventoryRemoveAll(modPath); err != nil {
+		logger.Warn("Error deleting module", "err", err)
+	}
+}
+
+func moduleList(cmd *cobra.Command, args []string) {
+	var modules []inventory.Module
+	inv := loadInventory()
+
+	if viper.GetBool("enrolled-only") && inv.IsEnrolled() {
+		modules = inv.GetModulesForSelf()
+	} else {
+		modules = inv.GetModules()
+	}
+
+	for _, mod := range modules {
+		fmt.Println(mod.String())
+	}
+}
diff --git a/cmd/mh/pprof.go b/cmd/mh/pprof.go
new file mode 100644
index 0000000..9390604
--- /dev/null
+++ b/cmd/mh/pprof.go
@@ -0,0 +1,87 @@
+package main
+
+import (
+	"fmt"
+	"log/slog"
+	"path"
+	"strconv"
+
+	"github.com/spf13/cobra"
+	"github.com/spf13/viper"
+)
+
+var (
+	pprofProfiles = []string{"allocs", "block", "cmdline", "goroutine", "heap", "mutex", "profile", "threadcreate", "trace"}
+
+	pprofCmd = &cobra.Command{
+		Use:   "pprof",
+		Short: "Command to simplify pprof interactions for mango",
+		Long:  "Command to interact with pprof for mango, including collecting profiles and opening them via `pprof`",
+	}
+
+	pprofListProfilesCmd = &cobra.Command{
+		Use:     "list-profiles",
+		Aliases: []string{"list", "profiles", "all"},
+		Short:   "Command to list all available pprof profiles from the given mango server",
+		Long:    "Command to list all available pprof profiles from the given mango server",
+		Args:    cobra.ExactArgs(0),
+		Run:     pprofListProfiles,
+	}
+
+	pprofGetProfileCmd = &cobra.Command{
+		Use:     "get",
+		Aliases: []string{"collect"},
+		Short:   "Command to get the specified profile from the given mango server",
+		Long:    "Command to get the specified profile from the given mango server. Flags are available to help control pprof params for things like profile durations.",
+		Args:    cobra.ExactArgs(1),
+		Run:     pprofGetProfile,
+	}
+)
+
+func init() {
+	mangoCmd.AddCommand(pprofCmd)
+
+	pprofCmd.AddCommand(pprofListProfilesCmd)
+
+	pprofGetProfileCmdFlagSet := pprofGetProfileCmd.Flags()
+	pprofGetProfileCmdFlagSet.Int("debug", 0, "Corresponds to debug query param - https://pkg.go.dev/net/http/pprof#hdr-Parameters")
+	pprofGetProfileCmdFlagSet.Int("gc", 0, "Corresponds to gc query param - https://pkg.go.dev/net/http/pprof#hdr-Parameters")
+	pprofGetProfileCmdFlagSet.Int("seconds", 0, "Corresponds to seconds query param - https://pkg.go.dev/net/http/pprof#hdr-Parameters")
+	if err := viper.BindPFlags(pprofGetProfileCmdFlagSet); err != nil {
+		panic(fmt.Errorf("Error binding flags for command <%s>: %s", "pprof get", err))
+	}
+	pprofCmd.AddCommand(pprofGetProfileCmd)
+}
+
+func pprofListProfiles(cmd *cobra.Command, args []string) {
+	for _, p := range pprofProfiles {
+		fmt.Println(p)
+	}
+}
+
+func pprofGetProfile(cmd *cobra.Command, args []string) {
+	addr := viper.GetString("address")
+	profile := args[0]
+	pprofProfilePath := path.Join("debug/pprof", profile)
+
+	params := []urlParam{}
+	debug := viper.GetInt("debug")
+	if debug > 0 {
+		params = append(params, urlParam{key: "debug", value: strconv.Itoa(debug)})
+	}
+	gc := viper.GetInt("gc")
+	if gc > 0 {
+		params = append(params, urlParam{key: "gc", value: strconv.Itoa(gc)})
+	}
+	seconds := viper.GetInt("seconds")
+	if seconds > 0 {
+		params = append(params, urlParam{key: "seconds", value: strconv.Itoa(seconds)})
+	}
+
+	body, err := httpGetBody(addr, pprofProfilePath, params)
+	if err != nil {
+		slog.Error("Error getting body for pprof profile", "err", err, "profile", profile)
+	}
+
+	fmt.Printf("%s", body)
+}
diff --git a/cmd/mh/role.go b/cmd/mh/role.go
new file mode 100644
index 0000000..5a422e9
--- /dev/null
+++ b/cmd/mh/role.go
@@ -0,0 +1,106 @@
+package main
+
+import (
+	"fmt"
+	"log/slog"
+	"path/filepath"
+
+	"github.com/spf13/cobra"
+	"github.com/spf13/viper"
+)
+
+var (
+	roleFiles = []string{"modules", "variables"}
+	roleDirs  = []string{"templates"}
+
+	roleCmd = &cobra.Command{
+		Use:     "role",
+		Aliases: []string{"roles"},
+		Short:   "Command to interact with mango roles in the inventory",
+		Long:    "Command to interact with mango role, including adding, deleting, etc",
+	}
+
+	roleAddCmd = &cobra.Command{
+		Use:     "add",
+		Aliases: addCmdAliases,
+		Short:   "Create an empty role with the provided name",
+		Long: "Command to add a new role by adding a new directory with the given" +
+			" name and creating empty role files to bootstrap",
+		Args: cobra.ExactArgs(1),
+		Run:  roleAdd,
+	}
+
+	roleDeleteCmd = &cobra.Command{
+		Use:     "delete",
+		Aliases: delCmdAliases,
+		Short:   "Delete the role with the provided name",
+		Long:    "Command to delete a role by recursively removing it from the inventory",
+		Args:    cobra.ExactArgs(1),
+		Run:     roleDelete,
+	}
+
+	roleListCmd = &cobra.Command{
+		Use:     "list",
+		Aliases: listCmdAliases,
+		Short:   "List roles in the inventory",
+		Long:    "List roles in the inventory",
+		Args:    cobra.ExactArgs(0),
+		Run:     roleList,
+	}
+)
+
+func init() {
+	inventoryCmd.AddCommand(roleCmd)
+	roleCmd.AddCommand(roleAddCmd)
+	roleCmd.AddCommand(roleDeleteCmd)
+	roleCmd.AddCommand(roleListCmd)
+}
+
+func roleAdd(cmd *cobra.Command, args []string) {
+	roleName := args[0]
+	logger := slog.Default().With("component", "role", "role", roleName)
+
+	roleDir := filepath.Join(viper.GetString("inventory.path"), "roles")
+	rolePath := filepath.Join(roleDir, roleName)
+
+	if err := inventoryAddDir(rolePath); err != nil {
+		logger.Warn("Error initializing role", "err", err)
+	}
+
+	for _, rFile := range roleFiles {
+		file := filepath.Join(rolePath, rFile)
+		if err := inventoryAddFile(file); err != nil {
+			logger.Warn("Error creating role file", "err", err, "file", file)
+		} else {
+			logger.Debug("Created role file", "file", file)
+		}
+	}
+
+	for _, rDir := range roleDirs {
+		dir := filepath.Join(rolePath, rDir)
+		if err := inventoryAddDir(dir); err != nil {
+			logger.Warn("Error initializing role", "err", err, "dir", dir)
+		} else {
+			logger.Debug("Created role directory", "dir", dir)
+		}
+	}
+}
+
+func roleDelete(cmd *cobra.Command, args []string) {
+	roleName := args[0]
+	logger := slog.Default().With("component", "role", "role", roleName)
+
+	roleDir := filepath.Join(viper.GetString("inventory.path"), "roles")
+	rolePath := filepath.Join(roleDir, roleName)
+
+	if err := inventoryRemoveAll(rolePath); err != nil {
+		logger.Warn("Error deleting role", "err", err)
+	}
+}
+
+func roleList(cmd *cobra.Command, args []string) {
+	inv := loadInventory()
+	for _, g := range inv.GetRoles() {
+		fmt.Println(g.String())
+	}
+}
diff --git a/cmd/mh/util.go b/cmd/mh/util.go
new file mode 100644
index 0000000..6eb04bf
--- /dev/null
+++ b/cmd/mh/util.go
@@ -0,0 +1,92 @@
+package main
+
+import (
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"os"
+	"path/filepath"
+	"strings"
+)
+
+var (
+	addCmdAliases  = []string{"create", "init", "new"}
+	delCmdAliases  = []string{"remove", "rm", "del"}
+	listCmdAliases = []string{"show", "print", "ls"}
+)
+
+func inventoryAddFile(name string) error {
+	file, err := os.OpenFile(name, os.O_RDONLY|os.O_CREATE, 0644)
+	if err != nil {
+		return fmt.Errorf("Error opening file <%s>: %s", name, err)
+	}
+	return file.Close()
+}
+
+func inventoryAddDir(name string) error {
+	if err := os.MkdirAll(name, 0755); err != nil {
+		return fmt.Errorf("Error making directory <%s>: %s", name, err)
+	}
+
+	if err := inventoryAddFile(filepath.Join(name, ".gitkeep")); err != nil {
+		return fmt.Errorf("Error adding directory file <%s>: %s", name, err)
+	}
+
+	return nil
+}
+
+func inventoryRemoveAll(name string) error {
+	err := os.RemoveAll(name)
+	if err != nil {
+		return fmt.Errorf("Error recursively removing <%s>: %s", name, err)
+	}
+
+	return nil
+}
+
+type urlParam struct {
+	key   string
+	value string
+}
+
+func httpGetBody(addr string, path string, urlParams []urlParam) (string, error) {
+	if !strings.HasPrefix(addr, "http") {
+		addr = "http://" + addr
+	}
+
+	pprofUrl, err := url.Parse(addr)
+	if err != nil {
+		return "", fmt.Errorf("Error parsing url <%s>: %s", addr, err)
+	}
+
+	if pprofUrl.Scheme == "" {
+		pprofUrl.Scheme = "http"
+	}
+
+	pprofUrl.Path += path
+
+	params := url.Values{}
+	for _, p := range urlParams {
+		params.Add(p.key, p.value)
+	}
+	pprofUrl.RawQuery = params.Encode()
+
+	// fmt.Printf("TJ DEBUG | assembled url is: %s\n", pprofUrl.String())
+	res, err := http.Get(pprofUrl.String())
+	if err != nil {
+		return "", fmt.Errorf("Error making HTTP Get request to <%s>: %s", addr, err)
+	}
+
+	body, err := io.ReadAll(res.Body)
+	res.Body.Close()
+
+	if res.StatusCode != http.StatusOK {
+		return string(body), fmt.Errorf("HTTP response failed (status code: %d)", res.StatusCode)
+	}
+	if err != nil {
+		return "", fmt.Errorf("Error reading HTTP response body: %s", err)
+	}
+
+	return string(body), nil
+}
diff --git a/docker-compose-test-mango.yaml b/docker-compose-test-mango.yaml
index 3cf171f..95cc25a 100644
--- a/docker-compose-test-mango.yaml
+++ b/docker-compose-test-mango.yaml
@@ -27,6 +27,7 @@ services:
       - mango
     volumes:
       - ./mango:/usr/bin/mango
+      - ./mh:/usr/bin/mh
       - ./test/mockup/inventory:/opt/mango/inventory/:ro
 
   mango-archlinux:
@@ -39,4 +40,4 @@ services:
       - mango
     volumes:
       - ./mango:/usr/bin/mango
-      - ./test/mockup/inventory:/opt/mango/inventory/:ro
+      - ./mh:/usr/bin/mh
diff --git a/go.mod b/go.mod
index f40f034..2aa0979 100644
--- a/go.mod
+++ b/go.mod
@@ -14,6 +14,7 @@ require (
 	github.com/prometheus/client_golang v1.20.2
 	github.com/prometheus/procfs v0.15.1
 	github.com/quay/claircore v1.5.29
+	github.com/spf13/cobra v1.8.1
 	github.com/spf13/pflag v1.0.5
 	github.com/spf13/viper v1.19.0
 	mvdan.cc/sh/v3 v3.9.0
@@ -30,6 +31,7 @@ require (
 	github.com/google/uuid v1.6.0 // indirect
 	github.com/hashicorp/errwrap v1.1.0 // indirect
 	github.com/hashicorp/hcl v1.0.0 // indirect
+	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 	github.com/klauspost/compress v1.17.9 // indirect
 	github.com/magiconair/properties v1.8.7 // indirect
 	github.com/mattn/go-colorable v0.1.13 // indirect
diff --git a/go.sum b/go.sum
index b4c7ec3..c461b55 100644
--- a/go.sum
+++ b/go.sum
@@ -11,6 +11,7 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
 github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
 github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
 github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
+github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
 github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
 github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -43,6 +44,8 @@ github.com/hashicorp/go-sockaddr v1.0.6 h1:RSG8rKU28VTUTvEKghe5gIhIQpv8evvNpnDEy
 github.com/hashicorp/go-sockaddr v1.0.6/go.mod h1:uoUUmtwU7n9Dv3O4SNLeFvg0SxQ3lyjsj6+CCykpaxI=
 github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
 github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
 github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
 github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
 github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w=
@@ -119,6 +122,7 @@ github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99
 github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
 github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
 github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk=
 github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0=
 github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
@@ -131,6 +135,8 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
 github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
 github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
 github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
+github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
+github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
 github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
 github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
diff --git a/internal/inventory/group.go b/internal/inventory/group.go
index eadfee4..dbd8f5a 100644
--- a/internal/inventory/group.go
+++ b/internal/inventory/group.go
@@ -13,6 +13,11 @@ import (
 	"github.com/prometheus/client_golang/prometheus"
 )
 
+var (
+	ValidGroupFiles = []string{"glob", "regex", "roles", "modules", "variables"}
+	ValidGroupDirs  = []string{"templates"}
+)
+
 // Group contains fields that represent a given group of hosts in the inventory.
 // - id: string idenitfying the group
 // - globs: a slice of glob patterns to match against the instance's hostname
diff --git a/internal/inventory/host.go b/internal/inventory/host.go
index fd9d65f..c58518d 100644
--- a/internal/inventory/host.go
+++ b/internal/inventory/host.go
@@ -11,6 +11,11 @@ import (
 	"github.com/prometheus/client_golang/prometheus"
 )
 
+var (
+	ValidHostFiles = []string{"modules", "roles", "variables"}
+	ValidHostDirs  = []string{"templates"}
+)
+
 // Host contains fields that represent a given host in the inventory.
 // - id: string idenitfying the host (generally the hostname of the system)
 // - roles: a slice of roles that are applied to this host
diff --git a/internal/inventory/module.go b/internal/inventory/module.go
index adc7141..c280e67 100644
--- a/internal/inventory/module.go
+++ b/internal/inventory/module.go
@@ -11,6 +11,11 @@ import (
 	"github.com/prometheus/client_golang/prometheus"
 )
 
+var (
+	ValidModuleFiles = []string{"apply", "test", "variables", "requires"}
+	ValidModuleDirs  = []string{"templates"}
+)
+
 // Module contains fields that represent a single module in the inventory.
 // - ID: string idenitfying the module (generally the file path to the module)
 // - Apply: path to apply script for the module