Compare commits

6 changed files with 280 additions and 125 deletions
+115 -27
View File
@@ -5,41 +5,129 @@ import (
"os"
"strings"
"nshell/executor"
"nshell/suggestions"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/c-bata/go-prompt"
"golang.org/x/term"
"nshell/executor"
)
var oldStateTerminal *term.State
// Сообщение, которое сигнализирует о завершении работы внешней команды
type processFinishedMsg struct{ err error }
func getColor(color string) prompt.Color {
color = strings.ToUpper(color)
switch color {
case "CYAN":
return prompt.Cyan
case "RED":
return prompt.Red
default:
return prompt.Green
type model struct {
textInput textinput.Model
lastInput string // Храним последнюю введенную строку для красивого лога
errText string // Храним ошибку, если команда не найдена
}
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() {
fd := int(os.Stdin.Fd())
if state, err := term.GetState(fd); err == nil {
oldStateTerminal = state
}
fmt.Println("Wellcome to noko-shell(nshell) :3")
// Применение setup скрипта
executor.ExecSetUpScript()
placeholderText := executor.GetVariable("NSHELL_NO_TEXT_PLACEHOLDER")
prefixText := executor.GetVariable("NSHELL_PREFIX")
options := []prompt.Option{
prompt.OptionLivePrefix(func() (string, bool) {
return executor.GetVariable("NSHELL_PREFIX_TEXT"), true
}),
prompt.OptionPrefixTextColor(getColor(executor.GetVariable("NSHELL_PREFIX_COLOR"))),
// Запускаем программу
p := tea.NewProgram(initialModel(placeholderText, prefixText))
if _, err := p.Run(); err != nil {
fmt.Printf("Error running noko-shell: %v\n", err)
os.Exit(1)
}
p := prompt.New(executor.BuildExecutor(oldStateTerminal, &fd), suggestions.Completer, options...)
p.Run()
}
+44 -54
View File
@@ -2,67 +2,57 @@
package executor
import (
"fmt"
"errors"
"os"
"os/exec"
"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) {
return func(line string) {
line = strings.TrimSpace(line)
if strings.Contains(line, "=") && !strings.HasPrefix(line, "export ") {
parts := strings.SplitN(line, "=", 2)
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
localVars[key] = value
return
}
line = os.Expand(line, GetVariable)
if line == "" {
return
}
args := strings.Fields(line)
command := args[0]
commandArgs := args[1:]
if command == "exit" {
fmt.Println("Bye Bye my small deer :3")
if oldStateTerminal != nil {
_ = term.Restore(*fd, oldStateTerminal)
}
os.Exit(0)
}
if command == "cd" {
if len(commandArgs) < 1 {
fmt.Fprintln(os.Stderr, "cd: argument was skipped")
return
}
if err := os.Chdir(commandArgs[0]); err != nil {
fmt.Fprintln(os.Stderr, err)
}
return
}
cmd := exec.Command(command, commandArgs...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Execution error: %v\n", err)
}
func PrepareCommand(in string) (*exec.Cmd, error) {
in = strings.TrimSpace(in)
if in == "" {
return nil, nil
}
if strings.Contains(in, "=") && !strings.HasPrefix(in, "export ") {
parts := strings.SplitN(in, "=", 2)
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
localVars[key] = value
return nil, nil
}
in = os.Expand(in, GetVariable)
in = strings.TrimSpace(in)
parts := strings.Fields(in)
head := parts[0]
args := parts[1:]
if len(parts) == 0 {
return nil, nil // Если после os.Expand строка стала пустой, безопасно выходим
}
switch head {
case "exit":
return nil, ErrBuiltInExit
case "help":
return exec.Command("echo", "noko-shell: available commands: any system command, help, exit"), nil
}
// Поиск системной утилиты в PATH (авто-проверка на существование)
fullPath, err := exec.LookPath(head)
if err != nil {
return nil, err
}
// Возвращаем подготовленный процесс
return exec.Command(fullPath, args...), nil
}
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
go 1.27.2
go 1.26.8
require (
github.com/c-bata/go-prompt v0.2.6
golang.org/x/term v0.47.0
charm.land/bubbles/v2 v2.2.1
charm.land/bubbletea/v2 v2.1.0
charm.land/lipgloss/v2 v2.0.6
)
require (
github.com/mattn/go-colorable v0.1.7 // indirect
github.com/mattn/go-isatty v0.0.12 // indirect
github.com/mattn/go-runewidth v0.0.9 // indirect
github.com/mattn/go-tty v0.0.3 // indirect
github.com/pkg/term v1.2.0-beta.2 // indirect
golang.org/x/sys v0.49.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect
github.com/charmbracelet/x/ansi v0.11.9 // indirect
github.com/charmbracelet/x/term v0.2.2 // 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=
github.com/c-bata/go-prompt v0.2.6/go.mod h1:/LMAke8wD2FsNu9EXNdHxNLbd9MedkPnCdfpU9wwHfY=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.7 h1:bQGKb3vps/j0E9GfJQ03JyhRuxsvdAanXlT9BTw3mdw=
github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-runewidth v0.0.6/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-tty v0.0.3 h1:5OfyWorkyO7xP52Mq7tB36ajHDG5OHrmBGIS/DtakQI=
github.com/mattn/go-tty v0.0.3/go.mod h1:ihxohKRERHTVzN+aSVRwACLCeqIoZAWpoICkkvrWyR0=
github.com/pkg/term v1.2.0-beta.2 h1:L3y/h2jkuBVFdWiJvNfYfKmzcCnILw7mJWm2JQuMppw=
github.com/pkg/term v1.2.0-beta.2/go.mod h1:E25nymQcrSllhX42Ok8MRm1+hyBdHY0dCeiKZ9jpNGw=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200918174421-af09f7315aff/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw=
charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU=
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 h1:hISLzUbwcvJbGoNR6A3L/TpguRRnACBmZOee8LcHLJw=
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4/go.mod h1:aJDVk+WOVM1K04rdc/71PMPoUGfjxjhvXfP3Pjz1Zio=
github.com/charmbracelet/x/ansi v0.11.9 h1:kIrqLBxtTVbLyyHO2Evoi93EjQo1mylWmpNEsfPxXAM=
github.com/charmbracelet/x/ansi v0.11.9/go.mod h1:DG/hpOnaI2QJ/KMMqLL2h0KVhluuJLazf8973P+Vy5w=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
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
import "github.com/c-bata/go-prompt"
import "strings"
var defaultSuggestions = []prompt.Suggest{
{Text: "exit", Description: "Exit from shell"},
}
var defaultSuggestions = []string{"exit"}
func Completer(d prompt.Document) []prompt.Suggest {
// Если пользователь уже начал что-то писать, фильтруем список
return prompt.FilterHasPrefix(defaultSuggestions, d.GetWordBeforeCursor(), true)
func Completer(in string) []string {
if in == "" {
return nil
}
var matched []string
for _, cmd := range defaultSuggestions {
if strings.HasPrefix(cmd, in) {
matched = append(matched, cmd)
}
}
return matched
}