cutesetup (empty) → 0.1.0.0
raw patch · 9 files changed
+1658/−0 lines, 9 filesdep +QuickCheckdep +aesondep +async
Dependencies added: QuickCheck, aeson, async, base, brick, bytestring, containers, directory, filepath, microlens, microlens-platform, process, stm, text, vector, vty, vty-crossplatform, vty-unix
Files
- LICENSE +208/−0
- README.org +74/−0
- app/Main.hs +6/−0
- cutesetup.cabal +68/−0
- src/Languages.hs +188/−0
- src/NixGen.hs +118/−0
- src/NixSearch.hs +137/−0
- src/UI.hs +741/−0
- test/Spec.hs +118/−0
+ LICENSE view
@@ -0,0 +1,208 @@+ Apache License+ Version 2.0, January 2004+ http://www.apache.org/licenses/++ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION++ 1. Definitions.++ "License" shall mean the terms and conditions for use, reproduction,+ and distribution as defined by Sections 1 through 9 of this document.++ "Licensor" shall mean the copyright owner or entity authorized by+ the copyright owner that is granting the License.++ "Legal Entity" shall mean the union of the acting entity and all+ other entities that control, are controlled by, or are under common+ control with that entity. For the purposes of this definition,+ "control" means (i) the power, direct or indirect, to cause the+ direction or management of such entity, whether by contract or+ otherwise, or (ii) ownership of fifty percent (50%) or more of the+ outstanding shares, or (iii) beneficial ownership of such entity.++ "You" (or "Your") shall mean an individual or Legal Entity+ exercising permissions granted by this License.++ "Source" form shall mean the preferred form for making modifications,+ including but not limited to software source code, documentation+ source, and configuration files.++ "Object" form shall mean any form resulting from mechanical+ transformation or translation of a Source form, including but+ not limited to compiled object code, generated documentation,+ and conversions to other media types.++ "Work" shall mean the work of authorship, whether in Source or+ Object form, made available under the License, as indicated by a+ copyright notice that is included in or attached to the work+ (an example is provided in the Appendix below).++ "Derivative Works" shall mean any work, whether in Source or Object+ form, that is based on (or derived from) the Work and for which the+ editorial revisions, annotations, elaborations, or other modifications+ represent, as a whole, an original work of authorship. For the purposes+ of this License, Derivative Works shall not include works that remain+ separable from, or merely link (or bind by name) to the interfaces of,+ the Work and Derivative Works thereof.++ "Contribution" shall mean any work of authorship, including+ the original version of the Work and any modifications or additions+ to that Work or Derivative Works thereof, that is intentionally+ submitted to Licensor for inclusion in the Work by the copyright owner+ or by an individual or Legal Entity authorized to submit on behalf of+ the copyright owner. For the purposes of this definition, "submitted"+ means any form of electronic, verbal, or written communication sent+ to the Licensor or its representatives, including but not limited to+ communication on electronic mailing lists, source code control systems,+ and issue tracking systems that are managed by, or on behalf of, the+ Licensor for the purpose of discussing and improving the Work, but+ excluding communication that is conspicuously marked or otherwise+ designated in writing by the copyright owner as "Not a Contribution."++ "Contributor" shall mean Licensor and any individual or Legal Entity+ on behalf of whom a Contribution has been received by Licensor and+ subsequently incorporated within the Work.++ 2. Grant of Copyright License. Subject to the terms and conditions of+ this License, each Contributor hereby grants to You a perpetual,+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable+ copyright license to reproduce, prepare Derivative Works of,+ publicly display, publicly perform, sublicense, and distribute the+ Work and such Derivative Works in Source or Object form.++ 3. Grant of Patent License. Subject to the terms and conditions of+ this License, each Contributor hereby grants to You a perpetual,+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable+ (except as stated in this section) patent license to make, have made,+ use, offer to sell, sell, import, and otherwise transfer the Work,+ where such license applies only to those patent claims licensable+ by such Contributor that are necessarily infringed by their+ Contribution(s) alone or by combination of their Contribution(s)+ with the Work to which such Contribution(s) was submitted. If You+ institute patent litigation against any entity (including a+ cross-claim or counterclaim in a lawsuit) alleging that the Work+ or a Contribution incorporated within the Work constitutes direct+ or contributory patent infringement, then any patent licenses+ granted to You under this License for that Work shall terminate+ as of the date such litigation is filed.++ 4. Redistribution. You may reproduce and distribute copies of the+ Work or Derivative Works thereof in any medium, with or without+ modifications, and in Source or Object form, provided that You+ meet the following conditions:++ (a) You must give any other recipients of the Work or+ Derivative Works a copy of this License; and++ (b) You must cause any modified files to carry prominent notices+ stating that You changed the files; and++ (c) You must retain, in the Source form of any Derivative Works+ that You distribute, all copyright, patent, trademark, and+ attribution notices from the Source form of the Work,+ excluding those notices that do not pertain to any part of+ the Derivative Works; and++ (d) If the Work includes a "NOTICE" text file as part of its+ distribution, then any Derivative Works that You distribute must+ include a readable copy of the attribution notices contained+ within such NOTICE file, excluding those notices that do not+ pertain to any part of the Derivative Works, in at least one+ of the following places: within a NOTICE text file distributed+ as part of the Derivative Works; within the Source form or+ documentation, if provided along with the Derivative Works; or,+ within a display generated by the Derivative Works, if and+ wherever such third-party notices normally appear. The contents+ of the NOTICE file are for informational purposes only and+ do not modify the License. You may add Your own attribution+ notices within Derivative Works that You distribute, alongside+ or as an addendum to the NOTICE text from the Work, provided+ that such additional attribution notices cannot be construed+ as modifying the License.++ You may add Your own copyright statement to Your modifications and+ may provide additional or different license terms and conditions+ for use, reproduction, or distribution of Your modifications, or+ for any such Derivative Works as a whole, provided Your use,+ reproduction, and distribution of the Work otherwise complies with+ the conditions stated in this License.++ 5. Submission of Contributions. Unless You explicitly state otherwise,+ any Contribution intentionally submitted for inclusion in the Work+ by You to the Licensor shall be under the terms and conditions of+ this License, without any additional terms or conditions.+ Notwithstanding the above, nothing herein shall supersede or modify+ the terms of any separate license agreement you may have executed+ with Licensor regarding such Contributions.++ 6. Trademarks. This License does not grant permission to use the trade+ names, trademarks, service marks, or product names of the Licensor,+ except as required for reasonable and customary use in describing the+ origin of the Work and reproducing the content of the NOTICE file.++ 7. Disclaimer of Warranty. Unless required by applicable law or+ agreed to in writing, Licensor provides the Work (and each+ Contributor provides its Contributions) on an "AS IS" BASIS,+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or+ implied, including, without limitation, any warranties or conditions+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A+ PARTICULAR PURPOSE. You are solely responsible for determining the+ appropriateness of using or redistributing the Work and assume any+ risks associated with Your exercise of permissions under this License.++ 8. Limitation of Liability. In no event and under no legal theory,+ whether in tort (including negligence), contract, or otherwise,+ unless required by applicable law (such as deliberate and grossly+ negligent acts) or agreed to in writing, shall any Contributor be+ liable to You for damages, including any direct, indirect, special,+ incidental, or consequential damages of any character arising as a+ result of this License or out of the use or inability to use the+ Work (including but not limited to damages for loss of goodwill,+ work stoppage, computer failure or malfunction, or any and all+ other commercial damages or losses), even if such Contributor+ has been advised of the possibility of such damages.++ 9. Accepting Warranty or Additional Liability. While redistributing+ the Work or Derivative Works thereof, You may choose to offer,+ and charge a fee for, acceptance of support, warranty, indemnity,+ or other liability obligations and/or rights consistent with this+ License. However, in accepting such obligations, You may act only+ on Your own behalf and on Your sole responsibility, not on behalf+ of any other Contributor, and only if You agree to indemnify,+ defend, and hold each Contributor harmless for any liability+ incurred by, or claims asserted against, such Contributor by reason+ of your accepting any such warranty or additional liability.++ END OF TERMS AND CONDITIONS++ APPENDIX: How to apply the Apache License to your work.++ To apply the Apache License to your work, attach the following+ boilerplate notice, with the fields enclosed by brackets "[]"+ replaced with your own identifying information. (Don't include+ the brackets!) The text should be enclosed in the appropriate+ comment syntax for the file format. We also recommend that a+ file or class name and description of purpose be included on the+ same "printed page" as the copyright notice for easier+ identification within third-party archives.++ Copyright 2026 Alice Liddell++ cutesetup - A nix develop flake creator++ Licensed under the Apache License, Version 2.0 (the "License");+ you may not use this file except in compliance with the License.+ You may obtain a copy of the License at++ http://www.apache.org/licenses/LICENSE-2.0++ Unless required by applicable law or agreed to in writing, software+ distributed under the License is distributed on an "AS IS" BASIS,+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.+ See the License for the specific language governing permissions and+ limitations under the License.++ EXCEPTION & OUTPUT LICENSE:+ All configuration files, templates, and boilerplate generated by this tool (including but not limited to flake.nix, .envrc, and shell scripts) are dedicated to the public domain under the Creative Commons Zero (CC0 1.0) Universal License. ++ You may use, modify, and distribute the generated files for any purpose, commercial or private, without restriction or attribution. To view a copy of this license, visit http://creativecommons.org/publicdomain/zero/1.0/
+ README.org view
@@ -0,0 +1,74 @@+#+author: Alice Liddell +#+options: toc:nil++[[file:demo.gif]]++* What?+✿ A cute Haskell TUI for quickly scaffolding =flake.nix= + =.envrc= for common languages.++* Why?+Because manually writing Nix flakes every time you start a new project is annoying! Let a cute TUI do the heavy lifting.++* Features+- *Language picker* - 13 languages: Python, Rust, Go, Node.js, Haskell, Ruby, Zig, C, Java, Elixir, Gleam, OCaml, Nix+- *Package picker* - pre-curated packages per language, toggle on/off with =Space=+- *nix search integration* - press =s= to open a live search screen powered by =nix search nixpkgs=, add results to your devshell+- *Auto-generates* =flake.nix= (with =flake-utils=, =eachDefaultSystem=, =mkShell=) and =.envrc= (=use flake=)+- Backs up existing files to =.bak= before overwriting++* How?++** Keys+| Screen | Key | Action |+|------------+---------------+------------------------|+| Language | =↑= / =↓= | Navigate |+| Language | =Enter= | Select language |+| Language | =q= | Quit |+| Packages | =↑= / =↓= | Navigate |+| Packages | =Space= | Toggle package |+| Packages | =s= | Open nix search |+| Packages | =Enter= | Continue to output dir |+| Packages | =Esc= | Back to lang picker |+| nix search | type | Search nixpkgs live |+| nix search | =↑= / =↓= | Navigate results |+| nix search | =Enter= | Add / remove result |+| nix search | =Tab= | Skip to output dir |+| nix search | =Esc= | Back to packages |+| Dir | type | Edit output path |+| Dir | =Enter= | *Generate files* |+| Dir | =Esc= | Back to packages |+| Done | =q= / =Enter= | Exit |++** Nix Run+#+BEGIN_SRC bash+nix run github:petalmaya/cutesetup+#+END_SRC+++** Building from source++*** With nix+#+BEGIN_SRC bash+nix build+#+END_SRC++*** With cabal (inside nix shell)+#+BEGIN_SRC bash+nix develop+cabal build+#+END_SRC++*** "Install"+I don't recommend this but if you want to use profiles go ahead~++#+BEGIN_SRC bash+nix profile install .+#+END_SRC++* Who's Alice?+Im Alice silly goose~++* License Note+While the generator itself is licensed under the Apache License, all templates and configuration files generated by it are dedicated to the public domain under the CC0 1.0 Universal license. You may use, modify, and distribute the output without any restrictions.++God this is horribly written for a README, I promise you the code is much better :3
+ app/Main.hs view
@@ -0,0 +1,6 @@+module Main (main) where++import UI (runApp)++main :: IO ()+main = runApp
+ cutesetup.cabal view
@@ -0,0 +1,68 @@+cabal-version: 3.0+name: cutesetup+version: 0.1.0.0+synopsis: Cute TUI for scaffolding nix devshells+description: A terminal UI to quickly generate flake.nix + .envrc for common languages+license: Apache-2.0+license-file: LICENSE+author: Alice Liddell+maintainer: Alice Liddell <rosegirl@tuta.io>+category: Development, Utility, User Interfaces+homepage: https://github.com/petalmaya/cutesetup+bug-reports: https://github.com/petalmaya/cutesetup/issues+build-type: Simple+extra-doc-files: README.org++source-repository head+ type: git+ location: https://github.com/petalmaya/cutesetup.git++common common-options+ default-language: GHC2021+ default-extensions:+ TypeApplications+ OverloadedStrings+ ghc-options: -Wall++common shared-deps+ build-depends:+ base >= 4.14 && < 5,+ containers >= 0.6 && < 1,+ directory >= 1.3 && < 2,+ filepath >= 1.4 && < 2,+ process >= 1.6 && < 2,+ aeson >= 2.1 && < 3,+ bytestring >= 0.11 && < 1,+ vector >= 0.13 && < 1++executable cutesetup+ import: common-options, shared-deps+ main-is: Main.hs+ other-modules:+ Languages+ NixGen+ NixSearch+ UI+ hs-source-dirs: app, src+ ghc-options: -threaded -rtsopts -with-rtsopts=-N++ build-depends:+ brick >= 2.1 && < 3,+ vty >= 6.0 && < 7,+ vty-unix >= 0.2 && < 1,+ vty-crossplatform >= 0.4 && < 0.5,+ text >= 2.0 && < 3,+ microlens >= 0.4 && < 1,+ microlens-platform >= 0.4 && < 1,+ async >= 2.2 && < 3,+ stm >= 2.5 && < 3++test-suite cutesetup-tests+ import: common-options, shared-deps+ type: exitcode-stdio-1.0+ main-is: Spec.hs+ hs-source-dirs: test, src+ build-depends:+ QuickCheck >= 2.14 && < 3++
+ src/Languages.hs view
@@ -0,0 +1,188 @@+module Languages+ ( Language (..)+ , LangPackage (..)+ , allLanguages+ , packagesFor+ , langNixInputs+ , langDescription+ , langIcon+ ) where++import Data.Map.Strict (Map)+import qualified Data.Map.Strict as Map++-- Supported languages+data Language+ = Python+ | Rust+ | Go+ | NodeJS+ | Haskell+ | Ruby+ | Zig+ | C+ | Java+ | Elixir+ | Gleam+ | Ocaml+ | Nix+ deriving (Eq, Ord, Show, Enum, Bounded)++-- A package selectable for a language+data LangPackage = LangPackage+ { pkgName :: String -- ^ nix attribute name+ , pkgLabel :: String -- ^ human-readable label+ , pkgDesc :: String -- ^ short description+ , pkgDefault :: Bool -- ^ pre-selected by default?+ } deriving (Eq, Show)++allLanguages :: [Language]+allLanguages = [minBound .. maxBound]++langIcon :: Language -> String+langIcon Python = "🐍"+langIcon Rust = "🦀"+langIcon Go = "🐹"+langIcon NodeJS = "🟩"+langIcon Haskell = "𝞴 "+langIcon Ruby = "💎"+langIcon Zig = "⚡"+langIcon C = "🔧"+langIcon Java = "☕"+langIcon Elixir = "💧"+langIcon Gleam = "✨"+langIcon Ocaml = "🐫"+langIcon Nix = "❄️"++langDescription :: Language -> String+langDescription Python = "Python 3 with pip / uv tooling"+langDescription Rust = "Rust via rustup / cargo"+langDescription Go = "Go toolchain"+langDescription NodeJS = "Node.js via npm / pnpm / yarn"+langDescription Haskell = "GHC + cabal / stack"+langDescription Ruby = "Ruby + bundler / gems"+langDescription Zig = "Zig compiler"+langDescription C = "GCC / Clang + cmake / meson"+langDescription Java = "JDK + Maven / Gradle"+langDescription Elixir = "Elixir + Mix + Erlang/OTP"+langDescription Gleam = "Gleam language + Erlang runtime"+langDescription Ocaml = "OCaml + opam / dune"+langDescription Nix = "Nix tooling (nil, alejandra…)"++-- Default packages per language+packagesFor :: Language -> [LangPackage]+packagesFor Python =+ [ LangPackage "python3" "python3" "Python 3 interpreter" True+ , LangPackage "python3Packages.pip" "pip" "Package installer" True+ , LangPackage "uv" "uv" "Fast Python pkg manager" False+ , LangPackage "python3Packages.virtualenv" "virtualenv" "Virtual environments" False+ , LangPackage "ruff" "ruff" "Fast Python linter" False+ , LangPackage "pyright" "pyright" "Python LSP" False+ , LangPackage "black" "black" "Code formatter" False+ , LangPackage "poetry" "poetry" "Dependency manager" False+ , LangPackage "python3Packages.pytest" "pytest" "Testing framework" False+ ]+packagesFor Rust =+ [ LangPackage "rustc" "rustc" "Rust compiler" True+ , LangPackage "cargo" "cargo" "Rust build tool" True+ , LangPackage "rustfmt" "rustfmt" "Code formatter" True+ , LangPackage "clippy" "clippy" "Linter" True+ , LangPackage "rust-analyzer" "rust-analyzer" "Rust LSP" False+ , LangPackage "cargo-watch" "cargo-watch" "Auto-rebuild on change" False+ , LangPackage "cargo-edit" "cargo-edit" "Add/rm deps from CLI" False+ , LangPackage "cargo-nextest" "cargo-nextest" "Faster test runner" False+ , LangPackage "sccache" "sccache" "Shared compilation cache" False+ , LangPackage "mold" "mold" "Fast linker" False+ ]+packagesFor Go =+ [ LangPackage "go" "go" "Go toolchain" True+ , LangPackage "gopls" "gopls" "Go LSP" False+ , LangPackage "golangci-lint" "golangci-lint" "Linter suite" False+ , LangPackage "gotools" "gotools" "Extra go tools" False+ , LangPackage "delve" "delve" "Go debugger" False+ , LangPackage "air" "air" "Live reload" False+ , LangPackage "migrate" "migrate" "DB migrations" False+ ]+packagesFor NodeJS =+ [ LangPackage "nodejs_22" "nodejs 22" "Node.js LTS" True+ , LangPackage "nodePackages.npm" "npm" "Package manager" True+ , LangPackage "nodePackages.pnpm" "pnpm" "Fast pkg manager" False+ , LangPackage "yarn" "yarn" "Yarn pkg manager" False+ , LangPackage "nodePackages.typescript" "typescript" "TypeScript compiler" False+ , LangPackage "nodePackages.ts-node" "ts-node" "TS execution" False+ , LangPackage "biome" "biome" "Formatter + linter" False+ , LangPackage "nodePackages.prettier" "prettier" "Code formatter" False+ , LangPackage "nodePackages.eslint" "eslint" "Linter" False+ , LangPackage "bun" "bun" "All-in-one JS runtime" False+ ]+packagesFor Haskell =+ [ LangPackage "ghc" "ghc" "Haskell compiler" True+ , LangPackage "cabal-install" "cabal" "Build tool" True+ , LangPackage "haskell-language-server" "HLS" "Haskell LSP" False+ , LangPackage "stack" "stack" "Stack build tool" False+ , LangPackage "hlint" "hlint" "Linter" False+ , LangPackage "ormolu" "ormolu" "Formatter" False+ , LangPackage "hoogle" "hoogle" "Package search" False+ , LangPackage "ghcid" "ghcid" "Auto-reload GHCi" False+ ]+packagesFor Ruby =+ [ LangPackage "ruby" "ruby" "Ruby interpreter" True+ , LangPackage "bundler" "bundler" "Gem bundler" True+ , LangPackage "rubyPackages.rubocop" "rubocop" "Linter + formatter" False+ , LangPackage "rubyPackages.solargraph" "solargraph" "Ruby LSP" False+ , LangPackage "rubyPackages.rake" "rake" "Build tool" False+ , LangPackage "rubyPackages.rspec" "rspec" "Testing framework" False+ ]+packagesFor Zig =+ [ LangPackage "zig" "zig" "Zig compiler" True+ , LangPackage "zls" "zls" "Zig LSP" False+ , LangPackage "lldb" "lldb" "Debugger" False+ ]+packagesFor C =+ [ LangPackage "gcc" "gcc" "GNU C/C++ compiler" True+ , LangPackage "clang" "clang" "Clang compiler" False+ , LangPackage "cmake" "cmake" "Build system" True+ , LangPackage "ninja" "ninja" "Fast build tool" False+ , LangPackage "meson" "meson" "Meson build system" False+ , LangPackage "gdb" "gdb" "GNU debugger" False+ , LangPackage "clang-tools" "clang-tools" "clangd LSP + clang-format" False+ , LangPackage "valgrind" "valgrind" "Memory error detector" False+ , LangPackage "pkg-config" "pkg-config" "Library config tool" True+ ]+packagesFor Java =+ [ LangPackage "jdk21" "JDK 21" "Java Development Kit" True+ , LangPackage "maven" "maven" "Build + dep manager" False+ , LangPackage "gradle" "gradle" "Gradle build tool" False+ , LangPackage "jdt-language-server" "jdtls" "Java LSP" False+ ]+packagesFor Elixir =+ [ LangPackage "elixir" "elixir" "Elixir + Erlang/OTP" True+ , LangPackage "elixir-ls" "elixir-ls" "Elixir LSP" False+ , LangPackage "erlang" "erlang" "Erlang runtime" False+ , LangPackage "rebar3" "rebar3" "Erlang build tool" False+ ]+packagesFor Gleam =+ [ LangPackage "gleam" "gleam" "Gleam compiler" True+ , LangPackage "erlang" "erlang" "Erlang runtime" True+ , LangPackage "rebar3" "rebar3" "Erlang build tool" True+ ]+packagesFor Ocaml =+ [ LangPackage "ocaml" "ocaml" "OCaml compiler" True+ , LangPackage "opam" "opam" "OCaml pkg manager" True+ , LangPackage "dune_3" "dune" "Build system" True+ , LangPackage "ocamlPackages.ocaml-lsp" "ocaml-lsp" "OCaml LSP" False+ , LangPackage "ocamlPackages.utop" "utop" "Better REPL" False+ ]+packagesFor Nix =+ [ LangPackage "nil" "nil" "Nix LSP" True+ , LangPackage "alejandra" "alejandra" "Nix formatter" True+ , LangPackage "nix-prefetch-git" "nix-prefetch-git" "Prefetch git sources" False+ , LangPackage "nix-tree" "nix-tree" "Nix store explorer" False+ , LangPackage "statix" "statix" "Nix linter" False+ , LangPackage "deadnix" "deadnix" "Dead code finder" False+ ]++-- Extra nix inputs needed for some languages (beyond nixpkgs)+langNixInputs :: Language -> Map String (String, String)+langNixInputs _ = Map.empty+-- Future: could add fenix for Rust nightly, etc.
+ src/NixGen.hs view
@@ -0,0 +1,118 @@+module NixGen+ ( GenerateConfig (..)+ , generateFlake+ , generateEnvrc+ , generateBoth+ ) where++import Data.List (intercalate, nub)+import Languages (Language (..), LangPackage (..))+import NixSearch (Channel (..))+import Control.Exception (try, SomeException)+import System.Directory (doesFileExist, createDirectoryIfMissing)+import System.FilePath ((</>))++data GenerateConfig = GenerateConfig+ { gcLang :: Language+ , gcPackages :: [LangPackage] -- ^ selected packages+ , gcExtraAttrs :: [(String, Channel)] -- ^ extra nixpkgs attr names with their channel from search+ , gcTargetDir :: FilePath+ , gcSystemArch :: String -- ^ e.g. "x86_64-linux"+ , gcPythonVenv :: Bool -- ^ toggle to enter virtualenv on shell hook+ , gcNixpkgsStable :: String -- ^ stable nixpkgs branch/tag (e.g. "nixos-25.11")+ } deriving (Show)++-- Indent a string by n spaces+indent :: Int -> String -> String+indent n s = replicate n ' ' ++ s++-- Generate flake.nix content+generateFlake :: GenerateConfig -> String+generateFlake cfg =+ let baseAttrs = nub $ map pkgName (gcPackages cfg)+ unstableExtras = nub [attr | (attr, Unstable) <- gcExtraAttrs cfg]+ stableExtras = nub [attr | (attr, Stable) <- gcExtraAttrs cfg]+ + unstableLine = map (\a -> indent 12 ("pkgs." ++ a)) (baseAttrs ++ unstableExtras)+ stableLine = map (\a -> indent 12 ("pkgs-stable." ++ a)) stableExtras+ pkgsLines = intercalate "\n" (unstableLine ++ stableLine)++ shellHookStr = if gcPythonVenv cfg+ then unlines+ [ " shellHook = ''"+ , " echo \"🚀 " ++ show (gcLang cfg) ++ " devshell ready!\""+ , " if [ ! -d .venv ]; then"+ , " echo \"Creating virtual environment in .venv...\""+ , " python3 -m venv .venv"+ , " fi"+ , " echo \"Activating virtual environment...\""+ , " source .venv/bin/activate"+ , " '';"+ ]+ else unlines+ [ " shellHook = ''"+ , " echo \"🚀 " ++ show (gcLang cfg) ++ " devshell ready!\""+ , " '';"+ ]+ in unlines+ [ "{"+ , " description = \"" ++ show (gcLang cfg) ++ " development environment\";"+ , ""+ , " inputs = {"+ , " nixpkgs.url = \"github:NixOS/nixpkgs/nixpkgs-unstable\";"+ , " nixpkgs-stable.url = \"github:NixOS/nixpkgs/" ++ gcNixpkgsStable cfg ++ "\";"+ , " flake-utils.url = \"github:numtide/flake-utils\";"+ , " };"+ , ""+ , " outputs = { self, nixpkgs, nixpkgs-stable, flake-utils, ... }:"+ , " flake-utils.lib.eachDefaultSystem (system:"+ , " let"+ , " pkgs = nixpkgs.legacyPackages.${system};"+ , " pkgs-stable = nixpkgs-stable.legacyPackages.${system};"+ , " in {"+ , " devShells.default = pkgs.mkShell {"+ , " nativeBuildInputs = ["+ , pkgsLines+ , " ];"+ , ""+ , init (shellHookStr) -- remove trailing newline for better formatting inside unlines+ , " };"+ , " }"+ , " );"+ , "}"+ ]++-- Generate .envrc content+generateEnvrc :: GenerateConfig -> String+generateEnvrc _ = unlines+ [ "# Generated by cutesetup"+ , "use flake"+ ]++-- Write both files to disk, backing up existing ones+generateBoth :: GenerateConfig -> IO (Either String String)+generateBoth cfg = do+ let dir = gcTargetDir cfg+ flakePath = dir </> "flake.nix"+ envrcPath = dir </> ".envrc"++ res <- try @SomeException $ do+ createDirectoryIfMissing True dir+ backupIfExists flakePath+ backupIfExists envrcPath+ writeFile flakePath (generateFlake cfg)+ writeFile envrcPath (generateEnvrc cfg)++ return $ case res of+ Left err -> Left ("File write failed: " ++ show err)+ Right _ -> Right $ "✅ Written:\n " ++ flakePath ++ "\n " ++ envrcPath++backupIfExists :: FilePath -> IO ()+backupIfExists fp = do+ exists <- doesFileExist fp+ if exists+ then do+ let backup = fp ++ ".bak"+ content <- readFile fp+ writeFile backup content+ else return ()
+ src/NixSearch.hs view
@@ -0,0 +1,137 @@+{-# LANGUAGE OverloadedStrings #-}+module NixSearch+ ( nixSearch+ , NixResult (..)+ , Channel (..)+ , getCachePath+ , cacheExists+ , loadCache+ , updateCache+ , searchLocal+ ) where++import Control.Exception (try, SomeException)+import Data.Aeson (FromJSON(..), decodeFileStrict, withArray)+import Data.Char (toLower)+import Data.List (isInfixOf)+import qualified Data.Vector as V+import System.Directory (createDirectoryIfMissing, doesFileExist, getXdgDirectory, XdgDirectory(XdgCache))+import System.FilePath ((</>))+import System.Process (readCreateProcessWithExitCode, shell)+import System.Exit (ExitCode(..))++-- Channel selection for stable vs unstable package databases+data Channel = Unstable | Stable deriving (Eq, Show, Ord, Enum, Bounded)++data NixResult = NixResult+ { nrAttr :: String+ , nrName :: String+ , nrDesc :: String+ } deriving (Eq, Show)++instance FromJSON NixResult where+ parseJSON = withArray "NixResult" $ \arr -> do+ if V.length arr >= 3+ then do+ attr <- parseJSON (arr V.! 0)+ name <- parseJSON (arr V.! 1)+ desc <- parseJSON (arr V.! 2)+ return $ NixResult attr name desc+ else fail "Expected a list of at least 3 elements"++-- Default fallback version for stable NixOS pinning+defaultStableVersion :: String+defaultStableVersion = "nixos-25.11"++-- Legacy function to keep the interface compileable if needed, or we can deprecate it.+nixSearch :: String -> IO (Either String [NixResult])+nixSearch query = do+ -- Legacy nix search, fallback to local search if cache exists+ exists <- cacheExists defaultStableVersion Unstable+ if exists+ then do+ res <- loadCache defaultStableVersion Unstable+ return $ case res of+ Left err -> Left err+ Right pkgs -> Right (searchLocal query pkgs)+ else return $ Left "Cache not found. Please update/download the packages cache first."++-- Get the URL for the package index of a channel+channelUrl :: String -> Channel -> String+channelUrl _ Unstable = "https://channels.nixos.org/nixpkgs-unstable/packages.json.br"+channelUrl version Stable = "https://channels.nixos.org/" ++ version ++ "/packages.json.br"++-- Get the cache directory path+getCacheDir :: IO FilePath+getCacheDir = do+ dir <- getXdgDirectory XdgCache "cutesetup"+ createDirectoryIfMissing True dir+ return dir++-- Get the cache file path for a channel+getCachePath :: String -> Channel -> IO FilePath+getCachePath version chan = do+ dir <- getCacheDir+ return $ case chan of+ Unstable -> dir </> "packages-unstable.cache.json"+ Stable -> dir </> "packages-stable-" ++ version ++ ".cache.json"++-- Check if the cache file exists for a channel+cacheExists :: String -> Channel -> IO Bool+cacheExists version chan = do+ path <- getCachePath version chan+ doesFileExist path++-- Load the cache database for a channel from disk+loadCache :: String -> Channel -> IO (Either String [NixResult])+loadCache version chan = do+ path <- getCachePath version chan+ exists <- doesFileExist path+ if not exists+ then return $ Left "Cache file does not exist"+ else do+ mVal <- decodeFileStrict path+ case mVal of+ Nothing -> return $ Left "Failed to parse cache JSON"+ Just pkgs -> return $ Right pkgs++-- Update/download the packages list for a channel+-- Downloads the .br file and extracts/compresses it on the fly via a python shell command+updateCache :: String -> Channel -> IO (Either String String)+updateCache version chan = do+ path <- getCachePath version chan+ let url = channelUrl version chan+ {- The pyScript is an optimization tool designed to run in a pipeline.+ It processes the large Nix packages JSON index on the fly:+ 1. `json.load(sys.stdin)` parses the raw decompressed channel packages database object.+ 2. For each package entry under the "packages" map:+ - `k` represents the Nixpkgs attribute path (e.g. "python3Packages.pip").+ - `v.get('pname', '')` extracts the package name without the attribute path.+ - `v.get('meta', {}).get('description', '')` fetches the package's description text.+ 3. It constructs a compact array of 3-element arrays `[attr, name, desc]` instead of full objects.+ 4. `json.dump` writes the compact list to stdout.+ This reduces the cached file size from ~60MB to ~5MB, yielding substantial memory and parsing speedups in Haskell. -}+ pyScript = "import json, sys; d=json.load(sys.stdin); out=[[k, v.get('pname',''), v.get('meta',{}).get('description','')] for k,v in d.get('packages',{}).items()]; json.dump(out, sys.stdout)"+ cmd = "curl -L " ++ url ++ " | nix-shell -p brotli python3 --run \"brotli -d | python3 -c \\\"" ++ pyScript ++ "\\\"\" > " ++ show path+ + res <- try @SomeException $ readCreateProcessWithExitCode (shell cmd) ""+ case res of+ Left err -> return $ Left ("Process execution failed: " ++ show err)+ Right (exitCode, _, stderr) -> case exitCode of+ ExitSuccess -> return $ Right ("Successfully updated " ++ show chan ++ " cache!")+ ExitFailure _ -> return $ Left ("Failed to update cache. Stderr:\n" ++ stderr)++-- Maximum number of search results to return+maxSearchResults :: Int+maxSearchResults = 40++-- Filter packages database locally based on query+searchLocal :: String -> [NixResult] -> [NixResult]+searchLocal query pkgs+ | null query = []+ | otherwise =+ let q = map toLower query+ matches p = q `isInfixOf` map toLower (nrAttr p)+ || q `isInfixOf` map toLower (nrName p)+ || q `isInfixOf` map toLower (nrDesc p)+ in take maxSearchResults (filter matches pkgs)
+ src/UI.hs view
@@ -0,0 +1,741 @@+{-# LANGUAGE TypeApplications #-}+{-# LANGUAGE TemplateHaskell #-}+module UI+ ( runApp+ ) where++import Brick+import Brick.BChan (BChan, newBChan, writeBChan)+import Brick.Widgets.Border+import Brick.Widgets.Border.Style+import Brick.Widgets.Center+import Brick.Widgets.Edit (Editor, editor, renderEditor, handleEditorEvent, getEditContents, editFocusedAttr)+import Control.Concurrent (forkIO)+import Control.Exception (try, SomeException)+import Control.Monad (void, when)+import Control.Monad.IO.Class (liftIO)+import Data.List (intercalate)+import qualified Data.Set as Set+import Data.Set (Set)+import System.Process (readProcess)+import qualified Graphics.Vty as V+import qualified Graphics.Vty.CrossPlatform as VCP+import Languages+import Lens.Micro.Platform+import NixGen+import NixSearch+import System.Directory (getCurrentDirectory)+import System.Environment (lookupEnv)++-- Resource names++data Name+ = LangList+ | PkgList+ | SearchEdit+ | SearchList+ | DirEdit+ deriving (Eq, Ord, Show)++-- Custom events++data CuteEvent+ = SearchResults [NixResult]+ | SearchError String+ | GenerateDone String+ | CacheUpdateStarted Channel+ | CacheUpdateDone Channel [NixResult]+ | CacheUpdateFailed Channel String+ | CacheLoadDone Channel [NixResult]+ | CacheLoadFailed Channel String++-- App screen++data Screen+ = LangScreen -- ^ pick a language+ | PkgScreen -- ^ pick packages+ | SearchScreen -- ^ nix search + custom add+ | DirScreen -- ^ pick output directory+ | DoneScreen -- ^ result message+ deriving (Eq, Show)++-- App state++data AppState = AppState+ { _screen :: Screen+ , _langCursor :: Int+ , _pkgCursor :: Int+ , _pkgToggled :: Set String -- ^ toggled OFF from defaults+ , _pkgEnabled :: Set String -- ^ toggled ON extras+ , _pythonVenv :: Bool -- ^ toggle to enter virtualenv on shell hook+ , _searchEdit :: Editor String Name+ , _searchResults :: [NixResult]+ , _searchCursor :: Int+ , _searchLoading :: Bool+ , _searchError :: Maybe String+ , _extraAttrs :: [(String, Channel)] -- ^ user-added from search with their channel+ , _dirEdit :: Editor String Name+ , _resultMsg :: String+ , _arch :: String+ , _activeChannel :: Channel+ , _unstableCache :: Maybe [NixResult]+ , _stableCache :: Maybe [NixResult]+ , _cacheUpdating :: Bool+ , _stableVersion :: String+ } deriving (Show)++makeLensesFor [("_searchEdit", "searchEdit"), ("_dirEdit", "dirEdit")] ''AppState++-- UI Layout and Truncation Constants+langColWidth :: Int+langColWidth = 12++pkgColWidth :: Int+pkgColWidth = 30++searchAttrColWidth :: Int+searchAttrColWidth = 28++searchDescMaxLength :: Int+searchDescMaxLength = 55++-- Helpers++currentLang :: AppState -> Language+currentLang s = allLanguages !! (_langCursor s)++currentPkgs :: AppState -> [LangPackage]+currentPkgs s =+ let lang = currentLang s+ base = packagesFor lang+ in base++-- A package is "active" if (default AND not toggled off) OR explicitly enabled+isPkgActive :: AppState -> LangPackage -> Bool+isPkgActive s p+ | pkgDefault p = not (Set.member (pkgName p) (_pkgToggled s))+ | otherwise = Set.member (pkgName p) (_pkgEnabled s)++selectedPkgNames :: AppState -> [LangPackage]+selectedPkgNames s = filter (isPkgActive s) (currentPkgs s)++targetDir :: AppState -> String+targetDir s = concat (getEditContents (_dirEdit s))++-- Colour theme++pink, lavender, mint, peach, fgDim :: AttrName+pink = attrName "pink"+lavender = attrName "lavender"+mint = attrName "mint"+peach = attrName "peach"+fgDim = attrName "fgDim"++selectedAttr, activeAttr, inactiveAttr, headerAttr, titleAttr, hintAttr :: AttrName+selectedAttr = attrName "selected"+activeAttr = attrName "active"+inactiveAttr = attrName "inactive"+headerAttr = attrName "header"+titleAttr = attrName "title"+hintAttr = attrName "hint"++rgb :: Int -> Int -> Int -> V.Color+rgb = V.rgbColor++theMap :: AttrMap+theMap = attrMap (V.white `on` V.black) $+ [ (pink, fg (rgb 255 105 180))+ , (lavender, fg (rgb 180 130 255))+ , (mint, fg (rgb 100 230 180))+ , (peach, fg (rgb 255 180 100))+ , (fgDim, fg (rgb 120 120 140))+ , (selectedAttr, rgb 30 30 50 `on` rgb 180 130 255)+ , (activeAttr, fg (rgb 100 230 180))+ , (inactiveAttr, fg (rgb 90 90 110))+ , (headerAttr, V.withStyle (fg (rgb 255 105 180)) V.bold)+ , (titleAttr, V.withStyle (fg (rgb 180 130 255)) V.bold)+ , (hintAttr, fg (rgb 100 110 130))+ , (editFocusedAttr, rgb 20 20 40 `on` rgb 200 150 255)+ ]+++-- Drawing++drawUI :: AppState -> [Widget Name]+drawUI s = case _screen s of+ LangScreen -> [drawLangScreen s]+ PkgScreen -> [drawPkgScreen s]+ SearchScreen -> [drawSearchScreen s]+ DirScreen -> [drawDirScreen s]+ DoneScreen -> [drawDoneScreen s]++-- Banner / header+drawBanner :: Widget Name+drawBanner = withAttr titleAttr $ vBox+ [ hCenter $ str " ✿ cutesetup ✿ "+ , hCenter $ withAttr hintAttr (str "nix devshell scaffolding")+ ]++drawFooter :: String -> Widget Name+drawFooter hints = withAttr hintAttr $ hCenter $ str hints++-- Lang screen++drawLangScreen :: AppState -> Widget Name+drawLangScreen s = joinBorders $+ withBorderStyle unicode $+ border $+ vBox+ [ drawBanner+ , hBorder+ , hCenter $ withAttr headerAttr (str "Pick a language")+ , hCenter $ withAttr hintAttr (str "↑↓ navigate Enter select q quit")+ , hBorder+ , viewport LangList Vertical $ vBox (zipWith (drawLangRow s) [0..] allLanguages)+ , hBorder+ , drawFooter $ " " ++ show (length allLanguages) ++ " languages available"+ ]++drawLangRow :: AppState -> Int -> Language -> Widget Name+drawLangRow s i lang =+ let sel = i == _langCursor s+ icon = langIcon lang+ name = show lang+ desc = langDescription lang+ row = hBox+ [ str (if sel then " ▶ " else " ")+ , withAttr peach (str icon)+ , str " "+ , withAttr (if sel then selectedAttr else titleAttr) (str (padStringRight langColWidth name))+ , str " "+ , withAttr fgDim (str desc)+ ]+ in if sel+ then withAttr selectedAttr (padRight Max row)+ else padRight Max row++-- ── Pkg screen++drawPkgScreen :: AppState -> Widget Name+drawPkgScreen s =+ let lang = currentLang s+ pkgs = currentPkgs s+ nSel = length (selectedPkgNames s)+ isPipActive = lang == Python && any (\p -> pkgName p == "python3Packages.pip") (selectedPkgNames s)+ hintStr = if isPipActive+ then "↑↓ navigate Space toggle v toggle venv s nix-search Enter next Esc back"+ else "↑↓ navigate Space toggle s nix-search Enter next Esc back"+ in joinBorders $+ withBorderStyle unicode $+ border $+ vBox+ [ drawBanner+ , hBorder+ , hCenter $ withAttr headerAttr+ (str (langIcon lang ++ " " ++ show lang ++ " packages"))+ , hCenter $ withAttr hintAttr+ (str hintStr)+ , hBorder+ , viewport PkgList Vertical $ vBox (zipWith (drawPkgRow s) [0..] pkgs)+ , drawVenvRecommendation s+ , hBorder+ , hBox+ [ withAttr mint (str (" ✔ " ++ show nSel ++ " selected"))+ , str " "+ , withAttr fgDim (str "s → nix search for more")+ ]+ ]++drawPkgRow :: AppState -> Int -> LangPackage -> Widget Name+drawPkgRow s i p =+ let sel = i == _pkgCursor s+ active = isPkgActive s p+ check = if active then withAttr mint (str "[✔]") else withAttr inactiveAttr (str "[ ]")+ row = hBox+ [ str (if sel then " ▶ " else " ")+ , check+ , str " "+ , withAttr (if active then activeAttr else inactiveAttr)+ (str (padStringRight pkgColWidth (pkgLabel p)))+ , str " "+ , withAttr fgDim (str (pkgDesc p))+ ]+ in if sel+ then withAttr selectedAttr (padRight Max row)+ else padRight Max row++-- Search screen++drawSearchScreen :: AppState -> Widget Name+drawSearchScreen s =+ let extraCount = length (_extraAttrs s)+ extraNames = map fst (_extraAttrs s)+ in joinBorders $+ withBorderStyle unicode $+ border $+ vBox+ [ drawBanner+ , hBorder+ , hCenter $ withAttr headerAttr (str "❄ nix search")+ , hCenter $ withAttr hintAttr+ (str "Type to search ↑↓ navigate Enter add Ctrl+s switch channel Ctrl+u update cache Esc back")+ , hBorder+ , hBox+ [ withAttr lavender (str " search: ")+ , renderEditor (str . unlines) (_screen s == SearchScreen) (_searchEdit s)+ , fill ' '+ , withAttr titleAttr (str " channel: ")+ , case _activeChannel s of+ Unstable -> withAttr pink (str "[unstable]")+ Stable -> withAttr mint (str "[stable]")+ , str " "+ ]+ , hBorder+ , if _searchLoading s+ then hCenter (withAttr peach (str (case _searchError s of+ Just err -> err+ Nothing -> "Loading packages cache…")))+ else case _searchError s of+ Just err -> hCenter (withAttr peach (str ("⚠️ " ++ err)))+ Nothing -> drawSearchResults s+ , hBorder+ , hBox+ [ withAttr mint (str (" ✔ " ++ show extraCount ++ " added:"))+ , str " "+ , withAttr fgDim (str (intercalate ", " extraNames))+ ]+ , hBorder+ , drawFooter " Enter → add to devshell Esc → back to packages Tab → continue"+ ]++drawSearchResults :: AppState -> Widget Name+drawSearchResults s =+ if null (_searchResults s)+ then hCenter $ withAttr fgDim (str "no results — try searching above")+ else viewport SearchList Vertical $+ vBox (zipWith (drawResultRow s) [0..] (_searchResults s))++drawResultRow :: AppState -> Int -> NixResult -> Widget Name+drawResultRow s i r =+ let sel = i == _searchCursor s+ added = any (\(attr, _) -> attr == nrAttr r) (_extraAttrs s)+ chanStr = case lookup (nrAttr r) (_extraAttrs s) of+ Just Unstable -> " [unstable]"+ Just Stable -> " [stable]"+ Nothing -> ""+ check = if added then withAttr mint (str "[+]") else str " "+ row = hBox+ [ str (if sel then " ▶ " else " ")+ , check+ , str " "+ , withAttr lavender (str (padStringRight searchAttrColWidth (nrAttr r)))+ , withAttr hintAttr (str chanStr)+ , str " "+ , withAttr fgDim (str (take searchDescMaxLength (nrDesc r)))+ ]+ in if sel+ then withAttr selectedAttr (padRight Max row)+ else padRight Max row++-- Dir screen++drawDirScreen :: AppState -> Widget Name+drawDirScreen s =+ joinBorders $+ withBorderStyle unicode $+ border $+ vBox+ [ drawBanner+ , hBorder+ , hCenter $ withAttr headerAttr (str " output directory")+ , hCenter $ withAttr hintAttr (str "Edit path Enter generate Esc back")+ , hBorder+ , hBox+ [ withAttr lavender (str " dir: ")+ , renderEditor (str . unlines) True (_dirEdit s)+ ]+ , hBorder+ , drawSummary s+ , hBorder+ , drawFooter " Enter → write flake.nix + .envrc"+ ]++drawSummary :: AppState -> Widget Name+drawSummary s =+ let lang = currentLang s+ pkgs = selectedPkgNames s+ extra = _extraAttrs s+ formatExtra (attr, chan) = attr ++ case chan of+ Unstable -> " (unstable)"+ Stable -> " (stable)"+ in vBox+ [ hBox [ withAttr lavender (str " language: ")+ , withAttr peach (str (langIcon lang ++ " " ++ show lang))+ ]+ , hBox [ withAttr lavender (str " packages: ")+ , withAttr mint (str (intercalate ", " (map pkgLabel pkgs)))+ ]+ , if null extra then emptyWidget+ else hBox [ withAttr lavender (str " extra: ")+ , withAttr mint (str (intercalate ", " (map formatExtra extra)))+ ]+ ]++-- Done screen++drawDoneScreen :: AppState -> Widget Name+drawDoneScreen s =+ joinBorders $+ withBorderStyle unicode $+ border $+ vBox+ [ drawBanner+ , hBorder+ , center $ vBox+ [ hCenter $ withAttr mint (str "✿ ✿ ✿ done! ✿ ✿ ✿")+ , str ""+ , hCenter $ withAttr activeAttr (str (_resultMsg s))+ , str ""+ , hCenter $ withAttr hintAttr (str "Run: direnv allow")+ , hCenter $ withAttr hintAttr (str "Then: nix develop")+ , str ""+ , hCenter $ withAttr peach (str "Press q or Enter to exit")+ ]+ ]++-- ─── Event handling ────────────────────────────────────────────────────────────++handleEvent :: CuteChan -> BrickEvent Name CuteEvent -> EventM Name AppState ()+handleEvent _ (AppEvent ev) = handleCustomEvent ev+handleEvent chan ev = do+ s <- get+ case _screen s of+ LangScreen -> handleLangEvent chan ev+ PkgScreen -> handlePkgEvent chan ev+ SearchScreen -> handleSearchEvent chan ev+ DirScreen -> handleDirEvent chan ev+ DoneScreen -> handleDoneEvent chan ev++handleCustomEvent :: CuteEvent -> EventM Name AppState ()+handleCustomEvent (SearchResults rs) = do+ modify $ \s -> s { _searchResults = rs+ , _searchLoading = False+ , _searchError = Nothing+ , _searchCursor = 0 }+handleCustomEvent (SearchError err) = do+ modify $ \s -> s { _searchLoading = False+ , _searchError = Just err }+handleCustomEvent (GenerateDone msg) = do+ modify $ \s -> s { _screen = DoneScreen, _resultMsg = msg }+handleCustomEvent (CacheUpdateStarted c) = do+ modify $ \s -> s { _cacheUpdating = True, _searchLoading = True, _searchError = Just ("⏳ Downloading " ++ show c ++ " packages list…") }+handleCustomEvent (CacheUpdateDone c pkgs) = do+ modify $ \s ->+ let nextState = case c of+ Unstable -> s { _unstableCache = Just pkgs }+ Stable -> s { _stableCache = Just pkgs }+ in nextState { _cacheUpdating = False, _searchLoading = False, _searchError = Nothing, _searchCursor = 0 }+handleCustomEvent (CacheUpdateFailed c err) = do+ modify $ \s -> s { _cacheUpdating = False, _searchLoading = False, _searchError = Just ("Failed to download " ++ show c ++ " cache: " ++ err) }+handleCustomEvent (CacheLoadDone c pkgs) = do+ modify $ \s ->+ let nextState = case c of+ Unstable -> s { _unstableCache = Just pkgs }+ Stable -> s { _stableCache = Just pkgs }+ in nextState { _searchLoading = False, _searchError = Nothing, _searchCursor = 0 }+handleCustomEvent (CacheLoadFailed _ err) = do+ modify $ \s -> s { _searchLoading = False, _searchError = Just err }++-- Lang screen+handleLangEvent :: CuteChan -> BrickEvent Name CuteEvent -> EventM Name AppState ()+handleLangEvent _ (VtyEvent (V.EvKey V.KUp _)) = do+ modify $ \s -> s { _langCursor = max 0 (_langCursor s - 1) }+ vScrollBy (viewportScroll LangList) (-1)+handleLangEvent _ (VtyEvent (V.EvKey V.KDown _)) = do+ modify $ \s -> s { _langCursor = min (length allLanguages - 1) (_langCursor s + 1) }+ vScrollBy (viewportScroll LangList) 1+handleLangEvent _ (VtyEvent (V.EvKey V.KEnter _)) = do+ modify $ \s -> s+ { _screen = PkgScreen+ , _pkgCursor = 0+ , _pkgToggled = Set.empty+ , _pkgEnabled = Set.empty+ , _extraAttrs = []+ }+handleLangEvent _ (VtyEvent (V.EvKey (V.KChar 'q') _)) = halt+handleLangEvent _ _ = return ()++-- Pkg screen+handlePkgEvent :: CuteChan -> BrickEvent Name CuteEvent -> EventM Name AppState ()+handlePkgEvent _ (VtyEvent (V.EvKey V.KUp _)) = do+ modify $ \s -> s { _pkgCursor = max 0 (_pkgCursor s - 1) }+ vScrollBy (viewportScroll PkgList) (-1)+handlePkgEvent _ (VtyEvent (V.EvKey V.KDown _)) = do+ modify $ \s -> s { _pkgCursor = min (length (currentPkgs s) - 1) (_pkgCursor s + 1) }+ vScrollBy (viewportScroll PkgList) 1+handlePkgEvent _ (VtyEvent (V.EvKey (V.KChar ' ') _)) = do+ s <- get+ let pkgs = currentPkgs s+ idx = _pkgCursor s+ p = pkgs !! idx+ name = pkgName p+ if pkgDefault p+ then modify $ \st ->+ if Set.member name (_pkgToggled st)+ then st { _pkgToggled = Set.delete name (_pkgToggled st) }+ else st { _pkgToggled = Set.insert name (_pkgToggled st) }+ else modify $ \st ->+ if Set.member name (_pkgEnabled st)+ then st { _pkgEnabled = Set.delete name (_pkgEnabled st) }+ else st { _pkgEnabled = Set.insert name (_pkgEnabled st) }+handlePkgEvent _ (VtyEvent (V.EvKey (V.KChar 'v') _)) = do+ s <- get+ let lang = currentLang s+ isPipActive = lang == Python && any (\p -> pkgName p == "python3Packages.pip") (selectedPkgNames s)+ when isPipActive $+ modify $ \st -> st { _pythonVenv = not (_pythonVenv st) }+handlePkgEvent chan (VtyEvent (V.EvKey (V.KChar 's') _)) = do+ modify $ \s -> s { _screen = SearchScreen }+ s2 <- get+ let activePkgs = case _activeChannel s2 of+ Unstable -> _unstableCache s2+ Stable -> _stableCache s2+ case activePkgs of+ Nothing -> do+ modify $ \st -> st { _searchLoading = True, _searchError = Nothing }+ liftIO $ triggerCacheLoad chan (_stableVersion s2) (_activeChannel s2)+ Just _ -> return ()+handlePkgEvent _ (VtyEvent (V.EvKey V.KEnter _)) =+ modify $ \s -> s { _screen = DirScreen }+handlePkgEvent _ (VtyEvent (V.EvKey V.KEsc _)) =+ modify $ \s -> s { _screen = LangScreen }+handlePkgEvent _ (VtyEvent (V.EvKey (V.KChar 'q') _)) = halt+handlePkgEvent _ _ = return ()++-- Search screen+handleSearchEvent :: CuteChan -> BrickEvent Name CuteEvent -> EventM Name AppState ()+handleSearchEvent _ (VtyEvent (V.EvKey V.KEsc _)) =+ modify $ \s -> s { _screen = PkgScreen }+handleSearchEvent _ (VtyEvent (V.EvKey (V.KChar '\t') [])) =+ modify $ \s -> s { _screen = DirScreen }+handleSearchEvent _ (VtyEvent (V.EvKey V.KUp _)) = do+ modify $ \s -> s { _searchCursor = max 0 (_searchCursor s - 1) }+ vScrollBy (viewportScroll SearchList) (-1)+handleSearchEvent _ (VtyEvent (V.EvKey V.KDown _)) = do+ modify $ \s -> s { _searchCursor = min (length (_searchResults s) - 1) (_searchCursor s + 1) }+ vScrollBy (viewportScroll SearchList) 1+handleSearchEvent _ (VtyEvent (V.EvKey V.KEnter _)) = do+ s <- get+ let results = _searchResults s+ when (not (null results)) $ do+ let r = results !! _searchCursor s+ attr = nrAttr r+ isAdded = any (\(a, _) -> a == attr) (_extraAttrs s)+ if isAdded+ then modify $ \st -> st { _extraAttrs = filter (\(a, _) -> a /= attr) (_extraAttrs st) }+ else modify $ \st -> st { _extraAttrs = _extraAttrs st ++ [(attr, _activeChannel st)] }+handleSearchEvent chan (VtyEvent (V.EvKey (V.KChar 's') [V.MCtrl])) = do+ s <- get+ when (not (_cacheUpdating s)) $ do+ let nextChan = case _activeChannel s of+ Unstable -> Stable+ Stable -> Unstable+ modify $ \st -> st { _activeChannel = nextChan, _searchResults = [], _searchCursor = 0 }+ s2 <- get+ let activePkgs = case nextChan of+ Unstable -> _unstableCache s2+ Stable -> _stableCache s2+ case activePkgs of+ Nothing -> do+ modify $ \st -> st { _searchLoading = True, _searchError = Nothing }+ liftIO $ triggerCacheLoad chan (_stableVersion s2) nextChan+ Just pkgs -> do+ let q = concat (getEditContents (_searchEdit s2))+ results = searchLocal q pkgs+ modify $ \st -> st { _searchResults = results, _searchError = Nothing }+handleSearchEvent chan (VtyEvent (V.EvKey (V.KChar 'u') [V.MCtrl])) = do+ s <- get+ when (not (_cacheUpdating s)) $ do+ liftIO $ triggerCacheUpdate chan (_stableVersion s) (_activeChannel s)+handleSearchEvent _ ev = zoom searchEdit $ handleEditorEvent ev++-- Dir screen+handleDirEvent :: CuteChan -> BrickEvent Name CuteEvent -> EventM Name AppState ()+handleDirEvent _ (VtyEvent (V.EvKey V.KEsc _)) =+ modify $ \s -> s { _screen = PkgScreen }+handleDirEvent _ (VtyEvent (V.EvKey V.KEnter _)) = do+ s <- get+ let dir = targetDir s+ lang = currentLang s+ pkgs = selectedPkgNames s+ extra = _extraAttrs s+ cfg = GenerateConfig+ { gcLang = lang+ , gcPackages = pkgs+ , gcExtraAttrs = extra+ , gcTargetDir = if null dir then "." else dir+ , gcSystemArch = _arch s+ , gcPythonVenv = _pythonVenv s && (lang == Python) && any (\p -> pkgName p == "python3Packages.pip") pkgs+ , gcNixpkgsStable = _stableVersion s+ }+ result <- liftIO $ generateBoth cfg+ case result of+ Right msg -> modify $ \st -> st { _screen = DoneScreen, _resultMsg = msg }+ Left err -> modify $ \st -> st { _resultMsg = "✖ " ++ err, _screen = DoneScreen }+handleDirEvent _ ev = zoom dirEdit $ handleEditorEvent ev++-- Done screen+handleDoneEvent :: CuteChan -> BrickEvent Name CuteEvent -> EventM Name AppState ()+handleDoneEvent _ (VtyEvent (V.EvKey V.KEnter _)) = halt+handleDoneEvent _ (VtyEvent (V.EvKey (V.KChar 'q') _)) = halt+handleDoneEvent _ _ = return ()++-- Cache helpers++triggerCacheLoad :: CuteChan -> String -> Channel -> IO ()+triggerCacheLoad chan version c = void $ forkIO $ do+ res <- loadCache version c+ case res of+ Right pkgs -> writeBChan chan (CacheLoadDone c pkgs)+ Left _ -> writeBChan chan (CacheLoadFailed c "Cache not found. Press Ctrl+u to download.")++triggerCacheUpdate :: CuteChan -> String -> Channel -> IO ()+triggerCacheUpdate chan version c = void $ forkIO $ do+ writeBChan chan (CacheUpdateStarted c)+ res <- updateCache version c+ case res of+ Left err -> writeBChan chan (CacheUpdateFailed c err)+ Right _ -> do+ loadRes <- loadCache version c+ case loadRes of+ Right pkgs -> writeBChan chan (CacheUpdateDone c pkgs)+ Left err -> writeBChan chan (CacheUpdateFailed c ("Failed to load cache after download: " ++ err))++-- Initial state++initialState :: FilePath -> String -> String -> AppState+initialState cwd arch version = AppState+ { _screen = LangScreen+ , _langCursor = 0+ , _pkgCursor = 0+ , _pkgToggled = Set.empty+ , _pkgEnabled = Set.empty+ , _pythonVenv = True+ , _searchEdit = editor SearchEdit (Just 1) ""+ , _searchResults = []+ , _searchCursor = 0+ , _searchLoading = False+ , _searchError = Nothing+ , _extraAttrs = []+ , _dirEdit = editor DirEdit (Just 1) cwd+ , _resultMsg = ""+ , _arch = arch+ , _activeChannel = Unstable+ , _unstableCache = Nothing+ , _stableCache = Nothing+ , _cacheUpdating = False+ , _stableVersion = version+ }++-- BChan-aware search trigger++type CuteChan = BChan CuteEvent++appWithSearch :: CuteChan -> App AppState CuteEvent Name+appWithSearch chan = App+ { appDraw = drawUI+ , appChooseCursor = showFirstCursor+ , appHandleEvent = handleEventWithChan chan+ , appStartEvent = return ()+ , appAttrMap = const theMap+ }++handleEventWithChan :: CuteChan -> BrickEvent Name CuteEvent -> EventM Name AppState ()+handleEventWithChan chan ev = do+ s <- get+ -- Intercept search-screen typing to do local search+ case (_screen s, ev) of+ (SearchScreen, VtyEvent (V.EvKey k mods))+ | isTypingKey k mods -> do+ zoom searchEdit $ handleEditorEvent ev+ s2 <- get+ let q = concat (getEditContents (_searchEdit s2))+ activePkgs = case _activeChannel s2 of+ Unstable -> _unstableCache s2+ Stable -> _stableCache s2+ case activePkgs of+ Nothing -> modify $ \st -> st { _searchResults = [], _searchError = Just "Cache not loaded. Press Ctrl+u to download." }+ Just pkgs -> do+ let results = searchLocal q pkgs+ modify $ \st -> st { _searchResults = results, _searchError = Nothing, _searchCursor = 0 }+ _ -> handleEvent chan ev+ where+ isTypingKey (V.KChar _) [] = True+ isTypingKey V.KBS [] = True+ isTypingKey V.KDel [] = True+ isTypingKey _ _ = False++-- Entry point++runApp :: IO ()+runApp = do+ cwd <- getCurrentDirectory+ arch <- detectArch+ chan <- newBChan 10++ envStable <- lookupEnv "NIX_STABLE_VERSION"+ let stableVersion = maybe "nixos-25.11" id envStable++ let buildVty = VCP.mkVty V.defaultConfig+ initialVty <- buildVty++ void $ customMain initialVty buildVty (Just chan)+ (appWithSearch chan)+ (initialState cwd arch stableVersion)++detectArch :: IO String+detectArch = do+ res <- tryReadProcess "uname" ["-m"] ""+ case res of+ Left _ -> return "x86_64-linux"+ Right r ->+ let m = filter (/= '\n') r+ in return $ case m of+ "x86_64" -> "x86_64-linux"+ "aarch64" -> "aarch64-linux"+ _ -> "x86_64-linux"++tryReadProcess :: String -> [String] -> String -> IO (Either String String)+tryReadProcess cmd args inp = do+ res <- try @SomeException (readProcess cmd args inp)+ return $ case res of+ Left e -> Left (show e)+ Right r -> Right r++-- Utility++padStringRight :: Int -> String -> String+padStringRight n s+ | length s >= n = take n s+ | otherwise = s ++ replicate (n - length s) ' '++drawVenvRecommendation :: AppState -> Widget Name+drawVenvRecommendation s =+ let lang = currentLang s+ isPipActive = lang == Python && any (\p -> pkgName p == "python3Packages.pip") (selectedPkgNames s)+ in if isPipActive+ then vBox+ [ hBorder+ , padLeftRight 2 $ withAttr peach $ border $ vBox+ [ hCenter $ withAttr pink (str " Highly Recommended for Pip")+ , hCenter $ str "Using pip inside a Nix environment can cause externally-managed-environment errors."+ , hCenter $ str "We highly recommend auto-creating and activating a virtual environment."+ , str ""+ , hCenter $ hBox+ [ withAttr mint (str (if _pythonVenv s then "[✔] " else "[ ] "))+ , withAttr titleAttr (str "Auto-create & enter virtualenv in flake shellHook")+ , withAttr hintAttr (str " (Press 'v' to toggle)")+ ]+ ]+ ]+ else emptyWidget
+ test/Spec.hs view
@@ -0,0 +1,118 @@+module Main (main) where++import Test.QuickCheck+import System.Exit (exitFailure, exitSuccess)+import Control.Monad (unless)+import Data.List (isInfixOf, isPrefixOf)+import qualified Data.Map.Strict as Map++import Languages+import NixGen+import NixSearch (Channel(..))++-- Generate arbitrary Language+instance Arbitrary Language where+ arbitrary = arbitraryBoundedEnum++-- Generate arbitrary Channel+instance Arbitrary Channel where+ arbitrary = arbitraryBoundedEnum++-- Generate arbitrary LangPackage+instance Arbitrary LangPackage where+ arbitrary = do+ name <- listOf1 (elements ['a'..'z'])+ label <- listOf1 (elements ['a'..'z'])+ desc <- listOf (elements (['a'..'z'] ++ [' ']))+ def <- arbitrary+ return (LangPackage name label desc def)++-- Generate arbitrary GenerateConfig+instance Arbitrary GenerateConfig where+ arbitrary = do+ lang <- arbitrary+ pkgs <- listOf arbitrary+ extraAttrs <- listOf $ do+ attr <- listOf1 (elements ['a'..'z'])+ chan <- arbitrary+ return (attr, chan)+ targetDir <- listOf (elements (['a'..'z'] ++ ['/']))+ systemArch <- elements ["x86_64-linux", "aarch64-linux"]+ pythonVenv <- arbitrary+ nixpkgsStable <- elements ["nixos-25.11", "nixos-24.11"]+ return $ GenerateConfig+ { gcLang = lang+ , gcPackages = pkgs+ , gcExtraAttrs = extraAttrs+ , gcTargetDir = targetDir+ , gcSystemArch = systemArch+ , gcPythonVenv = pythonVenv+ , gcNixpkgsStable = nixpkgsStable+ }++-- Property: Generated flake always starts with a valid Nix brace layout+prop_validFlakeBraceLayout :: GenerateConfig -> Bool+prop_validFlakeBraceLayout cfg =+ let flake = generateFlake cfg+ in "{" `isPrefixOf` flake && "}" `isInfixOf` flake++-- Property: Package inclusion in flake nativeBuildInputs+prop_allPackagesIncluded :: GenerateConfig -> Bool+prop_allPackagesIncluded cfg =+ let flake = generateFlake cfg+ -- All unstable packages in gcPackages should be in nativeBuildInputs as pkgs.<name>+ unstablePkgs = [pkgName p | p <- gcPackages cfg] ++ [attr | (attr, Unstable) <- gcExtraAttrs cfg]+ -- All stable packages in gcExtraAttrs should be as pkgs-stable.<name>+ stablePkgs = [attr | (attr, Stable) <- gcExtraAttrs cfg]+ + checkUnstable = all (\p -> ("pkgs." ++ p) `isInfixOf` flake) unstablePkgs+ checkStable = all (\p -> ("pkgs-stable." ++ p) `isInfixOf` flake) stablePkgs+ in checkUnstable && checkStable++-- Property: Python virtual environment shellHook logic+prop_pythonVenvHook :: GenerateConfig -> Bool+prop_pythonVenvHook cfg =+ let flake = generateFlake cfg+ hasVenv = gcPythonVenv cfg+ in if hasVenv+ then "python3 -m venv .venv" `isInfixOf` flake && "source .venv/bin/activate" `isInfixOf` flake+ else not ("source .venv/bin/activate" `isInfixOf` flake)++-- Property: Correct language description in the header/description field+prop_correctDescription :: GenerateConfig -> Bool+prop_correctDescription cfg =+ let flake = generateFlake cfg+ expectedDesc = show (gcLang cfg) ++ " development environment"+ in expectedDesc `isInfixOf` flake++-- Property: .envrc is always correctly generated+prop_validEnvrc :: GenerateConfig -> Bool+prop_validEnvrc cfg =+ let envrc = generateEnvrc cfg+ in "use flake" `isInfixOf` envrc && "# Generated by cutesetup" `isInfixOf` envrc++runProperty :: Testable prop => String -> prop -> IO Bool+runProperty name prop = do+ putStrLn $ "Running property: " ++ name+ res <- quickCheckResult prop+ case res of+ Success {} -> return True+ _ -> return False++main :: IO ()+main = do+ putStrLn "=== Running Cutesetup Property Tests ==="+ r1 <- runProperty "valid flake brace layout" prop_validFlakeBraceLayout+ r2 <- runProperty "all packages included in inputs" prop_allPackagesIncluded+ r3 <- runProperty "python venv shellHook activation" prop_pythonVenvHook+ r4 <- runProperty "correct description field" prop_correctDescription+ r5 <- runProperty "valid envrc structure" prop_validEnvrc+ + let success = r1 && r2 && r3 && r4 && r5+ if success+ then do+ putStrLn "All property tests passed!"+ exitSuccess+ else do+ putStrLn "Some tests failed."+ exitFailure