From eb79cd5fbc6c643aa3052d841eded6a5ce23cc84 Mon Sep 17 00:00:00 2001 From: samjtro Date: Thu, 14 Aug 2025 12:54:05 -0500 Subject: [PATCH 1/3] feat: add interactive mode skeleton --- .gitignore | 1 + internal/cli/add_interactive.go | 412 ++++++++++++++++++++++++++++++-- internal/cli/delete.go | 1 + internal/cli/update.go | 384 +++++++++++++++++++++++++++-- 4 files changed, 757 insertions(+), 41 deletions(-) diff --git a/.gitignore b/.gitignore index e9f17ad..e8ca806 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ workflow*/ !internal/workflow/ !pkg/workflow/ .claude/ +test-results/ # Binaries for programs and plugins *.exe diff --git a/internal/cli/add_interactive.go b/internal/cli/add_interactive.go index d7be227..d83d890 100644 --- a/internal/cli/add_interactive.go +++ b/internal/cli/add_interactive.go @@ -17,7 +17,9 @@ package cli // Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. import ( + "bufio" "fmt" + "io" "os" "path/filepath" "strings" @@ -26,6 +28,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/rizome-dev/opun/internal/plugin" + "github.com/rizome-dev/opun/internal/promptgarden" "gopkg.in/yaml.v3" ) @@ -47,7 +50,17 @@ const ( itemTypeTool ) -// Step 1: Choose source (Local or Remote) +// Step 1: Choose add method (Interactive or From File) +type addMethodChoice struct { + title string + description string + isInteractive bool +} + +func (s addMethodChoice) FilterValue() string { return s.title } +func (s addMethodChoice) Title() string { return s.title } +func (s addMethodChoice) Description() string { return s.description } + type sourceChoice struct { title string description string @@ -71,37 +84,39 @@ func (i itemChoice) Description() string { return i.description } // Main interactive add model type interactiveAddModel struct { - step string // "source", "type", "path", "done" + step string // "method", "source", "type", "path", "done" + isInteractive bool source sourceType itemType itemType path string url string list list.Model + methodChoice *addMethodChoice sourceChoice *sourceChoice itemChoice *itemChoice err error } func initialInteractiveAddModel() interactiveAddModel { - // Start with source selection - sources := []list.Item{ - sourceChoice{ - title: "Local", - description: "Add from files on your local filesystem", - source: sourceLocal, + // Start with method selection + methods := []list.Item{ + addMethodChoice{ + title: "Create interactively", + description: "Create a new configuration interactively", + isInteractive: true, }, - sourceChoice{ - title: "Remote", - description: "Add from a URL (GitHub, web, etc.)", - source: sourceRemote, + addMethodChoice{ + title: "From a file", + description: "Add from a local or remote file", + isInteractive: false, }, } const defaultWidth = 60 - listHeight := len(sources)*3 + 8 + listHeight := len(methods)*3 + 8 - l := list.New(sources, list.NewDefaultDelegate(), defaultWidth, listHeight) - l.Title = "Where is the configuration located?" + l := list.New(methods, list.NewDefaultDelegate(), defaultWidth, listHeight) + l.Title = "How would you like to add a component?" l.SetShowStatusBar(false) l.SetFilteringEnabled(false) l.SetShowPagination(false) @@ -111,7 +126,7 @@ func initialInteractiveAddModel() interactiveAddModel { Padding(0, 1) return interactiveAddModel{ - step: "source", + step: "method", list: l, } } @@ -128,6 +143,19 @@ func (m interactiveAddModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit case "enter": switch m.step { + case "method": + // Get selected method + if i, ok := m.list.SelectedItem().(addMethodChoice); ok { + m.methodChoice = &i + m.isInteractive = i.isInteractive + if m.isInteractive { + m.step = "type" + return m.createItemList(), nil + } else { + m.step = "source" + return m.createSourceList(), nil + } + } case "source": // Get selected source if i, ok := m.list.SelectedItem().(sourceChoice); ok { @@ -154,6 +182,37 @@ func (m interactiveAddModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } +func (m interactiveAddModel) createSourceList() interactiveAddModel { + sources := []list.Item{ + sourceChoice{ + title: "Local", + description: "Add from files on your local filesystem", + source: sourceLocal, + }, + sourceChoice{ + title: "Remote", + description: "Add from a URL (GitHub, web, etc.)", + source: sourceRemote, + }, + } + + const defaultWidth = 60 + listHeight := len(sources)*3 + 8 + + l := list.New(sources, list.NewDefaultDelegate(), defaultWidth, listHeight) + l.Title = "Where is the configuration located?" + l.SetShowStatusBar(false) + l.SetFilteringEnabled(false) + l.SetShowPagination(false) + l.Styles.Title = lipgloss.NewStyle(). + Background(lipgloss.Color("62")). + Foreground(lipgloss.Color("230")). + Padding(0, 1) + + m.list = l + return m +} + func (m interactiveAddModel) createItemList() interactiveAddModel { items := []list.Item{ itemChoice{ @@ -215,6 +274,10 @@ func RunInteractiveAdd() error { return fmt.Errorf("cancelled") } + if model.isInteractive { + return handleInteractiveCreate(model.itemType) + } + // Now handle based on source and type if model.source == sourceLocal { return handleLocalAdd(model.itemType) @@ -223,6 +286,293 @@ func RunInteractiveAdd() error { } } +// handleInteractiveCreate handles the interactive creation of components +func handleInteractiveCreate(itemType itemType) error { + switch itemType { + case itemTypeAction: + return handleInteractiveActionCreate() + case itemTypeTool: + return handleInteractiveToolCreate() + case itemTypePrompt: + return handleInteractivePromptCreate() + case itemTypeWorkflow: + return handleInteractiveWorkflowCreate() + } + return nil +} + +func handleInteractiveWorkflowCreate() error { + fmt.Println("Creating a new workflow interactively...") + + // Step 1: Basic workflow info + name, err := Prompt("Enter workflow name:") + if err != nil { + return err + } + + description, err := Prompt("Enter workflow description:") + if err != nil { + return err + } + + command, err := Prompt("Enter command name (for /command usage):") + if err != nil { + return err + } + + // Step 2: Create first agent + fmt.Println("\nCreating the first agent...") + agents := []map[string]interface{}{} + + agent, err := createInteractiveAgent("agent1") + if err != nil { + return err + } + agents = append(agents, agent) + + // Step 3: Ask for additional agents + for { + addMore, err := Confirm("Do you want to add another agent?") + if err != nil { + return err + } + + if !addMore { + break + } + + agentID := fmt.Sprintf("agent%d", len(agents)+1) + agent, err := createInteractiveAgent(agentID) + if err != nil { + return err + } + agents = append(agents, agent) + } + + // Step 4: Create workflow structure + workflow := map[string]interface{}{ + "name": name, + "description": description, + "command": command, + "version": "1.0.0", + "agents": agents, + "settings": map[string]interface{}{ + "stop_on_error": true, + }, + } + + // Step 5: Save workflow + data, err := yaml.Marshal(workflow) + if err != nil { + return fmt.Errorf("failed to marshal workflow to yaml: %w", err) + } + + // Get workflows directory + home, err := os.UserHomeDir() + if err != nil { + return err + } + + workflowsDir := filepath.Join(home, ".opun", "workflows") + if err := os.MkdirAll(workflowsDir, 0755); err != nil { + return fmt.Errorf("failed to create workflows directory: %w", err) + } + + // Save workflow + destPath := filepath.Join(workflowsDir, name+".yaml") + if err := os.WriteFile(destPath, data, 0644); err != nil { + return fmt.Errorf("failed to save workflow: %w", err) + } + + fmt.Printf("āœ“ Added workflow '%s'\n", name) + fmt.Printf(" Saved to: %s\n", destPath) + fmt.Printf(" Access with: /%s\n", command) + + return nil +} + +func createInteractiveAgent(agentID string) (map[string]interface{}, error) { + provider, err := Prompt("Enter agent provider (claude/gemini):") + if err != nil { + return nil, err + } + + model, err := Prompt("Enter agent model (e.g., sonnet, opus, flash):") + if err != nil { + return nil, err + } + + prompt, err := MultilinePrompt("Enter agent prompt:") + if err != nil { + return nil, err + } + + agent := map[string]interface{}{ + "id": agentID, + "provider": provider, + "model": model, + "prompt": prompt, + "settings": map[string]interface{}{ + "temperature": 0.7, + "timeout": 300, + }, + } + + return agent, nil +} + +func handleInteractivePromptCreate() error { + name, err := Prompt("Enter a name for the prompt:") + if err != nil { + return err + } + + description, err := Prompt("Enter a description for the prompt:") + if err != nil { + return err + } + + content, err := MultilinePrompt("Enter the prompt content:") + if err != nil { + return fmt.Errorf("failed to read prompt content: %w", err) + } + + // Get prompt garden + home, err := os.UserHomeDir() + if err != nil { + return err + } + + gardenPath := filepath.Join(home, ".opun", "promptgarden") + garden, err := promptgarden.NewGarden(gardenPath) + if err != nil { + return fmt.Errorf("failed to access prompt garden: %w", err) + } + + // Create prompt + prompt := &promptgarden.Prompt{ + ID: name, + Name: name, + Content: content, + Metadata: promptgarden.PromptMetadata{ + Tags: extractTags(content), + Category: "user", + Version: "1.0.0", + Description: description, + }, + } + + // Save prompt + if err := garden.SavePrompt(prompt); err != nil { + return fmt.Errorf("failed to save prompt: %w", err) + } + + fmt.Printf("āœ“ Added prompt '%s' to prompt garden\n", name) + fmt.Printf(" Access with: promptgarden://%s\n", name) + + return nil +} + +func handleInteractiveToolCreate() error { + name, err := Prompt("Enter a name for the tool:") + if err != nil { + return err + } + + description, err := Prompt("Enter a description for the tool:") + if err != nil { + return err + } + + // Create the tool file content + tool := make(map[string]interface{}) + tool["name"] = name + tool["description"] = description + + data, err := yaml.Marshal(tool) + if err != nil { + return fmt.Errorf("failed to marshal tool to yaml: %w", err) + } + + // Get tools directory + home, err := os.UserHomeDir() + if err != nil { + return err + } + + toolsDir := filepath.Join(home, ".opun", "tools") + if err := os.MkdirAll(toolsDir, 0755); err != nil { + return fmt.Errorf("failed to create tools directory: %w", err) + } + + // Save tool + destPath := filepath.Join(toolsDir, name+".yaml") + if err := os.WriteFile(destPath, data, 0644); err != nil { + return fmt.Errorf("failed to save tool: %w", err) + } + + fmt.Printf("āœ“ Added tool '%s'\n", name) + fmt.Printf(" Saved to: %s\n", destPath) + + return nil +} + +func handleInteractiveActionCreate() error { + name, err := Prompt("Enter a name for the action:") + if err != nil { + return err + } + + description, err := Prompt("Enter a description for the action:") + if err != nil { + return err + } + + command, err := Prompt("Enter the command to execute:") + if err != nil { + return err + } + + args, err := Prompt("Enter the arguments for the command (space-separated):") + if err != nil { + return err + } + + // Create the action file content + action := make(map[string]interface{}) + action["name"] = name + action["description"] = description + action["command"] = command + action["args"] = strings.Split(args, " ") + + data, err := yaml.Marshal(action) + if err != nil { + return fmt.Errorf("failed to marshal action to yaml: %w", err) + } + + // Get actions directory + home, err := os.UserHomeDir() + if err != nil { + return err + } + + actionsDir := filepath.Join(home, ".opun", "actions") + if err := os.MkdirAll(actionsDir, 0755); err != nil { + return fmt.Errorf("failed to create actions directory: %w", err) + } + + // Save action + destPath := filepath.Join(actionsDir, name+".yaml") + if err := os.WriteFile(destPath, data, 0644); err != nil { + return fmt.Errorf("failed to save action: %w", err) + } + + fmt.Printf("āœ“ Added action '%s'\n", name) + fmt.Printf(" Saved to: %s\n", destPath) + + return nil +} + // handleLocalAdd handles adding from local filesystem func handleLocalAdd(itemType itemType) error { var prompt, fileExt string @@ -314,7 +664,7 @@ func installFromURL(url string, itemType itemType) error { } // Initialize remote installer - baseDir := filepath.Join(home, ".opun") + baseDir := filepath.Join(home, ".opun", "plugins") installer, err := plugin.NewRemoteInstaller(baseDir) if err != nil { return fmt.Errorf("failed to create installer: %w", err) @@ -383,3 +733,31 @@ func addTool(path, name string) error { return nil } + + +// MultilinePrompt prompts for multi-line input with Enter to add new lines and ctrl+d to finish +func MultilinePrompt(prompt string) (string, error) { + fmt.Printf("%s\n", prompt) + fmt.Println("(Press Enter to add new lines, Ctrl+D to finish)") + + var lines []string + reader := bufio.NewReader(os.Stdin) + + for { + line, err := reader.ReadString('\n') + if err != nil { + if err == io.EOF { + // User pressed Ctrl+D, finish input + break + } + return "", err + } + + // Remove the trailing newline for processing + line = strings.TrimSuffix(line, "\n") + lines = append(lines, line) + } + + return strings.Join(lines, "\n"), nil +} + diff --git a/internal/cli/delete.go b/internal/cli/delete.go index 6da4072..51ccc4f 100644 --- a/internal/cli/delete.go +++ b/internal/cli/delete.go @@ -883,3 +883,4 @@ func selectItemToDelete(items []deleteItem, itemType string) (*deleteItem, error return nil, fmt.Errorf("no selection made") } + diff --git a/internal/cli/update.go b/internal/cli/update.go index 5e580cc..46ee1e1 100644 --- a/internal/cli/update.go +++ b/internal/cli/update.go @@ -17,6 +17,7 @@ package cli // Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. import ( + "bufio" "fmt" "os" "path/filepath" @@ -29,6 +30,7 @@ import ( "github.com/rizome-dev/opun/internal/tools" "github.com/rizome-dev/opun/internal/workflow" "github.com/spf13/cobra" + "gopkg.in/yaml.v3" ) // UpdateCmd creates the update command @@ -249,6 +251,7 @@ const ( updateAddTypeWorkflow updateAddType = iota updateAddTypePrompt updateAddTypeAction + updateAddTypeTool ) // updateTypeItem represents a selectable item in the list @@ -380,46 +383,357 @@ func runInteractiveUpdate() error { return err } - // Step 4: Get new file path - path, err := FilePrompt(fmt.Sprintf("Select the new file for %s '%s':", typeChoice, selectedItem.name)) + // Step 4: Ask user if they want to replace with a new file or edit interactively + updateMethod, err := selectUpdateMethod() if err != nil { return err } - if path == "" { - return fmt.Errorf("path cannot be empty") + + if updateMethod == "file" { + // Step 4a: Get new file path + path, err := FilePrompt(fmt.Sprintf("Select the new file for %s '%s':", typeChoice, selectedItem.name)) + if err != nil { + return err + } + if path == "" { + return fmt.Errorf("path cannot be empty") + } + + // Check if file exists + if _, err := os.Stat(path); os.IsNotExist(err) { + return fmt.Errorf("file not found: %s", path) + } + + // Step 5a: Confirm update + confirm, err := Confirm(fmt.Sprintf("Update %s '%s' with contents from %s?", typeChoice, selectedItem.name, filepath.Base(path))) + if err != nil { + return err + } + if !confirm { + fmt.Println("Update cancelled.") + return nil + } + + // Step 6a: Execute the update + fmt.Printf("\nšŸ“ Updating %s...\n", typeChoice) + + switch typeChoice { + case "workflow": + return updateWorkflow(selectedItem.name, path) + case "prompt": + return updatePrompt(selectedItem.name, path) + case "tool": + return updateAction(selectedItem.name, path) + default: + return fmt.Errorf("unknown type: %s", typeChoice) + } + } else { + // Step 4b: Execute interactive update + return runInteractiveEdit(selectedItem) } +} - // Check if file exists - if _, err := os.Stat(path); err != nil { - return fmt.Errorf("file not found: %s", path) +func selectUpdateMethod() (string, error) { + items := []list.Item{ + updateTypeItem{ + title: "Replace with a new file", + description: "Update the component from a local file", + }, + updateTypeItem{ + title: "Edit interactively", + description: "Edit the component fields interactively", + }, } - // Step 5: Confirm update - confirm, err := Confirm(fmt.Sprintf("Update %s '%s' with contents from %s?", typeChoice, selectedItem.name, filepath.Base(path))) + const defaultWidth = 60 + listHeight := len(items)*3 + 8 + + l := list.New(items, list.NewDefaultDelegate(), defaultWidth, listHeight) + l.Title = "How would you like to update the component?" + l.SetShowStatusBar(false) + l.SetFilteringEnabled(false) + l.SetShowPagination(false) + l.Styles.Title = lipgloss.NewStyle(). + Background(lipgloss.Color("62")). + Foreground(lipgloss.Color("230")). + Padding(0, 1) + + model := addModel{list: l, state: "choosing"} + + p := tea.NewProgram(model) + result, err := p.Run() if err != nil { - return err + return "", err } - if !confirm { - fmt.Println("Update cancelled.") - return nil + + if m, ok := result.(addModel); ok && m.choice != nil { + if m.choice.title == "Replace with a new file" { + return "file", nil + } else { + return "interactive", nil + } } - // Step 6: Execute the update - fmt.Printf("\nšŸ“ Updating %s...\n", typeChoice) + return "", fmt.Errorf("no selection made") +} - switch typeChoice { - case "workflow": - return updateWorkflow(selectedItem.name, path) - case "prompt": - return updatePrompt(selectedItem.name, path) +func runInteractiveEdit(item *updateItem) error { + switch item.itemType { + case "action": + return handleInteractiveActionEdit(item) case "tool": - return updateAction(selectedItem.name, path) - default: - return fmt.Errorf("unknown type: %s", typeChoice) + return handleInteractiveToolEdit(item) + case "prompt": + return handleInteractivePromptEdit(item) + case "workflow": + return handleInteractiveWorkflowEdit(item) } + return nil } -// selectUpdateType lets user choose between workflow, prompt, and tool +func handleInteractiveWorkflowEdit(item *updateItem) error { + // Get workflows directory + home, err := os.UserHomeDir() + if err != nil { + return err + } + + workflowsDir := filepath.Join(home, ".opun", "workflows") + workflowPath := filepath.Join(workflowsDir, item.name+".yaml") + + // Read existing workflow + data, err := os.ReadFile(workflowPath) + if err != nil { + return fmt.Errorf("failed to read workflow file: %w", err) + } + + var workflow map[string]interface{} + if err := yaml.Unmarshal(data, &workflow); err != nil { + return fmt.Errorf("failed to unmarshal workflow yaml: %w", err) + } + + // Edit basic info + name, err := PromptWithDefault("Enter a new name for the workflow:", getStringField(workflow, "name")) + if err != nil { + return err + } + + description, err := PromptWithDefault("Enter a new description for the workflow:", getStringField(workflow, "description")) + if err != nil { + return err + } + + command, err := PromptWithDefault("Enter a new command name:", getStringField(workflow, "command")) + if err != nil { + return err + } + + // Update workflow + workflow["name"] = name + workflow["description"] = description + workflow["command"] = command + + // Ask if user wants to edit agents + editAgents, err := Confirm("Do you want to edit the agents in this workflow?") + if err != nil { + return err + } + + if editAgents { + // This would be complex to implement fully - for now just show info + fmt.Println("Agent editing in interactive mode is not yet fully implemented.") + fmt.Println("For now, you can update the workflow file directly or use the file replacement option.") + } + + newData, err := yaml.Marshal(workflow) + if err != nil { + return fmt.Errorf("failed to marshal workflow to yaml: %w", err) + } + + // Save updated workflow + if err := os.WriteFile(workflowPath, newData, 0644); err != nil { + return fmt.Errorf("failed to save workflow: %w", err) + } + + fmt.Printf("āœ“ Updated workflow '%s'\n", name) + + return nil +} + +func getStringField(m map[string]interface{}, key string) string { + if val, ok := m[key]; ok { + if str, ok := val.(string); ok { + return str + } + } + return "" +} + +func handleInteractivePromptEdit(item *updateItem) error { + // Get prompt garden + home, err := os.UserHomeDir() + if err != nil { + return err + } + + gardenPath := filepath.Join(home, ".opun", "promptgarden") + garden, err := promptgarden.NewGarden(gardenPath) + if err != nil { + return fmt.Errorf("failed to access prompt garden: %w", err) + } + + // Get existing prompt + prompt, err := garden.GetPrompt(item.name) + if err != nil { + return fmt.Errorf("failed to get prompt: %w", err) + } + + // Prompt for new values + name, err := PromptWithDefault("Enter a new name for the prompt:", prompt.Name) + if err != nil { + return err + } + + description, err := PromptWithDefault("Enter a new description for the prompt:", prompt.Metadata.Description) + if err != nil { + return err + } + + content, err := MultilinePrompt("Enter the new prompt content:") + if err != nil { + return fmt.Errorf("failed to read prompt content: %w", err) + } + + // Update prompt + prompt.Name = name + prompt.Metadata.Description = description + prompt.Content = content + + // Save updated prompt + if err := garden.SavePrompt(prompt); err != nil { + return fmt.Errorf("failed to save prompt: %w", err) + } + + fmt.Printf("āœ“ Updated prompt '%s'\n", name) + + return nil +} + +func handleInteractiveToolEdit(item *updateItem) error { + // Get tools directory + home, err := os.UserHomeDir() + if err != nil { + return err + } + + toolsDir := filepath.Join(home, ".opun", "tools") + toolPath := filepath.Join(toolsDir, item.name+".yaml") + + // Read existing tool + data, err := os.ReadFile(toolPath) + if err != nil { + return fmt.Errorf("failed to read tool file: %w", err) + } + + var tool map[string]interface{} + if err := yaml.Unmarshal(data, &tool); err != nil { + return fmt.Errorf("failed to unmarshal tool yaml: %w", err) + } + + // Prompt for new values + name, err := PromptWithDefault("Enter a new name for the tool:", tool["name"].(string)) + if err != nil { + return err + } + + description, err := PromptWithDefault("Enter a new description for the tool:", tool["description"].(string)) + if err != nil { + return err + } + + // Update tool + tool["name"] = name + tool["description"] = description + + newData, err := yaml.Marshal(tool) + if err != nil { + return fmt.Errorf("failed to marshal tool to yaml: %w", err) + } + + // Save updated tool + if err := os.WriteFile(toolPath, newData, 0644); err != nil { + return fmt.Errorf("failed to save tool: %w", err) + } + + fmt.Printf("āœ“ Updated tool '%s'\n", name) + + return nil +} + +func handleInteractiveActionEdit(item *updateItem) error { + // Get actions directory + home, err := os.UserHomeDir() + if err != nil { + return err + } + + actionsDir := filepath.Join(home, ".opun", "actions") + actionPath := filepath.Join(actionsDir, item.name+".yaml") + + // Read existing action + data, err := os.ReadFile(actionPath) + if err != nil { + return fmt.Errorf("failed to read action file: %w", err) + } + + var action map[string]interface{} + if err := yaml.Unmarshal(data, &action); err != nil { + return fmt.Errorf("failed to unmarshal action yaml: %w", err) + } + + // Prompt for new values + name, err := PromptWithDefault("Enter a new name for the action:", action["name"].(string)) + if err != nil { + return err + } + + description, err := PromptWithDefault("Enter a new description for the action:", action["description"].(string)) + if err != nil { + return err + } + + command, err := PromptWithDefault("Enter a new command to execute:", action["command"].(string)) + if err != nil { + return err + } + + args, err := PromptWithDefault("Enter new arguments for the command (space-separated):", strings.Join(action["args"].([]string), " ")) + if err != nil { + return err + } + + // Update action + action["name"] = name + action["description"] = description + action["command"] = command + action["args"] = strings.Split(args, " ") + + newData, err := yaml.Marshal(action) + if err != nil { + return fmt.Errorf("failed to marshal action to yaml: %w", err) + } + + // Save updated action + if err := os.WriteFile(actionPath, newData, 0644); err != nil { + return fmt.Errorf("failed to save action: %w", err) + } + + fmt.Printf("āœ“ Updated action '%s'\n", name) + + return nil +} + +// selectUpdateType lets user choose between workflow, prompt, action, and tool func selectUpdateType() (string, error) { items := []list.Item{ updateTypeItem{ @@ -437,6 +751,11 @@ func selectUpdateType() (string, error) { description: "Update an existing action", addType: updateAddTypeAction, }, + updateTypeItem{ + title: "Tool", + description: "Update an existing tool", + addType: updateAddTypeTool, + }, } const defaultWidth = 60 @@ -468,6 +787,8 @@ func selectUpdateType() (string, error) { return "prompt", nil case updateAddTypeAction: return "action", nil + case updateAddTypeTool: + return "tool", nil } } @@ -663,3 +984,18 @@ func selectItemToUpdate(items []updateItem, itemType string) (*updateItem, error return nil, fmt.Errorf("no selection made") } + +func PromptWithDefault(prompt, defaultValue string) (string, error) { + fmt.Printf("%s (%s) ", prompt, defaultValue) + reader := bufio.NewReader(os.Stdin) + input, err := reader.ReadString('\n') + if err != nil { + return "", err + } + input = strings.TrimSpace(input) + if input == "" { + return defaultValue, nil + } + return input, nil +} + From a995b9823f02699d9aabf8ddd480df58b77c5ecc Mon Sep 17 00:00:00 2001 From: samjtro Date: Sat, 16 Aug 2025 12:10:39 -0500 Subject: [PATCH 2/3] chore: cleanup; running fmt/lint/vet, etc. --- internal/cli/add_interactive.go | 80 +++++++++++------------ internal/cli/delete.go | 1 - internal/cli/run.go | 8 +-- internal/cli/update.go | 27 ++++---- internal/pty/automator.go | 2 +- internal/pty/automator_test.go | 2 +- internal/pty/providers/gemini.go | 2 +- internal/workflow/interactive_executor.go | 18 ++--- test/e2e/workflow_cancellation_test.go | 2 +- 9 files changed, 69 insertions(+), 73 deletions(-) diff --git a/internal/cli/add_interactive.go b/internal/cli/add_interactive.go index d83d890..ba2c274 100644 --- a/internal/cli/add_interactive.go +++ b/internal/cli/add_interactive.go @@ -52,8 +52,8 @@ const ( // Step 1: Choose add method (Interactive or From File) type addMethodChoice struct { - title string - description string + title string + description string isInteractive bool } @@ -84,30 +84,30 @@ func (i itemChoice) Description() string { return i.description } // Main interactive add model type interactiveAddModel struct { - step string // "method", "source", "type", "path", "done" + step string // "method", "source", "type", "path", "done" isInteractive bool - source sourceType - itemType itemType - path string - url string - list list.Model - methodChoice *addMethodChoice - sourceChoice *sourceChoice - itemChoice *itemChoice - err error + source sourceType + itemType itemType + path string + url string + list list.Model + methodChoice *addMethodChoice + sourceChoice *sourceChoice + itemChoice *itemChoice + err error } func initialInteractiveAddModel() interactiveAddModel { // Start with method selection methods := []list.Item{ addMethodChoice{ - title: "Create interactively", - description: "Create a new configuration interactively", + title: "Create interactively", + description: "Create a new configuration interactively", isInteractive: true, }, addMethodChoice{ - title: "From a file", - description: "Add from a local or remote file", + title: "From a file", + description: "Add from a local or remote file", isInteractive: false, }, } @@ -303,44 +303,44 @@ func handleInteractiveCreate(itemType itemType) error { func handleInteractiveWorkflowCreate() error { fmt.Println("Creating a new workflow interactively...") - + // Step 1: Basic workflow info name, err := Prompt("Enter workflow name:") if err != nil { return err } - + description, err := Prompt("Enter workflow description:") if err != nil { return err } - + command, err := Prompt("Enter command name (for /command usage):") if err != nil { return err } - + // Step 2: Create first agent fmt.Println("\nCreating the first agent...") agents := []map[string]interface{}{} - + agent, err := createInteractiveAgent("agent1") if err != nil { return err } agents = append(agents, agent) - + // Step 3: Ask for additional agents for { addMore, err := Confirm("Do you want to add another agent?") if err != nil { return err } - + if !addMore { break } - + agentID := fmt.Sprintf("agent%d", len(agents)+1) agent, err := createInteractiveAgent(agentID) if err != nil { @@ -348,7 +348,7 @@ func handleInteractiveWorkflowCreate() error { } agents = append(agents, agent) } - + // Step 4: Create workflow structure workflow := map[string]interface{}{ "name": name, @@ -360,34 +360,34 @@ func handleInteractiveWorkflowCreate() error { "stop_on_error": true, }, } - + // Step 5: Save workflow data, err := yaml.Marshal(workflow) if err != nil { return fmt.Errorf("failed to marshal workflow to yaml: %w", err) } - + // Get workflows directory home, err := os.UserHomeDir() if err != nil { return err } - + workflowsDir := filepath.Join(home, ".opun", "workflows") if err := os.MkdirAll(workflowsDir, 0755); err != nil { return fmt.Errorf("failed to create workflows directory: %w", err) } - + // Save workflow destPath := filepath.Join(workflowsDir, name+".yaml") if err := os.WriteFile(destPath, data, 0644); err != nil { return fmt.Errorf("failed to save workflow: %w", err) } - + fmt.Printf("āœ“ Added workflow '%s'\n", name) fmt.Printf(" Saved to: %s\n", destPath) fmt.Printf(" Access with: /%s\n", command) - + return nil } @@ -396,17 +396,17 @@ func createInteractiveAgent(agentID string) (map[string]interface{}, error) { if err != nil { return nil, err } - + model, err := Prompt("Enter agent model (e.g., sonnet, opus, flash):") if err != nil { return nil, err } - + prompt, err := MultilinePrompt("Enter agent prompt:") if err != nil { return nil, err } - + agent := map[string]interface{}{ "id": agentID, "provider": provider, @@ -417,7 +417,7 @@ func createInteractiveAgent(agentID string) (map[string]interface{}, error) { "timeout": 300, }, } - + return agent, nil } @@ -734,15 +734,14 @@ func addTool(path, name string) error { return nil } - // MultilinePrompt prompts for multi-line input with Enter to add new lines and ctrl+d to finish func MultilinePrompt(prompt string) (string, error) { fmt.Printf("%s\n", prompt) fmt.Println("(Press Enter to add new lines, Ctrl+D to finish)") - + var lines []string reader := bufio.NewReader(os.Stdin) - + for { line, err := reader.ReadString('\n') if err != nil { @@ -752,12 +751,11 @@ func MultilinePrompt(prompt string) (string, error) { } return "", err } - + // Remove the trailing newline for processing line = strings.TrimSuffix(line, "\n") lines = append(lines, line) } - + return strings.Join(lines, "\n"), nil } - diff --git a/internal/cli/delete.go b/internal/cli/delete.go index 51ccc4f..6da4072 100644 --- a/internal/cli/delete.go +++ b/internal/cli/delete.go @@ -883,4 +883,3 @@ func selectItemToDelete(items []deleteItem, itemType string) (*deleteItem, error return nil, fmt.Errorf("no selection made") } - diff --git a/internal/cli/run.go b/internal/cli/run.go index 3599141..65db552 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -110,7 +110,7 @@ func runWorkflow(name string, vars map[string]string) error { sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) defer signal.Stop(sigChan) - + // Track if we're handling a signal signalHandled := make(chan struct{}) go func() { @@ -122,12 +122,12 @@ func runWorkflow(name string, vars map[string]string) error { // Execute workflow execErr := executor.Execute(ctx, wf, variables) - + // Always ensure terminal is restored, whether we succeeded or failed if term.IsTerminal(int(os.Stdin.Fd())) { _ = exec.Command("stty", "sane").Run() } - + // Check if we were interrupted select { case <-signalHandled: @@ -137,7 +137,7 @@ func runWorkflow(name string, vars map[string]string) error { default: // Normal completion or error } - + if execErr != nil { return fmt.Errorf("workflow execution failed: %w", execErr) } diff --git a/internal/cli/update.go b/internal/cli/update.go index 46ee1e1..3e45a9e 100644 --- a/internal/cli/update.go +++ b/internal/cli/update.go @@ -497,66 +497,66 @@ func handleInteractiveWorkflowEdit(item *updateItem) error { if err != nil { return err } - + workflowsDir := filepath.Join(home, ".opun", "workflows") workflowPath := filepath.Join(workflowsDir, item.name+".yaml") - + // Read existing workflow data, err := os.ReadFile(workflowPath) if err != nil { return fmt.Errorf("failed to read workflow file: %w", err) } - + var workflow map[string]interface{} if err := yaml.Unmarshal(data, &workflow); err != nil { return fmt.Errorf("failed to unmarshal workflow yaml: %w", err) } - + // Edit basic info name, err := PromptWithDefault("Enter a new name for the workflow:", getStringField(workflow, "name")) if err != nil { return err } - + description, err := PromptWithDefault("Enter a new description for the workflow:", getStringField(workflow, "description")) if err != nil { return err } - + command, err := PromptWithDefault("Enter a new command name:", getStringField(workflow, "command")) if err != nil { return err } - + // Update workflow workflow["name"] = name workflow["description"] = description workflow["command"] = command - + // Ask if user wants to edit agents editAgents, err := Confirm("Do you want to edit the agents in this workflow?") if err != nil { return err } - + if editAgents { // This would be complex to implement fully - for now just show info fmt.Println("Agent editing in interactive mode is not yet fully implemented.") fmt.Println("For now, you can update the workflow file directly or use the file replacement option.") } - + newData, err := yaml.Marshal(workflow) if err != nil { return fmt.Errorf("failed to marshal workflow to yaml: %w", err) } - + // Save updated workflow if err := os.WriteFile(workflowPath, newData, 0644); err != nil { return fmt.Errorf("failed to save workflow: %w", err) } - + fmt.Printf("āœ“ Updated workflow '%s'\n", name) - + return nil } @@ -998,4 +998,3 @@ func PromptWithDefault(prompt, defaultValue string) (string, error) { } return input, nil } - diff --git a/internal/pty/automator.go b/internal/pty/automator.go index 0c9b03f..7229828 100644 --- a/internal/pty/automator.go +++ b/internal/pty/automator.go @@ -44,7 +44,7 @@ func NewAutomator(session *Session) *Automator { // SendPromptWithCopy sends a prompt using copy/paste method func (a *Automator) SendPromptWithCopy(ctx context.Context, prompt string) error { fmt.Fprintf(os.Stderr, "[AUTOMATOR DEBUG] SendPromptWithCopy called with prompt length: %d\n", len(prompt)) - + // Copy prompt to clipboard if err := a.clipboard.Copy(prompt); err != nil { return fmt.Errorf("failed to copy to clipboard: %w", err) diff --git a/internal/pty/automator_test.go b/internal/pty/automator_test.go index 9de27ee..46c684e 100644 --- a/internal/pty/automator_test.go +++ b/internal/pty/automator_test.go @@ -45,4 +45,4 @@ func TestContainsPattern(t *testing.T) { } }) } -} \ No newline at end of file +} diff --git a/internal/pty/providers/gemini.go b/internal/pty/providers/gemini.go index 485393c..630faa8 100644 --- a/internal/pty/providers/gemini.go +++ b/internal/pty/providers/gemini.go @@ -50,7 +50,7 @@ func (p *GeminiPTYProvider) StartSession(ctx context.Context, workingDir string) fmt.Fprintf(os.Stderr, "[GEMINI DEBUG] Raw output: %q\n", string(data)) }, } - + fmt.Fprintf(os.Stderr, "[GEMINI DEBUG] Starting session with command: %s %v\n", config.Command, config.Args) session, err := pty.NewSession(config) diff --git a/internal/workflow/interactive_executor.go b/internal/workflow/interactive_executor.go index 6f12bba..c39735a 100644 --- a/internal/workflow/interactive_executor.go +++ b/internal/workflow/interactive_executor.go @@ -333,13 +333,13 @@ func (e *InteractiveExecutor) executeInteractiveAgent(ctx context.Context, agent if n > 0 { // Write to stdout os.Stdout.Write(buf[:n]) - + // Accumulate output for ready detection promptMutex.Lock() outputBuffer.Write(buf[:n]) - + currentOutput := outputBuffer.String() - + // Check if we should inject prompt based on provider if !promptInjected { switch agent.Provider { @@ -347,9 +347,9 @@ func (e *InteractiveExecutor) executeInteractiveAgent(ctx context.Context, agent // Claude prompt detection - original logic if strings.Contains(currentOutput, "│") && strings.Contains(currentOutput, ">") { // More specific check - look for the prompt line pattern - if strings.Contains(currentOutput, "│\u00a0>") || - strings.Contains(currentOutput, "│ >") || - strings.Contains(currentOutput, "\u00a0>\u00a0") { + if strings.Contains(currentOutput, "│\u00a0>") || + strings.Contains(currentOutput, "│ >") || + strings.Contains(currentOutput, "\u00a0>\u00a0") { promptInjected = true // Small delay to ensure UI is ready go func() { @@ -362,13 +362,13 @@ func (e *InteractiveExecutor) executeInteractiveAgent(ctx context.Context, agent }() } } - + case "gemini": // Gemini prompt detection - needs ANSI stripping // Strip ANSI escape sequences to check for patterns ansiRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`) strippedOutput := ansiRegex.ReplaceAllString(currentOutput, "") - + // Check for the prompt pattern in stripped output // The actual pattern is "│ > " with a space between pipe and arrow if strings.Contains(strippedOutput, "│ > ") { @@ -387,7 +387,7 @@ func (e *InteractiveExecutor) executeInteractiveAgent(ctx context.Context, agent } promptMutex.Unlock() } - + if err != nil { select { case errChan <- err: diff --git a/test/e2e/workflow_cancellation_test.go b/test/e2e/workflow_cancellation_test.go index 7c60626..71f131e 100644 --- a/test/e2e/workflow_cancellation_test.go +++ b/test/e2e/workflow_cancellation_test.go @@ -70,4 +70,4 @@ agents: output, err := verifyCmd.CombinedOutput() assert.NoError(t, err) assert.Contains(t, string(output), "terminal test") -} \ No newline at end of file +} From 15fa3d3d1c36e5641312c5a4495b59d38e0675f1 Mon Sep 17 00:00:00 2001 From: samjtro Date: Fri, 15 Aug 2025 20:24:55 -0500 Subject: [PATCH 3/3] feat: functional qwen-code integration - bug: zod error, seems internal to qwen --- README.md | 6 +- internal/cli/chat.go | 2 + internal/cli/chat_cmd.go | 5 +- internal/cli/chat_windows.go | 10 +- internal/cli/mcp.go | 14 +- internal/cli/root.go | 4 +- internal/cli/setup.go | 16 +- internal/config/injection_manager.go | 85 ++++++ internal/config/qwen_translator.go | 115 ++++++++ internal/config/qwen_translator_test.go | 108 ++++++++ internal/config/shared_manager.go | 37 ++- internal/mcp/stdio_server.go | 98 +++++-- internal/providers/detector.go | 6 + internal/providers/factory.go | 22 ++ internal/providers/factory_test.go | 206 ++++++++++++++ internal/providers/qwen.go | 344 ++++++++++++++++++++++++ internal/providers/qwen_test.go | 230 ++++++++++++++++ internal/tools/loader.go | 7 +- pkg/core/provider.go | 1 + 19 files changed, 1279 insertions(+), 37 deletions(-) create mode 100644 internal/config/qwen_translator.go create mode 100644 internal/config/qwen_translator_test.go create mode 100644 internal/providers/factory_test.go create mode 100644 internal/providers/qwen.go create mode 100644 internal/providers/qwen_test.go diff --git a/README.md b/README.md index 39e5edf..b255d11 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Download the latest binary for your platform from the [releases page](https://gi # If a fresh installation (configures default provider, default MCP servers, etc) opun setup -# Initialize a chat session with the default provider -- or, specify the provider (chat {gemini,claude}) +# Initialize a chat session with the default provider -- or, specify the provider (chat {gemini,claude,qwen}) opun chat # Add a workflow, tool or prompt -- this is interactive, no need for flags (--{prompt,workflow} --path --name) @@ -123,7 +123,7 @@ agents: # First agent: Initial code analysis - id: analyzer # Unique ID for referencing this agent name: "Code Analyzer" # Human-readable name displayed during execution - provider: claude # AI provider: claude or gemini + provider: claude # AI provider: claude, gemini, or qwen model: sonnet # Model variant (provider-specific) # The prompt is the instruction sent to the AI agent @@ -446,6 +446,7 @@ command: "rg --type-add 'code:*.{js,ts,go,py,java,rs,cpp,c,h}' -t code" providers: - claude - gemini + - qwen --- @@ -601,6 +602,7 @@ export MAX_FILE_SIZE="1000000" # Provider-specific settings export CLAUDE_MODEL="sonnet" export GEMINI_TEMPERATURE="0.7" +export QWEN_MODEL="code" ``` **Using in Configurations**: diff --git a/internal/cli/chat.go b/internal/cli/chat.go index 876f1ac..66306f7 100644 --- a/internal/cli/chat.go +++ b/internal/cli/chat.go @@ -87,6 +87,8 @@ func runChat(cmd *cobra.Command, provider string, providerArgs []string) error { command = "claude" case "gemini": command = "gemini" + case "qwen": + command = "qwen" default: return fmt.Errorf("unsupported provider: %s", provider) } diff --git a/internal/cli/chat_cmd.go b/internal/cli/chat_cmd.go index 7db0ea3..50d7383 100644 --- a/internal/cli/chat_cmd.go +++ b/internal/cli/chat_cmd.go @@ -13,7 +13,7 @@ func ChatCmd() *cobra.Command { cmd := &cobra.Command{ Use: "chat [provider] [-- additional-args...]", Short: "Start an interactive chat session with an AI provider", - Long: `Start an interactive chat session with Claude or Gemini. + Long: `Start an interactive chat session with Claude, Gemini, or Qwen. If no provider is specified, uses the default provider from your configuration. Your promptgarden prompts and configured slash commands are available through the injection system. @@ -24,8 +24,10 @@ Examples: opun chat # Use default provider opun chat claude # Chat with Claude opun chat gemini # Chat with Gemini + opun chat qwen # Chat with Qwen Code opun chat claude -- --continue # Chat with Claude using --continue flag opun chat gemini -- --model=pro # Chat with Gemini using specific model + opun chat qwen -- --model=code # Chat with Qwen using specific model opun chat -- --continue # Use default provider with --continue flag`, Args: cobra.MinimumNArgs(0), RunE: func(cmd *cobra.Command, args []string) error { @@ -36,6 +38,7 @@ Examples: knownProviders := map[string]bool{ "claude": true, "gemini": true, + "qwen": true, } // Parse provider and additional arguments diff --git a/internal/cli/chat_windows.go b/internal/cli/chat_windows.go index 71fecc7..2813b35 100644 --- a/internal/cli/chat_windows.go +++ b/internal/cli/chat_windows.go @@ -88,13 +88,21 @@ func runChat(cmd *cobra.Command, provider string, providerArgs []string) error { } else { return fmt.Errorf("gemini command not found, please install Gemini CLI") } + case "qwen": + if _, err := exec.LookPath("qwen"); err == nil { + command = "qwen" + } else if _, err := exec.LookPath("qwen.exe"); err == nil { + command = "qwen.exe" + } else { + return fmt.Errorf("qwen command not found, please install Qwen Code CLI") + } default: return fmt.Errorf("unsupported provider: %s", provider) } // Append provider arguments to command arguments commandArgs = append(commandArgs, providerArgs...) - + // Create command with prepared environment and provider arguments // #nosec G204 -- command is hardcoded based on provider type c := exec.Command(command, commandArgs...) diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go index 4f45ad7..df5a2ad 100644 --- a/internal/cli/mcp.go +++ b/internal/cli/mcp.go @@ -130,6 +130,8 @@ To use with Gemini, add this to ~/.gemini/settings.json: } }`, RunE: func(cmd *cobra.Command, args []string) error { + // Set environment variable to suppress warnings that could interfere with JSON-RPC + os.Setenv("OPUN_MCP_STDIO", "1") // Initialize components home, err := os.UserHomeDir() if err != nil { @@ -140,8 +142,8 @@ To use with Gemini, add this to ~/.gemini/settings.json: gardenPath := filepath.Join(home, ".opun", "promptgarden") garden, err := promptgarden.NewGarden(gardenPath) if err != nil { - // Log to stderr since stdout is used for MCP protocol - fmt.Fprintf(os.Stderr, "Warning: failed to initialize prompt garden: %v\n", err) + // Suppress warnings in stdio mode - they interfere with JSON-RPC protocol + // fmt.Fprintf(os.Stderr, "Warning: failed to initialize prompt garden: %v\n", err) } // Initialize command registry @@ -155,16 +157,16 @@ To use with Gemini, add this to ~/.gemini/settings.json: workflowPath := filepath.Join(home, ".opun", "workflows") workflowMgr, err := workflow.NewManager(workflowPath) if err != nil { - // Log to stderr - fmt.Fprintf(os.Stderr, "Warning: failed to initialize workflow manager: %v\n", err) + // Suppress warnings in stdio mode - they interfere with JSON-RPC protocol + // fmt.Fprintf(os.Stderr, "Warning: failed to initialize workflow manager: %v\n", err) } // Initialize tool registry toolsPath := filepath.Join(home, ".opun", "tools") toolLoader := tools.NewLoader(toolsPath) if err := toolLoader.LoadAll(); err != nil { - // Log to stderr - fmt.Fprintf(os.Stderr, "Warning: failed to load tools: %v\n", err) + // Suppress warnings in stdio mode - they interfere with JSON-RPC protocol + // fmt.Fprintf(os.Stderr, "Warning: failed to load tools: %v\n", err) } toolRegistry := toolLoader.GetRegistry() diff --git a/internal/cli/root.go b/internal/cli/root.go index 9670462..f510e8c 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -36,7 +36,7 @@ func RootCmd() *cobra.Command { rootCmd := &cobra.Command{ Use: "opun", Short: "AI code agent automation framework", - Long: `Opun automates interaction with AI code agents (Claude Code and Gemini CLI) + Long: `Opun automates interaction with AI code agents (Claude Code, Gemini CLI, and Qwen Code) by managing their interactive sessions and providing workflow orchestration.`, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { return initConfig(configFile) @@ -157,7 +157,7 @@ Use "{{.CommandPath}} [command] --help" for more information about a command. // GetCustomHelp returns the formatted help text for display func GetCustomHelp() string { - return `Opun automates interaction with AI code agents (Claude Code and Gemini CLI) + return `Opun automates interaction with AI code agents (Claude Code, Gemini CLI, and Qwen Code) by managing their interactive sessions and providing workflow orchestration. Usage: diff --git a/internal/cli/setup.go b/internal/cli/setup.go index ee54aab..3b2bfe0 100644 --- a/internal/cli/setup.go +++ b/internal/cli/setup.go @@ -238,6 +238,11 @@ func selectProvider() (string, error) { description: "Google's Gemini - powerful multimodal AI", value: "gemini", }, + providerItem{ + name: "Qwen", + description: "Qwen Code - optimized for coding tasks", + value: "qwen", + }, } // Calculate height to show all items with sufficient space @@ -336,11 +341,12 @@ func installMCPServersShared(serverNames []string, provider string) error { // Sync configuration to all providers providers := []string{provider} - // Also sync to the other provider if user might use both - if provider == "claude" { - providers = append(providers, "gemini") - } else { - providers = append(providers, "claude") + // Also sync to the other providers if user might use them + allProviders := []string{"claude", "gemini", "qwen"} + for _, p := range allProviders { + if p != provider { + providers = append(providers, p) + } } if err := installer.SyncConfigurations(providers); err != nil { diff --git a/internal/config/injection_manager.go b/internal/config/injection_manager.go index 3147c6f..904a929 100644 --- a/internal/config/injection_manager.go +++ b/internal/config/injection_manager.go @@ -89,6 +89,10 @@ func (m *InjectionManager) PrepareProviderEnvironment(provider string) (*Provide if err := m.prepareGeminiEnvironment(env); err != nil { return nil, fmt.Errorf("failed to prepare Gemini environment: %w", err) } + case "qwen": + if err := m.prepareQwenEnvironment(env); err != nil { + return nil, fmt.Errorf("failed to prepare Qwen environment: %w", err) + } default: return nil, fmt.Errorf("unsupported provider: %s", provider) } @@ -155,6 +159,23 @@ func (m *InjectionManager) prepareGeminiEnvironment(env *ProviderEnvironment) er return nil } +// prepareQwenEnvironment prepares Qwen-specific environment +func (m *InjectionManager) prepareQwenEnvironment(env *ProviderEnvironment) error { + // Since Qwen is a fork of Gemini, it has similar limitations + // We rely entirely on MCP servers for extensions + + // Create QWEN.md for system prompt customization in workspace + qwenMdPath := filepath.Join(m.workspaceDir, "QWEN.md") + if err := m.generateQwenSystemPrompt(qwenMdPath); err != nil { + return err + } + + // Ensure MCP servers include our slash command server + // This is already handled by SyncToProvider + + return nil +} + // generateClaudeSlashCommands generates markdown files for Claude slash commands func (m *InjectionManager) generateClaudeSlashCommands(commandsDir string) error { commands := m.sharedManager.GetSlashCommands() @@ -388,6 +409,70 @@ This is a managed Opun session with the following MCP servers available: return t.Execute(file, data) } +// generateQwenSystemPrompt generates QWEN.md for system customization +func (m *InjectionManager) generateQwenSystemPrompt(mdPath string) error { + tmpl := `# QWEN.md + +This file provides system-level guidance for Qwen Code CLI when working in this Opun session. + +## Available Commands via MCP + +Since Qwen doesn't support custom slash commands natively, use the MCP tools to access Opun functionality: + +### Workflows +{{range .Commands}}{{if eq .Type "workflow"}} +- **{{.Name}}**: {{.Description}} + - Handler: {{.Handler}} +{{end}}{{end}} + +### Prompts +{{range .Commands}}{{if eq .Type "prompt"}} +- **{{.Name}}**: {{.Description}} + - Handler: {{.Handler}} +{{end}}{{end}} + +### Built-in Commands +{{range .Commands}}{{if eq .Type "builtin"}} +- **{{.Name}}**: {{.Description}} +{{end}}{{end}} + +## Using Commands + +To execute any of these commands, use the MCP tools: +1. List available tools with the MCP server +2. Execute the desired command through the opun tool +3. For prompts, use the opun tool + +## Session Configuration + +This is a managed Opun session with the following MCP servers available: +{{range .Servers}}{{if .Installed}} +- **{{.Name}}**: {{.Package}} +{{end}}{{end}} +` + + t, err := template.New("qwen").Parse(tmpl) + if err != nil { + return err + } + + data := struct { + Commands []core.SharedSlashCommand + Servers []core.SharedMCPServer + }{ + Commands: m.sharedManager.GetSlashCommands(), + Servers: m.sharedManager.GetMCPServers(), + } + + file, err := os.Create(mdPath) + if err != nil { + return err + } + defer file.Close() + + return t.Execute(file, data) +} + // ProviderEnvironment contains the prepared environment for a provider type ProviderEnvironment struct { Provider string diff --git a/internal/config/qwen_translator.go b/internal/config/qwen_translator.go new file mode 100644 index 0000000..f3293c3 --- /dev/null +++ b/internal/config/qwen_translator.go @@ -0,0 +1,115 @@ +package config + +// Copyright (C) 2025 Rizome Labs, Inc. +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +import ( + "os" + "path/filepath" + + "github.com/rizome-dev/opun/pkg/core" +) + +// QwenConfigTranslator translates shared config to Qwen format +type QwenConfigTranslator struct{} + +// NewQwenConfigTranslator creates a new Qwen config translator +func NewQwenConfigTranslator() *QwenConfigTranslator { + return &QwenConfigTranslator{} +} + +// QwenConfig represents Qwen's configuration format +// Since Qwen is a fork of Gemini, it uses the same format +type QwenConfig struct { + MCPServers map[string]QwenMCPServer `json:"mcpServers,omitempty"` +} + +// QwenMCPServer represents an MCP server in Qwen's format +type QwenMCPServer struct { + Command string `json:"command,omitempty"` + Args []string `json:"args,omitempty"` + URL string `json:"url,omitempty"` + HTTPURL string `json:"httpUrl,omitempty"` + Env map[string]string `json:"env,omitempty"` + CWD string `json:"cwd,omitempty"` + Timeout int `json:"timeout,omitempty"` + Trust bool `json:"trust,omitempty"` +} + +// TranslateMCPConfig translates shared MCP config to Qwen format +func (q *QwenConfigTranslator) TranslateMCPConfig(servers []core.SharedMCPServer) (interface{}, error) { + qwenConfig := QwenConfig{ + MCPServers: make(map[string]QwenMCPServer), + } + + // Convert MCP servers to Qwen format + for _, server := range servers { + // Skip if not installed (unless required) + if !server.Installed && !server.Required { + continue + } + + qwenServer := QwenMCPServer{ + Command: server.Command, + Args: server.Args, + } + + // Add environment variables if present and not empty + if len(server.Env) > 0 { + envVars := make(map[string]string) + hasAnyValue := false + + for k, v := range server.Env { + if v != "" { + envVars[k] = v + hasAnyValue = true + } + } + + // Only set env if we have actual values + if hasAnyValue { + qwenServer.Env = envVars + } + } + + qwenConfig.MCPServers[server.Name] = qwenServer + } + + return qwenConfig, nil +} + +// TranslateSlashCommands is not supported by Qwen +func (q *QwenConfigTranslator) TranslateSlashCommands(commands []core.SharedSlashCommand) (interface{}, error) { + // Qwen doesn't support custom slash commands + // It only has built-in commands like /mcp, /chat, etc. + // Extensions should be done via MCP servers + return nil, nil +} + +// GetConfigPath returns Qwen's config file path +func (q *QwenConfigTranslator) GetConfigPath() string { + homeDir, _ := os.UserHomeDir() + + // Since Qwen is a fork of Gemini, it likely uses ~/.qwen/settings.json + return filepath.Join(homeDir, ".qwen", "settings.json") +} + +// SupportsSymlinks returns whether Qwen config can be symlinked +func (q *QwenConfigTranslator) SupportsSymlinks() bool { + // Assume Qwen can handle symlinks for now + return true +} + diff --git a/internal/config/qwen_translator_test.go b/internal/config/qwen_translator_test.go new file mode 100644 index 0000000..d082ca0 --- /dev/null +++ b/internal/config/qwen_translator_test.go @@ -0,0 +1,108 @@ +package config + +// Copyright (C) 2025 Rizome Labs, Inc. +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +import ( + "os" + "path/filepath" + "testing" + + "github.com/rizome-dev/opun/pkg/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestQwenConfigTranslator_TranslateMCPConfig(t *testing.T) { + // Set test home directory + tempDir := t.TempDir() + oldHome := os.Getenv("HOME") + os.Setenv("HOME", tempDir) + defer os.Setenv("HOME", oldHome) + + translator := NewQwenConfigTranslator() + + servers := []core.SharedMCPServer{ + { + Name: "opun", + Command: "opun", + Args: []string{"mcp", "stdio"}, + Installed: true, + Required: true, + }, + { + Name: "filesystem", + Command: "npx", + Args: []string{"@modelcontextprotocol/server-filesystem", "/path"}, + Installed: false, + }, + { + Name: "context7", + Command: "npx", + Args: []string{"@upstash/context7-mcp"}, + Env: map[string]string{"API_KEY": "secret"}, + Required: true, + }, + } + + config, err := translator.TranslateMCPConfig(servers) + require.NoError(t, err) + + qwenConfig, ok := config.(QwenConfig) + require.True(t, ok) + + // Check mcpServers - only installed or required servers are included + assert.Len(t, qwenConfig.MCPServers, 2) + + // Check opun server + opunServer, exists := qwenConfig.MCPServers["opun"] + require.True(t, exists) + assert.Equal(t, "opun", opunServer.Command) + assert.Equal(t, []string{"mcp", "stdio"}, opunServer.Args) + + // Check filesystem server is not included (not installed and not required) + _, exists = qwenConfig.MCPServers["filesystem"] + require.False(t, exists) + + // Check context7 server with environment + ctx7Server, exists := qwenConfig.MCPServers["context7"] + require.True(t, exists) + assert.Equal(t, "secret", ctx7Server.Env["API_KEY"]) +} + +func TestQwenConfigTranslator_GetConfigPath(t *testing.T) { + translator := NewQwenConfigTranslator() + path := translator.GetConfigPath() + assert.Equal(t, filepath.Join(os.Getenv("HOME"), ".qwen", "settings.json"), path) +} + +func TestQwenConfigTranslator_TranslateSlashCommands(t *testing.T) { + translator := NewQwenConfigTranslator() + + // Qwen doesn't support slash commands directly (similar to Gemini) + config, err := translator.TranslateSlashCommands([]core.SharedSlashCommand{ + {Name: "test", Description: "Test command"}, + }) + + require.NoError(t, err) + assert.Nil(t, config) +} + +func TestQwenConfigTranslator_SupportsSymlinks(t *testing.T) { + translator := NewQwenConfigTranslator() + assert.True(t, translator.SupportsSymlinks()) +} + diff --git a/internal/config/shared_manager.go b/internal/config/shared_manager.go index 224e3f8..73e8103 100644 --- a/internal/config/shared_manager.go +++ b/internal/config/shared_manager.go @@ -216,6 +216,8 @@ func (m *SharedConfigManager) SyncToProvider(providerName string) error { translator = NewClaudeConfigTranslator() case "gemini": translator = NewGeminiConfigTranslator() + case "qwen": + translator = NewQwenConfigTranslator() default: return fmt.Errorf("unsupported provider: %s", providerName) } @@ -248,10 +250,39 @@ func (m *SharedConfigManager) writeProviderConfig(translator core.ProviderConfig configPath = filepath.Join(homeDir, configPath[2:]) } - // Marshal config - data, err := json.MarshalIndent(config, "", " ") + // Read existing config if it exists + var existingConfig map[string]interface{} + if existingData, err := os.ReadFile(configPath); err == nil { + // Parse existing config + if err := json.Unmarshal(existingData, &existingConfig); err != nil { + // If we can't parse it, start fresh + existingConfig = make(map[string]interface{}) + } + } else { + // No existing config, start fresh + existingConfig = make(map[string]interface{}) + } + + // Convert new config to map for merging + newConfigData, err := json.Marshal(config) if err != nil { - return fmt.Errorf("failed to marshal config: %w", err) + return fmt.Errorf("failed to marshal new config: %w", err) + } + + var newConfig map[string]interface{} + if err := json.Unmarshal(newConfigData, &newConfig); err != nil { + return fmt.Errorf("failed to unmarshal new config: %w", err) + } + + // Merge new config into existing config (preserving existing fields) + for key, value := range newConfig { + existingConfig[key] = value + } + + // Marshal merged config + data, err := json.MarshalIndent(existingConfig, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal merged config: %w", err) } // Use utils.WriteFile which handles directory creation and permissions diff --git a/internal/mcp/stdio_server.go b/internal/mcp/stdio_server.go index 84467cf..bb4a541 100644 --- a/internal/mcp/stdio_server.go +++ b/internal/mcp/stdio_server.go @@ -66,14 +66,13 @@ func NewStdioMCPServer(garden *promptgarden.Garden, registry *command.Registry, // Run starts the stdio MCP server func (s *StdioMCPServer) Run(ctx context.Context) error { - // Log server start - fmt.Fprintf(os.Stderr, "Opun MCP server started (stdio mode)\n") + // Don't log server start - some clients may capture stderr // Main message loop - wait for requests for { select { case <-ctx.Done(): - fmt.Fprintf(os.Stderr, "MCP server shutting down: %v\n", ctx.Err()) + // Don't log to stderr - it might interfere with the protocol return ctx.Err() default: // Blocking read - this is the standard MCP approach @@ -81,11 +80,14 @@ func (s *StdioMCPServer) Run(ctx context.Context) error { if err != nil { if err == io.EOF { // EOF is normal when client disconnects - fmt.Fprintf(os.Stderr, "MCP client disconnected (EOF)\n") + // Don't log to stderr - it might interfere with the protocol return nil } - // Log other errors to stderr - fmt.Fprintf(os.Stderr, "Error reading request: %v\n", err) + // Don't log parse errors - they're already handled with proper JSON-RPC response + if !strings.Contains(err.Error(), "invalid JSON") { + // Don't log other errors to stderr either - it might interfere + // fmt.Fprintf(os.Stderr, "Error reading request: %v\n", err) + } continue } @@ -102,8 +104,22 @@ func (s *StdioMCPServer) readRequest() (map[string]interface{}, error) { return nil, err } + // Skip empty lines + line = strings.TrimSpace(line) + if line == "" { + return nil, io.EOF + } + + var request map[string]interface{} if err := json.Unmarshal([]byte(line), &request); err != nil { + // Don't send parse error for empty JSON objects or arrays + // Qwen might send these as initial probes + if line == "{}" || line == "[]" || line == "null" { + return nil, io.EOF + } + // Send proper JSON-RPC error response for parse errors + s.sendParseError() return nil, fmt.Errorf("invalid JSON: %w", err) } @@ -112,6 +128,11 @@ func (s *StdioMCPServer) readRequest() (map[string]interface{}, error) { // sendResponse sends a JSON-RPC response to stdout func (s *StdioMCPServer) sendResponse(id interface{}, result interface{}) { + // Don't send responses for notifications (no id) + if id == nil { + return + } + response := map[string]interface{}{ "jsonrpc": "2.0", "id": id, @@ -119,6 +140,8 @@ func (s *StdioMCPServer) sendResponse(id interface{}, result interface{}) { } data, _ := json.Marshal(response) + + fmt.Fprintf(s.writer, "%s\n", data) // Ensure output is flushed immediately if f, ok := s.writer.(*os.File); ok { @@ -128,6 +151,11 @@ func (s *StdioMCPServer) sendResponse(id interface{}, result interface{}) { // sendError sends a JSON-RPC error response func (s *StdioMCPServer) sendError(id interface{}, err error) { + // Don't send error responses for notifications (no id) + if id == nil { + return + } + response := map[string]interface{}{ "jsonrpc": "2.0", "id": id, @@ -137,6 +165,28 @@ func (s *StdioMCPServer) sendError(id interface{}, err error) { }, } + data, _ := json.Marshal(response) + + + fmt.Fprintf(s.writer, "%s\n", data) + // Ensure output is flushed immediately + if f, ok := s.writer.(*os.File); ok { + f.Sync() + } +} + +// sendParseError sends a JSON-RPC parse error (for malformed JSON) +func (s *StdioMCPServer) sendParseError() { + // According to JSON-RPC 2.0 spec, parse errors should have id: null + response := map[string]interface{}{ + "jsonrpc": "2.0", + "id": nil, + "error": map[string]interface{}{ + "code": -32700, + "message": "Parse error", + }, + } + data, _ := json.Marshal(response) fmt.Fprintf(s.writer, "%s\n", data) // Ensure output is flushed immediately @@ -151,26 +201,44 @@ func (s *StdioMCPServer) handleRequest(request map[string]interface{}) { id := request["id"] params, _ := request["params"].(map[string]interface{}) + + // If there's no id, this is a notification and we shouldn't respond + isNotification := id == nil + // Only log errors and important events to stderr switch method { case "initialize": - s.handleInitialize(id, params) + if !isNotification { + s.handleInitialize(id, params) + } case "tools/list": - s.handleToolsList(id) + if !isNotification { + s.handleToolsList(id) + } case "tools/call": - s.handleToolCall(id, params) + if !isNotification { + s.handleToolCall(id, params) + } case "prompts/list": - s.handlePromptsList(id) + if !isNotification { + s.handlePromptsList(id) + } case "prompts/get": - s.handlePromptsGet(id, params) + if !isNotification { + s.handlePromptsGet(id, params) + } case "ping": // Handle ping to keep connection alive - s.sendResponse(id, map[string]interface{}{ - "status": "ok", - }) + if !isNotification { + s.sendResponse(id, map[string]interface{}{ + "status": "ok", + }) + } default: - s.sendError(id, fmt.Errorf("unknown method: %s", method)) + if !isNotification { + s.sendError(id, fmt.Errorf("unknown method: %s", method)) + } } } diff --git a/internal/providers/detector.go b/internal/providers/detector.go index 871f2b5..8e50bfc 100644 --- a/internal/providers/detector.go +++ b/internal/providers/detector.go @@ -44,6 +44,12 @@ func (d *Detector) DetectCommand(provider string) (string, error) { } return "", fmt.Errorf("gemini command not found, please install Gemini CLI") + case "qwen": + if _, err := exec.LookPath("qwen"); err == nil { + return "qwen", nil + } + return "", fmt.Errorf("qwen command not found, please install Qwen Code CLI") + default: return "", fmt.Errorf("unsupported provider: %s", provider) } diff --git a/internal/providers/factory.go b/internal/providers/factory.go index 3a3a7ed..4405019 100644 --- a/internal/providers/factory.go +++ b/internal/providers/factory.go @@ -42,6 +42,8 @@ func (f *ProviderFactory) CreateProvider(config core.ProviderConfig) (core.Provi provider = NewClaudeProvider(config) case core.ProviderTypeGemini: provider = NewGeminiProvider(config) + case core.ProviderTypeQwen: + provider = NewQwenProvider(config) case core.ProviderTypeMock: provider = NewMockProvider(config.Name, config) default: @@ -65,6 +67,8 @@ func (f *ProviderFactory) CreateProviderFromType(providerType string, name strin pType = core.ProviderTypeClaude case "gemini": pType = core.ProviderTypeGemini + case "qwen": + pType = core.ProviderTypeQwen case "mock": pType = core.ProviderTypeMock default: @@ -89,6 +93,9 @@ func (f *ProviderFactory) CreateProviderFromType(providerType string, name strin case "gemini": config.Command = "gemini" config.Args = []string{"chat"} + case "qwen": + config.Command = "qwen" + config.Args = []string{"chat"} } return f.CreateProvider(config) @@ -101,6 +108,8 @@ func getDefaultModel(providerType string) string { return "sonnet" case "gemini": return "gemini-pro" + case "qwen": + return "code" default: return "" } @@ -135,6 +144,19 @@ func getDefaultFeatures(providerType string) core.ProviderFeatures { QualityModes: false, ContextWindowing: true, } + case "qwen": + return core.ProviderFeatures{ + Interactive: true, + Batch: false, + Streaming: true, + FileOutput: false, + MCP: true, + Tools: true, + SlashCommands: true, + Plugins: true, + QualityModes: false, + ContextWindowing: true, + } default: return core.ProviderFeatures{} } diff --git a/internal/providers/factory_test.go b/internal/providers/factory_test.go new file mode 100644 index 0000000..9aeac4c --- /dev/null +++ b/internal/providers/factory_test.go @@ -0,0 +1,206 @@ +package providers + +// Copyright (C) 2025 Rizome Labs, Inc. +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +import ( + "testing" + + "github.com/rizome-dev/opun/pkg/core" + "github.com/stretchr/testify/assert" +) + +func TestProviderFactory_CreateProvider(t *testing.T) { + factory := NewProviderFactory() + + tests := []struct { + name string + config core.ProviderConfig + wantType core.ProviderType + shouldError bool + }{ + { + name: "Create Claude provider", + config: core.ProviderConfig{ + Name: "test-claude", + Type: core.ProviderTypeClaude, + Command: "claude", + }, + wantType: core.ProviderTypeClaude, + shouldError: true, // Will error because claude command doesn't exist in test + }, + { + name: "Create Gemini provider", + config: core.ProviderConfig{ + Name: "test-gemini", + Type: core.ProviderTypeGemini, + Command: "gemini", + }, + wantType: core.ProviderTypeGemini, + shouldError: true, // Will error because gemini command doesn't exist in test + }, + { + name: "Create Qwen provider", + config: core.ProviderConfig{ + Name: "test-qwen", + Type: core.ProviderTypeQwen, + Command: "qwen", + }, + wantType: core.ProviderTypeQwen, + shouldError: true, // Will error because qwen command doesn't exist in test + }, + { + name: "Create Mock provider", + config: core.ProviderConfig{ + Name: "test-mock", + Type: core.ProviderTypeMock, + Command: "mock", + }, + wantType: core.ProviderTypeMock, + shouldError: false, // Mock provider doesn't validate command existence + }, + { + name: "Unsupported provider type", + config: core.ProviderConfig{ + Name: "test-unknown", + Type: "unknown", + Command: "unknown", + }, + shouldError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider, err := factory.CreateProvider(tt.config) + + if tt.shouldError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.NotNil(t, provider) + assert.Equal(t, tt.wantType, provider.Type()) + } + }) + } +} + +func TestProviderFactory_CreateProviderFromType(t *testing.T) { + factory := NewProviderFactory() + + tests := []struct { + name string + providerType string + providerName string + wantCommand string + wantModel string + shouldError bool + }{ + { + name: "Create Claude from type", + providerType: "claude", + providerName: "test-claude", + wantCommand: "claude", + wantModel: "sonnet", + shouldError: true, // Will error due to validation + }, + { + name: "Create Gemini from type", + providerType: "gemini", + providerName: "test-gemini", + wantCommand: "gemini", + wantModel: "gemini-pro", + shouldError: true, // Will error due to validation + }, + { + name: "Create Qwen from type", + providerType: "qwen", + providerName: "test-qwen", + wantCommand: "qwen", + wantModel: "code", + shouldError: true, // Will error due to validation + }, + { + name: "Create Mock from type", + providerType: "mock", + providerName: "test-mock", + shouldError: false, // Mock doesn't validate + }, + { + name: "Unsupported provider type", + providerType: "unknown", + providerName: "test-unknown", + shouldError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider, err := factory.CreateProviderFromType(tt.providerType, tt.providerName) + + if tt.shouldError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.NotNil(t, provider) + assert.Equal(t, tt.providerName, provider.Name()) + } + }) + } +} + +func TestGetDefaultModel(t *testing.T) { + tests := []struct { + providerType string + wantModel string + }{ + {"claude", "sonnet"}, + {"gemini", "gemini-pro"}, + {"qwen", "code"}, + {"unknown", ""}, + } + + for _, tt := range tests { + t.Run(tt.providerType, func(t *testing.T) { + model := getDefaultModel(tt.providerType) + assert.Equal(t, tt.wantModel, model) + }) + } +} + +func TestGetDefaultFeatures(t *testing.T) { + tests := []struct { + providerType string + wantInteractive bool + wantMCP bool + wantQualityModes bool + }{ + {"claude", true, true, true}, + {"gemini", true, true, false}, + {"qwen", true, true, false}, + {"unknown", false, false, false}, + } + + for _, tt := range tests { + t.Run(tt.providerType, func(t *testing.T) { + features := getDefaultFeatures(tt.providerType) + assert.Equal(t, tt.wantInteractive, features.Interactive) + assert.Equal(t, tt.wantMCP, features.MCP) + assert.Equal(t, tt.wantQualityModes, features.QualityModes) + }) + } +} + diff --git a/internal/providers/qwen.go b/internal/providers/qwen.go new file mode 100644 index 0000000..7204b07 --- /dev/null +++ b/internal/providers/qwen.go @@ -0,0 +1,344 @@ +package providers + +// Copyright (C) 2025 Rizome Labs, Inc. +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/rizome-dev/opun/internal/config" + "github.com/rizome-dev/opun/internal/io" + "github.com/rizome-dev/opun/internal/utils" + "github.com/rizome-dev/opun/pkg/core" +) + +// QwenProvider implements the Provider interface for Qwen Code CLI +type QwenProvider struct { + *core.BaseProvider + session *io.TransparentSession + clipboard utils.Clipboard + injectionManager *config.InjectionManager + environment *config.ProviderEnvironment +} + +// NewQwenProvider creates a new Qwen provider +func NewQwenProvider(providerConfig core.ProviderConfig) *QwenProvider { + baseProvider := core.NewBaseProvider(providerConfig.Name, core.ProviderTypeQwen) + baseProvider.Initialize(providerConfig) + + // Create injection manager (optional) + injectionManager, _ := config.NewInjectionManager(nil) + + return &QwenProvider{ + BaseProvider: baseProvider, + clipboard: utils.NewClipboard(), + injectionManager: injectionManager, + } +} + +// Validate validates the provider configuration +func (p *QwenProvider) Validate() error { + if err := p.BaseProvider.Validate(); err != nil { + return err + } + + // Check if qwen CLI is available + if err := p.checkQwenCLI(); err != nil { + return fmt.Errorf("qwen CLI not available: %w", err) + } + + return nil +} + +// GetPTYCommand returns the command to start Qwen +func (p *QwenProvider) GetPTYCommand() (*exec.Cmd, error) { + config := p.Config() + // #nosec G204 -- executing configured provider command + cmd := exec.Command(config.Command, config.Args...) + + // Apply injected environment if available + if p.environment != nil { + if p.environment.WorkingDir != "" { + cmd.Dir = p.environment.WorkingDir + } + // Add injected environment variables + cmd.Env = append(os.Environ(), "") // Start with system env + for k, v := range p.environment.Environment { + cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v)) + } + } else { + // Fall back to config settings + if config.WorkingDir != "" { + cmd.Dir = config.WorkingDir + } + // Set environment variables from config + for k, v := range config.Environment { + cmd.Env = append(os.Environ(), fmt.Sprintf("%s=%s", k, v)) + } + } + + return cmd, nil +} + +// GetPTYCommandWithPrompt returns the command with an initial prompt +func (p *QwenProvider) GetPTYCommandWithPrompt(prompt string) (*exec.Cmd, error) { + // Qwen doesn't support initial prompts via command line + // We'll handle this via clipboard injection + return p.GetPTYCommand() +} + +// SupportsModel checks if Qwen supports the given model +func (p *QwenProvider) SupportsModel(model string) bool { + // Qwen Code models - similar to Gemini but may have different names + supportedModels := []string{"pro", "flash", "ultra", "code", "chat"} + for _, m := range supportedModels { + if strings.EqualFold(m, model) { + return true + } + } + return false +} + +// PrepareSession prepares a Qwen session +func (p *QwenProvider) PrepareSession(ctx context.Context, sessionID string) error { + // Create session directory if needed + sessionDir := filepath.Join(os.TempDir(), "opun", "sessions", sessionID) + if err := os.MkdirAll(sessionDir, 0755); err != nil { + return err + } + + // Prepare provider environment if injection manager is available + if p.injectionManager != nil { + env, err := p.injectionManager.PrepareProviderEnvironment(string(p.Type())) + if err != nil { + return fmt.Errorf("failed to prepare provider environment: %w", err) + } + p.environment = env + } + + return nil +} + +// CleanupSession cleans up a Qwen session +func (p *QwenProvider) CleanupSession(ctx context.Context, sessionID string) error { + // Clean up injected environment + if p.environment != nil { + if err := p.environment.Cleanup(); err != nil { + // Log but don't fail on cleanup errors + fmt.Printf("Warning: failed to cleanup environment: %v\n", err) + } + p.environment = nil + } + + // Clean up session directory + sessionDir := filepath.Join(os.TempDir(), "opun", "sessions", sessionID) + return os.RemoveAll(sessionDir) +} + +// GetReadyPattern returns the pattern indicating Qwen is ready +func (p *QwenProvider) GetReadyPattern() string { + // From PRD: "Once Qwen Code loads successfully, the entry box is: │ >" + return "│ >" +} + +// GetOutputPattern returns the pattern indicating output completion +func (p *QwenProvider) GetOutputPattern() string { + return "│ >" +} + +// GetErrorPattern returns the pattern indicating an error +func (p *QwenProvider) GetErrorPattern() string { + return "Error:" +} + +// GetPromptInjectionMethod returns how to inject prompts +func (p *QwenProvider) GetPromptInjectionMethod() string { + return "clipboard" +} + +// InjectPrompt injects a prompt into Qwen +func (p *QwenProvider) InjectPrompt(prompt string) error { + return p.clipboard.Copy(prompt) +} + +// GetMCPServers returns MCP servers for Qwen +func (p *QwenProvider) GetMCPServers() []core.MCPServer { + // Qwen has limited MCP support currently (similar to Gemini) + return []core.MCPServer{ + { + Name: "filesystem", + Description: "File system operations", + Enabled: true, + }, + } +} + +// GetTools returns available tools +func (p *QwenProvider) GetTools() []core.Tool { + return []core.Tool{ + { + Name: "read_file", + Description: "Read contents of a file", + Category: "filesystem", + }, + { + Name: "write_file", + Description: "Write contents to a file", + Category: "filesystem", + }, + } +} + +// GetSlashCommands returns slash commands supported by Qwen +func (p *QwenProvider) GetSlashCommands() []core.SharedSlashCommand { + // Qwen supports slash commands via MCP integration + // These will be populated from the shared config + return []core.SharedSlashCommand{} +} + +// GetPlugins returns plugins used by Qwen +func (p *QwenProvider) GetPlugins() []core.PluginReference { + // Return empty list - plugins are handled via MCP servers + return []core.PluginReference{} +} + +// SupportsSlashCommands returns true as Qwen supports MCP-based slash commands +func (p *QwenProvider) SupportsSlashCommands() bool { + return true // Via MCP servers +} + +// GetSlashCommandDirectory returns empty as Qwen uses MCP not directories +func (p *QwenProvider) GetSlashCommandDirectory() string { + return "" +} + +// GetSlashCommandFormat returns "mcp" as Qwen uses MCP servers +func (p *QwenProvider) GetSlashCommandFormat() string { + return "mcp" +} + +// PrepareSlashCommands ensures MCP servers are configured for Qwen +func (p *QwenProvider) PrepareSlashCommands(commands []core.SharedSlashCommand, targetDir string) error { + // Qwen uses MCP servers to expose commands + // The actual configuration is handled by the MCP sync process + // Nothing to do here as MCP servers are configured in settings.json + return nil +} + +// StartSession starts an interactive session +func (p *QwenProvider) StartSession(ctx context.Context, workDir string) (*io.TransparentSession, error) { + cmd, args := p.getCommand() + + config := io.TransparentSessionConfig{ + Provider: p.Name(), + Command: cmd, + Args: args, + } + + session, err := io.NewTransparentSession(config) + if err != nil { + return nil, err + } + + p.session = session + return session, nil +} + +// SendPrompt sends a prompt to the session +func (p *QwenProvider) SendPrompt(prompt string) error { + if p.session == nil { + return fmt.Errorf("no active session") + } + return p.session.SendInput([]byte(prompt + "\n")) +} + +// CloseSession closes the current session +func (p *QwenProvider) CloseSession() error { + if p.session == nil { + return nil + } + err := p.session.Close() + p.session = nil + return err +} + +// GetReadyPatterns returns patterns that indicate Qwen is ready +func (p *QwenProvider) GetReadyPatterns() []string { + return []string{ + "│ >", + "Type your message", + } +} + +// getCommand returns the command and args to run Qwen +func (p *QwenProvider) getCommand() (string, []string) { + config := p.Config() + args := []string{"chat"} + + // Add model if specified + if config.Model != "" { + args = append(args, "--model", config.Model) + } else { + // Default to qwen-code or similar default model + args = append(args, "--model", "code") + } + + // Add temperature if specified + if temp, ok := config.Settings["temperature"].(float64); ok { + args = append(args, "--temperature", fmt.Sprintf("%.2f", temp)) + } + + // Add any additional args from config + args = append(args, config.Args...) + + // Get the qwen command + cmd := p.getQwenCommand() + + return cmd, args +} + +// checkQwenCLI checks if the Qwen CLI is available +func (p *QwenProvider) checkQwenCLI() error { + // Try 'qwen' command + if _, err := exec.LookPath("qwen"); err != nil { + return fmt.Errorf("qwen CLI not found in PATH") + } + + // Verify it works + cmd := exec.Command("qwen", "--version") + if err := cmd.Run(); err != nil { + return fmt.Errorf("qwen CLI found but not working: %w", err) + } + + return nil +} + +// getQwenCommand returns the command to run Qwen +func (p *QwenProvider) getQwenCommand() string { + config := p.Config() + // Check for override in config + if cmd, ok := config.Settings["command"].(string); ok && cmd != "" { + return cmd + } + + return "qwen" +} + diff --git a/internal/providers/qwen_test.go b/internal/providers/qwen_test.go new file mode 100644 index 0000000..d1fae2a --- /dev/null +++ b/internal/providers/qwen_test.go @@ -0,0 +1,230 @@ +package providers + +// Copyright (C) 2025 Rizome Labs, Inc. +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +import ( + "context" + "os" + "testing" + + "github.com/rizome-dev/opun/pkg/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewQwenProvider(t *testing.T) { + config := core.ProviderConfig{ + Name: "test-qwen", + Type: core.ProviderTypeQwen, + Model: "code", + Command: "qwen", + Args: []string{"chat"}, + Features: core.ProviderFeatures{ + Interactive: true, + MCP: true, + }, + } + + provider := NewQwenProvider(config) + assert.NotNil(t, provider) + assert.Equal(t, "test-qwen", provider.Name()) + assert.Equal(t, core.ProviderTypeQwen, provider.Type()) +} + +func TestQwenProvider_GetReadyPattern(t *testing.T) { + config := core.ProviderConfig{ + Name: "test-qwen", + Type: core.ProviderTypeQwen, + Command: "qwen", + } + + provider := NewQwenProvider(config) + assert.Equal(t, "│ >", provider.GetReadyPattern()) +} + +func TestQwenProvider_GetOutputPattern(t *testing.T) { + config := core.ProviderConfig{ + Name: "test-qwen", + Type: core.ProviderTypeQwen, + Command: "qwen", + } + + provider := NewQwenProvider(config) + assert.Equal(t, "│ >", provider.GetOutputPattern()) +} + +func TestQwenProvider_GetErrorPattern(t *testing.T) { + config := core.ProviderConfig{ + Name: "test-qwen", + Type: core.ProviderTypeQwen, + Command: "qwen", + } + + provider := NewQwenProvider(config) + assert.Equal(t, "Error:", provider.GetErrorPattern()) +} + +func TestQwenProvider_GetPromptInjectionMethod(t *testing.T) { + config := core.ProviderConfig{ + Name: "test-qwen", + Type: core.ProviderTypeQwen, + Command: "qwen", + } + + provider := NewQwenProvider(config) + assert.Equal(t, "clipboard", provider.GetPromptInjectionMethod()) +} + +func TestQwenProvider_SupportsModel(t *testing.T) { + config := core.ProviderConfig{ + Name: "test-qwen", + Type: core.ProviderTypeQwen, + Command: "qwen", + } + + provider := NewQwenProvider(config) + + // Test supported models + assert.True(t, provider.SupportsModel("code")) + assert.True(t, provider.SupportsModel("pro")) + assert.True(t, provider.SupportsModel("flash")) + assert.True(t, provider.SupportsModel("ultra")) + assert.True(t, provider.SupportsModel("chat")) + + // Test case insensitive + assert.True(t, provider.SupportsModel("CODE")) + assert.True(t, provider.SupportsModel("Chat")) + + // Test unsupported model + assert.False(t, provider.SupportsModel("unsupported")) +} + +func TestQwenProvider_GetMCPServers(t *testing.T) { + config := core.ProviderConfig{ + Name: "test-qwen", + Type: core.ProviderTypeQwen, + Command: "qwen", + } + + provider := NewQwenProvider(config) + servers := provider.GetMCPServers() + + assert.Len(t, servers, 1) + assert.Equal(t, "filesystem", servers[0].Name) + assert.True(t, servers[0].Enabled) +} + +func TestQwenProvider_GetTools(t *testing.T) { + config := core.ProviderConfig{ + Name: "test-qwen", + Type: core.ProviderTypeQwen, + Command: "qwen", + } + + provider := NewQwenProvider(config) + tools := provider.GetTools() + + assert.Len(t, tools, 2) + + // Check read_file tool + assert.Equal(t, "read_file", tools[0].Name) + assert.Equal(t, "filesystem", tools[0].Category) + + // Check write_file tool + assert.Equal(t, "write_file", tools[1].Name) + assert.Equal(t, "filesystem", tools[1].Category) +} + +func TestQwenProvider_PrepareSession(t *testing.T) { + config := core.ProviderConfig{ + Name: "test-qwen", + Type: core.ProviderTypeQwen, + Command: "qwen", + } + + provider := NewQwenProvider(config) + ctx := context.Background() + sessionID := "test-session" + + err := provider.PrepareSession(ctx, sessionID) + assert.NoError(t, err) + + // Check that session directory was created + sessionDir := os.TempDir() + "/opun/sessions/" + sessionID + _, err = os.Stat(sessionDir) + // Directory might not exist if cleanup ran, but PrepareSession should not error + + // Clean up + _ = provider.CleanupSession(ctx, sessionID) +} + +func TestQwenProvider_SupportsSlashCommands(t *testing.T) { + config := core.ProviderConfig{ + Name: "test-qwen", + Type: core.ProviderTypeQwen, + Command: "qwen", + } + + provider := NewQwenProvider(config) + assert.True(t, provider.SupportsSlashCommands()) +} + +func TestQwenProvider_GetSlashCommandFormat(t *testing.T) { + config := core.ProviderConfig{ + Name: "test-qwen", + Type: core.ProviderTypeQwen, + Command: "qwen", + } + + provider := NewQwenProvider(config) + assert.Equal(t, "mcp", provider.GetSlashCommandFormat()) +} + +func TestQwenProvider_GetPTYCommand(t *testing.T) { + config := core.ProviderConfig{ + Name: "test-qwen", + Type: core.ProviderTypeQwen, + Command: "qwen", + Args: []string{"chat"}, + WorkingDir: "/test/dir", + Environment: map[string]string{ + "TEST_VAR": "test_value", + }, + } + + provider := NewQwenProvider(config) + cmd, err := provider.GetPTYCommand() + + require.NoError(t, err) + // cmd.Path might be the full path to the binary, so just check it ends with "qwen" + assert.Contains(t, cmd.Path, "qwen") + // Check that Args contains the expected commands + assert.Contains(t, cmd.Args[0], "qwen") + assert.Equal(t, "chat", cmd.Args[1]) + assert.Equal(t, "/test/dir", cmd.Dir) + + // Check environment variable is set + envFound := false + for _, env := range cmd.Env { + if env == "TEST_VAR=test_value" { + envFound = true + break + } + } + assert.True(t, envFound, "TEST_VAR environment variable not found") +} + diff --git a/internal/tools/loader.go b/internal/tools/loader.go index 58bacfa..629756d 100644 --- a/internal/tools/loader.go +++ b/internal/tools/loader.go @@ -81,8 +81,11 @@ func (l *Loader) LoadAll() error { path := filepath.Join(l.toolsDir, entry.Name()) if err := l.LoadFile(path); err != nil { - // Log error but continue loading other tools - fmt.Fprintf(os.Stderr, "Warning: failed to load tool %s: %v\n", entry.Name(), err) + // Suppress warnings in MCP stdio mode to avoid interfering with JSON-RPC protocol + if os.Getenv("OPUN_MCP_STDIO") != "1" { + // Log error but continue loading other tools + fmt.Fprintf(os.Stderr, "Warning: failed to load tool %s: %v\n", entry.Name(), err) + } } } diff --git a/pkg/core/provider.go b/pkg/core/provider.go index 43d4223..3a5da71 100644 --- a/pkg/core/provider.go +++ b/pkg/core/provider.go @@ -28,6 +28,7 @@ type ProviderType string const ( ProviderTypeClaude ProviderType = "claude" ProviderTypeGemini ProviderType = "gemini" + ProviderTypeQwen ProviderType = "qwen" ProviderTypeMock ProviderType = "mock" )