Skip to content

Commit

Permalink
feat: extension impl (#41)
Browse files Browse the repository at this point in the history
* feat: extension impl

* update readme
  • Loading branch information
XiaoConstantine authored Jul 11, 2024
1 parent f534b9e commit fbb982c
Show file tree
Hide file tree
Showing 4 changed files with 408 additions and 3 deletions.
64 changes: 63 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

- `install`: Installs packages and tools.
- `configure`: Sets up configurations for tools like zsh, Neovim, etc.
- `extension`: (TODO) Extends functionality to support project build systems and integrate AI assistants.
- `extension`: Extends functionality to support project build systems, editor and integrate AI assistants, etc.

## Features

Expand Down Expand Up @@ -64,6 +64,68 @@ configure:
install_path: "~/.config/nvim/init.vim"
```
### Extension
mycli supports a powerful extension system that allows you to add custom functionality to the CLI.
#### Developing Extensions
To create a new extension for mycli:
1. Create a new directory for your extension:
2. Create an executable file named `mycli-myextension` (replace "myextension" with your extension name):
3. Edit the file and add your extension logic. Here's a simple example in bash:
```bash
#!/bin/bash
echo "Hello from myextension!"
echo "Arguments received: $@"
```
4. You can use any programming language to create your extension, as long as the file is executable and follows the naming convention mycli-<extensionname>.

#### Installing extension
To install an extension:

1. Use the mycli extension install command:
```bash
mycli extension install <repository-url>
```
Replace <repository-url> with the URL of the Git repository containing your extension.

2. The extension will be cloned into the mycli extensions directory (usually ~/.mycli/extensions/).

#### Using extension
Once an extension is installed, you can use it directly through mycli:
```bash
mycli myextension [arguments]
```

Replace myextension with the name of your extension and add any arguments it accepts.

#### Managing extension
* List installed extensions:
```bash
mycli extension list
```

* Update an extension:
```bash
mycli extension update <extension-name>
```

* Remove an extension:
```bash
mycli extension remove <extension-name>
```

#### Example extension structure
```bash
mycli-myextension/
├── mycli-myextension (executable)
├── README.md
├── LICENSE
└── tests/
└── test_myextension.sh
```

## Development
Ensure you have Go version 1.21 or higher installed. You can check your Go version by running:

Expand Down
157 changes: 157 additions & 0 deletions pkg/commands/extensions/extension.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package extensions

import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"

"github.com/XiaoConstantine/mycli/pkg/iostreams"
"github.com/spf13/cobra"
)

const ExtensionPrefix = "mycli-"

type Extension struct {
Name string
Path string
}

func isExecutable(path string) bool {
info, err := os.Stat(path)
if err != nil {
return false
}
return info.Mode().IsRegular() && (info.Mode().Perm()&0111 != 0)
}

func IsExtension(path string) bool {
base := filepath.Base(path)
return strings.HasPrefix(base, ExtensionPrefix) && isExecutable(path)
}

func GetExtensionsDir() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".mycli", "extensions")
}

func (e *Extension) Execute(args []string) error {
cmd := exec.Command(e.Path, args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}

func NewCmdExtension(iostream *iostreams.IOStreams) *cobra.Command {
cmd := &cobra.Command{
Use: "extension",
Short: "Manage mycli extensions",
}

cmd.AddCommand(newExtensionInstallCmd(iostream))
cmd.AddCommand(newExtensionListCmd(iostream))
cmd.AddCommand(newExtensionRemoveCmd(iostream))
cmd.AddCommand(newExtensionUpdateCmd(iostream))

return cmd
}

func newExtensionInstallCmd(iostream *iostreams.IOStreams) *cobra.Command {
return &cobra.Command{
Use: "install <repository>",
Short: "Install a mycli extension",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
repo := args[0]
extDir := GetExtensionsDir()
extName := filepath.Base(repo)
extPath := filepath.Join(extDir, ExtensionPrefix+extName)

if err := os.MkdirAll(extDir, 0755); err != nil {
return fmt.Errorf("failed to create extensions directory: %w", err)
}

gitCmd := exec.Command("git", "clone", repo, extPath)
gitCmd.Stdout = iostream.Out
gitCmd.Stderr = iostream.ErrOut

if err := gitCmd.Run(); err != nil {
return fmt.Errorf("failed to clone extension repository: %w", err)
}

fmt.Fprintf(iostream.Out, "Successfully installed extension '%s'\n", extName)
return nil
},
}
}

func newExtensionListCmd(iostream *iostreams.IOStreams) *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List installed mycli extensions",
RunE: func(cmd *cobra.Command, args []string) error {
extDir := GetExtensionsDir()
entries, err := os.ReadDir(extDir)
if err != nil {
if os.IsNotExist(err) {
fmt.Fprintln(iostream.Out, "No extensions installed")
return nil
}
return fmt.Errorf("failed to read extensions directory: %w", err)
}

for _, entry := range entries {
if entry.IsDir() && strings.HasPrefix(entry.Name(), ExtensionPrefix) {
fmt.Fprintln(iostream.Out, entry.Name()[len(ExtensionPrefix):])
}
}
return nil
},
}
}

func newExtensionRemoveCmd(iostream *iostreams.IOStreams) *cobra.Command {
return &cobra.Command{
Use: "remove <extension-name>",
Short: "Remove a mycli extension",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
extName := args[0]
extDir := GetExtensionsDir()
extPath := filepath.Join(extDir, ExtensionPrefix+extName)

if err := os.RemoveAll(extPath); err != nil {
return fmt.Errorf("failed to remove extension: %w", err)
}

fmt.Fprintf(iostream.Out, "Successfully removed extension '%s'\n", extName)
return nil
},
}
}

func newExtensionUpdateCmd(iostream *iostreams.IOStreams) *cobra.Command {
return &cobra.Command{
Use: "update <extension-name>",
Short: "Update a mycli extension",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
extName := args[0]
extDir := GetExtensionsDir()
extPath := filepath.Join(extDir, ExtensionPrefix+extName)

gitCmd := exec.Command("git", "-C", extPath, "pull")
gitCmd.Stdout = iostream.Out
gitCmd.Stderr = iostream.ErrOut

if err := gitCmd.Run(); err != nil {
return fmt.Errorf("failed to update extension: %w", err)
}

fmt.Fprintf(iostream.Out, "Successfully updated extension '%s'\n", extName)
return nil
},
}
}
155 changes: 155 additions & 0 deletions pkg/commands/extensions/extension_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package extensions

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
)

func TestGetExtensionsDir(t *testing.T) {
home, err := os.UserHomeDir()
assert.NoError(t, err)

expected := filepath.Join(home, ".mycli", "extensions")
result := GetExtensionsDir()

assert.Equal(t, expected, result)
}

func TestExtensionExecute(t *testing.T) {
// Create a temporary directory for testing
tempDir, err := os.MkdirTemp("", "mycli-test")
assert.NoError(t, err)
defer os.RemoveAll(tempDir)

// Create a mock executable file
mockExecutable := filepath.Join(tempDir, ExtensionPrefix+"mock")
mockContent := []byte("#!/bin/sh\necho 'Mock executed'")
err = os.WriteFile(mockExecutable, mockContent, 0755)
assert.NoError(t, err)

ext := &Extension{
Name: "mock",
Path: mockExecutable,
}

// Test execution
err = ext.Execute([]string{"arg1", "arg2"})
assert.NoError(t, err)

// Test execution with non-existent file
ext.Path = filepath.Join(tempDir, "non-existent")
err = ext.Execute([]string{})
assert.Error(t, err)
}

func TestIsExecutable(t *testing.T) {
// Create a temporary directory for testing
tempDir, err := os.MkdirTemp("", "mycli-test")
assert.NoError(t, err)
defer os.RemoveAll(tempDir)

// Test cases
testCases := []struct {
name string
fileName string
perms os.FileMode
expected bool
}{
{"Executable file", "exec", 0755, true},
{"Non-executable file", "non-exec", 0644, false},
{"Directory", "dir", 0755, false},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
path := filepath.Join(tempDir, tc.fileName)
if tc.name == "Directory" {
err = os.Mkdir(path, tc.perms)
} else {
err = os.WriteFile(path, []byte("test content"), tc.perms)
}
assert.NoError(t, err)

result := isExecutable(path)
assert.Equal(t, tc.expected, result)
})
}

// Test non-existent file
t.Run("Non-existent file", func(t *testing.T) {
result := isExecutable(filepath.Join(tempDir, "non-existent"))
assert.False(t, result)
})
}

func TestIsExtension(t *testing.T) {
// Create a temporary directory for testing
tempDir, err := os.MkdirTemp("", "mycli-test")
assert.NoError(t, err)
defer os.RemoveAll(tempDir)

// Test cases
testCases := []struct {
name string
fileName string
setup func(string) error
expected bool
}{
{
name: "Valid extension",
fileName: ExtensionPrefix + "test",
setup: func(path string) error {
return os.WriteFile(path, []byte("test content"), 0755)
},
expected: true,
},
{
name: "Non-executable extension",
fileName: ExtensionPrefix + "test",
setup: func(path string) error {
if err := os.WriteFile(path, []byte("test content"), 0644); err != nil {
return err
}
// Explicitly remove execute permissions
return os.Chmod(path, 0644)
},
expected: false,
},
{
name: "Non-prefix file",
fileName: "test",
setup: func(path string) error {
return os.WriteFile(path, []byte("test content"), 0755)
},
expected: false,
},
{
name: "Directory with prefix",
fileName: ExtensionPrefix + "dir",
setup: func(path string) error {
return os.Mkdir(path, 0755)
},
expected: false,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
path := filepath.Join(tempDir, tc.fileName)
err := tc.setup(path)
assert.NoError(t, err)

result := IsExtension(path)
assert.Equal(t, tc.expected, result)
})
}

// Test non-existent file
t.Run("Non-existent file", func(t *testing.T) {
result := IsExtension(filepath.Join(tempDir, "non-existent"))
assert.False(t, result)
})
}
Loading

0 comments on commit fbb982c

Please sign in to comment.