diff --git a/Makefile b/Makefile index 5388c48..383f46b 100644 --- a/Makefile +++ b/Makefile @@ -1,29 +1,19 @@ PREFIX ?= $(HOME)/.local PROFILE_DIR = $(HOME)/.config/nono/profiles BIN_DIR = $(PREFIX)/bin -TUI_DIR = tui - -.PHONY: install uninstall validate test tui tui-build tui-install - + +.PHONY: install uninstall validate test + install: @./scripts/install.sh - + uninstall: rm -f $(PROFILE_DIR)/opencode-tinfoil.json rm -f $(BIN_DIR)/opencode @echo "Uninstalled." - + validate: nono profile validate opencode-tinfoil - + test: $(BIN_DIR)/opencode --version - -tui: - cd $(TUI_DIR) && go run . - -tui-build: - cd $(TUI_DIR) && go build -buildvcs=false -o $(BIN_DIR)/nono-tui . - -tui-install: tui-build - @echo "Installed nono-tui to $(BIN_DIR)/nono-tui" \ No newline at end of file diff --git a/README.md b/README.md index 8a43264..1fb5bce 100644 --- a/README.md +++ b/README.md @@ -79,126 +79,35 @@ alias opencode="$HOME/.local/bin/opencode" ``` ## Usage - + From any project directory: - + ```bash opencode ``` - + Or use the profile directly with any opencode binary: - + ```bash nono run --profile opencode-tinfoil --allow-cwd -- opencode ``` - + You can also point it at a specific binary: - + ```bash # Homebrew (macOS) nono run --profile opencode-tinfoil --allow-cwd -- /opt/homebrew/bin/opencode - + # Homebrew (Linux) nono run --profile opencode-tinfoil --allow-cwd -- /home/linuxbrew/.linuxbrew/bin/opencode - + # npm global nono run --profile opencode-tinfoil --allow-cwd -- $(npm root -g)/opencode/bin/opencode ``` - -## TUI - -A terminal UI for managing the sandbox profile and checking environment status. - -### Run from the repo - -```bash -make tui -``` - -### Install for use from anywhere - -Build and install the binary to `~/.local/bin/nono-tui`: - -```bash -make tui-install -``` - -Then run from any directory: - -```bash -nono-tui -``` - -Make sure `~/.local/bin` is on your `PATH`. If it isn't, add this to your shell config file (e.g., `~/.bashrc` or `~/.zshrc`): - -```bash -export PATH="$HOME/.local/bin:$PATH" -``` - -The TUI reads and edits the installed profile at -`~/.config/nono/profiles/opencode-tinfoil.json`, so it works regardless of -your current directory. - -### What it does - -The TUI has two tabs: - -**Dashboard** — Shows live status of your sandbox setup at a glance: - -``` - ✓ nono installed /home/linuxbrew/.linuxbrew/bin/nono nono 0.x.x - ✓ profile installed ~/.config/nono/profiles/opencode-tinfoil.json - ✓ wrapper installed ~/.local/bin/opencode - ✗ wrapper first on PATH resolves to /opt/homebrew/bin/opencode - ✓ opencode binary /opt/homebrew/bin/opencode - ✓ tinfoil credentials ~/.tinfoil - - > Launch opencode in sandbox - Validate profile - Refresh checks -``` - -Use `j/k` to navigate the action list, `l` to launch opencode in the sandbox, -`v` to validate the profile, and `r` to re-run all checks. - -**Editor** — Edit the installed profile -(`~/.config/nono/profiles/opencode-tinfoil.json`) with four sub-tabs: - -| Sub-tab | Key | What you can edit | -|---------|-----|-------------------| -| Commands | `1` | Allowed binaries (git, node, npm, ...) and blocked binaries (sudo, su, mkfs, ...) | -| Filesystem | `2` | Allowed, read-only, write, and denied paths | -| Network | `3` | Allowed domains (`*.tinfoil.sh`, `api.openai.com`, ...) and network block toggle | -| Environment | `4` | Set environment variables and denied env vars | - -Changes are applied to the live installed profile — they take effect on the -next `opencode` launch. Press `s` to save (which also runs `nono profile -validate`). Saved files are written as clean JSON (comments are stripped). - -### Keybindings - -| Key | Action | -|-----|--------| -| `Tab` | Switch between Dashboard and Editor | -| `1`-`4` | Switch editor sub-tab | -| `j/k`, `↑/↓` | Move cursor | -| `←/→` | Switch between lists within a sub-tab | -| `a` | Add entry | -| `x` | Remove selected entry | -| `enter` | Edit selected entry inline | -| `b` | Toggle network block (Network tab only) | -| `s` | Save profile + validate | -| `l` | Launch opencode in sandbox (Dashboard only) | -| `v` | Validate profile (Dashboard only) | -| `r` | Refresh checks (Dashboard only) | -| `q` | Quit | -| `Q` | Force quit (discard unsaved changes) | - -Unsaved changes are marked with `*` in the title. Pressing `q` with unsaved -changes warns you; press `Q` to force quit. - + ## Tinfoil integration + The profile allows network access to: - `*.tinfoil.sh` @@ -224,19 +133,14 @@ and grants read/write access to `~/.tinfoil` for Tinfoil credentials. | Denied | `AWS_*`, `GOOGLE_*`, `AZURE_*`, `KUBECONFIG`, `SSH_AUTH_SOCK` | ## Uninstall - - + + ```bash make uninstall ``` - -To also remove the TUI binary: - -```bash -rm -f ~/.local/bin/nono-tui -``` - + ## License + GPL-3.0 diff --git a/tui/checks.go b/tui/checks.go deleted file mode 100644 index b222044..0000000 --- a/tui/checks.go +++ /dev/null @@ -1,105 +0,0 @@ -package main - -import ( - "os" - "os/exec" - "path/filepath" - "strings" -) - -type CheckStatus int - -const ( - StatusUnknown CheckStatus = iota - StatusOK - StatusFail -) - -type CheckResult struct { - Label string - Status CheckStatus - Detail string -} - -func RunChecks() []CheckResult { - var results []CheckResult - - if path, err := exec.LookPath("nono"); err == nil { - version := getNonoVersion() - detail := path - if version != "" { - detail += " " + version - } - results = append(results, CheckResult{"nono installed", StatusOK, detail}) - } else { - results = append(results, CheckResult{"nono installed", StatusFail, "not found in PATH"}) - } - - profilePath, _ := DefaultProfilePath() - if profilePath != "" { - if info, err := os.Stat(profilePath); err == nil { - results = append(results, CheckResult{"profile installed", StatusOK, - profilePath + " (" + info.ModTime().Format("2006-01-02") + ")"}) - } else { - results = append(results, CheckResult{"profile installed", StatusFail, "not found"}) - } - } - - home, _ := os.UserHomeDir() - wrapperPath := filepath.Join(home, ".local", "bin", "opencode") - if _, err := os.Stat(wrapperPath); err == nil { - results = append(results, CheckResult{"wrapper installed", StatusOK, wrapperPath}) - } else { - results = append(results, CheckResult{"wrapper installed", StatusFail, "not found"}) - } - - if path, err := exec.LookPath("opencode"); err == nil { - if path == wrapperPath { - results = append(results, CheckResult{"wrapper first on PATH", StatusOK, path}) - } else { - results = append(results, CheckResult{"wrapper first on PATH", StatusFail, "resolves to " + path}) - } - } else { - results = append(results, CheckResult{"wrapper first on PATH", StatusFail, "opencode not in PATH"}) - } - - opencodePath := findRealOpencode(wrapperPath) - if opencodePath != "" { - results = append(results, CheckResult{"opencode binary", StatusOK, opencodePath}) - } else { - results = append(results, CheckResult{"opencode binary", StatusFail, "real opencode not found"}) - } - - tinfoilDir := filepath.Join(home, ".tinfoil") - if info, err := os.Stat(tinfoilDir); err == nil && info.IsDir() { - results = append(results, CheckResult{"tinfoil credentials", StatusOK, tinfoilDir}) - } else { - results = append(results, CheckResult{"tinfoil credentials", StatusFail, "no ~/.tinfoil directory"}) - } - - return results -} - -func getNonoVersion() string { - cmd := exec.Command("nono", "--version") - out, err := cmd.Output() - if err != nil { - return "" - } - return strings.TrimSpace(string(out)) -} - -func findRealOpencode(wrapperPath string) string { - path := os.Getenv("PATH") - for _, dir := range filepath.SplitList(path) { - for _, name := range []string{"opencode", "opencode.exe"} { - candidate := filepath.Join(dir, name) - if info, err := os.Stat(candidate); err == nil && !info.IsDir() { - if candidate != wrapperPath { - return candidate - } - } - } - } - return "" -} diff --git a/tui/dashboard.go b/tui/dashboard.go deleted file mode 100644 index afa4d0f..0000000 --- a/tui/dashboard.go +++ /dev/null @@ -1,148 +0,0 @@ -package main - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - - tea "github.com/charmbracelet/bubbletea" -) - -type DashboardModel struct { - checks []CheckResult - cursor int - maxCursor int - output string - width int -} - -func NewDashboardModel(checks []CheckResult) DashboardModel { - return DashboardModel{ - checks: checks, - maxCursor: 2, - } -} - -type checksDoneMsg struct { - results []CheckResult -} - -type launchResultMsg struct { - err error -} - -type validateResultMsg struct { - output string - err error -} - -func runChecksCmd() tea.Cmd { - return func() tea.Msg { - return checksDoneMsg{results: RunChecks()} - } -} - -func launchOpencodeCmd() tea.Cmd { - home, _ := os.UserHomeDir() - wrapper := filepath.Join(home, ".local", "bin", "opencode") - cmd := exec.Command(wrapper) - return tea.ExecProcess(cmd, func(err error) tea.Msg { - return launchResultMsg{err: err} - }) -} - -func validateProfileCmd() tea.Cmd { - return func() tea.Msg { - cmd := exec.Command("nono", "profile", "validate", "opencode-tinfoil") - out, err := cmd.CombinedOutput() - return validateResultMsg{output: strings.TrimSpace(string(out)), err: err} - } -} - -func (m DashboardModel) Update(msg tea.Msg) (DashboardModel, tea.Cmd) { - switch msg := msg.(type) { - case checksDoneMsg: - m.checks = msg.results - return m, nil - case launchResultMsg: - if msg.err != nil { - m.output = fmt.Sprintf("Launch failed: %v", msg.err) - } else { - m.output = "opencode exited normally" - } - return m, nil - case validateResultMsg: - if msg.err != nil { - m.output = "Validation: " + msg.output - } else { - m.output = "Validation OK: " + msg.output - } - return m, nil - case tea.KeyMsg: - switch msg.String() { - case "up", "k": - if m.cursor > 0 { - m.cursor-- - } - case "down", "j": - if m.cursor < m.maxCursor { - m.cursor++ - } - case "l": - if m.cursor == 0 { - return m, launchOpencodeCmd() - } - case "v": - if m.cursor == 1 { - return m, validateProfileCmd() - } - case "r": - m.output = "Refreshing..." - return m, runChecksCmd() - } - } - return m, nil -} - -func (m DashboardModel) View() string { - var b strings.Builder - - b.WriteString("Dashboard\n\n") - - for _, check := range m.checks { - var icon string - style := checkOKStyle - if check.Status == StatusOK { - icon = "✓" - } else { - icon = "✗" - style = checkFailStyle - } - b.WriteString(fmt.Sprintf(" %s %s %s\n", - style.Render(icon), - labelStyle.Render(check.Label), - detailStyle.Render(check.Detail), - )) - } - - b.WriteString("\n") - - actions := []string{"Launch opencode in sandbox", "Validate profile", "Refresh checks"} - for i, action := range actions { - cursor := " " - if i == m.cursor { - cursor = cursorStyle.Render("> ") - } - b.WriteString(fmt.Sprintf("%s%s\n", cursor, action)) - } - - if m.output != "" { - b.WriteString("\n" + detailStyle.Render(m.output)) - } - - b.WriteString("\n\n" + helpStyle.Render(" l:launch v:validate r:refresh Tab:editor")) - - return b.String() -} diff --git a/tui/editor.go b/tui/editor.go deleted file mode 100644 index 0714b78..0000000 --- a/tui/editor.go +++ /dev/null @@ -1,224 +0,0 @@ -package main - -import ( - "fmt" - "strings" - - tea "github.com/charmbracelet/bubbletea" -) - -type editorTab int - -const ( - tabCommands editorTab = iota - tabFilesystem - tabNetwork - tabEnv -) - -var editorTabNames = []string{"Commands", "Filesystem", "Network", "Environment"} - -type EditorModel struct { - subTab editorTab - cmdLists [2]EditableList - fsLists [4]EditableList - netList EditableList - envLists [2]EditableList - netBlock bool - listIdx int - width int -} - -func NewEditorModel(p *Profile) EditorModel { - m := EditorModel{subTab: tabCommands} - if p != nil { - m.cmdLists[0] = NewEditableList("Allow", p.Commands.Allow) - m.cmdLists[1] = NewEditableList("Deny", p.Commands.Deny) - m.fsLists[0] = NewEditableList("Allow", p.FS.Allow) - m.fsLists[1] = NewEditableList("Read", p.FS.Read) - m.fsLists[2] = NewEditableList("Write", p.FS.Write) - m.fsLists[3] = NewEditableList("Deny", p.FS.Deny) - m.netList = NewEditableList("Allow Domains", p.Network.AllowDomain) - m.envLists[0] = NewEditableList("Set Vars (key=value)", keyValueStrings(p.Env.SetVars)) - m.envLists[1] = NewEditableList("Deny Vars", p.Env.DenyVars) - m.netBlock = p.Network.Block - } - return m -} - -func (m EditorModel) listCount() int { - switch m.subTab { - case tabCommands: - return 2 - case tabFilesystem: - return 4 - case tabNetwork: - return 1 - case tabEnv: - return 2 - } - return 0 -} - -func (m EditorModel) currentList() EditableList { - switch m.subTab { - case tabCommands: - return m.cmdLists[m.listIdx] - case tabFilesystem: - return m.fsLists[m.listIdx] - case tabNetwork: - return m.netList - case tabEnv: - return m.envLists[m.listIdx] - } - return EditableList{} -} - -func (m EditorModel) withList(idx int, l EditableList) EditorModel { - switch m.subTab { - case tabCommands: - m.cmdLists[idx] = l - case tabFilesystem: - m.fsLists[idx] = l - case tabNetwork: - m.netList = l - case tabEnv: - m.envLists[idx] = l - } - return m -} - -func (m EditorModel) listTitles() []string { - switch m.subTab { - case tabCommands: - return []string{"Allow", "Deny"} - case tabFilesystem: - return []string{"Allow", "Read", "Write", "Deny"} - case tabNetwork: - return []string{"Allow Domains"} - case tabEnv: - return []string{"Set Vars", "Deny Vars"} - } - return nil -} - -func (m EditorModel) Update(msg tea.Msg) (EditorModel, tea.Cmd) { - current := m.currentList() - if current.IsEditing() { - updated, cmd := current.Update(msg) - m = m.withList(m.listIdx, updated) - return m, cmd - } - - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "1": - m.subTab = tabCommands - m.listIdx = 0 - return m, nil - case "2": - m.subTab = tabFilesystem - m.listIdx = 0 - return m, nil - case "3": - m.subTab = tabNetwork - m.listIdx = 0 - return m, nil - case "4": - m.subTab = tabEnv - m.listIdx = 0 - return m, nil - case "left": - if m.listIdx > 0 { - m.listIdx-- - } - return m, nil - case "right": - if m.listIdx < m.listCount()-1 { - m.listIdx++ - } - return m, nil - case "b": - if m.subTab == tabNetwork { - m.netBlock = !m.netBlock - return m, nil - } - default: - updated, cmd := current.Update(msg) - m = m.withList(m.listIdx, updated) - return m, cmd - } - } - return m, nil -} - -func (m EditorModel) View() string { - var b strings.Builder - - b.WriteString(" ") - for i, name := range editorTabNames { - num := fmt.Sprintf("%d", i+1) - if editorTab(i) == m.subTab { - b.WriteString(activeTabStyle.Render(num + " " + name)) - } else { - b.WriteString(tabStyle.Render(num + " " + name)) - } - b.WriteString(" ") - } - b.WriteString("\n\n") - - current := m.currentList() - titles := m.listTitles() - if len(titles) > 1 { - list := current - list.title = fmt.Sprintf("%s (%d/%d)", titles[m.listIdx], m.listIdx+1, m.listCount()) - b.WriteString(list.View()) - } else { - b.WriteString(current.View()) - } - - if m.subTab == tabNetwork { - blockStr := "no" - if m.netBlock { - blockStr = "yes" - } - b.WriteString("\n\n Block: " + blockStr + " (b to toggle)") - } - - b.WriteString("\n\n" + helpStyle.Render(" 1-4:sub-tab <-/->:switch list a:add x:remove enter:edit s:save Tab:dashboard")) - - return b.String() -} - -func (m EditorModel) SyncToProfile(p *Profile) { - p.Commands.Allow = m.cmdLists[0].Items() - p.Commands.Deny = m.cmdLists[1].Items() - p.FS.Allow = m.fsLists[0].Items() - p.FS.Read = m.fsLists[1].Items() - p.FS.Write = m.fsLists[2].Items() - p.FS.Deny = m.fsLists[3].Items() - p.Network.AllowDomain = m.netList.Items() - p.Network.Block = m.netBlock - p.Env.SetVars = parseKeyValueStrings(m.envLists[0].Items()) - p.Env.DenyVars = m.envLists[1].Items() -} - -func keyValueStrings(m map[string]string) []string { - var result []string - for k, v := range m { - result = append(result, k+"="+v) - } - return result -} - -func parseKeyValueStrings(items []string) map[string]string { - result := make(map[string]string) - for _, item := range items { - parts := strings.SplitN(item, "=", 2) - if len(parts) == 2 { - result[parts[0]] = parts[1] - } - } - return result -} diff --git a/tui/go.mod b/tui/go.mod deleted file mode 100644 index 78f9462..0000000 --- a/tui/go.mod +++ /dev/null @@ -1,28 +0,0 @@ -module nono-opencode-tinfoil/tui - -go 1.18 - -require ( - github.com/charmbracelet/bubbles v0.16.1 - github.com/charmbracelet/bubbletea v0.24.2 - github.com/charmbracelet/lipgloss v0.7.1 -) - -require ( - github.com/atotto/clipboard v0.1.4 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-isatty v0.0.18 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.14 // indirect - github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect - github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/reflow v0.3.0 // indirect - github.com/muesli/termenv v0.15.1 // indirect - github.com/rivo/uniseg v0.2.0 // indirect - golang.org/x/sync v0.1.0 // indirect - golang.org/x/sys v0.6.0 // indirect - golang.org/x/term v0.6.0 // indirect - golang.org/x/text v0.3.8 // indirect -) diff --git a/tui/go.sum b/tui/go.sum deleted file mode 100644 index b40f65d..0000000 --- a/tui/go.sum +++ /dev/null @@ -1,41 +0,0 @@ -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-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5WdeuYSY= -github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc= -github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06RaW2cx/SY= -github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg= -github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E= -github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c= -github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= -github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= -github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= -github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= -github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= -github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/termenv v0.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs= -github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ= -github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= -golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= -golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/tui/list.go b/tui/list.go deleted file mode 100644 index 3db3529..0000000 --- a/tui/list.go +++ /dev/null @@ -1,143 +0,0 @@ -package main - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" -) - -type listMode int - -const ( - listBrowse listMode = iota - listAdding - listEditing -) - -type EditableList struct { - title string - items []string - cursor int - mode listMode - input textinput.Model -} - -func NewEditableList(title string, items []string) EditableList { - ti := textinput.New() - ti.Placeholder = "enter value..." - ti.CharLimit = 200 - return EditableList{ - title: title, - items: items, - mode: listBrowse, - input: ti, - } -} - -func (l EditableList) Items() []string { - return l.items -} - -func (l EditableList) IsEditing() bool { - return l.mode != listBrowse -} - -func (l EditableList) Update(msg tea.Msg) (EditableList, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch l.mode { - case listBrowse: - switch msg.String() { - case "up", "k": - if l.cursor > 0 { - l.cursor-- - } - case "down", "j": - if l.cursor < len(l.items)-1 { - l.cursor++ - } - case "a": - l.mode = listAdding - l.input.SetValue("") - l.input.Focus() - return l, textinput.Blink - case "x", "delete": - if len(l.items) > 0 && l.cursor < len(l.items) { - l.items = append(l.items[:l.cursor], l.items[l.cursor+1:]...) - if l.cursor > len(l.items)-1 { - l.cursor = len(l.items) - 1 - } - if l.cursor < 0 { - l.cursor = 0 - } - } - case "enter": - if len(l.items) > 0 { - l.mode = listEditing - l.input.SetValue(l.items[l.cursor]) - l.input.Focus() - return l, textinput.Blink - } - } - case listAdding, listEditing: - switch msg.String() { - case "enter": - val := strings.TrimSpace(l.input.Value()) - if val != "" { - if l.mode == listAdding { - l.items = append(l.items, val) - l.cursor = len(l.items) - 1 - } else { - l.items[l.cursor] = val - } - } - l.mode = listBrowse - l.input.Blur() - case "esc": - l.mode = listBrowse - l.input.Blur() - default: - var cmd tea.Cmd - l.input, cmd = l.input.Update(msg) - return l, cmd - } - } - } - return l, nil -} - -func (l EditableList) View() string { - var b strings.Builder - - switch l.mode { - case listAdding: - b.WriteString(fmt.Sprintf("%s (adding)\n", l.title)) - b.WriteString(fmt.Sprintf("> %s\n", l.input.View())) - b.WriteString(helpStyle.Render(" enter to confirm · esc to cancel")) - case listEditing: - b.WriteString(fmt.Sprintf("%s (editing)\n", l.title)) - b.WriteString(fmt.Sprintf("> %s\n", l.input.View())) - b.WriteString(helpStyle.Render(" enter to confirm · esc to cancel")) - default: - b.WriteString(l.title + "\n") - if len(l.items) == 0 { - b.WriteString(helpStyle.Render(" (empty)")) - } - for i, item := range l.items { - cursor := " " - if i == l.cursor { - cursor = cursorStyle.Render("> ") - } - b.WriteString(fmt.Sprintf("%s%s\n", cursor, item)) - } - } - - content := b.String() - style := boxStyle - if l.mode != listBrowse { - style = activeBoxStyle - } - return style.Render(strings.TrimRight(content, "\n")) -} diff --git a/tui/main.go b/tui/main.go deleted file mode 100644 index cd6f269..0000000 --- a/tui/main.go +++ /dev/null @@ -1,32 +0,0 @@ -package main - -import ( - "fmt" - "os/exec" - "strings" - - tea "github.com/charmbracelet/bubbletea" -) - -func saveAndValidateCmd(path string, p *Profile) tea.Cmd { - return func() tea.Msg { - if err := SaveProfile(path, p); err != nil { - return saveResultMsg{err: err} - } - cmd := exec.Command("nono", "profile", "validate", "opencode-tinfoil") - out, err := cmd.CombinedOutput() - output := strings.TrimSpace(string(out)) - if err != nil { - return saveResultMsg{output: output, err: fmt.Errorf("validation failed")} - } - return saveResultMsg{output: output} - } -} - -func main() { - m, _ := NewModel() - p := tea.NewProgram(m, tea.WithAltScreen()) - if _, err := p.Run(); err != nil { - fmt.Printf("Error: %v\n", err) - } -} diff --git a/tui/model.go b/tui/model.go deleted file mode 100644 index d339053..0000000 --- a/tui/model.go +++ /dev/null @@ -1,172 +0,0 @@ -package main - -import ( - "fmt" - "strings" - - tea "github.com/charmbracelet/bubbletea" -) - -type mainTab int - -const ( - tabDashboard mainTab = iota - tabEditor -) - -var mainTabNames = []string{"Dashboard", "Editor"} - -type saveResultMsg struct { - output string - err error -} - -type Model struct { - tab mainTab - dashboard DashboardModel - editor EditorModel - profile *Profile - profilePath string - dirty bool - statusMsg string - errMsg string - width int - height int - quitting bool -} - -func NewModel() (Model, tea.Cmd) { - profilePath, _ := DefaultProfilePath() - p, err := LoadProfile(profilePath) - if err != nil { - p = &Profile{} - } - - checks := RunChecks() - - m := Model{ - tab: tabDashboard, - dashboard: NewDashboardModel(checks), - editor: NewEditorModel(p), - profile: p, - profilePath: profilePath, - } - - return m, nil -} - -func (m Model) Init() tea.Cmd { - return nil -} - -func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - m.dashboard.width = msg.Width - m.editor.width = msg.Width - return m, nil - - case saveResultMsg: - if msg.err != nil { - m.errMsg = fmt.Sprintf("Save failed: %v", msg.err) - } else { - m.dirty = false - m.statusMsg = "Saved & validated: " + msg.output - m.errMsg = "" - } - return m, nil - - case tea.KeyMsg: - switch msg.String() { - case "ctrl+c", "q": - if m.dirty { - m.statusMsg = "Unsaved changes! Press Q to force quit, or s to save." - return m, nil - } - m.quitting = true - return m, tea.Quit - case "Q": - m.quitting = true - return m, tea.Quit - case "tab": - if m.tab == tabDashboard { - m.tab = tabEditor - } else { - m.tab = tabDashboard - } - m.statusMsg = "" - m.errMsg = "" - return m, nil - case "s": - if m.tab == tabEditor { - m.editor.SyncToProfile(m.profile) - cmd := saveAndValidateCmd(m.profilePath, m.profile) - m.statusMsg = "Saving..." - return m, cmd - } - } - } - - switch m.tab { - case tabDashboard: - var cmd tea.Cmd - m.dashboard, cmd = m.dashboard.Update(msg) - return m, cmd - case tabEditor: - var cmd tea.Cmd - m.editor, cmd = m.editor.Update(msg) - if !m.dirty { - m.dirty = true - } - return m, cmd - } - - return m, nil -} - -func (m Model) View() string { - if m.quitting { - return "" - } - - var b strings.Builder - - title := "nono-tui" - if m.dirty { - title += " *" - } - b.WriteString(titleStyle.Render(" " + title + " ")) - - tabs := []string{} - for i, name := range mainTabNames { - if mainTab(i) == m.tab { - tabs = append(tabs, activeTabStyle.Render(name)) - } else { - tabs = append(tabs, tabStyle.Render(name)) - } - } - b.WriteString(" " + strings.Join(tabs, " ")) - - b.WriteString("\n\n") - - switch m.tab { - case tabDashboard: - b.WriteString(m.dashboard.View()) - case tabEditor: - b.WriteString(m.editor.View()) - } - - b.WriteString("\n\n") - - if m.errMsg != "" { - b.WriteString(errorStyle.Render(" " + m.errMsg)) - } else if m.statusMsg != "" { - b.WriteString(successStyle.Render(" " + m.statusMsg)) - } else { - b.WriteString(helpStyle.Render(" q:quit Tab:switch tabs")) - } - - return b.String() -} diff --git a/tui/profile.go b/tui/profile.go deleted file mode 100644 index cd76648..0000000 --- a/tui/profile.go +++ /dev/null @@ -1,128 +0,0 @@ -package main - -import ( - "encoding/json" - "os" - "path/filepath" -) - -type Profile struct { - Meta ProfileMeta `json:"meta"` - Groups ProfileGroups `json:"groups"` - Commands ProfileCommands `json:"commands"` - Workdir ProfileWorkdir `json:"workdir"` - FS ProfileFS `json:"filesystem"` - Network ProfileNetwork `json:"network"` - Env ProfileEnv `json:"environment"` -} - -type ProfileMeta struct { - Name string `json:"name"` - Description string `json:"description"` -} - -type ProfileGroups struct { - Include []string `json:"include"` -} - -type ProfileCommands struct { - Allow []string `json:"allow"` - Deny []string `json:"deny"` -} - -type ProfileWorkdir struct { - Access string `json:"access"` -} - -type ProfileFS struct { - Allow []string `json:"allow"` - Read []string `json:"read"` - Write []string `json:"write"` - Deny []string `json:"deny"` -} - -type ProfileNetwork struct { - Block bool `json:"block"` - AllowDomain []string `json:"allow_domain"` -} - -type ProfileEnv struct { - SetVars map[string]string `json:"set_vars"` - DenyVars []string `json:"deny_vars"` -} - -func DefaultProfilePath() (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - return filepath.Join(home, ".config", "nono", "profiles", "opencode-tinfoil.json"), nil -} - -func LoadProfile(path string) (*Profile, error) { - raw, err := os.ReadFile(path) - if err != nil { - return nil, err - } - clean := stripJSONCComments(raw) - var p Profile - if err := json.Unmarshal(clean, &p); err != nil { - return nil, err - } - return &p, nil -} - -func SaveProfile(path string, p *Profile) error { - data, err := json.MarshalIndent(p, "", " ") - if err != nil { - return err - } - return os.WriteFile(path, data, 0644) -} - -func stripJSONCComments(data []byte) []byte { - var result []byte - inString := false - escaped := false - i := 0 - for i < len(data) { - c := data[i] - if inString { - result = append(result, c) - if escaped { - escaped = false - } else if c == '\\' { - escaped = true - } else if c == '"' { - inString = false - } - i++ - continue - } - if c == '"' { - inString = true - result = append(result, c) - i++ - continue - } - if c == '/' && i+1 < len(data) { - if data[i+1] == '/' { - for i < len(data) && data[i] != '\n' { - i++ - } - continue - } - if data[i+1] == '*' { - i += 2 - for i+1 < len(data) && !(data[i] == '*' && data[i+1] == '/') { - i++ - } - i += 2 - continue - } - } - result = append(result, c) - i++ - } - return result -} diff --git a/tui/styles.go b/tui/styles.go deleted file mode 100644 index 27dae51..0000000 --- a/tui/styles.go +++ /dev/null @@ -1,77 +0,0 @@ -package main - -import "github.com/charmbracelet/lipgloss" - -var ( - green = lipgloss.Color("#04B575") - red = lipgloss.Color("#FF4646") - yellow = lipgloss.Color("#FFA500") - blue = lipgloss.Color("#7D56F4") - gray = lipgloss.Color("#666666") - - titleStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#FAFAFA")). - Background(blue). - Padding(0, 2) - - tabStyle = lipgloss.NewStyle(). - Foreground(gray). - Padding(0, 2) - - activeTabStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#FAFAFA")). - Background(blue). - Padding(0, 2) - - checkOKStyle = lipgloss.NewStyle(). - Foreground(green). - Bold(true) - - checkFailStyle = lipgloss.NewStyle(). - Foreground(red). - Bold(true) - - labelStyle = lipgloss.NewStyle(). - Width(26). - Foreground(lipgloss.Color("#AAAAAA")) - - detailStyle = lipgloss.NewStyle(). - Foreground(gray) - - boxStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(gray). - Padding(0, 1) - - activeBoxStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(blue). - Padding(0, 1) - - cursorStyle = lipgloss.NewStyle(). - Foreground(yellow). - Bold(true) - - dirtyStyle = lipgloss.NewStyle(). - Foreground(yellow). - Bold(true) - - helpStyle = lipgloss.NewStyle(). - Foreground(gray). - Italic(true) - - statusBarStyle = lipgloss.NewStyle(). - Background(lipgloss.Color("#333333")). - Foreground(lipgloss.Color("#FFFFFF")). - Padding(0, 1) - - errorStyle = lipgloss.NewStyle(). - Foreground(red). - Bold(true) - - successStyle = lipgloss.NewStyle(). - Foreground(green). - Bold(true) -) diff --git a/tui/tui b/tui/tui deleted file mode 100755 index bf7b44b..0000000 Binary files a/tui/tui and /dev/null differ