Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,20 @@ gw preset list # list presets (compact)
gw preset show backend # show preset details
gw preset remove backend
gw create my-feature -p backend # use a preset instead of -r

# Plugins — extend gw with external commands
gw plugin install nicksenap/gw-dash # install from GitHub
gw plugin list # list installed plugins
gw plugin upgrade # upgrade all plugins
gw plugin remove dash # uninstall a plugin
```

All interactive menus support **type-to-search** filtering, arrow-key navigation (single-select), or arrow + tab (multi-select) with an `(all)` shortcut.

## Documentation

- [Per-repo config & hooks](docs/hooks.md) — `.grove.toml`, lifecycle hooks, `gw run`
- [Plugins](docs/plugins.md) — extend gw with external commands
- [Agent dashboard](docs/dashboard.md) — `gw dash`, Zellij integration
- [AI coding tools](docs/ai-tools.md) — Claude Code workflows, MCP server

Expand All @@ -126,10 +133,11 @@ The Go version covers the core workflow. What's missing are the TUI features.
| `shell-init` | Done | |
| `mcp-serve` | Done | JSON-RPC server for Claude Code |
| `hook` | Done | Claude Code hook handler |
| `plugin` | Done | Install, list, upgrade, remove |
| `run` | Partial | Inline prefix output, no split-pane TUI |
| `dash` | Not yet | Planned as separate plugin/binary |
| `dash` | Plugin | [`gw-dash`](https://github.com/nicksenap/gw-dash) — install with `gw plugin install nicksenap/gw-dash` |

285 tests passing (226 unit + 59 e2e).
299 tests passing (229 unit + 70 e2e).

### Performance

Expand Down
74 changes: 74 additions & 0 deletions docs/plugins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Plugins

Grove supports external plugins that add new commands. Plugins are standalone executables named `gw-<name>` — when you run `gw foo`, Grove looks for a `gw-foo` binary and executes it.

## Install methods

### From GitHub

```bash
gw plugin install nicksenap/gw-dash
gw plugin install github.com/user/gw-something
```

This downloads the latest release binary for your OS/architecture from the repo's GitHub Releases. The naming convention follows [goreleaser](https://goreleaser.com/) defaults: `gw-dash_0.1.0_darwin_arm64.tar.gz`.

### Manual

Drop any executable named `gw-<name>` into `~/.grove/plugins/`:

```bash
cp my-plugin ~/.grove/plugins/gw-myplugin
chmod +x ~/.grove/plugins/gw-myplugin
```

Or place it anywhere on your `$PATH`.

## Managing plugins

```bash
gw plugin list # list installed plugins
gw plugin upgrade dash # re-fetch latest release
gw plugin upgrade # upgrade all plugins
gw plugin remove dash # uninstall
```

`upgrade` works for plugins installed via `gw plugin install` — it remembers the source repo. Manually installed plugins are skipped.

## How plugins work

When you run `gw <name>`, Grove first checks its built-in commands. If no match is found, it looks for `gw-<name>` in:

1. `~/.grove/plugins/`
2. `$PATH`

If found, Grove replaces its own process with the plugin (`exec`), passing these environment variables:

| Variable | Description |
|---|---|
| `GROVE_DIR` | Path to `~/.grove` |
| `GROVE_CONFIG` | Path to `config.toml` |
| `GROVE_STATE` | Path to `state.json` |
| `GROVE_WORKSPACE` | Current workspace name (if cwd is inside one) |

The plugin gets full control of the terminal — this means TUI plugins (like `gw-dash`) work seamlessly.

## Writing a plugin

A plugin can be any executable in any language. The simplest plugin is a shell script:

```bash
#!/bin/sh
# ~/.grove/plugins/gw-hello
echo "Hello! GROVE_DIR=$GROVE_DIR"
```

For distributable plugins, use Go with [goreleaser](https://goreleaser.com/) so that `gw plugin install` can find the right binary. See [gw-dash](https://github.com/nicksenap/gw-dash) for a reference implementation.

### Reading Grove state

Plugins read Grove's data files directly — no shared libraries or IPC:

- **`$GROVE_DIR/state.json`** — array of workspaces, each with name, path, branch, and repos
- **`$GROVE_DIR/config.toml`** — global config (repo_dirs, workspace_dir, presets)
- **`$GROVE_DIR/status/*.json`** — live agent state files (for dashboard-style plugins)
4 changes: 3 additions & 1 deletion e2e/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ done
GROVE_SRC="${GROVE_SRC:-/src/grove}"
if [ -d "${GROVE_SRC}/.git" ]; then
git clone -q --local "${GROVE_SRC}" "${REPOS_DIR}/grove"
# Ensure we're on a named branch (CI checkouts may be detached HEAD)
(cd "${REPOS_DIR}/grove" && git checkout -q -B main HEAD 2>/dev/null || true)
echo "Cloned Grove repo ($(cd "${REPOS_DIR}/grove" && git rev-list --count HEAD) commits)"
else
# Fallback: create a bare origin + clone so we have proper remote refs
Expand Down Expand Up @@ -285,7 +287,7 @@ WS_DIR="${GROVE_HOME}/.grove/workspaces/test-ws"
section "Sync"

# Use the Grove clone — a real repo with full commit history
GROVE_BASE=$(cd "${REPOS_DIR}/grove" && git symbolic-ref --short HEAD)
GROVE_BASE=$(cd "${REPOS_DIR}/grove" && git symbolic-ref --short HEAD 2>/dev/null || echo "main")

gw create sync-ws --branch feat/sync-test --repos grove
SYNC_WS_DIR="${GROVE_HOME}/.grove/workspaces/sync-ws"
Expand Down
4 changes: 2 additions & 2 deletions go/cmd/addrepo.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ var addRepoCmd = &cobra.Command{
}
selected, err := picker.PickOne("Select workspace:", choices)
if err != nil {
exitError(err.Error())
exitOnPickerErr(err)
}
wsName = selected
}
Expand Down Expand Up @@ -74,7 +74,7 @@ var addRepoCmd = &cobra.Command{
}
selected, err := picker.PickMany("Select repos to add:", choices)
if err != nil {
exitError(err.Error())
exitOnPickerErr(err)
}
repoNames = selected
}
Expand Down
73 changes: 53 additions & 20 deletions go/cmd/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,29 +51,62 @@ var createCmd = &cobra.Command{
}
} else {
// Interactive
choices := make([]string, len(repos))
repoChoices := make([]string, len(repos))
for i, r := range repos {
choices[i] = r.Name
repoChoices[i] = r.Name
}
selected, err := picker.PickMany("Select repos for workspace:", choices)
if err != nil {
exitError(err.Error())
}
repoNames = selected

// Offer to save as preset
if len(cfg.Presets) == 0 && console.IsTerminal(os.Stdin) {
if console.Confirm("Save this selection as a preset?", false) {
presetName := console.Prompt("Preset name")
if presetName != "" {
if cfg.Presets == nil {
cfg.Presets = make(map[string]models.Preset)

// If presets exist, offer them first with a "Pick manually..." escape hatch
if len(cfg.Presets) > 0 {
presetNames := make([]string, 0, len(cfg.Presets))
presetChoices := make([]string, 0, len(cfg.Presets))
for name, p := range cfg.Presets {
presetNames = append(presetNames, name)
presetChoices = append(presetChoices, name+" ("+strings.Join(p.Repos, ", ")+")")
}
presetChoices = append(presetChoices, "Pick manually…")

choice, err := picker.PickOne("Select repos from:", presetChoices)
if err != nil {
exitOnPickerErr(err)
}

if choice != "Pick manually…" {
// Extract preset name (before the double space)
for i, display := range presetChoices {
if display == choice && i < len(presetNames) {
repoNames = cfg.Presets[presetNames[i]].Repos
break
}
cfg.Presets[presetName] = models.Preset{Repos: repoNames}
if err := config.Save(cfg); err != nil {
console.Warningf("Could not save preset: %s", err)
} else {
console.Successf("Saved preset %q", presetName)
}
} else {
selected, err := picker.PickMany("Select repos for workspace:", repoChoices)
if err != nil {
exitOnPickerErr(err)
}
repoNames = selected
}
} else {
selected, err := picker.PickMany("Select repos for workspace:", repoChoices)
if err != nil {
exitOnPickerErr(err)
}
repoNames = selected

// Offer to save as preset when none exist yet
if console.IsTerminal(os.Stdin) && len(selected) < len(repos) {
if console.Confirm("Save this selection as a preset?", false) {
presetName := console.Prompt("Preset name")
if presetName != "" {
if cfg.Presets == nil {
cfg.Presets = make(map[string]models.Preset)
}
cfg.Presets[presetName] = models.Preset{Repos: repoNames}
if err := config.Save(cfg); err != nil {
console.Warningf("Could not save preset: %s", err)
} else {
console.Successf("Saved preset %q", presetName)
}
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion go/cmd/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ var deleteCmd = &cobra.Command{
}
selected, err := picker.PickMany("Select workspaces to delete:", choices)
if err != nil {
exitError(err.Error())
exitOnPickerErr(err)
}
names = selected
}
Expand Down
2 changes: 1 addition & 1 deletion go/cmd/dirs.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ var removeDirCmd = &cobra.Command{
} else {
selected, err := picker.PickOne("Select directory to remove:", cfg.RepoDirs)
if err != nil {
exitError(err.Error())
exitOnPickerErr(err)
}
absPath = selected
}
Expand Down
6 changes: 3 additions & 3 deletions go/cmd/go_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ func resolveGoBack() string {
// Multiple parent dirs — let user pick
picked, err := picker.PickOne("Select repo directory:", parentList)
if err != nil {
exitError(err.Error())
exitOnPickerErr(err)
}
return picked
}
Expand Down Expand Up @@ -162,7 +162,7 @@ func pickWorkspaceForGo() string {

picked, err := picker.PickOne("Select workspace", choices)
if err != nil {
exitError(err.Error())
exitOnPickerErr(err)
}

if picked == backToRepos {
Expand All @@ -172,7 +172,7 @@ func pickWorkspaceForGo() string {
} else if len(cfg.RepoDirs) > 1 {
dir, err := picker.PickOne("Select repo directory", cfg.RepoDirs)
if err != nil {
exitError(err.Error())
exitOnPickerErr(err)
}
return dir
}
Expand Down
108 changes: 108 additions & 0 deletions go/cmd/plugin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package cmd

import (
"fmt"
"os"

"github.com/nicksenap/grove/internal/console"
"github.com/nicksenap/grove/internal/plugin"
"github.com/spf13/cobra"
)

var pluginCmd = &cobra.Command{
Use: "plugin",
Short: "Manage gw plugins",
Long: `Install, list, and remove external plugins that extend gw with new commands.

Plugins are executables named gw-<name>. Any unknown command "gw foo" will
look for a "gw-foo" plugin and run it.

Install methods:
gw plugin install <repo> Download from a GitHub release
Manual: place gw-<name> in ~/.grove/plugins/
Manual: place gw-<name> anywhere on $PATH`,
Run: func(cmd *cobra.Command, args []string) {
cmd.Help()
},
}

var pluginInstallCmd = &cobra.Command{
Use: "install <repo>",
Short: "Install a plugin from GitHub",
Long: `Download and install a plugin from a GitHub repository's latest release.

Examples:
gw plugin install nicksenap/gw-dash
gw plugin install github.com/nicksenap/gw-dash`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
if err := plugin.Install(args[0]); err != nil {
exitError(err.Error())
}
},
}

var pluginListCmd = &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "List installed plugins",
Run: func(cmd *cobra.Command, args []string) {
plugins, err := plugin.List()
if err != nil {
exitError(err.Error())
}

if len(plugins) == 0 {
console.Info("No plugins installed")
fmt.Fprintf(os.Stderr, " Install one with: gw plugin install <owner/repo>\n")
return
}

table := console.NewTable(os.Stdout, []string{"Plugin", "Path"})
for _, p := range plugins {
table.AddRow([]string{p.Name, p.Path})
}
table.Render()
},
}

var pluginRemoveCmd = &cobra.Command{
Use: "remove <name>",
Aliases: []string{"rm", "uninstall"},
Short: "Remove an installed plugin",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
if err := plugin.Remove(args[0]); err != nil {
exitError(err.Error())
}
console.Successf("Removed plugin %s", args[0])
},
}

var pluginUpgradeCmd = &cobra.Command{
Use: "upgrade [name]",
Short: "Upgrade installed plugin(s) to the latest release",
Long: `Re-fetch the latest release for a plugin. Without arguments, upgrades all
plugins that were installed via "gw plugin install".`,
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
if len(args) == 1 {
if err := plugin.Upgrade(args[0]); err != nil {
exitError(err.Error())
}
return
}

upgraded, err := plugin.UpgradeAll()
if err != nil {
exitError(err.Error())
}
if len(upgraded) == 0 {
console.Info("No plugins to upgrade")
}
},
}

func init() {
pluginCmd.AddCommand(pluginInstallCmd, pluginListCmd, pluginRemoveCmd, pluginUpgradeCmd)
}
Loading
Loading