Repository: grafana/tanka
Pull request #235 (2020-03-09 17:47:34)

feat: standalone cli
Big PR implementing the parts of `spf13/cobra` we actually use ... turns out not that much after all.

Motivation behind this was that `spf13/cobra` is a big library with many dependencies, that we don't use altogether. Furthermore, we want native completion using `posener/complete`, which I hacked upon cobra in an ugly way.

The new package `pkg/cli` has clearly defined interfaces for all tasks we need, a simple `cli.Command` struct with only the most basic features (enough for 90% of the cases) which fits exactly what Tanka needs.

**I expect to move this package out of Tanka in the long run**, especially if we need it in other projects. For the time being it seems simplest to leave it here until it matures.

Fixes https://github.com/grafana/tanka/issues/191


Patch:
diff --git a/cmd/tk/args.go b/cmd/tk/args.go
new file mode 100644
index 00000000..4fc04eee
--- /dev/null
+++ b/cmd/tk/args.go
@@ -0,0 +1,16 @@
+package main
+
+import (
+	"github.com/go-clix/cli"
+	"github.com/posener/complete"
+)
+
+var workflowArgs = cli.Args{
+	Validator: cli.ValidateExact(1),
+	Predictor: cli.PredictFunc(func(complete.Args) []string {
+		if dirs := findBaseDirs(); len(dirs) != 0 {
+			return dirs
+		}
+		return []string{""}
+	}),
+}
diff --git a/cmd/tk/env.go b/cmd/tk/env.go
index ba7c1f03..1d030dba 100644
--- a/cmd/tk/env.go
+++ b/cmd/tk/env.go
@@ -8,29 +8,29 @@ import (
 	"path/filepath"
 	"text/tabwriter"
 
+	"github.com/go-clix/cli"
 	"github.com/pkg/errors"
 	"github.com/posener/complete"
-	"github.com/spf13/cobra"
 	"github.com/spf13/pflag"
 
-	"github.com/grafana/tanka/pkg/cli"
-	"github.com/grafana/tanka/pkg/cli/cmp"
 	"github.com/grafana/tanka/pkg/kubernetes/client"
 	"github.com/grafana/tanka/pkg/spec/v1alpha1"
+	"github.com/grafana/tanka/pkg/term"
 )
 
