From 2d7629cc5516caa11e41d1d7b810ee6644312e5e Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 17 Oct 2026 11:23:15 +0530 Subject: [PATCH] feat: add extensions system (Mirror-style) + /extensions command --- cmd/chat_subcommand_extensions.go | 44 ++++++++++ go.mod | 5 ++ internal/extensions/extensions.go | 116 +++++++++++++++++++++++++ internal/extensions/extensions_test.go | 41 +++++++++ 4 files changed, 206 insertions(+) create mode 100644 cmd/chat_subcommand_extensions.go create mode 100644 internal/extensions/extensions.go create mode 100644 internal/extensions/extensions_test.go diff --git a/cmd/chat_subcommand_extensions.go b/cmd/chat_subcommand_extensions.go new file mode 100644 index 000000000..805f23ab6 --- /dev/null +++ b/cmd/chat_subcommand_extensions.go @@ -0,0 +1,44 @@ +package cmd + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + + "github.com/GrayCodeAI/reflect/internal/extensions" +) + +// extensionsSubcommand lists registered extensions. +type extensionsSubcommand struct{} + +func (c *extensionsSubcommand) Name() string { return "extensions" } +func (c *extensionsSubcommand) Aliases() []string { return []string{"ext"} } +func (c *extensionsSubcommand) Description() string { return "list extensions (~/.reflect/extensions)" } +func (c *extensionsSubcommand) Usage() string { return "/extensions" } + +func (c *extensionsSubcommand) Handle(m *chatModel, args []string, text string) (tea.Model, tea.Cmd) { + exts, err := extensions.Load() + if err != nil { + m.messages = append(m.messages, displayMsg{role: "error", content: err.Error()}) + return m, nil + } + if len(exts) == 0 { + m.messages = append(m.messages, displayMsg{role: "system", content: "No extensions installed. Add directories under " + extensions.Dir()}) + return m, nil + } + var b strings.Builder + b.WriteString("Extensions:\n") + for _, e := range exts { + b.WriteString(fmt.Sprintf(" - %s: %s\n", e.Name, e.Description)) + for _, c := range e.Commands { + b.WriteString(fmt.Sprintf(" /%s — %s\n", c.Name, c.Description)) + } + } + m.messages = append(m.messages, displayMsg{role: "system", content: b.String()}) + return m, nil +} + +func init() { + subcommandRegistry.Register(&extensionsSubcommand{}) +} diff --git a/go.mod b/go.mod index c3ef914b1..df7554c17 100644 --- a/go.mod +++ b/go.mod @@ -102,3 +102,8 @@ require ( + + + + + diff --git a/internal/extensions/extensions.go b/internal/extensions/extensions.go new file mode 100644 index 000000000..19109dff7 --- /dev/null +++ b/internal/extensions/extensions.go @@ -0,0 +1,116 @@ +// Package extensions provides a lightweight extension system inspired by +// Mirror's Python extensions. Extensions live in ~/.reflect/extensions// +// and contribute commands (shell or python scripts) and host tools. +// +// An extension directory must contain a manifest.json: +// +// { +// "name": "my-ext", +// "description": "...", +// "commands": [{"name": "hello", "description": "...", "script": "hello.py"}] +// } +package extensions + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/GrayCodeAI/reflect/internal/storage" +) + +// Command describes an extension-provided command. +type Command struct { + Name string `json:"name"` + Description string `json:"description"` + Script string `json:"script"` // relative to extension dir +} + +// Manifest describes an extension. +type Manifest struct { + Name string `json:"name"` + Description string `json:"description"` + Commands []Command `json:"commands,omitempty"` +} + +// Dir returns the extensions directory. +func Dir() string { + return filepath.Join(storage.ConfigDir(), "extensions") +} + +// Load reads all extension manifests. +func Load() ([]Manifest, error) { + dir := Dir() + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var out []Manifest + for _, e := range entries { + if !e.IsDir() { + continue + } + data, err := os.ReadFile(filepath.Join(dir, e.Name(), "manifest.json")) + if err != nil { + continue + } + var m Manifest + if err := json.Unmarshal(data, &m); err != nil { + continue + } + if m.Name == "" { + m.Name = e.Name() + } + out = append(out, m) + } + return out, nil +} + +// RunCommand executes an extension command script and returns its stdout. +func RunCommand(ctx context.Context, ext Manifest, cmd Command, args []string) (string, error) { + script := filepath.Join(Dir(), ext.Name, cmd.Script) + c := exec.CommandContext(ctx, "python3", append([]string{script}, args...)...) // #nosec G204 -- script path from trusted local manifest + // fall back to sh if python3 missing + if _, err := exec.LookPath("python3"); err != nil { + c = exec.CommandContext(ctx, "sh", append([]string{script}, args...)...) // #nosec G204 -- script path from trusted local manifest + } + out, err := c.CombinedOutput() + if err != nil { + return string(out), fmt.Errorf("extension %s/%s failed: %w: %s", ext.Name, cmd.Name, err, string(out)) + } + return string(out), nil +} + +// FindCommand looks up an extension command by name. +func FindCommand(name string) (Manifest, Command, bool) { + exts, err := Load() + if err != nil { + return Manifest{}, Command{}, false + } + for _, e := range exts { + for _, c := range e.Commands { + if c.Name == name { + return e, c, true + } + } + } + return Manifest{}, Command{}, false +} + +// ListCommandNames returns all extension command names. +func ListCommandNames() []string { + exts, _ := Load() + var out []string + for _, e := range exts { + for _, c := range e.Commands { + out = append(out, c.Name) + } + } + return out +} diff --git a/internal/extensions/extensions_test.go b/internal/extensions/extensions_test.go new file mode 100644 index 000000000..87b76265f --- /dev/null +++ b/internal/extensions/extensions_test.go @@ -0,0 +1,41 @@ +package extensions + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadEmpty(t *testing.T) { + dir := t.TempDir() + t.Setenv("REFLECT_CONFIG_DIR", dir) + os.MkdirAll(filepath.Join(dir, "extensions"), 0o755) + exts, err := Load() + if err != nil { + t.Fatal(err) + } + if len(exts) != 0 { + t.Fatalf("expected 0, got %d", len(exts)) + } +} + +func TestLoadAndRun(t *testing.T) { + dir := t.TempDir() + t.Setenv("REFLECT_CONFIG_DIR", dir) + extDir := filepath.Join(dir, "extensions", "myext") + os.MkdirAll(extDir, 0o755) + os.WriteFile(filepath.Join(extDir, "hello.py"), []byte("import sys; print('hi ' + ' '.join(sys.argv[1:]))"), 0o644) + manifest := `{"name":"myext","description":"test","commands":[{"name":"hello","description":"says hi","script":"hello.py"}]}` + os.WriteFile(filepath.Join(extDir, "manifest.json"), []byte(manifest), 0o644) + exts, err := Load() + if err != nil || len(exts) != 1 { + t.Fatalf("load: %v len=%d", err, len(exts)) + } + out, err := RunCommand(t.Context(), exts[0], exts[0].Commands[0], []string{"world"}) + if err != nil { + t.Fatal(err) + } + if out != "hi world\n" { + t.Fatalf("got %q", out) + } +}