diff --git a/.github/actions/kustomize/Dockerfile b/.github/actions/kustomize/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..2ebd6334a69711340f7904a3a901a2cb4dfe280c
--- /dev/null
+++ b/.github/actions/kustomize/Dockerfile
@@ -0,0 +1,6 @@
+FROM giantswarm/tiny-tools
+
+COPY entrypoint.sh /entrypoint.sh
+RUN chmod +x /entrypoint.sh
+
+ENTRYPOINT ["/entrypoint.sh"]
diff --git a/.github/actions/kustomize/action.yml b/.github/actions/kustomize/action.yml
new file mode 100644
index 0000000000000000000000000000000000000000..bd53d1d73e5576b7a1856de02e3d4e7f8cb6410d
--- /dev/null
+++ b/.github/actions/kustomize/action.yml
@@ -0,0 +1,9 @@
+name: 'kustomize'
+description: 'A GitHub Action to run kustomize commands'
+author: 'Stefan Prodan'
+branding:
+  icon: 'command'
+  color: 'blue'
+runs:
+  using: 'docker'
+  image: 'Dockerfile'
diff --git a/.github/actions/kustomize/entrypoint.sh b/.github/actions/kustomize/entrypoint.sh
new file mode 100644
index 0000000000000000000000000000000000000000..b9d560c920cb64fcd8b5ab442dad45a26786daaf
--- /dev/null
+++ b/.github/actions/kustomize/entrypoint.sh
@@ -0,0 +1,12 @@
+#!/bin/sh -l
+
+VERSION=3.5.4
+curl -sL https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv${VERSION}/kustomize_v${VERSION}_linux_amd64.tar.gz | tar xz
+
+mkdir -p $GITHUB_WORKSPACE/bin
+cp ./kustomize $GITHUB_WORKSPACE/bin
+chmod +x $GITHUB_WORKSPACE/bin/kustomize
+ls -lh $GITHUB_WORKSPACE/bin
+
+echo "::add-path::$GITHUB_WORKSPACE/bin"
+echo "::add-path::$RUNNER_WORKSPACE/$(basename $GITHUB_REPOSITORY)/bin"
diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..699015f21a9c1b545373b108cae04dd6d3274ab3
--- /dev/null
+++ b/.github/workflows/e2e.yaml
@@ -0,0 +1,50 @@
+name: e2e
+
+on:
+  pull_request:
+  push:
+    branches:
+      - master
+
+jobs:
+  kind:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v2
+      - name: Restore Go cache
+        uses: actions/cache@v1
+        with:
+          path: ~/go/pkg/mod
+          key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
+          restore-keys: |
+            ${{ runner.os }}-go-
+      - name: Setup Go
+        uses: actions/setup-go@v2-beta
+        with:
+          go-version: 1.14.x
+      - name: Setup Kubernetes
+        uses: engineerd/setup-kind@v0.3.0
+      - name: Setup Kustomize
+        uses: ./.github/actions/kustomize
+      - name: Run test
+        run: make test
+      - name: Check if working tree is dirty
+        run: |
+          if [[ $(git diff --stat) != '' ]]; then
+            echo 'run make test and commit changes'
+            exit 1
+          fi
+      - name: Build
+        run: sudo go build -o ./bin/tk ./cmd/tk
+      - name: Run integration tests
+        run: |
+          ./bin/tk check
+          ./bin/tk install --manifests ./manifests/install/
+          ./bin/tk create source podinfo --git-url https://github.com/stefanprodan/podinfo-deploy  --git-semver=">=0.0.1-rc.1 <0.1.0"
+      - name: Debug failure
+        if: failure()
+        run: |
+          kubectl version --client --short
+          kustomize version --short
+          kubectl -n gitops-system get all
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..f0deead17837c5e5f7b53a86a679abec7a040344
--- /dev/null
+++ b/.github/workflows/release.yaml
@@ -0,0 +1,34 @@
+name: release
+
+on:
+  push:
+    tags:
+      - '*'
+
+jobs:
+  goreleaser:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v2
+      - name: Unshallow
+        run: git fetch --prune --unshallow
+      - name: Setup Go
+        uses: actions/setup-go@v2-beta
+        with:
+          go-version: 1.14.x
+      - name: Download release notes utility
+        env:
+          GH_REL_URL: https://github.com/buchanae/github-release-notes/releases/download/0.2.0/github-release-notes-linux-amd64-0.2.0.tar.gz
+        run: cd /tmp && curl -sSL ${GH_REL_URL} | tar xz && sudo mv github-release-notes /usr/local/bin/
+      - name: Generate release notes
+        run: |
+          echo 'CHANGELOG' > /tmp/release.txt
+          github-release-notes -org fluxcd -repo toolkit -since-latest-release >> /tmp/release.txt
+      - name: Run GoReleaser
+        uses: goreleaser/goreleaser-action@v1
+        with:
+          version: latest
+          args: release --release-notes=/tmp/release.txt
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.goreleaser.yml b/.goreleaser.yml
new file mode 100644
index 0000000000000000000000000000000000000000..279671ece66878d07e49ecf0e2919fe4a38b0695
--- /dev/null
+++ b/.goreleaser.yml
@@ -0,0 +1,14 @@
+builds:
+  - main: ./cmd/tk
+    binary: tk
+    goos:
+      - darwin
+      - linux
+    goarch:
+      - amd64
+    env:
+      - CGO_ENABLED=0
+archives:
+  - name_template: "{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
+    files:
+      - none*
diff --git a/Makefile b/Makefile
index 2ada45ec445aface92c680ba24715db04186b7e8..b5c78f2c685e85a550f272db40a67d81b0e8d261 100644
--- a/Makefile
+++ b/Makefile
@@ -1,9 +1,9 @@
 VERSION?=$(shell grep 'VERSION' cmd/tk/main.go | awk '{ print $$4 }' | tr -d '"')
 
