feat: marketplace (#478)

Drew Smirnoff created

Change summary

README.md                             |  12 +
cli/config.go                         |  43 ++++
cli/install.go                        |  82 ++++++++
docs/docs/Features/CLI.md             |  68 ++++++
docs/docs/Features/Plugins.md         |  76 ++++++
docs/docs/setup-guides/ai-rewrite.md  |   7 
docs/docusaurus.config.ts             |   5 
docs/src/pages/marketplace.module.css | 114 +++++++++++
docs/src/pages/marketplace.tsx        | 112 +++++++++++
main.go                               |  35 +++
plugins/README.md                     |  93 +++++++++
plugins/embed.go                      |  68 ++++++
plugins/registry.json                 | 206 ++++++++++++++++++++
tui/README.md                         |   5 
tui/choice.go                         |   3 
tui/marketplace.go                    | 286 ++++++++++++++++++++++++++++
tui/messages.go                       |   3 
17 files changed, 1,205 insertions(+), 13 deletions(-)

Detailed changes

README.md 🔗

@@ -24,6 +24,18 @@
 
 ![Demo GIF](public/assets/demo.gif)
 
+### Plugin Marketplace
+
+Matcha has a built-in plugin system with 35+ community plugins. Browse and install them from the terminal or the [online marketplace](https://docs.matcha.floatpane.com/marketplace).
+
+```bash
+matcha marketplace                # browse plugins in the TUI
+matcha install <url_or_file>      # install a plugin
+matcha config <plugin_name>       # configure an installed plugin
+```
+
+Anyone can submit their own plugin — just add an entry to `plugins/registry.json` and open a PR. [Learn more](https://docs.matcha.floatpane.com/Features/Plugins#submit-your-plugin)
+
 ### AI Integration
 
 **AI Agent Support:** Matcha can be used by autonomous AI agents to send emails on your behalf. The `matcha send` CLI command provides a non-interactive interface for composing and sending emails.

cli/config.go 🔗

@@ -0,0 +1,43 @@
+package cli
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+)
+
+// RunConfig handles `matcha config [plugin_name]`.
+func RunConfig(args []string) error {
+	editor := os.Getenv("EDITOR")
+	if editor == "" {
+		editor = "vi"
+	}
+
+	home, err := os.UserHomeDir()
+	if err != nil {
+		return fmt.Errorf("cannot find home directory: %w", err)
+	}
+
+	var target string
+	if len(args) == 0 {
+		target = filepath.Join(home, ".config", "matcha", "config.json")
+	} else {
+		name := args[0]
+		// Add .lua extension if not present
+		if filepath.Ext(name) != ".lua" {
+			name = name + ".lua"
+		}
+		target = filepath.Join(home, ".config", "matcha", "plugins", name)
+	}
+
+	if _, err := os.Stat(target); os.IsNotExist(err) {
+		return fmt.Errorf("file not found: %s", target)
+	}
+
+	cmd := exec.Command(editor, target)
+	cmd.Stdin = os.Stdin
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+	return cmd.Run()
+}

cli/install.go 🔗

@@ -0,0 +1,82 @@
+package cli
+
+import (
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+)
+
+// RunInstall handles `matcha install <url_or_file>`.
+func RunInstall(args []string) error {
+	if len(args) == 0 {
+		return fmt.Errorf("usage: matcha install <url_or_file>")
+	}
+
+	source := args[0]
+	var data []byte
+	var filename string
+
+	if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") {
+		// Download from URL
+		client := &http.Client{Timeout: 30 * time.Second}
+		resp, err := client.Get(source)
+		if err != nil {
+			return fmt.Errorf("failed to download: %w", err)
+		}
+		defer resp.Body.Close()
+
+		if resp.StatusCode != http.StatusOK {
+			return fmt.Errorf("download returned status %d", resp.StatusCode)
+		}
+
+		data, err = io.ReadAll(resp.Body)
+		if err != nil {
+			return fmt.Errorf("failed to read response: %w", err)
+		}
+
+		// Extract filename from URL path
+		parts := strings.Split(strings.TrimRight(source, "/"), "/")
+		filename = parts[len(parts)-1]
+	} else {
+		// Read from local file
+		var err error
+		data, err = os.ReadFile(source)
+		if err != nil {
+			return fmt.Errorf("failed to read file: %w", err)
+		}
+		filename = filepath.Base(source)
+	}
+
+	if !strings.HasSuffix(filename, ".lua") {
+		return fmt.Errorf("plugin file must have a .lua extension")
+	}
+
+	pluginsDir, err := pluginsDir()
+	if err != nil {
+		return err
+	}
+
+	dest := filepath.Join(pluginsDir, filename)
+	if err := os.WriteFile(dest, data, 0644); err != nil {
+		return fmt.Errorf("failed to write plugin: %w", err)
+	}
+
+	fmt.Printf("Installed %s to %s\n", filename, dest)
+	return nil
+}
+
+func pluginsDir() (string, error) {
+	home, err := os.UserHomeDir()
+	if err != nil {
+		return "", fmt.Errorf("cannot find home directory: %w", err)
+	}
+	dir := filepath.Join(home, ".config", "matcha", "plugins")
+	if err := os.MkdirAll(dir, 0755); err != nil {
+		return "", fmt.Errorf("cannot create plugins directory: %w", err)
+	}
+	return dir, nil
+}

docs/docs/Features/CLI.md 🔗

@@ -88,6 +88,74 @@ matcha send --from work@company.com --to someone@example.com --subject "Hi" --bo
 | `0` | Email sent successfully |
 | `1` | Error (missing flags, bad config, send failure) |
 
+## matcha marketplace
+
+Open the interactive plugin marketplace in the terminal. Fetches the plugin registry from GitHub and displays a browsable list of available plugins.
+
+```bash
+matcha marketplace
+```
+
+Use `j/k` or arrow keys to navigate, `Enter` to install a plugin, and `q` to quit. Installed plugins are marked with an `[installed]` badge.
+
+You can also access the marketplace from Matcha's main menu, or browse the [online marketplace](https://docs.matcha.floatpane.com/marketplace).
+
+## matcha install
+
+Install a plugin from a URL or a local file.
+
+```bash
+matcha install <url_or_file>
+```
+
+### Examples
+
+**Install from the official plugin repository:**
+
+```bash
+matcha install https://raw.githubusercontent.com/floatpane/matcha/master/plugins/hello.lua
+```
+
+**Install from a third-party URL:**
+
+```bash
+matcha install https://raw.githubusercontent.com/someone/repo/main/my_plugin.lua
+```
+
+**Install from a local file:**
+
+```bash
+matcha install ~/Downloads/custom_plugin.lua
+```
+
+Plugins are saved to `~/.config/matcha/plugins/` and loaded automatically on next startup. The file must have a `.lua` extension.
+
+## matcha config
+
+Open a configuration file in your `$EDITOR` (falls back to `vi`).
+
+```bash
+matcha config [plugin_name]
+```
+
+### Examples
+
+**Open the main config file:**
+
+```bash
+matcha config
+```
+
+Opens `~/.config/matcha/config.json`.
+
+**Open a plugin for configuration:**
+
+```bash
+matcha config ai_rewrite
+```
+
+Opens `~/.config/matcha/plugins/ai_rewrite.lua` so you can edit settings like API keys or model names.
+
 ## matcha update
 
 Check for and install the latest version of Matcha.

docs/docs/Features/Plugins.md 🔗

@@ -315,9 +315,76 @@ end)
 | `cc`       | string | Current CC recipient(s)              |
 | `bcc`      | string | Current BCC recipient(s)             |
 
