Repository: puppetlabs/wash
Pull request #325 (2019-06-12 18:42:11)

Add the stree command
Contributions to this project require sign-off consistent with the [Developers Certificate of Origin](https://developercertificate.org). This can be as simple as using `git commit -s` on each commit.


Patch:
diff --git a/api/client/client.go b/api/client/client.go
index fbcfda7..623b871 100644
--- a/api/client/client.go
+++ b/api/client/client.go
@@ -35,6 +35,7 @@ type Client interface {
 	History(bool) (chan apitypes.Activity, error)
 	ActivityJournal(index int, follow bool) (io.ReadCloser, error)
 	Clear(path string) ([]string, error)
+	Schema(path string) (apitypes.EntrySchema, error)
 }
 
 // A domainSocketClient is a wash API client.
@@ -265,3 +266,12 @@ func (c *domainSocketClient) Clear(path string) ([]string, error) {
 
 	return result, nil
 }
+
+// Schema returns the entry's schema
+func (c *domainSocketClient) Schema(path string) (apitypes.EntrySchema, error) {
+	var schema apitypes.EntrySchema
+	if err := c.getRequest("/fs/schema", url.Values{"path": []string{path}}, &schema); err != nil {
+		return schema, err
+	}
+	return schema, nil
+}
diff --git a/api/fs/core.go b/api/fs/core.go
index 880b876..83d951d 100644
--- a/api/fs/core.go
+++ b/api/fs/core.go
@@ -16,28 +16,28 @@ type fsnode struct {
 	path string
 }
 
-func newFSNode(ctx context.Context, finfo os.FileInfo, path string) *fsnode {
-	// TODO: finfo.Sys() contains more detailed file attributes,
-	// but it's platform-specific. We should eventually use it for
-	// a more complete implementation of apifs.
-	n := &fsnode{
-		EntryBase: plugin.NewEntryBase(),
-		path:      path,
-	}
+func (n *fsnode) build(finfo os.FileInfo, path string) {
+	n.path = path
 	n.
 		SetName(finfo.Name()).
-		DisableDefaultCaching().
 		Attributes().
 		SetMtime(finfo.ModTime()).
 		SetMode(finfo.Mode()).
 		SetSize(uint64(finfo.Size())).
 		SetMeta(plugin.ToJSONObject(newFileInfo(finfo)))
+}
+
+func fsnodeBase() *fsnode {
+	n := &fsnode{
+		EntryBase: plugin.NewEntryBase(),
+	}
+	n.DisableDefaultCaching()
 	return n
 }
 
-// NewEntryBase constructs a new Wash entry from the given FS
+// NewEntry constructs a new Wash entry from the given FS
 // path
-func NewEntryBase(ctx context.Context, path string) (plugin.Entry, error) {
+func NewEntry(ctx context.Context, path string) (plugin.Entry, error) {
 	finfo, err := os.Stat(path)
 	if err != nil {
 		return nil, err
diff --git a/api/fs/dir.go b/api/fs/dir.go
index 150b92d..e07bf6e 100644
--- a/api/fs/dir.go
+++ b/api/fs/dir.go
@@ -12,10 +12,22 @@ type dir struct {
 	*fsnode
 }
 
-func newDir(ctx context.Context, finfo os.FileInfo, path string) *dir {
-	return &dir{
-		newFSNode(ctx, finfo, path),
+func dirBase() *dir {
+	d := &dir{
+		fsnode: fsnodeBase(),
 	}
+	d.SetLabel("dir")
+	return d
+}
+
+func newDir(ctx context.Context, finfo os.FileInfo, path string) *dir {
+	d := dirBase()
+	d.build(finfo, path)
+	return d
+}
+
+func (d *dir) ChildSchemas() []plugin.EntrySchema {
+	return plugin.ChildSchemas(dirBase(), fileBase())
 }
 
 func (d *dir) List(ctx context.Context) ([]plugin.Entry, error) {
@@ -26,7 +38,7 @@ func (d *dir) List(ctx context.Context) ([]plugin.Entry, error) {
 
 	entries := make([]plugin.Entry, len(matches))
 	for i, match := range matches {
-		entry, err := NewEntryBase(ctx, match)
+		entry, err := NewEntry(ctx, match)
 		if err != nil {
 			return nil, err
 		}
diff --git a/api/fs/file.go b/api/fs/file.go
index 0e55538..437404d 100644
--- a/api/fs/file.go
+++ b/api/fs/file.go
@@ -13,10 +13,18 @@ type file struct {
 	*fsnode
 }
 
-func newFile(ctx context.Context, finfo os.FileInfo, path string) *file {
-	return &file{
-		newFSNode(ctx, finfo, path),
+func fileBase() *file {
+	f := &file{
+		fsnode: fsnodeBase(),
 	}
+	f.SetLabel("file")
+	return f
+}
+
+func newFile(ctx context.Context, finfo os.FileInfo, path string) *file {
+	f := fileBase()
+	f.build(finfo, path)
+	return f
 }
 
 func (f *file) Open(ctx context.Context) (plugin.SizedReader, error) {
diff --git a/api/helpers.go b/api/helpers.go
index 2b1cf38..3737667 100644
--- a/api/helpers.go
+++ b/api/helpers.go
@@ -124,7 +124,7 @@ func getEntryFromRequest(r *http.Request) (plugin.Entry, string, *errorResponse)
 		// but it does mean that we'll need to re-evaluate this code once
 		// we get to the point where supporting remote Wash servers is
 		// desirable.
-		e, err := apifs.NewEntryBase(ctx, path)
+		e, err := apifs.NewEntry(ctx, path)
 		if err != nil {
 			if os.IsNotExist(err) {
 				return nil, "", entryNotFoundResponse(path, err.Error())
diff --git a/api/types/schema.go b/api/types/schema.go
new file mode 100644
index 0000000..2a52215
--- /dev/null
+++ b/api/types/schema.go
@@ -0,0 +1,7 @@
+package apitypes
+
+import "github.com/puppetlabs/wash/plugin"
+
+// EntrySchema describes an entry's schema, which
+// is what's returned by the /fs/schema endpoint.
+type EntrySchema = plugin.EntrySchema
diff --git a/cmd/internal/server/core.go b/cmd/internal/server/core.go
index 0152271..c90d1a3 100644
--- a/cmd/internal/server/core.go
+++ b/cmd/internal/server/core.go
@@ -13,18 +13,20 @@ import (
 	"github.com/puppetlabs/wash/api"
 	"github.com/puppetlabs/wash/fuse"
 	"github.com/puppetlabs/wash/plugin"
+	"github.com/puppetlabs/wash/plugin/aws"
+	"github.com/puppetlabs/wash/plugin/docker"
+	"github.com/puppetlabs/wash/plugin/kubernetes"
 
 	log "github.com/sirupsen/logrus"
 )
 
 // Opts exposes additional configuration for server operation.
 type Opts struct {
-	CPUProfilePath  string
-	ExternalPlugins []plugin.ExternalPluginSpec
-	LogFile         string
+	CPUProfilePath      string
+	ExternalPlugins     []plugin.ExternalPluginSpec
+	LogFile             string
 	// LogLevel can be "warn", "info", "debug", or "trace".
-	LogLevel     string
-	PluginConfig map[string]map[string]interface{}
+	LogLevel            string
 }
 
 type controlChannels struct {
@@ -40,7 +42,6 @@ type Server struct {
 	logFH      *os.File
 	api        controlChannels
 	fuse       controlChannels
-	plugins    map[string]plugin.Root
 }
 
 var levelMap = map[string]log.Level{
@@ -50,9 +51,9 @@ var levelMap = map[string]log.Level{
 	"trace": log.TraceLevel,
 }
 
-// New creates a new Server. Accepts a list of core plugins to load.
-func New(mountpoint string, socket string, plugins map[string]plugin.Root, opts Opts) *Server {
-	return &Server{mountpoint: mountpoint, socket: socket, plugins: plugins, opts: opts}
+// New creates a new Server.
+func New(mountpoint string, socket string, opts Opts) *Server {
+	return &Server{mountpoint: mountpoint, socket: socket, opts: opts}
 }
 
 // Start starts the server. It returns once the server is ready.
@@ -78,9 +79,9 @@ func (s *Server) Start() error {
 	}
 
 	registry := plugin.NewRegistry()
-	s.loadInternalPlugins(registry)
+	loadInternalPlugins(registry)
 	if len(s.opts.ExternalPlugins) > 0 {
-		s.loadExternalPlugins(registry, s.opts.ExternalPlugins)
+		loadExternalPlugins(registry, s.opts.ExternalPlugins)
 	}
 	if len(registry.Plugins()) == 0 {
 		return fmt.Errorf("No plugins loaded")
@@ -165,27 +166,27 @@ func (s *Server) Stop() {
 	s.shutdown()
 }
 
-func (s *Server) loadPlugin(registry *plugin.Registry, name string, root plugin.Root) {
+func loadPlugin(registry *plugin.Registry, name string, root plugin.Root) {
 	log.Infof("Loading %v", name)
-	if err := registry.RegisterPlugin(root, s.opts.PluginConfig[name]); err != nil {
+	if err := registry.RegisterPlugin(root); err != nil {
 		// %+v is a convention used by some errors to print additional context such as a stack trace
 		log.Warnf("%v failed to load: %+v", name, err)
 	}
 }
 
-func (s *Server) loadInternalPlugins(registry *plugin.Registry) {
+func loadInternalPlugins(registry *plugin.Registry) {
 	log.Debug("Loading internal plugins")
-	for name, root := range s.plugins {
-		s.loadPlugin(registry, name, root)
-	}
+	loadPlugin(registry, "aws", &aws.Root{})
+	loadPlugin(registry, "docker", &docker.Root{})
+	loadPlugin(registry, "kubernetes", &kubernetes.Root{})
 	log.Debug("Finished loading internal plugins")
 }
 
-func (s *Server) loadExternalPlugins(registry *plugin.Registry, externalPlugins []plugin.ExternalPluginSpec) {
+func loadExternalPlugins(registry *plugin.Registry, externalPlugins []plugin.ExternalPluginSpec) {
 	log.Infof("Loading external plugins")
 	for _, p := range externalPlugins {
 		log.Infof("Loading %v", p.Script)
-		if err := registry.RegisterExternalPlugin(p, s.opts.PluginConfig[p.Name()]); err != nil {
+		if err := registry.RegisterExternalPlugin(p); err != nil {
 			// %+v is a convention used by some errors to print additional context such as a stack trace
 			log.Warnf("%v failed to load: %+v", p.Script, err)
 		}
diff --git a/cmd/root.go b/cmd/root.go
index 387a2cc..2384a30 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -67,6 +67,7 @@ then starts your system shell with shortcuts configured for wash subcommands.`,
 	rootCmd.AddCommand(tailCommand())
 	rootCmd.AddCommand(historyCommand())
 	rootCmd.AddCommand(infoCommand())
+	rootCmd.AddCommand(streeCommand())
 
 	return rootCmd
 }
diff --git a/cmd/rootMain.go b/cmd/rootMain.go
index 5b89b1a..5643669 100644
--- a/cmd/rootMain.go
+++ b/cmd/rootMain.go
@@ -55,6 +55,7 @@ func runShell(rundir, mountpath, socketpath, execfile string) exitCode {
 		"list":     "list",
 		"meta":     "meta",
 		"tail":     "tail",
+		"stree":    "stree",
 	}
 	for name, subcommand := range aliases {
 		if err := writeAlias(filepath.Join(rundir, name), subcommand); err != nil {
@@ -157,7 +158,7 @@ func rootMain(cmd *cobra.Command, args []string) exitCode {
 		return exitCode{1}
 	}
 	socketpath := filepath.Join(rundir, "api.sock")
-	srv := server.New(mountpath, socketpath, internalPlugins, serverOpts)
+	srv := server.New(mountpath, socketpath, serverOpts)
 	if err := srv.Start(); err != nil {
 		cmdutil.ErrPrintf("Unable to start server: %v\n", err)
 		return exitCode{1}
diff --git a/cmd/server.go b/cmd/server.go
index 543a24d..f9755ba 100644
--- a/cmd/server.go
+++ b/cmd/server.go
@@ -13,9 +13,6 @@ import (
 	"github.com/puppetlabs/wash/cmd/internal/server"
 	cmdutil "github.com/puppetlabs/wash/cmd/util"
 	"github.com/puppetlabs/wash/plugin"
-	"github.com/puppetlabs/wash/plugin/aws"
-	"github.com/puppetlabs/wash/plugin/docker"
-	"github.com/puppetlabs/wash/plugin/kubernetes"
 
 	log "github.com/sirupsen/logrus"
 
@@ -25,12 +22,6 @@ import (
 	"gopkg.in/yaml.v2"
 )
 
-var internalPlugins = map[string]plugin.Root{
-	"aws":        &aws.Root{},
-	"docker":     &docker.Root{},
-	"kubernetes": &kubernetes.Root{},
-}
-
 func serverCommand() *cobra.Command {
 	serverCmd := &cobra.Command{
 		Use:   "server <mountpoint>",
@@ -69,7 +60,7 @@ func serverMain(cmd *cobra.Command, args []string) exitCode {
 		cmdutil.ErrPrintf("%v\n", err)
 		return exitCode{1}
 	}
-	srv := server.New(mountpoint, config.Socket, internalPlugins, serverOpts)
+	srv := server.New(mountpoint, config.Socket, serverOpts)
 	if err := srv.Start(); err != nil {
 		log.Warn(err)
 		return exitCode{1}
@@ -118,21 +109,11 @@ func serverOptsFor(cmd *cobra.Command) (server.Opts, error) {
 		}
 	}
 
-	config := make(map[string]map[string]interface{})
-	for name := range internalPlugins {
-		config[name] = viper.GetStringMap(name)
-	}
-	for _, spec := range externalPlugins {
-		name := spec.Name()
-		config[name] = viper.GetStringMap(name)
-	}
-
 	// Return the options
 	return server.Opts{
 		CPUProfilePath:  viper.GetString("cpuprofile"),
 		LogFile:         viper.GetString("logfile"),
 		LogLevel:        viper.GetString("loglevel"),
-		PluginConfig:    config,
 		ExternalPlugins: externalPlugins,
 	}, nil
 }
diff --git a/cmd/stree.go b/cmd/stree.go
new file mode 100644
index 0000000..f2f2c33
--- /dev/null
+++ b/cmd/stree.go
@@ -0,0 +1,61 @@
+package cmd
+
+import (
+	"fmt"
+
+	"github.com/xlab/treeprint"
+
+	apitypes "github.com/puppetlabs/wash/api/types"
+	cmdutil "github.com/puppetlabs/wash/cmd/util"
+	"github.com/spf13/cobra"
+)
+
+func streeCommand() *cobra.Command {
+	streeCmd := &cobra.Command{
+		Use:   "stree [<path>...]",
+		Short: "Displays the entry's stree (schema-tree)",
+		Long: `Displays the entry's stree (schema-tree), which is a high-level overview of the entry's
+hierarchy. Non-singleton types are bracketed with "[]".`,
+		RunE: toRunE(streeMain),
+	}
+	return streeCmd
+}
+
+func streeMain(cmd *cobra.Command, args []string) exitCode {
+	paths := args
+	if len(paths) == 0 {
+		paths = []string{"."}
+	}
+	conn := cmdutil.NewClient()
+	for _, path := range paths {
+		schema, err := conn.Schema(path)
+		if err != nil {
+			cmdutil.ErrPrintf("%v\n", err)
+			return exitCode{1}
+		}
+		stree := treeprint.New()
+		fill(stree, schema)
+		stree.SetValue(path)
+		cmdutil.Print(stree.String())
+	}
+	return exitCode{0}
+}
+
+func fill(stree treeprint.Tree, schema apitypes.EntrySchema) treeprint.Tree {
+	value := schema.Label
+	if len(value) == 0 {
+		value = schema.Type
+	}
+	if !schema.Singleton {
+		value = fmt.Sprintf("[%v]", value)
+	}
+	stree.SetValue(value)
+	for _, child := range schema.Children {
+		// treeprint.Tree has no "AddBranch()" method, so we need to
+		// set a stub value. Note that the value will be reset to the
+		// correct value in the recursive call, so this is OK.
+		subtree := stree.AddBranch("foo")
+		fill(subtree, child)
+	}
+	return stree
+}
diff --git a/docs/docs/external_plugins/index.html b/docs/docs/external_plugins/index.html
index 190aa2a..8614608 100644
--- a/docs/docs/external_plugins/index.html
+++ b/docs/docs/external_plugins/index.html
@@ -48,7 +48,7 @@
 <ol>
 <li><p>Write the <a href="#plugin-script">plugin script</a>. This is the script that Wash will shell out to whenever it needs to invoke a method on a specific entry within your plugin.</p></li>
 
-<li><p>Add the plugin to your <code>wash.yaml</code> file under the <code>external-plugins</code> key, and specify the path to the plugin script. <em>The plugin&rsquo;s name is the basename of the script without the extension.</em> An example <code>wash.yaml</code> config adding the <code>sshfs</code> plugin is shown below:</p>
+<li><p>Add the plugin to your <code>wash.yaml</code> file under the <code>external-plugins</code> key, and specify the path to the plugin script. The plugin&rsquo;s name will be determined by invoking the script with the <code>init</code> method. An example <code>wash.yaml</code> config is shown below:</p>
 <div class="highlight"><div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4">
 <table style="border-spacing:0;padding:0;margin:0;border:0;width:auto;overflow:auto;display:block;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
 <pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
@@ -56,7 +56,7 @@
 </span></pre></td>
 <td style="vertical-align:top;padding:0;margin:0;border:0;">
 <pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4">external-plugins:
-    - script: &#39;/path/to/wash/website/static/docs/external_plugins/examples/sshfs.sh&#39;</pre></td></tr></table>
+    - script: &#39;/Users/enis.inan/GitHub/wash/website/static/docs/external_plugins/examples/sshfs.sh&#39;</pre></td></tr></table>
 </div>
 </div></li>
 
@@ -91,7 +91,7 @@
 
 <h2 id="init">init</h2>
 
-<p>The <code>init</code> method is special. It is invoked as <code>&lt;plugin_script&gt; init &lt;config&gt;</code>, and it is invoked only once, when the external plugin is loaded. <code>&lt;config&gt;</code> is JSON containing any config supplied to Wash under the plugin&rsquo;s key. Given a Wash config file (<code>wash.yaml</code>)</p>
+<p>The <code>init</code> method is special. It is invoked as <code>&lt;plugin_script&gt; init</code>, and it is invoked only once, when the external plugin is loaded. When <code>init</code> is invoked, the script must output a JSON object representing the plugin root. Here&rsquo;s an example:</p>
 <div class="highlight"><div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4">
 <table style="border-spacing:0;padding:0;margin:0;border:0;width:auto;overflow:auto;display:block;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
 <pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
@@ -102,29 +102,19 @@
 </span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
 </span></pre></td>
 <td style="vertical-align:top;padding:0;margin:0;border:0;">
-<pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4">external-plugins:
-  - script: &#39;/path/to/myplugin.rb&#39;
-myplugin:
-  profiles:
-    - profile_a
-    - profile_b</pre></td></tr></table>
-</div>
-</div>
-<p>the <code>init</code> method for a plugin named <code>myplugin</code> will be invoked with</p>
-<div class="highlight"><div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4">
-<table style="border-spacing:0;padding:0;margin:0;border:0;width:auto;overflow:auto;display:block;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
-<pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
-</span></pre></td>
-<td style="vertical-align:top;padding:0;margin:0;border:0;">
-<pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4">&lt;plugin_script&gt; init &#39;{&#34;profiles&#34;:[&#34;profile_a&#34;,&#34;profile_b&#34;]}&#39;</pre></td></tr></table>
+<pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4">{
+  &#34;name&#34;: &#34;aws&#34;,
+  &#34;methods&#34;: [
+    &#34;list&#34;
+  ]
+}</pre></td></tr></table>
 </div>
 </div>
-<p>When <code>init</code> is invoked, the script must output a JSON object representing the plugin root. The <em>minimum</em> amount of information required for Wash to construct the plugin root is an empty object, <code>{}</code>.</p>
+<p>This example shows the <em>minimum</em> amount of information required for Wash to construct the plugin root. In fact, the example shows the minimum amount of information required for Wash to construct <em>any</em> external plugin entry. This information consists of the entry&rsquo;s name and its implemented methods.</p>
 
 <p>You can include additional (optional) keys in the printed JSON object. These keys are:</p>
 
 <ul>
-<li><code>methods</code>. This is an array specifying the list of methods, enumerated below, that can be called directly on the plugin entry. The plugin root must always include and implement the <code>list</code> method.</li>
 <li><code>cache_ttls</code>. This specifies how many seconds each method&rsquo;s result should be cached (<code>ttl</code> is short for time to live). Currently, Wash caches the result of <code>list</code>, <code>read</code>, and <code>metadata</code>.</li>
 <li><code>attributes</code>. This represents the entry&rsquo;s attributes (see the <a href="/wash/docs#attributes-metadata"><code>Attributes/Metadata</code></a> section). Time attributes are specified in Unix seconds. Octal modes must be prefixed with the <code>0</code> delimiter (e.g. like <code>0777</code>). Hexadecimal modes must be prefixed with the <code>0x</code> delimiter (e.g. like <code>0xabcd</code>).</li>
 <li><code>slash_replacer</code>. This overrides the default slash replacer <code>#</code>.</li>
@@ -149,10 +139,16 @@ myplugin:
 </span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
 </span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
 </span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
+</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
+</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
+</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
 </span></pre></td>
 <td style="vertical-align:top;padding:0;margin:0;border:0;">
 <pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4">{
-  &#34;methods&#34;: [&#34;list&#34;]
+  &#34;name&#34;: &#34;some_entry&#34;,
+  &#34;methods&#34;: [
+    &#34;list&#34;
+  ],
   &#34;cache_ttls&#34;: {
     &#34;list&#34;: 30
   },
@@ -168,34 +164,13 @@ myplugin:
 }</pre></td></tr></table>
 </div>
 </div>
-<p>We see from <code>cache_ttls</code> that the result of <code>some_entry</code>&rsquo;s <code>list</code> method will be cached for 30 seconds. We see from <code>attributes</code> that <code>some_entry</code> has an <code>mtime</code> attribute, and that it also includes the <code>meta</code> attribute. We see from <code>slash_replacer</code> that any <code>/</code>&lsquo;es in the entry&rsquo;s returned name will be replaced by a <code>:</code> instead of a <code>#</code>. Finally, we see from <code>state</code> that <code>some_entry</code> has some state that Wash will pass-back in via the <code>&lt;state&gt;</code> parameter whenever it invokes one of its methods. In this case, <code>&lt;state&gt;</code> is a stringified JSON object containing the entry&rsquo;s class (<code>SSHFS::Directory</code>) in whatever language the plugin script was written in.</p>
+<p>We see from <code>cache_ttls</code> that the result of <code>some_entry</code>&rsquo;s <code>list</code> method will be cached for 30 seconds. We see from <code>attributes</code> that <code>some_entry</code> has an <code>mtime</code> attribute, and that it also includes the <code>meta</code> attribute. We see from <code>slash_replacer</code> that any <code>/</code>&lsquo;es in the entry&rsquo;s returned name will be replaced by a <code>:</code> instead of a <code>#</code>. Finally, we see from <code>state</code> that <code>some_entry</code> has some state that Wash will pass-back in via the <code>&lt;state&gt;</code> parameter whenever it invokes one of its methods. In this case, only <code>list</code> is implemented, and <code>&lt;state&gt;</code> is a stringified JSON object containing the entry&rsquo;s class (<code>SSHFS::Directory</code>) in whatever language the plugin script was written in.</p>
 
 <p><code>init</code> adopts the standard error conventions described in the <a href="#errors">Errors</a> section.</p>
 
 <h2 id="list">list</h2>
 
-<p><code>list</code> is invoked as <code>&lt;plugin_script&gt; list &lt;path&gt; &lt;state&gt;</code>. When <code>list</code> is invoked, the script must output an array of JSON objects. The <em>minimum</em> information required is each entry&rsquo;s name and its implemented methods</p>
-<div class="highlight"><div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4">
-<table style="border-spacing:0;padding:0;margin:0;border:0;width:auto;overflow:auto;display:block;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
-<pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
-</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
-</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
-</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
-</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
-</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
-</span></pre></td>
-<td style="vertical-align:top;padding:0;margin:0;border:0;">
-<pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4">{
-  &#34;name&#34;: &#34;mydirectory&#34;,
-  &#34;methods&#34;: [
-    &#34;list&#34;
-  ]
-}</pre></td></tr></table>
-</div>
-</div>
-<p>Each entry may additionally return any keys described in <a href="#init">init</a>.</p>
-
-<p>Below is an example of valid <code>list</code> output:</p>
+<p><code>list</code> is invoked as <code>&lt;plugin_script&gt; list &lt;path&gt; &lt;state&gt;</code>. When <code>list</code> is invoked, the script must output an array of JSON objects. Each JSON object has the same schema as the JSON object described in the <code>init</code> section (hence, <code>list</code> outputs an array of entries). Below is an example of valid <code>list</code> output:</p>
 <div class="highlight"><div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4">
 <table style="border-spacing:0;padding:0;margin:0;border:0;width:auto;overflow:auto;display:block;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
 <pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
diff --git a/docs/docs/index.html b/docs/docs/index.html
index 78acdff..70c5f64 100644
--- a/docs/docs/index.html
+++ b/docs/docs/index.html
@@ -156,25 +156,13 @@ to display running processes on all listed nodes. Errors on paths that don&rsquo
 
 <ul>
 <li>EC2 and S3</li>
+<li>uses <code>AWS_SHARED_CREDENTIALS_FILE</code> environment variable or <code>$HOME/.aws/credentials</code> and <code>AWS_CONFIG_FILE</code> environment variable or <code>$HOME/.aws/config</code> to find profiles and configure the SDK.</li>
 <li>IAM roles are supported when configured as described here. Note that currently region will also need to be specified with the profile.</li>
 <li>if using MFA, Wash will prompt for it on standard input. Credentials are valid for 1 hour. They are cached under <code>wash/aws-credentials</code> in your user cache directory so they can be re-used across server restarts. Wash may have to re-prompt for a new MFA token in response to navigating the Wash environment to authorize a new session.</li>
 <li>supports streaming, and remote command execution via ssh</li>
 <li>supports full metadata for S3 content</li>
 </ul>
 
-<p>The AWS plugin reads the <code>AWS_SHARED_CREDENTIALS_FILE</code> environment variable or <code>$HOME/.aws/credentials</code> and <code>AWS_CONFIG_FILE</code> environment variable or <code>$HOME/.aws/config</code> to find profiles and configure the SDK. The profiles it lists can be limited by adding</p>
-<div class="highlight"><div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4">
-<table style="border-spacing:0;padding:0;margin:0;border:0;width:auto;overflow:auto;display:block;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
-<pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
-</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
-</span></pre></td>
-<td style="vertical-align:top;padding:0;margin:0;border:0;">
-<pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4">aws:
-  profiles: [profile_1, profile_2]</pre></td></tr></table>
-</div>
-</div>
-<p>to Wash&rsquo;s <a href="#config">config file</a>.</p>
-
 <h3 id="docker">Docker</h3>
 
 <ul>
diff --git a/docs/index.html b/docs/index.html
index 53201e0..b59fbd5 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -1,7 +1,7 @@
 <!DOCTYPE html>
 <html>
 <head>
-	<meta name="generator" content="Hugo 0.55.6" />
+	<meta name="generator" content="Hugo 0.55.5" />
     <meta charset="utf-8">
     <meta name="viewport" content="width=device-width, initial-scale=1">
 
diff --git a/go.mod b/go.mod
index 5f8ef7e..6e05059 100644
--- a/go.mod
+++ b/go.mod
@@ -63,6 +63,7 @@ require (
 	github.com/spf13/cobra v0.0.3
 	github.com/spf13/viper v1.3.2
 	github.com/stretchr/testify v1.2.2
+	github.com/xlab/treeprint v0.0.0-20181112141820-a009c3971eca
 	golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9
 	golang.org/x/net v0.0.0-20190110200230-915654e7eabc // indirect
 	golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890 // indirect
diff --git a/go.sum b/go.sum
index 5ef9c95..6a49aea 100644
--- a/go.sum
+++ b/go.sum
@@ -175,6 +175,8 @@ github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8 h1:3SVOIvH7Ae1KRYyQWRjXWJEA9sS/c/pjvH++55Gr648=
 github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
+github.com/xlab/treeprint v0.0.0-20181112141820-a009c3971eca h1:1CFlNzQhALwjS9mBAUkycX616GzgsuYUOCHA5+HSlXI=
+github.com/xlab/treeprint v0.0.0-20181112141820-a009c3971eca/go.mod h1:ce1O1j6UtZfjr22oyGxGLbauSBp2YVXpARAosm7dHBg=
 github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77 h1:ESFSdwYZvkeru3RtdrYueztKhOBCSAAzS4Gf+k0tEow=
 github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
 golang.org/x/crypto v0.0.0-20180904163835-0709b304e793 h1:u+LnwYTOOW7Ukr/fppxEb1Nwz0AtPflrblfvUudpo+I=
diff --git a/plugin/aws/ec2Instance.go b/plugin/aws/ec2Instance.go
index 134c056..d911a36 100644
--- a/plugin/aws/ec2Instance.go
+++ b/plugin/aws/ec2Instance.go
@@ -111,7 +111,7 @@ func (inst *ec2Instance) ChildSchemas() []plugin.EntrySchema {
 	return plugin.ChildSchemas(
 		ec2InstanceConsoleOutputBase(),
 		ec2InstanceMetadataJSONBase(),
-		volume.FSBase(),
+		volume.FSBase("fs"),
 	)
 }
 
diff --git a/plugin/aws/root.go b/plugin/aws/root.go
index b63776e..1d0d7b0 100644
--- a/plugin/aws/root.go
+++ b/plugin/aws/root.go
@@ -20,7 +20,6 @@ import (
 // Root of the AWS plugin
 type Root struct {
 	plugin.EntryBase
-	profs []string
 }
 
 func awsCredentialsFile() (string, error) {
@@ -61,26 +60,11 @@ func exists(path string) error {
 }
 
 // Init for root
-func (r *Root) Init(cfg map[string]interface{}) error {
+func (r *Root) Init() error {
 	r.EntryBase = plugin.NewEntryBase()
 	r.SetName("aws")
 	r.SetTTLOf(plugin.ListOp, 1*time.Minute)
 
-	if profsI, ok := cfg["profiles"]; ok {
-		profs, ok := profsI.([]interface{})
-		if !ok {
-			return fmt.Errorf("aws.profiles config must be an array of strings, not %s", profs)
-		}
-		r.profs = make([]string, len(profs))
-		for i, elem := range profs {
-			prof, ok := elem.(string)
-			if !ok {
-				return fmt.Errorf("aws.profiles config must be an array of strings, not %s", profs)
-			}
-			r.profs[i] = prof
-		}
-	}
-
 	// Force authorizing profiles on startup
 	_, err := r.List(context.Background())
 	return err
@@ -134,21 +118,12 @@ func (r *Root) List(ctx context.Context) ([]plugin.Entry, error) {
 		names[strings.TrimPrefix(section.Name(), "profile ")] = struct{}{}
 	}
 
-	profs := make(map[string]struct{})
-	for _, p := range r.profs {
-		profs[p] = struct{}{}
-	}
-
 	var profiles []plugin.Entry
 	for name := range names {
 		if name == "DEFAULT" {
 			continue
 		}
 
-		if _, ok := profs[name]; len(profs) > 0 && !ok {
-			continue
-		}
-
 		profile, err := newProfile(ctx, name)
 		if err != nil {
 			activity.Record(ctx, err.Error())
diff --git a/plugin/docker/container.go b/plugin/docker/container.go
index a75d8d0..e3ab3a9 100644
--- a/plugin/docker/container.go
+++ b/plugin/docker/container.go
@@ -70,7 +70,7 @@ func (c *container) ChildSchemas() []plugin.EntrySchema {
 	return plugin.ChildSchemas(
 		containerLogFileBase(),
 		containerMetadataBase(),
-		vol.FSBase(),
+		vol.FSBase("fs"),
 	)
 }
 
diff --git a/plugin/docker/containersDir.go b/plugin/docker/containersDir.go
index 5dc25b7..b733ce7 100644
--- a/plugin/docker/containersDir.go
+++ b/plugin/docker/containersDir.go
@@ -29,7 +29,7 @@ func newContainersDir(client *client.Client) *containersDir {
 }
 
 func (cs *containersDir) ChildSchemas() []plugin.EntrySchema {
-	return plugin.ChildSchemas(containersDirBase())
+	return plugin.ChildSchemas(containerBase())
 }
 
 // List
diff --git a/plugin/docker/root.go b/plugin/docker/root.go
index 89ec721..5e9431b 100644
--- a/plugin/docker/root.go
+++ b/plugin/docker/root.go
@@ -20,7 +20,7 @@ type Root struct {
 }
 
 // Init for root
-func (r *Root) Init(map[string]interface{}) error {
+func (r *Root) Init() error {
 	dockerCli, err := client.NewClientWithOpts(client.FromEnv)
 	if err != nil {
 		return err
diff --git a/plugin/externalPluginRoot.go b/plugin/externalPluginRoot.go
index 9702c24..07c7bd8 100644
--- a/plugin/externalPluginRoot.go
+++ b/plugin/externalPluginRoot.go
@@ -14,27 +14,19 @@ type externalPluginRoot struct {
 
 // newExternalPluginRoot returns a new external plugin root given
 // the plugin script
-func newExternalPluginRoot(name, script string) *externalPluginRoot {
-	root := &externalPluginRoot{&externalPluginEntry{
-		EntryBase: NewEntryBase(),
-		script:    externalPluginScriptImpl{path: script},
+func newExternalPluginRoot(script string) *externalPluginRoot {
+	return &externalPluginRoot{&externalPluginEntry{
+		script: externalPluginScriptImpl{path: script},
 	}}
-	root.SetName(name)
-	return root
 }
 
 // Init initializes the external plugin root
-func (r *externalPluginRoot) Init(cfg map[string]interface{}) error {
-	cfgJSON, err := json.Marshal(cfg)
-	if err != nil {
-		return fmt.Errorf("could not marshal plugin config %v into JSON: %v", cfg, err)
-	}
-
+func (r *externalPluginRoot) Init() error {
 	// Give external plugins about five-seconds to finish their
 	// initialization
-	ctx, cancelFunc := context.WithTimeout(context.Background(), 5*time.Second)
+	ctx, cancelFunc := context.WithTimeout(context.Background(), 5 * time.Second)
 	defer cancelFunc()
-	stdout, err := r.script.InvokeAndWait(ctx, "init", nil, string(cfgJSON))
+	stdout, err := r.script.InvokeAndWait(ctx, "init", nil)
 	if err != nil {
 		select {
 		case <-ctx.Done():
@@ -50,29 +42,18 @@ func (r *externalPluginRoot) Init(cfg map[string]interface{}) error {
 			"the plugin root",
 			err,
 			stdout,
-			"{}",
+			"{\"name\":\"plugin_name\",\"methods\":[\"list\"]}",
 		)
 	}
-
-	// Fill in required fields with data we already know.
-	if decodedRoot.Name == "" {
-		decodedRoot.Name = r.Name()
-	} else if decodedRoot.Name != r.Name() {
-		panic(fmt.Sprintf(`plugin root's name must match the basename (without extension) of %s
-it's safe to omit name from the response to 'init'`, r.script.Path()))
-	}
-	if decodedRoot.Methods == nil {
-		decodedRoot.Methods = []string{"list"}
-	}
 	entry, err := decodedRoot.toExternalPluginEntry()
 	if err != nil {
 		return err
 	}
 	if !ListAction().IsSupportedOn(entry) {
-		panic(fmt.Sprintf("plugin root for %s must implement 'list'", r.script.Path()))
+		return fmt.Errorf("plugin roots must implement 'list'")
 	}
 	script := r.script
 	r.externalPluginEntry = entry
 	r.externalPluginEntry.script = script
 	return nil
-}
+}
\ No newline at end of file
diff --git a/plugin/externalPluginScript.go b/plugin/externalPluginScript.go
index dcaf7e5..7befed3 100644
--- a/plugin/externalPluginScript.go
+++ b/plugin/externalPluginScript.go
@@ -60,7 +60,7 @@ func (s externalPluginScriptImpl) NewInvocation(
 	args ...string,
 ) *internal.Command {
 	if method == "init" {
-		return internal.NewCommand(ctx, s.Path(), append([]string{"init"}, args...)...)
+		return internal.NewCommand(ctx, s.Path(), "init")
 	}
 	if entry == nil {
 		msg := fmt.Sprintf("s.NewInvocation called with method '%v' and entry == nil", method)
@@ -68,7 +68,7 @@ func (s externalPluginScriptImpl) NewInvocation(
 	}
 	return internal.NewCommand(
 		ctx,
-		s.Path(),
+		s.Path(), 
 		append([]string{method, entry.id(), entry.state}, args...)...,
 	)
-}
+}
\ No newline at end of file
diff --git a/plugin/kubernetes/root.go b/plugin/kubernetes/root.go
index a0c52df..2794e36 100644
--- a/plugin/kubernetes/root.go
+++ b/plugin/kubernetes/root.go
@@ -40,7 +40,7 @@ func createContext(raw clientcmdapi.Config, name string, access clientcmd.Config
 }
 
 // Init for root
-func (r *Root) Init(map[string]interface{}) error {
+func (r *Root) Init() error {
 	loadingRules := clientcmd.NewDefaultClientConfigLoadingRules()
 	config := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, &clientcmd.ConfigOverrides{})
 	raw, err := config.RawConfig()
diff --git a/plugin/registry.go b/plugin/registry.go
index b1b8453..1c5cf6b 100644
--- a/plugin/registry.go
+++ b/plugin/registry.go
@@ -4,9 +4,7 @@ import (
 	"context"
 	"fmt"
 	"os"
-	"path/filepath"
 	"regexp"
-	"strings"
 )
 
 // Registry represents the plugin registry. It is also Wash's root.
@@ -39,8 +37,8 @@ var pluginNameRegex = regexp.MustCompile("^[0-9a-zA-Z_-]+$")
 
 // RegisterPlugin initializes the given plugin and adds it to the registry if
 // initialization was successful.
-func (r *Registry) RegisterPlugin(root Root, config map[string]interface{}) error {
-	if err := root.Init(config); err != nil {
+func (r *Registry) RegisterPlugin(root Root) error {
+	if err := root.Init(); err != nil {
 		return err
 	}
 
@@ -65,15 +63,9 @@ type ExternalPluginSpec struct {
 	Script string
 }
 
-// Name returns the plugin name, which is the basename of the script with extension removed.
-func (s ExternalPluginSpec) Name() string {
-	basename := filepath.Base(s.Script)
-	return strings.TrimSuffix(basename, filepath.Ext(basename))
-}
-
 // RegisterExternalPlugin initializes an external plugin from its spec and
 // passes it to RegisterPlugin.
-func (r *Registry) RegisterExternalPlugin(spec ExternalPluginSpec, cfg map[string]interface{}) error {
+func (r *Registry) RegisterExternalPlugin(spec ExternalPluginSpec) error {
 	fi, err := os.Stat(spec.Script)
 	if err != nil {
 		return err
@@ -83,8 +75,8 @@ func (r *Registry) RegisterExternalPlugin(spec ExternalPluginSpec, cfg map[strin
 		return fmt.Errorf("script %v is not executable", spec.Script)
 	}
 
-	root := newExternalPluginRoot(spec.Name(), spec.Script)
-	return r.RegisterPlugin(root, cfg)
+	root := newExternalPluginRoot(spec.Script)
+	return r.RegisterPlugin(root)
 }
 
 // ChildSchemas returns the child schemas of the plugin registry
diff --git a/plugin/types.go b/plugin/types.go
index d3bfd6b..5834369 100644
--- a/plugin/types.go
+++ b/plugin/types.go
@@ -57,11 +57,10 @@ type Parent interface {
 	List(context.Context) ([]Entry, error)
 }
 
-// Root represents the plugin root. The Init function is passed a config map representing
-// plugin-specific configuration.
+// Root represents the plugin root
 type Root interface {
 	Parent
-	Init(map[string]interface{}) error
+	Init() error
 }
 
 // ExecOptions is a struct we can add new features to that must be serializable to JSON.
diff --git a/volume/fs.go b/volume/fs.go
index a6f19b7..dfbd629 100644
--- a/volume/fs.go
+++ b/volume/fs.go
@@ -17,12 +17,13 @@ type FS struct {
 }
 
 // FSBase returns a base FS entry
-func FSBase() *FS {
+func FSBase(name string) *FS {
 	fs := &FS{
 		EntryBase: plugin.NewEntryBase(),
 	}
 	fs.
-		SetLabel("fs").
+		SetName(name).
+		IsSingleton().
 		// Caching handled in List.
 		DisableCachingFor(plugin.ListOp)
 	return fs
@@ -31,9 +32,8 @@ func FSBase() *FS {
 // NewFS creates a new FS entry with the given name, using the supplied executor to satisfy volume
 // operations.
 func NewFS(name string, executor plugin.Execable) *FS {
-	fs := FSBase()
+	fs := FSBase(name)
 	fs.executor = executor
-	fs.SetName(name)
 	return fs
 }
 
diff --git a/website/content/docs/_index.md b/website/content/docs/_index.md
index a4ff795..7c80340 100644
--- a/website/content/docs/_index.md
+++ b/website/content/docs/_index.md
@@ -101,18 +101,12 @@ NOTE: Do not override `socket` in a config file. Instead, override it via the `W
 ### AWS
 
 - EC2 and S3
+- uses `AWS_SHARED_CREDENTIALS_FILE` environment variable or `$HOME/.aws/credentials` and `AWS_CONFIG_FILE` environment variable or `$HOME/.aws/config` to find profiles and configure the SDK.
 - IAM roles are supported when configured as described here. Note that currently region will also need to be specified with the profile.
 - if using MFA, Wash will prompt for it on standard input. Credentials are valid for 1 hour. They are cached under `wash/aws-credentials` in your user cache directory so they can be re-used across server restarts. Wash may have to re-prompt for a new MFA token in response to navigating the Wash environment to authorize a new session.
 - supports streaming, and remote command execution via ssh
 - supports full metadata for S3 content
 
-The AWS plugin reads the `AWS_SHARED_CREDENTIALS_FILE` environment variable or `$HOME/.aws/credentials` and `AWS_CONFIG_FILE` environment variable or `$HOME/.aws/config` to find profiles and configure the SDK. The profiles it lists can be limited by adding
-```
-aws:
-  profiles: [profile_1, profile_2]
-```
-to Wash's [config file](#config).
-
 ### Docker
 
 - containers and volumes
diff --git a/website/content/docs/external_plugins/_index.md b/website/content/docs/external_plugins/_index.md
index 22cbcf3..e487208 100644
--- a/website/content/docs/external_plugins/_index.md
+++ b/website/content/docs/external_plugins/_index.md
@@ -6,13 +6,12 @@ External plugins let Wash talk to other things outside of the built-in plugins.
 
 1. Write the [plugin script](#plugin-script). This is the script that Wash will shell out to whenever it needs to invoke a method on a specific entry within your plugin.
 
-1. Add the plugin to your `wash.yaml` file under the `external-plugins` key, and specify the path to the plugin script. *The plugin's name is the basename of the script without the extension.* An example `wash.yaml` config adding the `sshfs` plugin is shown below:
+1. Add the plugin to your `wash.yaml` file under the `external-plugins` key, and specify the path to the plugin script. The plugin's name will be determined by invoking the script with the `init` method. An example `wash.yaml` config is shown below:
 
     ```
     external-plugins:
-        - script: '/path/to/wash/website/static/docs/external_plugins/examples/sshfs.sh'
+        - script: '/Users/enis.inan/GitHub/wash/website/static/docs/external_plugins/examples/sshfs.sh'
     ```
-
 1. Start the Wash shell to see your plugin in action.
 
 ## Plugin Script
@@ -37,28 +36,21 @@ The remaining sections describe all the possible Wash methods that can be passed
 NOTE: Plugin script invocations run in their own process group (pgrp). Wash will send a SIGTERM signal to the pgrp on a cancelled API/filesystem request. If after five seconds the invocation process has not terminated, then Wash will send a SIGKILL signal.
 
 ## init
-The `init` method is special. It is invoked as `<plugin_script> init <config>`, and it is invoked only once, when the external plugin is loaded. `<config>` is JSON containing any config supplied to Wash under the plugin's key. Given a Wash config file (`wash.yaml`)
-
-```
-external-plugins:
-  - script: '/path/to/myplugin.rb'
-myplugin:
-  profiles:
-    - profile_a
-    - profile_b
-```
-
-the `init` method for a plugin named `myplugin` will be invoked with
+The `init` method is special. It is invoked as `<plugin_script> init`, and it is invoked only once, when the external plugin is loaded. When `init` is invoked, the script must output a JSON object representing the plugin root. Here's an example:
 
 ```
-<plugin_script> init '{"profiles":["profile_a","profile_b"]}'
+{
+  "name": "aws",
+  "methods": [
+    "list"
+  ]
+}
 ```
 
-When `init` is invoked, the script must output a JSON object representing the plugin root. The *minimum* amount of information required for Wash to construct the plugin root is an empty object, `{}`.
+This example shows the *minimum* amount of information required for Wash to construct the plugin root. In fact, the example shows the minimum amount of information required for Wash to construct *any* external plugin entry. This information consists of the entry's name and its implemented methods.
 
 You can include additional (optional) keys in the printed JSON object. These keys are:
 
-* `methods`. This is an array specifying the list of methods, enumerated below, that can be called directly on the plugin entry. The plugin root must always include and implement the `list` method.
 * `cache_ttls`. This specifies how many seconds each method's result should be cached (`ttl` is short for time to live). Currently, Wash caches the result of `list`, `read`, and `metadata`.
 * `attributes`. This represents the entry's attributes (see the [`Attributes/Metadata`](/wash/docs#attributes-metadata) section). Time attributes are specified in Unix seconds. Octal modes must be prefixed with the `0` delimiter (e.g. like `0777`). Hexadecimal modes must be prefixed with the `0x` delimiter (e.g. like `0xabcd`).
 * `slash_replacer`. This overrides the default slash replacer `#`.
@@ -68,7 +60,10 @@ Below is an example JSON object showcasing all possible keys at once.
 
 ```
 {
-  "methods": ["list"]
+  "name": "some_entry",
+  "methods": [
+    "list"
+  ],
   "cache_ttls": {
     "list": 30
   },
@@ -84,25 +79,12 @@ Below is an example JSON object showcasing all possible keys at once.
 }
 ```
 
-We see from `cache_ttls` that the result of `some_entry`'s `list` method will be cached for 30 seconds. We see from `attributes` that `some_entry` has an `mtime` attribute, and that it also includes the `meta` attribute. We see from `slash_replacer` that any `/`'es in the entry's returned name will be replaced by a `:` instead of a `#`. Finally, we see from `state` that `some_entry` has some state that Wash will pass-back in via the `<state>` parameter whenever it invokes one of its methods. In this case, `<state>` is a stringified JSON object containing the entry's class (`SSHFS::Directory`) in whatever language the plugin script was written in.
+We see from `cache_ttls` that the result of `some_entry`'s `list` method will be cached for 30 seconds. We see from `attributes` that `some_entry` has an `mtime` attribute, and that it also includes the `meta` attribute. We see from `slash_replacer` that any `/`'es in the entry's returned name will be replaced by a `:` instead of a `#`. Finally, we see from `state` that `some_entry` has some state that Wash will pass-back in via the `<state>` parameter whenever it invokes one of its methods. In this case, only `list` is implemented, and `<state>` is a stringified JSON object containing the entry's class (`SSHFS::Directory`) in whatever language the plugin script was written in.
 
 `init` adopts the standard error conventions described in the [Errors](#errors) section.
 
 ## list
-`list` is invoked as `<plugin_script> list <path> <state>`. When `list` is invoked, the script must output an array of JSON objects. The *minimum* information required is each entry's name and its implemented methods
-
-```
-{
-  "name": "mydirectory",
-  "methods": [
-    "list"
-  ]
-}
-```
-
-Each entry may additionally return any keys described in [init](#init).
-
-Below is an example of valid `list` output:
+`list` is invoked as `<plugin_script> list <path> <state>`. When `list` is invoked, the script must output an array of JSON objects. Each JSON object has the same schema as the JSON object described in the `init` section (hence, `list` outputs an array of entries). Below is an example of valid `list` output:
 
 ```
 [


Test patch:
diff --git a/api/helpers_test.go b/api/helpers_test.go
index 0ae5b36..7278495 100644
--- a/api/helpers_test.go
+++ b/api/helpers_test.go
@@ -89,12 +89,12 @@ func (suite *HelpersTestSuite) TestFindEntry() {
 	duplicateFoo.SetName("foo#bar")
 	parent.entries = append(parent.entries, &duplicateFoo)
 	expectedErr := plugin.DuplicateCNameErr{
-		ParentID:                 plugin.ID(parent),
-		FirstChildName:           foo.Name(),
-		FirstChildSlashReplacer:  '#',
-		SecondChildName:          duplicateFoo.Name(),
-		SecondChildSlashReplacer: '#',
-		CName:                    "foo#bar",
+		ParentID:                        plugin.ID(parent),
+		FirstChildName:                  foo.Name(),
+		FirstChildSlashReplacer:         '#',
+		SecondChildName:                 duplicateFoo.Name(),
+		SecondChildSlashReplacer:        '#',
+		CName:                           "foo#bar",
 	}
 	runTestCase(
 		parent,
@@ -107,7 +107,7 @@ type mockRoot struct {
 	mock.Mock
 }
 
-func (m *mockRoot) Init(map[string]interface{}) error {
+func (m *mockRoot) Init() error {
 	return nil
 }
 
@@ -133,7 +133,7 @@ func (suite *HelpersTestSuite) TestGetEntryFromPath() {
 	plug := &mockRoot{EntryBase: plugin.NewEntryBase()}
 	plug.SetName("mine")
 	plug.SetTestID("/mine")
-	suite.NoError(reg.RegisterPlugin(plug, map[string]interface{}{}))
+	suite.NoError(reg.RegisterPlugin(plug))
 	ctx := context.WithValue(context.Background(), pluginRegistryKey, reg)
 
 	mountpoint := "/mountpoint"
diff --git a/cmd/internal/cmdtest/client.go b/cmd/internal/cmdtest/client.go
index adfc2d6..a0f50b0 100644
--- a/cmd/internal/cmdtest/client.go
+++ b/cmd/internal/cmdtest/client.go
@@ -59,4 +59,10 @@ func (c *MockClient) ActivityJournal(index int, follow bool) (io.ReadCloser, err
 func (c *MockClient) Clear(path string) ([]string, error) {
 	args := c.Called(path)
 	return args.Get(0).([]string), args.Error(1)
-}
\ No newline at end of file
+}
+
+// Schema mocks Client#Schema
+func (c *MockClient) Schema(path string) (apitypes.EntrySchema, error) {
+	args := c.Called(path)
+	return args.Get(0).(apitypes.EntrySchema), args.Error(1)
+}
diff --git a/plugin/externalPluginRoot_test.go b/plugin/externalPluginRoot_test.go
index bd92b9a..6f3da3c 100644
--- a/plugin/externalPluginRoot_test.go
+++ b/plugin/externalPluginRoot_test.go
@@ -19,39 +19,43 @@ func (suite *ExternalPluginRootTestSuite) TestInit() {
 		EntryBase: NewEntryBase(),
 		script:    mockScript,
 	}}
-	root.SetName("foo")
 
 	mockInvokeAndWait := func(stdout []byte, err error) {
 		mockScript.OnInvokeAndWait(
 			mock.Anything,
 			"init",
 			nil,
-			"null",
 		).Return(stdout, err).Once()
 	}
 
 	// Test that if InvokeAndWait errors, then Init returns its error
 	mockErr := fmt.Errorf("execution error")
 	mockInvokeAndWait([]byte{}, mockErr)
-	err := root.Init(nil)
+	err := root.Init()
 	suite.EqualError(mockErr, err.Error())
 
 	// Test that Init returns an error if stdout does not have the right
 	// output format
 	mockInvokeAndWait([]byte("bad format"), nil)
-	err = root.Init(nil)
+	err = root.Init()
 	suite.Regexp(regexp.MustCompile("stdout"), err)
 
+	// Test that Init returns an error if the root does not implement
+	// "list"
+	mockInvokeAndWait([]byte("{\"name\":\"root\",\"methods\":[]}"), nil)
+	err = root.Init()
+	suite.Regexp("implement.*list", err)
+
 	// Test that Init properly decodes the root from stdout
-	stdout := "{}"
+	stdout := "{\"name\":\"foo\",\"methods\":[\"list\"]}"
 	mockInvokeAndWait([]byte(stdout), nil)
-	err = root.Init(nil)
+	err = root.Init()
 	if suite.NoError(err) {
 		expectedRoot := &externalPluginRoot{
 			externalPluginEntry: &externalPluginEntry{
-				EntryBase: NewEntryBase(),
-				methods:   []string{"list"},
-				script:    root.script,
+				EntryBase:        NewEntryBase(),
+				methods:          []string{"list"},
+				script:           root.script,
 			},
 		}
 		expectedRoot.SetName("foo")
@@ -60,24 +64,6 @@ func (suite *ExternalPluginRootTestSuite) TestInit() {
 	}
 }
 
-func (suite *ExternalPluginRootTestSuite) TestInitWithConfig() {
-	mockScript := &mockExternalPluginScript{path: "plugin_script"}
-	root := &externalPluginRoot{&externalPluginEntry{
-		EntryBase: NewEntryBase(),
-		script:    mockScript,
-	}}
-	root.SetName("foo")
-
-	mockScript.OnInvokeAndWait(
-		mock.Anything,
-		"init",
-		nil,
-		`{"key":["value"]}`,
-	).Return([]byte("{}"), nil).Once()
-
-	suite.NoError(root.Init(map[string]interface{}{"key": []string{"value"}}))
-}
-
 func TestExternalPluginRoot(t *testing.T) {
 	suite.Run(t, new(ExternalPluginRootTestSuite))
 }
diff --git a/plugin/registry_test.go b/plugin/registry_test.go
index d9fd81a..d3f4c24 100644
--- a/plugin/registry_test.go
+++ b/plugin/registry_test.go
@@ -23,8 +23,8 @@ type mockRoot struct {
 	mock.Mock
 }
 
-func (m *mockRoot) Init(cfg map[string]interface{}) error {
-	args := m.Called(cfg)
+func (m *mockRoot) Init() error {
+	args := m.Called()
 	return args.Error(0)
 }
 
@@ -59,22 +59,9 @@ func (suite *RegistryTestSuite) TestRegisterPlugin() {
 	reg := NewRegistry()
 	m := &mockRoot{EntryBase: NewEntryBase()}
 	m.SetName("mine")
-	cfg := map[string]interface{}{}
-	m.On("Init", cfg).Return(nil)
+	m.On("Init").Return(nil)
 
-	suite.NoError(reg.RegisterPlugin(m, cfg))
-	m.AssertExpectations(suite.T())
-	suite.Contains(reg.Plugins(), "mine")
-}
-
-func (suite *RegistryTestSuite) TestRegisterPluginWithConfig() {
-	reg := NewRegistry()
-	m := &mockRoot{EntryBase: NewEntryBase()}
-	m.SetName("mine")
-	cfg := map[string]interface{}{"key": "value"}
-	m.On("Init", cfg).Return(nil)
-
-	suite.NoError(reg.RegisterPlugin(m, cfg))
+	suite.NoError(reg.RegisterPlugin(m))
 	m.AssertExpectations(suite.T())
 	suite.Contains(reg.Plugins(), "mine")
 }
@@ -83,9 +70,9 @@ func (suite *RegistryTestSuite) TestRegisterPluginInitError() {
 	reg := NewRegistry()
 	m := &mockRoot{EntryBase: NewEntryBase()}
 	m.SetName("mine")
-	m.On("Init", map[string]interface{}(nil)).Return(errors.New("failed"))
+	m.On("Init").Return(errors.New("failed"))
 
-	suite.EqualError(reg.RegisterPlugin(m, nil), "failed")
+	suite.EqualError(reg.RegisterPlugin(m), "failed")
 	m.AssertExpectations(suite.T())
 	suite.NotContains(reg.Plugins(), "mine")
 }
@@ -95,7 +82,7 @@ func (suite *RegistryTestSuite) TestRegisterPluginInvalidPluginName() {
 		reg := NewRegistry()
 		m := &mockRoot{EntryBase: NewEntryBase()}
 		m.SetName("b@dname")
-		_ = reg.RegisterPlugin(m, map[string]interface{}{})
+		_ = reg.RegisterPlugin(m)
 	}
 
 	suite.Panics(
@@ -109,8 +96,8 @@ func (suite *RegistryTestSuite) TestRegisterPluginRegisteredPlugin() {
 		reg := NewRegistry()
 		m1 := &mockRoot{EntryBase: NewEntryBase()}
 		m1.SetName("mine")
-		_ = reg.RegisterPlugin(m1, map[string]interface{}{})
-		_ = reg.RegisterPlugin(m1, map[string]interface{}{})
+		_ = reg.RegisterPlugin(m1)
+		_ = reg.RegisterPlugin(m1)
 	}
 
 	suite.Panics(panicFunc, "r.RegisterPlugin: the mine plugin's already been registered")
@@ -120,40 +107,32 @@ func (suite *RegistryTestSuite) TestRegisterExternalPlugin() {
 	reg := NewRegistry()
 	spec := ExternalPluginSpec{Script: "testdata/external.sh"}
 
-	suite.NoError(reg.RegisterExternalPlugin(spec, map[string]interface{}{}))
-	suite.Contains(reg.Plugins(), "external")
-}
-
-func (suite *RegistryTestSuite) TestRegisterExternalPluginWithConfig() {
-	reg := NewRegistry()
-	spec := ExternalPluginSpec{Script: "testdata/external.sh"}
-
-	suite.NoError(reg.RegisterExternalPlugin(spec, map[string]interface{}{"key": "value"}))
-	suite.Contains(reg.Plugins(), "external")
+	suite.NoError(reg.RegisterExternalPlugin(spec))
+	suite.Contains(reg.Plugins(), "test")
 }
 
 func (suite *RegistryTestSuite) TestRegisterExternalPluginNoExec() {
 	reg := NewRegistry()
 	spec := ExternalPluginSpec{Script: "testdata/noexec"}
 
-	suite.EqualError(reg.RegisterExternalPlugin(spec, map[string]interface{}{}), "script testdata/noexec is not executable")
-	suite.NotContains(reg.Plugins(), "external")
+	suite.EqualError(reg.RegisterExternalPlugin(spec), "script testdata/noexec is not executable")
+	suite.NotContains(reg.Plugins(), "test")
 }
 
 func (suite *RegistryTestSuite) TestRegisterExternalPluginNoExist() {
 	reg := NewRegistry()
 	spec := ExternalPluginSpec{Script: "testdata/noexist"}
 
-	suite.EqualError(reg.RegisterExternalPlugin(spec, map[string]interface{}{}), "stat testdata/noexist: no such file or directory")
-	suite.NotContains(reg.Plugins(), "external")
+	suite.EqualError(reg.RegisterExternalPlugin(spec), "stat testdata/noexist: no such file or directory")
+	suite.NotContains(reg.Plugins(), "test")
 }
 
 func (suite *RegistryTestSuite) TestRegisterExternalPluginNotFile() {
 	reg := NewRegistry()
 	spec := ExternalPluginSpec{Script: "testdata/notfile"}
 
-	suite.EqualError(reg.RegisterExternalPlugin(spec, map[string]interface{}{}), "script testdata/notfile is not a file")
-	suite.NotContains(reg.Plugins(), "external")
+	suite.EqualError(reg.RegisterExternalPlugin(spec), "script testdata/notfile is not a file")
+	suite.NotContains(reg.Plugins(), "test")
 }
 
 func TestRegistry(t *testing.T) {
diff --git a/plugin/testdata/external.sh b/plugin/testdata/external.sh
index 3480488..effe8fe 100755
--- a/plugin/testdata/external.sh
+++ b/plugin/testdata/external.sh
@@ -1,2 +1,7 @@
 #!/bin/sh
-echo '{}'
\ No newline at end of file
+echo '{
+  "name": "test",
+  "methods": [
+    "list"
+  ]
+}'
\ No newline at end of file