Files
higress/hgctl/pkg/agent/base.go
2025-12-26 13:47:32 +08:00

488 lines
13 KiB
Go

// Copyright (c) 2025 Alibaba Group Holding Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package agent
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/alibaba/higress/hgctl/pkg/agent/common"
"github.com/alibaba/higress/hgctl/pkg/agent/services"
"github.com/fatih/color"
"github.com/spf13/viper"
)
const (
NodeLeastVersion = 18
)
type HimarketAdminAuthArg struct {
hmURL string
hmUser string
hmPassword string
}
// Developer's page
type HimarketDevAuthArg struct {
hmURL string
hmUser string
hmPassword string
}
func (h *HimarketAdminAuthArg) validate() error {
if h.hmURL == "" || h.hmUser == "" || h.hmPassword == "" {
return fmt.Errorf("invalid args")
}
return nil
}
type HigressConsoleAuthArg struct {
// higress console auth arg
hgURL string
hgUser string
hgPassword string
}
func (h *HigressConsoleAuthArg) validate() error {
if h.hgURL == "" || h.hgUser == "" || h.hgPassword == "" {
fmt.Println("--higress-console-user, --higress-console-url, --higress-console-password must be provided")
return fmt.Errorf("invalid args")
}
return nil
}
func init() {
// Init the global configuration from config file
InitConfig()
}
func resolveHimarketAdminAuth(arg *HimarketAdminAuthArg) {
if arg.hmURL == "" {
arg.hmURL = viper.GetString(HIMARKET_ADMIN_URL)
}
if arg.hmUser == "" {
arg.hmUser = viper.GetString(HIMARKET_ADMIN_USER)
}
if arg.hmPassword == "" {
arg.hmPassword = viper.GetString(HIMARKET_ADMIN_PASSWORD)
}
}
// resolve from viper
func resolveHigressConsoleAuth(arg *HigressConsoleAuthArg) {
if arg.hgURL == "" {
arg.hgURL = viper.GetString(HIGRESS_CONSOLE_URL)
}
if arg.hgUser == "" {
arg.hgUser = viper.GetString(HIGRESS_CONSOLE_USER)
}
if arg.hgPassword == "" {
arg.hgPassword = viper.GetString(HIGRESS_CONSOLE_PASSWORD)
}
// fmt.Printf("arg: %v\n", arg)
if arg.hgUser == "" || arg.hgPassword == "" {
// Here we do not return this error, because it will failed when validate arg
if err := tryToGetLocalCredential(arg); err != nil {
fmt.Printf("failed to get local higress console credential: %s\n", err)
}
}
}
func parseTypeToAPIProductType(typ string) string {
switch typ {
case "a2a":
return string(common.AGENT_API)
case "restful":
return string(common.REST_API)
case "model":
return string(common.MODEL_API)
case "mcp":
return string(common.MCP_SERVER)
default:
return ""
}
}
// This function serves MCP API as well as Model API for now.
func publishAPIToHimarket(typ, name string, arg HimarketAdminAuthArg) error {
if err := arg.validate(); err != nil {
return err
}
client := services.NewHimarketClient(arg.hmURL, arg.hmUser, arg.hmPassword)
productName := fmt.Sprintf("%s-%s", typ, name)
var gatewayId = viper.GetString(HIMARKET_TARGET_HIGRESS_ID)
prompt := survey.Input{
Message: fmt.Sprintf("Enter the target Higress instance id on Himarket(%s):", gatewayId),
Default: gatewayId,
Help: fmt.Sprintf("refers to %s/consoles/gateway to get your target Higress instance's id", arg.hmURL),
}
if err := survey.AskOne(&prompt, &gatewayId); err != nil {
return fmt.Errorf("failed to get target higress gatewayID: %s", err)
}
body := services.BuildAPIProductBody(productName, "An agent API import by hgctl", parseTypeToAPIProductType(typ))
resp, err := services.HandleAddAPIProduct(client, body)
if err != nil {
fmt.Println(resp)
return err
}
product_id := string(resp)
var refBody map[string]interface{}
if typ == "mcp" {
refBody = services.BuildRefMCPAPIProductBody(gatewayId, product_id, name)
} else {
// target_route is the route_name in Higress, refers to `publishAgentAPIToHigress`
target_route := fmt.Sprintf("%s-route", name)
refBody = services.BuildRefModelAPIProductBody(gatewayId, product_id, target_route)
}
if resp, err := services.HandleRefAPIProduct(client, product_id, refBody); err != nil {
fmt.Println(string(resp))
return err
}
return nil
}
// use pre-defined command /gen-agent to generate sys prompt
func generateAgentPromptByCore(desc string) (string, error) {
core := NewAgenticCore()
prompt, err := core.runWithResult(fmt.Sprintf("/gen-agent %s", desc), "--print")
if err != nil {
return "", err
}
return prompt, nil
}
type EnvProvisioner struct {
core CoreType
installCmd string
releasePage string
// ~/.<core>
dirName string
}
func getCore() (*AgenticCore, error) {
provisioner := EnvProvisioner{
core: CoreType(viper.GetString(HGCTL_AGENT_CORE)),
}
if err := provisioner.check(); err != nil {
return nil, fmt.Errorf("⚠️ Prerequisites not satisfied: %s Exiting...", err)
}
return NewAgenticCore(), nil
}
func (p *EnvProvisioner) init() {
switch p.core {
case CORE_QODERCLI:
p.installCmd = "npm install -g @qoder-ai/qodercli"
p.releasePage = "https://docs.qoder.com/zh/cli/quick-start"
p.dirName = "qoder"
case CORE_CLAUDE:
p.installCmd = "npm install -g @anthropic-ai/claude-code"
p.releasePage = "https://docs.claude.com/en/docs/claude-code/setup"
p.dirName = "claude"
}
}
func (p *EnvProvisioner) check() error {
p.init()
if !p.checkNodeInstall() {
if err := p.promptNodeInstall(); err != nil {
return err
}
}
if !p.checkAgentInstall() {
if err := p.promptAgentInstall(); err != nil {
return err
}
}
return nil
}
func (p *EnvProvisioner) checkNodeInstall() bool {
cmd := exec.Command("node", "-v")
out, err := cmd.Output()
if err != nil {
return false
}
versionStr := strings.TrimPrefix(strings.TrimSpace(string(out)), "v")
parts := strings.Split(versionStr, ".")
if len(parts) == 0 {
return false
}
major, err := strconv.Atoi(parts[0])
if err != nil {
return false
}
return major >= NodeLeastVersion
}
func (p *EnvProvisioner) promptNodeInstall() error {
fmt.Println()
color.Yellow("⚠️ Node.js is not installed or not found in PATH.")
color.Cyan("🔧 Node.js is required to run the agent.")
fmt.Println()
options := []string{
"🚀 Install automatically (recommended)",
"📖 Exit and show manual installation guide",
}
var ans string
prompt := &survey.Select{
Message: "How would you like to install Node.js?",
Options: options,
}
if err := survey.AskOne(prompt, &ans); err != nil {
return fmt.Errorf("selection error: %w", err)
}
switch ans {
case options[0]:
fmt.Println()
color.Green("🚀 Installing Node.js automatically...")
if err := p.installNodeAutomatically(); err != nil {
color.Red("❌ Installation failed: %v", err)
fmt.Println()
p.showNodeManualInstallation()
return errors.New("node.js installation failed")
}
color.Green("✅ Node.js installation completed!")
fmt.Println()
color.Blue("🔍 Verifying installation...")
if p.checkNodeInstall() {
color.Green("🎉 Node.js is now available!")
return nil
} else {
color.Yellow("⚠️ Node.js installation completed but not found in PATH.")
color.Cyan("💡 You may need to restart your terminal or source your shell profile.")
return errors.New("node.js installed but not in PATH")
}
case options[1]:
p.showNodeManualInstallation()
return errors.New("node.js not installed")
default:
return errors.New("invalid selection")
}
}
func (p *EnvProvisioner) installNodeAutomatically() error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("could not get home directory: %w", err)
}
fnmBinPath := filepath.Join(homeDir, ".local/share/fnm/fnm")
if runtime.GOOS == "windows" {
fnmBinPath = filepath.Join(homeDir, "AppData/Roaming/fnm/fnm.exe")
}
switch runtime.GOOS {
case "windows":
color.Cyan("📦 For Windows, we recommend installing fnm via: 'winget install Schniz.fnm'")
return errors.New("automatic fnm installation on Windows is not implemented in this script")
case "darwin", "linux":
color.Cyan("🚀 Installing fnm (Fast Node Manager)...")
installFnmCmd := exec.Command("bash", "-c", "curl -fsSL https://fnm.vercel.app/install | bash -s -- --skip-shell")
installFnmCmd.Stdout = os.Stdout
installFnmCmd.Stderr = os.Stderr
if err := installFnmCmd.Run(); err != nil {
return fmt.Errorf("failed to install fnm: %w", err)
}
if _, err := os.Stat(fnmBinPath); os.IsNotExist(err) {
path, err := exec.LookPath("fnm")
if err == nil {
fnmBinPath = path
} else {
return errors.New("fnm was installed but binary not found at " + fnmBinPath)
}
}
color.Cyan("📦 Installing Node.js via fnm...")
installNodeCmd := exec.Command(fnmBinPath, "install", "--lts")
installNodeCmd.Stdout = os.Stdout
installNodeCmd.Stderr = os.Stderr
if err := installNodeCmd.Run(); err != nil {
return fmt.Errorf("failed to install node via fnm: %w", err)
}
color.Cyan("✅ Setting LTS as default Node.js version...")
useNodeCmd := exec.Command(fnmBinPath, "default", "lts-latest")
return useNodeCmd.Run()
default:
return errors.New("unsupported OS for automatic installation")
}
}
func (p *EnvProvisioner) showNodeManualInstallation() {
fmt.Println()
color.New(color.FgGreen, color.Bold).Println("📖 Manual Node.js Installation Guide")
fmt.Println()
fmt.Println(color.MagentaString("Choose one of the following installation methods:"))
fmt.Println()
color.Cyan("Method 1: Install via package manager")
color.Cyan("macOS (brew): brew install node")
color.Cyan("Ubuntu/Debian: sudo apt install -y nodejs npm")
color.Cyan("Windows: download from https://nodejs.org and run installer")
fmt.Println()
color.Yellow("Method 2: Download from official website")
color.Yellow("1. Download Node.js from https://nodejs.org/en/download/")
color.Yellow("2. Follow installer instructions and add to PATH if needed")
fmt.Println()
color.Green("✅ Verify Installation")
fmt.Println(color.WhiteString("node -v"))
fmt.Println(color.WhiteString("npm -v"))
fmt.Println()
color.Cyan("💡 After installation, restart your terminal or source your shell profile.")
fmt.Println()
}
func (p *EnvProvisioner) checkAgentInstall() bool {
cmd := exec.Command(string(p.core), "--version")
if err := cmd.Run(); err != nil {
return false
}
return true
}
func (p *EnvProvisioner) promptAgentInstall() error {
fmt.Println()
color.Yellow("⚠️ %s is not installed or not found in PATH.", p.core)
color.Cyan("🔧 %s is required to run the agent.", p.core)
fmt.Println()
options := []string{
"🚀 Install automatically",
"📖 Exit and show manual installation guide",
}
var ans string
prompt := &survey.Select{
Message: "How would you like to install " + string(p.core) + "?",
Options: options,
}
if err := survey.AskOne(prompt, &ans); err != nil {
return fmt.Errorf("selection error: %w", err)
}
switch ans {
case options[0]:
fmt.Println()
color.Green("🚀 Installing %s automatically...", p.core)
if err := p.installAgentAutomatically(); err != nil {
color.Red("❌ Installation failed: %v", err)
fmt.Println()
p.showAgentManualInstallation()
return errors.New(string(p.core) + " installation failed")
}
fmt.Println()
color.Blue("🔍 Verifying installation...")
if p.checkAgentInstall() {
color.Green("🎉 %s is now available!", p.core)
return nil
} else {
color.Yellow("⚠️ %s installed but not found in PATH.", p.core)
color.Cyan("💡 You may need to restart your terminal or source your shell profile.")
return errors.New(string(p.core) + " installed but not in PATH")
}
case options[1]:
p.showAgentManualInstallation()
return errors.New(string(p.core) + " not installed")
default:
return errors.New("invalid selection")
}
}
func (p *EnvProvisioner) installAgentAutomatically() error {
switch runtime.GOOS {
case "windows":
cmd := exec.Command("cmd", "/C", p.installCmd)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
case "darwin":
cmd := exec.Command("bash", "-c", p.installCmd)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
case "linux":
cmd := exec.Command("bash", "-c", p.installCmd)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
default:
return errors.New("unsupported OS for automatic installation")
}
}
func (p *EnvProvisioner) showAgentManualInstallation() {
fmt.Println()
color.New(color.FgGreen, color.Bold).Printf("📖 Manual %s Installation Guide\n", p.core)
fmt.Println()
color.Cyan(fmt.Sprintf("1. Go to official release page: %s", p.releasePage))
fmt.Printf(color.CyanString("2. Download %s for your OS\n"), p.core)
color.Cyan("3. Make it executable and place it in a directory in your PATH")
fmt.Println()
color.Cyan("💡 After installation, restart your terminal or source your shell profile.")
fmt.Println()
}