Skip to content
Open
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
77 changes: 75 additions & 2 deletions cmd/ext.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package cmd

import (
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"time"

"github.com/jongio/grut/internal/config"
"github.com/jongio/grut/internal/extension"
Expand Down Expand Up @@ -74,12 +77,16 @@ func newExtRemoveCmd() *cobra.Command {
}

func newExtListCmd() *cobra.Command {
return &cobra.Command{
var jsonFlag bool
cmd := &cobra.Command{
Use: cmdList,
Short: "List installed extensions",
RunE: func(cmd *cobra.Command, _ []string) error {
mgr := extManager()
list := mgr.List()
if jsonFlag {
return printExtensionListJSON(cmd, list)
}
if len(list) == 0 {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "No extensions installed.")
return nil
Expand All @@ -95,6 +102,8 @@ func newExtListCmd() *cobra.Command {
return nil
},
}
cmd.Flags().BoolVar(&jsonFlag, "json", false, "Print machine-readable JSON")
return cmd
}

func newExtEnableCmd() *cobra.Command {
Expand Down Expand Up @@ -176,7 +185,8 @@ func newExtCreateCmd() *cobra.Command {
}

func newExtInfoCmd() *cobra.Command {
return &cobra.Command{
var jsonFlag bool
cmd := &cobra.Command{
Use: "info <name>",
Short: "Show details about an installed extension",
Args: cobra.ExactArgs(1),
Expand All @@ -186,6 +196,9 @@ func newExtInfoCmd() *cobra.Command {
if err != nil {
return fmt.Errorf("ext info: %w", err)
}
if jsonFlag {
return printExtensionJSON(cmd, *info)
}
w := cmd.OutOrStdout()
status := "enabled"
if !info.Enabled {
Expand All @@ -206,4 +219,64 @@ func newExtInfoCmd() *cobra.Command {
return nil
},
}
cmd.Flags().BoolVar(&jsonFlag, "json", false, "Print machine-readable JSON")
return cmd
}

type extensionInventoryJSON struct {
InstalledAt time.Time `json:"installed_at"`
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description"`
Author string `json:"author"`
License string `json:"license"`
Runtime string `json:"runtime"`
EntryPoint string `json:"entry_point"`
MinGrut string `json:"min_grut"`
Permissions []string `json:"permissions"`
Directory string `json:"directory"`
SourceURL string `json:"source_url,omitempty"`
CommitHash string `json:"commit_hash,omitempty"`
Enabled bool `json:"enabled"`
}

func printExtensionListJSON(cmd *cobra.Command, list []extension.ExtensionInfo) error {
out := make([]extensionInventoryJSON, 0, len(list))
for _, info := range list {
out = append(out, extensionInventory(info))
}
sort.Slice(out, func(i, j int) bool {
return out[i].Name < out[j].Name
})
return writeExtensionJSON(cmd, out)
}

func printExtensionJSON(cmd *cobra.Command, info extension.ExtensionInfo) error {
return writeExtensionJSON(cmd, extensionInventory(info))
}

func extensionInventory(info extension.ExtensionInfo) extensionInventoryJSON {
permissions := append([]string(nil), info.Manifest.Permissions...)
return extensionInventoryJSON{
Name: info.Manifest.Name,
Version: info.Manifest.Version,
Description: info.Manifest.Description,
Author: info.Manifest.Author,
License: info.Manifest.License,
Runtime: info.Manifest.Runtime,
EntryPoint: info.Manifest.EntryPoint,
MinGrut: info.Manifest.MinGrut,
Permissions: permissions,
Enabled: info.Enabled,
SourceURL: info.SourceURL,
CommitHash: info.CommitHash,
InstalledAt: info.InstalledAt,
Directory: info.Dir,
}
}

func writeExtensionJSON(cmd *cobra.Command, value any) error {
enc := json.NewEncoder(cmd.OutOrStdout())
enc.SetIndent("", " ")
return enc.Encode(value)
}
107 changes: 107 additions & 0 deletions cmd/ext_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ package cmd

import (
"bytes"
"encoding/json"
"testing"
"time"

"github.com/jongio/grut/internal/extension"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -114,3 +117,107 @@ func TestNewExtCreateCmd_FlagsRegistered(t *testing.T) {
require.NotNil(t, listFlag, "--list flag must be registered")
assert.Equal(t, "false", listFlag.DefValue)
}

func TestExtListCmd_JSONFlagRegistered(t *testing.T) {
cmd := newExtListCmd()
flag := cmd.Flags().Lookup("json")
require.NotNil(t, flag, "--json flag must be registered")
assert.Equal(t, "bool", flag.Value.Type())
assert.Equal(t, "false", flag.DefValue)
}

func TestExtInfoCmd_JSONFlagRegistered(t *testing.T) {
cmd := newExtInfoCmd()
flag := cmd.Flags().Lookup("json")
require.NotNil(t, flag, "--json flag must be registered")
assert.Equal(t, "bool", flag.Value.Type())
assert.Equal(t, "false", flag.DefValue)
}

func TestPrintExtensionListJSON_EmptyList(t *testing.T) {
cmd := newExtListCmd()
var buf bytes.Buffer
cmd.SetOut(&buf)

err := printExtensionListJSON(cmd, nil)
require.NoError(t, err)

var out []extensionInventoryJSON
require.NoError(t, json.Unmarshal(buf.Bytes(), &out))
assert.Empty(t, out)
}

func TestPrintExtensionListJSON_SortedInventory(t *testing.T) {
cmd := newExtListCmd()
var buf bytes.Buffer
cmd.SetOut(&buf)
installed := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)

err := printExtensionListJSON(cmd, []extension.ExtensionInfo{
{
Manifest: extension.Manifest{Name: "zeta", Version: "1.0.0", Runtime: "lua"},
Dir: "z-dir",
Enabled: false,
InstalledAt: installed,
},
{
Manifest: extension.Manifest{Name: "alpha", Version: "2.0.0", Runtime: "wasm", Permissions: []string{"notify"}},
Dir: "a-dir",
Enabled: true,
InstalledAt: installed,
SourceURL: "https://github.com/example/alpha",
CommitHash: "abc123",
},
})
require.NoError(t, err)

var out []extensionInventoryJSON
require.NoError(t, json.Unmarshal(buf.Bytes(), &out))
require.Len(t, out, 2)
assert.Equal(t, "alpha", out[0].Name)
assert.Equal(t, "wasm", out[0].Runtime)
assert.Equal(t, []string{"notify"}, out[0].Permissions)
assert.True(t, out[0].Enabled)
assert.Equal(t, "https://github.com/example/alpha", out[0].SourceURL)
assert.Equal(t, "abc123", out[0].CommitHash)
assert.Equal(t, "a-dir", out[0].Directory)
assert.Equal(t, "zeta", out[1].Name)
}

func TestPrintExtensionJSON_IncludesManifestAndState(t *testing.T) {
cmd := newExtInfoCmd()
var buf bytes.Buffer
cmd.SetOut(&buf)
installed := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)

err := printExtensionJSON(cmd, extension.ExtensionInfo{
Manifest: extension.Manifest{
Name: "demo",
Version: "1.2.3",
Description: "Demo extension",
Author: "Grut",
License: "MIT",
Runtime: "lua",
EntryPoint: "init.lua",
MinGrut: "0.1.0",
Permissions: []string{"notify", "git_read"},
},
Dir: "demo-dir",
Enabled: true,
InstalledAt: installed,
SourceURL: "https://github.com/example/demo",
CommitHash: "def456",
})
require.NoError(t, err)

var out extensionInventoryJSON
require.NoError(t, json.Unmarshal(buf.Bytes(), &out))
assert.Equal(t, "demo", out.Name)
assert.Equal(t, "1.2.3", out.Version)
assert.Equal(t, "lua", out.Runtime)
assert.Equal(t, "init.lua", out.EntryPoint)
assert.Equal(t, []string{"notify", "git_read"}, out.Permissions)
assert.True(t, out.Enabled)
assert.Equal(t, "demo-dir", out.Directory)
assert.Equal(t, installed, out.InstalledAt)
}
Loading