All checks were successful
CI / build (push) Successful in 2m37s
- Add AES-256-GCM encryption for API keys (internal/secret) - Add dangerous command detection in terminal - Add muyue doctor command for system health checks - Add scanner TTL cache, orchestrator history mutex, shared HTTP client - Deduplicate MCP config generation, refactor skills YAML parser - Add XDG-compliant config dir with legacy migration - Add cleanup on all TUI quit paths - Add 8 test files (config, workflow, skills, orchestrator, version, platform, scanner, secret) - Update CI to actions/setup-go@v5 - Add CHANGELOG.md, update README and Makefile 🤖 Generated with Crush Assisted-by: GLM-5.1 via Crush <crush@charm.land>
155 lines
4.0 KiB
Go
155 lines
4.0 KiB
Go
package tui
|
|
|
|
import (
|
|
"os"
|
|
"os/exec"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
var dangerousPatterns = []*regexp.Regexp{
|
|
regexp.MustCompile(`(?i)\brm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+|/)`),
|
|
regexp.MustCompile(`(?i)\bmkfs\b`),
|
|
regexp.MustCompile(`(?i)\bdd\s+if=`),
|
|
regexp.MustCompile(`(?i)\b(format\s+[A-Za-z]:)\b`),
|
|
regexp.MustCompile(`(?i):\(\)\{.*\}`),
|
|
regexp.MustCompile(`(?i)>(/dev/|/etc/|/boot/)`),
|
|
regexp.MustCompile(`(?i)\bshutdown\b`),
|
|
regexp.MustCompile(`(?i)\breboot\b`),
|
|
regexp.MustCompile(`(?i)\bhalt\b`),
|
|
regexp.MustCompile(`(?i)\bpoweroff\b`),
|
|
}
|
|
|
|
func isDangerousCommand(input string) bool {
|
|
for _, pat := range dangerousPatterns {
|
|
if pat.MatchString(input) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (m Model) handleTerminalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "ctrl+c":
|
|
if m.termCmd != nil && m.termCmd.Process != nil {
|
|
m.termCmd.Process.Kill()
|
|
m.termRunning = false
|
|
m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(errorColor).Render("^C"))
|
|
m.termCmd = nil
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, nil
|
|
}
|
|
now := time.Now()
|
|
if m.ctrlCCount > 0 && now.Sub(m.lastCtrlC) < 2*time.Second {
|
|
return m, tea.Quit
|
|
}
|
|
m.ctrlCCount++
|
|
m.lastCtrlC = now
|
|
m.showingQuit = true
|
|
m.confirmCursor = 1
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, nil
|
|
case "ctrl+t":
|
|
m.showingTabMenu = true
|
|
m.tabMenuCursor = int(m.activeTab)
|
|
return m, nil
|
|
case "enter":
|
|
if m.termRunning {
|
|
return m, nil
|
|
}
|
|
input := strings.TrimSpace(m.termInput)
|
|
m.termInput = ""
|
|
if input == "" {
|
|
return m, nil
|
|
}
|
|
if input == "exit" || input == "quit" {
|
|
return m, nil
|
|
}
|
|
if input == "clear" {
|
|
m.termLog = nil
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, nil
|
|
}
|
|
if isDangerousCommand(input) {
|
|
m.termLog = append(m.termLog, errMsgStyle.Render("blocked: potentially dangerous command"))
|
|
m.viewport.SetContent(m.renderContent())
|
|
m.viewport.GotoBottom()
|
|
return m, nil
|
|
}
|
|
if strings.HasPrefix(input, "cd ") {
|
|
dir := strings.TrimPrefix(input, "cd ")
|
|
dir = strings.TrimSpace(dir)
|
|
if dir == "~" {
|
|
home, _ := os.UserHomeDir()
|
|
dir = home
|
|
}
|
|
if err := os.Chdir(dir); err == nil {
|
|
m.termCwd, _ = os.Getwd()
|
|
m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(dimColor).Render(m.termCwd+" $ "+input))
|
|
} else {
|
|
m.termLog = append(m.termLog, errMsgStyle.Render("cd: "+err.Error()))
|
|
}
|
|
m.viewport.SetContent(m.renderContent())
|
|
m.viewport.GotoBottom()
|
|
return m, nil
|
|
}
|
|
m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(dimColor).Render(m.termCwd+" $ ")+input)
|
|
m.viewport.SetContent(m.renderContent())
|
|
m.viewport.GotoBottom()
|
|
return m, m.runTermCommand(input)
|
|
case "backspace":
|
|
if len(m.termInput) > 0 {
|
|
m.termInput = m.termInput[:len(m.termInput)-1]
|
|
m.viewport.SetContent(m.renderContent())
|
|
}
|
|
return m, nil
|
|
default:
|
|
if len(msg.String()) == 1 {
|
|
m.termInput += msg.String()
|
|
m.viewport.SetContent(m.renderContent())
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) runTermCommand(input string) tea.Cmd {
|
|
return tea.Cmd(func() tea.Msg {
|
|
shell := os.Getenv("SHELL")
|
|
if shell == "" {
|
|
shell = "/bin/sh"
|
|
}
|
|
cmd := exec.Command(shell, "-c", input)
|
|
cmd.Dir = m.termCwd
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return termOutputMsg{line: string(out) + errMsgStyle.Render(err.Error())}
|
|
}
|
|
return termOutputMsg{line: string(out)}
|
|
})
|
|
}
|
|
|
|
func (m Model) renderTerminal() string {
|
|
var b strings.Builder
|
|
|
|
b.WriteString(sectionStyle.Render("Terminal"))
|
|
b.WriteString(" ")
|
|
b.WriteString(lipgloss.NewStyle().Foreground(dimColor).Render(m.termCwd))
|
|
b.WriteString("\n\n")
|
|
|
|
for _, line := range m.termLog {
|
|
b.WriteString(line + "\n")
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
func (m Model) renderTermInput() string {
|
|
prompt := lipgloss.NewStyle().Foreground(successColor).Render("$ ")
|
|
return prompt + m.termInput + lipgloss.NewStyle().Foreground(baseColor).Render("")
|
|
}
|