From 21ff99840f83feef561d30103e9b5bc5eed38d2b Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:53:08 -0700 Subject: [PATCH] Add JSON output for extension inventory Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- cmd/ext.go | 77 +++++++++++++++++++++++++++++++++- cmd/ext_test.go | 107 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+), 2 deletions(-) diff --git a/cmd/ext.go b/cmd/ext.go index fd53a177..7ba16d9e 100644 --- a/cmd/ext.go +++ b/cmd/ext.go @@ -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" @@ -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 @@ -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 { @@ -176,7 +185,8 @@ func newExtCreateCmd() *cobra.Command { } func newExtInfoCmd() *cobra.Command { - return &cobra.Command{ + var jsonFlag bool + cmd := &cobra.Command{ Use: "info ", Short: "Show details about an installed extension", Args: cobra.ExactArgs(1), @@ -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 { @@ -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) } diff --git a/cmd/ext_test.go b/cmd/ext_test.go index 7defb1d9..be213872 100644 --- a/cmd/ext_test.go +++ b/cmd/ext_test.go @@ -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" ) @@ -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) +}