Skip to content
Merged
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
44 changes: 44 additions & 0 deletions cmd/chat_subcommand_extensions.go
Original file line number Diff line number Diff line change
@@ -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{})
}
5 changes: 5 additions & 0 deletions go.mod

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

116 changes: 116 additions & 0 deletions internal/extensions/extensions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Package extensions provides a lightweight extension system inspired by
// Mirror's Python extensions. Extensions live in ~/.reflect/extensions/<name>/
// 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
}
41 changes: 41 additions & 0 deletions internal/extensions/extensions_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading