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: -![image](https://github.com/user-attachments/assets/1a82ff84-0256-4b97-bbde-33942914c997) +``` +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) -}