diff --git a/build/bin/verstak-desktop b/build/bin/verstak-desktop
index f251fb9..45b83bf 100755
Binary files a/build/bin/verstak-desktop and b/build/bin/verstak-desktop differ
diff --git a/frontend/src/lib/plugin-manager/PluginManager.svelte b/frontend/src/lib/plugin-manager/PluginManager.svelte
index 9acd0cd..11bee15 100644
--- a/frontend/src/lib/plugin-manager/PluginManager.svelte
+++ b/frontend/src/lib/plugin-manager/PluginManager.svelte
@@ -7,16 +7,31 @@
let permissions = [];
let loading = true;
let error = '';
+ let hasConfig = true;
+
+ // Wails binding call with timeout
+ async function rpc(promise, label, timeoutMs = 8000) {
+ const timeout = new Promise((_, reject) =>
+ setTimeout(() => reject(new Error(`${label}: timeout (${timeoutMs}ms)`)), timeoutMs)
+ );
+ return await Promise.race([promise, timeout]);
+ }
async function loadData() {
loading = true;
error = '';
try {
- plugins = await window.go.api.App.GetPlugins();
- capabilities = await window.go.api.App.GetCapabilities();
- permissions = await window.go.api.App.GetPermissions();
+ const [p, caps, perms] = await Promise.all([
+ rpc(window.go.api.App.GetPlugins(), 'GetPlugins'),
+ rpc(window.go.api.App.GetCapabilities(), 'GetCapabilities'),
+ rpc(window.go.api.App.GetPermissions(), 'GetPermissions'),
+ ]);
+ plugins = p;
+ capabilities = caps;
+ permissions = perms;
} catch (e) {
error = String(e);
+ console.error('[PluginManager] load error:', e);
} finally {
loading = false;
}
@@ -27,8 +42,16 @@
});
async function reload() {
- await window.go.api.App.ReloadPlugins();
- await loadData();
+ loading = true;
+ error = '';
+ try {
+ await rpc(window.go.api.App.ReloadPlugins(), 'ReloadPlugins');
+ await loadData();
+ } catch (e) {
+ error = String(e);
+ console.error('[PluginManager] reload error:', e);
+ loading = false;
+ }
}
$: totalPlugins = plugins.length;
@@ -47,7 +70,11 @@
{#if loading}
Scanning plugin directories...
{:else if error}
- Error: {error}
+
+
⚠
+
{error}
+
+
{:else}
@@ -59,8 +86,14 @@
{#if plugins.length === 0}
-
No plugins discovered.
-
Place plugins in ~/.config/verstak/plugins/ or ./plugins/
+
📂
+
No plugins found
+
Plugin directories scanned:
+
+ ~/.config/verstak/plugins/ — user plugins
+ ./plugins/ — bundled plugins (app directory)
+
+
Place a plugin folder with plugin.json in one of these directories and click Reload.
{:else}
@@ -143,10 +176,37 @@
color: #e94560;
}
+ .error-icon {
+ font-size: 2rem;
+ margin-bottom: 0.5rem;
+ }
+
+ .error-message {
+ font-family: monospace;
+ font-size: 0.85rem;
+ margin-bottom: 1rem;
+ word-break: break-word;
+ }
+
+ .retry-btn {
+ background: #0f3460;
+ color: #e0e0e0;
+ border: 1px solid #533483;
+ padding: 0.4rem 1rem;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 0.85rem;
+ }
+
+ .retry-btn:hover {
+ background: #533483;
+ }
+
.summary {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
+ flex-wrap: wrap;
}
.badge {
@@ -167,12 +227,29 @@
border: 1px dashed #0f3460;
}
+ .empty-icon {
+ font-size: 2rem;
+ margin-bottom: 0.5rem;
+ }
+
.hint {
font-size: 0.85rem;
margin-top: 0.5rem;
opacity: 0.7;
}
+ .hint-list {
+ list-style: none;
+ padding: 0;
+ margin: 0.5rem 0;
+ font-size: 0.8rem;
+ opacity: 0.7;
+ }
+
+ .hint-list li {
+ margin: 0.25rem 0;
+ }
+
.hint code {
background: #0f3460;
padding: 0.1rem 0.3rem;
diff --git a/frontend/wailsjs/go/api/App.d.ts b/frontend/wailsjs/go/api/App.d.ts
index e776530..5b688b2 100755
--- a/frontend/wailsjs/go/api/App.d.ts
+++ b/frontend/wailsjs/go/api/App.d.ts
@@ -13,6 +13,6 @@ export function GetPermissions():Promise
>;
export function GetPlugins():Promise>;
-export function ReloadPlugins():Promise;
+export function ReloadPlugins():Promise;
export function Startup():Promise;
diff --git a/internal/api/app.go b/internal/api/app.go
index af5f69c..94bd5e1 100644
--- a/internal/api/app.go
+++ b/internal/api/app.go
@@ -2,6 +2,11 @@
package api
import (
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+
"github.com/verstak/verstak-desktop/internal/core/capability"
"github.com/verstak/verstak-desktop/internal/core/contribution"
"github.com/verstak/verstak-desktop/internal/core/events"
@@ -37,6 +42,7 @@ func NewApp(
// Startup is called when the app starts.
func (a *App) Startup() error {
+ log.Printf("[api] App.Startup: initialized with %d plugins", len(a.plugins))
return nil
}
@@ -44,17 +50,22 @@ func (a *App) Startup() error {
// GetPlugins returns all discovered plugins.
func (a *App) GetPlugins() []plugin.Plugin {
+ log.Printf("[api] GetPlugins: returning %d plugins", len(a.plugins))
return a.plugins
}
// GetCapabilities returns all registered capabilities.
func (a *App) GetCapabilities() []capability.Entry {
- return a.capRegistry.List()
+ entries := a.capRegistry.List()
+ log.Printf("[api] GetCapabilities: returning %d entries", len(entries))
+ return entries
}
// GetPermissions returns all known permissions.
func (a *App) GetPermissions() []permissions.Entry {
- return a.permRegistry.List()
+ entries := a.permRegistry.List()
+ log.Printf("[api] GetPermissions: returning %d entries", len(entries))
+ return entries
}
// GetContributions returns all registered contributions.
@@ -70,14 +81,58 @@ func (a *App) GetContributions() ContributionSummary {
}
}
-// ReloadPlugins re-discovers plugins from disk.
-func (a *App) ReloadPlugins() {
+// expandPath resolves "~" to the user's home directory.
+func expandPath(path string) string {
+ if strings.HasPrefix(path, "~/") {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ log.Printf("[api] expandPath: cannot get home dir: %v", err)
+ return path
+ }
+ return filepath.Join(home, path[2:])
+ }
+ return path
+}
+
+// ReloadPlugins re-discovers plugins from disk and returns a summary.
+func (a *App) ReloadPlugins() (int, string) {
discoveryDirs := []string{
"~/.config/verstak/plugins",
"./plugins",
}
- plugins, _ := plugin.DiscoverPlugins(discoveryDirs)
+
+ // Expand tilde in all paths
+ for i, d := range discoveryDirs {
+ discoveryDirs[i] = expandPath(d)
+ }
+
+ log.Printf("[api] ReloadPlugins: scanning dirs: %v", discoveryDirs)
+
+ plugins, errs := plugin.DiscoverPlugins(discoveryDirs)
a.plugins = plugins
+
+ var buf strings.Builder
+ buf.WriteString("discovery complete")
+ if len(plugins) > 0 {
+ buf.WriteString(": ")
+ buf.WriteString(plugin.FormatDiscoverySummary(plugins))
+ }
+
+ if len(errs) > 0 {
+ log.Printf("[api] ReloadPlugins: %d warning(s)", len(errs))
+ for _, e := range errs {
+ log.Printf("[api] discovery warning: %v", e)
+ }
+ }
+
+ log.Printf("[api] ReloadPlugins: discovered %d plugin(s)", len(plugins))
+
+ discoveryDirsStr := strings.Join(discoveryDirs, ", ")
+ summary := buf.String()
+
+ log.Printf("[api] ReloadPlugins: dirs=[%s] %s", discoveryDirsStr, summary)
+
+ return len(plugins), summary
}
// ContributionSummary aggregates all contribution types for the frontend.
diff --git a/internal/core/plugin/plugin.go b/internal/core/plugin/plugin.go
index 573732c..f4c7868 100644
--- a/internal/core/plugin/plugin.go
+++ b/internal/core/plugin/plugin.go
@@ -4,6 +4,7 @@ package plugin
import (
"encoding/json"
"fmt"
+ "log"
"os"
"path/filepath"
"strings"
@@ -231,6 +232,18 @@ func isAllowedInID(r rune) bool {
// ─── Discovery ──────────────────────────────────────────────
+// FormatDiscoverySummary returns a human-readable summary of discovered plugins.
+func FormatDiscoverySummary(plugins []Plugin) string {
+ if len(plugins) == 0 {
+ return "no plugins found"
+ }
+ ids := make([]string, 0, len(plugins))
+ for _, p := range plugins {
+ ids = append(ids, p.Manifest.ID+"@"+p.Manifest.Version)
+ }
+ return fmt.Sprintf("%d plugin(s): %s", len(plugins), strings.Join(ids, ", "))
+}
+
// DiscoverPlugins scans the given directories for plugin.json manifests.
func DiscoverPlugins(dirs []string) ([]Plugin, []error) {
var plugins []Plugin
@@ -238,16 +251,22 @@ func DiscoverPlugins(dirs []string) ([]Plugin, []error) {
seen := make(map[string]bool)
+ log.Printf("[discovery] start: %d dir(s): %v", len(dirs), dirs)
+
for _, dir := range dirs {
entries, err := os.ReadDir(dir)
if os.IsNotExist(err) {
+ log.Printf("[discovery] dir %q: does not exist (skip)", dir)
continue
}
if err != nil {
errs = append(errs, fmt.Errorf("reading plugin directory %s: %w", dir, err))
+ log.Printf("[discovery] dir %q: error: %v", dir, err)
continue
}
+ log.Printf("[discovery] dir %q: %d entries", dir, len(entries))
+
for _, entry := range entries {
if !entry.IsDir() {
continue
@@ -257,24 +276,30 @@ func DiscoverPlugins(dirs []string) ([]Plugin, []error) {
manifestPath := filepath.Join(pluginDir, "plugin.json")
if _, err := os.Stat(manifestPath); os.IsNotExist(err) {
+ log.Printf("[discovery] %s: no plugin.json (skip)", entry.Name())
continue
}
plugin, err := loadPlugin(pluginDir)
if err != nil {
errs = append(errs, fmt.Errorf("plugin %s: %w", entry.Name(), err))
+ log.Printf("[discovery] %s: load error: %v", entry.Name(), err)
continue
}
if seen[plugin.Manifest.ID] {
errs = append(errs, fmt.Errorf("duplicate plugin ID %q in %s", plugin.Manifest.ID, pluginDir))
+ log.Printf("[discovery] %s: duplicate ID %q (skip)", entry.Name(), plugin.Manifest.ID)
continue
}
seen[plugin.Manifest.ID] = true
plugins = append(plugins, plugin)
+ log.Printf("[discovery] %s: ✅ %s@%s", entry.Name(), plugin.Manifest.ID, plugin.Manifest.Version)
}
}
+ log.Printf("[discovery] end: %d plugin(s) found, %d error(s)", len(plugins), len(errs))
+
return plugins, errs
}
diff --git a/internal/core/plugin/plugin_test.go b/internal/core/plugin/plugin_test.go
new file mode 100644
index 0000000..47f13e3
--- /dev/null
+++ b/internal/core/plugin/plugin_test.go
@@ -0,0 +1,212 @@
+package plugin
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// createTempPlugin creates a minimal valid plugin directory for testing.
+func createTempPlugin(t *testing.T, dir, id, name string) string {
+ t.Helper()
+ pluginDir := filepath.Join(dir, id)
+ if err := os.MkdirAll(pluginDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+ manifest := `{
+ "schemaVersion": 1,
+ "id": "` + id + `",
+ "name": "` + name + `",
+ "version": "1.0.0",
+ "apiVersion": "1.0",
+ "provides": ["` + id + `.cap1"],
+ "permissions": ["vault.read"]
+ }`
+ if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(manifest), 0644); err != nil {
+ t.Fatal(err)
+ }
+ return pluginDir
+}
+
+// TestDiscoverPlugins_EmptyDir tests discovery on a directory that does not exist.
+// It should return empty results, not hang or error out.
+func TestDiscoverPlugins_EmptyDir(t *testing.T) {
+ plugins, errs := DiscoverPlugins([]string{"/tmp/nonexistent-plugin-dir-12345"})
+ if len(plugins) != 0 {
+ t.Errorf("expected 0 plugins, got %d", len(plugins))
+ }
+ if len(errs) != 0 {
+ t.Errorf("expected 0 errors, got %d: %v", len(errs), errs)
+ }
+}
+
+// TestDiscoverPlugins_MissingDir tests discovery where the directory does not exist.
+// It should not be treated as an error — just skip.
+func TestDiscoverPlugins_MissingDir(t *testing.T) {
+ plugins, errs := DiscoverPlugins([]string{"/tmp/missing-dir-99999"})
+ if len(plugins) != 0 {
+ t.Errorf("expected 0 plugins, got %d", len(plugins))
+ }
+ if len(errs) != 0 {
+ t.Errorf("expected 0 errors for missing dir, got %d", len(errs))
+ }
+}
+
+// TestDiscoverPlugins_ValidPlugin tests discovery of a single valid plugin.
+func TestDiscoverPlugins_ValidPlugin(t *testing.T) {
+ dir := t.TempDir()
+ createTempPlugin(t, dir, "test.plugin", "Test Plugin")
+
+ plugins, errs := DiscoverPlugins([]string{dir})
+ if len(plugins) != 1 {
+ t.Fatalf("expected 1 plugin, got %d", len(plugins))
+ }
+ if plugins[0].Manifest.ID != "test.plugin" {
+ t.Errorf("expected id 'test.plugin', got %q", plugins[0].Manifest.ID)
+ }
+ if !plugins[0].Enabled {
+ t.Error("expected plugin to be enabled by default")
+ }
+ if len(errs) > 0 {
+ t.Errorf("expected 0 errors, got %d: %v", len(errs), errs)
+ }
+}
+
+// TestDiscoverPlugins_NoManifest ensures subdirs without plugin.json are skipped.
+func TestDiscoverPlugins_NoManifest(t *testing.T) {
+ dir := t.TempDir()
+ noManifestDir := filepath.Join(dir, "no-manifest")
+ if err := os.MkdirAll(noManifestDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+
+ plugins, errs := DiscoverPlugins([]string{dir})
+ if len(plugins) != 0 {
+ t.Errorf("expected 0 plugins (no manifest), got %d", len(plugins))
+ }
+ if len(errs) != 0 {
+ t.Errorf("expected 0 errors, got %d", len(errs))
+ }
+}
+
+// TestDiscoverPlugins_BrokenJSON ensures a corrupted manifest is reported as error
+// but does not crash or hang discovery.
+func TestDiscoverPlugins_BrokenJSON(t *testing.T) {
+ dir := t.TempDir()
+ pluginDir := filepath.Join(dir, "broken")
+ if err := os.MkdirAll(pluginDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte("{invalid json}"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ plugins, errs := DiscoverPlugins([]string{dir})
+ if len(plugins) != 0 {
+ t.Errorf("expected 0 plugins (broken manifest), got %d", len(plugins))
+ }
+ if len(errs) == 0 {
+ t.Error("expected at least 1 error for broken manifest, got 0")
+ }
+}
+
+// TestDiscoverPlugins_DuplicateID ensures duplicate plugin IDs are detected.
+func TestDiscoverPlugins_DuplicateID(t *testing.T) {
+ dir := t.TempDir()
+ createTempPlugin(t, dir, "dup-one", "First")
+
+ // Create a second plugin with the same ID (duplicate)
+ pluginDir2 := filepath.Join(dir, "dup-two")
+ if err := os.MkdirAll(pluginDir2, 0755); err != nil {
+ t.Fatal(err)
+ }
+ manifest := `{
+ "schemaVersion": 1,
+ "id": "dup-one",
+ "name": "Second",
+ "version": "1.0.0",
+ "apiVersion": "1.0",
+ "provides": ["dup-one.cap1"],
+ "permissions": ["vault.read"]
+ }`
+ if err := os.WriteFile(filepath.Join(pluginDir2, "plugin.json"), []byte(manifest), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ plugins, errs := DiscoverPlugins([]string{dir})
+ if len(plugins) != 1 {
+ t.Errorf("expected 1 plugin (deduped), got %d", len(plugins))
+ }
+ hasDupError := false
+ for _, e := range errs {
+ if strings.Contains(e.Error(), "duplicate") {
+ hasDupError = true
+ }
+ }
+ if !hasDupError {
+ t.Error("expected duplicate plugin ID error")
+ }
+}
+
+// TestDiscoverPlugins_MultipleDirs ensures discovery scans multiple directories.
+func TestDiscoverPlugins_MultipleDirs(t *testing.T) {
+ dir1 := t.TempDir()
+ dir2 := t.TempDir()
+ createTempPlugin(t, dir1, "alpha.plugin", "Alpha")
+ createTempPlugin(t, dir2, "beta.plugin", "Beta")
+
+ plugins, errs := DiscoverPlugins([]string{dir1, dir2})
+ if len(plugins) != 2 {
+ t.Fatalf("expected 2 plugins, got %d", len(plugins))
+ }
+ if len(errs) > 0 {
+ t.Errorf("expected 0 errors, got %d: %v", len(errs), errs)
+ }
+}
+
+// TestDiscoverPlugins_AllowsNonexistentDirs ensures that a mix of valid and
+// nonexistent directories doesn't cause issues.
+func TestDiscoverPlugins_AllowsNonexistentDirs(t *testing.T) {
+ dir := t.TempDir()
+ createTempPlugin(t, dir, "survivor.plugin", "Survivor")
+
+ plugins, errs := DiscoverPlugins([]string{
+ "/tmp/missing-aaa-88888",
+ dir,
+ "/tmp/missing-bbb-99999",
+ })
+ if len(plugins) != 1 {
+ t.Fatalf("expected 1 plugin, got %d", len(plugins))
+ }
+ if plugins[0].Manifest.ID != "survivor.plugin" {
+ t.Errorf("expected 'survivor.plugin', got %q", plugins[0].Manifest.ID)
+ }
+ if len(errs) > 0 {
+ t.Errorf("expected 0 errors, got %d: %v", len(errs), errs)
+ }
+}
+
+// TestFormatDiscoverySummary on empty list.
+func TestFormatDiscoverySummary_Empty(t *testing.T) {
+ s := FormatDiscoverySummary(nil)
+ if s != "no plugins found" {
+ t.Errorf("expected 'no plugins found', got %q", s)
+ }
+}
+
+// TestFormatDiscoverySummary on populated list.
+func TestFormatDiscoverySummary_Plugins(t *testing.T) {
+ plugins := []Plugin{
+ {
+ Manifest: Manifest{ID: "test.one", Version: "1.0.0"},
+ },
+ {
+ Manifest: Manifest{ID: "test.two", Version: "2.0.0"},
+ },
+ }
+ s := FormatDiscoverySummary(plugins)
+ if s != "2 plugin(s): test.one@1.0.0, test.two@2.0.0" {
+ t.Errorf("unexpected summary: %q", s)
+ }
+}
diff --git a/main.go b/main.go
index 475d67d..055a63f 100644
--- a/main.go
+++ b/main.go
@@ -3,6 +3,9 @@ package main
import (
"embed"
"log"
+ "os"
+ "path/filepath"
+ "strings"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
@@ -19,6 +22,19 @@ import (
//go:embed frontend/dist
var assets embed.FS
+// expandPath resolves "~" to the user's home directory.
+func expandPath(path string) string {
+ if strings.HasPrefix(path, "~/") {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ log.Printf("[main] expandPath: cannot get home dir: %v", err)
+ return path
+ }
+ return filepath.Join(home, path[2:])
+ }
+ return path
+}
+
func main() {
// ─── Initialize Core Registries ──────────────────────────
capRegistry := capability.NewRegistry()
@@ -32,6 +48,13 @@ func main() {
"./plugins",
}
+ // Expand tilde in all paths
+ for i, d := range discoveryDirs {
+ discoveryDirs[i] = expandPath(d)
+ }
+
+ log.Printf("[main] plugin dirs: %v", discoveryDirs)
+
plugins, discErrors := plugin.DiscoverPlugins(discoveryDirs)
for _, err := range discErrors {
log.Printf("[plugin] discovery warning: %v", err)