Skip to content

Commit 1915a44

Browse files
scotwellsclaude
andcommitted
feat(plugin): surface installed plugins and the marketplace on the landing screen
The no-argument landing now reflects the plugins a user has added: each installed plugin is listed as a runnable `datumctl <command>` verb badged with the catalog it came from, and a new "Extend datumctl" entry plus a few tips point at `datumctl plugin browse`. The plugin list is read locally and best-effort, so it never slows down or breaks the landing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d89e4be commit 1915a44

2 files changed

Lines changed: 176 additions & 0 deletions

File tree

internal/cmd/landing.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ import (
44
"fmt"
55
"io"
66
"math/rand"
7+
"sort"
78
"strings"
89
"time"
910

1011
"github.com/spf13/cobra"
1112

1213
"go.datum.net/datumctl/internal/datumconfig"
14+
"go.datum.net/datumctl/internal/pluginstore"
1315
)
1416

1517
// runLanding prints a contextual welcome when `datumctl` is invoked with no
@@ -113,19 +115,81 @@ func printLoggedInLanding(out io.Writer, cfg *datumconfig.ConfigV1Beta1, session
113115
fmt.Fprintln(out, " datumctl describe <resource> <name>")
114116
fmt.Fprintln(out, " Ship something datumctl apply -f file.yaml")
115117
fmt.Fprintln(out, " datumctl create <resource> ...")
118+
fmt.Fprintln(out, " Extend datumctl datumctl plugin browse")
116119
fmt.Fprintln(out, " Explore the API datumctl api-resources")
117120
fmt.Fprintln(out, " Follow the audit trail datumctl activity")
118121
fmt.Fprintln(out, " Switch context datumctl ctx")
119122
}
120123
fmt.Fprintln(out, " Switch account datumctl auth switch")
121124
fmt.Fprintln(out)
122125

126+
// Installed plugins become `datumctl <command>` verbs, so reflect the user's
127+
// own setup on the landing. Best-effort and local-only — never blocks the
128+
// landing on a missing or unreadable plugin store.
129+
printInstalledPlugins(out)
130+
123131
fmt.Fprintf(out, "Tip: %s\n", pickTip(time.Now().UnixNano()))
124132
fmt.Fprintln(out)
125133

126134
fmt.Fprintln(out, "Run 'datumctl --help' for the full command reference.")
127135
}
128136

