diff --git a/README.md b/README.md
index f8e68e4..0701705 100644
--- a/README.md
+++ b/README.md
@@ -2,126 +2,185 @@
⚙️
- proCLI
+ proCLEE
+---
+
+**clee** or **proCLEE** is a terminal‑first **software project assistant** built in Go using **Bubble Tea**.
+
+It is designed to grow into a **modular, extensible TUI toolbox** for software teams: Scrum facilitation, project health checks, diagnostics ("project doctors"), and custom workflows — all from the terminal.
-**ProCLI** is a command-line tool designed to help developers manage and validate project prerequisites. It simplifies the setup and ensures consistency by checking for required tools, environment variables, tokens, and version control systems.
+The current codebase contains only the **first foundational brick**: a local, deterministic random chooser. Everything else will be layered on **only after full understanding and ownership** of each step.
---
-## Features
+## Vision
+
+`clee` aims to become:
+
+* A **CLI/TUI assistant** for running software projects
+* Extensible via **modules / plugins** (conceptually, not dynamically loaded yet)
+* Opinionated where it helps, customizable where it matters
+
+Think:
-- **Initialize Project Configurations**:
- - Use the `init` command to interactively create a project configuration file.
- - Supports specifying:
- - Required tools
- - Environment variables
- - Tokens
- - Version control systems
+* **Scrum poker, standups, retros** (later)
+* **Project diagnostics** similar to `flutter doctor`, but:
-- **Validate Project Setup**:
- - Use the `check` command to validate if the system meets the project prerequisites.
- - Provides a clear, actionable output with success and failure indicators.
+ * configurable
+ * project‑specific
+ * language / stack agnostic
-- **Configuration Management**:
- - Configuration files are stored locally in `~/.config/procli/config.yaml`.
- - Supports multiple projects and a default project.
+The terminal is the primary UI.
---
-## Installation
+## Current MVP (first brick)
-### Prerequisites
-- [Go](https://golang.org/dl/) 1.20 or later installed.
+The current implementation is intentionally small. It exists to:
-### Clone and Build
-1. Clone the repository:
- ```bash
- git clone
- cd procli
- ```
-2. Build the binary:
- ```bash
- go build -o procli
- ```
+* Establish the TUI foundation (Bubble Tea)
+* Define UX patterns we will reuse later
+* Ensure correctness, determinism, and understanding
-3. Install `procli`
- ```bash
- go install
- ```
+### What the MVP does
+
+* Manage a list of participants stored in a **simple text file**
+* Enable / disable participants via **checkboxes**
+* Add, edit, and delete names
+* Start a **random selection** with a visual animation
+* Pick a **truly random winner** (winner chosen first, animation is cosmetic)
+* Display a dedicated **Winner 🎉 screen**
+
+Everything runs **locally** in the terminal.
---
-## Usage
+## Screens
+
+1. **Participant list**
+
+ * Arrow keys to navigate
+ * Checkbox per participant (included / excluded)
+
+2. **Edit screen**
+
+ * Add a new name
+ * Rename an existing one
+
+3. **Selection animation**
+
+ * Cursor cycles through enabled participants
+ * Slows down and lands on the pre‑selected winner
+
+4. **Winner screen**
+
+ * Displays: `winner 🎉 {name}`
+ * Return to list to run again
+
+---
+
+## Key bindings
+
+| Key | Action |
+| -------------- | ------------------------- |
+| ↑ / ↓ or k / j | Move cursor |
+| Space | Toggle participant on/off |
+| a | Add participant |
+| e | Edit participant |
+| d / Backspace | Delete participant |
+| Enter | Start random selection |
+| q / Ctrl+C | Quit |
+
+On the **Winner screen**:
+
+* `Enter` or `Esc` returns to the list
+
+---
+
+## Data storage
+
+Participants are stored in a plain text file:
-### Initialize a Project
-Run the `init` command to create a new project configuration:
-```bash
-procli init
```
-Example interaction:
-```plaintext
-Enter project name: tensorflow
-Enter required tools (comma-separated): clang-tidy, pylint, docker, bazel, python
-Enter environment variables (comma-separated): INDIVIDUAL_CLA, CORPORATE_CLA
-Enter required tokens (comma-separated):
-Enter version control system (e.g., git): git
-Project configuration saved!
+~/.config/clee/participants.tsv
```
-### Validate a Project
-Run the `check` command to validate project prerequisites:
-```bash
-procli check
+Format:
+
```
-If a default project is configured, the project name can be omitted:
-```bash
-procli check
+\t
```
-Example output:
+Example:
-
+```
+1 Alice
+0 Bob
+1 Charlie
+```
+
+* `1` = enabled
+* `0` = disabled
+
+This format is intentionally simple and editable by hand.
---
-## Configuration File Structure
-
-Configurations are stored as YAML in `~/.config/procli/config.yaml`. Example structure:
-```yaml
-default: tensorflow
-projects:
- tensorflow:
- required_tools:
- - docker
- - python
- environment_vars:
- - INDIVIDUAL_CLA
- - CORPORATE_CLA
- required_tokens: []
- version_control: git
+## Build & run
+
+Requirements:
+
+* Go 1.20+
+
+```bash
+go mod tidy
+go build -o clee .
+./clee
+```
+
+Or install into your Go bin directory:
+
+```bash
+go install .
```
---
-## Contributing
+## Design principles
-Contributions are welcome! Please follow these steps:
-1. Fork the repository.
-2. Create a feature branch (`git checkout -b feature-name`).
-3. Commit your changes (`git commit -m "Add feature"`).
-4. Push to the branch (`git push origin feature-name`).
-5. Open a Pull Request.
+These principles apply to the entire project:
+
+* **Foundation first** — no features without understanding
+* **Winner / result first, animation second** — logic must be correct
+* **Small composable bricks** — each feature stands alone
+* **Terminal as a first‑class UI**
+* **No premature complexity**
+
+Feature growth is gated intentionally.
---
-## License
+## Roadmap (pinned, not implemented yet)
+
+The following are **explicitly out of scope for the current MVP**:
-This project is licensed under the MIT License. See the [LICENSE](./LICENSE) file for details.
+* Scrum poker (multiple voting schemes)
+* TCP / socket networking
+* Session discovery & codes
+* Multi‑client coordination
+* Project diagnostics ("project doctors")
+* Plugin / module registry
+* WebAssembly / browser spectator UI
+* Reports / exports
+
+They will only be implemented after explicit confirmation.
---
-## Roadmap
+## Status
+
+**Status:** foundational TUI brick complete and understood.
-- Integrate a TUI (using Bubble Tea) for project initialization and editing.
+This repository will evolve into a broader **software project assistant**, step by step, without skipping understanding.
diff --git a/cmd/check.go b/cmd/check.go
deleted file mode 100644
index 833c4c9..0000000
--- a/cmd/check.go
+++ /dev/null
@@ -1,97 +0,0 @@
-package cmd
-
-import (
- "fmt"
- "os"
- "os/exec"
-
- "github.com/fatih/color"
- "github.com/realvorl/procli/pkg"
- "github.com/spf13/cobra"
-)
-
-var checkCmd = &cobra.Command{
- Use: "check [project]",
- Short: "Check project prerequisites",
- Run: func(cmd *cobra.Command, args []string) {
- config := pkg.LoadConfig()
- projectName := config.DefaultProject
- if len(args) < 1 {
- if config.DefaultProject == "" {
- fmt.Println(color.RedString("No project specified and no default project set."))
- return
- }
- } else {
- projectName = args[0]
- }
- // Load configuration
- project, exists := config.Projects[projectName]
- if !exists {
- fmt.Printf(color.RedString("Project '%s' not found in the configuration.\n"), projectName)
- return
- }
-
- // Perform checks
- fmt.Printf(color.CyanString("Checking prerequisites for project: %s\n"), projectName)
-
- checkTools(project.RequiredTools)
- checkEnvVars(project.EnvironmentVars)
- checkTokens(project.RequiredTokens)
- checkVersionControl(project.VersionControl)
-
- fmt.Println(color.GreenString("\nCheck complete!"))
- },
-}
-
-// Check tools
-func checkTools(tools []string) {
- if len(tools) == 0 {
- return
- }
- fmt.Println(color.YellowString("\nChecking required tools:"))
- for _, tool := range tools {
- status := true
- if _, err := exec.LookPath(tool); err != nil {
- status = false
- }
- pkg.PrintCheckResult(tool, status)
- }
-}
-
-// Check environment variables
-func checkEnvVars(vars []string) {
- if len(vars) == 0 {
- return
- }
- fmt.Println(color.YellowString("\nChecking environment variables:"))
- for _, envVar := range vars {
- status := os.Getenv(envVar) != ""
- pkg.PrintCheckResult(envVar, status)
- }
-}
-
-// Check tokens
-func checkTokens(tokens []string) {
- if len(tokens) == 0 {
- return
- }
- fmt.Println(color.YellowString("\nChecking required tokens(%s):", len(tokens)))
- for _, token := range tokens {
- status := os.Getenv(token) != ""
- pkg.PrintCheckResult(token, status)
- }
-}
-
-// Check version control
-func checkVersionControl(vcs string) {
- fmt.Println(color.YellowString("\nChecking version control system:"))
- status := true
- if _, err := exec.LookPath(vcs); err != nil {
- status = false
- }
- pkg.PrintCheckResult(vcs, status)
-}
-
-func init() {
- rootCmd.AddCommand(checkCmd)
-}
diff --git a/cmd/init.go b/cmd/init.go
deleted file mode 100644
index 9eaeda4..0000000
--- a/cmd/init.go
+++ /dev/null
@@ -1,95 +0,0 @@
-package cmd
-
-import (
- "bufio"
- "fmt"
- "os"
- "strings"
-
- "github.com/realvorl/procli/pkg"
- "github.com/spf13/cobra"
-)
-
-var initCmd = &cobra.Command{
- Use: "init",
- Short: "Add a new project to the configuration",
- Run: func(cmd *cobra.Command, args []string) {
- reader := bufio.NewReader(os.Stdin)
-
- // Load existing configuration
- config := pkg.LoadConfig()
-
- // Prompt for project name
- fmt.Print("Enter project name: ")
- projectName, _ := reader.ReadString('\n')
- projectName = strings.TrimSpace(projectName)
-
- // Check if the project already exists
- if _, exists := config.Projects[projectName]; exists {
- fmt.Println("Project already exists in the configuration.")
- return
- }
-
- fmt.Print("Do you want to set this as the default project? (y/n): ")
- setDefault, _ := reader.ReadString('\n')
- if strings.ToLower(strings.TrimSpace(setDefault)) == "y" {
- config.DefaultProject = projectName
- fmt.Printf("Default project set to '%s'.\n", projectName)
- }
-
- // Create new project configuration
- projectConfig := pkg.ProjectConfig{}
-
- // Ask if tools are needed
- fmt.Print("Does your project use tools? (y/n): ")
- usesTools, _ := reader.ReadString('\n')
- if strings.ToLower(strings.TrimSpace(usesTools)) == "y" {
- fmt.Print("Enter required tools (comma-separated): ")
- tools, _ := reader.ReadString('\n')
- projectConfig.RequiredTools = pkg.ParseCommaSeparated(tools)
- }
-
- // Ask if environment variables are needed
- fmt.Print("Does your project use environment variables? (y/n): ")
- usesEnvVars, _ := reader.ReadString('\n')
- if strings.ToLower(strings.TrimSpace(usesEnvVars)) == "y" {
- fmt.Print("Enter environment variables (comma-separated): ")
- envVars, _ := reader.ReadString('\n')
- projectConfig.EnvironmentVars = pkg.ParseCommaSeparated(envVars)
- }
-
- // Ask if tokens are needed
- fmt.Print("Does your project use tokens? (y/n): ")
- usesTokens, _ := reader.ReadString('\n')
- if strings.ToLower(strings.TrimSpace(usesTokens)) == "y" {
- fmt.Print("Enter required tokens (comma-separated): ")
- tokens, _ := reader.ReadString('\n')
- projectConfig.RequiredTokens = pkg.ParseCommaSeparated(tokens)
- }
-
- // Ask if version control is needed
- fmt.Print("Does your project use version control? (y/n): ")
- usesVCS, _ := reader.ReadString('\n')
- if strings.ToLower(strings.TrimSpace(usesVCS)) == "y" {
- fmt.Print("Enter version control system (e.g., git): ")
- vcs, _ := reader.ReadString('\n')
- projectConfig.VersionControl = strings.TrimSpace(vcs)
- }
-
- // Add to projects
- config.Projects[projectName] = projectConfig
-
- // Save configuration
- err := pkg.SaveConfig(config)
- if err != nil {
- fmt.Println("Error saving configuration:", err)
- return
- }
-
- fmt.Println("Project configuration saved!")
- },
-}
-
-func init() {
- rootCmd.AddCommand(initCmd)
-}
diff --git a/cmd/root.go b/cmd/root.go
deleted file mode 100644
index 59e3fe6..0000000
--- a/cmd/root.go
+++ /dev/null
@@ -1,51 +0,0 @@
-/*
-Copyright © 2024 Viorel PETCU
-
-*/
-package cmd
-
-import (
- "os"
-
- "github.com/spf13/cobra"
-)
-
-
-
-// rootCmd represents the base command when called without any subcommands
-var rootCmd = &cobra.Command{
- Use: "procli",
- Short: "A brief description of your application",
- Long: `A longer description that spans multiple lines and likely contains
-examples and usage of using your application. For example:
-
-Cobra is a CLI library for Go that empowers applications.
-This application is a tool to generate the needed files
-to quickly create a Cobra application.`,
- // Uncomment the following line if your bare application
- // has an action associated with it:
- // Run: func(cmd *cobra.Command, args []string) { },
-}
-
-// Execute adds all child commands to the root command and sets flags appropriately.
-// This is called by main.main(). It only needs to happen once to the rootCmd.
-func Execute() {
- err := rootCmd.Execute()
- if err != nil {
- os.Exit(1)
- }
-}
-
-func init() {
- // Here you will define your flags and configuration settings.
- // Cobra supports persistent flags, which, if defined here,
- // will be global for your application.
-
- // rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.procli.yaml)")
-
- // Cobra also supports local flags, which will only run
- // when this action is called directly.
- rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
-}
-
-
diff --git a/go.mod b/go.mod
index 8801ed0..e803d46 100644
--- a/go.mod
+++ b/go.mod
@@ -1,17 +1,27 @@
-module github.com/realvorl/procli
+module github.com/realvorl/clee
-go 1.22.2
+go 1.24.5
require (
- github.com/fatih/color v1.18.0
- github.com/spf13/cobra v1.8.1
- gopkg.in/yaml.v3 v3.0.1
-)
-
-require (
- github.com/inconshreveable/mousetrap v1.1.0 // indirect
- github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/atotto/clipboard v0.1.4 // indirect
+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+ github.com/charmbracelet/bubbles v0.21.0 // indirect
+ github.com/charmbracelet/bubbletea v1.3.10 // indirect
+ github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
+ github.com/charmbracelet/lipgloss v1.1.0 // indirect
+ github.com/charmbracelet/x/ansi v0.10.1 // indirect
+ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
+ github.com/charmbracelet/x/term v0.2.1 // indirect
+ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
+ github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
- github.com/spf13/pflag v1.0.5 // indirect
- golang.org/x/sys v0.27.0 // indirect
+ github.com/mattn/go-localereader v0.0.1 // indirect
+ github.com/mattn/go-runewidth v0.0.16 // indirect
+ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
+ github.com/muesli/cancelreader v0.2.2 // indirect
+ github.com/muesli/termenv v0.16.0 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+ golang.org/x/sys v0.36.0 // indirect
+ golang.org/x/text v0.3.8 // indirect
)
diff --git a/go.sum b/go.sum
index 82167d1..180680e 100644
--- a/go.sum
+++ b/go.sum
@@ -1,23 +1,45 @@
-github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
-github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
-github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
-github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
-github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
-github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
-github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
-github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+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.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
+github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
+github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
+github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
+github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
+github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
+github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
+github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
+github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
+github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
+github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
+github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
+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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
-github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
-github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
-github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+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.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
+github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
+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/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
+github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+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 v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
-golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
-gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
+golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+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/main.go b/main.go
index 3934e16..5d258c5 100644
--- a/main.go
+++ b/main.go
@@ -1,11 +1,542 @@
-/*
-Copyright © 2024 NAME HERE
-
-*/
package main
-import "github.com/realvorl/procli/cmd"
+import (
+ "bufio"
+ "errors"
+ "fmt"
+ "math/rand"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/charmbracelet/bubbles/textinput"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+type participant struct {
+ Name string
+ Enabled bool
+}
+
+type screen int
+
+const (
+ screenList screen = iota
+ screenEdit
+ screenWinner
+)
+
+type editMode int
+
+const (
+ editAdd editMode = iota
+ editRename
+)
+
+type model struct {
+ // data
+ filePath string
+ participants []participant
+ cursor int
+
+ // ui state
+ screen screen
+ editMode editMode
+ input textinput.Model
+ err error
+
+ // draw state
+ drawing bool
+ drawIndex int
+ drawSteps int
+ drawStepsTo int
+ winnerName string
+ targetIndex int // the chosen winner index (in participants slice)
+ cyclesLeft int // how many full wrap-arounds to do before we’re allowed to stop
+}
+
+type tickMsg struct{}
+
+var (
+ titleStyle = lipgloss.NewStyle().Bold(true)
+ helpStyle = lipgloss.NewStyle().Faint(true)
+ errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true)
+ winnerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("10"))
+)
func main() {
- cmd.Execute()
+ fp := defaultParticipantsPath()
+
+ m := newModel(fp)
+ p := tea.NewProgram(m, tea.WithAltScreen())
+ if _, err := p.Run(); err != nil {
+ fmt.Println("fatal:", err)
+ os.Exit(1)
+ }
+}
+
+func newModel(filePath string) model {
+ items, err := loadParticipants(filePath)
+ if err != nil {
+ // start empty; show error in UI
+ items = []participant{}
+ }
+
+ ti := textinput.New()
+ ti.Placeholder = "Name"
+ ti.CharLimit = 60
+ ti.Width = 30
+
+ return model{
+ filePath: filePath,
+ participants: items,
+ cursor: 0,
+ screen: screenList,
+ input: ti,
+ err: err,
+ }
+}
+
+func (m model) Init() tea.Cmd { return nil }
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch m.screen {
+ case screenList:
+ return m.updateList(msg)
+ case screenEdit:
+ return m.updateEdit(msg)
+ case screenWinner:
+ return m.updateWinner(msg)
+ default:
+ return m, nil
+ }
+}
+
+func (m model) View() string {
+ switch m.screen {
+ case screenList:
+ return m.viewList()
+ case screenEdit:
+ return m.viewEdit()
+ case screenWinner:
+ return m.viewWinner()
+ default:
+ return "unknown screen"
+ }
+}
+
+/* -------------------- LIST SCREEN -------------------- */
+
+func (m model) updateList(msg tea.Msg) (tea.Model, tea.Cmd) {
+ // drawing tick
+ if m.drawing {
+ switch msg.(type) {
+ case tickMsg:
+ return m.stepDraw()
+ }
+ }
+
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ switch msg.String() {
+ case "ctrl+c", "q":
+ return m, tea.Quit
+
+ case "up", "k":
+ if len(m.participants) > 0 {
+ if m.cursor > 0 {
+ m.cursor--
+ } else {
+ m.cursor = len(m.participants) - 1
+ }
+ }
+ case "down", "j":
+ if len(m.participants) > 0 {
+ if m.cursor < len(m.participants)-1 {
+ m.cursor++
+ } else {
+ m.cursor = 0
+ }
+ }
+
+ case " ":
+ // toggle enabled
+ if len(m.participants) > 0 && m.cursor >= 0 && m.cursor < len(m.participants) {
+ m.participants[m.cursor].Enabled = !m.participants[m.cursor].Enabled
+ m.err = saveParticipants(m.filePath, m.participants)
+ }
+
+ case "a":
+ // add
+ m.screen = screenEdit
+ m.editMode = editAdd
+ m.input.SetValue("")
+ m.input.Focus()
+ return m, nil
+
+ case "e":
+ // edit/rename
+ if len(m.participants) == 0 {
+ break
+ }
+ m.screen = screenEdit
+ m.editMode = editRename
+ m.input.SetValue(m.participants[m.cursor].Name)
+ m.input.CursorEnd()
+ m.input.Focus()
+ return m, nil
+
+ case "d", "backspace":
+ // delete
+ if len(m.participants) == 0 {
+ break
+ }
+ m.participants = append(m.participants[:m.cursor], m.participants[m.cursor+1:]...)
+ if m.cursor >= len(m.participants) && m.cursor > 0 {
+ m.cursor--
+ }
+ m.err = saveParticipants(m.filePath, m.participants)
+
+ case "enter":
+
+ // start draw (winner picked first, animation is just theater)
+ enabledIdx := enabledIndices(m.participants)
+ if len(enabledIdx) < 1 {
+ m.err = errors.New("no enabled participants to draw from (toggle with Space)")
+ break
+ }
+
+ rand.Seed(time.Now().UnixNano())
+
+ // pick winner upfront from enabled participants
+ m.targetIndex = enabledIdx[rand.Intn(len(enabledIdx))]
+
+ // animation setup
+ m.drawing = true
+ m.drawSteps = 0
+ m.drawStepsTo = 0 // no longer used for stopping (can remove later)
+ m.cyclesLeft = 2 + rand.Intn(3) // 2..4 full loops for suspense
+
+ // start from current cursor (or 0)
+ m.drawIndex = clamp(m.cursor, 0, max(0, len(m.participants)-1))
+
+ // clear previous winner
+ m.winnerName = ""
+
+ return m, nextTick()
+ }
+ }
+ return m, nil
+}
+
+func (m model) stepDraw() (tea.Model, tea.Cmd) {
+ if len(m.participants) == 0 {
+ m.drawing = false
+ return m, nil
+ }
+
+ enabledIdx := enabledIndices(m.participants)
+ if len(enabledIdx) == 0 {
+ m.drawing = false
+ return m, nil
+ }
+
+ // advance to next enabled participant
+ prev := m.drawIndex
+ m.drawIndex = nextEnabledIndex(m.participants, m.drawIndex)
+
+ // if we wrapped around, count one completed cycle
+ if m.drawIndex < prev {
+ if m.cyclesLeft > 0 {
+ m.cyclesLeft--
+ }
+ }
+
+ // show animation by moving cursor highlight
+ m.cursor = m.drawIndex
+
+ // stop condition: only after cycles are done AND we landed on target
+ if m.cyclesLeft == 0 && m.drawIndex == m.targetIndex {
+ m.drawing = false
+ m.winnerName = m.participants[m.drawIndex].Name
+ m.screen = screenWinner
+ return m, nil
+ }
+
+ // slow down near the end (once we're allowed to stop)
+ delay := 25 * time.Millisecond
+ if m.cyclesLeft == 0 {
+ // as we get close to target, slow down progressively
+ // simple heuristic: if within ~5 hops, slow down more
+ // (we don't compute exact distance; this is “good enough” theater)
+ delay = 80 * time.Millisecond
+ if m.drawIndex == m.targetIndex {
+ delay = 160 * time.Millisecond
+ }
+ }
+
+ return m, tea.Tick(delay, func(time.Time) tea.Msg { return tickMsg{} })
+}
+
+func (m model) viewList() string {
+ var b strings.Builder
+
+ b.WriteString(titleStyle.Render("clee — random name chooser"))
+ b.WriteString("\n")
+ b.WriteString(helpStyle.Render(fmt.Sprintf("file: %s", m.filePath)))
+ b.WriteString("\n\n")
+
+ if m.err != nil {
+ b.WriteString(errorStyle.Render("⚠ " + m.err.Error()))
+ b.WriteString("\n\n")
+ }
+
+ if len(m.participants) == 0 {
+ b.WriteString("No participants yet.\n")
+ b.WriteString("Press 'a' to add.\n\n")
+ } else {
+ for i, p := range m.participants {
+ cursor := " "
+ if i == m.cursor {
+ cursor = "➤"
+ }
+ box := "[ ]"
+ if p.Enabled {
+ box = "[x]"
+ }
+ line := fmt.Sprintf("%s %s %s", cursor, box, p.Name)
+ b.WriteString(line)
+ b.WriteString("\n")
+ }
+ b.WriteString("\n")
+ }
+
+ if m.drawing {
+ b.WriteString(helpStyle.Render("Selecting…"))
+ b.WriteString("\n")
+ }
+
+ b.WriteString(helpStyle.Render("Keys: ↑/↓ move | Space toggle | a add | e edit | d delete | Enter start | q quit"))
+ b.WriteString("\n")
+
+ return b.String()
+}
+
+/* -------------------- EDIT SCREEN -------------------- */
+
+func (m model) updateEdit(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmd tea.Cmd
+ m.input, cmd = m.input.Update(msg)
+
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ switch msg.String() {
+ case "esc":
+ m.screen = screenList
+ m.input.Blur()
+ return m, nil
+
+ case "enter":
+ name := strings.TrimSpace(m.input.Value())
+ if name == "" {
+ m.err = errors.New("name cannot be empty")
+ return m, nil
+ }
+
+ switch m.editMode {
+ case editAdd:
+ m.participants = append(m.participants, participant{Name: name, Enabled: true})
+ m.cursor = len(m.participants) - 1
+ case editRename:
+ if len(m.participants) > 0 && m.cursor >= 0 && m.cursor < len(m.participants) {
+ m.participants[m.cursor].Name = name
+ }
+ }
+
+ m.err = saveParticipants(m.filePath, m.participants)
+ m.screen = screenList
+ m.input.Blur()
+ return m, nil
+ }
+ }
+
+ return m, cmd
+}
+
+func (m model) viewEdit() string {
+ var b strings.Builder
+ title := "Add participant"
+ if m.editMode == editRename {
+ title = "Edit participant"
+ }
+ b.WriteString(titleStyle.Render(title))
+ b.WriteString("\n\n")
+ b.WriteString("Name:\n")
+ b.WriteString(m.input.View())
+ b.WriteString("\n\n")
+ b.WriteString(helpStyle.Render("Enter save | Esc cancel"))
+ b.WriteString("\n")
+ return b.String()
+}
+
+/* -------------------- WINNER SCREEN -------------------- */
+
+func (m model) updateWinner(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ switch msg.String() {
+ case "enter", "esc", "q":
+ // back to list; keep cursor on winner
+ m.screen = screenList
+ m.err = nil
+ return m, nil
+ case "ctrl+c":
+ return m, tea.Quit
+ }
+ }
+ return m, nil
+}
+
+func (m model) viewWinner() string {
+ var b strings.Builder
+ b.WriteString("\n")
+ b.WriteString(winnerStyle.Render("winner 🎉 " + m.winnerName))
+ b.WriteString("\n\n")
+ b.WriteString(helpStyle.Render("Press Enter to go back"))
+ b.WriteString("\n")
+ return b.String()
+}
+
+/* -------------------- STORAGE -------------------- */
+
+// Format (simple + editable):
+// each line: "\t"
+// enabled: 1 or 0
+func loadParticipants(path string) ([]participant, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ // if missing file, treat as empty (not an error)
+ if os.IsNotExist(err) {
+ return []participant{}, nil
+ }
+ return nil, err
+ }
+ lines := strings.Split(string(data), "\n")
+ out := make([]participant, 0, len(lines))
+ for _, ln := range lines {
+ ln = strings.TrimSpace(ln)
+ if ln == "" || strings.HasPrefix(ln, "#") {
+ continue
+ }
+ parts := strings.SplitN(ln, "\t", 2)
+ if len(parts) != 2 {
+ continue
+ }
+ en := strings.TrimSpace(parts[0])
+ name := strings.TrimSpace(parts[1])
+ if name == "" {
+ continue
+ }
+ enabled := en == "1" || strings.EqualFold(en, "true") || en == "x"
+ out = append(out, participant{Name: name, Enabled: enabled})
+ }
+ return out, nil
+}
+
+func saveParticipants(path string, ps []participant) error {
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return err
+ }
+ tmp := path + ".tmp"
+
+ f, err := os.Create(tmp)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+
+ w := bufio.NewWriter(f)
+ _, _ = w.WriteString("# clee participants: \\t\n")
+ for _, p := range ps {
+ en := "0"
+ if p.Enabled {
+ en = "1"
+ }
+ _, _ = w.WriteString(en + "\t" + p.Name + "\n")
+ }
+ if err := w.Flush(); err != nil {
+ return err
+ }
+ if err := f.Close(); err != nil {
+ return err
+ }
+ return os.Rename(tmp, path)
+}
+
+func defaultParticipantsPath() string {
+ // Cross-platform enough for now:
+ // Linux/macOS: ~/.config/clee/participants.tsv
+ // Windows: still works if HOME is set; we’ll refine once you tell me platform.
+ home := os.Getenv("HOME")
+ if home == "" {
+ home = "."
+ }
+ return filepath.Join(home, ".config", "clee", "participants.tsv")
+}
+
+/* -------------------- HELPERS -------------------- */
+
+func enabledIndices(ps []participant) []int {
+ out := make([]int, 0, len(ps))
+ for i, p := range ps {
+ if p.Enabled {
+ out = append(out, i)
+ }
+ }
+ return out
+}
+
+func nextEnabledIndex(ps []participant, start int) int {
+ if len(ps) == 0 {
+ return 0
+ }
+ // walk forward circularly until we hit enabled
+ for i := 1; i <= len(ps); i++ {
+ idx := (start + i) % len(ps)
+ if ps[idx].Enabled {
+ return idx
+ }
+ }
+ // none enabled (should be checked by caller)
+ return start
+}
+
+func nextTick() tea.Cmd {
+ return tea.Tick(40*time.Millisecond, func(time.Time) tea.Msg { return tickMsg{} })
+}
+
+func clamp(v, lo, hi int) int {
+ if v < lo {
+ return lo
+ }
+ if v > hi {
+ return hi
+ }
+ return v
+}
+
+func max(a, b int) int {
+ if a > b {
+ return a
+ }
+ return b
+}
+
+// unused but handy later (kept tiny)
+func atoi(s string) int {
+ n, _ := strconv.Atoi(s)
+ return n
}
diff --git a/pkg/config.go b/pkg/config.go
deleted file mode 100644
index 4ab9984..0000000
--- a/pkg/config.go
+++ /dev/null
@@ -1,77 +0,0 @@
-package pkg
-
-import (
- "os"
- "path/filepath"
- "strings"
-
- "gopkg.in/yaml.v3"
-)
-
-type ProjectConfig struct {
- RequiredTools []string `yaml:"required_tools"`
- EnvironmentVars []string `yaml:"environment_vars"`
- RequiredTokens []string `yaml:"required_tokens"`
- VersionControl string `yaml:"version_control"`
-}
-
-type Config struct {
- DefaultProject string `yaml:"default"`
- Projects map[string]ProjectConfig `yaml:"projects"`
-}
-
-// LoadConfig loads the configuration file from ~/.config/procli/config.yaml
-func LoadConfig() *Config {
- configFile := filepath.Join(os.Getenv("HOME"), ".config", "procli", "config.yaml")
- file, err := os.Open(configFile)
- if err != nil {
- // Return a config with an initialized map
- return &Config{Projects: make(map[string]ProjectConfig)}
- }
- defer file.Close()
-
- var config Config
- decoder := yaml.NewDecoder(file)
- err = decoder.Decode(&config)
- if err != nil {
- // Ensure the map is initialized even if decoding fails
- return &Config{Projects: make(map[string]ProjectConfig)}
- }
-
- // Initialize the map if it's nil (e.g., empty file or incomplete config)
- if config.Projects == nil {
- config.Projects = make(map[string]ProjectConfig)
- }
-
- return &config
-}
-
-// SaveConfig saves the configuration to ~/.config/procli/config.yaml
-func SaveConfig(config *Config) error {
- configDir := filepath.Join(os.Getenv("HOME"), ".config", "procli")
- os.MkdirAll(configDir, os.ModePerm)
- configFile := filepath.Join(configDir, "config.yaml")
-
- file, err := os.Create(configFile)
- if err != nil {
- return err
- }
- defer file.Close()
-
- encoder := yaml.NewEncoder(file)
- err = encoder.Encode(config)
- if err != nil {
- return err
- }
-
- return nil
-}
-
-// ParseCommaSeparated splits a comma-separated string into a slice
-func ParseCommaSeparated(input string) []string {
- var result []string
- for _, item := range strings.Split(input, ",") {
- result = append(result, strings.TrimSpace(item))
- }
- return result
-}
diff --git a/pkg/helpers.go b/pkg/helpers.go
deleted file mode 100644
index 9cb7003..0000000
--- a/pkg/helpers.go
+++ /dev/null
@@ -1,23 +0,0 @@
-package pkg
-
-import (
- "fmt"
-
- "github.com/fatih/color"
-)
-
-// PrintCheckResult prints the result with appropriate icon and color
-func PrintCheckResult(item string, status bool) {
- var icon string
- var output string
-
- if status {
- icon = "✅"
- output = color.GreenString("%s %s", icon, item)
- } else {
- icon = "❌"
- output = color.RedString("%s %s", icon, item)
- }
-
- fmt.Println(output)
-}