Compare commits

6 changed files with 280 additions and 125 deletions
+115 -27
View File
@@ -5,41 +5,129 @@ import (
"os" "os"
"strings" "strings"
"nshell/executor" "charm.land/bubbles/v2/textinput"
"nshell/suggestions" tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/c-bata/go-prompt" "nshell/executor"
"golang.org/x/term"
) )
var oldStateTerminal *term.State // Сообщение, которое сигнализирует о завершении работы внешней команды
type processFinishedMsg struct{ err error }
func getColor(color string) prompt.Color { type model struct {
color = strings.ToUpper(color) textInput textinput.Model
switch color { lastInput string // Храним последнюю введенную строку для красивого лога
case "CYAN": errText string // Храним ошибку, если команда не найдена
return prompt.Cyan
case "RED":
return prompt.Red
default:
return prompt.Green
} }
func initialModel(placeholderText string, prefixText string) model {
ti := textinput.New()
ti.Placeholder = placeholderText
ti.Focus()
ti.CharLimit = 250
ti.SetWidth(60)
// НАСТРОЙКА TRUECOLOR
prefixStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF00FA")).
Bold(true)
ti.Prompt = prefixStyle.Render(prefixText)
return model{
textInput: ti,
lastInput: "",
errText: "",
}
}
func (m model) Init() tea.Cmd {
return textinput.Blink
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
m.textInput.SetValue("")
return m, nil
case "ctrl+d":
return m, tea.Quit
case "enter":
inputVal := m.textInput.Value()
m.textInput.SetValue("") // Очищаем поле ввода сразу
m.errText = "" // Сбрасываем прошлые ошибки
if strings.TrimSpace(inputVal) == "" {
return m, nil
}
m.lastInput = inputVal
// Передаем ввод в наш executor для подготовки команды
c, err := executor.PrepareCommand(inputVal)
if err != nil {
if err == executor.ErrBuiltInExit {
return m, tea.Quit
}
// Если exec.LookPath не нашел бинарник в системе
m.errText = fmt.Sprintf("noko-shell: command not found: %s", strings.Fields(inputVal)[0])
return m, nil
}
// Универсальный запуск процесса (и для ls, и для nano/top/htop)
// Мы связываем IO процесса с системным, чтобы stderr и stdout вывелись в терминал напрямую.
c.Stdin = os.Stdin
c.Stdout = os.Stdout
c.Stderr = os.Stderr
// Вызываем ExecProcess, который сворачивает TUI, выполняет команду и возвращает управление
return m, tea.ExecProcess(c, func(err error) tea.Msg {
return processFinishedMsg{err: err}
})
}
case processFinishedMsg:
if msg.err != nil {
// Ошибка самого процесса (например, неверные флаги у команды)
m.errText = fmt.Sprintf("process exited with error: %v", msg.err)
}
return m, nil
}
var cmd tea.Cmd
m.textInput, cmd = m.textInput.Update(msg)
return m, cmd
}
func (m model) View() tea.View {
var s strings.Builder
if m.errText != "" {
errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF0000"))
s.WriteString(errStyle.Render(m.errText))
s.WriteString("\n")
}
s.WriteString(m.textInput.View())
// Возвращаем объект tea.View, обернув в него строку
return tea.NewView(s.String())
} }
func main() { func main() {
fd := int(os.Stdin.Fd()) // Применение setup скрипта
if state, err := term.GetState(fd); err == nil { executor.ExecSetUpScript()
oldStateTerminal = state placeholderText := executor.GetVariable("NSHELL_NO_TEXT_PLACEHOLDER")
} prefixText := executor.GetVariable("NSHELL_PREFIX")
fmt.Println("Wellcome to noko-shell(nshell) :3")
options := []prompt.Option{ // Запускаем программу
prompt.OptionLivePrefix(func() (string, bool) { p := tea.NewProgram(initialModel(placeholderText, prefixText))
return executor.GetVariable("NSHELL_PREFIX_TEXT"), true if _, err := p.Run(); err != nil {
}), fmt.Printf("Error running noko-shell: %v\n", err)
prompt.OptionPrefixTextColor(getColor(executor.GetVariable("NSHELL_PREFIX_COLOR"))), os.Exit(1)
} }
p := prompt.New(executor.BuildExecutor(oldStateTerminal, &fd), suggestions.Completer, options...)
p.Run()
} }
+32 -42
View File
@@ -2,67 +2,57 @@
package executor package executor
import ( import (
"fmt" "errors"
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
"golang.org/x/term"
) )
var localVars = make(map[string]string) var (
ErrBuiltInExit = errors.New("exit command")
localVars = make(map[string]string)
)
func BuildExecutor(oldStateTerminal *term.State, fd *int) func(string) { func PrepareCommand(in string) (*exec.Cmd, error) {
return func(line string) { in = strings.TrimSpace(in)
line = strings.TrimSpace(line) if in == "" {
return nil, nil
if strings.Contains(line, "=") && !strings.HasPrefix(line, "export ") { }
parts := strings.SplitN(line, "=", 2) if strings.Contains(in, "=") && !strings.HasPrefix(in, "export ") {
parts := strings.SplitN(in, "=", 2)
key := strings.TrimSpace(parts[0]) key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1]) value := strings.TrimSpace(parts[1])
localVars[key] = value localVars[key] = value
return return nil, nil
} }
line = os.Expand(line, GetVariable) in = os.Expand(in, GetVariable)
in = strings.TrimSpace(in)
if line == "" { parts := strings.Fields(in)
return head := parts[0]
args := parts[1:]
if len(parts) == 0 {
return nil, nil // Если после os.Expand строка стала пустой, безопасно выходим
} }
args := strings.Fields(line) switch head {
command := args[0] case "exit":
commandArgs := args[1:] return nil, ErrBuiltInExit
case "help":
if command == "exit" { return exec.Command("echo", "noko-shell: available commands: any system command, help, exit"), nil
fmt.Println("Bye Bye my small deer :3")
if oldStateTerminal != nil {
_ = term.Restore(*fd, oldStateTerminal)
}
os.Exit(0)
} }
if command == "cd" { // Поиск системной утилиты в PATH (авто-проверка на существование)
if len(commandArgs) < 1 { fullPath, err := exec.LookPath(head)
fmt.Fprintln(os.Stderr, "cd: argument was skipped") if err != nil {
return return nil, err
}
if err := os.Chdir(commandArgs[0]); err != nil {
fmt.Fprintln(os.Stderr, err)
}
return
} }
cmd := exec.Command(command, commandArgs...) // Возвращаем подготовленный процесс
cmd.Stdin = os.Stdin return exec.Command(fullPath, args...), nil
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Execution error: %v\n", err)
}
}
} }
func GetVariable(key string) string { func GetVariable(key string) string {
+44
View File
@@ -0,0 +1,44 @@
package executor
import (
"bufio"
"fmt"
"os"
"strings"
)
const defaultStartScript string = `
echo meow meow
NSHELL_NO_TEXT_PLACEHOLDER=Type Noko Meow Meow
NSHELL_PREFIX=noko>
`
func ExecSetUpScript() {
scanner := bufio.NewScanner(strings.NewReader(defaultStartScript))
for scanner.Scan() {
line := scanner.Text()
cmd, err := PrepareCommand(line)
if err != nil {
fmt.Printf("Error %s\n", err.Error())
}
if err != nil {
fmt.Printf("Error preparing command: %s\n", err.Error())
continue
}
if cmd == nil {
continue
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err = cmd.Run()
if err != nil {
fmt.Printf("Ошибка при выполнении команды: %s\n", err.Error())
}
}
if err := scanner.Err(); err != nil {
fmt.Println("Ошибка чтения:", err)
}
}
+20 -9
View File
@@ -1,17 +1,28 @@
module nshell module nshell
go 1.27.2 go 1.26.8
require ( require (
github.com/c-bata/go-prompt v0.2.6 charm.land/bubbles/v2 v2.2.1
golang.org/x/term v0.47.0 charm.land/bubbletea/v2 v2.1.0
charm.land/lipgloss/v2 v2.0.6
) )
require ( require (
github.com/mattn/go-colorable v0.1.7 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/mattn/go-isatty v0.0.12 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/mattn/go-runewidth v0.0.9 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect
github.com/mattn/go-tty v0.0.3 // indirect github.com/charmbracelet/x/ansi v0.11.9 // indirect
github.com/pkg/term v1.2.0-beta.2 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect
golang.org/x/sys v0.49.0 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
github.com/mattn/go-runewidth v0.0.30 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v1.0.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
) )
+44 -28
View File
@@ -1,28 +1,44 @@
github.com/c-bata/go-prompt v0.2.6 h1:POP+nrHE+DfLYx370bedwNhsqmpCUynWPxuHi0C5vZI= charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
github.com/c-bata/go-prompt v0.2.6/go.mod h1:/LMAke8wD2FsNu9EXNdHxNLbd9MedkPnCdfpU9wwHfY= charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw=
github.com/mattn/go-colorable v0.1.7 h1:bQGKb3vps/j0E9GfJQ03JyhRuxsvdAanXlT9BTw3mdw= charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU=
github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/mattn/go-runewidth v0.0.6/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/mattn/go-tty v0.0.3 h1:5OfyWorkyO7xP52Mq7tB36ajHDG5OHrmBGIS/DtakQI= github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 h1:hISLzUbwcvJbGoNR6A3L/TpguRRnACBmZOee8LcHLJw=
github.com/mattn/go-tty v0.0.3/go.mod h1:ihxohKRERHTVzN+aSVRwACLCeqIoZAWpoICkkvrWyR0= github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4/go.mod h1:aJDVk+WOVM1K04rdc/71PMPoUGfjxjhvXfP3Pjz1Zio=
github.com/pkg/term v1.2.0-beta.2 h1:L3y/h2jkuBVFdWiJvNfYfKmzcCnILw7mJWm2JQuMppw= github.com/charmbracelet/x/ansi v0.11.9 h1:kIrqLBxtTVbLyyHO2Evoi93EjQo1mylWmpNEsfPxXAM=
github.com/pkg/term v1.2.0-beta.2/go.mod h1:E25nymQcrSllhX42Ok8MRm1+hyBdHY0dCeiKZ9jpNGw= github.com/charmbracelet/x/ansi v0.11.9/go.mod h1:DG/hpOnaI2QJ/KMMqLL2h0KVhluuJLazf8973P+Vy5w=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
golang.org/x/sys v0.0.0-20200918174421-af09f7315aff/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+13 -7
View File
@@ -1,13 +1,19 @@
// Package suggestions // Package suggestions
package suggestions package suggestions
import "github.com/c-bata/go-prompt" import "strings"
var defaultSuggestions = []prompt.Suggest{ var defaultSuggestions = []string{"exit"}
{Text: "exit", Description: "Exit from shell"},
}
func Completer(d prompt.Document) []prompt.Suggest { func Completer(in string) []string {
// Если пользователь уже начал что-то писать, фильтруем список if in == "" {
return prompt.FilterHasPrefix(defaultSuggestions, d.GetWordBeforeCursor(), true) return nil
}
var matched []string
for _, cmd := range defaultSuggestions {
if strings.HasPrefix(cmd, in) {
matched = append(matched, cmd)
}
}
return matched
} }