-all: tidy fmt vet test build
+all: test build
 
-build:
-	CGO_ENABLED=0 go build -o ./bin/tk ./cmd/tk
+tidy:
+	go mod tidy
 
 fmt:
 	go fmt ./...
@@ -11,9 +11,11 @@ fmt:
 vet:
 	go vet ./...
 
-tidy:
-	go mod tidy
-
-test:
+test: tidy fmt vet
 	go test ./... -coverprofile cover.out
 
+build:
+	CGO_ENABLED=0 go build -o ./bin/tk ./cmd/tk
+
+install:
+	go install cmd/tk
diff --git a/README.md b/README.md
index c2765f6ada4e78d7924f21ef177ecf4336142fd9..9a2d2859bf08fc1c5d64f10b9bba144c8cfce636 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,9 @@
 # toolkit
-Experimental toolkit for assembling CD pipelines
+
+[![e2e](https://github.com/fluxcd/toolkit/workflows/e2e/badge.svg)](https://github.com/fluxcd/toolkit/actions)
+
+Experimental toolkit for assembling CD pipelines.
+
+Components:
+* [source-controller](https://github.com/fluxcd/source-controller)
+* [kustomize-controller](https://github.com/fluxcd/kustomize-controller)
diff --git a/cmd/tk/check.go b/cmd/tk/check.go
index 4ebb03ab4a59426d8208254d6489b71d55ecc549..b201c05f967ec3391b0d75c33e7631164334f9e3 100644
--- a/cmd/tk/check.go
+++ b/cmd/tk/check.go
@@ -1,16 +1,17 @@
 package main
 
 import (
-	"fmt"
 	"os"
 	"os/exec"
+	"strings"
 
+	"github.com/blang/semver"
 	"github.com/spf13/cobra"
 )
 
 var checkCmd = &cobra.Command{
-	Use:   "check --pre",
-	Short: "Check for potential problems",
+	Use:   "check",
+	Short: "Check requirements",
 	Long: `
 The check command will perform a series of checks to validate that
 the local environment is configured correctly.`,
@@ -30,49 +31,145 @@ func init() {
 }
 
 func runCheckCmd(cmd *cobra.Command, args []string) error {
-	if !checkLocal() {
-		os.Exit(1)
+	logAction("starting verification")
+	checkFailed := false
+	if !sshCheck() {
+		checkFailed = true
+	}
+
+	if !kubectlCheck(">=1.18.0") {
+		checkFailed = true
+	}
+
+	if !kustomizeCheck(">=3.5.0") {
+		checkFailed = true
 	}
+
 	if checkPre {
-		fmt.Println(`✔`, "all prerequisites checks passed")
+		if checkFailed {
+			os.Exit(1)
+		}
+		logSuccess("all prerequisites checks passed")
 		return nil
 	}
 
-	if !checkRemote() {
+	if !kubernetesCheck(">=1.14.0") {
+		checkFailed = true
+	}
+
+	if checkFailed {
 		os.Exit(1)
-	} else {
-		fmt.Println(`✔`, "all checks passed")
 	}
+	logSuccess("all checks passed")
 	return nil
 }
 
-func checkLocal() bool {
+func sshCheck() bool {
 	ok := true
-	for _, cmd := range []string{"kubectl", "kustomize"} {
+	for _, cmd := range []string{"ssh-keygen", "ssh-keyscan"} {
 		_, err := exec.LookPath(cmd)
 		if err != nil {
-			fmt.Println(`✗`, cmd, "not found")
+			logFailure("%s not found", cmd)
 			ok = false
 		} else {
-			fmt.Println(`✔`, cmd, "found")
+			logSuccess("%s found", cmd)
 		}
 	}
+
 	return ok
 }
 
-func checkRemote() bool {
-	client, err := NewKubernetesClient()
+func kubectlCheck(version string) bool {
+	_, err := exec.LookPath("kubectl")
 	if err != nil {
-		fmt.Println(`✗`, "kubernetes client initialization failed", err.Error())
+		logFailure("kubectl not found")
+		return false
+	}
+
+	output, err := execCommand("kubectl version --client --short | awk '{ print $3 }'")
+	if err != nil {
+		logFailure("kubectl version can't be determined")
+		return false
+	}
+
+	v, err := semver.ParseTolerant(output)
+	if err != nil {
+		logFailure("kubectl version can't be parsed")
+		return false
+	}
+
+	rng, _ := semver.ParseRange(version)
+	if !rng(v) {
+		logFailure("kubectl version must be %s", version)
+		return false
+	}
+
+	logSuccess("kubectl %s %s", v.String(), version)
+	return true
+}
+
+func kustomizeCheck(version string) bool {
+	_, err := exec.LookPath("kustomize")
+	if err != nil {
+		logFailure("kustomize not found")
+		return false
+	}
+
+	output, err := execCommand("kustomize version --short | awk '{ print $1 }' | cut -c2-")
+	if err != nil {
+		logFailure("kustomize version can't be determined")
+		return false
+	}
+
+	if strings.Contains(output, "kustomize/") {
+		output, err = execCommand("kustomize version --short | awk '{ print $1 }' | cut -c12-")
+		if err != nil {
+			logFailure("kustomize version can't be determined")
+			return false
+		}
+	}
+
+	v, err := semver.ParseTolerant(output)
+	if err != nil {
+		logFailure("kustomize version can't be parsed")
+		return false
+	}
+
+	rng, _ := semver.ParseRange(version)
+	if !rng(v) {
+		logFailure("kustomize version must be %s", version)
+		return false
+	}
+
+	logSuccess("kustomize %s %s", v.String(), version)
+	return true
+}
+
+func kubernetesCheck(version string) bool {
+	client, err := kubernetesClient()
+	if err != nil {
+		logFailure("kubernetes client initialization failed: %s", err.Error())
 		return false
 	}
 
 	ver, err := client.Discovery().ServerVersion()
 	if err != nil {
-		fmt.Println(`✗`, "kubernetes API call failed", err.Error())
+		logFailure("kubernetes API call failed %s", err.Error())
+		return false
+	}
+
+	v, err := semver.ParseTolerant(ver.String())
+	if err != nil {
+		logFailure("kubernetes version can't be determined")
+		return false
+	}
+
+	rng, _ := semver.ParseRange(version)
+	if !rng(v) {
+		logFailure("kubernetes version must be %s", version)
 		return false
 	}
 
-	fmt.Println(`✔`, "kubernetes version", ver.String())
+	logSuccess("kubernetes %s %s", v.String(), version)
 	return true
 }
diff --git a/cmd/tk/create.go b/cmd/tk/create.go
new file mode 100644
index 0000000000000000000000000000000000000000..f63c1c5f55db5f4a11e4c84d50352a74b277e891
--- /dev/null
+++ b/cmd/tk/create.go
@@ -0,0 +1,19 @@
+package main
+
+import (
+	"github.com/spf13/cobra"
+)
+
+var createCmd = &cobra.Command{
+	Use:   "create",
+	Short: "Create commands",
+}
+
+var (
+	interval string
+)
+
+func init() {
+	createCmd.PersistentFlags().StringVar(&interval, "interval", "1m", "source sync interval")
+	rootCmd.AddCommand(createCmd)
+}
diff --git a/cmd/tk/create_source.go b/cmd/tk/create_source.go
new file mode 100644
index 0000000000000000000000000000000000000000..543a6777de8f90a38188ff9f57c27c2225919aed
--- /dev/null
+++ b/cmd/tk/create_source.go
@@ -0,0 +1,236 @@
+package main
+
+import (
+	"bufio"
+	"bytes"
+	"context"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"net/url"
+	"os"
+	"os/exec"
+	"strings"
+	"text/template"
+
+	"github.com/manifoldco/promptui"
+	"github.com/spf13/cobra"
+)
+
+var createSourceCmd = &cobra.Command{
+	Use:   "source [name]",
+	Short: "Create source resource",
+	Long: `
+The create source command generates a source.fluxcd.io resource and waits for it to sync.
+For Git over SSH, host and SSH keys are automatically generated.`,
+	Example: `  # Create a gitrepository.source.fluxcd.io for a public repository
+  create source podinfo --git-url https://github.com/stefanprodan/podinfo-deploy --git-branch master
+
+  # Create a gitrepository.source.fluxcd.io that syncs tags based on a semver range
+  create source podinfo --git-url https://github.com/stefanprodan/podinfo-deploy  --git-semver=">=0.0.1-rc.1 <0.1.0"
+
+  # Create a gitrepository.source.fluxcd.io with SSH authentication
+  create source podinfo --git-url ssh://git@github.com/stefanprodan/podinfo-deploy
+
+  # Create a gitrepository.source.fluxcd.io with basic authentication
+  create source podinfo --git-url https://github.com/stefanprodan/podinfo-deploy -u username -p password
+`,
+	RunE: createSourceCmdRun,
+}
+
+var (
+	sourceGitURL    string
+	sourceGitBranch string
+	sourceGitSemver string
+	sourceUsername  string
+	sourcePassword  string
+	sourceVerbose   bool
+)
+
+func init() {
+	createSourceCmd.Flags().StringVar(&sourceGitURL, "git-url", "", "git address, e.g. ssh://git@host/org/repository")
+	createSourceCmd.Flags().StringVar(&sourceGitBranch, "git-branch", "master", "git branch")
+	createSourceCmd.Flags().StringVar(&sourceGitSemver, "git-semver", "", "git tag semver range")
+	createSourceCmd.Flags().StringVarP(&sourceUsername, "username", "u", "", "basic authentication username")
+	createSourceCmd.Flags().StringVarP(&sourcePassword, "password", "p", "", "basic authentication password")
+	createSourceCmd.Flags().BoolVarP(&sourceVerbose, "verbose", "", false, "print generated source object")
+
+	createCmd.AddCommand(createSourceCmd)
+}
+
+func createSourceCmdRun(cmd *cobra.Command, args []string) error {
+	if len(args) < 1 {
+		return fmt.Errorf("source name is required")
+	}
+	name := args[0]
+
+	if sourceGitURL == "" {
+		return fmt.Errorf("git-url is required")
+	}
+
+	tmpDir, err := ioutil.TempDir("", name)
+	if err != nil {
+		return err
+	}
+	defer os.RemoveAll(tmpDir)
+
+	u, err := url.Parse(sourceGitURL)
+	if err != nil {
+		return fmt.Errorf("git URL parse failed: %w", err)
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), timeout)
+	defer cancel()
+
+	withAuth := false
+	if strings.HasPrefix(sourceGitURL, "ssh") {
+		if err := generateSSH(name, u.Host, tmpDir); err != nil {
+			return err
+		}
+		withAuth = true
+	} else if sourceUsername != "" && sourcePassword != "" {
+		if err := generateBasicAuth(name); err != nil {
+			return err
+		}
+		withAuth = true
+	}
+
+	logAction("generating source %s in %s namespace", name, namespace)
+
+	t, err := template.New("tmpl").Parse(gitSource)
+	if err != nil {
+		return fmt.Errorf("template parse error: %w", err)
+	}
+
+	source := struct {
+		Name      string
+		Namespace string
+		URL       string
+		Branch    string
+		Semver    string
+		Interval  string
+		WithAuth  bool
+	}{
+		Name:      name,
+		Namespace: namespace,
+		URL:       sourceGitURL,
+		Branch:    sourceGitBranch,
+		Semver:    sourceGitSemver,
+		Interval:  interval,
+		WithAuth:  withAuth,
+	}
+
+	var data bytes.Buffer
+	writer := bufio.NewWriter(&data)
+	if err := t.Execute(writer, source); err != nil {
+		return fmt.Errorf("template execution failed: %w", err)
+	}
+	if err := writer.Flush(); err != nil {
+		return fmt.Errorf("source flush failed: %w", err)
+	}
+
+	if sourceVerbose {
+		fmt.Print(data.String())
+	}
+
+	command := fmt.Sprintf("echo '%s' | kubectl apply -f-", data.String())
+	c := exec.CommandContext(ctx, "/bin/sh", "-c", command)
+
+	var stdoutBuf, stderrBuf bytes.Buffer
+	c.Stdout = io.MultiWriter(os.Stdout, &stdoutBuf)
+	c.Stderr = io.MultiWriter(os.Stderr, &stderrBuf)
+
+	err = c.Run()
+	if err != nil {
+		return fmt.Errorf("source apply failed")
+	}
+
+	logAction("waiting for source sync")
+	if output, err := execCommand(fmt.Sprintf(
+		"kubectl -n %s wait gitrepository/%s --for=condition=ready --timeout=1m",
+		namespace, name)); err != nil {
+		return fmt.Errorf("source sync failed: %s", output)
+	} else {
+		fmt.Print(output)
+	}
+
+	return nil
+}
+
+func generateBasicAuth(name string) error {
+	logAction("saving credentials")
+	credentials := fmt.Sprintf("--from-literal=username='%s' --from-literal=password='%s'",
+		sourceUsername, sourcePassword)
+	secret := fmt.Sprintf("kubectl -n %s create secret generic %s %s --dry-run=client -oyaml | kubectl apply -f-",
+		namespace, name, credentials)
+	if output, err := execCommand(secret); err != nil {
+		return fmt.Errorf("kubectl create secret failed: %s", output)
+	} else {
+		fmt.Print(output)
+	}
+	return nil
+}
+
+func generateSSH(name, host, tmpDir string) error {
+	logAction("generating host key for %s", host)
+
+	keyscan := fmt.Sprintf("ssh-keyscan %s > %s/known_hosts", host, tmpDir)
+	if output, err := execCommand(keyscan); err != nil {
+		return fmt.Errorf("ssh-keyscan failed: %s", output)
+	}
+
+	logAction("generating deploy key")
+
+	keygen := fmt.Sprintf("ssh-keygen -b 2048 -t rsa -f %s/identity -q -N \"\"", tmpDir)
+	if output, err := execCommand(keygen); err != nil {
+		return fmt.Errorf("ssh-keygen failed: %s", output)
+	}
+
+	deployKey, err := execCommand(fmt.Sprintf("cat %s/identity.pub", tmpDir))
+	if err != nil {
+		return fmt.Errorf("unable to read identity.pub: %w", err)
+	}
+
+	fmt.Print(deployKey)
+	prompt := promptui.Prompt{
+		Label:     "Have you added the deploy key to your repository",
+		IsConfirm: true,
+	}
+	if _, err := prompt.Run(); err != nil {
+		logFailure("aborting")
+		os.Exit(1)
+	}
+
+	logAction("saving deploy key")
+	files := fmt.Sprintf("--from-file=%s/identity --from-file=%s/identity.pub --from-file=%s/known_hosts",
+		tmpDir, tmpDir, tmpDir)
+	secret := fmt.Sprintf("kubectl -n %s create secret generic %s %s --dry-run=client -oyaml | kubectl apply -f-",
+		namespace, name, files)
+	if output, err := execCommand(secret); err != nil {
+		return fmt.Errorf("kubectl create secret failed: %s", output)
+	} else {
+		fmt.Print(output)
+	}
+	return nil
+}
+
+var gitSource = `---
+apiVersion: source.fluxcd.io/v1alpha1
+kind: GitRepository
+metadata:
+  name: {{.Name}}
+  namespace: {{.Namespace}}
+spec:
+  interval: {{.Interval}}
+  url: {{.URL}}
+  ref:
+{{- if .Semver }}
+    semver: "{{.Semver}}"
+{{- else }}
+    branch: {{.Branch}}
+{{- end }}
+{{- if .WithAuth }}
+  secretRef:
+    name: {{.Name}}
+{{- end }}
+`
diff --git a/cmd/tk/install.go b/cmd/tk/install.go
new file mode 100644
index 0000000000000000000000000000000000000000..1cb12467f07668ff59529152fb40f66726828a85
--- /dev/null
+++ b/cmd/tk/install.go
@@ -0,0 +1,93 @@
+package main
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"io"
+	"os"
+	"os/exec"
+	"strings"
+
+	"github.com/spf13/cobra"
+)
+
+var installCmd = &cobra.Command{
+	Use:   "install",
+	Short: "Install the toolkit components",
+	Long: `
+The Install command deploys the toolkit components
+on the configured Kubernetes cluster in ~/.kube/config`,
+	Example: `  install --manifests github.com/fluxcd/toolkit//manifests/install --dry-run`,
+	RunE:    installCmdRun,
+}
+
+var (
+	installDryRun        bool
+	installManifestsPath string
+)
+
+func init() {
+	installCmd.Flags().BoolVarP(&installDryRun, "dry-run", "", false,
+		"only print the object that would be applied")
+	installCmd.Flags().StringVarP(&installManifestsPath, "manifests", "", "",
+		"path to the manifest directory")
+
+	rootCmd.AddCommand(installCmd)
+}
+
+func installCmdRun(cmd *cobra.Command, args []string) error {
+	if installManifestsPath == "" {
+		return fmt.Errorf("no manifests specified")
+	}
+
+	if !strings.HasPrefix(installManifestsPath, "github.com/") {
+		if _, err := os.Stat(installManifestsPath); err != nil {
+			return fmt.Errorf("manifests not found: %w", err)
+		}
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), timeout)
+	defer cancel()
+
+	dryRun := ""
+	if installDryRun {
+		dryRun = "--dry-run=client"
+	}
+	command := fmt.Sprintf("kustomize build %s | kubectl apply -f- %s",
+		installManifestsPath, dryRun)
+	c := exec.CommandContext(ctx, "/bin/sh", "-c", command)
+
+	var stdoutBuf, stderrBuf bytes.Buffer
+	c.Stdout = io.MultiWriter(os.Stdout, &stdoutBuf)
+	c.Stderr = io.MultiWriter(os.Stderr, &stderrBuf)
+
+	logAction("installing components in %s namespace", namespace)
+	err := c.Run()
+	if err != nil {
+		logFailure("install failed")
+		os.Exit(1)
+	}
+
+	if installDryRun {
+		logSuccess("install dry-run finished")
+		return nil
+	}
+
+	logAction("verifying installation")
+	for _, deployment := range []string{"source-controller", "kustomize-controller"} {
+		command = fmt.Sprintf("kubectl -n %s rollout status deployment %s --timeout=%s",
+			namespace, deployment, timeout.String())
+		c = exec.CommandContext(ctx, "/bin/sh", "-c", command)
+		c.Stdout = io.MultiWriter(os.Stdout, &stdoutBuf)
+		c.Stderr = io.MultiWriter(os.Stderr, &stderrBuf)
+		err := c.Run()
+		if err != nil {
+			logFailure("install failed")
+			os.Exit(1)
+		}
+	}
+
+	logSuccess("install finished")
+	return nil
+}
diff --git a/cmd/tk/main.go b/cmd/tk/main.go
index e85426312870b2794b3b6249401ddcfc8f53f205..33aa2178d9e89f6db32d97a4517b5db9e297540a 100644
--- a/cmd/tk/main.go
+++ b/cmd/tk/main.go
@@ -4,8 +4,9 @@ import (
 	"fmt"
 	"log"
 	"os"
+	"os/exec"
 	"path/filepath"
-	"strings"
+	"time"
 
 	"github.com/spf13/cobra"
 	"k8s.io/client-go/kubernetes"
@@ -16,13 +17,17 @@ import (
 var VERSION = "0.0.1"
 
 var rootCmd = &cobra.Command{
-	Use:     "tk",
-	Short:   "Kubernetes CD assembler",
-	Version: VERSION,
+	Use:           "tk",
+	Short:         "Kubernetes CD assembler",
+	Version:       VERSION,
+	SilenceUsage:  true,
+	SilenceErrors: true,
 }
 
 var (
 	kubeconfig string
+	namespace  string
+	timeout    time.Duration
 )
 
 func init() {
@@ -30,18 +35,19 @@ func init() {
 		rootCmd.PersistentFlags().StringVarP(&kubeconfig, "kubeconfig", "", filepath.Join(home, ".kube", "config"),
 			"path to the kubeconfig file")
 	} else {
-		checkCmd.PersistentFlags().StringVarP(&kubeconfig, "kubeconfig", "", "",
+		rootCmd.PersistentFlags().StringVarP(&kubeconfig, "kubeconfig", "", "",
 			"absolute path to the kubeconfig file")
 	}
+	rootCmd.PersistentFlags().StringVarP(&namespace, "namespace", "", "gitops-system",
+		"the namespace scope for this operation")
+	rootCmd.PersistentFlags().DurationVarP(&timeout, "timeout", "", 5*time.Minute,
+		"timeout for this operation")
 }
 
 func main() {
 	log.SetFlags(0)
-
-	rootCmd.SetArgs(os.Args[1:])
 	if err := rootCmd.Execute(); err != nil {
-		e := err.Error()
-		fmt.Println(strings.ToUpper(e[:1]) + e[1:])
+		logFailure("%v", err)
 		os.Exit(1)
 	}
 }
@@ -53,7 +59,7 @@ func homeDir() string {
 	return os.Getenv("USERPROFILE") // windows
 }
 
-func NewKubernetesClient() (*kubernetes.Clientset, error) {
+func kubernetesClient() (*kubernetes.Clientset, error) {
 	config, err := clientcmd.BuildConfigFromFlags("", kubeconfig)
 	if err != nil {
 		return nil, err
@@ -66,3 +72,24 @@ func NewKubernetesClient() (*kubernetes.Clientset, error) {
 
 	return client, nil
 }
+
+func execCommand(command string) (string, error) {
+	c := exec.Command("/bin/sh", "-c", command)
+	output, err := c.CombinedOutput()
+	if err != nil {
+		return "", err
+	}
+	return string(output), nil
+}
+
+func logAction(format string, a ...interface{}) {
+	fmt.Println(`✚`, fmt.Sprintf(format, a...))
+}
+
+func logSuccess(format string, a ...interface{}) {
+	fmt.Println(`✔`, fmt.Sprintf(format, a...))
+}
+
+func logFailure(format string, a ...interface{}) {
+	fmt.Println(`✗`, fmt.Sprintf(format, a...))
+}
diff --git a/cmd/tk/uninstall.go b/cmd/tk/uninstall.go
new file mode 100644
index 0000000000000000000000000000000000000000..aeea68cff02ea72ef4a45d70fa09e4dc8e685d9c
--- /dev/null
+++ b/cmd/tk/uninstall.go
@@ -0,0 +1,79 @@
+package main
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"io"
+	"os"
+	"os/exec"
+
+	"github.com/manifoldco/promptui"
+	"github.com/spf13/cobra"
+)
+
+var uninstallCmd = &cobra.Command{
+	Use:   "uninstall",
+	Short: "Uninstall the toolkit components",
+	Long: `
+The uninstall command removes the namespace, cluster roles,
+cluster role bindings and CRDs`,
+	Example: `  uninstall --namespace=gitops-system --crds --dry-run`,
+	RunE:    uninstallCmdRun,
+}
+
+var (
+	uninstallCRDs   bool
+	uninstallDryRun bool
+)
+
+func init() {
+	uninstallCmd.Flags().BoolVarP(&uninstallCRDs, "crds", "", false,
+		"removes all CRDs previously installed")
+	uninstallCmd.Flags().BoolVarP(&uninstallDryRun, "dry-run", "", false,
+		"only print the object that would be deleted")
+
+	rootCmd.AddCommand(uninstallCmd)
+}
+
+func uninstallCmdRun(cmd *cobra.Command, args []string) error {
+	ctx, cancel := context.WithTimeout(context.Background(), timeout)
+	defer cancel()
+
+	dryRun := ""
+	if uninstallDryRun {
+		dryRun = "--dry-run=client"
+	} else {
+		prompt := promptui.Prompt{
+			Label:     fmt.Sprintf("Are you sure you want to delete the %s namespace", namespace),
+			IsConfirm: true,
+		}
+		if _, err := prompt.Run(); err != nil {
+			logFailure("aborting")
+			os.Exit(1)
+		}
+	}
+
+	kinds := "namespace,clusterroles,clusterrolebindings"
+	if uninstallCRDs {
+		kinds += ",crds"
+	}
+
+	command := fmt.Sprintf("kubectl delete %s -l app.kubernetes.io/instance=%s --timeout=%s %s",
+		kinds, namespace, timeout.String(), dryRun)
+	c := exec.CommandContext(ctx, "/bin/sh", "-c", command)
+
+	var stdoutBuf, stderrBuf bytes.Buffer
+	c.Stdout = io.MultiWriter(os.Stdout, &stdoutBuf)
+	c.Stderr = io.MultiWriter(os.Stderr, &stderrBuf)
+
+	logAction("uninstalling components")
+	err := c.Run()
+	if err != nil {
+		logFailure("uninstall failed")
+		os.Exit(1)
+	}
+
+	logSuccess("uninstall finished")
+	return nil
+}
diff --git a/go.mod b/go.mod
index dedccacb089f7a84dd85857cca8d9226add5a29b..826f06896bea71892f4da0ec384f672c0bbe91c2 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,8 @@ module github.com/fluxcd/toolkit
 go 1.14
 
 require (
+	github.com/blang/semver v3.5.1+incompatible
+	github.com/manifoldco/promptui v0.7.0
 	github.com/spf13/cobra v0.0.6
 	k8s.io/client-go v0.18.0
 )
diff --git a/go.sum b/go.sum
index 4ebe84a970fcd1177b26e4eb363f69ae6a6aea3d..221b5a3ed5098221f929e492f9a49b9ba8aa847c 100644
--- a/go.sum
+++ b/go.sum
@@ -26,7 +26,15 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRF
 github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
 github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
 github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
+github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
+github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
 github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
+github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
+github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
+github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
+github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
+github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8=
+github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
 github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
 github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
@@ -107,6 +115,8 @@ github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCV
 github.com/json-iterator/go v1.1.8 h1:QiWkFLKq0T7mpzwOTu6BzNDbfTE8OLrYhVKYMLF46Ok=
 github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
 github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
+github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a h1:FaWFmfWdAUKbSCtOU2QjDaorUexogfaMgbipgYATUMU=
+github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a/go.mod h1:UJSiEoRfvx3hP73CvoARgeLjaIOjybY9vj8PUPPFGeU=
 github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
 github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
 github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00=
@@ -118,8 +128,16 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
 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/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a h1:weJVJJRzAJBFRlAiJQROKQs8oC9vOxvm4rZmBBk0ONw=
+github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI=
 github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
 github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
+github.com/manifoldco/promptui v0.7.0 h1:3l11YT8tm9MnwGFQ4kETwkzpAwY2Jt9lCrumCUW4+z4=
+github.com/manifoldco/promptui v0.7.0/go.mod h1:n4zTdgP0vr0S3w7/O/g98U+e0gwLScEXGwov2nIKuGQ=
+github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4=
+github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
+github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs=
+github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
 github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
 github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
 github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
@@ -226,6 +244,7 @@ golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5h
 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190209173611-3b5209105503/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
diff --git a/manifests/bases/kustomize-controller/kustomization.yaml b/manifests/bases/kustomize-controller/kustomization.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..9492b8d0cdf3357c00231685e910c17f1983de7d
--- /dev/null
+++ b/manifests/bases/kustomize-controller/kustomization.yaml
@@ -0,0 +1,5 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+- github.com/fluxcd/kustomize-controller/config//crd?ref=v0.0.1-alpha.4
+- github.com/fluxcd/kustomize-controller/config//manager?ref=v0.0.1-alpha.4
diff --git a/manifests/bases/source-controller/kustomization.yaml b/manifests/bases/source-controller/kustomization.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..39ede655f8d7ebdb6aebdb824e5da54395f84f2e
--- /dev/null
+++ b/manifests/bases/source-controller/kustomization.yaml
@@ -0,0 +1,5 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+- github.com/fluxcd/source-controller/config//crd?ref=v0.0.1-alpha.2
+- github.com/fluxcd/source-controller/config//manager?ref=v0.0.1-alpha.2
diff --git a/manifests/install/kustomization.yaml b/manifests/install/kustomization.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..f5e991b056abc3ef40246c0ce338c8b3229f1d82
--- /dev/null
+++ b/manifests/install/kustomization.yaml
@@ -0,0 +1,11 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+namespace: gitops-system
+resources:
+  - namespace.yaml
+  - ../bases/source-controller
+  - ../bases/kustomize-controller
+  - ../rbac
+  - ../policies
+transformers:
+  - labels.yaml
diff --git a/manifests/install/labels.yaml b/manifests/install/labels.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..aecb8e3a9f3704d0032723e54dd362e0ca11b180
--- /dev/null
+++ b/manifests/install/labels.yaml
@@ -0,0 +1,9 @@
+apiVersion: builtin
+kind: LabelTransformer
+metadata:
+  name: labels
+labels:
+  app.kubernetes.io/instance: gitops-system
+fieldSpecs:
+  - path: metadata/labels
+    create: true
diff --git a/manifests/install/namespace.yaml b/manifests/install/namespace.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..ab45ab3c9182b47b52a1e403694995133b2ba5be
--- /dev/null
+++ b/manifests/install/namespace.yaml
@@ -0,0 +1,4 @@
+apiVersion: v1
+kind: Namespace
+metadata:
+  name: gitops-system
diff --git a/manifests/policies/deny-ingress.yaml b/manifests/policies/deny-ingress.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..d9d0d0a35d04dff636c1b37f2a724dcd6848b987
--- /dev/null
+++ b/manifests/policies/deny-ingress.yaml
@@ -0,0 +1,8 @@
+apiVersion: networking.k8s.io/v1
+kind: NetworkPolicy
+metadata:
+  name: deny-ingress
+spec:
+  podSelector: {}
+  policyTypes:
+    - Ingress
diff --git a/manifests/policies/kustomization.yaml b/manifests/policies/kustomization.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..f535811deb4b0bbe5d48e5da9a3f79c6d089b2c0
--- /dev/null
+++ b/manifests/policies/kustomization.yaml
@@ -0,0 +1,4 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+  - deny-ingress.yaml
diff --git a/manifests/rbac/cluster_role.yaml b/manifests/rbac/cluster_role.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..9ce30d9129c5d0de8d1ad1e0ffd2cc2a6950ee5a
--- /dev/null
+++ b/manifests/rbac/cluster_role.yaml
@@ -0,0 +1,23 @@
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+  name: cluster-reconciler
+rules:
+  - apiGroups: ['*']
+    resources: ['*']
+    verbs: ['*']
+  - nonResourceURLs: ['*']
+    verbs: ['*']
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRoleBinding
+metadata:
+  name: cluster-reconciler
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: ClusterRole
+  name: cluster-reconciler
+subjects:
+  - kind: ServiceAccount
+    name: default
+    namespace: system
diff --git a/manifests/rbac/kustomization.yaml b/manifests/rbac/kustomization.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..ea165a8f556f1d2316c9691d1dcc4e20d36e51af
--- /dev/null
+++ b/manifests/rbac/kustomization.yaml
@@ -0,0 +1,5 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+  - cluster_role.yaml
+  - role.yaml
diff --git a/manifests/rbac/role.yaml b/manifests/rbac/role.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..4e79d1854ca4bef30383a659c1822a6489665b9f
--- /dev/null
+++ b/manifests/rbac/role.yaml
@@ -0,0 +1,24 @@
+apiVersion: rbac.authorization.k8s.io/v1
+kind: Role
+metadata:
+  name: crd-controller
+rules:
+- apiGroups: ['source.fluxcd.io']
+  resources: ['*']
+  verbs: ['*']
+- apiGroups: ['kustomize.fluxcd.io']
+  resources: ['*']
+  verbs: ['*']
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: RoleBinding
+metadata:
+  name: crd-controller
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: Role
+  name: crd-controller
+subjects:
+  - kind: ServiceAccount
+    name: default
+    namespace: system