diff --git a/core/hash.go b/core/hash.go
new file mode 100644
index 0000000..ef05119
--- /dev/null
+++ b/core/hash.go
@@ -0,0 +1,23 @@
+package core
+
+import (
+	"crypto/md5"
+	"crypto/sha256"
+	"crypto/sha512"
+	"errors"
+	"hash"
+)
+
+// GetHashImpl gets an implementation of hash.Hash for the given hash type string
+func GetHashImpl(hashType string) (hash.Hash, error) {
+	switch hashType {
+	case "sha256":
+		return sha256.New(), nil
+	case "sha512":
+		return sha512.New(), nil
+	case "md5":
+		return md5.New(), nil
+	}
+	// TODO: implement murmur2
+	return nil, errors.New("hash implementation not found")
+}
diff --git a/core/index.go b/core/index.go
index 1cd9b85..2eb558e 100644
--- a/core/index.go
+++ b/core/index.go
@@ -1,8 +1,8 @@
 package core
 
 import (
-	"crypto/sha256"
 	"encoding/hex"
+	"errors"
 	"io"
 	"os"
 	"path/filepath"
@@ -130,7 +130,10 @@ func (in *Index) updateFile(path string) error {
 	// Hash usage strategy (may change):
 	// Just use SHA256, overwrite existing hash regardless of what it is
 	// May update later to continue using the same hash that was already being used
-	h := sha256.New()
+	h, err := GetHashImpl("sha256")
+	if err != nil {
+		return err
+	}
 	if _, err := io.Copy(h, f); err != nil {
 		return err
 	}
@@ -310,3 +313,37 @@ func (in Index) GetAllMods() []string {
 	}
 	return list
 }
+
+// GetFilePath attempts to get the path of the destination index file as it is stored on disk
+func (in Index) GetFilePath(f IndexFile) string {
+	return filepath.Join(filepath.Dir(in.indexFile), filepath.FromSlash(f.File))
+}
+
+// SaveFile attempts to read the file from disk
+func (in Index) SaveFile(f IndexFile, dest io.Writer) error {
+	hashFormat := f.HashFormat
+	if hashFormat == "" {
+		hashFormat = in.HashFormat
+	}
+	src, err := os.Open(in.GetFilePath(f))
+	if err != nil {
+		return err
+	}
+	h, err := GetHashImpl(hashFormat)
+	if err != nil {
+		return err
+	}
+
+	w := io.MultiWriter(h, dest)
+	_, err = io.Copy(w, src)
+	if err != nil {
+		return err
+	}
+
+	calculatedHash := hex.EncodeToString(h.Sum(nil))
+	if calculatedHash != f.Hash {
+		return errors.New("hash of saved file is invalid")
+	}
+
+	return nil
+}
diff --git a/core/mod.go b/core/mod.go
index 2bfce5e..de08ef2 100644
--- a/core/mod.go
+++ b/core/mod.go
@@ -1,12 +1,13 @@
 package core
 
 import (
-	"crypto/sha256"
 	"encoding/hex"
 	"errors"
 	"io"
+	"net/http"
 	"os"
 	"path/filepath"
+	"strconv"
 
 	"github.com/BurntSushi/toml"
 )
@@ -37,6 +38,7 @@ type ModDownload struct {
 }
 
 // The three possible values of Side (the side that the mod is on) are "server", "client", and "both".
+//noinspection GoUnusedConst
 const (
 	ServerSide    = "server"
 	ClientSide    = "client"
@@ -88,7 +90,10 @@ func (m Mod) Write() (string, string, error) {
 	}
 	defer f.Close()
 
-	h := sha256.New()
+	h, err := GetHashImpl("sha256")
+	if err != nil {
+		return "", "", err
+	}
 	w := io.MultiWriter(h, f)
 
 	enc := toml.NewEncoder(w)
@@ -109,3 +114,36 @@ func (m Mod) GetParsedUpdateData(updaterName string) (interface{}, bool) {
 func (m Mod) GetFilePath() string {
 	return m.metaFile
 }
+
+// GetDestFilePath returns the path of the destination file of the mod
+func (m Mod) GetDestFilePath() string {
+	return filepath.Join(filepath.Dir(m.metaFile), filepath.FromSlash(m.FileName))
+}
+
+// DownloadFile attempts to resolve and download the file
+func (m Mod) DownloadFile(dest io.Writer) error {
+	resp, err := http.Get(m.Download.URL)
+	if err != nil {
+		return err
+	}
+	if resp.StatusCode != 200 {
+		_ = resp.Body.Close()
+		return errors.New("invalid status code " + strconv.Itoa(resp.StatusCode))
+	}
+	h, err := GetHashImpl(m.Download.HashFormat)
+	if err != nil {
+		return err
+	}
+
+	w := io.MultiWriter(h, dest)
+	_, err = io.Copy(w, resp.Body)
+	if err != nil {
+		return err
+	}
+
+	calculatedHash := hex.EncodeToString(h.Sum(nil))
+	if calculatedHash != m.Download.Hash {
+		return errors.New("hash of saved file is invalid")
+	}
+	return nil
+}
diff --git a/core/pack.go b/core/pack.go
index ec8dbd0..972fe3b 100644
--- a/core/pack.go
+++ b/core/pack.go
@@ -1,7 +1,6 @@
 package core
 
 import (
-	"crypto/sha256"
 	"encoding/hex"
 	"errors"
 	"io"
@@ -65,7 +64,10 @@ func (pack *Pack) UpdateIndexHash() error {
 	// Hash usage strategy (may change):
 	// Just use SHA256, overwrite existing hash regardless of what it is
 	// May update later to continue using the same hash that was already being used
-	h := sha256.New()
+	h, err := GetHashImpl("sha256")
+	if err != nil {
+		return err
+	}
 	if _, err := io.Copy(h, f); err != nil {
 		return err
 	}
diff --git a/curseforge/export.go b/curseforge/export.go
index 7e951a9..7d5bd09 100644
--- a/curseforge/export.go
+++ b/curseforge/export.go
@@ -1,8 +1,15 @@
 package curseforge
 
 import (
+	"archive/zip"
+	"bufio"
 	"fmt"
+	"github.com/spf13/viper"
 	"os"
+	"path/filepath"
+	"strconv"
+
+	"github.com/comp500/packwiz/curseforge/packinterop"
 
 	"github.com/comp500/packwiz/core"
 	"github.com/spf13/cobra"
@@ -12,7 +19,7 @@ import (
 var exportCmd = &cobra.Command{
 	Use:   "export",
 	Short: "Export the current modpack into a .zip for curseforge",
-	// TODO: argument for file name
+	// TODO: arguments for file name, author? projectID?
 	Args: cobra.NoArgs,
 	Run: func(cmd *cobra.Command, args []string) {
 		fmt.Println("Loading modpack...")
@@ -27,6 +34,155 @@ var exportCmd = &cobra.Command{
 			os.Exit(1)
 		}
 
-		_ = index
+		// TODO: should index just expose indexPath itself, through a function?
+		indexPath := filepath.Join(filepath.Dir(viper.GetString("pack-file")), filepath.FromSlash(pack.Index.File))
+
+		// TODO: filter mods for optional/server/etc
+		mods := loadMods(index)
+
+		expFile, err := os.Create("export.zip")
+		if err != nil {
+			fmt.Println(err)
+			os.Exit(1)
+		}
+		defer expFile.Close()
+		exp := zip.NewWriter(expFile)
+		defer exp.Close()
+
+		cfFileRefs := make([]packinterop.AddonFileReference, 0, len(mods))
+		for _, mod := range mods {
+			projectRaw, ok := mod.GetParsedUpdateData("curseforge")
+			// If the mod has curseforge metadata, add it to cfFileRefs
+			// TODO: how to handle files with CF metadata, but with different download path?
+			if ok {
+				p := projectRaw.(cfUpdateData)
+				cfFileRefs = append(cfFileRefs, packinterop.AddonFileReference{ProjectID: p.ProjectID, FileID: p.FileID})
+			} else {
+				// If the mod doesn't have the metadata, save it into the zip
+				path, err := filepath.Rel(filepath.Dir(indexPath), mod.GetDestFilePath())
+				if err != nil {
+					fmt.Printf("Error resolving mod file: %s\n", err.Error())
+					// TODO: exit(1)?
+					continue
+				}
+				modFile, err := exp.Create(filepath.ToSlash(filepath.Join("overrides", path)))
+				if err != nil {
+					fmt.Printf("Error creating mod file: %s\n", err.Error())
+					// TODO: exit(1)?
+					continue
+				}
+				err = mod.DownloadFile(modFile)
+				if err != nil {
+					fmt.Printf("Error downloading mod file: %s\n", err.Error())
+					// TODO: exit(1)?
+					continue
+				}
+			}
+		}
+
+		manifestFile, err := exp.Create("manifest.json")
+		if err != nil {
+			fmt.Println("Error creating manifest: " + err.Error())
+			os.Exit(1)
+		}
+
+		err = packinterop.WriteManifestFromPack(pack, cfFileRefs, manifestFile)
+		if err != nil {
+			fmt.Println("Error creating manifest: " + err.Error())
+			os.Exit(1)
+		}
+
+		err = createModlist(exp, mods)
+		if err != nil {
+			fmt.Println("Error creating mod list: " + err.Error())
+			os.Exit(1)
+		}
+
+		i := 0
+		for _, v := range index.Files {
+			if !v.MetaFile {
+				// Save all non-metadata files into the zip
+				path, err := filepath.Rel(filepath.Dir(indexPath), index.GetFilePath(v))
+				if err != nil {
+					fmt.Printf("Error resolving file: %s\n", err.Error())
+					// TODO: exit(1)?
+					continue
+				}
+				file, err := exp.Create(filepath.ToSlash(filepath.Join("overrides", path)))
+				if err != nil {
+					fmt.Printf("Error creating file: %s\n", err.Error())
+					// TODO: exit(1)?
+					continue
+				}
+				err = index.SaveFile(v, file)
+				if err != nil {
+					fmt.Printf("Error copying file: %s\n", err.Error())
+					// TODO: exit(1)?
+					continue
+				}
+				i++
+			}
+		}
+
+		fmt.Println("Modpack exported to export.zip!")
 	},
 }
+
+func createModlist(zw *zip.Writer, mods []core.Mod) error {
+	modlistFile, err := zw.Create("modlist.html")
+	if err != nil {
+		return err
+	}
+
+	w := bufio.NewWriter(modlistFile)
+
+	_, err = w.WriteString("
\r\n")
+	if err != nil {
+		return err
+	}
+	for _, mod := range mods {
+		projectRaw, ok := mod.GetParsedUpdateData("curseforge")
+		if !ok {
+			// TODO: read homepage URL or something similar?
+			_, err = w.WriteString("- " + mod.Name + "\r\n")
+			if err != nil {
+				return err
+			}
+			continue
+		}
+		project := projectRaw.(cfUpdateData)
+		projIDString := strconv.Itoa(project.ProjectID)
+		_, err = w.WriteString("
- " + mod.Name + "\r\n")
+		if err != nil {
+			return err
+		}
+	}
+	_, err = w.WriteString("
\r\n")
+	if err != nil {
+		return err
+	}
+	return w.Flush()
+}
+
+func loadMods(index core.Index) []core.Mod {
+	modPaths := index.GetAllMods()
+	mods := make([]core.Mod, len(modPaths))
+	i := 0
+	fmt.Println("Reading mod files...")
+	for _, v := range modPaths {
+		modData, err := core.LoadMod(v)
+		if err != nil {
+			fmt.Printf("Error reading mod file: %s\n", err.Error())
+			// TODO: exit(1)?
+			continue
+		}
+
+		mods[i] = modData
+		i++
+	}
+	return mods[:i]
+}
+
+func init() {
+	curseforgeCmd.AddCommand(exportCmd)
+}
diff --git a/curseforge/import.go b/curseforge/import.go
index d5bea39..5c95401 100644
--- a/curseforge/import.go
+++ b/curseforge/import.go
@@ -4,9 +4,9 @@ import (
 	"archive/zip"
 	"bufio"
 	"bytes"
-	"encoding/json"
 	"errors"
 	"fmt"
+	"github.com/comp500/packwiz/curseforge/packinterop"
 	"io"
 	"io/ioutil"
 	"os"
@@ -19,43 +19,20 @@ import (
 	"github.com/spf13/viper"
 )
 
-// TODO: this file is a mess, I need to refactor it
-// TODO: test modpack importing before proceeding with further implementation
-
-type importPackFile interface {
-	Name() string
-	Open() (io.ReadCloser, error)
-}
-
-type importPackMetadata interface {
-	Name() string
-	Versions() map[string]string
-	Mods() []struct {
-		ModID  int
-		FileID int
-	}
-	GetFiles() ([]importPackFile, error)
-}
-
-type importPackSource interface {
-	GetFile(path string) (importPackFile, error)
-	//TODO: was GetFileList(base string), is it needed?
-	GetFileList() ([]importPackFile, error)
-	GetPackFile() importPackFile
-}
-
 // importCmd represents the import command
 var importCmd = &cobra.Command{
 	Use:   "import [modpack]",
-	Short: "Import an installed curseforge modpack, from a download URL or a downloaded pack zip, or an installed metadata json file",
+	Short: "Import a curseforge modpack, from a download URL or a downloaded pack zip, or an installed metadata json file",
 	Args:  cobra.ExactArgs(1),
 	Run: func(cmd *cobra.Command, args []string) {
 		inputFile := args[0]
-		var packImport importPackMetadata
+		var packImport packinterop.ImportPackMetadata
 
+		// TODO: refactor/extract file checking?
 		if strings.HasPrefix(inputFile, "http") {
-			fmt.Println("it do be a http doe")
-			os.Exit(0)
+			// TODO: implement
+			fmt.Println("HTTP not supported (yet)")
+			os.Exit(1)
 		} else {
 			// Attempt to read from file
 			var f *os.File
@@ -140,8 +117,7 @@ var importCmd = &cobra.Command{
 				// Search the zip for minecraftinstance.json or manifest.json
 				var metaFile *zip.File
 				for _, v := range zr.File {
-					fileName := filepath.Base(v.Name)
-					if fileName == "minecraftinstance.json" || fileName == "manifest.json" {
+					if v.Name == "minecraftinstance.json" || v.Name == "manifest.json" {
 						metaFile = v
 					}
 				}
@@ -151,17 +127,9 @@ var importCmd = &cobra.Command{
 					os.Exit(1)
 				}
 
-				packImport = readMetadata(zipPackSource{
-					MetaFile: metaFile,
-					Reader:   zr,
-				})
-
+				packImport = packinterop.ReadMetadata(packinterop.GetZipPackSource(metaFile, zr))
 			} else {
-				packImport = readMetadata(diskPackSource{
-					MetaSource: buf,
-					MetaName:   inputFile, // TODO: is this always the correct file?
-					BasePath:   filepath.Dir(inputFile),
-				})
+				packImport = packinterop.ReadMetadata(packinterop.GetDiskPackSource(buf, filepath.ToSlash(filepath.Base(inputFile)), filepath.Dir(inputFile)))
 			}
 		}
 
@@ -255,13 +223,10 @@ var importCmd = &cobra.Command{
 				os.Exit(1)
 			}
 
+			// TODO: just use mods-folder directly? does texture pack importing affect this?
 			ref, err := filepath.Abs(filepath.Join(filepath.Dir(core.ResolveMod(modInfoValue.Slug)), fileInfo.FileName))
 			if err == nil {
 				referencedModPaths = append(referencedModPaths, ref)
-				if len(ref) == 0 {
-					fmt.Println(core.ResolveMod(modInfoValue.Slug))
-					fmt.Println(filepath.Dir(core.ResolveMod(modInfoValue.Slug)))
-				}
 			}
 
 			fmt.Printf("Imported mod \"%s\" successfully!\n", modInfoValue.Name)
@@ -280,10 +245,7 @@ var importCmd = &cobra.Command{
 		successes = 0
 		indexFolder := filepath.Dir(filepath.Join(filepath.Dir(viper.GetString("pack-file")), filepath.FromSlash(pack.Index.File)))
 		for _, v := range filesList {
-			filePath := v.Name()
-			if !filepath.IsAbs(filePath) {
-				filePath = filepath.Join(indexFolder, v.Name())
-			}
+			filePath := filepath.Join(indexFolder, filepath.FromSlash(v.Name()))
 			filePathAbs, err := filepath.Abs(filePath)
 			if err == nil {
 				found := false
@@ -298,12 +260,12 @@ var importCmd = &cobra.Command{
 					successes++
 					continue
 				}
-				if filepath.Base(filePathAbs) == "minecraftinstance.json" {
+				if v.Name() == "minecraftinstance.json" {
 					fmt.Println("Ignored file \"minecraftinstance.json\"")
 					successes++
 					continue
 				}
-				if filepath.Base(filePathAbs) == "manifest.json" {
+				if v.Name() == "manifest.json" {
 					fmt.Println("Ignored file \"manifest.json\"")
 					successes++
 					continue
@@ -376,240 +338,3 @@ var importCmd = &cobra.Command{
 func init() {
 	curseforgeCmd.AddCommand(importCmd)
 }
-
-type diskFile struct {
-	NameInternal string
-	Path         string
-}
-
-func (f diskFile) Name() string {
-	return f.NameInternal
-}
-
-func (f diskFile) Open() (io.ReadCloser, error) {
-	return os.Open(f.Path)
-}
-
-type zipReaderFile struct {
-	NameInternal string
-	*zip.File
-}
-
-func (f zipReaderFile) Name() string {
-	return f.NameInternal
-}
-
-type readerFile struct {
-	NameInternal string
-	Reader       *io.ReadCloser
-}
-
-func (f readerFile) Name() string {
-	return f.NameInternal
-}
-
-func (f readerFile) Open() (io.ReadCloser, error) {
-	return *f.Reader, nil
-}
-
-func diskFilesFromPath(base string) ([]importPackFile, error) {
-	list := make([]importPackFile, 0)
-	err := filepath.Walk(base, func(path string, info os.FileInfo, err error) error {
-		if err != nil {
-			return err
-		}
-		if info.IsDir() {
-			return nil
-		}
-		name, err := filepath.Rel(base, path)
-		if err != nil {
-			return err
-		}
-		list = append(list, diskFile{name, path})
-		return nil
-	})
-	if err != nil {
-		return nil, err
-	}
-	return list, nil
-}
-
-type diskPackSource struct {
-	MetaSource *bufio.Reader
-	MetaName   string
-	BasePath   string
-}
-
-func (s diskPackSource) GetFile(path string) (importPackFile, error) {
-	return diskFile{s.BasePath, path}, nil
-}
-
-func (s diskPackSource) GetFileList() ([]importPackFile, error) {
-	return diskFilesFromPath(s.BasePath)
-}
-
-func (s diskPackSource) GetPackFile() importPackFile {
-	rc := ioutil.NopCloser(s.MetaSource)
-	return readerFile{s.MetaName, &rc}
-}
-
-type zipPackSource struct {
-	MetaFile       *zip.File
-	Reader         *zip.Reader
-	cachedFileList []importPackFile
-}
-
-func (s zipPackSource) GetFile(path string) (importPackFile, error) {
-	if s.cachedFileList == nil {
-		s.cachedFileList = make([]importPackFile, len(s.Reader.File))
-		for i, v := range s.Reader.File {
-			s.cachedFileList[i] = zipReaderFile{v.Name, v}
-		}
-	}
-	for _, v := range s.cachedFileList {
-		if v.Name() == path {
-			return v, nil
-		}
-	}
-	return zipReaderFile{}, errors.New("file not found in zip")
-}
-
-func (s zipPackSource) GetFileList() ([]importPackFile, error) {
-	if s.cachedFileList == nil {
-		s.cachedFileList = make([]importPackFile, len(s.Reader.File))
-		for i, v := range s.Reader.File {
-			s.cachedFileList[i] = zipReaderFile{v.Name, v}
-		}
-	}
-	return s.cachedFileList, nil
-}
-
-func (s zipPackSource) GetPackFile() importPackFile {
-	return zipReaderFile{s.MetaFile.Name, s.MetaFile}
-}
-
-type twitchInstalledPackMeta struct {
-	NameInternal string `json:"name"`
-	Path         string `json:"installPath"`
-	// TODO: javaArgsOverride?
-	// TODO: allocatedMemory?
-	MCVersion string `json:"gameVersion"`
-	Modloader struct {
-		Name               string `json:"name"`
-		MavenVersionString string `json:"mavenVersionString"`
-	} `json:"baseModLoader"`
-	ModpackOverrides []string `json:"modpackOverrides"`
-	ModsInternal     []struct {
-		ID   int `json:"addonID"`
-		File struct {
-			// I've given up on using this cached data, just going to re-request it
-			ID int `json:"id"`
-		} `json:"installedFile"`
-	} `json:"installedAddons"`
-	// Used to determine if modpackOverrides should be used or not
-	IsUnlocked bool `json:"isUnlocked"`
-	srcFile    string
-}
-
-func (m twitchInstalledPackMeta) Name() string {
-	return m.NameInternal
-}
-
-func (m twitchInstalledPackMeta) Versions() map[string]string {
-	vers := make(map[string]string)
-	vers["minecraft"] = m.MCVersion
-	if strings.HasPrefix(m.Modloader.Name, "forge") {
-		if len(m.Modloader.MavenVersionString) > 0 {
-			vers["forge"] = strings.TrimPrefix(m.Modloader.MavenVersionString, "net.minecraftforge:forge:")
-		} else {
-			vers["forge"] = m.MCVersion + "-" + strings.TrimPrefix(m.Modloader.Name, "forge-")
-		}
-	}
-	return vers
-}
-
-func (m twitchInstalledPackMeta) Mods() []struct {
-	ModID  int
-	FileID int
-} {
-	list := make([]struct {
-		ModID  int
-		FileID int
-	}, len(m.ModsInternal))
-	for i, v := range m.ModsInternal {
-		list[i] = struct {
-			ModID  int
-			FileID int
-		}{
-			ModID:  v.ID,
-			FileID: v.File.ID,
-		}
-	}
-	return list
-}
-
-func (m twitchInstalledPackMeta) GetFiles() ([]importPackFile, error) {
-	dir := filepath.Dir(m.srcFile)
-	if _, err := os.Stat(m.Path); err == nil {
-		dir = m.Path
-	}
-	if m.IsUnlocked {
-		return diskFilesFromPath(dir)
-	}
-	list := make([]importPackFile, len(m.ModpackOverrides))
-	for i, v := range m.ModpackOverrides {
-		list[i] = diskFile{
-			Path:         filepath.Join(dir, v),
-			NameInternal: v,
-		}
-	}
-	return list, nil
-}
-
-func readMetadata(s importPackSource) importPackMetadata {
-	var packImport importPackMetadata
-	metaFile := s.GetPackFile()
-	rdr, err := metaFile.Open()
-	if err != nil {
-		fmt.Printf("Error reading file: %s\n", err)
-		os.Exit(1)
-	}
-
-	// Read the whole file (as we are going to parse it multiple times)
-	fileData, err := ioutil.ReadAll(rdr)
-	if err != nil {
-		fmt.Printf("Error reading file: %s\n", err)
-		os.Exit(1)
-	}
-
-	// Determine what format the file is
-	var jsonFile map[string]interface{}
-	err = json.Unmarshal(fileData, &jsonFile)
-	if err != nil {
-		fmt.Printf("Error parsing JSON: %s\n", err)
-		os.Exit(1)
-	}
-
-	isManifest := false
-	if v, ok := jsonFile["manifestType"]; ok {
-		isManifest = v.(string) == "minecraftModpack"
-	}
-	if isManifest {
-		fmt.Println("it do be a manifest doe")
-		os.Exit(0)
-		// TODO: implement manifest parsing
-	} else {
-		// Replace FileNameOnDisk with fileNameOnDisk
-		fileData = bytes.ReplaceAll(fileData, []byte("FileNameOnDisk"), []byte("fileNameOnDisk"))
-		packMeta := twitchInstalledPackMeta{}
-		err = json.Unmarshal(fileData, &packMeta)
-		if err != nil {
-			fmt.Printf("Error parsing JSON: %s\n", err)
-			os.Exit(1)
-		}
-		packMeta.srcFile = metaFile.Name()
-		packImport = packMeta
-	}
-
-	return packImport
-}
diff --git a/curseforge/packinterop/disk.go b/curseforge/packinterop/disk.go
new file mode 100644
index 0000000..f302bd1
--- /dev/null
+++ b/curseforge/packinterop/disk.go
@@ -0,0 +1,81 @@
+package packinterop
+
+import (
+	"bufio"
+	"io"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+)
+
+type diskFile struct {
+	NameInternal string
+	Path         string
+}
+
+func (f diskFile) Name() string {
+	return f.NameInternal
+}
+
+func (f diskFile) Open() (io.ReadCloser, error) {
+	return os.Open(f.Path)
+}
+
+type readerFile struct {
+	NameInternal string
+	Reader       *io.ReadCloser
+}
+
+func (f readerFile) Name() string {
+	return f.NameInternal
+}
+
+func (f readerFile) Open() (io.ReadCloser, error) {
+	return *f.Reader, nil
+}
+
+type diskPackSource struct {
+	MetaSource *bufio.Reader
+	MetaName   string
+	BasePath   string
+}
+
+func (s diskPackSource) GetFile(path string) (ImportPackFile, error) {
+	return diskFile{path, filepath.Join(s.BasePath, filepath.FromSlash(path))}, nil
+}
+
+func (s diskPackSource) GetFileList() ([]ImportPackFile, error) {
+	list := make([]ImportPackFile, 0)
+	err := filepath.Walk(s.BasePath, func(path string, info os.FileInfo, err error) error {
+		if err != nil {
+			return err
+		}
+		if info.IsDir() {
+			return nil
+		}
+		// Get the name of the file, relative to the pack folder
+		name, err := filepath.Rel(s.BasePath, path)
+		if err != nil {
+			return err
+		}
+		list = append(list, diskFile{filepath.ToSlash(name), path})
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+	return list, nil
+}
+
+func (s diskPackSource) GetPackFile() ImportPackFile {
+	rc := ioutil.NopCloser(s.MetaSource)
+	return readerFile{s.MetaName, &rc}
+}
+
+func GetDiskPackSource(metaSource *bufio.Reader, metaName string, basePath string) ImportPackSource {
+	return diskPackSource{
+		MetaSource: metaSource,
+		MetaName:   metaName,
+		BasePath:   basePath,
+	}
+}
diff --git a/curseforge/packinterop/interfaces.go b/curseforge/packinterop/interfaces.go
new file mode 100644
index 0000000..beca2c9
--- /dev/null
+++ b/curseforge/packinterop/interfaces.go
@@ -0,0 +1,25 @@
+package packinterop
+
+import "io"
+
+type ImportPackFile interface {
+	Name() string
+	Open() (io.ReadCloser, error)
+}
+
+type ImportPackMetadata interface {
+	Name() string
+	Versions() map[string]string
+	// TODO: use AddonFileReference?
+	Mods() []struct {
+		ModID  int
+		FileID int
+	}
+	GetFiles() ([]ImportPackFile, error)
+}
+
+type ImportPackSource interface {
+	GetFile(path string) (ImportPackFile, error)
+	GetFileList() ([]ImportPackFile, error)
+	GetPackFile() ImportPackFile
+}
diff --git a/curseforge/packinterop/manifest.go b/curseforge/packinterop/manifest.go
new file mode 100644
index 0000000..06a226b
--- /dev/null
+++ b/curseforge/packinterop/manifest.go
@@ -0,0 +1,104 @@
+package packinterop
+
+import "strings"
+
+type cursePackMeta struct {
+	Minecraft struct {
+		Version    string         `json:"version"`
+		ModLoaders []modLoaderDef `json:"modLoaders"`
+	} `json:"minecraft"`
+	ManifestType    string `json:"manifestType"`
+	ManifestVersion int    `json:"manifestVersion"`
+	NameInternal    string `json:"name"`
+	Version         string `json:"version"`
+	Author          string `json:"author"`
+	ProjectID       int    `json:"projectID"`
+	Files           []struct {
+		ProjectID int  `json:"projectID"`
+		FileID    int  `json:"fileID"`
+		Required  bool `json:"required"`
+	} `json:"files"`
+	Overrides string `json:"overrides"`
+	importSrc ImportPackSource
+}
+
+type modLoaderDef struct {
+	ID      string `json:"id"`
+	Primary bool   `json:"primary"`
+}
+
+func (c cursePackMeta) Name() string {
+	return c.NameInternal
+}
+
+func (c cursePackMeta) Versions() map[string]string {
+	vers := make(map[string]string)
+	vers["minecraft"] = c.Minecraft.Version
+	for _, v := range c.Minecraft.ModLoaders {
+		// Seperate dash-separated modloader/version pairs
+		parts := strings.SplitN(v.ID, "-", 2)
+		if len(parts) == 2 {
+			vers[parts[0]] = parts[1]
+		}
+	}
+	if val, ok := vers["forge"]; ok {
+		// Remove the minecraft version prefix, if it exists
+		vers["forge"] = strings.TrimPrefix(val, c.Minecraft.Version+"-")
+	}
+	return vers
+}
+
+func (c cursePackMeta) Mods() []struct {
+	ModID  int
+	FileID int
+} {
+	list := make([]struct {
+		ModID  int
+		FileID int
+	}, len(c.Files))
+	for i, v := range c.Files {
+		list[i] = struct {
+			ModID  int
+			FileID int
+		}{
+			ModID:  v.ProjectID,
+			FileID: v.FileID,
+		}
+	}
+	return list
+}
+
+type cursePackOverrideWrapper struct {
+	name string
+	ImportPackFile
+}
+
+func (w cursePackOverrideWrapper) Name() string {
+	return w.name
+}
+
+func (c cursePackMeta) GetFiles() ([]ImportPackFile, error) {
+	// Only import files from overrides directory
+	if len(c.Overrides) > 0 {
+		fullList, err := c.importSrc.GetFileList()
+		if err != nil {
+			return nil, err
+		}
+		overridesList := make([]ImportPackFile, 0, len(fullList))
+		overridesPath := c.Overrides
+		if !strings.HasSuffix(overridesPath, "/") {
+			overridesPath = c.Overrides + "/"
+		}
+		// Wrap files, removing overrides/ from the start
+		for _, v := range fullList {
+			if strings.HasPrefix(v.Name(), overridesPath) {
+				overridesList = append(overridesList, cursePackOverrideWrapper{
+					name:           strings.TrimPrefix(v.Name(), overridesPath),
+					ImportPackFile: v,
+				})
+			}
+		}
+		return overridesList, nil
+	}
+	return []ImportPackFile{}, nil
+}
diff --git a/curseforge/packinterop/minecraftinstance.go b/curseforge/packinterop/minecraftinstance.go
new file mode 100644
index 0000000..0e161df
--- /dev/null
+++ b/curseforge/packinterop/minecraftinstance.go
@@ -0,0 +1,85 @@
+package packinterop
+
+import (
+	"path/filepath"
+	"strings"
+)
+
+type twitchInstalledPackMeta struct {
+	NameInternal string `json:"name"`
+	Path         string `json:"installPath"`
+	// TODO: javaArgsOverride?
+	// TODO: allocatedMemory?
+	MCVersion string `json:"gameVersion"`
+	Modloader struct {
+		Name               string `json:"name"`
+		MavenVersionString string `json:"mavenVersionString"`
+	} `json:"baseModLoader"`
+	ModpackOverrides []string `json:"modpackOverrides"`
+	ModsInternal     []struct {
+		ID   int `json:"addonID"`
+		File struct {
+			// I've given up on using this cached data, just going to re-request it
+			ID int `json:"id"`
+		} `json:"installedFile"`
+	} `json:"installedAddons"`
+	// Used to determine if modpackOverrides should be used or not
+	IsUnlocked bool `json:"isUnlocked"`
+	importSrc  ImportPackSource
+}
+
+func (m twitchInstalledPackMeta) Name() string {
+	return m.NameInternal
+}
+
+func (m twitchInstalledPackMeta) Versions() map[string]string {
+	vers := make(map[string]string)
+	vers["minecraft"] = m.MCVersion
+	if strings.HasPrefix(m.Modloader.Name, "forge") {
+		if len(m.Modloader.MavenVersionString) > 0 {
+			vers["forge"] = strings.TrimPrefix(m.Modloader.MavenVersionString, "net.minecraftforge:forge:")
+		} else {
+			vers["forge"] = strings.TrimPrefix(m.Modloader.Name, "forge-")
+		}
+		// Remove the minecraft version prefix, if it exists
+		vers["forge"] = strings.TrimPrefix(vers["forge"], m.MCVersion+"-")
+	}
+	return vers
+}
+
+func (m twitchInstalledPackMeta) Mods() []struct {
+	ModID  int
+	FileID int
+} {
+	list := make([]struct {
+		ModID  int
+		FileID int
+	}, len(m.ModsInternal))
+	for i, v := range m.ModsInternal {
+		list[i] = struct {
+			ModID  int
+			FileID int
+		}{
+			ModID:  v.ID,
+			FileID: v.File.ID,
+		}
+	}
+	return list
+}
+
+func (m twitchInstalledPackMeta) GetFiles() ([]ImportPackFile, error) {
+	// If the modpack is unlocked, import all the files
+	// Otherwise import just the modpack overrides
+	if m.IsUnlocked {
+		return m.importSrc.GetFileList()
+	}
+	list := make([]ImportPackFile, len(m.ModpackOverrides))
+	var err error
+	for i, v := range m.ModpackOverrides {
+		list[i], err = m.importSrc.GetFile(filepath.ToSlash(v))
+		if err != nil {
+			return nil, err
+		}
+	}
+	return list, nil
+}
diff --git a/curseforge/packinterop/translation.go b/curseforge/packinterop/translation.go
new file mode 100644
index 0000000..c7f454a
--- /dev/null
+++ b/curseforge/packinterop/translation.go
@@ -0,0 +1,119 @@
+package packinterop
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"github.com/comp500/packwiz/core"
+	"io"
+	"io/ioutil"
+	"os"
+)
+
+func ReadMetadata(s ImportPackSource) ImportPackMetadata {
+	var packImport ImportPackMetadata
+	metaFile := s.GetPackFile()
+	rdr, err := metaFile.Open()
+	if err != nil {
+		fmt.Printf("Error reading file: %s\n", err)
+		os.Exit(1)
+	}
+
+	// Read the whole file (as we are going to parse it multiple times)
+	fileData, err := ioutil.ReadAll(rdr)
+	if err != nil {
+		fmt.Printf("Error reading file: %s\n", err)
+		os.Exit(1)
+	}
+
+	// Determine what format the file is
+	var jsonFile map[string]interface{}
+	err = json.Unmarshal(fileData, &jsonFile)
+	if err != nil {
+		fmt.Printf("Error parsing JSON: %s\n", err)
+		os.Exit(1)
+	}
+
+	isManifest := false
+	if v, ok := jsonFile["manifestType"]; ok {
+		isManifest = v.(string) == "minecraftModpack"
+	}
+	if isManifest {
+		packMeta := cursePackMeta{importSrc: s}
+		err = json.Unmarshal(fileData, &packMeta)
+		if err != nil {
+			fmt.Printf("Error parsing JSON: %s\n", err)
+			os.Exit(1)
+		}
+		packImport = packMeta
+	} else {
+		// Replace FileNameOnDisk with fileNameOnDisk
+		fileData = bytes.ReplaceAll(fileData, []byte("FileNameOnDisk"), []byte("fileNameOnDisk"))
+		packMeta := twitchInstalledPackMeta{importSrc: s}
+		err = json.Unmarshal(fileData, &packMeta)
+		if err != nil {
+			fmt.Printf("Error parsing JSON: %s\n", err)
+			os.Exit(1)
+		}
+		packImport = packMeta
+	}
+
+	return packImport
+}
+
+// AddonFileReference is a pair of Project ID and File ID to reference a single file on CurseForge
+type AddonFileReference struct {
+	ProjectID int
+	FileID    int
+}
+
+func WriteManifestFromPack(pack core.Pack, fileRefs []AddonFileReference, out io.Writer) error {
+	// TODO: should Required be false sometimes?
+	files := make([]struct {
+		ProjectID int  `json:"projectID"`
+		FileID    int  `json:"fileID"`
+		Required  bool `json:"required"`
+	}, len(fileRefs))
+	for i, fr := range fileRefs {
+		files[i] = struct {
+			ProjectID int  `json:"projectID"`
+			FileID    int  `json:"fileID"`
+			Required  bool `json:"required"`
+		}{ProjectID: fr.ProjectID, FileID: fr.FileID, Required: true}
+	}
+
+	modLoaders := make([]modLoaderDef, 0, 1)
+	forgeVersion, ok := pack.Versions["forge"]
+	if ok {
+		modLoaders = append(modLoaders, modLoaderDef{
+			ID:      "forge-" + forgeVersion,
+			Primary: true,
+		})
+	}
+
+	manifest := cursePackMeta{
+		Minecraft: struct {
+			Version    string         `json:"version"`
+			ModLoaders []modLoaderDef `json:"modLoaders"`
+		}{
+			Version:    pack.Versions["minecraft"],
+			ModLoaders: modLoaders,
+		},
+		ManifestType:    "minecraftModpack",
+		ManifestVersion: 1,
+		NameInternal:    pack.Name,
+		Version:         "", // TODO: store or take this?
+		Author:          "", // TODO: store or take this?
+		ProjectID:       0,  // TODO: store or take this?
+		Files:           files,
+		Overrides:       "overrides",
+	}
+
+	w := json.NewEncoder(out)
+	w.SetIndent("", "  ") // Match CF export
+	err := w.Encode(manifest)
+	if err != nil {
+		return err
+	}
+	return nil
+}
diff --git a/curseforge/packinterop/zip.go b/curseforge/packinterop/zip.go
new file mode 100644
index 0000000..73c311f
--- /dev/null
+++ b/curseforge/packinterop/zip.go
@@ -0,0 +1,57 @@
+package packinterop
+
+import (
+	"archive/zip"
+	"errors"
+)
+
+type zipReaderFile struct {
+	NameInternal string
+	*zip.File
+}
+
+func (f zipReaderFile) Name() string {
+	return f.NameInternal
+}
+
+type zipPackSource struct {
+	MetaFile       *zip.File
+	Reader         *zip.Reader
+	cachedFileList []ImportPackFile
+}
+
+func (s zipPackSource) GetFile(path string) (ImportPackFile, error) {
+	if s.cachedFileList == nil {
+		s.cachedFileList = make([]ImportPackFile, len(s.Reader.File))
+		for i, v := range s.Reader.File {
+			s.cachedFileList[i] = zipReaderFile{v.Name, v}
+		}
+	}
+	for _, v := range s.cachedFileList {
+		if v.Name() == path {
+			return v, nil
+		}
+	}
+	return zipReaderFile{}, errors.New("file not found in zip")
+}
+
+func (s zipPackSource) GetFileList() ([]ImportPackFile, error) {
+	if s.cachedFileList == nil {
+		s.cachedFileList = make([]ImportPackFile, len(s.Reader.File))
+		for i, v := range s.Reader.File {
+			s.cachedFileList[i] = zipReaderFile{v.Name, v}
+		}
+	}
+	return s.cachedFileList, nil
+}
+
+func (s zipPackSource) GetPackFile() ImportPackFile {
+	return zipReaderFile{s.MetaFile.Name, s.MetaFile}
+}
+
+func GetZipPackSource(metaFile *zip.File, reader *zip.Reader) ImportPackSource {
+	return zipPackSource{
+		MetaFile: metaFile,
+		Reader:   reader,
+	}
+}