diff --git a/README.md b/README.md index 6c7ca3d..4f50e24 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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-. + +#### Installing extension +To install an extension: + +1. Use the mycli extension install command: +```bash +mycli extension install +``` +Replace 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 +``` + +* Remove an extension: +```bash +mycli extension remove +``` + +#### 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: diff --git a/pkg/commands/extensions/extension.go b/pkg/commands/extensions/extension.go new file mode 100644 index 0000000..6e9d1a9 --- /dev/null +++ b/pkg/commands/extensions/extension.go @@ -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 ", + 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 ", + 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 ", + 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 + }, + } +} diff --git a/pkg/commands/extensions/extension_test.go b/pkg/commands/extensions/extension_test.go new file mode 100644 index 0000000..c92c3f8 --- /dev/null +++ b/pkg/commands/extensions/extension_test.go @@ -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) + }) +} diff --git a/pkg/commands/root/root.go b/pkg/commands/root/root.go index 70eeef2..a50232e 100644 --- a/pkg/commands/root/root.go +++ b/pkg/commands/root/root.go @@ -7,8 +7,11 @@ import ( "context" "errors" "fmt" + "os" + "path/filepath" "github.com/XiaoConstantine/mycli/pkg/build" + "github.com/XiaoConstantine/mycli/pkg/commands/extensions" "github.com/XiaoConstantine/mycli/pkg/commands/install" "github.com/XiaoConstantine/mycli/pkg/commands/update" "github.com/XiaoConstantine/mycli/pkg/iostreams" @@ -48,6 +51,22 @@ func NewRootCmd(iostream *iostreams.IOStreams) (*cobra.Command, error) { SilenceErrors: true, SilenceUsage: true, Version: fmt.Sprintf("%s (%s) - built on %s", build.Version, build.Commit, build.Date), + RunE: func(cmd *cobra.Command, args []string) error { + // Check if the first argument is an extension + if len(args) == 0 { + return nil + } + extName := args[0] + extDir := extensions.GetExtensionsDir() + extPath := filepath.Join(extDir, extensions.ExtensionPrefix+extName) + + if _, err := os.Stat(extPath); err == nil { + ext := &extensions.Extension{Name: extName, Path: extPath} + return ext.Execute(args[1:]) + } + + return nil + }, } rootCmd.AddGroup( &cobra.Group{ @@ -66,6 +85,11 @@ func NewRootCmd(iostream *iostreams.IOStreams) (*cobra.Command, error) { Title: "Update command", }) + rootCmd.AddGroup(&cobra.Group{ + ID: "extension", + Title: "Extension commands", + }) + installCmd := install.NewInstallCmd(iostream) configureCmd := configure.NewConfigureCmd(iostream) updateCmd := update.NewUpdateCmd(iostream) @@ -73,6 +97,14 @@ func NewRootCmd(iostream *iostreams.IOStreams) (*cobra.Command, error) { rootCmd.AddCommand(installCmd) rootCmd.AddCommand(configureCmd) rootCmd.AddCommand(updateCmd) + + // Add extension management command + extCmd := extensions.NewCmdExtension(iostream) + if extCmd != nil { + rootCmd.AddCommand(extCmd) + } else { + return nil, fmt.Errorf("failed to create extension command") + } rootCmd.PersistentFlags().Bool("help", false, "Show help for command") rootCmd.PersistentFlags().BoolVar(&nonInteractive, "non-interactive", false, "Run in non-interactive mode") @@ -130,7 +162,7 @@ func Run(args []string) ExitCode { nonInteractive = true } // If args are provided or --help flag is set, execute the command directly - if len(args) > 0 || rootCmd.Flags().Lookup("help").Changed { + if len(args) > 0 { if _, err := rootCmd.ExecuteContextC(ctx); err != nil { handleExecutionError(err, iostream) return exitError @@ -138,7 +170,6 @@ func Run(args []string) ExitCode { return exitOK } return runInteractiveMode(rootCmd, ctx, iostream, options) - } func runInteractiveMode(rootCmd *cobra.Command, ctx context.Context, iostream *iostreams.IOStreams, options []string) ExitCode {