137+
// printInstalledPlugins renders a "Your plugins" block listing each installed
138+
// plugin as a runnable `datumctl <command>` verb with the catalog it came from.
139+
// It is best-effort and local-only: any error reading the plugin store, or no
140+
// installed plugins, simply prints nothing.
141+
func printInstalledPlugins(out io.Writer) {
142+
dir, err := pluginstore.PluginsDir("")
143+
if err != nil {
144+
return
145+
}
146+
manifest, err := pluginstore.Load(dir)
147+
if err != nil || manifest == nil || len(manifest.Plugins) == 0 {
148+
return
149+
}
150+
151+
names := make([]string, 0, len(manifest.Plugins))
152+
for name := range manifest.Plugins {
153+
names = append(names, name)
154+
}
155+
sort.Strings(names)
156+
157+
// Pad the command column so the source labels line up.
158+
cmdWidth := 0
159+
for _, name := range names {
160+
if w := len("datumctl " + name); w > cmdWidth {
161+
cmdWidth = w
162+
}
163+
}
164+
165+
for i, name := range names {
166+
label := ""
167+
if i == 0 {
168+
label = "Your plugins"
169+
}
170+
command := "datumctl " + name
171+
fmt.Fprintf(out, " %-22s %-*s (%s)\n", label, cmdWidth, command, landingPluginSource(manifest.Plugins[name]))
172+
}
173+
fmt.Fprintln(out)
174+
}
175+
176+
// landingPluginSource returns a short catalog label for an installed plugin,
177+
// mirroring how `plugin list` labels provenance.
178+
func landingPluginSource(entry *pluginstore.InstalledPlugin) string {
179+
if entry == nil {
180+
return ""
181+
}
182+
if entry.Catalog != "" {
183+
return pluginstore.CanonicalCatalogName(entry.Catalog)
184+
}
185+
// Legacy records: a slash in Source means a direct GitHub install; otherwise
186+
// it came from the curated official catalog.
187+
if strings.Contains(entry.Source, "/") {
188+
return "direct"
189+
}
190+
return pluginstore.OfficialCatalogName
191+
}
192+
129193
// firstName returns the first whitespace-delimited token of full. Returns ""
130194
// for empty/whitespace input.
131195
func firstName(full string) string {
@@ -169,6 +233,10 @@ var landingTips = []string{
169233
// Audit tips
170234
"'datumctl activity' tails the audit trail across your whole control plane.",
171235
"'datumctl activity --start-time now-1h' scopes the feed to the last hour.",
236+
// Plugin / marketplace tips
237+
"'datumctl plugin browse' explores plugins across every registered catalog.",
238+
"'datumctl plugin search <keyword>' finds plugins to install from any catalog.",
239+
"'datumctl plugin index add <name> <url>' registers a team or community catalog.",
172240
// Power-user tips
173241
"Set DATUM_PROJECT or DATUM_ORGANIZATION to override context for a single command.",
174242
"'datumctl describe <resource> <name>' shows status conditions — handy for debugging.",

internal/cmd/landing_test.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package cmd
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
"testing"
10+
11+
"go.datum.net/datumctl/internal/pluginstore"
12+
)
13+
14+
func seedPluginsManifest(t *testing.T, plugins map[string]*pluginstore.InstalledPlugin) {
15+
t.Helper()
16+
dir := t.TempDir()
17+
t.Setenv("DATUMCTL_PLUGINS_DIR", dir)
18+
data, err := json.MarshalIndent(pluginstore.Manifest{Plugins: plugins}, "", " ")
19+
if err != nil {
20+
t.Fatal(err)
21+
}
22+
if err := os.WriteFile(filepath.Join(dir, "plugins.json"), data, 0o600); err != nil {
23+
t.Fatal(err)
24+
}
25+
}
26+
27+
func TestPrintInstalledPlugins_listsInstalledAsCommands(t *testing.T) {
28+
seedPluginsManifest(t, map[string]*pluginstore.InstalledPlugin{
29+
// Catalog "default" is the official catalog under its legacy alias; it must
30+
// render with the canonical product-facing name "datum".
31+
"dns": {Catalog: "default", Version: "v1.2.3", Source: "datum"},
32+
"ipam": {Catalog: "acme", Version: "v0.4.0", Source: "https://plugins.acme.example/index.yaml"},
33+
// Legacy record with no catalog and a slashed source -> "direct".
34+
"costs": {Source: "octo/datumctl-costs", Version: "v2.0.0"},
35+
})
36+
37+
var buf bytes.Buffer
38+
printInstalledPlugins(&buf)
39+
out := buf.String()
40+
41+
if !strings.Contains(out, "Your plugins") {
42+
t.Fatalf("expected a Your plugins block, got:\n%s", out)
43+
}
44+
// Each plugin is shown as a runnable `datumctl <command>` verb.
45+
for _, cmd := range []string{"datumctl dns", "datumctl ipam", "datumctl costs"} {
46+
if !strings.Contains(out, cmd) {
47+
t.Errorf("expected %q in output:\n%s", cmd, out)
48+
}
49+
}
50+
// Provenance is labelled, with the legacy "default" alias canonicalized.
51+
for _, src := range []string{"(datum)", "(acme)", "(direct)"} {
52+
if !strings.Contains(out, src) {
53+
t.Errorf("expected source label %q in output:\n%s", src, out)
54+
}
55+
}
56+
if strings.Contains(out, "(default)") {
57+
t.Errorf("legacy 'default' catalog should render as 'datum', not 'default':\n%s", out)
58+
}
59+
}
60+
61+
func TestPrintInstalledPlugins_emptyWhenNoneInstalled(t *testing.T) {
62+
seedPluginsManifest(t, map[string]*pluginstore.InstalledPlugin{})
63+
64+
var buf bytes.Buffer
65+
printInstalledPlugins(&buf)
66+
67+
if buf.Len() != 0 {
68+
t.Fatalf("expected no output when no plugins are installed, got:\n%s", buf.String())
69+
}
70+
}
71+
72+
func TestPrintInstalledPlugins_emptyWhenStoreUnreadable(t *testing.T) {
73+
// Point the store at a path that cannot be a valid plugins dir; the landing
74+
// must degrade silently rather than emit a partial or erroring block.
75+
file := filepath.Join(t.TempDir(), "not-a-dir")
76+
if err := os.WriteFile(file, []byte("x"), 0o600); err != nil {
77+
t.Fatal(err)
78+
}
79+
t.Setenv("DATUMCTL_PLUGINS_DIR", filepath.Join(file, "nested"))
80+
81+
var buf bytes.Buffer
82+
printInstalledPlugins(&buf)
83+
84+
if buf.Len() != 0 {
85+
t.Fatalf("expected no output when the plugin store is unreadable, got:\n%s", buf.String())
86+
}
87+
}
88+
89+
func TestLandingPluginSource(t *testing.T) {
90+
cases := []struct {
91+
name string
92+
entry *pluginstore.InstalledPlugin
93+
want string
94+
}{
95+
{"nil", nil, ""},
96+
{"catalog", &pluginstore.InstalledPlugin{Catalog: "acme"}, "acme"},
97+
{"legacy default alias", &pluginstore.InstalledPlugin{Catalog: "default"}, "datum"},
98+
{"legacy direct", &pluginstore.InstalledPlugin{Source: "octo/repo"}, "direct"},
99+
{"legacy official", &pluginstore.InstalledPlugin{Source: "datum"}, pluginstore.OfficialCatalogName},
100+
}
101+
for _, tc := range cases {
102+
t.Run(tc.name, func(t *testing.T) {
103+
if got := landingPluginSource(tc.entry); got != tc.want {
104+
t.Errorf("landingPluginSource = %q, want %q", got, tc.want)
105+
}
106+
})
107+
}
108+
}

0 commit comments

Comments
 (0)