-func envCmd() *cobra.Command {
-	cmd := &cobra.Command{
+func envCmd() *cli.Command {
+	cmd := &cli.Command{
 		Use:   "env [action]",
 		Short: "manipulate environments",
 	}
-	cmd.PersistentFlags().Bool("json", false, "output in json format")
+
 	cmd.AddCommand(
 		envAddCmd(),
 		envSetCmd(),
 		envListCmd(),
 		envRemoveCmd(),
 	)
+
 	return cmd
 }
 
@@ -41,25 +41,23 @@ func envSettingsFlags(env *v1alpha1.Config, fs *pflag.FlagSet) {
 	fs.StringVar(&env.Spec.DiffStrategy, "diff-strategy", env.Spec.DiffStrategy, "specify diff-strategy. Automatically detected otherwise.")
 }
 
-func envSetCmd() *cobra.Command {
-	cmd := &cobra.Command{
+var kubectlContexts = cli.PredictFunc(
+	func(complete.Args) []string {
+		c, _ := client.Contexts()
+		return c
+	},
+)
+
+func envSetCmd() *cli.Command {
+	cmd := &cli.Command{
 		Use:   "set",
 		Short: "update properties of an environment",
-		Args:  cobra.ExactArgs(1),
-		Annotations: map[string]string{
-			"args":                      "baseDir",
-			"flags/server-from-context": "kubectlContexts",
+		Args:  workflowArgs,
+		Predictors: complete.Flags{
+			"server-from-context": kubectlContexts,
 		},
 	}
 
-	// handler for server-from-context
-	cmp.Handlers.Add("kubectlContexts", complete.PredictFunc(
-		func(complete.Args) []string {
-			c, _ := client.Contexts()
-			return c
-		},
-	))
-
 	// flags
 	tmp := v1alpha1.Config{}
 	envSettingsFlags(&tmp, cmd.Flags())
@@ -68,20 +66,20 @@ func envSetCmd() *cobra.Command {
 	name := cmd.Flags().String("name", "", "")
 	_ = cmd.Flags().MarkHidden("name")
 
-	cmd.Run = func(cmd *cobra.Command, args []string) {
+	cmd.Run = func(cmd *cli.Command, args []string) error {
 		if *name != "" {
-			log.Fatalln("It looks like you attempted to rename the environment using `--name`. However, this is not possible with Tanka, because the environments name is inferred from the directories name. To rename the environment, rename its directory instead.")
+			return fmt.Errorf("It looks like you attempted to rename the environment using `--name`. However, this is not possible with Tanka, because the environments name is inferred from the directories name. To rename the environment, rename its directory instead.")
 		}
 
 		path, err := filepath.Abs(args[0])
 		if err != nil {
-			log.Fatalln(err)
+			return err
 		}
 
 		if cmd.Flags().Changed("server-from-context") {
 			server, err := client.IPFromContext(tmp.Spec.APIServer)
 			if err != nil {
-				log.Fatalf("Resolving IP from context: %s", err)
+				return fmt.Errorf("Resolving IP from context: %s", err)
 			}
 			tmp.Spec.APIServer = server
 		}
@@ -101,35 +99,36 @@ func envSetCmd() *cobra.Command {
 		}
 
 		if err := writeJSON(cfg, filepath.Join(path, "spec.json")); err != nil {
-			log.Fatalln(err)
+			return err
 		}
+
+		return nil
 	}
 	return cmd
 }
 
-func envAddCmd() *cobra.Command {
-	cmd := &cobra.Command{
+func envAddCmd() *cli.Command {
+	cmd := &cli.Command{
 		Use:   "add <path>",
 		Short: "create a new environment",
-		Args:  cobra.ExactArgs(1),
-		Annotations: map[string]string{
-			"args": "dirs",
-		},
+		Args:  cli.ArgsExact(1),
 	}
 	cfg := v1alpha1.New()
 	envSettingsFlags(cfg, cmd.Flags())
-	cmd.Run = func(cmd *cobra.Command, args []string) {
+	cmd.Run = func(cmd *cli.Command, args []string) error {
 		if cmd.Flags().Changed("server-from-context") {
 			server, err := client.IPFromContext(cfg.Spec.APIServer)
 			if err != nil {
-				log.Fatalf("Resolving IP from context: %s", err)
+				return fmt.Errorf("Resolving IP from context: %s", err)
 			}
 			cfg.Spec.APIServer = server
 		}
 
 		if err := addEnv(args[0], cfg); err != nil {
-			log.Fatalln(err)
+			return err
 		}
+
+		return nil
 	}
 	return cmd
 }
@@ -138,7 +137,7 @@ func envAddCmd() *cobra.Command {
 func addEnv(dir string, cfg *v1alpha1.Config) error {
 	path, err := filepath.Abs(dir)
 	if err != nil {
-		log.Fatalln(err)
+		return err
 	}
 	if _, err := os.Stat(path); err != nil {
 		// folder does not exist
@@ -161,59 +160,56 @@ func addEnv(dir string, cfg *v1alpha1.Config) error {
 
 	// write spec.json
 	if err := writeJSON(cfg, filepath.Join(path, "spec.json")); err != nil {
-		log.Fatalln(err)
+		return err
 	}
 
 	// write main.jsonnet
 	if err := writeJSON(struct{}{}, filepath.Join(path, "main.jsonnet")); err != nil {
-		log.Fatalln(err)
+		return err
 	}
 
 	return nil
 }
 
-func envRemoveCmd() *cobra.Command {
-	return &cobra.Command{
+func envRemoveCmd() *cli.Command {
+	return &cli.Command{
 		Use:     "remove <path>",
 		Aliases: []string{"rm"},
 		Short:   "delete an environment",
-		Annotations: map[string]string{
-			"args": "baseDir",
-		},
-		Run: func(cmd *cobra.Command, args []string) {
+		Args:    workflowArgs,
+		Run: func(cmd *cli.Command, args []string) error {
 			for _, arg := range args {
 				path, err := filepath.Abs(arg)
 				if err != nil {
-					log.Fatalln("parsing environments name:", err)
+					return fmt.Errorf("parsing environments name: %s", err)
 				}
-				if err := cli.Confirm(fmt.Sprintf("Permanently removing the environment located at '%s'.", path), "yes"); err != nil {
-					log.Fatalln(err)
+				if err := term.Confirm(fmt.Sprintf("Permanently removing the environment located at '%s'.", path), "yes"); err != nil {
+					return err
 				}
 				if err := os.RemoveAll(path); err != nil {
-					log.Fatalf("Removing '%s': %s", path, err)
+					return fmt.Errorf("Removing '%s': %s", path, err)
 				}
 				fmt.Println("Removed", path)
 			}
+			return nil
 		},
 	}
 }
 
-func envListCmd() *cobra.Command {
-	cmd := &cobra.Command{
+func envListCmd() *cli.Command {
+	cmd := &cli.Command{
 		Use:     "list",
 		Aliases: []string{"ls"},
 		Short:   "list environments",
-		Args:    cobra.NoArgs,
+		Args:    cli.ArgsNone(),
 	}
 
-	cmd.Run = func(cmd *cobra.Command, args []string) {
+	useJSON := cmd.Flags().Bool("json", false, "json output")
+
+	cmd.Run = func(cmd *cli.Command, args []string) error {
 		envs := []v1alpha1.Config{}
 		dirs := findBaseDirs()
-		useJSON, err := cmd.Flags().GetBool("json")
-		if err != nil {
-			// this err should never occur. Panic in case
-			panic(err)
-		}
+
 		for _, dir := range dirs {
 			env := setupConfiguration(dir)
 			if env == nil {
@@ -223,13 +219,13 @@ func envListCmd() *cobra.Command {
 			envs = append(envs, *env)
 		}
 
-		if useJSON {
+		if *useJSON {
 			j, err := json.Marshal(envs)
 			if err != nil {
-				log.Fatalln("Formatting as json:", j)
+				return fmt.Errorf("Formatting as json: %s", err)
 			}
 			fmt.Println(string(j))
-			return
+			return nil
 		}
 
 		w := tabwriter.NewWriter(os.Stdout, 0, 0, 4, ' ', 0)
@@ -239,6 +235,8 @@ func envListCmd() *cobra.Command {
 			fmt.Fprintf(w, f, e.Metadata.Name, e.Spec.Namespace, e.Spec.APIServer)
 		}
 		w.Flush()
+
+		return nil
 	}
 	return cmd
 }
diff --git a/cmd/tk/export.go b/cmd/tk/export.go
index 5d928941..86d089cd 100644
--- a/cmd/tk/export.go
+++ b/cmd/tk/export.go
@@ -2,6 +2,7 @@ package main
 
 import (
 	"bytes"
+	"fmt"
 	"io"
 	"io/ioutil"
 	"log"
@@ -11,39 +12,40 @@ import (
 
 	"text/template"
 
-	"github.com/spf13/cobra"
+	"github.com/go-clix/cli"
 
 	"github.com/grafana/tanka/pkg/tanka"
 )
 
-func exportCmd() *cobra.Command {
-	cmd := &cobra.Command{
+func exportCmd() *cli.Command {
+	args := workflowArgs
+	args.Validator = cli.ValidateExact(2)
+
+	cmd := &cli.Command{
 		Use:   "export <environment> <outputDir>",
 		Short: "write each resources as a YAML file",
-		Args:  cobra.ExactArgs(2),
-		Annotations: map[string]string{
-			"args": "baseDir",
-		},
+		Args:  args,
 	}
+
 	vars := workflowFlags(cmd.Flags())
 	getExtCode := extCodeParser(cmd.Flags())
 	format := cmd.Flags().String("format", "{{.apiVersion}}.{{.kind}}-{{.metadata.name}}", "https://tanka.dev/exporting#filenames")
 
-	cmd.Run = func(cmd *cobra.Command, args []string) {
+	cmd.Run = func(cmd *cli.Command, args []string) error {
 		// dir must be empty
 		to := args[1]
 		empty, err := dirEmpty(to)
 		if err != nil {
-			log.Fatalln("Checking target dir:", err)
+			return fmt.Errorf("Checking target dir: %s", err)
 		}
 		if !empty {
-			log.Fatalln("Target dir", to, "not empty. Aborting.")
+			return fmt.Errorf("Target dir `%s` not empty. Aborting.", to)
 		}
 
 		// exit early if the template is bad
 		tmpl, err := template.New("").Parse(*format)
 		if err != nil {
-			log.Fatalln("Parsing name format:", err)
+			return fmt.Errorf("Parsing name format: %s", err)
 		}
 
 		// get the manifests
@@ -52,7 +54,7 @@ func exportCmd() *cobra.Command {
 			tanka.WithTargets(stringsToRegexps(vars.targets)...),
 		)
 		if err != nil {
-			log.Fatalln(err)
+			return err
 		}
 
 		// write each to a file
@@ -65,9 +67,11 @@ func exportCmd() *cobra.Command {
 
 			data := m.String()
 			if err := ioutil.WriteFile(filepath.Join(to, name+".yaml"), []byte(data), 0644); err != nil {
-				log.Fatalln("Writing manifest:", err)
+				return fmt.Errorf("Writing manifest: %s", err)
 			}
 		}
+
+		return nil
 	}
 	return cmd
 }
diff --git a/cmd/tk/init.go b/cmd/tk/init.go
index dc10572d..eab8161e 100644
--- a/cmd/tk/init.go
+++ b/cmd/tk/init.go
@@ -8,48 +8,48 @@ import (
 	"os"
 	"os/exec"
 
-	"github.com/spf13/cobra"
+	"github.com/go-clix/cli"
 
 	"github.com/grafana/tanka/pkg/spec/v1alpha1"
 )
 
 // initCmd creates a new application
-func initCmd() *cobra.Command {
-	cmd := &cobra.Command{
+func initCmd() *cli.Command {
+	cmd := &cli.Command{
 		Use:   "init",
 		Short: "Create the directory structure",
-		Args:  cobra.NoArgs,
+		Args:  cli.ArgsNone(),
 	}
 
 	force := cmd.Flags().BoolP("force", "f", false, "ignore the working directory not being empty")
 	installK8sLibFlag := cmd.Flags().Bool("k8s", true, "set to false to skip installation of k.libsonnet")
 
-	cmd.Run = func(cmd *cobra.Command, args []string) {
+	cmd.Run = func(cmd *cli.Command, args []string) error {
 		failed := false
 
 		files, err := ioutil.ReadDir(".")
 		if err != nil {
-			log.Fatalln("Error listing files:", err)
+			return fmt.Errorf("Error listing files: %s", err)
 		}
 		if len(files) > 0 && !*force {
-			log.Fatalln("Error: directory not empty. Use `-f` to force")
+			return fmt.Errorf("Error: directory not empty. Use `-f` to force")
 		}
 
 		if err := writeNewFile("jsonnetfile.json", "{}"); err != nil {
-			log.Fatalln("Error creating `jsonnetfile.json`:", err)
+			return fmt.Errorf("Error creating `jsonnetfile.json`: %s", err)
 		}
 
 		if err := os.Mkdir("vendor", os.ModePerm); err != nil {
-			log.Fatalln("Error creating `vendor/` folder:", err)
+			return fmt.Errorf("Error creating `vendor/` folder: %s", err)
 		}
 
 		if err := os.Mkdir("lib", os.ModePerm); err != nil {
-			log.Fatalln("Error creating `lib/` folder:", err)
+			return fmt.Errorf("Error creating `lib/` folder: %s", err)
 		}
 
 		cfg := v1alpha1.New()
 		if err := addEnv("environments/default", cfg); err != nil {
-			log.Fatalln(err)
+			return err
 		}
 
 		if *installK8sLibFlag {
@@ -64,6 +64,8 @@ func initCmd() *cobra.Command {
 		if failed {
 			log.Println("Errors occured while initializing the project. Check the above logs for details.")
 		}
+
+		return nil
 	}
 	return cmd
 }
diff --git a/cmd/tk/jsonnet.go b/cmd/tk/jsonnet.go
index 8f54c162..01e39393 100644
--- a/cmd/tk/jsonnet.go
+++ b/cmd/tk/jsonnet.go
@@ -6,39 +6,38 @@ import (
 	"log"
 	"strings"
 
-	"github.com/spf13/cobra"
 	"github.com/spf13/pflag"
 
+	"github.com/go-clix/cli"
+
 	"github.com/grafana/tanka/pkg/tanka"
 )
 
-func evalCmd() *cobra.Command {
-	cmd := &cobra.Command{
+func evalCmd() *cli.Command {
+	cmd := &cli.Command{
 		Short: "evaluate the jsonnet to json",
 		Use:   "eval <path>",
-		Args:  cobra.ExactArgs(1),
-		Annotations: map[string]string{
-			"args": "baseDir",
-		},
+		Args:  workflowArgs,
 	}
 
 	getExtCode := extCodeParser(cmd.Flags())
 
-	cmd.Run = func(cmd *cobra.Command, args []string) {
+	cmd.Run = func(cmd *cli.Command, args []string) error {
 		raw, err := tanka.Eval(args[0],
 			tanka.WithExtCode(getExtCode()),
 		)
 
 		if err != nil {
-			log.Fatalln(err, nil)
+			return err
 		}
 
 		out, err := json.MarshalIndent(raw, "", "  ")
 		if err != nil {
-			log.Fatalln(err)
+			return err
 		}
 
 		pageln(string(out))
+		return nil
 	}
 
 	return cmd
@@ -55,7 +54,7 @@ func extCodeParser(fs *pflag.FlagSet) func() map[string]string {
 		for _, s := range *values {
 			split := strings.SplitN(s, "=", 2)
 			if len(split) != 2 {
-				log.Fatalln("extCode argument has wrong format:", s+".", "Expected 'key=<code>'")
+				log.Fatalf("extCode argument has wrong format: `%s`. Expected `key=<code>`", s)
 			}
 			m[split[0]] = split[1]
 		}
@@ -63,7 +62,7 @@ func extCodeParser(fs *pflag.FlagSet) func() map[string]string {
 		for _, s := range *strs {
 			split := strings.SplitN(s, "=", 2)
 			if len(split) != 2 {
-				log.Fatalln("extVar argument has wrong format:", s+".", "Expected 'key=value'")
+				log.Fatalf("extCode argument has wrong format: `%s`. Expected `key=<value>`", s)
 			}
 			m[split[0]] = fmt.Sprintf(`"%s"`, split[1])
 		}
diff --git a/cmd/tk/main.go b/cmd/tk/main.go
index bcfde512..aeba94ed 100644
--- a/cmd/tk/main.go
+++ b/cmd/tk/main.go
@@ -1,17 +1,15 @@
 package main
 
 import (
-	"flag"
 	"fmt"
 	"log"
 	"os"
 	"path/filepath"
 
-	"github.com/posener/complete"
-	"github.com/spf13/cobra"
 	"golang.org/x/crypto/ssh/terminal"
 
-	"github.com/grafana/tanka/pkg/cli/cmp"
+	"github.com/go-clix/cli"
+
 	"github.com/grafana/tanka/pkg/jsonnet/jpath"
 	"github.com/grafana/tanka/pkg/spec"
 	"github.com/grafana/tanka/pkg/spec/v1alpha1"
@@ -29,16 +27,12 @@ var (
 
 func main() {
 	log.SetFlags(0)
-	rootCmd := &cobra.Command{
-		Use:              "tk",
-		Short:            "tanka <3 jsonnet",
-		Version:          Version,
-		TraverseChildren: true,
-	}
-	rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "")
 
-	// Subcommands
-	cobra.EnableCommandSorting = false
+	rootCmd := &cli.Command{
+		Use:     "tk",
+		Short:   "tanka <3 jsonnet",
+		Version: Version,
+	}
 
 	// workflow commands
 	rootCmd.AddCommand(
@@ -60,30 +54,9 @@ func main() {
 		toolCmd(),
 	)
 
-	// completion
-	cmp.Handlers.Add("baseDir", complete.PredictFunc(
-		func(complete.Args) []string {
-			return findBaseDirs()
-		},
-	))
-
-	c := complete.New("tk", cmp.Create(rootCmd))
-	c.InstallName = "install-completion"
-	c.UninstallName = "uninstall-completion"
-	fs := &flag.FlagSet{}
-	c.AddFlags(fs)
-	rootCmd.Flags().AddGoFlagSet(fs)
-
-	rootCmd.Run = func(cmd *cobra.Command, args []string) {
-		if c.Complete() {
-			return
-		}
-		_ = cmd.Help()
-	}
-
 	// Run!
 	if err := rootCmd.Execute(); err != nil {
-		log.Fatalln("Ouch:", err)
+		log.Fatalln(err)
 	}
 }
 
diff --git a/cmd/tk/status.go b/cmd/tk/status.go
index cb893c64..3d956b6f 100644
--- a/cmd/tk/status.go
+++ b/cmd/tk/status.go
@@ -2,29 +2,26 @@ package main
 
 import (
 	"fmt"
-	"log"
 	"os"
 	"text/tabwriter"
 
 	"github.com/fatih/structs"
-	"github.com/spf13/cobra"
+
+	"github.com/go-clix/cli"
 
 	"github.com/grafana/tanka/pkg/tanka"
 )
 
-func statusCmd() *cobra.Command {
-	cmd := &cobra.Command{
+func statusCmd() *cli.Command {
+	cmd := &cli.Command{
 		Use:   "status <path>",
 		Short: "display an overview of the environment, including contents and metadata.",
-		Args:  cobra.ExactArgs(1),
-		Annotations: map[string]string{
-			"args": "baseDir",
-		},
+		Args:  workflowArgs,
 	}
-	cmd.Run = func(cmd *cobra.Command, args []string) {
+	cmd.Run = func(cmd *cli.Command, args []string) error {
 		status, err := tanka.Status(args[0])
 		if err != nil {
-			log.Fatalln(err)
+			return err
 		}
 
 		context := status.Client.Kubeconfig.Context
@@ -43,6 +40,8 @@ func statusCmd() *cobra.Command {
 			fmt.Fprintf(w, f, r.Metadata().Namespace(), r.Kind(), r.Metadata().Name())
 		}
 		w.Flush()
+
+		return nil
 	}
 	return cmd
 }
diff --git a/cmd/tk/tool.go b/cmd/tk/tool.go
index 4042f572..72f77e51 100644
--- a/cmd/tk/tool.go
+++ b/cmd/tk/tool.go
@@ -4,20 +4,19 @@ import (
 	"bytes"
 	"encoding/json"
 	"fmt"
-	"log"
 	"os"
 	"os/exec"
 	"path/filepath"
 	"strings"
 
-	"github.com/spf13/cobra"
+	"github.com/go-clix/cli"
 
 	"github.com/grafana/tanka/pkg/jsonnet"
 	"github.com/grafana/tanka/pkg/jsonnet/jpath"
 )
 
-func toolCmd() *cobra.Command {
-	cmd := &cobra.Command{
+func toolCmd() *cli.Command {
+	cmd := &cli.Command{
 		Short: "handy utilities for working with jsonnet",
 		Use:   "tool [command]",
 	}
@@ -26,97 +25,99 @@ func toolCmd() *cobra.Command {
 	return cmd
 }
 
-func jpathCmd() *cobra.Command {
-	cmd := &cobra.Command{
+func jpathCmd() *cli.Command {
+	cmd := &cli.Command{
 		Short: "print information about the jpath",
 		Use:   "jpath",
-		RunE: func(cmd *cobra.Command, args []string) error {
+		Run: func(cmd *cli.Command, args []string) error {
 			pwd, err := os.Getwd()
 			if err != nil {
 				return err
 			}
 			path, base, root, err := jpath.Resolve(pwd)
 			if err != nil {
-				log.Fatalln("Resolving JPATH:", err)
+				return fmt.Errorf("Resolving JPATH: %s", err)
 			}
+
 			fmt.Println("main:", filepath.Join(base, "main.jsonnet"))
 			fmt.Println("rootDir:", root)
 			fmt.Println("baseDir:", base)
 			fmt.Println("jpath:", path)
+
 			return nil
 		},
 	}
 	return cmd
 }
 
-func importsCmd() *cobra.Command {
-	cmd := &cobra.Command{
+func importsCmd() *cli.Command {
+	cmd := &cli.Command{
 		Use:   "imports <directory>",
 		Short: "list all transitive imports of an environment",
-		Args:  cobra.ExactArgs(1),
-		Annotations: map[string]string{
-			"args": "baseDir",
-		},
-		Run: func(cmd *cobra.Command, args []string) {
-			var modFiles []string
-			if cmd.Flag("check").Changed {
-				var err error
-				modFiles, err = gitChangedFiles(cmd.Flag("check").Value.String())
-				if err != nil {
-					log.Fatalln("invoking git:", err)
-				}
-			}
-
-			dir, err := filepath.Abs(args[0])
-			if err != nil {
-				log.Fatalln("Loading environment:", err)
-			}
-
-			fi, err := os.Stat(dir)
-			if err != nil {
-				log.Fatalln("Loading environment:", err)
-			}
-
-			if !fi.IsDir() {
-				log.Fatalln("The argument must be an environment's directory, but this does not seem to be the case.")
-			}
+		Args:  workflowArgs,
+	}
 
-			deps, err := jsonnet.TransitiveImports(dir)
-			if err != nil {
-				log.Fatalln("Resolving imports:", err)
-			}
+	check := cmd.Flags().StringP("check", "c", "", "git commit hash to check against")
 
-			root, err := gitRoot()
+	cmd.Run = func(cmd *cli.Command, args []string) error {
+		var modFiles []string
+		if *check != "" {
+			var err error
+			modFiles, err = gitChangedFiles(*check)
 			if err != nil {
-				log.Fatalln("Invoking git:", err)
+				return fmt.Errorf("invoking git: %s", err)
 			}
-			if modFiles != nil {
-				for _, m := range modFiles {
-					mod := filepath.Join(root, m)
-					if err != nil {
-						log.Fatalln(err)
-					}
+		}
+
+		dir, err := filepath.Abs(args[0])
+		if err != nil {
+			return fmt.Errorf("Loading environment: %s", err)
+		}
+
+		fi, err := os.Stat(dir)
+		if err != nil {
+			return fmt.Errorf("Loading environment: %s", err)
+		}
+
+		if !fi.IsDir() {
+			return fmt.Errorf("The argument must be an environment's directory, but this does not seem to be the case.")
+		}
+
+		deps, err := jsonnet.TransitiveImports(dir)
+		if err != nil {
+			return fmt.Errorf("Resolving imports: %s", err)
+		}
+
+		root, err := gitRoot()
+		if err != nil {
+			return fmt.Errorf("Invoking git: %s", err)
+		}
+		if modFiles != nil {
+			for _, m := range modFiles {
+				mod := filepath.Join(root, m)
+				if err != nil {
+					return err
+				}
 
-					for _, dep := range deps {
-						if mod == dep {
-							fmt.Printf("Rebuild required. File `%s` imports `%s`, which has been changed in `%s`.\n", args[0], dep, cmd.Flag("check").Value.String())
-							os.Exit(16)
-						}
+				for _, dep := range deps {
+					if mod == dep {
+						fmt.Printf("Rebuild required. File `%s` imports `%s`, which has been changed in `%s`.\n", args[0], dep, *check)
+						os.Exit(16)
 					}
 				}
-				fmt.Printf("Rebuild not required, because no imported files have been changed in `%s`.\n", cmd.Flag("check").Value.String())
-				os.Exit(0)
 			}
+			fmt.Printf("Rebuild not required, because no imported files have been changed in `%s`.\n", *check)
+			os.Exit(0)
+		}
 
-			s, err := json.Marshal(deps)
-			if err != nil {
-				log.Fatalln("Formatting:", err)
-			}
-			fmt.Println(string(s))
-		},
-	}
+		s, err := json.Marshal(deps)
+		if err != nil {
+			return fmt.Errorf("Formatting: %s", err)
+		}
+		fmt.Println(string(s))
 
-	cmd.Flags().StringP("check", "c", "", "git commit hash to check against")
+		return nil
+	}
 
 	return cmd
 }
diff --git a/cmd/tk/workflow.go b/cmd/tk/workflow.go
index 9cea31a4..f38d3b05 100644
--- a/cmd/tk/workflow.go
+++ b/cmd/tk/workflow.go
@@ -7,13 +7,13 @@ import (
 	"regexp"
 
 	"github.com/posener/complete"
-	"github.com/spf13/cobra"
 	"github.com/spf13/pflag"
 
-	"github.com/grafana/tanka/pkg/cli"
-	"github.com/grafana/tanka/pkg/cli/cmp"
+	"github.com/go-clix/cli"
+
 	"github.com/grafana/tanka/pkg/kubernetes/util"
 	"github.com/grafana/tanka/pkg/tanka"
+	"github.com/grafana/tanka/pkg/term"
 )
 
 // special exit codes for tk diff
@@ -34,22 +34,20 @@ func workflowFlags(fs *pflag.FlagSet) *workflowFlagVars {
 	return &v
 }
 
-func applyCmd() *cobra.Command {
-	cmd := &cobra.Command{
+func applyCmd() *cli.Command {
+	cmd := &cli.Command{
 		Use:   "apply <path>",
 		Short: "apply the configuration to the cluster",
-		Args:  cobra.ExactArgs(1),
-		Annotations: map[string]string{
-			"args": "baseDir",
-		},
+		Args:  workflowArgs,
 	}
+
 	vars := workflowFlags(cmd.Flags())
 	force := cmd.Flags().Bool("force", false, "force applying (kubectl apply --force)")
 	validate := cmd.Flags().Bool("validate", true, "validation of resources (kubectl --validate=false)")
 	autoApprove := cmd.Flags().Bool("dangerous-auto-approve", false, "skip interactive approval. Only for automation!")
 	getExtCode := extCodeParser(cmd.Flags())
 
-	cmd.Run = func(cmd *cobra.Command, args []string) {
+	cmd.Run = func(cmd *cli.Command, args []string) error {
 		err := tanka.Apply(args[0],
 			tanka.WithTargets(stringsToRegexps(vars.targets)...),
 			tanka.WithExtCode(getExtCode()),
@@ -58,23 +56,20 @@ func applyCmd() *cobra.Command {
 			tanka.WithApplyAutoApprove(*autoApprove),
 		)
 		if err != nil {
-			log.Fatalln(err)
+			return err
 		}
+		return nil
 	}
 	return cmd
 }
 
-func diffCmd() *cobra.Command {
-	// completion
-	cmp.Handlers.Add("diffStrategy", complete.PredictSet("native", "subset"))
-
-	cmd := &cobra.Command{
+func diffCmd() *cli.Command {
+	cmd := &cli.Command{
 		Use:   "diff <path>",
 		Short: "differences between the configuration and the cluster",
-		Args:  cobra.ExactArgs(1),
-		Annotations: map[string]string{
-			"args":                "baseDir",
-			"flags/diff-strategy": "diffStrategy",
+		Args:  workflowArgs,
+		Predictors: complete.Flags{
+			"diff-strategy": cli.PredictSet("native", "subset"),
 		},
 	}
 
@@ -87,7 +82,7 @@ func diffCmd() *cobra.Command {
 
 	getExtCode := extCodeParser(cmd.Flags())
 
-	cmd.Run = func(cmd *cobra.Command, args []string) {
+	cmd.Run = func(cmd *cli.Command, args []string) error {
 		changes, err := tanka.Diff(args[0],
 			tanka.WithTargets(stringsToRegexps(vars.targets)...),
 			tanka.WithExtCode(getExtCode()),
@@ -95,7 +90,7 @@ func diffCmd() *cobra.Command {
 			tanka.WithDiffSummarize(*summarize),
 		)
 		if err != nil {
-			log.Fatalln(err)
+			return err
 		}
 
 		if changes == nil {
@@ -104,36 +99,34 @@ func diffCmd() *cobra.Command {
 		}
 
 		if interactive {
-			r := cli.Colordiff(*changes)
+			r := term.Colordiff(*changes)
 			fPageln(r)
 		} else {
 			fmt.Println(*changes)
 		}
 
 		os.Exit(ExitStatusDiff)
+		return nil
 	}
 
 	return cmd
 }
 
-func showCmd() *cobra.Command {
-	cmd := &cobra.Command{
+func showCmd() *cli.Command {
+	cmd := &cli.Command{
 		Use:   "show <path>",
 		Short: "jsonnet as yaml",
-		Args:  cobra.ExactArgs(1),
-		Annotations: map[string]string{
-			"args": "baseDir",
-		},
+		Args:  workflowArgs,
 	}
 	vars := workflowFlags(cmd.Flags())
 	allowRedirect := cmd.Flags().Bool("dangerous-allow-redirect", false, "allow redirecting output to a file or a pipe.")
 	getExtCode := extCodeParser(cmd.Flags())
-	cmd.Run = func(cmd *cobra.Command, args []string) {
+	cmd.Run = func(cmd *cli.Command, args []string) error {
 		if !interactive && !*allowRedirect {
 			fmt.Fprintln(os.Stderr, `Redirection of the output of tk show is discouraged and disabled by default.
 If you want to export .yaml files for use with other tools, try 'tk export'.
 Otherwise run tk show --dangerous-allow-redirect to bypass this check.`)
-			return
+			return nil
 		}
 
 		pretty, err := tanka.Show(args[0],
@@ -141,10 +134,11 @@ Otherwise run tk show --dangerous-allow-redirect to bypass this check.`)
 			tanka.WithTargets(stringsToRegexps(vars.targets)...),
 		)
 		if err != nil {
-			log.Fatalln(err)
+			return err
 		}
 
 		pageln(pretty.String())
+		return nil
 	}
 	return cmd
 }
diff --git a/docs/docs/completion.md b/docs/docs/completion.md
index ef2d33de..df71acd2 100644
--- a/docs/docs/completion.md
+++ b/docs/docs/completion.md
@@ -9,10 +9,10 @@ Tanka supports CLI completion for `bash`, `zsh` and `fish`.
 
 ```bash
 # Install
-$ tk --install-completion
+$ tk complete
 
 # Uninstall
-$ tk --uninstall-completion
+$ tk complete --remove
 ```
 
 As tanka is its own completion handler, it needs to hook into your shell's
diff --git a/docs/docs/env-vars.md b/docs/docs/env-vars.md
index 550c8f11..d145f8d1 100644
--- a/docs/docs/env-vars.md
+++ b/docs/docs/env-vars.md
@@ -9,3 +9,8 @@ route: "/env-vars"
 
 **Description**: Path to the `kubectl` tool executable  
 **Default**: `$PATH/kubectl`
+
+### TANKA_KUBECTL_TRACE
+
+**Description**: Print all calls to `kubectl`  
+**Default**: `false`
diff --git a/go.mod b/go.mod
index 9dbca0cc..1487aa24 100644
--- a/go.mod
+++ b/go.mod
@@ -6,18 +6,18 @@ require (
 	github.com/Masterminds/semver v1.4.2
 	github.com/fatih/color v1.7.0
 	github.com/fatih/structs v1.1.0
+	github.com/go-clix/cli v0.1.0
 	github.com/google/go-jsonnet v0.14.1-0.20200127133145-399a61ca3178
 	github.com/kr/pretty v0.1.0 // indirect
 	github.com/pkg/errors v0.8.1
-	github.com/posener/complete v1.2.1
-	github.com/spf13/cobra v0.0.5
-	github.com/spf13/pflag v1.0.3
+	github.com/posener/complete v1.2.3
+	github.com/spf13/pflag v1.0.5
 	github.com/stretchr/objx v0.2.0
-	github.com/stretchr/testify v1.3.0
+	github.com/stretchr/testify v1.4.0
 	github.com/thoas/go-funk v0.4.0
-	golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2
-	golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f // indirect
+	golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586
+	golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456 // indirect
 	gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
-	gopkg.in/yaml.v2 v2.2.2
+	gopkg.in/yaml.v2 v2.2.8
 	gopkg.in/yaml.v3 v3.0.0-20191010095647-fc94e3f71652
 )
diff --git a/go.sum b/go.sum
index ddca8e83..61897204 100644
--- a/go.sum
+++ b/go.sum
@@ -1,12 +1,5 @@
-github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
-github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
 github.com/Masterminds/semver v1.4.2 h1:WBLTQ37jOCzSLtXNdoo8bNM8876KhNqOKvrlGITgsTc=
 github.com/Masterminds/semver v1.4.2/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
-github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
-github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
-github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
-github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
-github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
 github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -15,77 +8,55 @@ github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
 github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
 github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
 github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
-github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
-github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
+github.com/go-clix/cli v0.1.0 h1:8QvcZJvtHu3+/Eg4XXlGzhvqf7H8rL438xzvt5dcPME=
+github.com/go-clix/cli v0.1.0/go.mod h1:dYJevXraB9mXZFhz5clyQestG0qGcmT5rRC/P9etoRQ=
 github.com/google/go-jsonnet v0.14.1-0.20200127133145-399a61ca3178 h1:bgicsPQ2jV/XPlpdrVnHSpOgNnyXhgV6xRIOxsgfLFY=
 github.com/google/go-jsonnet v0.14.1-0.20200127133145-399a61ca3178/go.mod h1:ex9QcU8vzXQUDeNe4gaN1uhGQbTYpOeZ6AbWdy6JbX4=
 github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
 github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
 github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o=
 github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
-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.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
-github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
 github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
-github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY=
-github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
 github.com/mattn/go-colorable v0.1.1 h1:G1f5SKeVxmagw/IyvzvtZE4Gybcc4Tr1tf7I8z0XgOg=
 github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
 github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
 github.com/mattn/go-isatty v0.0.7 h1:UvyT9uN+3r7yLEYSlJsbQGdsaB/a0DlgWP3pql6iwOc=
 github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
-github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
-github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
-github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
-github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc=
-github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
 github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/posener/complete v1.2.1 h1:LrvDIY//XNo65Lq84G/akBuMGlawHvGBABv8f/ZN6DI=
-github.com/posener/complete v1.2.1/go.mod h1:6gapUrK/U1TAN7ciCoNRIdVC5sbdBTUh1DKN0g6uH7E=
-github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo=
-github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
+github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo=
+github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
 github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
 github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
-github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI=
-github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
-github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8=
-github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
-github.com/spf13/cobra v0.0.5 h1:f0B+LkLX6DtmRH1isoNA9VTtNUK9K8xYd28JNNfOv/s=
-github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
-github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk=
-github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
-github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg=
-github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
-github.com/spf13/viper v1.3.2 h1:VUFqw5KcqRf7i70GOzW7N+Q7+gxVBkSSqiXB12+JQ4M=
-github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
+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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48=
 github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
-github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 github.com/thoas/go-funk v0.4.0 h1:KBaa5NL7NMtsFlQaD8nQMbDt1wuM+OOaNQyYNYQFhVo=
 github.com/thoas/go-funk v0.4.0/go.mod h1:mlR+dHGb+4YgXkf13rkQTuzrneeHANxOm6+ZnEV9HsA=
-github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
-github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
-golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586 h1:7KByu05hhLed2MO29w7p1XfZvZ13m8mub3shuVftRs0=
+golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223 h1:DH4skfRX4EBpamg7iV4ZlCpblAHI6s6TDM39bFZumv8=
 golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190531175056-4c3a928424d2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f h1:25KHgbfyiSm6vwQLbM3zZIe1v9p/3ea4Rz+nnM5K/i4=
-golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456 h1:ng0gs1AKnRRuEMZoTLLlbOd+C17zUDepwGQBb/n+JVg=
+golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
@@ -94,5 +65,7 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v3 v3.0.0-20191010095647-fc94e3f71652 h1:VKvJ/mQ4BgCjZUDggYFxTe0qv9jPMHsZPD4Xt91Y5H4=
 gopkg.in/yaml.v3 v3.0.0-20191010095647-fc94e3f71652/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/pkg/cli/cmp/complete.go b/pkg/cli/cmp/complete.go
deleted file mode 100644
index 29201c4d..00000000
--- a/pkg/cli/cmp/complete.go
+++ /dev/null
@@ -1,76 +0,0 @@
-package cmp
-
-import (
-	"github.com/posener/complete"
-	"github.com/spf13/cobra"
-	"github.com/spf13/pflag"
-)
-
-// CompletionHandlers is a set of predictors
-type CompletionHandlers map[string]complete.Predictor
-
-// Add adds a predictor to the list of handlers
-func (h CompletionHandlers) Add(k string, p complete.Predictor) {
-	h[k] = p
-}
-
-// Get returns a predictor from the list of handlers
-func (h CompletionHandlers) Get(k string) complete.Predictor {
-	return h[k]
-}
-
-// GetOrNone returns a predictor in case it exists, otherwise complete.PredictNothing
-func (h CompletionHandlers) GetOrNone(k string) complete.Predictor {
-	if p, ok := h[k]; ok {
-		return p
-	}
-	return complete.PredictNothing
-}
-
-// Has returns whether a predictor exists
-func (h CompletionHandlers) Has(k string) bool {
-	_, ok := h[k]
-	return ok
-}
-
-// Handlers are global Handlers to be used in annotations
-var Handlers = CompletionHandlers{
-	"dirs": complete.PredictDirs("*"),
-}
-
-// Create parses a *cobra.Command into a complete.Command
-func Create(root *cobra.Command) complete.Command {
-	rootCmp := complete.Command{}
-
-	// Flags
-	rootCmp.Flags = make(complete.Flags)
-	addFlags := func(flag *pflag.Flag) {
-		if flag.Hidden {
-			return
-		}
-
-		handler := Handlers.GetOrNone(root.Annotations["flags/"+flag.Name])
-
-		if len(flag.Shorthand) > 0 {
-			rootCmp.Flags["-"+flag.Shorthand] = handler
-		}
-
-		rootCmp.Flags["--"+flag.Name] = handler
-	}
-	root.LocalFlags().VisitAll(addFlags)
-	root.InheritedFlags().VisitAll(addFlags)
-
-	// Subcommands
-	if root.HasAvailableSubCommands() {
-		rootCmp.Sub = make(complete.Commands)
-		for _, c := range root.Commands() {
-			if !c.Hidden {
-				rootCmp.Sub[c.Name()] = Create(c)
-			}
-		}
-	}
-
-	// Positional Arguments
-	rootCmp.Args = Handlers.GetOrNone(root.Annotations["args"])
-	return rootCmp
-}
diff --git a/pkg/kubernetes/apply.go b/pkg/kubernetes/apply.go
index c8b1aace..c6cd35d0 100644
--- a/pkg/kubernetes/apply.go
+++ b/pkg/kubernetes/apply.go
@@ -5,9 +5,9 @@ import (
 
 	"github.com/fatih/color"
 
-	"github.com/grafana/tanka/pkg/cli"
 	"github.com/grafana/tanka/pkg/kubernetes/client"
 	"github.com/grafana/tanka/pkg/kubernetes/manifest"
+	"github.com/grafana/tanka/pkg/term"
 )
 
 // ApplyOpts allow set additional parameters for the apply operation
@@ -20,7 +20,7 @@ func (k *Kubernetes) Apply(state manifest.List, opts ApplyOpts) error {
 	cluster := k.ctl.Info().Kubeconfig.Cluster
 	context := k.ctl.Info().Kubeconfig.Context
 	if !opts.AutoApprove {
-		if err := cli.Confirm(
+		if err := term.Confirm(
 			fmt.Sprintf(`Applying to namespace '%s' of cluster '%s' at '%s' using context '%s'.`,
 				alert(k.Spec.Namespace),
 				alert(cluster.Name),
diff --git a/pkg/kubernetes/client/client.go b/pkg/kubernetes/client/client.go
index 2f560c9d..c4d7bdc0 100644
--- a/pkg/kubernetes/client/client.go
+++ b/pkg/kubernetes/client/client.go
@@ -24,6 +24,8 @@ type Client interface {
 
 	// Namespaces the cluster currently has
 	Namespaces() (map[string]bool, error)
+	// Resources returns all known api-resources of the cluster
+	Resources() (Resources, error)
 
 	// Info returns known informational data about the client. Best effort based,
 	// fields of `Info` that cannot be stocked with valuable data, e.g.
diff --git a/pkg/kubernetes/client/diff.go b/pkg/kubernetes/client/diff.go
index 29abc35d..22aefbce 100644
--- a/pkg/kubernetes/client/diff.go
+++ b/pkg/kubernetes/client/diff.go
@@ -9,18 +9,11 @@ import (
 	"github.com/Masterminds/semver"
 
 	"github.com/grafana/tanka/pkg/kubernetes/manifest"
-	"github.com/grafana/tanka/pkg/kubernetes/util"
 )
 
 // DiffServerSide takes the desired state and computes the differences on the
 // server, returning them in `diff(1)` format
 func (k Kubectl) DiffServerSide(data manifest.List) (*string, error) {
-	ns, err := k.Namespaces()
-	if err != nil {
-		return nil, err
-	}
-
-	ready, missing := separateMissingNamespace(data, ns)
 	cmd := k.ctl("diff", "-f", "-")
 
 	raw := bytes.Buffer{}
@@ -29,28 +22,19 @@ func (k Kubectl) DiffServerSide(data manifest.List) (*string, error) {
 	fw := FilterWriter{filters: []*regexp.Regexp{regexp.MustCompile(`exit status \d`)}}
 	cmd.Stderr = &fw
 
-	cmd.Stdin = strings.NewReader(ready.String())
+	cmd.Stdin = strings.NewReader(data.String())
 
-	err = cmd.Run()
+	err := cmd.Run()
 	if diffErr := parseDiffErr(err, fw.buf, k.Info().ClientVersion); diffErr != nil {
 		return nil, diffErr
 	}
 
 	s := raw.String()
-	for _, r := range missing {
-		d, err := util.DiffStr(util.DiffName(r), "", r.String())
-		if err != nil {
-			return nil, err
-		}
-		s += d
-	}
-
-	if s != "" {
-		return &s, nil
+	if s == "" {
+		return nil, nil
 	}
 
-	// no diff -> nil
-	return nil, nil
+	return &s, nil
 }
 
 // parseDiffErr handles the exit status code of `kubectl diff`. It returns err
@@ -85,15 +69,3 @@ func parseDiffErr(err error, stderr string, version *semver.Version) error {
 	// differences found is not an error
 	return nil
 }
-
-func separateMissingNamespace(in manifest.List, exists map[string]bool) (ready, missingNamespace manifest.List) {
-	for _, r := range in {
-		// namespace does not exist, also ignore implicit default ("")
-		if ns := r.Metadata().Namespace(); ns != "" && !exists[ns] {
-			missingNamespace = append(missingNamespace, r)
-			continue
-		}
-		ready = append(ready, r)
-	}
-	return
-}
diff --git a/pkg/kubernetes/client/errors.go b/pkg/kubernetes/client/errors.go
index 8d25b96e..356f7831 100644
--- a/pkg/kubernetes/client/errors.go
+++ b/pkg/kubernetes/client/errors.go
@@ -25,12 +25,12 @@ func (e ErrorUnknownResource) Error() string {
 type ErrorNoContext string
 
 func (e ErrorNoContext) Error() string {
-	return fmt.Sprintf("no context named `%s` was found. Please check your $KUBECONFIG", e)
+	return fmt.Sprintf("no context named `%s` was found. Please check your $KUBECONFIG", string(e))
 }
 
 // ErrorNoCluster means that the cluster that was searched for couldn't be found
 type ErrorNoCluster string
 
 func (e ErrorNoCluster) Error() string {
-	return fmt.Sprintf("no cluster that matches the apiServer `%s` was found. Please check your $KUBECONFIG", e)
+	return fmt.Sprintf("no cluster that matches the apiServer `%s` was found. Please check your $KUBECONFIG", string(e))
 }
diff --git a/pkg/kubernetes/client/exec.go b/pkg/kubernetes/client/exec.go
index b4c5e12c..34ceb76c 100644
--- a/pkg/kubernetes/client/exec.go
+++ b/pkg/kubernetes/client/exec.go
@@ -32,6 +32,10 @@ func (k Kubectl) ctl(action string, args ...string) *exec.Cmd {
 	cmd := kubectlCmd(argv...)
 	cmd.Env = patchKubeconfig(k.nsPatch, os.Environ())
 
+	if os.Getenv("TANKA_KUBECTL_TRACE") != "" {
+		fmt.Println(cmd.String())
+	}
+
 	return cmd
 }
 
diff --git a/pkg/kubernetes/client/resources.go b/pkg/kubernetes/client/resources.go
new file mode 100644
index 00000000..6a30c1a0
--- /dev/null
+++ b/pkg/kubernetes/client/resources.go
@@ -0,0 +1,128 @@
+package client
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"os"
+	"regexp"
+	"strings"
+
+	"github.com/pkg/errors"
+
+	"github.com/grafana/tanka/pkg/kubernetes/manifest"
+)
+
+// Resources the Kubernetes API knows
+type Resources []Resource
+
+// Namespaced returns whether a resource is namespace-specific or cluster-wide
+func (r Resources) Namespaced(m manifest.Manifest) bool {
+	for _, res := range r {
+		if m.Kind() == res.Kind {
+			return res.Namespaced
+		}
+	}
+
+	return false
+}
+
+// Resource is a Kubernetes API Resource
+type Resource struct {
+	APIGroup   string `json:"APIGROUP"`
+	Kind       string `json:"KIND"`
+	Name       string `json:"NAME"`
+	Namespaced bool   `json:"NAMESPACED,string"`
+	Shortnames string `json:"SHORTNAMES"`
+}
+
+// Resources returns all API resources known to the server
+func (k Kubectl) Resources() (Resources, error) {
+	cmd := k.ctl("api-resources", "--cached")
+	var out bytes.Buffer
+	cmd.Stdout = &out
+	cmd.Stderr = os.Stderr
+
+	if err := cmd.Run(); err != nil {
+		return nil, err
+	}
+
+	var res Resources
+	if err := UnmarshalTable(out.String(), &res); err != nil {
+		return nil, errors.Wrap(err, "parsing table")
+	}
+
+	return res, nil
+}
+
+// UnmarshalTable unmarshals a raw CLI table into ptr. `json` package is used
+// for getting the dat into the ptr, `json:` struct tags can be used.
+func UnmarshalTable(raw string, ptr interface{}) error {
+	raw = strings.TrimSpace(raw)
+
+	lines := strings.Split(raw, "\n")
+	if len(lines) == 0 {
+		return ErrorNoHeader
+	}
+
+	headerStr := lines[0]
+	// headers are ALL-CAPS
+	if !regexp.MustCompile(`^[A-Z\s]+$`).MatchString(headerStr) {
+		return ErrorNoHeader
+	}
+
+	lines = lines[1:]
+
+	spc := regexp.MustCompile(`[A-Z]+\s*`)
+	header := spc.FindAllString(headerStr, -1)
+
+	tbl := make([]map[string]string, 0, len(lines))
+	for _, l := range lines {
+		elems := splitRow(l, header)
+		if len(elems) != len(header) {
+			return ErrorElementsMismatch{Header: len(header), Row: len(elems)}
+		}
+
+		row := make(map[string]string)
+		for i, e := range elems {
+			key := strings.TrimSpace(header[i])
+			row[key] = strings.TrimSpace(e)
+		}
+		tbl = append(tbl, row)
+	}
+
+	j, err := json.Marshal(tbl)
+	if err != nil {
+		return err
+	}
+
+	return json.Unmarshal(j, ptr)
+}
+
+// ErrorNoHeader occurs when the table lacks an ALL-CAPS header
+var ErrorNoHeader = fmt.Errorf("table has no header")
+
+// ErrorElementsMismatch occurs when a row has a different count of elements
+// than it's header
+type ErrorElementsMismatch struct {
+	Header, Row int
+}
+
+func (e ErrorElementsMismatch) Error() string {
+	return fmt.Sprintf("header and row have different element count: %v != %v", e.Header, e.Row)
+}
+
+func splitRow(s string, header []string) (elems []string) {
+	pos := 0
+	for i, h := range header {
+		if i == len(header)-1 {
+			elems = append(elems, s[pos:])
+			continue
+		}
+
+		lim := len(h)
+		elems = append(elems, s[pos:pos+lim])
+		pos += lim
+	}
+	return elems
+}
diff --git a/pkg/kubernetes/diff.go b/pkg/kubernetes/diff.go
new file mode 100644
index 00000000..ffe01e99
--- /dev/null
+++ b/pkg/kubernetes/diff.go
@@ -0,0 +1,188 @@
+package kubernetes
+
+import (
+	"fmt"
+
+	"github.com/grafana/tanka/pkg/kubernetes/client"
+	"github.com/grafana/tanka/pkg/kubernetes/manifest"
+	"github.com/grafana/tanka/pkg/kubernetes/util"
+	"github.com/pkg/errors"
+)
+
+// Diff takes the desired state and returns the differences from the cluster
+func (k *Kubernetes) Diff(state manifest.List, opts DiffOpts) (*string, error) {
+	// required for separating
+	namespaces, err := k.ctl.Namespaces()
+	if err != nil {
+		return nil, errors.Wrap(err, "listing namespaces")
+	}
+	resources, err := k.ctl.Resources()
+	if err != nil {
+		return nil, errors.Wrap(err, "listing known api-resources")
+	}
+
+	// separate resources in groups
+	//
+	// soon: resources that have unmet dependencies that will be met during
+	// apply. These will be diffed statically, because checking with the cluster
+	// would cause an error
+	//
+	// live: all other resources
+	live, soon := separate(state, k.Spec.Namespace, separateOpts{
+		namespaces: namespaces,
+		resources:  resources,
+	})
+
+	// differ for live resources
+	liveDiff, err := k.differ(opts.Strategy)
+	if err != nil {
+		return nil, err
+	}
+
+	// reports all resources as new
+	staticDiff := StaticDiffer(true)
+
+	// run the diff
+	d, err := multiDiff{
+		{differ: liveDiff, state: live},
+		{differ: staticDiff, state: soon},
+	}.diff()
+
+	switch {
+	case err != nil:
+		return nil, err
+	case d == nil:
+		return nil, nil
+	}
+
+	if opts.Summarize {
+		return util.Diffstat(*d)
+	}
+
+	return d, nil
+}
+
+type separateOpts struct {
+	namespaces map[string]bool
+	resources  client.Resources
+}
+
+func separate(state manifest.List, defaultNs string, opts separateOpts) (live manifest.List, soon manifest.List) {
+	soonNs := make(map[string]bool)
+	for _, m := range state {
+		if m.Kind() != "Namespace" {
+			continue
+		}
+		soonNs[m.Metadata().Name()] = true
+	}
+
+	for _, m := range state {
+		// non-namespaced always live
+		if !opts.resources.Namespaced(m) {
+			live = append(live, m)
+			continue
+		}
+
+		// handle implicit default
+		ns := m.Metadata().Namespace()
+		if ns == "" {
+			ns = defaultNs
+		}
+
+		// special case: namespace missing, BUT will be created during apply
+		if !opts.namespaces[ns] && soonNs[ns] {
+			soon = append(soon, m)
+			continue
+		}
+
+		// everything else
+		live = append(live, m)
+	}
+
+	return live, soon
+}
+
+// ErrorDiffStrategyUnknown occurs when a diff-strategy is requested that does
+// not exist.
+type ErrorDiffStrategyUnknown struct {
+	Requested string
+	differs   map[string]Differ
+}
+
+func (e ErrorDiffStrategyUnknown) Error() string {
+	strats := []string{}
+	for s := range e.differs {
+		strats = append(strats, s)
+	}
+	return fmt.Sprintf("diff strategy `%s` does not exist. Pick one of: %v", e.Requested, strats)
+}
+
+func (k *Kubernetes) differ(override string) (Differ, error) {
+	strategy := k.Spec.DiffStrategy
+	if override != "" {
+		strategy = override
+	}
+
+	d, ok := k.differs[strategy]
+	if !ok {
+		return nil, ErrorDiffStrategyUnknown{
+			Requested: strategy,
+			differs:   k.differs,
+		}
+	}
+
+	return d, nil
+}
+
+// StaticDiffer returns a differ that reports all resources as either created or
+// deleted.
+func StaticDiffer(create bool) Differ {
+	return func(state manifest.List) (*string, error) {
+		s := ""
+		for _, m := range state {
+			is, should := m.String(), ""
+			if create {
+				is, should = should, is
+			}
+
+			d, err := util.DiffStr(util.DiffName(m), is, should)
+			if err != nil {
+				return nil, err
+			}
+			s += d
+		}
+
+		if s == "" {
+			return nil, nil
+		}
+
+		return &s, nil
+	}
+}
+
+// multiDiff runs multiple differs (in series). In the future it might be worth
+// parallelizing this.
+type multiDiff []struct {
+	differ Differ
+	state  manifest.List
+}
+
+func (m multiDiff) diff() (*string, error) {
+	diff := ""
+	for _, d := range m {
+		s, err := d.differ(d.state)
+		if err != nil {
+			return nil, err
+		}
+
+		if s == nil {
+			continue
+		}
+		diff += *s
+	}
+
+	if diff == "" {
+		return nil, nil
+	}
+	return &diff, nil
+}
diff --git a/pkg/kubernetes/kubernetes.go b/pkg/kubernetes/kubernetes.go
index a343150a..ad0c01c7 100644
--- a/pkg/kubernetes/kubernetes.go
+++ b/pkg/kubernetes/kubernetes.go
@@ -4,11 +4,9 @@ import (
 	"fmt"
 
 	"github.com/Masterminds/semver"
-	"github.com/pkg/errors"
 
 	"github.com/grafana/tanka/pkg/kubernetes/client"
 	"github.com/grafana/tanka/pkg/kubernetes/manifest"
-	"github.com/grafana/tanka/pkg/kubernetes/util"
 	"github.com/grafana/tanka/pkg/spec/v1alpha1"
 )
 
@@ -32,7 +30,7 @@ func New(s v1alpha1.Spec) (*Kubernetes, error) {
 	// setup client
 	ctl, err := client.New(s.APIServer, s.Namespace)
 	if err != nil {
-		return nil, errors.Wrap(err, "creating client")
+		return nil, err
 	}
 
 	// setup diffing
@@ -70,28 +68,6 @@ type DiffOpts struct {
 	Strategy string
 }
 
-// Diff takes the desired state and returns the differences from the cluster
-func (k *Kubernetes) Diff(state manifest.List, opts DiffOpts) (*string, error) {
-	strategy := k.Spec.DiffStrategy
-	if opts.Strategy != "" {
-		strategy = opts.Strategy
-	}
-
-	d, err := k.differs[strategy](state)
-	switch {
-	case err != nil:
-		return nil, err
-	case d == nil:
-		return nil, nil
-	}
-
-	if opts.Summarize {
-		return util.Diffstat(*d)
-	}
-
-	return d, nil
-}
-
 // Info about the client, etc.
 func (k *Kubernetes) Info() client.Info {
 	return k.ctl.Info()
diff --git a/pkg/tanka/workflow.go b/pkg/tanka/workflow.go
index 316e0f59..7eda0a59 100644
--- a/pkg/tanka/workflow.go
+++ b/pkg/tanka/workflow.go
@@ -3,9 +3,9 @@ package tanka
 import (
 	"fmt"
 
-	"github.com/grafana/tanka/pkg/cli"
 	"github.com/grafana/tanka/pkg/kubernetes"
 	"github.com/grafana/tanka/pkg/kubernetes/manifest"
+	"github.com/grafana/tanka/pkg/term"
 )
 
 // Apply parses the environment at the given directory (a `baseDir`) and applies
@@ -37,7 +37,7 @@ func Apply(baseDir string, mods ...Modifier) error {
 		diff = &tmp
 	}
 
-	b := cli.Colordiff(*diff)
+	b := term.Colordiff(*diff)
 	fmt.Print(b.String())
 
 	return kube.Apply(p.Resources, opts.apply)
diff --git a/pkg/cli/alert.go b/pkg/term/alert.go
similarity index 97%
rename from pkg/cli/alert.go
rename to pkg/term/alert.go
index 7ea24655..f17e0983 100644
--- a/pkg/cli/alert.go
+++ b/pkg/term/alert.go
@@ -1,4 +1,4 @@
-package cli
+package term
 
 import (
 	"bufio"
diff --git a/pkg/cli/colordiff.go b/pkg/term/colordiff.go
similarity index 98%
rename from pkg/cli/colordiff.go
rename to pkg/term/colordiff.go
index 6575b185..bb6b30b0 100644
--- a/pkg/cli/colordiff.go
+++ b/pkg/term/colordiff.go
@@ -1,4 +1,4 @@
-package cli
+package term
 
 import (
 	"bytes"


Test patch:
diff --git a/pkg/kubernetes/client/diff_test.go b/pkg/kubernetes/client/diff_test.go
deleted file mode 100644
index 2b3dfa8d..00000000
--- a/pkg/kubernetes/client/diff_test.go
+++ /dev/null
@@ -1,92 +0,0 @@
-package client
-
-import (
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-
-	"github.com/grafana/tanka/pkg/kubernetes/manifest"
-)
-
-func TestSeparateMissingNamespace(t *testing.T) {
-	cases := []struct {
-		name string
-		td   nsTd
-
-		missing bool
-	}{
-		// default should always exist
-		{
-			name: "default",
-			td: newNsTd(func(m manifest.Metadata) {
-				m["namespace"] = "default"
-			}, []string{}),
-			missing: false,
-		},
-		// implcit default (not specfiying an ns at all) also
-		{
-			name: "implicit-default",
-			td: newNsTd(func(m manifest.Metadata) {
-				delete(m, "namespace")
-			}, []string{}),
-			missing: false,
-		},
-		// custom ns that exists
-		{
-			name: "custom-ns",
-			td: newNsTd(func(m manifest.Metadata) {
-				m["namespace"] = "custom"
-			}, []string{"custom"}),
-			missing: false,
-		},
-		// custom ns that does not exist
-		{
-			name: "missing-ns",
-			td: newNsTd(func(m manifest.Metadata) {
-				m["namespace"] = "missing"
-			}, []string{}),
-			missing: true,
-		},
-	}
-
-	for _, c := range cases {
-		t.Run(c.name, func(t *testing.T) {
-			ready, missing := separateMissingNamespace(manifest.List{c.td.m}, c.td.ns)
-			if c.missing {
-				assert.Lenf(t, ready, 0, "expected manifest to be missing (ready = 0)")
-				assert.Lenf(t, missing, 1, "expected manifest to be missing (missing = 1)")
-			} else {
-				assert.Lenf(t, ready, 1, "expected manifest to be ready (ready = 1)")
-				assert.Lenf(t, missing, 0, "expected manifest to be ready (missing = 0)")
-			}
-		})
-	}
-}
-
-type nsTd struct {
-	m  manifest.Manifest
-	ns map[string]bool
-}
-
-func newNsTd(f func(m manifest.Metadata), ns []string) nsTd {
-	m := manifest.Manifest{
-		"apiVersion": "apps/v1",
-		"kind":       "Deployment",
-		"metadata":   map[string]interface{}{},
-	}
-	if f != nil {
-		f(m.Metadata())
-	}
-
-	nsMap := map[string]bool{
-		"default": true, // you can't get rid of this one ever
-	}
-	for _, n := range ns {
-		nsMap[n] = true
-	}
-
-	return nsTd{
-		m:  m,
-		ns: nsMap,
-	}
-}
diff --git a/pkg/kubernetes/client/resources_test.go b/pkg/kubernetes/client/resources_test.go
new file mode 100644
index 00000000..137e08f3
--- /dev/null
+++ b/pkg/kubernetes/client/resources_test.go
@@ -0,0 +1,76 @@
+package client
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestUnmarshalTable(t *testing.T) {
+	cases := []struct {
+		name string
+		tbl  string
+		dest interface{}
+		want interface{}
+		err  error
+	}{
+		{
+			name: "normal",
+			tbl:  strings.TrimSpace(tblNormal),
+			want: &Resources{
+				{APIGroup: "apps", Name: "Deployment", Namespaced: true},
+				{APIGroup: "networking", Name: "Ingress", Namespaced: true},
+				{APIGroup: "", Name: "Namespace", Namespaced: false},
+				{APIGroup: "extensions", Name: "DaemonSet", Namespaced: true},
+			},
+			dest: &Resources{},
+		},
+		{
+			name: "empty",
+			tbl:  strings.TrimSpace(tblEmpty),
+			want: &Resources{},
+			dest: &Resources{
+				{APIGroup: "apps", Name: "Deployment", Namespaced: true},
+			},
+		},
+		{
+			name: "no-header",
+			tbl:  strings.TrimSpace(tblNoHeader),
+			err:  ErrorNoHeader,
+		},
+		{
+			name: "nothing",
+			tbl:  tblNothing,
+			err:  ErrorNoHeader,
+		},
+	}
+
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			err := UnmarshalTable(c.tbl, c.dest)
+			require.Equal(t, c.err, err)
+			assert.Equal(t, c.want, c.dest)
+		})
+	}
+}
+
+var tblNormal = `
+APIGROUP    NAME        NAMESPACED
+apps        Deployment  true
+networking  Ingress     true
+            Namespace   false
+extensions  DaemonSet   true
+`
+
+var tblEmpty = `
+APIGROUP    NAME        NAMESPACED
+`
+
+var tblNoHeader = `
+apps        Deployment  true
+networking  Ingress     true
+`
+
+var tblNothing = ``
diff --git a/pkg/kubernetes/diff_test.go b/pkg/kubernetes/diff_test.go
new file mode 100644
index 00000000..2151dd88
--- /dev/null
+++ b/pkg/kubernetes/diff_test.go
@@ -0,0 +1,146 @@
+package kubernetes
+
+import (
+	"testing"
+
+	"github.com/grafana/tanka/pkg/kubernetes/client"
+	"github.com/grafana/tanka/pkg/kubernetes/manifest"
+	"github.com/stretchr/testify/assert"
+)
+
+// TestSeparate checks that separate properly separates resources:
+//
+// - cluster-wide resources are always `live`
+// - resources with missing namespaces:
+//   - `soon` if their namespace is included in the Jsonnet
+//   - `live` otherwise, to cause a helpful error message
+//      for the user that the namespace is indeed missing
+// - `namespace: ""` should be properly translated into the default namespace
+func TestSeparate(t *testing.T) {
+	cases := []struct {
+		name string
+
+		// state returned from Jsonnet
+		state manifest.List
+		// namespaces that exist
+		namespaces []string
+
+		// the default namespace (no value -> implicit default '')
+		defaultNs string
+
+		// resources that can be checked with the cluster
+		live manifest.List
+		// resources depending on a condition that will be met on next apply
+		soon manifest.List
+	}{
+		{
+			name:      "multi",
+			defaultNs: "default",
+			namespaces: []string{
+				"default",
+				"kube-system",
+				"custom",
+			},
+			state: manifest.List{
+				// cluster-wide resources: always live
+				m("rbac.authorization.k8s.io/v1", "ClusterRole", "globalRole", ""),
+				m("rbac.authorization.k8s.io/v1", "ClusterRoleBinding", "binding", "whydoihaveanamespace"),
+
+				// default, existing namespace: `live`
+				m("apps/v1", "Deployment", "loki", ""),
+				m("apps/v1", "Deployment", "grafana", "default"),
+
+				// custom, existing namespace: `live`
+				m("apps/v1", "Deployment", "cortex", "custom"),
+
+				// custom, soon existing namespace:
+				m("v1", "Namespace", "monitoring", ""),                 // `live`
+				m("apps/v1", "Deployment", "prometheus", "monitoring"), // `soon`
+
+				// custom, missing namespace: `live`
+				m("apps/v1", "Deployment", "metrictank", "metrics"),
+			},
+			live: manifest.List{
+				m("rbac.authorization.k8s.io/v1", "ClusterRole", "globalRole", ""),
+				m("rbac.authorization.k8s.io/v1", "ClusterRoleBinding", "binding", "whydoihaveanamespace"),
+				m("apps/v1", "Deployment", "loki", ""),
+				m("apps/v1", "Deployment", "grafana", "default"),
+				m("apps/v1", "Deployment", "cortex", "custom"),
+				m("v1", "Namespace", "monitoring", ""),
+				m("apps/v1", "Deployment", "metrictank", "metrics"),
+			},
+			soon: manifest.List{
+				m("apps/v1", "Deployment", "prometheus", "monitoring"),
+			},
+		},
+		{
+			name:       "default/soon",
+			defaultNs:  "grafana",
+			namespaces: []string{"default", "kube-system"}, // `grafana` missing
+			state: manifest.List{
+				m("v1", "Namespace", "grafana", ""), // `grafana` created during apply
+				m("apps/v1", "Deployment", "prometheus", "grafana"),
+				m("apps/v1", "Deployment", "cortex", ""), // implicit default `""`
+			},
+			live: manifest.List{
+				m("v1", "Namespace", "grafana", ""),
+			},
+			soon: manifest.List{
+				m("apps/v1", "Deployment", "prometheus", "grafana"),
+				m("apps/v1", "Deployment", "cortex", ""),
+			},
+		},
+		{
+			name:       "default/missing",
+			defaultNs:  "grafana",
+			namespaces: []string{"default", "kube-system"}, // `grafana` missing
+			state: manifest.List{
+				// m("", "Namespace", "grafana", ""), <- `grafana` NOT created
+				m("apps/v1", "Deployment", "prometheus", "grafana"),
+				m("apps/v1", "Deployment", "cortex", ""), // implicit default (`""`)
+			},
+			live: manifest.List{
+				// `live`, so user notices missing ns
+				m("apps/v1", "Deployment", "prometheus", "grafana"),
+				m("apps/v1", "Deployment", "cortex", ""),
+			},
+		},
+	}
+
+	// static set of resources for this test (usually obtained using
+	// `client.Resources()`)
+	staticResources := client.Resources{
+		{APIGroup: "", Kind: "Namespace", Namespaced: false},
+		{APIGroup: "apps/v1", Kind: "Deployment", Namespaced: true},
+		{APIGroup: "rbac.authorization.k8s.io/v1", Kind: "ClusterRole", Namespaced: false},
+		{APIGroup: "rbac.authorization.k8s.io/v1", Kind: "ClusterRoleBinding", Namespaced: false},
+	}
+
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			namespaces := make(map[string]bool)
+			for _, n := range c.namespaces {
+				namespaces[n] = true
+			}
+
+			live, soon := separate(c.state, c.defaultNs, separateOpts{
+				namespaces: namespaces,
+				resources:  staticResources,
+			})
+
+			assert.ElementsMatch(t, c.live, live, "live")
+			assert.ElementsMatch(t, c.soon, soon, "soon")
+		})
+	}
+}
+
+func m(apiVersion, kind, name, namespace string) manifest.Manifest {
+	return manifest.Manifest{
+		"apiVersion": apiVersion,
+		"kind":       kind,
+		"metadata": map[string]interface{}{
+			"name":      name,
+			"namespace": namespace,
+		},
+	}
+}
diff --git a/pkg/cli/colordiff_test.go b/pkg/term/colordiff_test.go
similarity index 99%
rename from pkg/cli/colordiff_test.go
rename to pkg/term/colordiff_test.go
index 13d1b32d..6add8718 100644
--- a/pkg/cli/colordiff_test.go
+++ b/pkg/term/colordiff_test.go
@@ -1,4 +1,4 @@
-package cli
+package term
 
 import (
 	"io/ioutil"