diff --git a/actions/actions_test.go b/actions/actions_test.go
index 8570ee4f717c8327064f9c5382e460d68058441e..3721f22b47dfd4b2516334991cab2df887d31e3d 100644
--- a/actions/actions_test.go
+++ b/actions/actions_test.go
@@ -22,6 +22,7 @@ import (
 
 	cmocks "github.com/ksonnet/ksonnet/component/mocks"
 	"github.com/ksonnet/ksonnet/metadata/app/mocks"
+	"github.com/ksonnet/ksonnet/pkg/registry"
 	rmocks "github.com/ksonnet/ksonnet/pkg/registry/mocks"
 	"github.com/spf13/afero"
 	"github.com/stretchr/testify/require"
@@ -68,7 +69,7 @@ func mockNsWithName(name string) *cmocks.Namespace {
 func mockRegistry(name string) *rmocks.Registry {
 	m := &rmocks.Registry{}
 	m.On("Name").Return(name)
-	m.On("Protocol").Return("github")
+	m.On("Protocol").Return(registry.ProtocolGitHub)
 	m.On("URI").Return("github.com/ksonnet/parts/tree/master/incubator")
 
 	return m
diff --git a/actions/pkg_install.go b/actions/pkg_install.go
new file mode 100644
index 0000000000000000000000000000000000000000..ac289948401aace6461156c8f1cb0bbe57cb03c6
--- /dev/null
+++ b/actions/pkg_install.go
@@ -0,0 +1,93 @@
+// Copyright 2018 The ksonnet authors
+//
+//
+//    Licensed under the Apache License, Version 2.0 (the "License");
+//    you may not use this file except in compliance with the License.
+//    You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+//    Unless required by applicable law or agreed to in writing, software
+//    distributed under the License is distributed on an "AS IS" BASIS,
+//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//    See the License for the specific language governing permissions and
+//    limitations under the License.
+
+package actions
+
+import (
+	"github.com/ksonnet/ksonnet/metadata/app"
+	"github.com/ksonnet/ksonnet/pkg/pkg"
+	"github.com/ksonnet/ksonnet/pkg/registry"
+)
+
+// DepCacher is a function that caches a dependency.j
+type DepCacher func(app.App, pkg.Descriptor, string) error
+
+// PkgInstallDepCacher sets the dep cacher for pkg install.
+func PkgInstallDepCacher(dc DepCacher) PkgInstallOpt {
+	return func(pi *PkgInstall) {
+		pi.depCacher = dc
+	}
+}
+
+// PkgInstallOpt is an option for configuring PkgInstall.
+type PkgInstallOpt func(*PkgInstall)
+
+// RunPkgInstall runs `pkg install`
+func RunPkgInstall(ksApp app.App, libName, customName string, opts ...PkgInstallOpt) error {
+	pi, err := NewPkgInstall(ksApp, libName, customName, opts...)
+	if err != nil {
+		return err
+	}
+
+	return pi.Run()
+}
+
+// PkgInstall lists namespaces.
+type PkgInstall struct {
+	app        app.App
+	libName    string
+	customName string
+	depCacher  DepCacher
+}
+
+// NewPkgInstall creates an instance of PkgInstall.
+func NewPkgInstall(ksApp app.App, libName, name string, opts ...PkgInstallOpt) (*PkgInstall, error) {
+	nl := &PkgInstall{
+		app:        ksApp,
+		libName:    libName,
+		customName: name,
+		depCacher:  registry.CacheDependency,
+	}
+
+	for _, opt := range opts {
+		opt(nl)
+	}
+
+	return nl, nil
+}
+
+// Run lists namespaces.
+func (pi *PkgInstall) Run() error {
+	d, customName, err := pi.parseDepSpec()
+	if err != nil {
+		return err
+	}
+
+	return pi.depCacher(pi.app, d, customName)
+}
+
+func (pi *PkgInstall) parseDepSpec() (pkg.Descriptor, string, error) {
+	d, err := pkg.ParseName(pi.libName)
+	if err != nil {
+		return pkg.Descriptor{}, "", err
+	}
+
+	customName := pi.customName
+	if customName == "" {
+		customName = d.Part
+	}
+
+	return d, customName, nil
+}
diff --git a/actions/pkg_install_test.go b/actions/pkg_install_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..0610df528eb5952c9bc6396b145e1540e68a43a6
--- /dev/null
+++ b/actions/pkg_install_test.go
@@ -0,0 +1,61 @@
+// Copyright 2018 The ksonnet authors
+//
+//
+//    Licensed under the Apache License, Version 2.0 (the "License");
+//    you may not use this file except in compliance with the License.
+//    You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+//    Unless required by applicable law or agreed to in writing, software
+//    distributed under the License is distributed on an "AS IS" BASIS,
+//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//    See the License for the specific language governing permissions and
+//    limitations under the License.
+
+package actions
+
+import (
+	"testing"
+
+	"github.com/ksonnet/ksonnet/metadata/app"
+	amocks "github.com/ksonnet/ksonnet/metadata/app/mocks"
+	"github.com/ksonnet/ksonnet/pkg/pkg"
+	"github.com/ksonnet/ksonnet/pkg/registry"
+	"github.com/stretchr/testify/require"
+)
+
+func TestPkgInstall(t *testing.T) {
+	withApp(t, func(appMock *amocks.App) {
+		libName := "incubator/apache"
+		customName := "customName"
+
+		dc := func(a app.App, d pkg.Descriptor, cn string) error {
+			expectedD := pkg.Descriptor{
+				Registry: "incubator",
+				Part:     "apache",
+			}
+			require.Equal(t, expectedD, d)
+			require.Equal(t, "customName", cn)
+			return nil
+		}
+		dcOpt := PkgInstallDepCacher(dc)
+
+		a, err := NewPkgInstall(appMock, libName, customName, dcOpt)
+		require.NoError(t, err)
+
+		libaries := app.LibraryRefSpecs{}
+		appMock.On("Libraries").Return(libaries, nil)
+
+		registries := app.RegistryRefSpecs{
+			"incubator": &app.RegistryRefSpec{
+				Protocol: registry.ProtocolFilesystem,
+				URI:      "file:///tmp",
+			},
+		}
+		appMock.On("Registries").Return(registries, nil)
+
+		err = a.Run()
+		require.NoError(t, err)
+	})
+}
diff --git a/actions/pkg_list.go b/actions/pkg_list.go
new file mode 100644
index 0000000000000000000000000000000000000000..ec76edf125ee4961015fc74f2948839b9a684478
--- /dev/null
+++ b/actions/pkg_list.go
@@ -0,0 +1,104 @@
+// Copyright 2018 The ksonnet authors
+//
+//
+//    Licensed under the Apache License, Version 2.0 (the "License");
+//    you may not use this file except in compliance with the License.
+//    You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+//    Unless required by applicable law or agreed to in writing, software
+//    distributed under the License is distributed on an "AS IS" BASIS,
+//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//    See the License for the specific language governing permissions and
+//    limitations under the License.
+
+package actions
+
+import (
+	"io"
+	"os"
+	"sort"
+	"strings"
+
+	"github.com/ksonnet/ksonnet/metadata/app"
+	"github.com/ksonnet/ksonnet/pkg/registry"
+	"github.com/ksonnet/ksonnet/pkg/util/table"
+)
+
+const (
+	// pkgInstalled denotes a package is installed
+	pkgInstalled = "*"
+)
+
+// RunPkgList runs `pkg list`
+func RunPkgList(ksApp app.App) error {
+	rl, err := NewPkgList(ksApp)
+	if err != nil {
+		return err
+	}
+
+	return rl.Run()
+}
+
+// PkgList lists available registries
+type PkgList struct {
+	app app.App
+	rm  registry.Manager
+	out io.Writer
+}
+
+// NewPkgList creates an instance of PkgList
+func NewPkgList(ksApp app.App) (*PkgList, error) {
+	rl := &PkgList{
+		app: ksApp,
+		rm:  registry.DefaultManager,
+		out: os.Stdout,
+	}
+
+	return rl, nil
+}
+
+// Run runs the env list action.
+func (pl *PkgList) Run() error {
+	registries, err := pl.rm.List(pl.app)
+	if err != nil {
+		return err
+	}
+
+	var rows [][]string
+
+	appLibraries, err := pl.app.Libraries()
+	if err != nil {
+		return err
+	}
+
+	for _, r := range registries {
+		spec, err := r.FetchRegistrySpec()
+		if err != nil {
+			return err
+		}
+
+		for libName := range spec.Libraries {
+			row := []string{r.Name(), libName}
+			_, isInstalled := appLibraries[libName]
+			if isInstalled {
+				row = append(row, pkgInstalled)
+			}
+
+			rows = append(rows, row)
+		}
+	}
+
+	sort.Slice(rows, func(i, j int) bool {
+		nameI := strings.Join([]string{rows[i][0], rows[i][1]}, "-")
+		nameJ := strings.Join([]string{rows[j][0], rows[j][1]}, "-")
+
+		return nameI < nameJ
+	})
+
+	t := table.New(pl.out)
+	t.SetHeader([]string{"registry", "name", "installed"})
+	t.AppendBulk(rows)
+	return t.Render()
+}
diff --git a/actions/pkg_list_test.go b/actions/pkg_list_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..c8c510edb25bea0740d95d0144f57cdddfaa7af7
--- /dev/null
+++ b/actions/pkg_list_test.go
@@ -0,0 +1,64 @@
+// Copyright 2018 The ksonnet authors
+//
+//
+//    Licensed under the Apache License, Version 2.0 (the "License");
+//    you may not use this file except in compliance with the License.
+//    You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+//    Unless required by applicable law or agreed to in writing, software
+//    distributed under the License is distributed on an "AS IS" BASIS,
+//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//    See the License for the specific language governing permissions and
+//    limitations under the License.
+
+package actions
+
+import (
+	"bytes"
+	"testing"
+
+	"github.com/ksonnet/ksonnet/metadata/app"
+	amocks "github.com/ksonnet/ksonnet/metadata/app/mocks"
+	"github.com/ksonnet/ksonnet/pkg/registry"
+	rmocks "github.com/ksonnet/ksonnet/pkg/registry/mocks"
+	"github.com/stretchr/testify/require"
+)
+
+func TestPkgList(t *testing.T) {
+	withApp(t, func(appMock *amocks.App) {
+		libaries := app.LibraryRefSpecs{
+			"lib1": &app.LibraryRefSpec{},
+		}
+
+		appMock.On("Libraries").Return(libaries, nil)
+
+		a, err := NewPkgList(appMock)
+		require.NoError(t, err)
+
+		var buf bytes.Buffer
+		a.out = &buf
+
+		rm := &rmocks.Manager{}
+		a.rm = rm
+
+		spec := &registry.Spec{
+			Libraries: registry.LibraryRefSpecs{
+				"lib1": &registry.LibraryRef{},
+				"lib2": &registry.LibraryRef{},
+			},
+		}
+
+		incubator := mockRegistry("incubator")
+		incubator.On("FetchRegistrySpec").Return(spec, nil)
+
+		registries := []registry.Registry{incubator}
+		rm.On("List", appMock).Return(registries, nil)
+
+		err = a.Run()
+		require.NoError(t, err)
+
+		assertOutput(t, "pkg/list/output.txt", buf.String())
+	})
+}
diff --git a/actions/registry_add.go b/actions/registry_add.go
new file mode 100644
index 0000000000000000000000000000000000000000..80d3b3dc74cd8362996f656a14106316e4c70b45
--- /dev/null
+++ b/actions/registry_add.go
@@ -0,0 +1,81 @@
+// Copyright 2018 The ksonnet authors
+//
+//
+//    Licensed under the Apache License, Version 2.0 (the "License");
+//    you may not use this file except in compliance with the License.
+//    You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+//    Unless required by applicable law or agreed to in writing, software
+//    distributed under the License is distributed on an "AS IS" BASIS,
+//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//    See the License for the specific language governing permissions and
+//    limitations under the License.
+
+package actions
+
+import (
+	"net/url"
+	"strings"
+
+	"github.com/ksonnet/ksonnet/metadata/app"
+	"github.com/ksonnet/ksonnet/pkg/registry"
+)
+
+// RunRegistryAdd runs `registry add`
+func RunRegistryAdd(ksApp app.App, name, uri, version string) error {
+	nl, err := NewRegistryAdd(ksApp, name, uri, version)
+	if err != nil {
+		return err
+	}
+
+	return nl.Run()
+}
+
+// RegistryAdd lists namespaces.
+type RegistryAdd struct {
+	app     app.App
+	name    string
+	uri     string
+	version string
+
+	rm registry.Manager
+}
+
+// NewRegistryAdd creates an instance of RegistryAdd.
+func NewRegistryAdd(ksApp app.App, name, uri, version string) (*RegistryAdd, error) {
+	ra := &RegistryAdd{
+		app:     ksApp,
+		name:    name,
+		uri:     uri,
+		version: version,
+		rm:      registry.DefaultManager,
+	}
+
+	return ra, nil
+}
+
+// Run lists namespaces.
+func (ra *RegistryAdd) Run() error {
+	uri, protocol := ra.protocol()
+	_, err := ra.rm.Add(ra.app, ra.name, protocol, uri, ra.version)
+	return err
+}
+
+func (ra *RegistryAdd) protocol() (string, string) {
+	if strings.HasPrefix(ra.uri, "file://") {
+		return ra.uri, registry.ProtocolFilesystem
+	}
+
+	if strings.HasPrefix(ra.uri, "/") {
+		u := url.URL{
+			Scheme: "file",
+			Path:   ra.uri,
+		}
+
+		return u.String(), registry.ProtocolFilesystem
+	}
+
+	return ra.uri, registry.ProtocolGitHub
+}
diff --git a/actions/registry_add_test.go b/actions/registry_add_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..42b3e07675c0a9aebce54329599856de74bf7bb8
--- /dev/null
+++ b/actions/registry_add_test.go
@@ -0,0 +1,73 @@
+// Copyright 2018 The ksonnet authors
+//
+//
+//    Licensed under the Apache License, Version 2.0 (the "License");
+//    you may not use this file except in compliance with the License.
+//    You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+//    Unless required by applicable law or agreed to in writing, software
+//    distributed under the License is distributed on an "AS IS" BASIS,
+//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//    See the License for the specific language governing permissions and
+//    limitations under the License.
+
+package actions
+
+import (
+	"testing"
+
+	amocks "github.com/ksonnet/ksonnet/metadata/app/mocks"
+	"github.com/ksonnet/ksonnet/pkg/registry"
+	"github.com/ksonnet/ksonnet/pkg/registry/mocks"
+	"github.com/stretchr/testify/require"
+)
+
+func TestRegistryAdd(t *testing.T) {
+	withApp(t, func(appMock *amocks.App) {
+		name := "new"
+
+		cases := []struct {
+			name        string
+			uri         string
+			version     string
+			expectedURI string
+			protocol    string
+		}{
+			{
+				name:        "github",
+				uri:         "github.com/foo/bar",
+				expectedURI: "github.com/foo/bar",
+				protocol:    registry.ProtocolGitHub,
+			},
+			{
+				name:        "fs",
+				uri:         "/path",
+				expectedURI: "file:///path",
+				protocol:    registry.ProtocolFilesystem,
+			},
+			{
+				name:        "fs with URL",
+				uri:         "file:///path",
+				expectedURI: "file:///path",
+				protocol:    registry.ProtocolFilesystem,
+			},
+		}
+
+		for _, tc := range cases {
+			t.Run(tc.name, func(t *testing.T) {
+				a, err := NewRegistryAdd(appMock, name, tc.uri, tc.version)
+				require.NoError(t, err)
+
+				rm := &mocks.Manager{}
+				rm.On("Add", appMock, "new", tc.protocol, tc.expectedURI, tc.version).Return(nil, nil)
+				a.rm = rm
+
+				err = a.Run()
+				require.NoError(t, err)
+			})
+		}
+
+	})
+}
diff --git a/actions/registry_list.go b/actions/registry_list.go
index 5062e6a1ac0c54b6c130a5c9f1bf098a5c817b67..edd33a78c750332a1e291ac7ec1965f579718965 100644
--- a/actions/registry_list.go
+++ b/actions/registry_list.go
@@ -54,7 +54,7 @@ func NewRegistryList(ksApp app.App) (*RegistryList, error) {
 
 // Run runs the env list action.
 func (rl *RegistryList) Run() error {
-	registries, err := rl.rm.Registries(rl.app)
+	registries, err := rl.rm.List(rl.app)
 	if err != nil {
 		return err
 	}
diff --git a/actions/registry_list_test.go b/actions/registry_list_test.go
index 30ff5d53e6b057aeee8babdf71b31658851705f4..42f4e70f5a19b75b32146e427f088db2a47ef81b 100644
--- a/actions/registry_list_test.go
+++ b/actions/registry_list_test.go
@@ -39,7 +39,7 @@ func TestRegistryList(t *testing.T) {
 		registries := []registry.Registry{
 			mockRegistry("incubator"),
 		}
-		rm.On("Registries", appMock).Return(registries, nil)
+		rm.On("List", appMock).Return(registries, nil)
 
 		err = a.Run()
 		require.NoError(t, err)
diff --git a/actions/testdata/pkg/list/output.txt b/actions/testdata/pkg/list/output.txt
new file mode 100644
index 0000000000000000000000000000000000000000..e232abcccdb1b65c8ed1e0836147e795de7a2e2f
--- /dev/null
+++ b/actions/testdata/pkg/list/output.txt
@@ -0,0 +1,4 @@
+REGISTRY  NAME INSTALLED
+========  ==== =========
+incubator lib1 *
+incubator lib2
diff --git a/cmd/pkg.go b/cmd/pkg.go
index e0ba3bc644c6f9ab650b6c42bf7ea7177fa9a993..38d3ea67067dc8c471f3c2e3cd5a85f2076c39f2 100644
--- a/cmd/pkg.go
+++ b/cmd/pkg.go
@@ -18,12 +18,10 @@ package cmd
 import (
 	"fmt"
 	"os"
-	"sort"
 	"strings"
 
 	"github.com/ksonnet/ksonnet/metadata"
-	"github.com/ksonnet/ksonnet/metadata/parts"
-	"github.com/ksonnet/ksonnet/pkg/util/table"
+	"github.com/ksonnet/ksonnet/pkg/parts"
 	"github.com/spf13/cobra"
 )
 
@@ -41,10 +39,7 @@ var errInvalidSpec = fmt.Errorf("Command 'pkg install' requires a single argumen
 
 func init() {
 	RootCmd.AddCommand(pkgCmd)
-	pkgCmd.AddCommand(pkgInstallCmd)
-	pkgCmd.AddCommand(pkgListCmd)
 	pkgCmd.AddCommand(pkgDescribeCmd)
-	pkgInstallCmd.PersistentFlags().String(flagName, "", "Name to give the dependency, to use within the ksonnet app")
 }
 
 var pkgCmd = &cobra.Command{
@@ -84,67 +79,6 @@ See the annotated file tree below, as an example:
 	},
 }
 
-var pkgInstallCmd = &cobra.Command{
-	Use:     "install <registry>/<library>@<version>",
-	Short:   pkgShortDesc["install"],
-	Aliases: []string{"get"},
-	RunE: func(cmd *cobra.Command, args []string) error {
-		if len(args) != 1 {
-			return fmt.Errorf("Command requires a single argument of the form <registry>/<library>@<version>\n\n%s", cmd.UsageString())
-		}
-
-		registry, libID, name, version, err := parseDepSpec(cmd, args[0])
-		if err != nil {
-			return err
-		}
-
-		cwd, err := os.Getwd()
-		if err != nil {
-			return err
-		}
-
-		manager, err := metadata.Find(cwd)
-		if err != nil {
-			return err
-		}
-
-		_, err = manager.CacheDependency(registry, libID, name, version)
-		if err != nil {
-			return err
-		}
-
-		return nil
-	},
-	Long: `
-The ` + "`install`" + ` command caches a ksonnet library locally, and makes it available
-for use in the current ksonnet application. Enough info and metadata is recorded in
-` + "`app.yaml` " + `that new users can retrieve the dependency after a fresh clone of this app.
-
-The library itself needs to be located in a registry (e.g. Github repo). By default,
-ksonnet knows about two registries: *incubator* and *stable*, which are the release
-channels for official ksonnet libraries.
-
-### Related Commands
-
-* ` + "`ks pkg list` " + `— ` + pkgShortDesc["list"] + `
-* ` + "`ks prototype list` " + `— ` + protoShortDesc["list"] + `
-* ` + "`ks registry describe` " + `— ` + regShortDesc["describe"] + `
-
-### Syntax
-`,
-	Example: `
-# Install an nginx dependency, based on the latest branch.
-# In a ksonnet source file, this can be referenced as:
-#   local nginx = import "incubator/nginx/nginx.libsonnet";
-ks pkg install incubator/nginx
-
-# Install an nginx dependency, based on the 'master' branch.
-# In a ksonnet source file, this can be referenced as:
-#   local nginx = import "incubator/nginx/nginx.libsonnet";
-ks pkg install incubator/nginx@master
-`,
-}
-
 var pkgDescribeCmd = &cobra.Command{
 	Use:   "describe [<registry-name>/]<package-name>",
 	Short: pkgShortDesc["describe"],
@@ -218,90 +152,6 @@ known ` + "`<registry-name>`" + ` like *incubator*). The output includes:
 `,
 }
 
-var pkgListCmd = &cobra.Command{
-	Use:   "list",
-	Short: pkgShortDesc["list"],
-	RunE: func(cmd *cobra.Command, args []string) error {
-		const (
-			nameHeader      = "NAME"
-			registryHeader  = "REGISTRY"
-			installedHeader = "INSTALLED"
-			installed       = "*"
-		)
-
-		if len(args) != 0 {
-			return fmt.Errorf("Command 'pkg list' does not take arguments")
-		}
-
-		cwd, err := os.Getwd()
-		if err != nil {
-			return err
-		}
-
-		manager, err := metadata.Find(cwd)
-		if err != nil {
-			return err
-		}
-
-		app, err := manager.App()
-		if err != nil {
-			return err
-		}
-
-		t := table.New(os.Stdout)
-		t.SetHeader([]string{registryHeader, nameHeader, installedHeader})
-
-		rows := make([][]string, 0)
-		for name := range app.Registries() {
-			reg, _, err := manager.GetRegistry(name)
-			if err != nil {
-				return err
-			}
-
-			for libName := range reg.Libraries {
-				var row []string
-				_, isInstalled := app.Libraries()[libName]
-				if isInstalled {
-					row = []string{name, libName, installed}
-				} else {
-					row = []string{name, libName}
-				}
-
-				rows = append(rows, row)
-			}
-		}
-
-		sort.Slice(rows, func(i, j int) bool {
-			nameI := strings.Join([]string{rows[i][0], rows[i][1]}, "-")
-			nameJ := strings.Join([]string{rows[j][0], rows[j][1]}, "-")
-
-			return nameI < nameJ
-		})
-
-		t.AppendBulk(rows)
-		t.Render()
-
-		return nil
-	},
-	Long: `
-The ` + "`list`" + ` command outputs a table that describes all *known* packages (not
-necessarily downloaded, but available from existing registries). This includes
-the following info:
-
-1. Library name
-2. Registry name
-3. Installed status — an asterisk indicates 'installed'
-
-### Related Commands
-
-* ` + "`ks pkg install` " + `— ` + pkgShortDesc["install"] + `
-* ` + "`ks pkg describe` " + `— ` + pkgShortDesc["describe"] + `
-* ` + "`ks registry describe` " + `— ` + regShortDesc["describe"] + `
-
-### Syntax
-`,
-}
-
 func parsePkgSpec(spec string) (registry, libID string, err error) {
 	split := strings.SplitN(spec, "/", 2)
 	if len(split) < 2 {
@@ -312,31 +162,3 @@ func parsePkgSpec(spec string) (registry, libID string, err error) {
 	libID = strings.SplitN(split[1], "@", 2)[0]
 	return
 }
-
-func parseDepSpec(cmd *cobra.Command, spec string) (registry, libID, name, version string, err error) {
-	registry, libID, err = parsePkgSpec(spec)
-	if err != nil {
-		return "", "", "", "", err
-	}
-
-	split := strings.Split(spec, "@")
-	if len(split) > 2 {
-		return "", "", "", "", fmt.Errorf("Symbol '@' is only allowed once, at the end of the argument of the form <registry>/<library>@<version>")
-	}
-	version = ""
-	if len(split) == 2 {
-		version = split[1]
-	}
-
-	name, err = cmd.Flags().GetString(flagName)
-	if err != nil {
-		return "", "", "", "", err
-	} else if name == "" {
-		// Get last component, strip off trailing `@<version>`.
-		split = strings.Split(spec, "/")
-		lastComponent := split[len(split)-1]
-		name = strings.SplitN(lastComponent, "@", 2)[0]
-	}
-
-	return
-}
diff --git a/cmd/pkg_install.go b/cmd/pkg_install.go
new file mode 100644
index 0000000000000000000000000000000000000000..40100daafb20c5ed394d475f458a07c80821190f
--- /dev/null
+++ b/cmd/pkg_install.go
@@ -0,0 +1,79 @@
+// Copyright 2018 The ksonnet authors
+//
+//
+//    Licensed under the Apache License, Version 2.0 (the "License");
+//    you may not use this file except in compliance with the License.
+//    You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+//    Unless required by applicable law or agreed to in writing, software
+//    distributed under the License is distributed on an "AS IS" BASIS,
+//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//    See the License for the specific language governing permissions and
+//    limitations under the License.
+
+package cmd
+
+import (
+	"fmt"
+
+	"github.com/spf13/viper"
+
+	"github.com/ksonnet/ksonnet/actions"
+	"github.com/spf13/cobra"
+)
+
+var (
+	vPkgInstallName = "pkg-install-name"
+)
+
+var pkgInstallCmd = &cobra.Command{
+	Use:     "install <registry>/<library>@<version>",
+	Short:   pkgShortDesc["install"],
+	Aliases: []string{"get"},
+	RunE: func(cmd *cobra.Command, args []string) error {
+		if len(args) != 1 {
+			return fmt.Errorf("Command requires a single argument of the form <registry>/<library>@<version>\n\n%s", cmd.UsageString())
+		}
+
+		customName := viper.GetString(vPkgInstallName)
+
+		return actions.RunPkgInstall(ka, args[0], customName)
+	},
+	Long: `
+The ` + "`install`" + ` command caches a ksonnet library locally, and makes it available
+for use in the current ksonnet application. Enough info and metadata is recorded in
+` + "`app.yaml` " + `that new users can retrieve the dependency after a fresh clone of this app.
+
+The library itself needs to be located in a registry (e.g. Github repo). By default,
+ksonnet knows about two registries: *incubator* and *stable*, which are the release
+channels for official ksonnet libraries.
+
+### Related Commands
+
+* ` + "`ks pkg list` " + `— ` + pkgShortDesc["list"] + `
+* ` + "`ks prototype list` " + `— ` + protoShortDesc["list"] + `
+* ` + "`ks registry describe` " + `— ` + regShortDesc["describe"] + `
+
+### Syntax
+`,
+	Example: `
+# Install an nginx dependency, based on the latest branch.
+# In a ksonnet source file, this can be referenced as:
+#   local nginx = import "incubator/nginx/nginx.libsonnet";
+ks pkg install incubator/nginx
+
+# Install an nginx dependency, based on the 'master' branch.
+# In a ksonnet source file, this can be referenced as:
+#   local nginx = import "incubator/nginx/nginx.libsonnet";
+ks pkg install incubator/nginx@master
+`,
+}
+
+func init() {
+	pkgCmd.AddCommand(pkgInstallCmd)
+
+	pkgInstallCmd.Flags().String(flagName, "", "Name to give the dependency, to use within the ksonnet app")
+	viper.BindPFlag(vPkgInstallName, pkgInstallCmd.Flags().Lookup(flagName))
+}
diff --git a/cmd/pkg_list.go b/cmd/pkg_list.go
new file mode 100644
index 0000000000000000000000000000000000000000..41570550df70860e360b5913111458c76d3e0650
--- /dev/null
+++ b/cmd/pkg_list.go
@@ -0,0 +1,56 @@
+// Copyright 2018 The ksonnet authors
+//
+//
+//    Licensed under the Apache License, Version 2.0 (the "License");
+//    you may not use this file except in compliance with the License.
+//    You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+//    Unless required by applicable law or agreed to in writing, software
+//    distributed under the License is distributed on an "AS IS" BASIS,
+//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//    See the License for the specific language governing permissions and
+//    limitations under the License.
+
+package cmd
+
+import (
+	"fmt"
+
+	"github.com/ksonnet/ksonnet/actions"
+	"github.com/spf13/cobra"
+)
+
+var pkgListCmd = &cobra.Command{
+	Use:   "list",
+	Short: pkgShortDesc["list"],
+	RunE: func(cmd *cobra.Command, args []string) error {
+		if len(args) != 0 {
+			return fmt.Errorf("Command 'pkg list' does not take arguments")
+		}
+
+		return actions.RunPkgList(ka)
+	},
+	Long: `
+The ` + "`list`" + ` command outputs a table that describes all *known* packages (not
+necessarily downloaded, but available from existing registries). This includes
+the following info:
+
+1. Library name
+2. Registry name
+3. Installed status — an asterisk indicates 'installed'
+
+### Related Commands
+
+* ` + "`ks pkg install` " + `— ` + pkgShortDesc["install"] + `
+* ` + "`ks pkg describe` " + `— ` + pkgShortDesc["describe"] + `
+* ` + "`ks registry describe` " + `— ` + regShortDesc["describe"] + `
+
+### Syntax
+`,
+}
+
+func init() {
+	pkgCmd.AddCommand(pkgListCmd)
+}
diff --git a/cmd/registry.go b/cmd/registry.go
index 8939b685cb55b59f4821ef85d4d8d8b8cdb2403b..9c3785e9b41eb9ae4f538dd9a4440af9796e5a7c 100644
--- a/cmd/registry.go
+++ b/cmd/registry.go
@@ -22,7 +22,6 @@ import (
 	"strings"
 
 	"github.com/ksonnet/ksonnet/metadata"
-	"github.com/ksonnet/ksonnet/pkg/kubecfg"
 	"github.com/spf13/cobra"
 )
 
@@ -38,7 +37,6 @@ var regShortDesc = map[string]string{
 
 func init() {
 	RootCmd.AddCommand(registryCmd)
-	registryCmd.AddCommand(registryListCmd)
 	registryCmd.AddCommand(registryDescribeCmd)
 	registryCmd.AddCommand(registryAddCmd)
 
@@ -97,7 +95,11 @@ var registryDescribeCmd = &cobra.Command{
 			return err
 		}
 
-		regRef, exists := app.Registries()[name]
+		appRegistries, err := app.Registries()
+		if err != nil {
+			return err
+		}
+		regRef, exists := appRegistries[name]
 		if !exists {
 			return fmt.Errorf("Registry '%s' doesn't exist", name)
 		}
@@ -145,59 +147,3 @@ by ` + "`<registry-name>`" + `. Specifically, it displays the following:
 ### Syntax
 `,
 }
-
-var registryAddCmd = &cobra.Command{
-	Use:   "add <registry-name> <registry-uri>",
-	Short: regShortDesc["add"],
-	RunE: func(cmd *cobra.Command, args []string) error {
-		flags := cmd.Flags()
-
-		if len(args) != 2 {
-			return fmt.Errorf("Command 'registry add' takes two arguments, which is the name and the repository address of the registry to add")
-		}
-
-		name := args[0]
-		uri := args[1]
-
-		version, err := flags.GetString(flagRegistryVersion)
-		if err != nil {
-			return err
-		}
-
-		// TODO allow protocol to be specified by flag once there is greater
-		// support for other protocol types.
-		return kubecfg.NewRegistryAddCmd(name, "github", uri, version).Run()
-	},
-
-	Long: `
-The ` + "`add`" + ` command allows custom registries to be added to your ksonnet app,
-provided that their file structures follow the appropriate schema. *You can look
-at the ` + "`incubator`" + ` repo (https://github.com/ksonnet/parts/tree/master/incubator)
-as an example.*
-
-A registry is uniquely identified by its:
-
-1. Name (e.g. ` + "`incubator`" + `)
-2. Version (e.g. ` + "`master`" + `)
-
-Currently, only registries supporting the **GitHub protocol** can be added.
-
-During creation, all registries must specify a unique name and URI where the
-registry lives. Optionally, a version can be provided (e.g. the *Github branch
-name*). If a version is not specified, it will default to ` + "`latest`" + `.
-
-
-### Related Commands
-
-* ` + "`ks registry list` " + `— ` + regShortDesc["list"] + `
-
-### Syntax
-`,
-	Example: `# Add a registry with the name 'databases' at the uri 'github.com/example'
-ks registry add databases github.com/example
-
-# Add a registry with the name 'databases' at the uri
-# 'github.com/example/tree/master/reg' and the version (branch name) 0.0.1
-# NOTE that "0.0.1" overrides the branch name in the URI ("master")
-ks registry add databases github.com/example/tree/master/reg --version=0.0.1`,
-}
diff --git a/cmd/registry_add.go b/cmd/registry_add.go
new file mode 100644
index 0000000000000000000000000000000000000000..df3e3c629c7c223d5bdb960a58a9f83a2432a906
--- /dev/null
+++ b/cmd/registry_add.go
@@ -0,0 +1,77 @@
+// Copyright 2018 The ksonnet authors
+//
+//
+//    Licensed under the Apache License, Version 2.0 (the "License");
+//    you may not use this file except in compliance with the License.
+//    You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+//    Unless required by applicable law or agreed to in writing, software
+//    distributed under the License is distributed on an "AS IS" BASIS,
+//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//    See the License for the specific language governing permissions and
+//    limitations under the License.
+
+package cmd
+
+import (
+	"fmt"
+
+	"github.com/ksonnet/ksonnet/actions"
+	"github.com/spf13/cobra"
+)
+
+var registryAddCmd = &cobra.Command{
+	Use:   "add <registry-name> <registry-uri>",
+	Short: regShortDesc["add"],
+	RunE: func(cmd *cobra.Command, args []string) error {
+		flags := cmd.Flags()
+
+		if len(args) != 2 {
+			return fmt.Errorf("Command 'registry add' takes two arguments, which is the name and the repository address of the registry to add")
+		}
+
+		name := args[0]
+		uri := args[1]
+
+		version, err := flags.GetString(flagRegistryVersion)
+		if err != nil {
+			return err
+		}
+
+		return actions.RunRegistryAdd(ka, name, uri, version)
+	},
+
+	Long: `
+The ` + "`add`" + ` command allows custom registries to be added to your ksonnet app,
+provided that their file structures follow the appropriate schema. *You can look
+at the ` + "`incubator`" + ` repo (https://github.com/ksonnet/parts/tree/master/incubator)
+as an example.*
+
+A registry is uniquely identified by its:
+
+1. Name (e.g. ` + "`incubator`" + `)
+2. Version (e.g. ` + "`master`" + `)
+
+Currently, only registries supporting the **GitHub protocol** can be added.
+
+During creation, all registries must specify a unique name and URI where the
+registry lives. Optionally, a version can be provided (e.g. the *Github branch
+name*). If a version is not specified, it will default to ` + "`latest`" + `.
+
+
+### Related Commands
+
+* ` + "`ks registry list` " + `— ` + regShortDesc["list"] + `
+
+### Syntax
+`,
+	Example: `# Add a registry with the name 'databases' at the uri 'github.com/example'
+ks registry add databases github.com/example
+
+# Add a registry with the name 'databases' at the uri
+# 'github.com/example/tree/master/reg' and the version (branch name) 0.0.1
+# NOTE that "0.0.1" overrides the branch name in the URI ("master")
+ks registry add databases github.com/example/tree/master/reg --version=0.0.1`,
+}
diff --git a/cmd/registry_list.go b/cmd/registry_list.go
index e0b222bdb4837b3415c1fbbf67396a76a7f2b43e..abfe3def5248961aeb25fd353b2cc0de9b5fd189 100644
--- a/cmd/registry_list.go
+++ b/cmd/registry_list.go
@@ -47,3 +47,7 @@ table includes the following info:
 ### Syntax
 `,
 }
+
+func init() {
+	registryCmd.AddCommand(registryListCmd)
+}
diff --git a/e2e/app.go b/e2e/app.go
index baa51e73e0dc98ff564d3f8f09f00bb190839d19..f69ec6c2456b2257c6400022e898914c13671dd8 100644
--- a/e2e/app.go
+++ b/e2e/app.go
@@ -63,6 +63,13 @@ func (a *app) paramList(args ...string) *output {
 	return o
 }
 
+func (a *app) pkgList() *output {
+	o := a.runKs("pkg", "list")
+	assertExitStatus(o, 0)
+
+	return o
+}
+
 func (a *app) paramSet(key, value string, args ...string) *output {
 	o := a.runKs(append([]string{"param", "set", key, value}, args...)...)
 	assertExitStatus(o, 0)
@@ -70,6 +77,20 @@ func (a *app) paramSet(key, value string, args ...string) *output {
 	return o
 }
 
+func (a *app) registryAdd(registryName, uri string) *output {
+	o := a.runKs("registry", "add", registryName, uri)
+	assertExitStatus(o, 0)
+
+	return o
+}
+
+func (a *app) registryList(args ...string) *output {
+	o := a.runKs(append([]string{"registry", "list"}, args...)...)
+	assertExitStatus(o, 0)
+
+	return o
+}
+
 func (a *app) generateDeployedService() {
 	appDir := a.dir
 
diff --git a/e2e/helpers.go b/e2e/helpers.go
index 624c2f00727f96ee815c1568742f51ffdfdb78f1..2eb93605a64663a1bdcb16d9e2f8fe0c0775330a 100644
--- a/e2e/helpers.go
+++ b/e2e/helpers.go
@@ -16,7 +16,10 @@
 package e2e
 
 import (
+	"bytes"
+	"html/template"
 	"io/ioutil"
+	"net/url"
 	"os"
 	"path/filepath"
 	"strings"
@@ -77,3 +80,34 @@ func assertContents(name, path string) {
 		"expected output to be:\n%s\nit was:\n%s\n",
 		expected, got)
 }
+
+func assertTemplate(data interface{}, name, output string) {
+	path := filepath.Join("testdata", "output", name)
+	ExpectWithOffset(1, path).To(BeAnExistingFile())
+
+	t, err := template.ParseFiles(path)
+	ExpectWithOffset(1, err).ToNot(HaveOccurred())
+
+	var buf bytes.Buffer
+	err = t.Execute(&buf, data)
+	ExpectWithOffset(1, err).ToNot(HaveOccurred())
+
+	expected := buf.String()
+	got := output
+	ExpectWithOffset(1, expected).To(Equal(got),
+		"expected output to be:\n%s\nit was:\n%s\n",
+		expected, got)
+}
+
+func convertPathToURI(path string) string {
+	if strings.HasPrefix(path, "file://") {
+		return path
+	}
+
+	u := url.URL{
+		Scheme: "file",
+		Path:   path,
+	}
+
+	return u.String()
+}
diff --git a/e2e/pkg_test.go b/e2e/pkg_test.go
index 94b58a80c287f27f65a901ff3d8afe9e55647893..8dd1b615fa90379a6074ea302df9f7d11c4ce0ab 100644
--- a/e2e/pkg_test.go
+++ b/e2e/pkg_test.go
@@ -31,7 +31,7 @@ var _ = Describe("ks pkg", func() {
 		a = e.initApp("")
 	})
 
-	Describe("describe", func() {
+	Describe("add", func() {
 		Context("incubator/apache", func() {
 			It("describes the package", func() {
 				o := a.runKs("pkg", "describe", "incubator/apache")
@@ -41,16 +41,49 @@ var _ = Describe("ks pkg", func() {
 		})
 	})
 
-	Describe("install", func() {
+	Describe("describe", func() {
 		Context("incubator/apache", func() {
 			It("describes the package", func() {
-				o := a.runKs("pkg", "install", "incubator/apache")
+				o := a.runKs("pkg", "describe", "incubator/apache")
 				assertExitStatus(o, 0)
+				assertOutput("pkg/describe/output.txt", o.stdout)
+			})
+		})
+	})
+
+	Describe("install", func() {
+		Context("github based part", func() {
+			Context("incubator/apache", func() {
+				It("describes the package", func() {
+					o := a.runKs("pkg", "install", "incubator/apache")
+					assertExitStatus(o, 0)
 
-				pkgDir := filepath.Join(a.dir, "vendor", "incubator", "apache")
-				Expect(pkgDir).To(BeADirectory())
+					pkgDir := filepath.Join(a.dir, "vendor", "incubator", "apache")
+					Expect(pkgDir).To(BeADirectory())
+				})
 			})
 		})
+
+		Context("fs based part", func() {
+			Context("local/contour", func() {
+				It("describes the package", func() {
+					path, err := filepath.Abs(filepath.Join("testdata", "registries", "parts-infra"))
+					Expect(err).ToNot(HaveOccurred())
+
+					o := a.registryAdd("local", path)
+
+					o = a.runKs("pkg", "install", "local/contour")
+					assertExitStatus(o, 0)
+
+					o = a.pkgList()
+
+					m := map[string]interface{}{}
+					tPath := filepath.Join("pkg", "install", "fs-output.txt.tmpl")
+					assertTemplate(m, tPath, o.stdout)
+				})
+			})
+		})
+
 	})
 
 	Describe("list", func() {
diff --git a/e2e/registry_test.go b/e2e/registry_test.go
index 44fa9b94f43307fd7bbb972e974e0322ad3075f0..7dbd3a105d4423f9e7424974bb81b2b561aa0049 100644
--- a/e2e/registry_test.go
+++ b/e2e/registry_test.go
@@ -18,7 +18,10 @@
 package e2e
 
 import (
+	"path/filepath"
+
 	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
 )
 
 var _ = Describe("ks registry", func() {
@@ -29,6 +32,46 @@ var _ = Describe("ks registry", func() {
 		a.generateDeployedService()
 	})
 
+	Describe("add", func() {
+
+		var add = func(path string) {
+			o := a.runKs("registry", "add", "local", path)
+			assertExitStatus(o, 0)
+
+			uri := convertPathToURI(path)
+			m := map[string]interface{}{
+				"uri": uri,
+			}
+
+			o = a.registryList()
+
+			tPath := filepath.Join("registry", "add", "output.txt.tmpl")
+			assertTemplate(m, tPath, o.stdout)
+		}
+
+		Context("a filesystem based registry", func() {
+			Context("as a path", func() {
+				It("adds a registry", func() {
+					path, err := filepath.Abs(filepath.Join("testdata", "registries", "parts-infra"))
+					Expect(err).ToNot(HaveOccurred())
+
+					add(path)
+				})
+			})
+			Context("as a URL", func() {
+				It("adds a registry", func() {
+					path, err := filepath.Abs(filepath.Join("testdata", "registries", "parts-infra"))
+					Expect(err).ToNot(HaveOccurred())
+
+					uri := convertPathToURI(path)
+
+					add(uri)
+				})
+			})
+
+		})
+	})
+
 	Describe("list", func() {
 		It("lists the currently configured registries", func() {
 			o := a.runKs("registry", "list")
diff --git a/e2e/testdata/output/pkg/install/fs-output.txt.tmpl b/e2e/testdata/output/pkg/install/fs-output.txt.tmpl
new file mode 100644
index 0000000000000000000000000000000000000000..dcd990f489316df151157239a9c71f1f439671cf
--- /dev/null
+++ b/e2e/testdata/output/pkg/install/fs-output.txt.tmpl
@@ -0,0 +1,14 @@
+REGISTRY  NAME      INSTALLED
+========  ====      =========
+incubator apache
+incubator efk
+incubator mariadb
+incubator memcached
+incubator mongodb
+incubator mysql
+incubator nginx
+incubator node
+incubator postgres
+incubator redis
+incubator tomcat
+local     contour   *
diff --git a/e2e/testdata/output/registry/add/output.txt.tmpl b/e2e/testdata/output/registry/add/output.txt.tmpl
new file mode 100644
index 0000000000000000000000000000000000000000..26c0114ba1a4dbeabdaf343387ab88081a41e00f
--- /dev/null
+++ b/e2e/testdata/output/registry/add/output.txt.tmpl
@@ -0,0 +1,4 @@
+NAME      PROTOCOL URI
+====      ======== ===
+incubator github   github.com/ksonnet/parts/tree/master/incubator
+local     fs       {{ .uri }}
diff --git a/e2e/testdata/registries/parts-infra/contour/parts.yaml b/e2e/testdata/registries/parts-infra/contour/parts.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..29a9240e38f0024acc019ef37ab0495ec52bcad6
--- /dev/null
+++ b/e2e/testdata/registries/parts-infra/contour/parts.yaml
@@ -0,0 +1,12 @@
+name: contour
+apiVersion: 0.0.1
+kind: ksonnet.io/parts
+description: >
+  Contour is a Kubernetes ingress controller for Lyft's Envoy proxy.
+author: Bryan Liles <bryan@heptio.com>
+quickStart:
+  prototype: io.foo.bar
+  componentName: contour
+  flags:
+  comment: Logging stack that processes input from fluentd.
+license: Apache 2.0
diff --git a/e2e/testdata/registries/parts-infra/contour/prototypes/simple.jsonnet b/e2e/testdata/registries/parts-infra/contour/prototypes/simple.jsonnet
new file mode 100644
index 0000000000000000000000000000000000000000..b4ccc02ad3b42cb2ae0fa9162175defe49022298
--- /dev/null
+++ b/e2e/testdata/registries/parts-infra/contour/prototypes/simple.jsonnet
@@ -0,0 +1,6 @@
+// @apiVersion 0.0.1
+// @name foo.bar.baz.contour
+// @description Something fancy
+// @shortDescription short description
+
+{}
\ No newline at end of file
diff --git a/e2e/testdata/registries/parts-infra/registry.yaml b/e2e/testdata/registries/parts-infra/registry.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..172726d1a0d11d994e3cdda609a06331e34ab985
--- /dev/null
+++ b/e2e/testdata/registries/parts-infra/registry.yaml
@@ -0,0 +1,5 @@
+apiVersion: 0.1.0
+kind: ksonnet.io/registry
+libraries:
+  contour:
+    path: contour
\ No newline at end of file
diff --git a/metadata/app/app.go b/metadata/app/app.go
index ff67825727ae13a46b555ce1eb6dfac94dcca03b..663742fc44e26722f33fb587d2e46851b6b655d5 100644
--- a/metadata/app/app.go
+++ b/metadata/app/app.go
@@ -50,18 +50,20 @@ var (
 // App is a ksonnet application.
 type App interface {
 	AddEnvironment(name, k8sSpecFlag string, spec *EnvironmentSpec) error
+	AddRegistry(spec *RegistryRefSpec) error
 	Environment(name string) (*EnvironmentSpec, error)
 	Environments() (EnvironmentSpecs, error)
 	EnvironmentParams(name string) (string, error)
 	Fs() afero.Fs
 	Init() error
 	LibPath(envName string) (string, error)
-	Libraries() LibraryRefSpecs
-	Registries() RegistryRefSpecs
+	Libraries() (LibraryRefSpecs, error)
+	Registries() (RegistryRefSpecs, error)
 	RemoveEnvironment(name string) error
 	RenameEnvironment(from, to string) error
 	Root() string
 	UpdateTargets(envName string, targets []string) error
+	UpdateLib(name string, spec *LibraryRefSpec) error
 	Upgrade(dryRun bool) error
 }
 
@@ -77,6 +79,37 @@ func newBaseApp(fs afero.Fs, root string) *baseApp {
 	}
 }
 
+func (ba *baseApp) AddRegistry(newReg *RegistryRefSpec) error {
+	spec, err := ba.load()
+	if err != nil {
+		return err
+	}
+
+	if newReg.Name == "" {
+		return ErrRegistryNameInvalid
+	}
+
+	_, exists := spec.Registries[newReg.Name]
+	if exists {
+		return ErrRegistryExists
+	}
+
+	spec.Registries[newReg.Name] = newReg
+
+	return ba.save(spec)
+}
+
+func (ba *baseApp) UpdateLib(name string, libSpec *LibraryRefSpec) error {
+	spec, err := ba.load()
+	if err != nil {
+		return err
+	}
+
+	spec.Libraries[name] = libSpec
+
+	return ba.save(spec)
+}
+
 func (ba *baseApp) Fs() afero.Fs {
 	return ba.fs
 }
@@ -93,7 +126,6 @@ func (ba *baseApp) EnvironmentParams(envName string) (string, error) {
 	}
 
 	return string(b), nil
-
 }
 
 // Load loads the application configuration.
@@ -107,10 +139,23 @@ func Load(fs afero.Fs, appRoot string) (App, error) {
 	default:
 		return nil, errors.Errorf("unknown apiVersion %q in %s", spec.APIVersion, appYamlName)
 	case "0.0.1":
-		return NewApp001(fs, appRoot)
+		return NewApp001(fs, appRoot), nil
 	case "0.1.0":
-		return NewApp010(fs, appRoot)
+		return NewApp010(fs, appRoot), nil
+	}
+}
+
+func (ba *baseApp) save(spec *Spec) error {
+	return Write(ba.fs, ba.root, spec)
+}
+
+func (ba *baseApp) load() (*Spec, error) {
+	spec, err := Read(ba.fs, ba.root)
+	if err != nil {
+		return nil, err
 	}
+
+	return spec, nil
 }
 
 func updateLibData(fs afero.Fs, k8sSpecFlag, libPath string, useVersionPath bool) (string, error) {
diff --git a/metadata/app/app001.go b/metadata/app/app001.go
index 470c19d6e51dd578c4691d04802afb50425610d7..8396ff563cb58978b4d4f8dc2a710763d6693d72 100644
--- a/metadata/app/app001.go
+++ b/metadata/app/app001.go
@@ -35,27 +35,20 @@ const (
 
 // App001 is a ksonnet 0.0.1 application.
 type App001 struct {
-	spec *Spec
-	out  io.Writer
+	out io.Writer
 	*baseApp
 }
 
 var _ App = (*App001)(nil)
 
 // NewApp001 creates an App001 instance.
-func NewApp001(fs afero.Fs, root string) (*App001, error) {
-	spec, err := Read(fs, root)
-	if err != nil {
-		return nil, err
-	}
-
+func NewApp001(fs afero.Fs, root string) *App001 {
 	ba := newBaseApp(fs, root)
 
 	return &App001{
-		spec:    spec,
 		out:     os.Stdout,
 		baseApp: ba,
-	}, nil
+	}
 }
 
 // AddEnvironment adds an environment spec to the app spec. If the spec already exists,
@@ -135,13 +128,22 @@ func (a *App001) LibPath(envName string) (string, error) {
 }
 
 // Libraries returns application libraries.
-func (a *App001) Libraries() LibraryRefSpecs {
-	return a.spec.Libraries
+func (a *App001) Libraries() (LibraryRefSpecs, error) {
+	spec, err := a.load()
+	if err != nil {
+		return nil, err
+	}
+	return spec.Libraries, nil
 }
 
 // Registries returns application registries.
-func (a *App001) Registries() RegistryRefSpecs {
-	return a.spec.Registries
+func (a *App001) Registries() (RegistryRefSpecs, error) {
+	spec, err := a.load()
+	if err != nil {
+		return nil, err
+	}
+
+	return spec.Registries, nil
 }
 
 // RemoveEnvironment removes an environment.
@@ -163,7 +165,8 @@ func (a *App001) UpdateTargets(envName string, targets []string) error {
 
 // Upgrade upgrades the app to the latest apiVersion.
 func (a *App001) Upgrade(dryRun bool) error {
-	if err := a.load(); err != nil {
+	spec, err := a.load()
+	if err != nil {
 		return err
 	}
 
@@ -183,10 +186,10 @@ func (a *App001) Upgrade(dryRun bool) error {
 		a.convertEnvironment(env.Path, dryRun)
 	}
 
-	a.spec.APIVersion = "0.1.0"
+	spec.APIVersion = "0.1.0"
 
 	if dryRun {
-		data, err := a.spec.Marshal()
+		data, err := spec.Marshal()
 		if err != nil {
 			return err
 		}
@@ -196,7 +199,7 @@ func (a *App001) Upgrade(dryRun bool) error {
 		return nil
 	}
 
-	return a.save()
+	return a.save(spec)
 }
 
 type k8sSchema struct {
@@ -253,7 +256,12 @@ func (a *App001) convertEnvironment(envName string, dryRun bool) error {
 		return err
 	}
 
-	a.spec.Environments[envName] = env
+	spec, err := a.load()
+	if err != nil {
+		return err
+	}
+
+	spec.Environments[envName] = env
 
 	if dryRun {
 		fmt.Fprintf(a.out, "[dry run]\t* adding the environment description in environment `%s to `app.yaml`.\n",
@@ -267,25 +275,15 @@ func (a *App001) convertEnvironment(envName string, dryRun bool) error {
 
 	k8sSpecFlag := fmt.Sprintf("version:%s", env.KubernetesVersion)
 	_, err = LibUpdater(a.fs, k8sSpecFlag, app010LibPath(a.root), true)
-	return err
-}
-
-func (a *App001) appLibPath(envName string) string {
-	return filepath.Join(a.root, EnvironmentDirName, envName, ".metadata")
-}
-
-func (a *App001) save() error {
-	return Write(a.fs, a.root, a.spec)
-}
-
-func (a *App001) load() error {
-	spec, err := Read(a.fs, a.root)
 	if err != nil {
 		return err
 	}
 
-	a.spec = spec
-	return nil
+	return a.save(spec)
+}
+
+func (a *App001) appLibPath(envName string) string {
+	return filepath.Join(a.root, EnvironmentDirName, envName, ".metadata")
 }
 
 func (a *App001) envDir(envName string) string {
diff --git a/metadata/app/app001_test.go b/metadata/app/app001_test.go
index 0c780924e919819f03b7e508ebf1db7881423352..e9d4989fb8d495e6df18d860a184e402825696a7 100644
--- a/metadata/app/app001_test.go
+++ b/metadata/app/app001_test.go
@@ -291,7 +291,6 @@ func withApp001Fs(t *testing.T, appName string, fn func(app *App001)) {
 
 	stageFile(t, fs, appName, "/app.yaml")
 
-	app, err := NewApp001(fs, "/")
-	require.NoError(t, err)
+	app := NewApp001(fs, "/")
 	fn(app)
 }
diff --git a/metadata/app/app010.go b/metadata/app/app010.go
index d4cdc08c9746646d76af4b8d898e001a87328fd0..380fa393469ea770436f62c9d7c160dadb96d936 100644
--- a/metadata/app/app010.go
+++ b/metadata/app/app010.go
@@ -29,35 +29,31 @@ import (
 
 // App010 is a ksonnet 0.1.0 application.
 type App010 struct {
-	spec *Spec
 	*baseApp
 }
 
 var _ App = (*App010)(nil)
 
 // NewApp010 creates an App010 instance.
-func NewApp010(fs afero.Fs, root string) (*App010, error) {
+func NewApp010(fs afero.Fs, root string) *App010 {
 	ba := newBaseApp(fs, root)
 
 	a := &App010{
 		baseApp: ba,
 	}
 
-	if err := a.load(); err != nil {
-		return nil, err
-	}
-
-	return a, nil
+	return a
 }
 
 // AddEnvironment adds an environment spec to the app spec. If the spec already exists,
 // it is overwritten.
-func (a *App010) AddEnvironment(name, k8sSpecFlag string, spec *EnvironmentSpec) error {
-	if err := a.load(); err != nil {
+func (a *App010) AddEnvironment(name, k8sSpecFlag string, newEnv *EnvironmentSpec) error {
+	spec, err := a.load()
+	if err != nil {
 		return err
 	}
 
-	a.spec.Environments[name] = spec
+	spec.Environments[name] = newEnv
 
 	if k8sSpecFlag != "" {
 		ver, err := LibUpdater(a.fs, k8sSpecFlag, app010LibPath(a.root), true)
@@ -65,15 +61,20 @@ func (a *App010) AddEnvironment(name, k8sSpecFlag string, spec *EnvironmentSpec)
 			return err
 		}
 
-		a.spec.Environments[name].KubernetesVersion = ver
+		spec.Environments[name].KubernetesVersion = ver
 	}
 
-	return a.save()
+	return a.save(spec)
 }
 
 // Environment returns the spec for an environment.
 func (a *App010) Environment(name string) (*EnvironmentSpec, error) {
-	s, ok := a.spec.Environments[name]
+	spec, err := a.load()
+	if err != nil {
+		return nil, err
+	}
+
+	s, ok := spec.Environments[name]
 	if !ok {
 		return nil, errors.Errorf("environment %q was not found", name)
 	}
@@ -83,7 +84,12 @@ func (a *App010) Environment(name string) (*EnvironmentSpec, error) {
 
 // Environments returns all environment specs.
 func (a *App010) Environments() (EnvironmentSpecs, error) {
-	return a.spec.Environments, nil
+	spec, err := a.load()
+	if err != nil {
+		return nil, err
+	}
+
+	return spec.Environments, nil
 }
 
 // Init initializes the App.
@@ -123,22 +129,33 @@ func (a *App010) LibPath(envName string) (string, error) {
 }
 
 // Libraries returns application libraries.
-func (a *App010) Libraries() LibraryRefSpecs {
-	return a.spec.Libraries
+func (a *App010) Libraries() (LibraryRefSpecs, error) {
+	spec, err := a.load()
+	if err != nil {
+		return nil, err
+	}
+
+	return spec.Libraries, nil
 }
 
 // Registries returns application registries.
-func (a *App010) Registries() RegistryRefSpecs {
-	return a.spec.Registries
+func (a *App010) Registries() (RegistryRefSpecs, error) {
+	spec, err := a.load()
+	if err != nil {
+		return nil, err
+	}
+
+	return spec.Registries, nil
 }
 
 // RemoveEnvironment removes an environment.
 func (a *App010) RemoveEnvironment(envName string) error {
-	if err := a.load(); err != nil {
+	spec, err := a.load()
+	if err != nil {
 		return err
 	}
-	delete(a.spec.Environments, envName)
-	return a.save()
+	delete(spec.Environments, envName)
+	return a.save(spec)
 }
 
 // RenameEnvironment renames environments.
@@ -147,16 +164,17 @@ func (a *App010) RenameEnvironment(from, to string) error {
 		return err
 	}
 
-	if err := a.load(); err != nil {
+	spec, err := a.load()
+	if err != nil {
 		return err
 	}
 
-	a.spec.Environments[to] = a.spec.Environments[from]
-	delete(a.spec.Environments, from)
+	spec.Environments[to] = spec.Environments[from]
+	delete(spec.Environments, from)
 
-	a.spec.Environments[to].Path = to
+	spec.Environments[to].Path = to
 
-	return a.save()
+	return a.save(spec)
 }
 
 // UpdateTargets updates the list of targets for a 0.1.0 application.
@@ -203,17 +221,3 @@ func (a *App010) findLegacySpec() ([]string, error) {
 
 	return found, nil
 }
-
-func (a *App010) save() error {
-	return Write(a.fs, a.root, a.spec)
-}
-
-func (a *App010) load() error {
-	spec, err := Read(a.fs, a.root)
-	if err != nil {
-		return err
-	}
-
-	a.spec = spec
-	return nil
-}
diff --git a/metadata/app/app010_test.go b/metadata/app/app010_test.go
index dbccc236bec1ad960ef0445e7a45a90d6d34282c..7f812fb05d192de83e2bf9cb7860911c8b40650e 100644
--- a/metadata/app/app010_test.go
+++ b/metadata/app/app010_test.go
@@ -317,8 +317,7 @@ func withApp010Fs(t *testing.T, appName string, fn func(app *App010)) {
 
 	stageFile(t, fs, appName, "/app.yaml")
 
-	app, err := NewApp010(fs, "/")
-	require.NoError(t, err)
+	app := NewApp010(fs, "/")
 
 	fn(app)
 }
diff --git a/metadata/app/mocks/App.go b/metadata/app/mocks/App.go
index e24c8061030a8617f57da125e4c4c1c0c6b56633..bef8ec9873980d8915497f19935be653ea1aadf8 100644
--- a/metadata/app/mocks/App.go
+++ b/metadata/app/mocks/App.go
@@ -39,6 +39,20 @@ func (_m *App) AddEnvironment(name string, k8sSpecFlag string, spec *app.Environ
 	return r0
 }
 
+// AddRegistry provides a mock function with given fields: spec
+func (_m *App) AddRegistry(spec *app.RegistryRefSpec) error {
+	ret := _m.Called(spec)
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(*app.RegistryRefSpec) error); ok {
+		r0 = rf(spec)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
 // Environment provides a mock function with given fields: name
 func (_m *App) Environment(name string) (*app.EnvironmentSpec, error) {
 	ret := _m.Called(name)
@@ -158,7 +172,7 @@ func (_m *App) LibPath(envName string) (string, error) {
 }
 
 // Libraries provides a mock function with given fields:
-func (_m *App) Libraries() app.LibraryRefSpecs {
+func (_m *App) Libraries() (app.LibraryRefSpecs, error) {
 	ret := _m.Called()
 
 	var r0 app.LibraryRefSpecs
@@ -170,11 +184,18 @@ func (_m *App) Libraries() app.LibraryRefSpecs {
 		}
 	}
 
-	return r0
+	var r1 error
+	if rf, ok := ret.Get(1).(func() error); ok {
+		r1 = rf()
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
 }
 
 // Registries provides a mock function with given fields:
-func (_m *App) Registries() app.RegistryRefSpecs {
+func (_m *App) Registries() (app.RegistryRefSpecs, error) {
 	ret := _m.Called()
 
 	var r0 app.RegistryRefSpecs
@@ -186,7 +207,14 @@ func (_m *App) Registries() app.RegistryRefSpecs {
 		}
 	}
 
-	return r0
+	var r1 error
+	if rf, ok := ret.Get(1).(func() error); ok {
+		r1 = rf()
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
 }
 
 // RemoveEnvironment provides a mock function with given fields: name
@@ -231,6 +259,20 @@ func (_m *App) Root() string {
 	return r0
 }
 
+// UpdateLib provides a mock function with given fields: name, spec
+func (_m *App) UpdateLib(name string, spec *app.LibraryRefSpec) error {
+	ret := _m.Called(name, spec)
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(string, *app.LibraryRefSpec) error); ok {
+		r0 = rf(name, spec)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
 // UpdateTargets provides a mock function with given fields: envName, targets
 func (_m *App) UpdateTargets(envName string, targets []string) error {
 	ret := _m.Called(envName, targets)
diff --git a/metadata/interface.go b/metadata/interface.go
index 21a2e7d23fd83db8df1f8eff62e6d5f4b698f32b..0cecd9202f30b1836f3bc5ed42f31fdc2c6ce487 100644
--- a/metadata/interface.go
+++ b/metadata/interface.go
@@ -22,8 +22,8 @@ import (
 	"github.com/ksonnet/ksonnet/env"
 	"github.com/ksonnet/ksonnet/metadata/app"
 	param "github.com/ksonnet/ksonnet/metadata/params"
-	"github.com/ksonnet/ksonnet/metadata/parts"
-	"github.com/ksonnet/ksonnet/metadata/registry"
+	"github.com/ksonnet/ksonnet/pkg/parts"
+	"github.com/ksonnet/ksonnet/pkg/registry"
 	"github.com/ksonnet/ksonnet/prototype"
 	"github.com/spf13/afero"
 )
@@ -68,10 +68,8 @@ type Manager interface {
 	GetDestination(envName string) (env.Destination, error)
 
 	// Dependency/registry API.
-	AddRegistry(name, protocol, uri, version string) (*registry.Spec, error)
 	GetRegistry(name string) (*registry.Spec, string, error)
 	GetPackage(registryName, libID string) (*parts.Spec, error)
-	CacheDependency(registryName, libID, libName, libVersion string) (*parts.Spec, error)
 	GetDependency(libName string) (*parts.Spec, error)
 	GetAllPrototypes() (prototype.SpecificationSchemas, error)
 }
@@ -92,9 +90,9 @@ func Init(name, rootPath string, k8sSpecFlag, serverURI, namespace *string) (Man
 		defaultIncubatorURI     = "github.com/ksonnet/parts/tree/master/" + defaultIncubatorRegName
 	)
 
-	gh, err := makeGitHubRegistryManager(&app.RegistryRefSpec{
+	gh, err := registry.NewGitHub(&app.RegistryRefSpec{
 		Name:     "incubator",
-		Protocol: "github",
+		Protocol: registry.ProtocolGitHub,
 		URI:      defaultIncubatorURI,
 	})
 	if err != nil {
diff --git a/metadata/manager.go b/metadata/manager.go
index 022efea53aa16997fcb7e2d2fb4ff473f189ed5d..bf5b888020123e2058e32491b9ab63fb0e58beca 100644
--- a/metadata/manager.go
+++ b/metadata/manager.go
@@ -24,7 +24,7 @@ import (
 	"github.com/ksonnet/ksonnet/component"
 	"github.com/ksonnet/ksonnet/env"
 	"github.com/ksonnet/ksonnet/metadata/app"
-	"github.com/ksonnet/ksonnet/metadata/registry"
+	"github.com/ksonnet/ksonnet/pkg/registry"
 	str "github.com/ksonnet/ksonnet/strings"
 	"github.com/pkg/errors"
 	log "github.com/sirupsen/logrus"
@@ -115,7 +115,7 @@ func findManager(p string, appFS afero.Fs) (*manager, error) {
 	}
 }
 
-func initManager(name, rootPath string, k8sSpecFlag, serverURI, namespace *string, incubatorReg registry.Manager, appFS afero.Fs) (*manager, error) {
+func initManager(name, rootPath string, k8sSpecFlag, serverURI, namespace *string, incubatorReg registry.Registry, appFS afero.Fs) (*manager, error) {
 	m, err := newManager(rootPath, appFS)
 	if err != nil {
 		return nil, errors.Wrap(err, "create manager")
@@ -231,7 +231,7 @@ func (m *manager) createUserDirTree() error {
 	return nil
 }
 
-func (m *manager) createAppDirTree(name string, appYAMLData, baseLibData []byte, gh registry.Manager) error {
+func (m *manager) createAppDirTree(name string, appYAMLData, baseLibData []byte, gh registry.Registry) error {
 	exists, err := afero.DirExists(m.appFS, m.rootPath)
 	if err != nil {
 		return fmt.Errorf("Could not check existence of directory '%s':\n%v", m.rootPath, err)
@@ -289,7 +289,7 @@ func (m *manager) createAppDirTree(name string, appYAMLData, baseLibData []byte,
 	return nil
 }
 
-func generateRegistryYAMLData(incubatorReg registry.Manager) ([]byte, error) {
+func generateRegistryYAMLData(incubatorReg registry.Registry) ([]byte, error) {
 	regSpec, err := incubatorReg.FetchRegistrySpec()
 	if err != nil {
 		return nil, err
diff --git a/metadata/registry.go b/metadata/registry.go
index 99df3ed59416b24542f2894cb4cdfd122b968657..eb70fbaf9a9326e5042c7e17472fe74757fa74a2 100644
--- a/metadata/registry.go
+++ b/metadata/registry.go
@@ -21,64 +21,20 @@ import (
 	"path/filepath"
 
 	"github.com/ksonnet/ksonnet/metadata/app"
-	"github.com/ksonnet/ksonnet/metadata/parts"
-	"github.com/ksonnet/ksonnet/metadata/registry"
+	"github.com/ksonnet/ksonnet/pkg/parts"
+	"github.com/ksonnet/ksonnet/pkg/registry"
 	"github.com/ksonnet/ksonnet/prototype"
 	str "github.com/ksonnet/ksonnet/strings"
-	log "github.com/sirupsen/logrus"
 	"github.com/spf13/afero"
 )
 
-// AddRegistry adds a registry with `name`, `protocol`, and `uri` to
-// the current ksonnet application.
-func (m *manager) AddRegistry(name, protocol, uri, version string) (*registry.Spec, error) {
-	appSpec, err := app.Read(m.appFS, m.rootPath)
-	if err != nil {
-		return nil, err
-	}
-
-	// Add registry reference to app spec.
-	registryManager, err := makeGitHubRegistryManager(&app.RegistryRefSpec{
-		Name:     name,
-		Protocol: protocol,
-		URI:      uri,
-	})
-	if err != nil {
-		return nil, err
-	}
-
-	err = appSpec.AddRegistryRef(registryManager.RegistryRefSpec)
-	if err != nil {
-		return nil, err
-	}
-
-	// Retrieve the contents of registry.
-	registrySpec, err := m.getOrCacheRegistry(registryManager)
-	if err != nil {
-		return nil, err
-	}
-
-	// Write registry specification back out to app specification.
-	specBytes, err := appSpec.Marshal()
-	if err != nil {
-		return nil, err
-	}
-
-	err = afero.WriteFile(m.appFS, m.appYAMLPath, specBytes, defaultFilePermissions)
-	if err != nil {
-		return nil, err
-	}
-
-	return registrySpec, nil
-}
-
 func (m *manager) GetRegistry(name string) (*registry.Spec, string, error) {
-	registryManager, protocol, err := m.getRegistryManager(name)
+	r, protocol, err := m.getRegistryManager(name)
 	if err != nil {
 		return nil, "", err
 	}
 
-	regSpec, exists, err := m.registrySpecFromFile(m.registryPath(registryManager))
+	regSpec, exists, err := m.registrySpecFromFile(m.registryPath(r))
 	if !exists {
 		return nil, "", fmt.Errorf("Registry '%s' does not exist", name)
 	} else if err != nil {
@@ -97,7 +53,7 @@ func (m *manager) GetPackage(registryName, libID string) (*parts.Spec, error) {
 
 	regRefSpec, ok := appSpec.GetRegistryRef(registryName)
 	if !ok {
-		return nil, fmt.Errorf("COuld not find registry '%s'", registryName)
+		return nil, fmt.Errorf("Could not find registry '%s'", registryName)
 	}
 
 	registryManager, _, err := m.getRegistryManagerFor(regRefSpec)
@@ -157,79 +113,6 @@ func (m *manager) GetDependency(libName string) (*parts.Spec, error) {
 	return partsSpec, nil
 }
 
-func (m *manager) CacheDependency(registryName, libID, libName, libVersion string) (*parts.Spec, error) {
-	// Retrieve application specification.
-	appSpec, err := app.Read(m.appFS, m.rootPath)
-	if err != nil {
-		return nil, err
-	}
-
-	if _, ok := appSpec.Libraries[libName]; ok {
-		return nil, fmt.Errorf("Package '%s' already exists. Use the --name flag to install this package with a unique identifier", libName)
-	}
-
-	// Retrieve registry manager for this specific registry.
-	regRefSpec, exists := appSpec.GetRegistryRef(registryName)
-	if !exists {
-		return nil, fmt.Errorf("Registry '%s' does not exist", registryName)
-	}
-
-	registryManager, _, err := m.getRegistryManagerFor(regRefSpec)
-	if err != nil {
-		return nil, err
-	}
-
-	// Get all directories and files first, then write to disk. This
-	// protects us from failing with a half-cached dependency because of
-	// a network failure.
-	directories := []string{}
-	files := map[string][]byte{}
-	parts, libRef, err := registryManager.ResolveLibrary(
-		libID,
-		libName,
-		libVersion,
-		func(relPath string, contents []byte) error {
-			files[str.AppendToPath(m.vendorPath, relPath)] = contents
-			return nil
-		},
-		func(relPath string) error {
-			directories = append(directories, str.AppendToPath(m.vendorPath, relPath))
-			return nil
-		})
-	if err != nil {
-		return nil, err
-	}
-
-	// Add library to app specification, but wait to write it out until
-	// the end, in case one of the network calls fails.
-	appSpec.Libraries[libName] = libRef
-	appSpecData, err := appSpec.Marshal()
-	if err != nil {
-		return nil, err
-	}
-
-	log.Infof("Retrieved %d files", len(files))
-
-	for _, dir := range directories {
-		if err := m.appFS.MkdirAll(dir, defaultFolderPermissions); err != nil {
-			return nil, err
-		}
-	}
-
-	for path, content := range files {
-		if err := afero.WriteFile(m.appFS, path, content, defaultFilePermissions); err != nil {
-			return nil, err
-		}
-	}
-
-	err = afero.WriteFile(m.appFS, m.appYAMLPath, appSpecData, defaultFilePermissions)
-	if err != nil {
-		return nil, err
-	}
-
-	return parts, nil
-}
-
 func (m *manager) GetPrototypesForDependency(registryName, libID string) (prototype.SpecificationSchemas, error) {
 	// TODO: Remove `registryName` when we flatten vendor/.
 	specs := prototype.SpecificationSchemas{}
@@ -286,15 +169,19 @@ func (m *manager) GetAllPrototypes() (prototype.SpecificationSchemas, error) {
 	return specs, nil
 }
 
-func (m *manager) registryDir(regManager registry.Manager) string {
+func (m *manager) registryDir(regManager registry.Registry) string {
 	return str.AppendToPath(m.registriesPath, regManager.RegistrySpecDir())
 }
 
-func (m *manager) registryPath(regManager registry.Manager) string {
+func (m *manager) registryPath(regManager registry.Registry) string {
+	path := regManager.RegistrySpecFilePath()
+	if filepath.IsAbs(path) {
+		return path
+	}
 	return str.AppendToPath(m.registriesPath, regManager.RegistrySpecFilePath())
 }
 
-func (m *manager) getRegistryManager(registryName string) (registry.Manager, string, error) {
+func (m *manager) getRegistryManager(registryName string) (registry.Registry, string, error) {
 	appSpec, err := app.Read(m.appFS, m.rootPath)
 	if err != nil {
 		return nil, "", err
@@ -308,24 +195,18 @@ func (m *manager) getRegistryManager(registryName string) (registry.Manager, str
 	return m.getRegistryManagerFor(regRefSpec)
 }
 
-func (m *manager) getRegistryManagerFor(registryRefSpec *app.RegistryRefSpec) (registry.Manager, string, error) {
-	var err error
-	var manager registry.Manager
-	var protocol string
-
-	switch registryRefSpec.Protocol {
-	case "github":
-		manager, err = makeGitHubRegistryManager(registryRefSpec)
-		protocol = "github"
-	default:
-		return nil, "", fmt.Errorf("Invalid protocol '%s'", registryRefSpec.Protocol)
+func (m *manager) getRegistryManagerFor(registryRefSpec *app.RegistryRefSpec) (registry.Registry, string, error) {
+	a, err := m.App()
+	if err != nil {
+		return nil, "", err
 	}
 
+	r, err := registry.Locate(a, registryRefSpec)
 	if err != nil {
 		return nil, "", err
 	}
 
-	return manager, protocol, nil
+	return r, r.Protocol(), nil
 }
 
 func (m *manager) registrySpecFromFile(path string) (*registry.Spec, bool, error) {
@@ -357,39 +238,3 @@ func (m *manager) registrySpecFromFile(path string) (*registry.Spec, bool, error
 
 	return nil, false, nil
 }
-
-func (m *manager) getOrCacheRegistry(gh registry.Manager) (*registry.Spec, error) {
-	// Check local disk cache.
-	registrySpecFile := m.registryPath(gh)
-	registrySpec, exists, err := m.registrySpecFromFile(registrySpecFile)
-	if !exists {
-		// If failed, use the protocol to try to retrieve app specification.
-		registrySpec, err = gh.FetchRegistrySpec()
-		if err != nil {
-			return nil, err
-		}
-
-		registrySpecBytes, err := registrySpec.Marshal()
-		if err != nil {
-			return nil, err
-		}
-
-		// NOTE: We call mkdir after getting the registry spec, since a
-		// network call might fail and leave this half-initialized empty
-		// directory.
-		registrySpecDir := str.AppendToPath(m.registriesPath, gh.RegistrySpecDir())
-		err = m.appFS.MkdirAll(registrySpecDir, defaultFolderPermissions)
-		if err != nil {
-			return nil, err
-		}
-
-		err = afero.WriteFile(m.appFS, registrySpecFile, registrySpecBytes, defaultFilePermissions)
-		if err != nil {
-			return nil, err
-		}
-	} else if err != nil {
-		return nil, err
-	}
-
-	return registrySpec, nil
-}
diff --git a/metadata/registry_managers.go b/metadata/registry_managers.go
deleted file mode 100644
index 6719be79d8bacb9cc74559a4609c9879915ab1c0..0000000000000000000000000000000000000000
--- a/metadata/registry_managers.go
+++ /dev/null
@@ -1,380 +0,0 @@
-// Copyright 2018 The kubecfg authors
-//
-//
-//    Licensed under the Apache License, Version 2.0 (the "License");
-//    you may not use this file except in compliance with the License.
-//    You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-//    Unless required by applicable law or agreed to in writing, software
-//    distributed under the License is distributed on an "AS IS" BASIS,
-//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-//    See the License for the specific language governing permissions and
-//    limitations under the License.
-
-package metadata
-
-import (
-	"context"
-	"fmt"
-	"net/http"
-	"net/url"
-	"os"
-	"path"
-	"strings"
-
-	"github.com/google/go-github/github"
-	"github.com/ksonnet/ksonnet/metadata/app"
-	"github.com/ksonnet/ksonnet/metadata/parts"
-	"github.com/ksonnet/ksonnet/metadata/registry"
-	"golang.org/x/oauth2"
-)
-
-const (
-	rawGitHubRoot       = "https://raw.githubusercontent.com"
-	defaultGitHubBranch = "master"
-
-	uriField         = "uri"
-	refSpecField     = "refSpec"
-	resolvedSHAField = "resolvedSHA"
-)
-
-var (
-	errInvalidURI = fmt.Errorf("Invalid GitHub URI: try navigating in GitHub to the URI of the folder containing the 'registry.yaml', and using that URI instead. Generally, this URI should be of the form 'github.com/{organization}/{repository}/tree/{branch}/[path-to-directory]'")
-)
-
-//
-// GitHub registry manager.
-//
-
-type gitHubRegistryManager struct {
-	*app.RegistryRefSpec
-	registryDir          string
-	org                  string
-	repo                 string
-	registryRepoPath     string
-	registrySpecRepoPath string
-}
-
-func makeGithubClient() *github.Client {
-	var hc *http.Client
-
-	ght := os.Getenv("GITHUB_TOKEN")
-	if len(ght) > 0 {
-		ctx := context.Background()
-		ts := oauth2.StaticTokenSource(
-			&oauth2.Token{AccessToken: ght},
-		)
-		hc = oauth2.NewClient(ctx, ts)
-	}
-
-	return github.NewClient(hc)
-}
-
-func makeGitHubRegistryManager(registryRef *app.RegistryRefSpec) (*gitHubRegistryManager, error) {
-	gh := gitHubRegistryManager{RegistryRefSpec: registryRef}
-
-	var err error
-
-	// Set registry path.
-	gh.registryDir = gh.Name
-
-	// Parse GitHub URI.
-	var refspec string
-	gh.org, gh.repo, refspec, gh.registryRepoPath, gh.registrySpecRepoPath, err = parseGitHubURI(gh.URI)
-	if err != nil {
-		return nil, err
-	}
-
-	// Resolve the refspec to a commit SHA.
-	client := makeGithubClient()
-	ctx := context.Background()
-
-	sha, _, err := client.Repositories.GetCommitSHA1(ctx, gh.org, gh.repo, refspec, "")
-	if err != nil {
-		return nil, err
-	}
-	gh.GitVersion = &app.GitVersionSpec{
-		RefSpec:   refspec,
-		CommitSHA: sha,
-	}
-
-	return &gh, nil
-}
-
-func (gh *gitHubRegistryManager) RegistrySpecDir() string {
-	return gh.registryDir
-}
-
-func (gh *gitHubRegistryManager) RegistrySpecFilePath() string {
-	if gh.GitVersion.CommitSHA != "" {
-		return path.Join(gh.registryDir, gh.GitVersion.CommitSHA+".yaml")
-	}
-	return path.Join(gh.registryDir, gh.GitVersion.RefSpec+".yaml")
-}
-
-func (gh *gitHubRegistryManager) FetchRegistrySpec() (*registry.Spec, error) {
-	// Fetch app spec at specific commit.
-	client := makeGithubClient()
-	ctx := context.Background()
-
-	// Get contents.
-	getOpts := github.RepositoryContentGetOptions{Ref: gh.GitVersion.CommitSHA}
-	file, _, _, err := client.Repositories.GetContents(ctx, gh.org, gh.repo, gh.registrySpecRepoPath, &getOpts)
-	if file == nil {
-		return nil, fmt.Errorf("Could not find valid registry at uri '%s/%s/%s' and refspec '%s' (resolves to sha '%s')", gh.org, gh.repo, gh.registrySpecRepoPath, gh.GitVersion.RefSpec, gh.GitVersion.CommitSHA)
-	} else if err != nil {
-		return nil, err
-	}
-
-	registrySpecText, err := file.GetContent()
-	if err != nil {
-		return nil, err
-	}
-
-	// Deserialize, return.
-	registrySpec, err := registry.Unmarshal([]byte(registrySpecText))
-	if err != nil {
-		return nil, err
-	}
-
-	registrySpec.GitVersion = &app.GitVersionSpec{
-		RefSpec:   gh.GitVersion.RefSpec,
-		CommitSHA: gh.GitVersion.CommitSHA,
-	}
-
-	return registrySpec, nil
-}
-
-func (gh *gitHubRegistryManager) MakeRegistryRefSpec() *app.RegistryRefSpec {
-	return gh.RegistryRefSpec
-}
-
-func (gh *gitHubRegistryManager) ResolveLibrarySpec(libID, libRefSpec string) (*parts.Spec, error) {
-	client := makeGithubClient()
-
-	// Resolve `version` (a git refspec) to a specific SHA.
-	ctx := context.Background()
-	resolvedSHA, _, err := client.Repositories.GetCommitSHA1(ctx, gh.org, gh.repo, libRefSpec, "")
-	if err != nil {
-		return nil, err
-	}
-
-	// Resolve app spec.
-	appSpecPath := strings.Join([]string{gh.registryRepoPath, libID, partsYAMLFile}, "/")
-	ctx = context.Background()
-	getOpts := &github.RepositoryContentGetOptions{Ref: resolvedSHA}
-	file, directory, _, err := client.Repositories.GetContents(ctx, gh.org, gh.repo, appSpecPath, getOpts)
-	if err != nil {
-		return nil, err
-	} else if directory != nil {
-		return nil, fmt.Errorf("Can't download library specification; resource '%s' points at a file", gh.registrySpecRawURL())
-	}
-
-	partsSpecText, err := file.GetContent()
-	if err != nil {
-		return nil, err
-	}
-
-	parts, err := parts.Unmarshal([]byte(partsSpecText))
-	if err != nil {
-		return nil, err
-	}
-
-	return parts, nil
-}
-
-func (gh *gitHubRegistryManager) ResolveLibrary(libID, libAlias, libRefSpec string, onFile registry.ResolveFile, onDir registry.ResolveDirectory) (*parts.Spec, *app.LibraryRefSpec, error) {
-	const (
-		defaultRefSpec = "master"
-	)
-
-	client := makeGithubClient()
-
-	// Resolve `version` (a git refspec) to a specific SHA.
-	ctx := context.Background()
-	if len(libRefSpec) == 0 {
-		libRefSpec = defaultRefSpec
-	}
-	resolvedSHA, _, err := client.Repositories.GetCommitSHA1(ctx, gh.org, gh.repo, libRefSpec, "")
-	if err != nil {
-		return nil, nil, err
-	}
-
-	// Resolve directories and files.
-	path := strings.Join([]string{gh.registryRepoPath, libID}, "/")
-	err = gh.resolveDir(client, libID, path, resolvedSHA, onFile, onDir)
-	if err != nil {
-		return nil, nil, err
-	}
-
-	// Resolve app spec.
-	appSpecPath := strings.Join([]string{path, partsYAMLFile}, "/")
-	ctx = context.Background()
-	getOpts := &github.RepositoryContentGetOptions{Ref: resolvedSHA}
-	file, directory, _, err := client.Repositories.GetContents(ctx, gh.org, gh.repo, appSpecPath, getOpts)
-	if err != nil {
-		return nil, nil, err
-	} else if directory != nil {
-		return nil, nil, fmt.Errorf("Can't download library specification; resource '%s' points at a file", gh.registrySpecRawURL())
-	}
-
-	partsSpecText, err := file.GetContent()
-	if err != nil {
-		return nil, nil, err
-	}
-
-	parts, err := parts.Unmarshal([]byte(partsSpecText))
-	if err != nil {
-		return nil, nil, err
-	}
-
-	refSpec := app.LibraryRefSpec{
-		Name:     libAlias,
-		Registry: gh.Name,
-		GitVersion: &app.GitVersionSpec{
-			RefSpec:   libRefSpec,
-			CommitSHA: resolvedSHA,
-		},
-	}
-
-	return parts, &refSpec, nil
-}
-
-func (gh *gitHubRegistryManager) resolveDir(client *github.Client, libID, path, version string, onFile registry.ResolveFile, onDir registry.ResolveDirectory) error {
-	ctx := context.Background()
-	getOpts := &github.RepositoryContentGetOptions{Ref: version}
-	file, directory, _, err := client.Repositories.GetContents(ctx, gh.org, gh.repo, path, getOpts)
-	if err != nil {
-		return err
-	} else if file != nil {
-		return fmt.Errorf("Lib ID '%s' resolves to a file in registry '%s'", libID, gh.Name)
-	}
-
-	for _, item := range directory {
-		switch item.GetType() {
-		case "file":
-			itemPath := item.GetPath()
-			file, directory, _, err := client.Repositories.GetContents(ctx, gh.org, gh.repo, itemPath, getOpts)
-			if err != nil {
-				return err
-			} else if directory != nil {
-				return fmt.Errorf("INTERNAL ERROR: GitHub API reported resource '%s' of type file, but returned type dir", itemPath)
-			}
-			contents, err := file.GetContent()
-			if err != nil {
-				return err
-			}
-			if err := onFile(itemPath, []byte(contents)); err != nil {
-				return err
-			}
-		case "dir":
-			itemPath := item.GetPath()
-			if err := onDir(itemPath); err != nil {
-				return err
-			}
-			if err := gh.resolveDir(client, libID, itemPath, version, onFile, onDir); err != nil {
-				return err
-			}
-		case "symlink":
-		case "submodule":
-			return fmt.Errorf("Invalid library '%s'; ksonnet doesn't support libraries with symlinks or submodules", libID)
-		}
-	}
-
-	return nil
-}
-
-func (gh *gitHubRegistryManager) registrySpecRawURL() string {
-	return strings.Join([]string{rawGitHubRoot, gh.org, gh.repo, gh.GitVersion.RefSpec, gh.registrySpecRepoPath}, "/")
-}
-
-//
-// Helper functions.
-//
-
-func parseGitHubURI(uri string) (org, repo, refSpec, regRepoPath, regSpecRepoPath string, err error) {
-	// Normalize URI.
-	uri = strings.TrimSpace(uri)
-	if strings.HasPrefix(uri, "http://github.com") || strings.HasPrefix(uri, "https://github.com") || strings.HasPrefix(uri, "http://www.github.com") || strings.HasPrefix(uri, "https://www.github.com") {
-		// Do nothing.
-	} else if strings.HasPrefix(uri, "github.com") || strings.HasPrefix(uri, "www.github.com") {
-		uri = "http://" + uri
-	} else {
-		return "", "", "", "", "", fmt.Errorf("Registries using protocol 'github' must provide URIs beginning with 'github.com' (optionally prefaced with 'http', 'https', 'www', and so on")
-	}
-
-	parsed, err := url.Parse(uri)
-	if err != nil {
-		return "", "", "", "", "", err
-	}
-
-	if len(parsed.Query()) != 0 {
-		return "", "", "", "", "", fmt.Errorf("No query strings allowed in registry URI:\n%s", uri)
-	}
-
-	components := strings.Split(parsed.Path, "/")
-	if len(components) < 3 {
-		return "", "", "", "", "", fmt.Errorf("GitHub URI must point at a respository:\n%s", uri)
-	}
-
-	// NOTE: The first component is always blank, because the path
-	// begins like: '/whatever'.
-	org = components[1]
-	repo = components[2]
-
-	//
-	// Parse out `regSpecRepoPath`. There are a few cases:
-	//   * URI points at a directory inside the respoitory, e.g.,
-	//     'http://github.com/ksonnet/parts/tree/master/incubator'
-	//   * URI points at an 'app.yaml', e.g.,
-	//     'http://github.com/ksonnet/parts/blob/master/registry.yaml'
-	//   * URI points at a repository root, e.g.,
-	//     'http://github.com/ksonnet/parts'
-	//
-	if len := len(components); len > 4 {
-		refSpec = components[4]
-
-		//
-		// Case where we're pointing at either a directory inside a GitHub
-		// URL, or an 'app.yaml' inside a GitHub URL.
-		//
-
-		// See note above about first component being blank.
-		if components[3] == "tree" {
-			// If we have a trailing '/' character, last component will be blank. Make
-			// sure that `regRepoPath` does not contain a trailing `/`.
-			if components[len-1] == "" {
-				regRepoPath = strings.Join(components[5:len-1], "/")
-				components[len-1] = registryYAMLFile
-			} else {
-				regRepoPath = strings.Join(components[5:], "/")
-				components = append(components, registryYAMLFile)
-			}
-			regSpecRepoPath = strings.Join(components[5:], "/")
-			return
-		} else if components[3] == "blob" && components[len-1] == registryYAMLFile {
-			regRepoPath = strings.Join(components[5:len-1], "/")
-			// Path to the `registry.yaml` (may or may not exist).
-			regSpecRepoPath = strings.Join(components[5:], "/")
-			return
-		} else {
-			return "", "", "", "", "", errInvalidURI
-		}
-	} else {
-		refSpec = defaultGitHubBranch
-
-		// Else, URI should point at repository root.
-		if components[len-1] == "" {
-			components[len-1] = defaultGitHubBranch
-			components = append(components, registryYAMLFile)
-		} else {
-			components = append(components, defaultGitHubBranch, registryYAMLFile)
-		}
-
-		regRepoPath = ""
-		regSpecRepoPath = registryYAMLFile
-		return
-	}
-}
diff --git a/metadata/registry_test.go b/metadata/registry_test.go
index 01cef9b8b94c6a2100937dd5453e217b0e09069c..2e6d281f09cd3e6004ea85459fbc1950b1c0212f 100644
--- a/metadata/registry_test.go
+++ b/metadata/registry_test.go
@@ -17,160 +17,12 @@ package metadata
 
 import (
 	"path"
-	"testing"
 
 	"github.com/ksonnet/ksonnet/metadata/app"
-	"github.com/ksonnet/ksonnet/metadata/parts"
-	"github.com/ksonnet/ksonnet/metadata/registry"
+	"github.com/ksonnet/ksonnet/pkg/parts"
+	"github.com/ksonnet/ksonnet/pkg/registry"
 )
 
-func TestParseGiHubRegistryURITest(t *testing.T) {
-	tests := []struct {
-		// Specification to parse.
-		uri string
-
-		// Optional error to check.
-		targetErr error
-
-		// Optional results to verify.
-		targetOrg                  string
-		targetRepo                 string
-		targetRefSpec              string
-		targetRegistryRepoPath     string
-		targetRegistrySpecRepoPath string
-	}{
-		//
-		// `parseGitHubURI` should correctly parse org, repo, and refspec. Does not
-		// test path parsing.
-		//
-		{
-			uri: "github.com/exampleOrg1/exampleRepo1",
-
-			targetOrg:                  "exampleOrg1",
-			targetRepo:                 "exampleRepo1",
-			targetRefSpec:              "master",
-			targetRegistryRepoPath:     "",
-			targetRegistrySpecRepoPath: "registry.yaml",
-		},
-		{
-			uri: "github.com/exampleOrg2/exampleRepo2/tree/master",
-
-			targetOrg:                  "exampleOrg2",
-			targetRepo:                 "exampleRepo2",
-			targetRefSpec:              "master",
-			targetRegistryRepoPath:     "",
-			targetRegistrySpecRepoPath: "registry.yaml",
-		},
-		{
-			uri: "github.com/exampleOrg3/exampleRepo3/tree/exampleBranch1",
-
-			targetOrg:                  "exampleOrg3",
-			targetRepo:                 "exampleRepo3",
-			targetRefSpec:              "exampleBranch1",
-			targetRegistryRepoPath:     "",
-			targetRegistrySpecRepoPath: "registry.yaml",
-		},
-		{
-			// Fails because `blob` refers to a file, but this refers to a directory.
-			uri:       "github.com/exampleOrg4/exampleRepo4/blob/master",
-			targetErr: errInvalidURI,
-		},
-		{
-			uri: "github.com/exampleOrg4/exampleRepo4/tree/exampleBranch2",
-
-			targetOrg:                  "exampleOrg4",
-			targetRepo:                 "exampleRepo4",
-			targetRefSpec:              "exampleBranch2",
-			targetRegistryRepoPath:     "",
-			targetRegistrySpecRepoPath: "registry.yaml",
-		},
-
-		//
-		// Parsing URIs with paths.
-		//
-		{
-			// Fails because referring to a directory requires a URI with
-			// `tree/{branchName}` prepending the path.
-			uri:       "github.com/exampleOrg6/exampleRepo6/path/to/some/registry",
-			targetErr: errInvalidURI,
-		},
-		{
-			uri: "github.com/exampleOrg5/exampleRepo5/tree/master/path/to/some/registry",
-
-			targetOrg:                  "exampleOrg5",
-			targetRepo:                 "exampleRepo5",
-			targetRefSpec:              "master",
-			targetRegistryRepoPath:     "path/to/some/registry",
-			targetRegistrySpecRepoPath: "path/to/some/registry/registry.yaml",
-		},
-		{
-			uri: "github.com/exampleOrg6/exampleRepo6/tree/exampleBranch3/path/to/some/registry",
-
-			targetOrg:                  "exampleOrg6",
-			targetRepo:                 "exampleRepo6",
-			targetRefSpec:              "exampleBranch3",
-			targetRegistryRepoPath:     "path/to/some/registry",
-			targetRegistrySpecRepoPath: "path/to/some/registry/registry.yaml",
-		},
-		{
-			// Fails because `blob` refers to a file, but this refers to a directory.
-			uri:       "github.com/exampleOrg7/exampleRepo7/blob/master",
-			targetErr: errInvalidURI,
-		},
-		{
-			// Fails because `blob` refers to a file, but this refers to a directory.
-			uri:       "github.com/exampleOrg5/exampleRepo5/blob/exampleBranch2",
-			targetErr: errInvalidURI,
-		},
-	}
-
-	for _, test := range tests {
-		// Make sure we correctly parse each URN as a bare-domain URI, as well as
-		// with 'http://' and 'https://' as prefixes.
-		for _, prefix := range []string{"http://", "https://", "http://www.", "https://www.", "www.", ""} {
-			// Make sure we correctly parse each URI even if it has the optional
-			// trailing `/` character.
-			for _, suffix := range []string{"/", ""} {
-				uri := prefix + test.uri + suffix
-
-				t.Run(uri, func(t *testing.T) {
-					org, repo, refspec, registryRepoPath, registrySpecRepoPath, err := parseGitHubURI(uri)
-					if test.targetErr != nil {
-						if err != test.targetErr {
-							t.Fatalf("Expected URI '%s' parse to fail with err '%v', got: '%v'", uri, test.targetErr, err)
-						}
-						return
-					}
-
-					if err != nil {
-						t.Fatalf("Expected parse to succeed, but failed with error '%v'", err)
-					}
-
-					if org != test.targetOrg {
-						t.Errorf("Expected org '%s', got '%s'", test.targetOrg, org)
-					}
-
-					if repo != test.targetRepo {
-						t.Errorf("Expected repo '%s', got '%s'", test.targetRepo, repo)
-					}
-
-					if refspec != test.targetRefSpec {
-						t.Errorf("Expected refspec '%s', got '%s'", test.targetRefSpec, refspec)
-					}
-
-					if registryRepoPath != test.targetRegistryRepoPath {
-						t.Errorf("Expected registryRepoPath '%s', got '%s'", test.targetRegistryRepoPath, registryRepoPath)
-					}
-
-					if registrySpecRepoPath != test.targetRegistrySpecRepoPath {
-						t.Errorf("Expected targetRegistrySpecRepoPath '%s', got '%s'", test.targetRegistrySpecRepoPath, registrySpecRepoPath)
-					}
-				})
-			}
-		}
-	}
-}
-
 //
 // Mock registry manager for end-to-end tests.
 //
@@ -180,6 +32,8 @@ type mockRegistryManager struct {
 	registryDir string
 }
 
+var _ registry.Registry = (*mockRegistryManager)(nil)
+
 func newMockRegistryManager(name string) *mockRegistryManager {
 	return &mockRegistryManager{
 		registryDir: name,
@@ -189,6 +43,18 @@ func newMockRegistryManager(name string) *mockRegistryManager {
 	}
 }
 
+func (m *mockRegistryManager) Name() string {
+	return m.registryDir
+}
+
+func (m *mockRegistryManager) Protocol() string {
+	return registry.ProtocolGitHub
+}
+
+func (m *mockRegistryManager) URI() string {
+	return "github.com/foo/bar"
+}
+
 func (m *mockRegistryManager) ResolveLibrarySpec(libID, libRefSpec string) (*parts.Spec, error) {
 	return nil, nil
 }
diff --git a/pkg/kubecfg/registry.go b/pkg/kubecfg/registry.go
deleted file mode 100644
index 4de55f786de3052d2a76b5719f66aa08b16a6ae7..0000000000000000000000000000000000000000
--- a/pkg/kubecfg/registry.go
+++ /dev/null
@@ -1,44 +0,0 @@
-// Copyright 2017 The ksonnet authors
-//
-//
-//    Licensed under the Apache License, Version 2.0 (the "License");
-//    you may not use this file except in compliance with the License.
-//    You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-//    Unless required by applicable law or agreed to in writing, software
-//    distributed under the License is distributed on an "AS IS" BASIS,
-//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-//    See the License for the specific language governing permissions and
-//    limitations under the License.
-
-package kubecfg
-
-// RegistryAddCmd contains the metadata needed to create a registry.
-type RegistryAddCmd struct {
-	name     string
-	protocol string
-	uri      string
-	version  string
-}
-
-// NewRegistryAddCmd initializes a RegistryAddCmd.
-func NewRegistryAddCmd(name, protocol, uri, version string) *RegistryAddCmd {
-	if version == "" {
-		version = "latest"
-	}
-
-	return &RegistryAddCmd{name: name, protocol: protocol, uri: uri, version: version}
-}
-
-// Run adds the registry to the ksonnet project.
-func (c *RegistryAddCmd) Run() error {
-	manager, err := manager()
-	if err != nil {
-		return err
-	}
-
-	_, err = manager.AddRegistry(c.name, c.protocol, c.uri, c.version)
-	return err
-}
diff --git a/metadata/parts/schema.go b/pkg/parts/schema.go
similarity index 100%
rename from metadata/parts/schema.go
rename to pkg/parts/schema.go
diff --git a/metadata/parts/schema_test.go b/pkg/parts/schema_test.go
similarity index 100%
rename from metadata/parts/schema_test.go
rename to pkg/parts/schema_test.go
diff --git a/pkg/pkg/name.go b/pkg/pkg/name.go
new file mode 100644
index 0000000000000000000000000000000000000000..1fb22e7e1fd648e0040e0c235eca8c9ffc7a1b09
--- /dev/null
+++ b/pkg/pkg/name.go
@@ -0,0 +1,58 @@
+// Copyright 2018 The ksonnet authors
+//
+//
+//    Licensed under the Apache License, Version 2.0 (the "License");
+//    you may not use this file except in compliance with the License.
+//    You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+//    Unless required by applicable law or agreed to in writing, software
+//    distributed under the License is distributed on an "AS IS" BASIS,
+//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//    See the License for the specific language governing permissions and
+//    limitations under the License.
+
+package pkg
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/pkg/errors"
+)
+
+var errInvalidSpec = fmt.Errorf("package name should be in the form `<registry>/<library>@<version>`")
+
+// Descriptor describes a package.
+type Descriptor struct {
+	Registry string
+	Part     string
+	Version  string
+}
+
+// ParseName parses a package name into its components
+func ParseName(name string) (Descriptor, error) {
+	split := strings.SplitN(name, "/", 2)
+	if len(split) < 2 {
+		return Descriptor{}, errInvalidSpec
+	}
+
+	registryName := split[0]
+	partName := strings.SplitN(split[1], "@", 2)[0]
+
+	split = strings.Split(name, "@")
+	if len(split) > 2 {
+		return Descriptor{}, errors.Errorf("symbol '@' is only allowed once, at the end of the argument of the form <registry>/<library>@<version>")
+	}
+	var version string
+	if len(split) == 2 {
+		version = split[1]
+	}
+
+	return Descriptor{
+		Registry: registryName,
+		Part:     partName,
+		Version:  version,
+	}, nil
+}
diff --git a/pkg/pkg/name_test.go b/pkg/pkg/name_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..000390d8ab9e6a621914ee13c734510972ab6680
--- /dev/null
+++ b/pkg/pkg/name_test.go
@@ -0,0 +1,59 @@
+// Copyright 2018 The ksonnet authors
+//
+//
+//    Licensed under the Apache License, Version 2.0 (the "License");
+//    you may not use this file except in compliance with the License.
+//    You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+//    Unless required by applicable law or agreed to in writing, software
+//    distributed under the License is distributed on an "AS IS" BASIS,
+//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//    See the License for the specific language governing permissions and
+//    limitations under the License.
+
+package pkg
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func Test_ParseName(t *testing.T) {
+	cases := []struct {
+		name     string
+		expected Descriptor
+		isErr    bool
+	}{
+		{
+			name:     "parts-infra/contour",
+			expected: Descriptor{Registry: "parts-infra", Part: "contour"},
+		},
+		{
+			name:     "parts-infra/contour@0.1.0",
+			expected: Descriptor{Registry: "parts-infra", Part: "contour", Version: "0.1.0"},
+		},
+		{
+			name:  "invalid",
+			isErr: true,
+		},
+		{
+			name:  "foo/bar@baz@doh",
+			isErr: true,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			d, err := ParseName(tc.name)
+			if tc.isErr {
+				require.Error(t, err)
+				return
+			}
+
+			require.Equal(t, tc.expected, d)
+		})
+	}
+}
diff --git a/pkg/registry/add.go b/pkg/registry/add.go
new file mode 100644
index 0000000000000000000000000000000000000000..58d7b2189b1299ec6b3d10319de99758cc08465d
--- /dev/null
+++ b/pkg/registry/add.go
@@ -0,0 +1,135 @@
+// Copyright 2018 The ksonnet authors
+//
+//
+//    Licensed under the Apache License, Version 2.0 (the "License");
+//    you may not use this file except in compliance with the License.
+//    You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+//    Unless required by applicable law or agreed to in writing, software
+//    distributed under the License is distributed on an "AS IS" BASIS,
+//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//    See the License for the specific language governing permissions and
+//    limitations under the License.
+
+package registry
+
+import (
+	"path/filepath"
+
+	"github.com/ksonnet/ksonnet/metadata/app"
+	"github.com/pkg/errors"
+	"github.com/spf13/afero"
+)
+
+// Add adds a registry with `name`, `protocol`, and `uri` to
+// the current ksonnet application.
+func Add(a app.App, name, protocol, uri, version string) (*Spec, error) {
+	var r Registry
+	var err error
+
+	initSpec := &app.RegistryRefSpec{
+		Name:     name,
+		Protocol: protocol,
+		URI:      uri,
+	}
+
+	switch protocol {
+	case ProtocolGitHub:
+		r, err = githubFactory(initSpec)
+	case ProtocolFilesystem:
+		r, err = NewFs(a, initSpec)
+	default:
+		return nil, errors.Errorf("invalid registry protocol %q", protocol)
+	}
+
+	if err != nil {
+		return nil, err
+	}
+
+	err = a.AddRegistry(r.MakeRegistryRefSpec())
+	if err != nil {
+		return nil, err
+	}
+
+	// Retrieve the contents of registry.
+	registrySpec, err := getOrCacheRegistry(a, r)
+	if err != nil {
+		return nil, errors.Wrap(err, "cache registry")
+	}
+
+	return registrySpec, nil
+}
+
+func getOrCacheRegistry(a app.App, gh Registry) (*Spec, error) {
+	// Check local disk cache.
+	registrySpecFile := makePath(a, gh)
+	registrySpec, exists, err := load(a, registrySpecFile)
+	if err != nil {
+		return nil, errors.Wrap(err, "load registry spec file")
+	}
+
+	if !exists {
+		// If failed, use the protocol to try to retrieve app specification.
+		registrySpec, err = gh.FetchRegistrySpec()
+		if err != nil {
+			return nil, err
+		}
+
+		registrySpecBytes, err := registrySpec.Marshal()
+		if err != nil {
+			return nil, err
+		}
+
+		// NOTE: We call mkdir after getting the registry spec, since a
+		// network call might fail and leave this half-initialized empty
+		// directory.
+		registrySpecDir := filepath.Join(root(a), gh.RegistrySpecDir())
+		err = a.Fs().MkdirAll(registrySpecDir, app.DefaultFolderPermissions)
+		if err != nil {
+			return nil, err
+		}
+
+		err = afero.WriteFile(a.Fs(), registrySpecFile, registrySpecBytes, app.DefaultFilePermissions)
+		if err != nil {
+			return nil, err
+		}
+	} else if err != nil {
+		return nil, err
+	}
+
+	return registrySpec, nil
+}
+
+func load(a app.App, path string) (*Spec, bool, error) {
+	exists, err := afero.Exists(a.Fs(), path)
+	if err != nil {
+		return nil, false, errors.Wrapf(err, "check if %q exists", path)
+	}
+
+	// NOTE: case where directory of the same name exists should be
+	// fine, most filesystems allow you to have a directory and file of
+	// the same name.
+	if exists {
+		isDir, err := afero.IsDir(a.Fs(), path)
+		if err != nil {
+			return nil, false, errors.Wrapf(err, "check if %q is a dir", path)
+		}
+
+		if !isDir {
+			registrySpecBytes, err := afero.ReadFile(a.Fs(), path)
+			if err != nil {
+				return nil, false, err
+			}
+
+			registrySpec, err := Unmarshal(registrySpecBytes)
+			if err != nil {
+				return nil, false, err
+			}
+			return registrySpec, true, nil
+		}
+	}
+
+	return nil, false, nil
+}
diff --git a/pkg/registry/add_test.go b/pkg/registry/add_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..897489d56db7675b093fabfeade925edabca5e2b
--- /dev/null
+++ b/pkg/registry/add_test.go
@@ -0,0 +1,148 @@
+// Copyright 2018 The ksonnet authors
+//
+//
+//    Licensed under the Apache License, Version 2.0 (the "License");
+//    you may not use this file except in compliance with the License.
+//    You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+//    Unless required by applicable law or agreed to in writing, software
+//    distributed under the License is distributed on an "AS IS" BASIS,
+//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//    See the License for the specific language governing permissions and
+//    limitations under the License.
+
+package registry
+
+import (
+	"path/filepath"
+	"testing"
+
+	"github.com/spf13/afero"
+
+	"github.com/ksonnet/ksonnet/metadata/app"
+	amocks "github.com/ksonnet/ksonnet/metadata/app/mocks"
+	ghutil "github.com/ksonnet/ksonnet/pkg/util/github"
+	"github.com/ksonnet/ksonnet/pkg/util/github/mocks"
+	"github.com/ksonnet/ksonnet/pkg/util/test"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+	"github.com/stretchr/testify/require"
+)
+
+func withMockManager(t *testing.T, fn func(*amocks.App, afero.Fs)) {
+	fs := afero.NewMemMapFs()
+	appMock := &amocks.App{}
+	appMock.On("Fs").Return(fs)
+	appMock.On("Root").Return("/app")
+	appMock.On("LibPath", mock.AnythingOfType("string")).Return(filepath.Join("/app", "lib", "v1.8.7"), nil)
+
+	fn(appMock, fs)
+}
+
+func TestAdd(t *testing.T) {
+	withMockManager(t, func(appMock *amocks.App, fs afero.Fs) {
+		expectedSpec := &app.RegistryRefSpec{
+			Name:     "new",
+			Protocol: ProtocolGitHub,
+			URI:      "github.com/foo/bar",
+			GitVersion: &app.GitVersionSpec{
+				CommitSHA: "40285d8a14f1ac5787e405e1023cf0c07f6aa28c",
+				RefSpec:   "master",
+			},
+		}
+
+		appMock.On("AddRegistry", expectedSpec).Return(nil)
+
+		ghMock := &mocks.GitHub{}
+		ghMock.On("CommitSHA1", mock.Anything, mock.Anything, "master").Return("40285d8a14f1ac5787e405e1023cf0c07f6aa28c", nil)
+
+		registryContent := buildContent(t, registryYAMLFile)
+		ghMock.On(
+			"Contents",
+			mock.Anything,
+			ghutil.Repo{Org: "foo", Repo: "bar"},
+			registryYAMLFile,
+			"40285d8a14f1ac5787e405e1023cf0c07f6aa28c").
+			Return(registryContent, nil, nil)
+
+		ghOpt := GitHubClient(ghMock)
+		ogGHFactory := githubFactory
+		defer func(fn func(registryRef *app.RegistryRefSpec) (*GitHub, error)) {
+			githubFactory = fn
+		}(ogGHFactory)
+
+		githubFactory = func(registryRef *app.RegistryRefSpec) (*GitHub, error) {
+			return NewGitHub(registryRef, ghOpt)
+		}
+
+		spec, err := Add(appMock, "new", ProtocolGitHub, "github.com/foo/bar", "")
+		require.NoError(t, err)
+
+		require.Equal(t, registrySpec, spec)
+
+	})
+}
+
+func Test_load(t *testing.T) {
+	withMockManager(t, func(appMock *amocks.App, fs afero.Fs) {
+
+		test.StageFile(t, fs, "registry.yaml", "/app/registry.yaml")
+		test.StageFile(t, fs, "invalid-registry.yaml", "/app/invalid-registry.yaml")
+
+		cases := []struct {
+			name     string
+			path     string
+			isErr    bool
+			expected *Spec
+			exists   bool
+		}{
+			{
+				name:     "file exists",
+				path:     "/app/registry.yaml",
+				expected: registrySpec,
+				exists:   true,
+			},
+			{
+				name:   "file is not valid",
+				path:   "/app/invalid-registry.yaml",
+				isErr:  true,
+				exists: false,
+			},
+		}
+
+		for _, tc := range cases {
+			t.Run(tc.name, func(t *testing.T) {
+				s, exists, err := load(appMock, tc.path)
+
+				if tc.isErr {
+					require.Error(t, err)
+					return
+				}
+
+				require.NoError(t, err)
+				assert.Equal(t, tc.expected, s)
+				assert.Equal(t, tc.exists, exists)
+			})
+		}
+
+	})
+}
+
+var (
+	registrySpec = &Spec{
+		APIVersion: "0.1.0",
+		Kind:       "ksonnet.io/registry",
+		GitVersion: &app.GitVersionSpec{
+			RefSpec:   "master",
+			CommitSHA: "40285d8a14f1ac5787e405e1023cf0c07f6aa28c",
+		},
+		Libraries: LibraryRefSpecs{
+			"apache": &LibraryRef{
+				Version: "master",
+				Path:    "apache",
+			},
+		},
+	}
+)
diff --git a/pkg/registry/cache.go b/pkg/registry/cache.go
new file mode 100644
index 0000000000000000000000000000000000000000..d8144e60625bfb57f1f928e66c5c4779cc938a31
--- /dev/null
+++ b/pkg/registry/cache.go
@@ -0,0 +1,99 @@
+// Copyright 2018 The ksonnet authors
+//
+//
+//    Licensed under the Apache License, Version 2.0 (the "License");
+//    you may not use this file except in compliance with the License.
+//    You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+//    Unless required by applicable law or agreed to in writing, software
+//    distributed under the License is distributed on an "AS IS" BASIS,
+//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//    See the License for the specific language governing permissions and
+//    limitations under the License.
+
+package registry
+
+import (
+	"fmt"
+	"path/filepath"
+
+	"github.com/ksonnet/ksonnet/metadata/app"
+	"github.com/ksonnet/ksonnet/pkg/pkg"
+	"github.com/pkg/errors"
+	log "github.com/sirupsen/logrus"
+	"github.com/spf13/afero"
+)
+
+// CacheDependency caches registry dependencies.
+// TODO: create unit tests for this once mocks for this package are
+// worked out.
+func CacheDependency(a app.App, d pkg.Descriptor, customName string) error {
+	libs, err := a.Libraries()
+	if err != nil {
+		return err
+	}
+
+	if _, ok := libs[customName]; ok {
+		return errors.Errorf("package '%s' already exists. Use the --name flag to install this package with a unique identifier",
+			customName)
+	}
+
+	registries, err := a.Registries()
+	if err != nil {
+		return err
+	}
+
+	regRefSpec, exists := registries[d.Registry]
+	if !exists {
+		return fmt.Errorf("registry '%s' does not exist", d.Registry)
+	}
+
+	r, err := Locate(a, regRefSpec)
+	if err != nil {
+		return err
+	}
+
+	vendorPath := filepath.Join(a.Root(), "vendor")
+
+	// Get all directories and files first, then write to disk. This
+	// protects us from failing with a half-cached dependency because of
+	// a network failure.
+	directories := []string{}
+	files := map[string][]byte{}
+	_, libRef, err := r.ResolveLibrary(
+		d.Part,
+		customName,
+		d.Version,
+		func(relPath string, contents []byte) error {
+			files[filepath.Join(vendorPath, relPath)] = contents
+			return nil
+		},
+		func(relPath string) error {
+			directories = append(directories, filepath.Join(vendorPath, relPath))
+			return nil
+		})
+	if err != nil {
+		return err
+	}
+
+	// Add library to app specification, but wait to write it out until
+	// the end, in case one of the network calls fails.
+
+	log.Infof("Retrieved %d files", len(files))
+
+	for _, dir := range directories {
+		if err = a.Fs().MkdirAll(dir, app.DefaultFolderPermissions); err != nil {
+			return err
+		}
+	}
+
+	for path, content := range files {
+		if err = afero.WriteFile(a.Fs(), path, content, app.DefaultFilePermissions); err != nil {
+			return err
+		}
+	}
+
+	return a.UpdateLib(customName, libRef)
+}
diff --git a/pkg/registry/fs.go b/pkg/registry/fs.go
new file mode 100644
index 0000000000000000000000000000000000000000..0f341881ad9fa163d912ddc9638481c203e04cf7
--- /dev/null
+++ b/pkg/registry/fs.go
@@ -0,0 +1,152 @@
+// Copyright 2018 The ksonnet authors
+//
+//
+//    Licensed under the Apache License, Version 2.0 (the "License");
+//    you may not use this file except in compliance with the License.
+//    You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+//    Unless required by applicable law or agreed to in writing, software
+//    distributed under the License is distributed on an "AS IS" BASIS,
+//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//    See the License for the specific language governing permissions and
+//    limitations under the License.
+
+package registry
+
+import (
+	"net/url"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/ksonnet/ksonnet/metadata/app"
+	"github.com/ksonnet/ksonnet/pkg/parts"
+	"github.com/pkg/errors"
+	"github.com/sirupsen/logrus"
+	"github.com/spf13/afero"
+)
+
+// Fs is a registry based on a local filesystem.
+type Fs struct {
+	app  app.App
+	spec *app.RegistryRefSpec
+	root string
+}
+
+// NewFs creates an instance of Fs. Assign a name to the RegistryRefSpec if you want
+// the Fs to know it's name.
+func NewFs(a app.App, registryRef *app.RegistryRefSpec) (*Fs, error) {
+	fs := &Fs{
+		app:  a,
+		spec: registryRef,
+	}
+
+	u, err := url.Parse(registryRef.URI)
+	if err != nil {
+		return nil, err
+	}
+
+	if u.Scheme != "file" {
+		return nil, errors.Errorf("unknown file protocol %q", u.Scheme)
+	}
+
+	fs.root = u.Path
+
+	return fs, nil
+}
+
+var _ Registry = (*Fs)(nil)
+
+// Name is the registry name.
+func (fs *Fs) Name() string {
+	return fs.spec.Name
+}
+
+// Protocol is the registry protocol.
+func (fs *Fs) Protocol() string {
+	return fs.spec.Protocol
+}
+
+// URI is the registry URI.
+func (fs *Fs) URI() string {
+	return fs.spec.URI
+}
+
+// RegistrySpecDir is the registry directory.
+func (fs *Fs) RegistrySpecDir() string {
+	return fs.root
+}
+
+// RegistrySpecFilePath is the path for the registry.yaml
+func (fs *Fs) RegistrySpecFilePath() string {
+	return filepath.Join(fs.root, registryYAMLFile)
+}
+
+// FetchRegistrySpec fetches the registry spec.
+func (fs *Fs) FetchRegistrySpec() (*Spec, error) {
+	logrus.Debugf("fs: fetching fs registry spec %s", fs.RegistrySpecFilePath())
+	data, err := afero.ReadFile(fs.app.Fs(), fs.RegistrySpecFilePath())
+	if err != nil {
+		return nil, err
+	}
+
+	return Unmarshal(data)
+}
+
+// MakeRegistryRefSpec returns an app registry ref spec.
+func (fs *Fs) MakeRegistryRefSpec() *app.RegistryRefSpec {
+	return fs.spec
+}
+
+// ResolveLibrarySpec returns a resolved spec for a part. `libRefSpec` is ignored.
+func (fs *Fs) ResolveLibrarySpec(partName, libRefSpec string) (*parts.Spec, error) {
+	partRoot := filepath.Join(fs.RegistrySpecDir(), partName, partsYAMLFile)
+	data, err := afero.ReadFile(fs.app.Fs(), partRoot)
+	if err != nil {
+		return nil, err
+	}
+
+	return parts.Unmarshal(data)
+}
+
+// ResolveLibrary fetches the part and creates a parts spec and library ref spec.
+func (fs *Fs) ResolveLibrary(partName, partAlias, libRefSpec string, onFile ResolveFile, onDir ResolveDirectory) (*parts.Spec, *app.LibraryRefSpec, error) {
+	partRoot := filepath.Join(fs.RegistrySpecDir(), partName)
+	parentDir := filepath.Dir(fs.RegistrySpecDir())
+
+	partsSpec, err := fs.ResolveLibrarySpec(partName, libRefSpec)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	err = afero.Walk(fs.app.Fs(), partRoot, func(path string, fi os.FileInfo, err error) error {
+		if err != nil {
+			return err
+		}
+
+		libPath := strings.TrimPrefix(path, parentDir+"/")
+		if fi.IsDir() {
+			return onDir(libPath)
+		}
+
+		data, err := afero.ReadFile(fs.app.Fs(), path)
+		if err != nil {
+			return err
+		}
+		return onFile(libPath, data)
+	})
+
+	if err != nil {
+		return nil, nil, err
+	}
+
+	refSpec := &app.LibraryRefSpec{
+		Name:     partAlias,
+		Registry: fs.Name(),
+	}
+
+	return partsSpec, refSpec, nil
+
+}
diff --git a/pkg/registry/fs_test.go b/pkg/registry/fs_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..f8eda1e45c816cc24fb3224beb5f23d37b14e8bf
--- /dev/null
+++ b/pkg/registry/fs_test.go
@@ -0,0 +1,250 @@
+// Copyright 2018 The ksonnet authors
+//
+//
+//    Licensed under the Apache License, Version 2.0 (the "License");
+//    you may not use this file except in compliance with the License.
+//    You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+//    Unless required by applicable law or agreed to in writing, software
+//    distributed under the License is distributed on an "AS IS" BASIS,
+//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//    See the License for the specific language governing permissions and
+//    limitations under the License.
+
+package registry
+
+import (
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/ksonnet/ksonnet/metadata/app"
+	"github.com/ksonnet/ksonnet/metadata/app/mocks"
+	"github.com/ksonnet/ksonnet/pkg/parts"
+	"github.com/spf13/afero"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+	"github.com/stretchr/testify/require"
+)
+
+func withRFS(t *testing.T, fn func(*Fs, *mocks.App, afero.Fs)) {
+	fs := afero.NewMemMapFs()
+	appMock := &mocks.App{}
+	appMock.On("Fs").Return(fs)
+	appMock.On("Root").Return("/app")
+	appMock.On("LibPath", mock.AnythingOfType("string")).Return(filepath.Join("/app", "lib", "v1.8.7"), nil)
+
+	spec := &app.RegistryRefSpec{
+		Name:     "local",
+		Protocol: ProtocolFilesystem,
+		URI:      "file:///work/local",
+	}
+
+	partRoot := filepath.Join("testdata", "part", "incubator")
+	err := filepath.Walk(partRoot, func(path string, fi os.FileInfo, err error) error {
+		if err != nil {
+			return err
+		}
+
+		newPath := filepath.Join("/work", "local", strings.TrimPrefix(path, partRoot))
+		if fi.IsDir() {
+			return fs.MkdirAll(newPath, 0750)
+		}
+
+		data, err := ioutil.ReadFile(path)
+		require.NoError(t, err)
+
+		return afero.WriteFile(fs, newPath, data, 0644)
+	})
+	require.NoError(t, err)
+
+	data, err := ioutil.ReadFile(filepath.Join("testdata", "fs-registry.yaml"))
+	require.NoError(t, err)
+
+	err = afero.WriteFile(fs, "/work/local/registry.yaml", data, 0644)
+	require.NoError(t, err)
+
+	rfs, err := NewFs(appMock, spec)
+	require.NoError(t, err)
+
+	fn(rfs, appMock, fs)
+}
+
+func TestFs_Name(t *testing.T) {
+	withRFS(t, func(rfs *Fs, appMock *mocks.App, fs afero.Fs) {
+		assert.Equal(t, "local", rfs.Name())
+	})
+}
+
+func TestFs_Protocol(t *testing.T) {
+	withRFS(t, func(rfs *Fs, appMock *mocks.App, fs afero.Fs) {
+		assert.Equal(t, ProtocolFilesystem, rfs.Protocol())
+	})
+}
+
+func TestFs_URI(t *testing.T) {
+	withRFS(t, func(rfs *Fs, appMock *mocks.App, fs afero.Fs) {
+		assert.Equal(t, "file:///work/local", rfs.URI())
+	})
+}
+
+func TestFs_RegistrySpecDir(t *testing.T) {
+	withRFS(t, func(rfs *Fs, appMock *mocks.App, fs afero.Fs) {
+		assert.Equal(t, "/work/local", rfs.RegistrySpecDir())
+	})
+}
+
+func TestFs_RegistrySpecFilePath(t *testing.T) {
+	withRFS(t, func(rfs *Fs, appMock *mocks.App, fs afero.Fs) {
+		assert.Equal(t, "/work/local/registry.yaml", rfs.RegistrySpecFilePath())
+	})
+}
+
+func TestFs_FetchRegistrySpec(t *testing.T) {
+	withRFS(t, func(rfs *Fs, appMock *mocks.App, fs afero.Fs) {
+		spec, err := rfs.FetchRegistrySpec()
+		require.NoError(t, err)
+
+		expected := &Spec{
+			APIVersion: "0.1.0",
+			Kind:       "ksonnet.io/registry",
+			Libraries: LibraryRefSpecs{
+				"apache": &LibraryRef{
+					Path: "apache",
+				},
+			},
+		}
+
+		assert.Equal(t, expected, spec)
+	})
+}
+
+func TestFs_MakeRegistryRefSpec(t *testing.T) {
+	withRFS(t, func(rfs *Fs, appMock *mocks.App, fs afero.Fs) {
+		expected := &app.RegistryRefSpec{
+			Name:     "local",
+			Protocol: ProtocolFilesystem,
+			URI:      "file:///work/local",
+		}
+		assert.Equal(t, expected, rfs.MakeRegistryRefSpec())
+
+	})
+}
+
+func TestFs_ResolveLibrarySpec(t *testing.T) {
+	withRFS(t, func(rfs *Fs, appMock *mocks.App, fs afero.Fs) {
+		spec, err := rfs.ResolveLibrarySpec("apache", "")
+		require.NoError(t, err)
+
+		expected := &parts.Spec{
+			APIVersion:  "0.0.1",
+			Kind:        "ksonnet.io/parts",
+			Name:        "apache",
+			Description: "part description",
+			Author:      "author",
+			Contributors: parts.ContributorSpecs{
+				&parts.ContributorSpec{Name: "author 1", Email: "email@example.com"},
+				&parts.ContributorSpec{Name: "author 2", Email: "email@example.com"},
+			},
+			Repository: parts.RepositorySpec{
+				Type: "git",
+				URL:  "https://github.com/ksonnet/mixins",
+			},
+			Bugs: &parts.BugSpec{
+				URL: "https://github.com/ksonnet/mixins/issues",
+			},
+			Keywords: []string{"apache", "server", "http"},
+			QuickStart: &parts.QuickStartSpec{
+				Prototype:     "io.ksonnet.pkg.apache-simple",
+				ComponentName: "apache",
+				Flags: map[string]string{
+					"name":      "apache",
+					"namespace": "default",
+				},
+				Comment: "Run a simple Apache server",
+			},
+			License: "Apache 2.0",
+		}
+
+		assert.Equal(t, expected, spec)
+
+	})
+}
+
+func TestFs_ResolveLibrary(t *testing.T) {
+	withRFS(t, func(rfs *Fs, appMock *mocks.App, fs afero.Fs) {
+		var files []string
+		onFile := func(relPath string, contents []byte) error {
+			files = append(files, relPath)
+			return nil
+		}
+
+		var directories []string
+		onDir := func(relPath string) error {
+			directories = append(directories, relPath)
+			return nil
+		}
+
+		spec, libRefSpec, err := rfs.ResolveLibrary("apache", "alias", "54321", onFile, onDir)
+		require.NoError(t, err)
+
+		expectedSpec := &parts.Spec{
+			APIVersion:  "0.0.1",
+			Kind:        "ksonnet.io/parts",
+			Name:        "apache",
+			Description: "part description",
+			Author:      "author",
+			Contributors: parts.ContributorSpecs{
+				&parts.ContributorSpec{Name: "author 1", Email: "email@example.com"},
+				&parts.ContributorSpec{Name: "author 2", Email: "email@example.com"},
+			},
+			Repository: parts.RepositorySpec{
+				Type: "git",
+				URL:  "https://github.com/ksonnet/mixins",
+			},
+			Bugs: &parts.BugSpec{
+				URL: "https://github.com/ksonnet/mixins/issues",
+			},
+			Keywords: []string{"apache", "server", "http"},
+			QuickStart: &parts.QuickStartSpec{
+				Prototype:     "io.ksonnet.pkg.apache-simple",
+				ComponentName: "apache",
+				Flags: map[string]string{
+					"name":      "apache",
+					"namespace": "default",
+				},
+				Comment: "Run a simple Apache server",
+			},
+			License: "Apache 2.0",
+		}
+		assert.Equal(t, expectedSpec, spec)
+
+		expectedLibRefSpec := &app.LibraryRefSpec{
+			Name:     "alias",
+			Registry: "local",
+		}
+		assert.Equal(t, expectedLibRefSpec, libRefSpec)
+
+		expectedFiles := []string{
+			"local/apache/README.md",
+			"local/apache/apache.libsonnet",
+			"local/apache/examples/apache.jsonnet",
+			"local/apache/examples/generated.yaml",
+			"local/apache/parts.yaml",
+			"local/apache/prototypes/apache-simple.jsonnet",
+		}
+		assert.Equal(t, expectedFiles, files)
+
+		expectedDirs := []string{
+			"local/apache",
+			"local/apache/examples",
+			"local/apache/prototypes",
+		}
+		assert.Equal(t, expectedDirs, directories)
+
+	})
+}
diff --git a/pkg/registry/github.go b/pkg/registry/github.go
index c37663c86188e8ad440dc9def928d22fe1decfc9..ab96f112173fe3e89fa054d748befaf93cc048c3 100644
--- a/pkg/registry/github.go
+++ b/pkg/registry/github.go
@@ -15,35 +15,392 @@
 
 package registry
 
-import "github.com/ksonnet/ksonnet/metadata/app"
+import (
+	"context"
+	"fmt"
+	"net/url"
+	"path"
+	"strings"
 
-// GitHub is a GitHub based registry.
+	"github.com/ksonnet/ksonnet/metadata/app"
+	"github.com/ksonnet/ksonnet/pkg/parts"
+	"github.com/ksonnet/ksonnet/pkg/util/github"
+	"github.com/pkg/errors"
+)
+
+const (
+	rawGitHubRoot       = "https://raw.githubusercontent.com"
+	defaultGitHubBranch = "master"
+
+	uriField         = "uri"
+	refSpecField     = "refSpec"
+	resolvedSHAField = "resolvedSHA"
+)
+
+var (
+	// errInvalidURI is an invalid github uri error.
+	errInvalidURI = fmt.Errorf("Invalid GitHub URI: try navigating in GitHub to the URI of the folder containing the 'yaml', and using that URI instead. Generally, this URI should be of the form 'github.com/{organization}/{repository}/tree/{branch}/[path-to-directory]'")
+
+	githubFactory = func(spec *app.RegistryRefSpec) (*GitHub, error) {
+		return NewGitHub(spec)
+	}
+)
+
+// GitHubClient is an option for the setting a github client.
+func GitHubClient(c github.GitHub) GitHubOpt {
+	return func(gh *GitHub) {
+		gh.ghClient = c
+	}
+}
+
+// GitHubOpt is an option for configuring GitHub.
+type GitHubOpt func(*GitHub)
+
+// GitHub is a Github Registry
 type GitHub struct {
-	name string
-	spec *app.RegistryRefSpec
+	name     string
+	hd       *hubDescriptor
+	ghClient github.GitHub
+	spec     *app.RegistryRefSpec
 }
 
 // NewGitHub creates an instance of GitHub.
-func NewGitHub(name string, spec *app.RegistryRefSpec) *GitHub {
-	return &GitHub{
-		name: name,
-		spec: spec,
+func NewGitHub(registryRef *app.RegistryRefSpec, opts ...GitHubOpt) (*GitHub, error) {
+	if registryRef == nil {
+		return nil, errors.New("registry ref is nil")
 	}
-}
 
-var _ Registry = (*GitHub)(nil)
+	gh := &GitHub{
+		name:     registryRef.Name,
+		spec:     registryRef,
+		ghClient: github.DefaultClient,
+	}
+
+	hd, err := parseGitHubURI(gh.URI())
+	if err != nil {
+		return nil, err
+	}
+	gh.hd = hd
+
+	for _, opt := range opts {
+		opt(gh)
+	}
+
+	ctx := context.Background()
+	sha, err := gh.ghClient.CommitSHA1(ctx, hd.Repo(), hd.refSpec)
+	if err != nil {
+		return nil, errors.Wrap(err, "unable to find SHA1 for repo")
+	}
+
+	gh.spec.GitVersion = &app.GitVersionSpec{
+		RefSpec:   hd.refSpec,
+		CommitSHA: sha,
+	}
+
+	return gh, nil
+}
 
 // Name is the registry name.
-func (g *GitHub) Name() string {
-	return g.name
+func (gh *GitHub) Name() string {
+	return gh.name
 }
 
 // Protocol is the registry protocol.
-func (g *GitHub) Protocol() string {
-	return g.spec.Protocol
+func (gh *GitHub) Protocol() string {
+	return gh.spec.Protocol
 }
 
 // URI is the registry URI.
-func (g *GitHub) URI() string {
-	return g.spec.URI
+func (gh *GitHub) URI() string {
+	return gh.spec.URI
+}
+
+// RegistrySpecDir is the registry directory.
+func (gh *GitHub) RegistrySpecDir() string {
+	return gh.Name()
+}
+
+// RegistrySpecFilePath is the path for the registry.yaml
+func (gh *GitHub) RegistrySpecFilePath() string {
+	if gh.spec.GitVersion.CommitSHA != "" {
+		return path.Join(gh.Name(), gh.spec.GitVersion.CommitSHA+".yaml")
+	}
+	return path.Join(gh.Name(), gh.spec.GitVersion.RefSpec+".yaml")
+}
+
+// FetchRegistrySpec fetches the registry spec.
+func (gh *GitHub) FetchRegistrySpec() (*Spec, error) {
+	ctx := context.Background()
+
+	file, _, err := gh.ghClient.Contents(ctx, gh.hd.Repo(), gh.hd.regSpecRepoPath,
+		gh.spec.GitVersion.CommitSHA)
+	if file == nil {
+		return nil, fmt.Errorf("Could not find valid registry at uri '%s/%s/%s' and refspec '%s' (resolves to sha '%s')",
+			gh.hd.org, gh.hd.org, gh.hd.regSpecRepoPath, gh.spec.GitVersion.RefSpec,
+			gh.spec.GitVersion.CommitSHA)
+	} else if err != nil {
+		return nil, err
+	}
+
+	registrySpecText, err := file.GetContent()
+	if err != nil {
+		return nil, err
+	}
+
+	// Deserialize, return.
+	registrySpec, err := Unmarshal([]byte(registrySpecText))
+	if err != nil {
+		return nil, err
+	}
+
+	registrySpec.GitVersion = &app.GitVersionSpec{
+		RefSpec:   gh.spec.GitVersion.RefSpec,
+		CommitSHA: gh.spec.GitVersion.CommitSHA,
+	}
+
+	return registrySpec, nil
+}
+
+// MakeRegistryRefSpec returns an app registry ref spec.
+func (gh *GitHub) MakeRegistryRefSpec() *app.RegistryRefSpec {
+	return gh.spec
+}
+
+// ResolveLibrarySpec returns a resolved spec for a part.
+func (gh *GitHub) ResolveLibrarySpec(partName, libRefSpec string) (*parts.Spec, error) {
+	ctx := context.Background()
+	resolvedSHA, err := gh.ghClient.CommitSHA1(ctx, gh.hd.Repo(), libRefSpec)
+	if err != nil {
+		return nil, err
+	}
+
+	// Resolve app spec.
+	appSpecPath := strings.Join([]string{gh.hd.regRepoPath, partName, partsYAMLFile}, "/")
+
+	file, directory, err := gh.ghClient.Contents(ctx, gh.hd.Repo(), appSpecPath, resolvedSHA)
+	if err != nil {
+		return nil, err
+	} else if directory != nil {
+		return nil, fmt.Errorf("Can't download library specification; resource '%s' points at a file", gh.registrySpecRawURL())
+	}
+
+	partsSpecText, err := file.GetContent()
+	if err != nil {
+		return nil, err
+	}
+
+	parts, err := parts.Unmarshal([]byte(partsSpecText))
+	if err != nil {
+		return nil, err
+	}
+
+	return parts, nil
+}
+
+// ResolveLibrary fetches the part and creates a parts spec and library ref spec.
+func (gh *GitHub) ResolveLibrary(partName, partAlias, libRefSpec string, onFile ResolveFile, onDir ResolveDirectory) (*parts.Spec, *app.LibraryRefSpec, error) {
+	defaultRefSpec := "master"
+
+	// Resolve `version` (a git refspec) to a specific SHA.
+	ctx := context.Background()
+	if len(libRefSpec) == 0 {
+		libRefSpec = defaultRefSpec
+	}
+
+	resolvedSHA, err := gh.ghClient.CommitSHA1(ctx, gh.hd.Repo(), libRefSpec)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	// Resolve directories and files.
+	path := strings.Join([]string{gh.hd.regRepoPath, partName}, "/")
+	err = gh.resolveDir(partName, path, resolvedSHA, onFile, onDir)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	// Resolve app spec.
+	appSpecPath := strings.Join([]string{path, partsYAMLFile}, "/")
+	ctx = context.Background()
+	file, directory, err := gh.ghClient.Contents(ctx, gh.hd.Repo(), appSpecPath, resolvedSHA)
+
+	if err != nil {
+		return nil, nil, err
+	} else if directory != nil {
+		return nil, nil, fmt.Errorf("Can't download library specification; resource '%s' points at a file", gh.registrySpecRawURL())
+	}
+
+	partsSpecText, err := file.GetContent()
+	if err != nil {
+		return nil, nil, err
+	}
+
+	parts, err := parts.Unmarshal([]byte(partsSpecText))
+	if err != nil {
+		return nil, nil, err
+	}
+
+	refSpec := app.LibraryRefSpec{
+		Name:     partAlias,
+		Registry: gh.Name(),
+		GitVersion: &app.GitVersionSpec{
+			RefSpec:   libRefSpec,
+			CommitSHA: resolvedSHA,
+		},
+	}
+
+	return parts, &refSpec, nil
+}
+
+func (gh *GitHub) resolveDir(libID, path, version string, onFile ResolveFile, onDir ResolveDirectory) error {
+	ctx := context.Background()
+
+	file, directory, err := gh.ghClient.Contents(ctx, gh.hd.Repo(), path, version)
+	if err != nil {
+		return err
+	} else if file != nil {
+		return fmt.Errorf("Lib ID %q resolves to a file in registry %q", libID, gh.Name())
+	}
+
+	for _, item := range directory {
+		switch item.GetType() {
+		case "file":
+			itemPath := item.GetPath()
+			file, directory, err := gh.ghClient.Contents(ctx, gh.hd.Repo(), itemPath, version)
+			if err != nil {
+				return err
+			} else if directory != nil {
+				return fmt.Errorf("INTERNAL ERROR: GitHub API reported resource %q of type file, but returned type dir", itemPath)
+			}
+			contents, err := file.GetContent()
+			if err != nil {
+				return err
+			}
+			if err := onFile(itemPath, []byte(contents)); err != nil {
+				return err
+			}
+		case "dir":
+			itemPath := item.GetPath()
+			if err := onDir(itemPath); err != nil {
+				return err
+			}
+			if err := gh.resolveDir(libID, itemPath, version, onFile, onDir); err != nil {
+				return err
+			}
+		case "symlink":
+		case "submodule":
+			return fmt.Errorf("Invalid library %q; ksonnet doesn't support libraries with symlinks or submodules", libID)
+		}
+	}
+
+	return nil
+}
+
+func (gh *GitHub) registrySpecRawURL() string {
+	return strings.Join([]string{
+		rawGitHubRoot,
+		gh.hd.org,
+		gh.hd.repo,
+		gh.spec.GitVersion.RefSpec,
+		gh.hd.regSpecRepoPath}, "/")
+}
+
+type hubDescriptor struct {
+	org             string
+	repo            string
+	refSpec         string
+	regRepoPath     string
+	regSpecRepoPath string
+}
+
+func (hd *hubDescriptor) Repo() github.Repo {
+	return github.Repo{Org: hd.org, Repo: hd.repo}
+}
+
+// func parseGitHubURI(uri string) (org, repo, refSpec, regRepoPath, regSpecRepoPath string, err error) {
+func parseGitHubURI(uri string) (hd *hubDescriptor, err error) {
+	// Normalize URI.
+	uri = strings.TrimSpace(uri)
+	if strings.HasPrefix(uri, "http://github.com") || strings.HasPrefix(uri, "https://github.com") || strings.HasPrefix(uri, "http://www.github.com") || strings.HasPrefix(uri, "https://www.github.com") {
+		// Do nothing.
+	} else if strings.HasPrefix(uri, "github.com") || strings.HasPrefix(uri, "www.github.com") {
+		uri = "http://" + uri
+	} else {
+		return nil, errors.Errorf("Registries using protocol 'github' must provide URIs beginning with 'github.com' (optionally prefaced with 'http', 'https', 'www', and so on")
+	}
+
+	parsed, err := url.Parse(uri)
+	if err != nil {
+		return nil, err
+	}
+
+	if len(parsed.Query()) != 0 {
+		return nil, errors.Errorf("No query strings allowed in registry URI:\n%s", uri)
+	}
+
+	components := strings.Split(parsed.Path, "/")
+	if len(components) < 3 {
+		return nil, errors.Errorf("GitHub URI must point at a repository:\n%s", uri)
+	}
+
+	hd = &hubDescriptor{}
+
+	// NOTE: The first component is always blank, because the path
+	// begins like: '/whatever'.
+	hd.org = components[1]
+	hd.repo = components[2]
+
+	//
+	// Parse out `regSpecRepoPath`. There are a few cases:
+	//   * URI points at a directory inside the respoitory, e.g.,
+	//     'http://github.com/ksonnet/parts/tree/master/incubator'
+	//   * URI points at an 'app.yaml', e.g.,
+	//     'http://github.com/ksonnet/parts/blob/master/yaml'
+	//   * URI points at a repository root, e.g.,
+	//     'http://github.com/ksonnet/parts'
+	//
+	if len := len(components); len > 4 {
+		hd.refSpec = components[4]
+
+		//
+		// Case where we're pointing at either a directory inside a GitHub
+		// URL, or an 'app.yaml' inside a GitHub URL.
+		//
+
+		// See note above about first component being blank.
+		if components[3] == "tree" {
+			// If we have a trailing '/' character, last component will be blank. Make
+			// sure that `regRepoPath` does not contain a trailing `/`.
+			if components[len-1] == "" {
+				hd.regRepoPath = strings.Join(components[5:len-1], "/")
+				components[len-1] = registryYAMLFile
+			} else {
+				hd.regRepoPath = strings.Join(components[5:], "/")
+				components = append(components, registryYAMLFile)
+			}
+			hd.regSpecRepoPath = strings.Join(components[5:], "/")
+			return
+		} else if components[3] == "blob" && components[len-1] == registryYAMLFile {
+			hd.regRepoPath = strings.Join(components[5:len-1], "/")
+			// Path to the `yaml` (may or may not exist).
+			hd.regSpecRepoPath = strings.Join(components[5:], "/")
+			return
+		} else {
+			return nil, errInvalidURI
+		}
+	} else {
+		hd.refSpec = defaultGitHubBranch
+
+		// Else, URI should point at repository root.
+		if components[len-1] == "" {
+			components[len-1] = defaultGitHubBranch
+			components = append(components, registryYAMLFile)
+		} else {
+			components = append(components, defaultGitHubBranch, registryYAMLFile)
+		}
+
+		hd.regRepoPath = ""
+		hd.regSpecRepoPath = registryYAMLFile
+		return
+	}
 }
diff --git a/pkg/registry/github_test.go b/pkg/registry/github_test.go
index a69e095fe31f74893f13f88c1cb8e0b11050041d..0701c0d771c8532e87135c9cd1c0ed8190bad342 100644
--- a/pkg/registry/github_test.go
+++ b/pkg/registry/github_test.go
@@ -16,21 +16,487 @@
 package registry
 
 import (
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"strings"
 	"testing"
 
+	"github.com/google/go-github/github"
 	"github.com/ksonnet/ksonnet/metadata/app"
+	"github.com/ksonnet/ksonnet/pkg/parts"
+	ghutil "github.com/ksonnet/ksonnet/pkg/util/github"
+	"github.com/ksonnet/ksonnet/pkg/util/github/mocks"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+	"github.com/stretchr/testify/require"
 )
 
-func TestGithub(t *testing.T) {
+func makeGh(t *testing.T, u, sha1 string) (*GitHub, *mocks.GitHub) {
+	ghMock := &mocks.GitHub{}
+	ghMock.On("CommitSHA1", mock.Anything, ghutil.Repo{Org: "ksonnet", Repo: "parts"}, "master").
+		Return(sha1, nil)
+
+	optGh := GitHubClient(ghMock)
+
 	spec := &app.RegistryRefSpec{
-		Protocol: "github",
-		URI:      "github.com/foo/bar",
+		Name:     "incubator",
+		Protocol: ProtocolGitHub,
+		URI:      "github.com/ksonnet/parts/tree/master/incubator",
+	}
+
+	g, err := NewGitHub(spec, optGh)
+	require.NoError(t, err)
+
+	return g, ghMock
+}
+
+func buildContent(t *testing.T, name string) *github.RepositoryContent {
+	path := name
+	if !strings.HasPrefix(name, "testdata") {
+		path = filepath.Join("testdata", name)
+	}
+	data, err := ioutil.ReadFile(path)
+	require.NoError(t, err)
+
+	path = strings.TrimPrefix(path, "testdata/part/")
+
+	rc := &github.RepositoryContent{
+		Type:    github.String("file"),
+		Content: github.String(string(data)),
+		Path:    github.String(path),
+	}
+
+	return rc
+}
+
+func buildContentDir(t *testing.T, name string) []*github.RepositoryContent {
+	path := name
+	if !strings.HasPrefix(name, "testdata") {
+		path = filepath.Join("testdata", name)
 	}
+	fi, err := os.Stat(path)
+	require.NoError(t, err)
+	require.True(t, fi.IsDir())
 
-	g := NewGitHub("incubator", spec)
+	rcs := make([]*github.RepositoryContent, 0)
+
+	fis, err := ioutil.ReadDir(path)
+	require.NoError(t, err)
+
+	for _, fi = range fis {
+		childPath := filepath.Join(strings.TrimPrefix(path, "testdata/"), fi.Name())
+
+		if fi.IsDir() {
+			rc := &github.RepositoryContent{
+				Type: github.String("dir"),
+				Path: github.String(strings.TrimPrefix(childPath, "part/")),
+			}
+			rcs = append(rcs, rc)
+			continue
+		}
+		rc := buildContent(t, childPath)
+		rcs = append(rcs, rc)
+	}
+
+	return rcs
+}
+
+func TestGithub_Name(t *testing.T) {
+	u := "github.com/ksonnet/parts/tree/master/incubator"
+	g, _ := makeGh(t, u, "12345")
 
 	assert.Equal(t, "incubator", g.Name())
-	assert.Equal(t, "github", g.Protocol())
-	assert.Equal(t, "github.com/foo/bar", g.URI())
+}
+
+func TestGithub_Protocol(t *testing.T) {
+	u := "github.com/ksonnet/parts/tree/master/incubator"
+	g, _ := makeGh(t, u, "12345")
+
+	assert.Equal(t, ProtocolGitHub, g.Protocol())
+}
+
+func TestGithub_URI(t *testing.T) {
+	u := "github.com/ksonnet/parts/tree/master/incubator"
+	g, _ := makeGh(t, u, "12345")
+
+	assert.Equal(t, u, g.URI())
+}
+
+func TestGithub_RegistrySpecDir(t *testing.T) {
+	u := "github.com/ksonnet/parts/tree/master/incubator"
+	g, _ := makeGh(t, u, "12345")
+
+	assert.Equal(t, "incubator", g.RegistrySpecDir())
+}
+
+func TestGithub_RegistrySpecFilePath(t *testing.T) {
+	u := "github.com/ksonnet/parts/tree/master/incubator"
+	g, _ := makeGh(t, u, "12345")
+
+	assert.Equal(t, "incubator/12345.yaml", g.RegistrySpecFilePath())
+}
+
+func TestGithub_RegistrySpecFilePath_no_sha1(t *testing.T) {
+	u := "github.com/ksonnet/parts/tree/master/incubator"
+	g, _ := makeGh(t, u, "")
+
+	assert.Equal(t, "incubator/master.yaml", g.RegistrySpecFilePath())
+}
+
+func TestGithub_FetchRegistrySpec(t *testing.T) {
+	u := "github.com/ksonnet/parts/tree/master/incubator"
+	g, ghMock := makeGh(t, u, "12345")
+
+	file := buildContent(t, "registry.yaml")
+
+	ghMock.On(
+		"Contents",
+		mock.Anything,
+		ghutil.Repo{Org: "ksonnet", Repo: "parts"},
+		"incubator/registry.yaml",
+		"12345",
+	).Return(file, nil, nil)
+
+	spec, err := g.FetchRegistrySpec()
+	require.NoError(t, err)
+
+	expected := &Spec{
+		APIVersion: "0.1.0",
+		Kind:       "ksonnet.io/registry",
+		GitVersion: &app.GitVersionSpec{
+			CommitSHA: "12345",
+			RefSpec:   "master",
+		},
+		Libraries: LibraryRefSpecs{
+			"apache": &LibraryRef{
+				Path:    "apache",
+				Version: "master",
+			},
+		},
+	}
+
+	assert.Equal(t, expected, spec)
+}
+
+func TestGithub_FetchRegistrySpec_invalid_manifest(t *testing.T) {
+	u := "github.com/ksonnet/parts/tree/master/incubator"
+	g, ghMock := makeGh(t, u, "12345")
+
+	file := buildContent(t, "invalid-registry.yaml")
+
+	ghMock.On(
+		"Contents",
+		mock.Anything,
+		ghutil.Repo{Org: "ksonnet", Repo: "parts"},
+		"incubator/registry.yaml",
+		"12345",
+	).Return(file, nil, nil)
+
+	_, err := g.FetchRegistrySpec()
+	require.Error(t, err)
+}
+
+func TestGithub_MakeRegistryRefSpec(t *testing.T) {
+	u := "github.com/ksonnet/parts/tree/master/incubator"
+	g, _ := makeGh(t, u, "12345")
+
+	expected := &app.RegistryRefSpec{
+		Name:     "incubator",
+		Protocol: ProtocolGitHub,
+		URI:      "github.com/ksonnet/parts/tree/master/incubator",
+		GitVersion: &app.GitVersionSpec{
+			CommitSHA: "12345",
+			RefSpec:   "master",
+		},
+	}
+
+	assert.Equal(t, expected, g.MakeRegistryRefSpec())
+}
+
+func TestGithub_ResolveLibrarySpec(t *testing.T) {
+	u := "github.com/ksonnet/parts/tree/master/incubator"
+	g, ghMock := makeGh(t, u, "12345")
+
+	repo := ghutil.Repo{Org: "ksonnet", Repo: "parts"}
+
+	ghMock.On("CommitSHA1", mock.Anything, repo, "54321").Return("54321", nil)
+
+	file := buildContent(t, "apache-part.yaml")
+
+	ghMock.On("Contents", mock.Anything, repo, "incubator/apache/parts.yaml", "54321").
+		Return(file, nil, nil)
+
+	spec, err := g.ResolveLibrarySpec("apache", "54321")
+	require.NoError(t, err)
+
+	expected := &parts.Spec{
+		APIVersion:  "0.0.1",
+		Kind:        "ksonnet.io/parts",
+		Name:        "apache",
+		Description: "part description",
+		Author:      "author",
+		Contributors: parts.ContributorSpecs{
+			&parts.ContributorSpec{Name: "author 1", Email: "email@example.com"},
+			&parts.ContributorSpec{Name: "author 2", Email: "email@example.com"},
+		},
+		Repository: parts.RepositorySpec{
+			Type: "git",
+			URL:  "https://github.com/ksonnet/mixins",
+		},
+		Bugs: &parts.BugSpec{
+			URL: "https://github.com/ksonnet/mixins/issues",
+		},
+		Keywords: []string{"apache", "server", "http"},
+		QuickStart: &parts.QuickStartSpec{
+			Prototype:     "io.ksonnet.pkg.apache-simple",
+			ComponentName: "apache",
+			Flags: map[string]string{
+				"name":      "apache",
+				"namespace": "default",
+			},
+			Comment: "Run a simple Apache server",
+		},
+		License: "Apache 2.0",
+	}
+
+	assert.Equal(t, expected, spec)
+}
+
+func mockPartFs(t *testing.T, repo ghutil.Repo, ghMock *mocks.GitHub, name, sha1 string) {
+	root := filepath.Join("testdata", "part", name)
+	_, err := os.Stat(root)
+	require.NoError(t, err)
+
+	err = filepath.Walk(root, func(path string, fi os.FileInfo, err error) error {
+		require.NoError(t, err)
+
+		if fi.IsDir() {
+			rcs := buildContentDir(t, path)
+			path = strings.TrimPrefix(path, filepath.Join("testdata", "part"))
+			path = strings.TrimPrefix(path, "/")
+
+			ghMock.On("Contents", mock.Anything, repo, path, sha1).Return(nil, rcs, nil)
+			return nil
+		}
+
+		rc := buildContent(t, path)
+		path = strings.TrimPrefix(path, "testdata/part/")
+		ghMock.On("Contents", mock.Anything, repo, path, sha1).Return(rc, nil, nil)
+		return nil
+	})
+
+	require.NoError(t, err)
+}
+
+func TestGithub_ResolveLibrary(t *testing.T) {
+	u := "github.com/ksonnet/parts/tree/master/incubator"
+	g, ghMock := makeGh(t, u, "12345")
+
+	repo := ghutil.Repo{Org: "ksonnet", Repo: "parts"}
+
+	ghMock.On("CommitSHA1", mock.Anything, repo, "54321").Return("54321", nil)
+
+	partName := filepath.Join("incubator", "apache")
+	mockPartFs(t, repo, ghMock, partName, "54321")
+
+	var files []string
+	onFile := func(relPath string, contents []byte) error {
+		files = append(files, relPath)
+		return nil
+	}
+
+	var directories []string
+	onDir := func(relPath string) error {
+		directories = append(directories, relPath)
+		return nil
+	}
+
+	spec, libRefSpec, err := g.ResolveLibrary("apache", "alias", "54321", onFile, onDir)
+	require.NoError(t, err)
+
+	expectedSpec := &parts.Spec{
+		APIVersion:  "0.0.1",
+		Kind:        "ksonnet.io/parts",
+		Name:        "apache",
+		Description: "part description",
+		Author:      "author",
+		Contributors: parts.ContributorSpecs{
+			&parts.ContributorSpec{Name: "author 1", Email: "email@example.com"},
+			&parts.ContributorSpec{Name: "author 2", Email: "email@example.com"},
+		},
+		Repository: parts.RepositorySpec{
+			Type: "git",
+			URL:  "https://github.com/ksonnet/mixins",
+		},
+		Bugs: &parts.BugSpec{
+			URL: "https://github.com/ksonnet/mixins/issues",
+		},
+		Keywords: []string{"apache", "server", "http"},
+		QuickStart: &parts.QuickStartSpec{
+			Prototype:     "io.ksonnet.pkg.apache-simple",
+			ComponentName: "apache",
+			Flags: map[string]string{
+				"name":      "apache",
+				"namespace": "default",
+			},
+			Comment: "Run a simple Apache server",
+		},
+		License: "Apache 2.0",
+	}
+	assert.Equal(t, expectedSpec, spec)
+
+	expectedLibRefSpec := &app.LibraryRefSpec{
+		Name:     "alias",
+		Registry: "incubator",
+		GitVersion: &app.GitVersionSpec{
+			RefSpec:   "54321",
+			CommitSHA: "54321",
+		},
+	}
+	assert.Equal(t, expectedLibRefSpec, libRefSpec)
+
+	expectedFiles := []string{
+		"incubator/apache/README.md",
+		"incubator/apache/apache.libsonnet",
+		"incubator/apache/examples/apache.jsonnet",
+		"incubator/apache/examples/generated.yaml",
+		"incubator/apache/parts.yaml",
+		"incubator/apache/prototypes/apache-simple.jsonnet",
+	}
+	assert.Equal(t, expectedFiles, files)
+
+	expectedDirs := []string{
+		"incubator/apache/examples",
+		"incubator/apache/prototypes",
+	}
+	assert.Equal(t, expectedDirs, directories)
+}
+
+func Test_parseGitHubURI(t *testing.T) {
+	tests := []struct {
+		// Specification to parse.
+		uri string
+
+		// Optional error to check.
+		targetErr error
+
+		// Optional results to verify.
+		targetOrg                  string
+		targetRepo                 string
+		targetRefSpec              string
+		targetRegistryRepoPath     string
+		targetRegistrySpecRepoPath string
+	}{
+		//
+		// `parseGitHubURI` should correctly parse org, repo, and refspec. Does not
+		// test path parsing.
+		//
+		{
+			uri: "github.com/exampleOrg1/exampleRepo1",
+
+			targetOrg:                  "exampleOrg1",
+			targetRepo:                 "exampleRepo1",
+			targetRefSpec:              "master",
+			targetRegistryRepoPath:     "",
+			targetRegistrySpecRepoPath: "registry.yaml",
+		},
+		{
+			uri: "github.com/exampleOrg2/exampleRepo2/tree/master",
+
+			targetOrg:                  "exampleOrg2",
+			targetRepo:                 "exampleRepo2",
+			targetRefSpec:              "master",
+			targetRegistryRepoPath:     "",
+			targetRegistrySpecRepoPath: "registry.yaml",
+		},
+		{
+			uri: "github.com/exampleOrg3/exampleRepo3/tree/exampleBranch1",
+
+			targetOrg:                  "exampleOrg3",
+			targetRepo:                 "exampleRepo3",
+			targetRefSpec:              "exampleBranch1",
+			targetRegistryRepoPath:     "",
+			targetRegistrySpecRepoPath: "registry.yaml",
+		},
+		{
+			// Fails because `blob` refers to a file, but this refers to a directory.
+			uri:       "github.com/exampleOrg4/exampleRepo4/blob/master",
+			targetErr: errInvalidURI,
+		},
+		{
+			uri: "github.com/exampleOrg4/exampleRepo4/tree/exampleBranch2",
+
+			targetOrg:                  "exampleOrg4",
+			targetRepo:                 "exampleRepo4",
+			targetRefSpec:              "exampleBranch2",
+			targetRegistryRepoPath:     "",
+			targetRegistrySpecRepoPath: "registry.yaml",
+		},
+
+		//
+		// Parsing URIs with paths.
+		//
+		{
+			// Fails because referring to a directory requires a URI with
+			// `tree/{branchName}` prepending the path.
+			uri:       "github.com/exampleOrg6/exampleRepo6/path/to/some/registry",
+			targetErr: errInvalidURI,
+		},
+		{
+			uri: "github.com/exampleOrg5/exampleRepo5/tree/master/path/to/some/registry",
+
+			targetOrg:                  "exampleOrg5",
+			targetRepo:                 "exampleRepo5",
+			targetRefSpec:              "master",
+			targetRegistryRepoPath:     "path/to/some/registry",
+			targetRegistrySpecRepoPath: "path/to/some/registry/registry.yaml",
+		},
+		{
+			uri: "github.com/exampleOrg6/exampleRepo6/tree/exampleBranch3/path/to/some/registry",
+
+			targetOrg:                  "exampleOrg6",
+			targetRepo:                 "exampleRepo6",
+			targetRefSpec:              "exampleBranch3",
+			targetRegistryRepoPath:     "path/to/some/registry",
+			targetRegistrySpecRepoPath: "path/to/some/registry/registry.yaml",
+		},
+		{
+			// Fails because `blob` refers to a file, but this refers to a directory.
+			uri:       "github.com/exampleOrg7/exampleRepo7/blob/master",
+			targetErr: errInvalidURI,
+		},
+		{
+			// Fails because `blob` refers to a file, but this refers to a directory.
+			uri:       "github.com/exampleOrg5/exampleRepo5/blob/exampleBranch2",
+			targetErr: errInvalidURI,
+		},
+	}
+
+	prefixes := []string{"http://", "https://", "http://www.", "https://www.", "www.", ""}
+	suffixes := []string{"/", ""}
+
+	for _, test := range tests {
+		for _, prefix := range prefixes {
+			for _, suffix := range suffixes {
+				uri := prefix + test.uri + suffix
+
+				t.Run(uri, func(t *testing.T) {
+					hd, err := parseGitHubURI(uri)
+					if test.targetErr != nil {
+						require.Equal(t, test.targetErr, err)
+						return
+					}
+
+					require.NoError(t, err)
+
+					assert.Equal(t, test.targetOrg, hd.org)
+					assert.Equal(t, test.targetRepo, hd.repo)
+					assert.Equal(t, test.targetRefSpec, hd.refSpec)
+					assert.Equal(t, test.targetRegistryRepoPath, hd.regRepoPath)
+					assert.Equal(t, test.targetRegistrySpecRepoPath, hd.regSpecRepoPath)
+				})
+			}
+		}
+	}
 }
diff --git a/pkg/registry/manager.go b/pkg/registry/manager.go
index 7c62ccf1f66b178e0df848f0df658690e2fa54e8..2cd8e272cfcb4067bca6d15afc11a1d9e7c39ccd 100644
--- a/pkg/registry/manager.go
+++ b/pkg/registry/manager.go
@@ -15,37 +15,75 @@
 
 package registry
 
-import "github.com/ksonnet/ksonnet/metadata/app"
+import (
+	"path/filepath"
+
+	"github.com/ksonnet/ksonnet/metadata/app"
+	"github.com/pkg/errors"
+)
 
 var (
 	// DefaultManager is the default manager for registries.
 	DefaultManager = &defaultManager{}
 )
 
-// Registry is a registry.
-type Registry interface {
-	Name() string
-	Protocol() string
-	URI() string
+// Locate locates a registry given a spec.
+func Locate(a app.App, spec *app.RegistryRefSpec) (Registry, error) {
+	switch spec.Protocol {
+	case ProtocolGitHub:
+		return NewGitHub(spec)
+	case ProtocolFilesystem:
+		return NewFs(a, spec)
+	default:
+		return nil, errors.Errorf("invalid registry protocol %q", spec.Protocol)
+	}
+}
+
+// TODO: add this to App
+func root(a app.App) string {
+	return filepath.Join(a.Root(), ".ksonnet", "registries")
+}
+
+func makePath(a app.App, r Registry) string {
+	path := r.RegistrySpecFilePath()
+	if filepath.IsAbs(path) {
+		return path
+	}
+
+	return filepath.Join(root(a), path)
 }
 
 // Manager is a manager for registry related actions.
 type Manager interface {
+	Add(a app.App, name, protoocol, uri, version string) (*Spec, error)
 	// Registries returns a list of alphabetically sorted registries. The
 	// registries are sorted by name.
-	Registries(ksApp app.App) ([]Registry, error)
+	List(ksApp app.App) ([]Registry, error)
 }
 
 type defaultManager struct{}
 
 var _ Manager = (*defaultManager)(nil)
 
-func (dm *defaultManager) Registries(ksApp app.App) ([]Registry, error) {
+func (dm *defaultManager) List(ksApp app.App) ([]Registry, error) {
 
 	var registries []Registry
-	for name, regRef := range ksApp.Registries() {
-		registries = append(registries, NewGitHub(name, regRef))
+	appRegistries, err := ksApp.Registries()
+	if err != nil {
+		return nil, err
+	}
+	for name, regRef := range appRegistries {
+		regRef.Name = name
+		r, err := Locate(ksApp, regRef)
+		if err != nil {
+			return nil, err
+		}
+		registries = append(registries, r)
 	}
 
 	return registries, nil
 }
+
+func (dm *defaultManager) Add(a app.App, name, protoocol, uri, version string) (*Spec, error) {
+	return Add(a, name, protoocol, uri, version)
+}
diff --git a/pkg/registry/manager_test.go b/pkg/registry/manager_test.go
index 34bcee69d7c5098e039473ebd5b9f705d7ea9739..655f5d7db91a695b5a32b2af004676e866914d17 100644
--- a/pkg/registry/manager_test.go
+++ b/pkg/registry/manager_test.go
@@ -20,30 +20,24 @@ import (
 
 	"github.com/ksonnet/ksonnet/metadata/app"
 	"github.com/ksonnet/ksonnet/metadata/app/mocks"
-	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
 
-func Test_defaultManager_Registries(t *testing.T) {
+func OffTest_defaultManager_Registries(t *testing.T) {
 	dm := &defaultManager{}
 
 	specs := app.RegistryRefSpecs{
 		"incubator": &app.RegistryRefSpec{
-			Protocol: "github",
-			URI:      "github.com/foo/bar",
+			Protocol: ProtocolGitHub,
+			URI:      "github.com/ksonnet/parts/tree/master/incubator",
 		},
 	}
 
 	appMock := &mocks.App{}
 	appMock.On("Registries").Return(specs)
 
-	registries, err := dm.Registries(appMock)
+	registries, err := dm.List(appMock)
 	require.NoError(t, err)
 
 	require.Len(t, registries, 1)
-
-	r := registries[0]
-	assert.Equal(t, "incubator", r.Name())
-	assert.Equal(t, "github", r.Protocol())
-	assert.Equal(t, "github.com/foo/bar", r.URI())
 }
diff --git a/pkg/registry/mocks/Manager.go b/pkg/registry/mocks/Manager.go
index ac000ef33f7def4dd048cfc39b580abd7d8b17ce..70a5d42dd7a2e67caa32914092c885d2315fdfd8 100644
--- a/pkg/registry/mocks/Manager.go
+++ b/pkg/registry/mocks/Manager.go
@@ -25,8 +25,31 @@ type Manager struct {
 	mock.Mock
 }
 
-// Registries provides a mock function with given fields: ksApp
-func (_m *Manager) Registries(ksApp app.App) ([]registry.Registry, error) {
+// Add provides a mock function with given fields: a, name, protoocol, uri, version
+func (_m *Manager) Add(a app.App, name string, protoocol string, uri string, version string) (*registry.Spec, error) {
+	ret := _m.Called(a, name, protoocol, uri, version)
+
+	var r0 *registry.Spec
+	if rf, ok := ret.Get(0).(func(app.App, string, string, string, string) *registry.Spec); ok {
+		r0 = rf(a, name, protoocol, uri, version)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*registry.Spec)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(app.App, string, string, string, string) error); ok {
+		r1 = rf(a, name, protoocol, uri, version)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// List provides a mock function with given fields: ksApp
+func (_m *Manager) List(ksApp app.App) ([]registry.Registry, error) {
 	ret := _m.Called(ksApp)
 
 	var r0 []registry.Registry
diff --git a/pkg/registry/mocks/Registry.go b/pkg/registry/mocks/Registry.go
index 34f3ae4529891244738c0881e6be4bb06f7c0a06..9685d0490d0ec31e434d19d2fb2f192b26e4a964 100644
--- a/pkg/registry/mocks/Registry.go
+++ b/pkg/registry/mocks/Registry.go
@@ -16,13 +16,55 @@
 // Code generated by mockery v1.0.0
 package mocks
 
+import app "github.com/ksonnet/ksonnet/metadata/app"
 import mock "github.com/stretchr/testify/mock"
+import parts "github.com/ksonnet/ksonnet/pkg/parts"
+import registry "github.com/ksonnet/ksonnet/pkg/registry"
 
 // Registry is an autogenerated mock type for the Registry type
 type Registry struct {
 	mock.Mock
 }
 
+// FetchRegistrySpec provides a mock function with given fields:
+func (_m *Registry) FetchRegistrySpec() (*registry.Spec, error) {
+	ret := _m.Called()
+
+	var r0 *registry.Spec
+	if rf, ok := ret.Get(0).(func() *registry.Spec); ok {
+		r0 = rf()
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*registry.Spec)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func() error); ok {
+		r1 = rf()
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// MakeRegistryRefSpec provides a mock function with given fields:
+func (_m *Registry) MakeRegistryRefSpec() *app.RegistryRefSpec {
+	ret := _m.Called()
+
+	var r0 *app.RegistryRefSpec
+	if rf, ok := ret.Get(0).(func() *app.RegistryRefSpec); ok {
+		r0 = rf()
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*app.RegistryRefSpec)
+		}
+	}
+
+	return r0
+}
+
 // Name provides a mock function with given fields:
 func (_m *Registry) Name() string {
 	ret := _m.Called()
@@ -51,6 +93,89 @@ func (_m *Registry) Protocol() string {
 	return r0
 }
 
+// RegistrySpecDir provides a mock function with given fields:
+func (_m *Registry) RegistrySpecDir() string {
+	ret := _m.Called()
+
+	var r0 string
+	if rf, ok := ret.Get(0).(func() string); ok {
+		r0 = rf()
+	} else {
+		r0 = ret.Get(0).(string)
+	}
+
+	return r0
+}
+
+// RegistrySpecFilePath provides a mock function with given fields:
+func (_m *Registry) RegistrySpecFilePath() string {
+	ret := _m.Called()
+
+	var r0 string
+	if rf, ok := ret.Get(0).(func() string); ok {
+		r0 = rf()
+	} else {
+		r0 = ret.Get(0).(string)
+	}
+
+	return r0
+}
+
+// ResolveLibrary provides a mock function with given fields: libID, libAlias, version, onFile, onDir
+func (_m *Registry) ResolveLibrary(libID string, libAlias string, version string, onFile registry.ResolveFile, onDir registry.ResolveDirectory) (*parts.Spec, *app.LibraryRefSpec, error) {
+	ret := _m.Called(libID, libAlias, version, onFile, onDir)
+
+	var r0 *parts.Spec
+	if rf, ok := ret.Get(0).(func(string, string, string, registry.ResolveFile, registry.ResolveDirectory) *parts.Spec); ok {
+		r0 = rf(libID, libAlias, version, onFile, onDir)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*parts.Spec)
+		}
+	}
+
+	var r1 *app.LibraryRefSpec
+	if rf, ok := ret.Get(1).(func(string, string, string, registry.ResolveFile, registry.ResolveDirectory) *app.LibraryRefSpec); ok {
+		r1 = rf(libID, libAlias, version, onFile, onDir)
+	} else {
+		if ret.Get(1) != nil {
+			r1 = ret.Get(1).(*app.LibraryRefSpec)
+		}
+	}
+
+	var r2 error
+	if rf, ok := ret.Get(2).(func(string, string, string, registry.ResolveFile, registry.ResolveDirectory) error); ok {
+		r2 = rf(libID, libAlias, version, onFile, onDir)
+	} else {
+		r2 = ret.Error(2)
+	}
+
+	return r0, r1, r2
+}
+
+// ResolveLibrarySpec provides a mock function with given fields: libID, libRefSpec
+func (_m *Registry) ResolveLibrarySpec(libID string, libRefSpec string) (*parts.Spec, error) {
+	ret := _m.Called(libID, libRefSpec)
+
+	var r0 *parts.Spec
+	if rf, ok := ret.Get(0).(func(string, string) *parts.Spec); ok {
+		r0 = rf(libID, libRefSpec)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*parts.Spec)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(string, string) error); ok {
+		r1 = rf(libID, libRefSpec)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
 // URI provides a mock function with given fields:
 func (_m *Registry) URI() string {
 	ret := _m.Called()
diff --git a/metadata/registry/interface.go b/pkg/registry/registry.go
similarity index 65%
rename from metadata/registry/interface.go
rename to pkg/registry/registry.go
index a379fda928553ef5ad9214bf0fe9d1c7d6df5ba5..f19aa8ca5a617c47571abbcffedc9ce842c285d8 100644
--- a/metadata/registry/interface.go
+++ b/pkg/registry/registry.go
@@ -1,4 +1,4 @@
-// Copyright 2018 The kubecfg authors
+// Copyright 2018 The ksonnet authors
 //
 //
 //    Licensed under the Apache License, Version 2.0 (the "License");
@@ -17,17 +17,34 @@ package registry
 
 import (
 	"github.com/ksonnet/ksonnet/metadata/app"
-	"github.com/ksonnet/ksonnet/metadata/parts"
+	"github.com/ksonnet/ksonnet/pkg/parts"
 )
 
+const (
+	// ProtocolFilesystem is the protocol for file system based registries.
+	ProtocolFilesystem = "fs"
+	// ProtocolGitHub is a the protocol for GitHub based registries.
+	ProtocolGitHub = "github"
+
+	registryYAMLFile = "registry.yaml"
+	partsYAMLFile    = "parts.yaml"
+)
+
+// ResolveFile resolves files found when searching a part.
 type ResolveFile func(relPath string, contents []byte) error
+
+// ResolveDirectory resolves directories when searching a part.
 type ResolveDirectory func(relPath string) error
 
-type Manager interface {
+// Registry is a Registry
+type Registry interface {
 	RegistrySpecDir() string
 	RegistrySpecFilePath() string
 	FetchRegistrySpec() (*Spec, error)
 	MakeRegistryRefSpec() *app.RegistryRefSpec
 	ResolveLibrarySpec(libID, libRefSpec string) (*parts.Spec, error)
 	ResolveLibrary(libID, libAlias, version string, onFile ResolveFile, onDir ResolveDirectory) (*parts.Spec, *app.LibraryRefSpec, error)
+	Name() string
+	Protocol() string
+	URI() string
 }
diff --git a/metadata/registry/schema.go b/pkg/registry/schema.go
similarity index 84%
rename from metadata/registry/schema.go
rename to pkg/registry/schema.go
index 5c91de3b6a4fdf130e40ccb3b09f683904b26c4f..9d5f62350860d7dc21b24127d1a06642ab89fda6 100644
--- a/metadata/registry/schema.go
+++ b/pkg/registry/schema.go
@@ -25,10 +25,13 @@ import (
 )
 
 const (
+	// DefaultAPIVersion is the default version of the registry API.
 	DefaultAPIVersion = "0.1.0"
-	DefaultKind       = "ksonnet.io/registry"
+	// DefaultKind is the default kind of the registry API.
+	DefaultKind = "ksonnet.io/registry"
 )
 
+// Spec describes how a registry is stored.
 type Spec struct {
 	APIVersion string              `json:"apiVersion"`
 	Kind       string              `json:"kind"`
@@ -36,6 +39,7 @@ type Spec struct {
 	Libraries  LibraryRefSpecs     `json:"libraries"`
 }
 
+// Unmarshal unmarshals bytes to a Spec.
 func Unmarshal(bytes []byte) (*Spec, error) {
 	schema := Spec{}
 	err := yaml.Unmarshal(bytes, &schema)
@@ -50,6 +54,7 @@ func Unmarshal(bytes []byte) (*Spec, error) {
 	return &schema, nil
 }
 
+// Marshal marshals a Spec to YAML.
 func (s *Spec) Marshal() ([]byte, error) {
 	return yaml.Marshal(s)
 }
@@ -75,11 +80,14 @@ func (s *Spec) validate() error {
 	return nil
 }
 
+// Specs is a slice of *Spec.
 type Specs []*Spec
 
+// LibraryRef is library reference.
 type LibraryRef struct {
 	Version string `json:"version"`
 	Path    string `json:"path"`
 }
 
+// LibraryRefSpecs maps LibraryRefs to a name.
 type LibraryRefSpecs map[string]*LibraryRef
diff --git a/metadata/registry/schema_test.go b/pkg/registry/schema_test.go
similarity index 58%
rename from metadata/registry/schema_test.go
rename to pkg/registry/schema_test.go
index 4ceb2bb6933c4ab32c41967b83e01fe511142217..0ac19b9f2a4b98b98b5cc825188026c27aaff361 100644
--- a/metadata/registry/schema_test.go
+++ b/pkg/registry/schema_test.go
@@ -16,12 +16,65 @@
 package registry
 
 import (
+	"io/ioutil"
 	"testing"
 
 	"github.com/blang/semver"
+	"github.com/ksonnet/ksonnet/metadata/app"
+	"github.com/stretchr/testify/require"
 )
 
-func TestApiVersionValidate(t *testing.T) {
+func Test_Unmarshal(t *testing.T) {
+	data, err := ioutil.ReadFile("testdata/registry.yaml")
+	require.NoError(t, err)
+
+	spec, err := Unmarshal(data)
+	require.NoError(t, err)
+
+	expected := &Spec{
+		APIVersion: DefaultAPIVersion,
+		Kind:       DefaultKind,
+		GitVersion: &app.GitVersionSpec{
+			CommitSHA: "40285d8a14f1ac5787e405e1023cf0c07f6aa28c",
+			RefSpec:   "master",
+		},
+		Libraries: LibraryRefSpecs{
+			"apache": &LibraryRef{
+				Path:    "apache",
+				Version: "master",
+			},
+		},
+	}
+
+	require.Equal(t, expected, spec)
+}
+
+func TestSpec_Marshal(t *testing.T) {
+	spec := &Spec{
+		APIVersion: DefaultAPIVersion,
+		Kind:       DefaultKind,
+		GitVersion: &app.GitVersionSpec{
+			CommitSHA: "40285d8a14f1ac5787e405e1023cf0c07f6aa28c",
+			RefSpec:   "master",
+		},
+		Libraries: LibraryRefSpecs{
+			"apache": &LibraryRef{
+				Path:    "apache",
+				Version: "master",
+			},
+		},
+	}
+
+	expected, err := ioutil.ReadFile("testdata/registry.yaml")
+	require.NoError(t, err)
+
+	data, err := spec.Marshal()
+	require.NoError(t, err)
+
+	require.Equal(t, string(expected), string(data))
+}
+
+func Test_ApiVersionValidate(t *testing.T) {
 	type spec struct {
 		spec string
 		err  bool
diff --git a/pkg/registry/testdata/apache-part.yaml b/pkg/registry/testdata/apache-part.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..50e38ed13ae8cf2977189480e9dc5852945a0d2a
--- /dev/null
+++ b/pkg/registry/testdata/apache-part.yaml
@@ -0,0 +1,40 @@
+{
+  "name": "apache",
+  "apiVersion": "0.0.1",
+  "kind": "ksonnet.io/parts",
+  "description": "part description",
+  "author": "author",
+  "contributors": [
+    {
+    "name": "author 1",
+    "email": "email@example.com"
+    },
+    {
+    "name": "author 2",
+    "email": "email@example.com"
+    }
+  ],
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/ksonnet/mixins"
+  },
+  "bugs": {
+    "url": "https://github.com/ksonnet/mixins/issues"
+  },
+  "keywords": [
+    "apache",
+    "server",
+    "http"
+  ],
+  "quickStart": {
+    "prototype": "io.ksonnet.pkg.apache-simple",
+    "componentName": "apache",
+    "flags": {
+      "name": "apache",
+      "namespace": "default"
+    },
+    "comment": "Run a simple Apache server"
+  },
+  "license": "Apache 2.0"
+}
+
diff --git a/pkg/registry/testdata/fs-registry.yaml b/pkg/registry/testdata/fs-registry.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..e156c872baeaaf5b827efe732129069cd41986c4
--- /dev/null
+++ b/pkg/registry/testdata/fs-registry.yaml
@@ -0,0 +1,5 @@
+apiVersion: 0.1.0
+kind: ksonnet.io/registry
+libraries:
+  apache:
+    path: apache
diff --git a/pkg/registry/testdata/invalid-registry.yaml b/pkg/registry/testdata/invalid-registry.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/pkg/registry/testdata/part/incubator/apache/README.md b/pkg/registry/testdata/part/incubator/apache/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..0efbda17d7f6fb7c77a46afa2471dbe6bf780829
--- /dev/null
+++ b/pkg/registry/testdata/part/incubator/apache/README.md
@@ -0,0 +1,60 @@
+# apache
+
+> Apache Ksonnet mixin library contains a simple prototype with pre-configured components to help you deploy a Apache HTTP Server app to a Kubernetes cluster with ease.
+
+* [Quickstart](#quickstart)
+* [Using Prototypes](#using-prototypes)
+  * [io.ksonnet.pkg.apache-simple](#io.ksonnet.pkg.apache-simple)
+
+## Quickstart
+
+*The following commands use the `io.ksonnet.pkg.apache-simple` prototype to generate Kubernetes YAML for apache, and then deploys it to your Kubernetes cluster.*
+
+First, create a cluster and install the ksonnet CLI (see root-level [README.md](rootReadme)).
+
+If you haven't yet created a [ksonnet application](linkToSomewhere), do so using `ks init <app-name>`.
+
+Finally, in the ksonnet application directory, run the following:
+
+```shell
+# Expand prototype as a Jsonnet file, place in a file in the
+# `components/` directory. (YAML and JSON are also available.)
+$ ks prototype use io.ksonnet.pkg.apache-simple apache \
+  --name apache \
+  --namespace default
+
+# Apply to server.
+$ ks apply -f apache.jsonnet
+```
+
+## Using the library
+
+The library files for apache define a set of relevant *parts* (_e.g._, deployments, services, secrets, and so on) that can be combined to configure apache for a wide variety of scenarios. For example, a database like Redis may need a secret to hold the user password, or it may have no password if it's acting as a cache.
+
+This library provides a set of pre-fabricated "flavors" (or "distributions") of apache, each of which is configured for a different use case. These are captured as ksonnet *prototypes*, which allow users to interactively customize these distributions for their specific needs.
+
+These prototypes, as well as how to use them, are enumerated below.
+
+### io.ksonnet.pkg.apache-simple
+
+Apache HTTP Server. Apache is deployed using a deployment, and exposed to the network using a service.
+
+#### Example
+
+```shell
+# Expand prototype as a Jsonnet file, place in a file in the
+# `components/` directory. (YAML and JSON are also available.)
+$ ks prototype use io.ksonnet.pkg.apache-simple apache \
+  --namespace YOUR_NAMESPACE_HERE \
+  --name YOUR_NAME_HERE
+```
+
+#### Parameters
+
+The available options to pass prototype are:
+
+* `--namespace=<namespace>`: Namespace to divvy up your cluster; default is 'default' [string]
+* `--name=<name>`: Name to identify all Kubernetes objects in this prototype [string]
+
+
+[rootReadme]: https://github.com/ksonnet/mixins
diff --git a/pkg/registry/testdata/part/incubator/apache/apache.libsonnet b/pkg/registry/testdata/part/incubator/apache/apache.libsonnet
new file mode 100644
index 0000000000000000000000000000000000000000..09e246529b2db7c7af13bb97339842af8086ec4e
--- /dev/null
+++ b/pkg/registry/testdata/part/incubator/apache/apache.libsonnet
@@ -0,0 +1,107 @@
+local k = import "k.libsonnet";
+local deployment = k.extensions.v1beta1.deployment;
+
+{
+  parts::{
+    svc(namespace, name, selector={app: name}):: {
+      apiVersion: "v1",
+      kind: "Service",
+      metadata: {
+        name: name,
+        namespace: namespace,
+        labels: {
+          app: name
+        },
+      },
+      spec: {
+        type: "LoadBalancer",
+        ports: [
+          {
+            name: "http",
+            port: 80,
+            targetPort: "http",
+          },
+          {
+            name: "https",
+            port: 443,
+            targetPort: "https",
+          },
+        ],
+        selector: selector
+      },
+    },
+
+    deployment(namespace, name, labels={app: name})::{
+      local defaults = {
+        // ref: https://hub.docker.com/r/bitnami/apache/tags/
+        imageTag:: "2.4.23-r12",
+        // ref: http://kubernetes.io/docs/user-guide/images/#pre-pulling-images
+        imagePullPolicy:: "IfNotPresent",
+      },
+      apiVersion: "extensions/v1beta1",
+      kind: "Deployment",
+      metadata: {
+        namespace: namespace,
+        name: name,
+        labels: labels
+      },
+      spec: {
+        replicas: 1,
+        template: {
+          metadata: {
+            labels: labels
+          },
+          spec: {
+            containers: [
+              {
+                name: name,
+                image: "bitnami/apache:%s" % defaults.imageTag,
+                imagePullPolicy: defaults.imagePullPolicy,
+                ports: [
+                  {
+                    name: "http",
+                    containerPort: 80,
+                  },
+                  {
+                    name: "https",
+                    containerPort: 443,
+                  }
+                ],
+                livenessProbe: {
+                  httpGet: {
+                    path: "/",
+                    port: "http",
+                  },
+                  initialDelaySeconds: 30,
+                  timeoutSeconds: 5,
+                  failureThreshold: 6,
+                },
+                readinessProbe: {
+                  httpGet: {
+                    path: "/",
+                    port: "http",
+                  },
+                  initialDelaySeconds: 5,
+                  timeoutSeconds: 3,
+                  periodSeconds: 5,
+                },
+                volumeMounts: [
+                  {
+                    name: "apache-data",
+                    mountPath: "/bitnami/apache",
+                  }
+                ],
+              }
+            ],
+            volumes: [
+              {
+                name: "apache-data",
+                emptyDir: {},
+              }
+            ]
+          },
+        },
+      },
+    },
+  },
+}
diff --git a/pkg/registry/testdata/part/incubator/apache/examples/apache.jsonnet b/pkg/registry/testdata/part/incubator/apache/examples/apache.jsonnet
new file mode 100644
index 0000000000000000000000000000000000000000..523d9d0c9ff05486217bb3b67b579732131a00b2
--- /dev/null
+++ b/pkg/registry/testdata/part/incubator/apache/examples/apache.jsonnet
@@ -0,0 +1,13 @@
+local k = import "k.libsonnet";
+local apache = import "../apache.libsonnet";
+
+
+local namespace = "default";
+local name = "apache-app";
+
+k.core.v1.list.new(
+  [
+    apache.parts.deployment(namespace, name),
+    apache.parts.svc(namespace, name)
+  ]
+)
diff --git a/pkg/registry/testdata/part/incubator/apache/examples/generated.yaml b/pkg/registry/testdata/part/incubator/apache/examples/generated.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..f92073ce7d512387fe5d88eb79aa7e367bba8f77
--- /dev/null
+++ b/pkg/registry/testdata/part/incubator/apache/examples/generated.yaml
@@ -0,0 +1,61 @@
+---
+apiVersion: extensions/v1beta1
+kind: Deployment
+metadata:
+  labels:
+    app: apache-app
+  name: apache-app
+spec:
+  replicas: 1
+  template:
+    metadata:
+      labels:
+        app: apache-app
+    spec:
+      containers:
+      - image: bitnami/apache:2.4.23-r12
+        imagePullPolicy: IfNotPresent
+        livenessProbe:
+          failureThreshold: 6
+          httpGet:
+            path: /
+            port: http
+          initialDelaySeconds: 30
+          timeoutSeconds: 5
+        name: apache-app
+        ports:
+        - containerPort: 80
+          name: http
+        - containerPort: 443
+          name: https
+        readinessProbe:
+          httpGet:
+            path: /
+            port: http
+          initialDelaySeconds: 5
+          periodSeconds: 5
+          timeoutSeconds: 3
+        volumeMounts:
+        - mountPath: /bitnami/apache
+          name: apache-data
+      volumes:
+      - emptyDir: {}
+        name: apache-data
+---
+apiVersion: v1
+kind: Service
+metadata:
+  labels:
+    app: apache-app
+  name: apache-app
+spec:
+  ports:
+  - name: http
+    port: 80
+    targetPort: http
+  - name: https
+    port: "443"
+    targetPort: https
+  selector:
+    app: apache-app
+  type: LoadBalancer
\ No newline at end of file
diff --git a/pkg/registry/testdata/part/incubator/apache/parts.yaml b/pkg/registry/testdata/part/incubator/apache/parts.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..50e38ed13ae8cf2977189480e9dc5852945a0d2a
--- /dev/null
+++ b/pkg/registry/testdata/part/incubator/apache/parts.yaml
@@ -0,0 +1,40 @@
+{
+  "name": "apache",
+  "apiVersion": "0.0.1",
+  "kind": "ksonnet.io/parts",
+  "description": "part description",
+  "author": "author",
+  "contributors": [
+    {
+    "name": "author 1",
+    "email": "email@example.com"
+    },
+    {
+    "name": "author 2",
+    "email": "email@example.com"
+    }
+  ],
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/ksonnet/mixins"
+  },
+  "bugs": {
+    "url": "https://github.com/ksonnet/mixins/issues"
+  },
+  "keywords": [
+    "apache",
+    "server",
+    "http"
+  ],
+  "quickStart": {
+    "prototype": "io.ksonnet.pkg.apache-simple",
+    "componentName": "apache",
+    "flags": {
+      "name": "apache",
+      "namespace": "default"
+    },
+    "comment": "Run a simple Apache server"
+  },
+  "license": "Apache 2.0"
+}
+
diff --git a/pkg/registry/testdata/part/incubator/apache/prototypes/apache-simple.jsonnet b/pkg/registry/testdata/part/incubator/apache/prototypes/apache-simple.jsonnet
new file mode 100644
index 0000000000000000000000000000000000000000..b3fce3b8e8066854780410e1b5005dd7a17324fd
--- /dev/null
+++ b/pkg/registry/testdata/part/incubator/apache/prototypes/apache-simple.jsonnet
@@ -0,0 +1,18 @@
+// @apiVersion 0.0.1
+// @name io.ksonnet.pkg.apache-simple
+// @description Apache HTTP Server. Apache is deployed using a deployment, and exposed to the
+//   network using a service.
+// @shortDescription A simple, stateless Apache HTTP server.
+// @param namespace string Namespace to divvy up your cluster; default is 'default'
+// @param name string Name to identify all Kubernetes objects in this prototype
+
+local k = import 'k.libsonnet';
+local apache = import 'incubator/apache/apache.libsonnet';
+
+local namespace = import 'param://namespace';
+local appName = import 'param://name';
+
+k.core.v1.list.new([
+  apache.parts.deployment(namespace, appName),
+  apache.parts.svc(namespace, appName)
+])
diff --git a/pkg/registry/testdata/registry.yaml b/pkg/registry/testdata/registry.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..82c71a8153462939c37f6bee3d1ecb6c95666418
--- /dev/null
+++ b/pkg/registry/testdata/registry.yaml
@@ -0,0 +1,9 @@
+apiVersion: 0.1.0
+gitVersion:
+  commitSha: 40285d8a14f1ac5787e405e1023cf0c07f6aa28c
+  refSpec: master
+kind: ksonnet.io/registry
+libraries:
+  apache:
+    path: apache
+    version: master
diff --git a/pkg/util/github/github.go b/pkg/util/github/github.go
new file mode 100644
index 0000000000000000000000000000000000000000..c9eebc1b09b46bdc2ee4fc41814dc4ef91659580
--- /dev/null
+++ b/pkg/util/github/github.go
@@ -0,0 +1,84 @@
+// Copyright 2018 The ksonnet authors
+//
+//
+//    Licensed under the Apache License, Version 2.0 (the "License");
+//    you may not use this file except in compliance with the License.
+//    You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+//    Unless required by applicable law or agreed to in writing, software
+//    distributed under the License is distributed on an "AS IS" BASIS,
+//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//    See the License for the specific language governing permissions and
+//    limitations under the License.
+
+package github
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"os"
+
+	"github.com/google/go-github/github"
+	"github.com/sirupsen/logrus"
+	"golang.org/x/oauth2"
+)
+
+const (
+	registryYAMLFile    = "registry.yaml"
+	defaultGitHubBranch = "master"
+)
+
+var (
+	// DefaultClient is the default GitHub client.
+	DefaultClient = &defaultGitHub{}
+
+	errInvalidURI = fmt.Errorf("Invalid GitHub URI: try navigating in GitHub to the URI of the folder containing the 'yaml', and using that URI instead. Generally, this URI should be of the form 'github.com/{organization}/{repository}/tree/{branch}/[path-to-directory]'")
+)
+
+// Repo is a GitHub repo
+type Repo struct {
+	Org  string
+	Repo string
+}
+
+// GitHub is an interface for communicating with GitHub.
+type GitHub interface {
+	CommitSHA1(ctx context.Context, repo Repo, refSpec string) (string, error)
+	Contents(ctx context.Context, repo Repo, path, sha1 string) (*github.RepositoryContent, []*github.RepositoryContent, error)
+}
+
+type defaultGitHub struct{}
+
+var _ GitHub = (*defaultGitHub)(nil)
+
+func (dg *defaultGitHub) CommitSHA1(ctx context.Context, repo Repo, refSpec string) (string, error) {
+	logrus.Debugf("github: fetching SHA1 for %s/%s - %s", repo.Org, repo.Repo, refSpec)
+	sha, _, err := dg.client().Repositories.GetCommitSHA1(ctx, repo.Org, repo.Repo, refSpec, "")
+	return sha, err
+}
+
+func (dg *defaultGitHub) Contents(ctx context.Context, repo Repo, path, sha1 string) (*github.RepositoryContent, []*github.RepositoryContent, error) {
+	logrus.Debugf("github: fetching contents for %s/%s/%s - %s", repo.Org, repo.Repo, path, sha1)
+	opts := &github.RepositoryContentGetOptions{Ref: sha1}
+
+	file, dir, _, err := dg.client().Repositories.GetContents(ctx, repo.Org, repo.Repo, path, opts)
+	return file, dir, err
+}
+
+func (dg *defaultGitHub) client() *github.Client {
+	var hc *http.Client
+
+	ght := os.Getenv("GITHUB_TOKEN")
+	if len(ght) > 0 {
+		ctx := context.Background()
+		ts := oauth2.StaticTokenSource(
+			&oauth2.Token{AccessToken: ght},
+		)
+		hc = oauth2.NewClient(ctx, ts)
+	}
+
+	return github.NewClient(hc)
+}
diff --git a/pkg/util/github/mocks/GitHub.go b/pkg/util/github/mocks/GitHub.go
new file mode 100644
index 0000000000000000000000000000000000000000..2a6ac04254a6afb2631d0317bbfacb0546e04789
--- /dev/null
+++ b/pkg/util/github/mocks/GitHub.go
@@ -0,0 +1,80 @@
+// Copyright 2018 The ksonnet authors
+//
+//
+//    Licensed under the Apache License, Version 2.0 (the "License");
+//    you may not use this file except in compliance with the License.
+//    You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+//    Unless required by applicable law or agreed to in writing, software
+//    distributed under the License is distributed on an "AS IS" BASIS,
+//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//    See the License for the specific language governing permissions and
+//    limitations under the License.
+
+// Code generated by mockery v1.0.0
+package mocks
+
+import context "context"
+import github "github.com/ksonnet/ksonnet/pkg/util/github"
+import go_githubgithub "github.com/google/go-github/github"
+import mock "github.com/stretchr/testify/mock"
+
+// GitHub is an autogenerated mock type for the GitHub type
+type GitHub struct {
+	mock.Mock
+}
+
+// CommitSHA1 provides a mock function with given fields: ctx, repo, refSpec
+func (_m *GitHub) CommitSHA1(ctx context.Context, repo github.Repo, refSpec string) (string, error) {
+	ret := _m.Called(ctx, repo, refSpec)
+
+	var r0 string
+	if rf, ok := ret.Get(0).(func(context.Context, github.Repo, string) string); ok {
+		r0 = rf(ctx, repo, refSpec)
+	} else {
+		r0 = ret.Get(0).(string)
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(context.Context, github.Repo, string) error); ok {
+		r1 = rf(ctx, repo, refSpec)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// Contents provides a mock function with given fields: ctx, repo, path, sha1
+func (_m *GitHub) Contents(ctx context.Context, repo github.Repo, path string, sha1 string) (*go_githubgithub.RepositoryContent, []*go_githubgithub.RepositoryContent, error) {
+	ret := _m.Called(ctx, repo, path, sha1)
+
+	var r0 *go_githubgithub.RepositoryContent
+	if rf, ok := ret.Get(0).(func(context.Context, github.Repo, string, string) *go_githubgithub.RepositoryContent); ok {
+		r0 = rf(ctx, repo, path, sha1)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*go_githubgithub.RepositoryContent)
+		}
+	}
+
+	var r1 []*go_githubgithub.RepositoryContent
+	if rf, ok := ret.Get(1).(func(context.Context, github.Repo, string, string) []*go_githubgithub.RepositoryContent); ok {
+		r1 = rf(ctx, repo, path, sha1)
+	} else {
+		if ret.Get(1) != nil {
+			r1 = ret.Get(1).([]*go_githubgithub.RepositoryContent)
+		}
+	}
+
+	var r2 error
+	if rf, ok := ret.Get(2).(func(context.Context, github.Repo, string, string) error); ok {
+		r2 = rf(ctx, repo, path, sha1)
+	} else {
+		r2 = ret.Error(2)
+	}
+
+	return r0, r1, r2
+}
diff --git a/pkg/util/test/test.go b/pkg/util/test/test.go
new file mode 100644
index 0000000000000000000000000000000000000000..80062739ca42b6dbb7d3c3c9ac509f4cbdc834a7
--- /dev/null
+++ b/pkg/util/test/test.go
@@ -0,0 +1,41 @@
+// Copyright 2018 The ksonnet authors
+//
+//
+//    Licensed under the Apache License, Version 2.0 (the "License");
+//    you may not use this file except in compliance with the License.
+//    You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+//    Unless required by applicable law or agreed to in writing, software
+//    distributed under the License is distributed on an "AS IS" BASIS,
+//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//    See the License for the specific language governing permissions and
+//    limitations under the License.
+
+package test
+
+import (
+	"io/ioutil"
+	"path/filepath"
+	"testing"
+
+	"github.com/spf13/afero"
+	"github.com/stretchr/testify/require"
+)
+
+// StageFile stages a file on on the provided filesystem from
+// testdata.
+func StageFile(t *testing.T, fs afero.Fs, src, dest string) {
+	in := filepath.Join("testdata", src)
+
+	b, err := ioutil.ReadFile(in)
+	require.NoError(t, err)
+
+	dir := filepath.Dir(dest)
+	err = fs.MkdirAll(dir, 0755)
+	require.NoError(t, err)
+
+	err = afero.WriteFile(fs, dest, b, 0644)
+	require.NoError(t, err)
+}