+## Marketplace
+
+Matcha includes a built-in plugin marketplace with 35+ community plugins. You can browse and install plugins from the terminal or from the [online marketplace](/marketplace).
+
+### Browse Plugins
+
+Open the interactive TUI marketplace:
+
+```bash
+matcha marketplace
+```
+
+Use `j/k` or arrow keys to navigate, `Enter` to install a plugin, and `q` to quit. You can also access it from Matcha's main menu.
+
+### Install a Plugin
+
+Install from the marketplace or directly by URL:
+
+```bash
+matcha install https://raw.githubusercontent.com/floatpane/matcha/master/plugins/hello.lua
+```
+
+Install from a local file:
+
+```bash
+matcha install path/to/my_plugin.lua
+```
+
+Plugins are saved to `~/.config/matcha/plugins/` and loaded on next startup.
+
+### Configure a Plugin
+
+Open an installed plugin in your editor to change its settings:
+
+```bash
+matcha config hello          # opens ~/.config/matcha/plugins/hello.lua
+matcha config                # opens ~/.config/matcha/config.json
+```
+
+### Submit Your Plugin
+
+Anyone can add their plugin to the Matcha marketplace by submitting a pull request to the [matcha repository](https://github.com/floatpane/matcha).
+
+1. Write your plugin as a `.lua` file following the API documented on this page.
+
+2. Add an entry to [`plugins/registry.json`](https://github.com/floatpane/matcha/blob/master/plugins/registry.json):
+
+   ```json
+   {
+     "name": "my_plugin",
+     "title": "My Plugin",
+     "description": "A short description of what your plugin does.",
+     "file": "my_plugin.lua",
+     "url": "https://raw.githubusercontent.com/YOUR_USER/YOUR_REPO/main/my_plugin.lua"
+   }
+   ```
+
+   The `url` field points to where your plugin file is hosted. If you include the `.lua` file directly in the Matcha repo, you can omit `url` and it will default to the `plugins/` directory.
+
+3. Submit your pull request. Once merged, your plugin will appear in the TUI marketplace, the CLI, and the [online marketplace](/marketplace).
+
+**Guidelines:**
+- Keep plugins focused — one plugin, one purpose.
+- Include a comment header in your `.lua` file with a description.
+- Test your plugin with the latest version of Matcha before submitting.
+- Plugins run in a sandboxed environment — no external dependencies are available.
+
 ## Example Plugins
 
-Example plugins are included in the repository under `examples/plugins/`:
+The repository includes 35+ example plugins. Here are a few to get started:
 
 | Plugin               | Description                                  |
 | -------------------- | -------------------------------------------- |
@@ -331,12 +398,7 @@ Example plugins are included in the repository under `examples/plugins/`:
 | `weather_status.lua` | Shows current weather in the inbox status bar |
 | `ai_rewrite.lua`   | AI-powered email rewriting in the composer     |
 
-To try one, copy it to your plugins directory:
-
-```bash
-mkdir -p ~/.config/matcha/plugins
-cp examples/plugins/hello.lua ~/.config/matcha/plugins/
-```
+Browse the full list in the [Plugin Marketplace](/marketplace) or run `matcha marketplace`.
 
 ## Security
 

docs/docs/setup-guides/ai-rewrite.md 🔗

@@ -7,14 +7,13 @@ sidebar_position: 3
 
 Matcha includes an `ai_rewrite.lua` plugin that allows you to rewrite email drafts using an AI model. By default, it works with any OpenAI-compatible API.
 
-To get started, copy the plugin to your configuration directory:
+To get started, install the plugin:
 
 ```bash
-mkdir -p ~/.config/matcha/plugins
-cp plugins/ai_rewrite.lua ~/.config/matcha/plugins/
+matcha install https://raw.githubusercontent.com/floatpane/matcha/master/plugins/ai_rewrite.lua
 ```
 
-You can then edit `~/.config/matcha/plugins/ai_rewrite.lua` to configure it for your preferred AI provider. Since the plugin relies on the OpenAI chat completions format, it seamlessly integrates with OpenAI, local providers like Ollama, and other services that offer an OpenAI-compatible endpoint (like Gemini). For providers without native OpenAI compatibility (like Claude), you can use a local proxy like [LiteLLM](https://github.com/BerriAI/litellm).
+You can then edit `~/.config/matcha/plugins/ai_rewrite.lua` to configure it for your preferred AI provider (`matcha config ai_rewrite`). Since the plugin relies on the OpenAI chat completions format, it seamlessly integrates with OpenAI, local providers like Ollama, and other services that offer an OpenAI-compatible endpoint (like Gemini). For providers without native OpenAI compatibility (like Claude), you can use a local proxy like [LiteLLM](https://github.com/BerriAI/litellm).
 
 Here are the configuration snippets for various popular AI providers. Update the variables at the top of your `ai_rewrite.lua` file.
 

docs/docusaurus.config.ts 🔗

@@ -51,6 +51,11 @@ const config: Config = {
         src: "img/logo.png", // We will update this later or rely on the text
       },
       items: [
+        {
+          to: "/marketplace",
+          label: "Marketplace",
+          position: "left",
+        },
         {
           href: "https://github.com/floatpane/matcha",
           label: "GitHub",

docs/src/pages/marketplace.module.css 🔗

@@ -0,0 +1,114 @@
+.marketplace {
+    padding: 2rem 0;
+    max-width: 1200px;
+    margin: 0 auto;
+}
+
+.header {
+    text-align: center;
+    margin-bottom: 2rem;
+}
+
+.header h1 {
+    font-size: 2.5rem;
+    margin-bottom: 0.5rem;
+}
+
+.header p {
+    color: var(--ifm-color-emphasis-600);
+    font-size: 1.1rem;
+}
+
+.grid {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
+    gap: 1.5rem;
+    padding: 0 1rem;
+}
+
+.card {
+    border: 1px solid var(--ifm-color-emphasis-200);
+    border-radius: 8px;
+    padding: 1.5rem;
+    transition:
+        border-color 0.2s,
+        box-shadow 0.2s;
+}
+
+.card:hover {
+    border-color: var(--ifm-color-primary);
+    box-shadow: 0 2px 12px rgba(74, 222, 128, 0.15);
+}
+
+.cardTitle {
+    font-size: 1.15rem;
+    font-weight: 600;
+    color: var(--ifm-color-primary);
+    margin: 0 0 0.5rem 0;
+}
+
+.cardDescription {
+    color: var(--ifm-color-emphasis-700);
+    font-size: 0.9rem;
+    margin-bottom: 1rem;
+    line-height: 1.5;
+}
+
+.installRow {
+    display: flex;
+    align-items: center;
+    gap: 0.5rem;
+}
+
+.installCommand {
+    background: var(--ifm-color-emphasis-100);
+    border-radius: 4px;
+    padding: 0.5rem 0.75rem;
+    font-family: var(--ifm-font-family-monospace);
+    font-size: 0.75rem;
+    overflow-x: auto;
+    white-space: nowrap;
+    color: var(--ifm-color-emphasis-800);
+    flex: 1;
+    min-width: 0;
+}
+
+.copyButton {
+    background: transparent;
+    color: var(--ifm-color-emphasis-600);
+    border: 1px solid var(--ifm-color-emphasis-300);
+    border-radius: 4px;
+    padding: 0.4rem 0.75rem;
+    font-size: 0.7rem;
+    cursor: pointer;
+    white-space: nowrap;
+    flex-shrink: 0;
+    transition:
+        color 0.2s,
+        border-color 0.2s,
+        background 0.2s;
+}
+
+.copyButton:hover {
+    color: var(--ifm-color-primary);
+    border-color: var(--ifm-color-primary);
+    background: var(--ifm-color-emphasis-100);
+}
+
+.installLabel {
+    font-size: 0.75rem;
+    color: var(--ifm-color-emphasis-500);
+    margin-bottom: 0.25rem;
+}
+
+.count {
+    text-align: center;
+    color: var(--ifm-color-emphasis-500);
+    margin-bottom: 1.5rem;
+}
+
+.error {
+    text-align: center;
+    color: var(--ifm-color-danger);
+    margin-bottom: 1.5rem;
+}

docs/src/pages/marketplace.tsx 🔗

@@ -0,0 +1,112 @@
+import React, { useState, useEffect } from "react";
+import Layout from "@theme/Layout";
+import styles from "./marketplace.module.css";
+
+interface Plugin {
+  name: string;
+  title: string;
+  description: string;
+  file: string;
+  url?: string;
+}
+
+const REGISTRY_URL =
+  "https://raw.githubusercontent.com/floatpane/matcha/master/plugins/registry.json";
+const RAW_BASE =
+  "https://raw.githubusercontent.com/floatpane/matcha/master/plugins/";
+
+function pluginUrl(plugin: Plugin): string {
+  return plugin.url || `${RAW_BASE}${plugin.file}`;
+}
+
+function installCmd(plugin: Plugin): string {
+  return `matcha install ${pluginUrl(plugin)}`;
+}
+
+function CopyButton({ text }: { text: string }) {
+  const [copied, setCopied] = useState(false);
+
+  const handleCopy = () => {
+    navigator.clipboard.writeText(text);
+    setCopied(true);
+    setTimeout(() => setCopied(false), 2000);
+  };
+
+  return (
+    <button
+      className={styles.copyButton}
+      onClick={handleCopy}
+      title="Copy to clipboard"
+      type="button"
+    >
+      {copied ? "Copied!" : "Copy"}
+    </button>
+  );
+}
+
+function PluginCard({ plugin }: { plugin: Plugin }) {
+  const cmd = installCmd(plugin);
+
+  return (
+    <div className={styles.card}>
+      <h3 className={styles.cardTitle}>{plugin.title}</h3>
+      <p className={styles.cardDescription}>{plugin.description}</p>
+      <div className={styles.installLabel}>Install:</div>
+      <div className={styles.installRow}>
+        <code className={styles.installCommand}>{cmd}</code>
+        <CopyButton text={cmd} />
+      </div>
+    </div>
+  );
+}
+
+export default function Marketplace(): React.JSX.Element {
+  const [plugins, setPlugins] = useState<Plugin[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<string | null>(null);
+
+  useEffect(() => {
+    fetch(REGISTRY_URL)
+      .then((res) => {
+        if (!res.ok)
+          throw new Error(`Failed to fetch registry (${res.status})`);
+        return res.json();
+      })
+      .then((data: Plugin[]) => {
+        setPlugins(data);
+        setLoading(false);
+      })
+      .catch((err) => {
+        setError(err.message);
+        setLoading(false);
+      });
+  }, []);
+
+  return (
+    <Layout
+      title="Plugin Marketplace"
+      description="Browse and install Matcha plugins"
+    >
+      <div className={styles.marketplace}>
+        <div className={styles.header}>
+          <h1>Plugin Marketplace</h1>
+          <p>
+            Browse community plugins for Matcha. Click install commands to copy.
+          </p>
+        </div>
+        {loading && <p className={styles.count}>Loading plugins...</p>}
+        {error && <p className={styles.error}>Error: {error}</p>}
+        {!loading && !error && (
+          <>
+            <p className={styles.count}>{plugins.length} plugins available</p>
+            <div className={styles.grid}>
+              {plugins.map((plugin) => (
+                <PluginCard key={plugin.name} plugin={plugin} />
+              ))}
+            </div>
+          </>
+        )}
+      </div>
+    </Layout>
+  );
+}

main.go 🔗

@@ -26,6 +26,7 @@ import (
 	_ "github.com/floatpane/matcha/backend/imap"
 	_ "github.com/floatpane/matcha/backend/jmap"
 	_ "github.com/floatpane/matcha/backend/pop3"
+	matchaCli "github.com/floatpane/matcha/cli"
 	"github.com/floatpane/matcha/clib"
 	"github.com/floatpane/matcha/config"
 	"github.com/floatpane/matcha/fetcher"
@@ -762,6 +763,11 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.current, cmd = m.current.Update(tui.DraftDeletedMsg{DraftID: msg.DraftID})
 		return m, cmd
 
+	case tui.GoToMarketplaceMsg:
+		m.current = tui.NewMarketplace(false)
+		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+		return m, m.current.Init()
+
 	case tui.GoToSettingsMsg:
 		m.current = tui.NewSettings(m.config)
 		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
@@ -2830,6 +2836,35 @@ func main() {
 		os.Exit(0)
 	}
 
+	// Install plugin CLI subcommand: matcha install <url_or_file>
+	if len(os.Args) > 1 && os.Args[1] == "install" {
+		if err := matchaCli.RunInstall(os.Args[2:]); err != nil {
+			fmt.Fprintf(os.Stderr, "install failed: %v\n", err)
+			os.Exit(1)
+		}
+		os.Exit(0)
+	}
+
+	// Config CLI subcommand: matcha config [plugin_name]
+	if len(os.Args) > 1 && os.Args[1] == "config" {
+		if err := matchaCli.RunConfig(os.Args[2:]); err != nil {
+			fmt.Fprintf(os.Stderr, "config failed: %v\n", err)
+			os.Exit(1)
+		}
+		os.Exit(0)
+	}
+
+	// Marketplace TUI subcommand: matcha marketplace
+	if len(os.Args) > 1 && os.Args[1] == "marketplace" {
+		mp := tui.NewMarketplace(true)
+		p := tea.NewProgram(mp)
+		if _, err := p.Run(); err != nil {
+			fmt.Fprintf(os.Stderr, "marketplace failed: %v\n", err)
+			os.Exit(1)
+		}
+		os.Exit(0)
+	}
+
 	cfg, err := config.LoadConfig()
 	if err == nil && cfg.Theme != "" {
 		theme.SetTheme(cfg.Theme)

plugins/README.md 🔗

@@ -0,0 +1,93 @@
+# Matcha Plugin Marketplace
+
+This directory contains the official Matcha plugin collection and the plugin registry that powers the marketplace.
+
+## Installing Plugins
+
+### From the TUI Marketplace
+
+Browse and install plugins interactively:
+
+```bash
+matcha marketplace
+```
+
+Use `j/k` or arrow keys to navigate, `Enter` to install, and `q` to quit. You can also access the marketplace from Matcha's main menu.
+
+### From a URL
+
+```bash
+matcha install https://raw.githubusercontent.com/floatpane/matcha/master/plugins/hello.lua
+```
+
+### From a Local File
+
+```bash
+matcha install path/to/my_plugin.lua
+```
+
+### Using curl
+
+```bash
+curl -sL https://raw.githubusercontent.com/floatpane/matcha/master/plugins/hello.lua \
+  -o ~/.config/matcha/plugins/hello.lua
+```
+
+Plugins are installed to `~/.config/matcha/plugins/` and loaded automatically on next startup.
+
+## Configuring Plugins
+
+Open a plugin file in your editor to configure it:
+
+```bash
+matcha config hello          # opens ~/.config/matcha/plugins/hello.lua in $EDITOR
+matcha config                # opens ~/.config/matcha/config.json in $EDITOR
+```
+
+## Submitting Your Plugin
+
+Anyone can add their plugin to the marketplace by submitting a PR to this repository.
+
+### Steps
+
+1. **Write your plugin** as a `.lua` file following the [Plugin API docs](https://docs.matcha.floatpane.com/Features/Plugins).
+
+2. **Add an entry to `registry.json`** in this directory:
+
+   ```json
+   {
+     "name": "my_plugin",
+     "title": "My Plugin",
+     "description": "A short description of what your plugin does.",
+     "file": "my_plugin.lua",
+     "url": "https://raw.githubusercontent.com/YOUR_USER/YOUR_REPO/main/my_plugin.lua"
+   }
+   ```
+
+3. **Submit a pull request** to [floatpane/matcha](https://github.com/floatpane/matcha).
+
+### Registry Fields
+
+| Field         | Required | Description                                                                 |
+|---------------|----------|-----------------------------------------------------------------------------|
+| `name`        | yes      | Unique identifier (lowercase, underscores). Must match the filename without `.lua`. |
+| `title`       | yes      | Human-readable name shown in the marketplace.                               |
+| `description` | yes      | One or two sentences describing what the plugin does.                       |
+| `file`        | yes      | The `.lua` filename that gets saved to the user's plugins directory.        |
+| `url`         | no       | Direct download URL for the plugin file. If omitted, defaults to this repo's `plugins/` directory. |
+
+### Guidelines
+
+- Keep plugins focused — one plugin, one purpose.
+- Include a comment header in your `.lua` file with a description:
+  ```lua
+  -- my_plugin.lua
+  -- A short description of what this plugin does.
+  ```
+- Test your plugin with the latest version of Matcha before submitting.
+- Do not include external dependencies — plugins run in a sandboxed Lua environment.
+- The `url` field should point to a raw file URL that stays stable (use a tagged release or `main` branch).
+
+## Browsing Online
+
+Visit the [Plugin Marketplace](https://docs.matcha.floatpane.com/marketplace) on the Matcha documentation site to browse all available plugins with one-line install commands.

plugins/embed.go 🔗

@@ -0,0 +1,68 @@
+package plugins
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"time"
+)
+
+const RegistryURL = "https://raw.githubusercontent.com/floatpane/matcha/master/plugins/registry.json"
+const RawPluginBaseURL = "https://raw.githubusercontent.com/floatpane/matcha/master/plugins/"
+
+// PluginEntry represents a single plugin in the registry.
+type PluginEntry struct {
+	Name        string `json:"name"`
+	Title       string `json:"title"`
+	Description string `json:"description"`
+	File        string `json:"file"`
+	URL         string `json:"url,omitempty"`
+}
+
+// FetchRegistry fetches the plugin registry from GitHub.
+func FetchRegistry() ([]PluginEntry, error) {
+	client := &http.Client{Timeout: 10 * time.Second}
+	resp, err := client.Get(RegistryURL)
+	if err != nil {
+		return nil, fmt.Errorf("failed to fetch registry: %w", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("registry returned status %d", resp.StatusCode)
+	}
+
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, fmt.Errorf("failed to read registry: %w", err)
+	}
+
+	var entries []PluginEntry
+	if err := json.Unmarshal(body, &entries); err != nil {
+		return nil, fmt.Errorf("failed to parse registry: %w", err)
+	}
+	return entries, nil
+}
+
+// FetchPlugin downloads a plugin file. If the entry has a URL, it downloads
+// from there; otherwise it falls back to the default repo location.
+func FetchPlugin(entry PluginEntry) ([]byte, error) {
+	url := entry.URL
+	if url == "" {
+		url = RawPluginBaseURL + entry.File
+	}
+
+	client := &http.Client{Timeout: 10 * time.Second}
+	resp, err := client.Get(url)
+	if err != nil {
+		return nil, fmt.Errorf("failed to fetch plugin: %w", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("plugin download returned status %d", resp.StatusCode)
+	}
+
+	return io.ReadAll(resp.Body)
+}

plugins/registry.json 🔗

@@ -0,0 +1,206 @@
+[
+  {
+    "name": "account_indicator",
+    "title": "Account Indicator",
+    "description": "Shows which account received the email you're currently viewing.",
+    "file": "account_indicator.lua"
+  },
+  {
+    "name": "ai_rewrite",
+    "title": "AI Rewrite",
+    "description": "Rewrites the email body using an AI model. Press ctrl+r in the composer to open the prompt overlay. Works with any OpenAI-compatible API.",
+    "file": "ai_rewrite.lua"
+  },
+  {
+    "name": "attachment_reminder",
+    "title": "Attachment Reminder",
+    "description": "Warns if your email body mentions an attachment but you might have forgotten to attach it.",
+    "file": "attachment_reminder.lua"
+  },
+  {
+    "name": "auto_bcc",
+    "title": "Auto BCC",
+    "description": "Automatically adds a BCC address to every email you compose.",
+    "file": "auto_bcc.lua"
+  },
+  {
+    "name": "char_counter",
+    "title": "Char Counter",
+    "description": "Shows a live character count in the composer help bar.",
+    "file": "char_counter.lua"
+  },
+  {
+    "name": "domain_filter",
+    "title": "Domain Filter",
+    "description": "Highlights emails from specific domains with a notification.",
+    "file": "domain_filter.lua"
+  },
+  {
+    "name": "email_age",
+    "title": "Email Age",
+    "description": "Shows the date of the email you're viewing in the status bar.",
+    "file": "email_age.lua"
+  },
+  {
+    "name": "empty_body_guard",
+    "title": "Empty Body Guard",
+    "description": "Warns before sending an email with an empty body.",
+    "file": "empty_body_guard.lua"
+  },
+  {
+    "name": "folder_announcer",
+    "title": "Folder Announcer",
+    "description": "Shows a brief notification when you switch folders.",
+    "file": "folder_announcer.lua"
+  },
+  {
+    "name": "folder_favorites",
+    "title": "Folder Favorites",
+    "description": "Tracks your most visited folders and logs the top 3 on shutdown.",
+    "file": "folder_favorites.lua"
+  },
+  {
+    "name": "greeting",
+    "title": "Greeting",
+    "description": "Shows a random motivational greeting on startup.",
+    "file": "greeting.lua"
+  },
+  {
+    "name": "hello",
+    "title": "Hello",
+    "description": "A minimal example plugin that logs lifecycle events.",
+    "file": "hello.lua"
+  },
+  {
+    "name": "inbox_activity",
+    "title": "Inbox Activity",
+    "description": "Shows a live activity indicator in the inbox status bar with received, read, and sent counts.",
+    "file": "inbox_activity.lua"
+  },
+  {
+    "name": "keyword_highlighter",
+    "title": "Keyword Highlighter",
+    "description": "Notifies you when incoming emails contain specific keywords.",
+    "file": "keyword_highlighter.lua"
+  },
+  {
+    "name": "notify_github",
+    "title": "Notify GitHub",
+    "description": "Shows a notification when emails from GitHub arrive.",
+    "file": "notify_github.lua"
+  },
+  {
+    "name": "quick_label",
+    "title": "Quick Label",
+    "description": "Demonstrates custom keyboard shortcuts with matcha.bind_key(). Press ctrl+i in the inbox to show the selected email's subject.",
+    "file": "quick_label.lua"
+  },
+  {
+    "name": "reading_time",
+    "title": "Reading Time",
+    "description": "Estimates reading time based on word count while composing.",
+    "file": "reading_time.lua"
+  },
+  {
+    "name": "read_tracker",
+    "title": "Read Tracker",
+    "description": "Displays a running count of emails you've read this session.",
+    "file": "read_tracker.lua"
+  },
+  {
+    "name": "recipient_counter",
+    "title": "Recipient Counter",
+    "description": "Shows the number of recipients in the composer status bar.",
+    "file": "recipient_counter.lua"
+  },
+  {
+    "name": "reply_all_warn",
+    "title": "Reply All Warn",
+    "description": "Warns when sending to many recipients (possible accidental reply-all).",
+    "file": "reply_all_warn.lua"
+  },
+  {
+    "name": "self_email_warn",
+    "title": "Self Email Warn",
+    "description": "Notifies you when you receive an email you sent to yourself.",
+    "file": "self_email_warn.lua"
+  },
+  {
+    "name": "sender_frequency",
+    "title": "Sender Frequency",
+    "description": "Tracks how many emails each sender sends you and shows repeat senders.",
+    "file": "sender_frequency.lua"
+  },
+  {
+    "name": "send_logger",
+    "title": "Send Logger",
+    "description": "Logs every email you send for personal record-keeping.",
+    "file": "send_logger.lua"
+  },
+  {
+    "name": "session_stats",
+    "title": "Session Stats",
+    "description": "Tracks emails received, read, and sent during your session.",
+    "file": "session_stats.lua"
+  },
+  {
+    "name": "spam_detector",
+    "title": "Spam Detector",
+    "description": "Flags incoming emails that match common spam patterns.",
+    "file": "spam_detector.lua"
+  },
+  {
+    "name": "subject_length_warn",
+    "title": "Subject Length Warn",
+    "description": "Warns when your subject line is getting too long.",
+    "file": "subject_length_warn.lua"
+  },
+  {
+    "name": "subject_reminder",
+    "title": "Subject Reminder",
+    "description": "Warns you if you're composing an email without a subject line.",
+    "file": "subject_reminder.lua"
+  },
+  {
+    "name": "thread_tracker",
+    "title": "Thread Tracker",
+    "description": "Tracks how many replies and forwards you receive per session.",
+    "file": "thread_tracker.lua"
+  },
+  {
+    "name": "ultimate_plugin",
+    "title": "Ultimate Plugin",
+    "description": "A comprehensive demo plugin showcasing all available Matcha plugin APIs.",
+    "file": "ultimate_plugin.lua"
+  },
+  {
+    "name": "unread_counter",
+    "title": "Unread Counter",
+    "description": "Displays unread count in the inbox title bar.",
+    "file": "unread_counter.lua"
+  },
+  {
+    "name": "vip_alerts",
+    "title": "VIP Alerts",
+    "description": "Shows prominent notifications for emails from important senders.",
+    "file": "vip_alerts.lua"
+  },
+  {
+    "name": "weather_status",
+    "title": "Weather Status",
+    "description": "Fetches current weather and displays it in the inbox status bar.",
+    "file": "weather_status.lua"
+  },
+  {
+    "name": "webhook_notify",
+    "title": "Webhook Notify",
+    "description": "Posts a JSON payload to a webhook URL when an email is received.",
+    "file": "webhook_notify.lua"
+  },
+  {
+    "name": "word_counter",
+    "title": "Word Counter",
+    "description": "Shows a live word count in the composer help bar.",
+    "file": "word_counter.lua"
+  }
+]

tui/README.md 🔗

@@ -19,7 +19,8 @@ The TUI layer is the interactive frontend of Matcha. Each view implements the `t
 | `login.go` | Account login form supporting Gmail, iCloud, and custom IMAP/SMTP providers. Collects credentials, server settings, and optionally S/MIME certificate paths. Validates input before submission. |
 | `settings.go` | Settings panel for managing accounts (add/remove), configuring mailing lists, editing signatures, toggling image display, managing tips visibility, and setting up S/MIME certificates. |
 | `mailing_list.go` | Editor for creating and modifying mailing list groups (name + comma-separated email addresses). |
-| `choice.go` | Main menu / start screen. Presents account selection, navigation to inbox, compose, drafts, sent, folders, trash/archive, and settings. |
+| `choice.go` | Main menu / start screen. Presents account selection, navigation to inbox, compose, drafts, marketplace, sent, folders, trash/archive, and settings. |
+| `marketplace.go` | Plugin marketplace browser. Fetches the plugin registry from GitHub, displays a scrollable list with install status badges, and installs plugins on selection. Can run standalone (`matcha marketplace`) or from the main menu. |
 | `filepicker.go` | File browser for selecting email attachments. Navigates the filesystem with directory listing and file selection. |
 
 ### Supporting Files
@@ -28,7 +29,7 @@ The TUI layer is the interactive frontend of Matcha. Each view implements the `t
 |------|-------------|
 | `styles.go` | Global Lip Gloss style definitions used across all views (dialog boxes, help text, tips, headings, body text). Also defines the `Status` component for spinner-based loading messages. |
 | `theme.go` | `RebuildStyles` function that updates all package-level style variables when the active theme changes, ensuring consistent colors across the UI. |
-| `messages.go` | Shared message types for inter-component communication: `ViewEmailMsg`, `SendEmailMsg`, `Credentials`, `ChooseServiceMsg`, `EmailResultMsg`, `ClearStatusMsg`, and the `MailboxKind` enum. |
+| `messages.go` | Shared message types for inter-component communication: `ViewEmailMsg`, `SendEmailMsg`, `Credentials`, `ChooseServiceMsg`, `EmailResultMsg`, `ClearStatusMsg`, `GoToMarketplaceMsg`, and the `MailboxKind` enum. |
 | `signature.go` | Textarea-based editor for composing and saving email signatures. |
 
 ### Test Files

tui/choice.go 🔗

@@ -47,6 +47,7 @@ func NewChoice() Choice {
 	if hasSavedDrafts {
 		choices = append(choices, "\uec0e Drafts")
 	}
+	choices = append(choices, "\uf487 Marketplace")
 	choices = append(choices, "\uf013 Settings")
 	return Choice{
 		choices:         choices,
@@ -87,6 +88,8 @@ func (m Choice) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				return m, func() tea.Msg { return GoToSendMsg{} }
 			case "\uec0e Drafts":
 				return m, func() tea.Msg { return GoToDraftsMsg{} }
+			case "\uf487 Marketplace":
+				return m, func() tea.Msg { return GoToMarketplaceMsg{} }
 			case "\uf013 Settings":
 				return m, func() tea.Msg { return GoToSettingsMsg{} }
 			}

tui/marketplace.go 🔗

@@ -0,0 +1,286 @@
+package tui
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/floatpane/matcha/plugins"
+	"github.com/floatpane/matcha/theme"
+)
+
+var (
+	mpTitleStyle = lipgloss.NewStyle().
+			Foreground(lipgloss.Color("#FFFDF5")).
+			Background(lipgloss.Color("#25A065")).
+			Padding(0, 1)
+
+	mpItemNameStyle = lipgloss.NewStyle().
+			Foreground(lipgloss.Color("42")).
+			Bold(true)
+
+	mpItemDescStyle = lipgloss.NewStyle().
+			Foreground(lipgloss.Color("245"))
+
+	mpInstalledStyle = lipgloss.NewStyle().
+				Foreground(lipgloss.Color("35"))
+
+	mpSelectedStyle = lipgloss.NewStyle().
+			Foreground(lipgloss.Color("42")).
+			Bold(true)
+
+	mpCursorStyle = lipgloss.NewStyle().
+			Foreground(lipgloss.Color("42"))
+
+	mpStatusStyle = lipgloss.NewStyle().
+			Foreground(lipgloss.Color("214"))
+)
+
+type marketplaceState int
+
+const (
+	marketplaceLoading marketplaceState = iota
+	marketplaceReady
+	marketplaceError
+)
+
+// RegistryFetchedMsg signals that the plugin registry was fetched.
+type RegistryFetchedMsg struct {
+	Entries []plugins.PluginEntry
+	Err     error
+}
+
+// PluginInstalledMsg signals that a plugin was installed from the marketplace.
+type PluginInstalledMsg struct {
+	Name string
+	Err  error
+}
+
+type Marketplace struct {
+	entries    []plugins.PluginEntry
+	installed  map[string]bool
+	cursor     int
+	offset     int // scroll offset
+	width      int
+	height     int
+	state      marketplaceState
+	errMsg     string
+	status     string // transient status message
+	standalone bool   // true when launched via `matcha marketplace` (not from main menu)
+}
+
+func NewMarketplace(standalone bool) Marketplace {
+	return Marketplace{
+		installed:  installedPlugins(),
+		standalone: standalone,
+	}
+}
+
+func (m Marketplace) Init() tea.Cmd {
+	return fetchRegistry
+}
+
+func fetchRegistry() tea.Msg {
+	entries, err := plugins.FetchRegistry()
+	return RegistryFetchedMsg{Entries: entries, Err: err}
+}
+
+func (m Marketplace) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		m.width = msg.Width
+		m.height = msg.Height
+		return m, nil
+
+	case RegistryFetchedMsg:
+		if msg.Err != nil {
+			m.state = marketplaceError
+			m.errMsg = msg.Err.Error()
+			return m, nil
+		}
+		m.entries = msg.Entries
+		m.state = marketplaceReady
+		return m, nil
+
+	case PluginInstalledMsg:
+		if msg.Err != nil {
+			m.status = fmt.Sprintf("Failed to install %s: %v", msg.Name, msg.Err)
+		} else {
+			m.status = fmt.Sprintf("Installed %s", msg.Name)
+			m.installed[msg.Name] = true
+		}
+		return m, nil
+
+	case tea.KeyPressMsg:
+		if m.state != marketplaceReady {
+			if msg.String() == "q" || msg.String() == "esc" || msg.String() == "ctrl+c" {
+				if m.standalone {
+					return m, tea.Quit
+				}
+				return m, func() tea.Msg { return GoToChoiceMenuMsg{} }
+			}
+			return m, nil
+		}
+
+		switch msg.String() {
+		case "q", "esc":
+			if m.standalone {
+				return m, tea.Quit
+			}
+			return m, func() tea.Msg { return GoToChoiceMenuMsg{} }
+		case "ctrl+c":
+			return m, tea.Quit
+		case "up", "k":
+			if m.cursor > 0 {
+				m.cursor--
+				if m.cursor < m.offset {
+					m.offset = m.cursor
+				}
+			}
+		case "down", "j":
+			if m.cursor < len(m.entries)-1 {
+				m.cursor++
+				visible := m.visibleRows()
+				if m.cursor >= m.offset+visible {
+					m.offset = m.cursor - visible + 1
+				}
+			}
+		case "enter":
+			if m.cursor < len(m.entries) {
+				entry := m.entries[m.cursor]
+				if m.installed[entry.Name] {
+					m.status = fmt.Sprintf("%s is already installed", entry.Name)
+					return m, nil
+				}
+				m.status = fmt.Sprintf("Installing %s...", entry.Name)
+				return m, installPlugin(entry)
+			}
+		}
+	}
+	return m, nil
+}
+
+func (m Marketplace) visibleRows() int {
+	// Each entry takes 2 lines (name + description), plus header/footer
+	available := m.height - 8 // header + footer + padding
+	if available < 1 {
+		return 1
+	}
+	return available / 2
+}
+
+func installPlugin(entry plugins.PluginEntry) tea.Cmd {
+	return func() tea.Msg {
+		data, err := plugins.FetchPlugin(entry)
+		if err != nil {
+			return PluginInstalledMsg{Name: entry.Name, Err: err}
+		}
+
+		home, err := os.UserHomeDir()
+		if err != nil {
+			return PluginInstalledMsg{Name: entry.Name, Err: err}
+		}
+
+		dir := filepath.Join(home, ".config", "matcha", "plugins")
+		if err := os.MkdirAll(dir, 0755); err != nil {
+			return PluginInstalledMsg{Name: entry.Name, Err: err}
+		}
+
+		dest := filepath.Join(dir, entry.File)
+		if err := os.WriteFile(dest, data, 0644); err != nil {
+			return PluginInstalledMsg{Name: entry.Name, Err: err}
+		}
+
+		return PluginInstalledMsg{Name: entry.Name}
+	}
+}
+
+func installedPlugins() map[string]bool {
+	installed := make(map[string]bool)
+	home, err := os.UserHomeDir()
+	if err != nil {
+		return installed
+	}
+	dir := filepath.Join(home, ".config", "matcha", "plugins")
+	entries, err := os.ReadDir(dir)
+	if err != nil {
+		return installed
+	}
+	for _, e := range entries {
+		if !e.IsDir() && strings.HasSuffix(e.Name(), ".lua") {
+			name := strings.TrimSuffix(e.Name(), ".lua")
+			installed[name] = true
+		}
+	}
+	return installed
+}
+
+func (m Marketplace) View() tea.View {
+	var b strings.Builder
+
+	accentStyle := lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent)
+	b.WriteString(accentStyle.Render(choiceLogo))
+	b.WriteString("\n")
+	b.WriteString(mpTitleStyle.Render(" Plugin Marketplace "))
+	b.WriteString("\n\n")
+
+	switch m.state {
+	case marketplaceLoading:
+		b.WriteString("  Fetching plugins...\n")
+	case marketplaceError:
+		errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
+		b.WriteString(errStyle.Render(fmt.Sprintf("  Error: %s", m.errMsg)))
+		b.WriteString("\n")
+	case marketplaceReady:
+		visible := m.visibleRows()
+		end := m.offset + visible
+		if end > len(m.entries) {
+			end = len(m.entries)
+		}
+
+		for i := m.offset; i < end; i++ {
+			entry := m.entries[i]
+			cursor := "  "
+			nameStyle := mpItemNameStyle
+			if i == m.cursor {
+				cursor = mpCursorStyle.Render("> ")
+				nameStyle = mpSelectedStyle
+			}
+
+			name := nameStyle.Render(entry.Title)
+			if m.installed[entry.Name] {
+				name += " " + mpInstalledStyle.Render("[installed]")
+			}
+
+			b.WriteString(fmt.Sprintf("%s%s\n", cursor, name))
+			b.WriteString(fmt.Sprintf("    %s\n", mpItemDescStyle.Render(entry.Description)))
+		}
+
+		if len(m.entries) > visible {
+			b.WriteString(fmt.Sprintf("\n  %d/%d plugins", m.cursor+1, len(m.entries)))
+		}
+	}
+
+	if m.status != "" {
+		b.WriteString("\n")
+		b.WriteString(mpStatusStyle.Render("  " + m.status))
+	}
+
+	mainContent := b.String()
+	help := helpStyle.Render("↑/↓ navigate • enter install • q back")
+
+	if m.height > 0 {
+		currentHeight := lipgloss.Height(DocStyle.Render(mainContent + "\n" + help))
+		gap := m.height - currentHeight
+		if gap > 0 {
+			mainContent += strings.Repeat("\n", gap)
+		}
+	} else {
+		mainContent += "\n\n"
+	}
+
+	return tea.NewView(DocStyle.Render(mainContent + "\n" + help))
+}

tui/messages.go 🔗

@@ -452,3 +452,6 @@ type PluginPromptSubmitMsg struct {
 
 // PluginPromptCancelMsg signals that the user cancelled a plugin prompt input.
 type PluginPromptCancelMsg struct{}
+
+// GoToMarketplaceMsg signals navigation to the plugin marketplace.
+type GoToMarketplaceMsg struct{}