├── .github └── workflows │ └── build-example.yml ├── .gitignore ├── README.md ├── default.nix ├── entry.nix ├── example.nix └── modules ├── common.nix ├── completions └── default.nix ├── default.nix └── generators ├── bash ├── common.nix ├── default.nix ├── derivation.nix └── validator.nix └── default.nix /.github/workflows/build-example.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: Build example 4 | 5 | # Controls when the workflow will run 6 | on: 7 | push: 8 | # Allows you to run this workflow manually from the Actions tab 9 | workflow_dispatch: 10 | 11 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 12 | jobs: 13 | # This workflow contains a single job called "build" 14 | build: 15 | # The type of runner that the job will run on 16 | runs-on: ubuntu-latest 17 | 18 | # Steps represent a sequence of tasks that will be executed as part of the job 19 | steps: 20 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 21 | - uses: actions/checkout@v3 22 | - uses: cachix/install-nix-action@v18 23 | with: 24 | nix_path: nixpkgs=channel:nixos-unstable 25 | - run: nix-build example.nix 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | result 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # climod 2 | 3 | [![built with nix](https://builtwithnix.org/badge.svg)](https://builtwithnix.org) 4 | 5 | Modular generated command line interfaces using the same technology as the NixOS module system. 6 | 7 | ## Objective 8 | What if you could generate CLI scripts with keyword parsing and environment variables with value validation and so on using a JSON like interface, but turing complete... 9 | 10 | Or even generate consistent CLIs for more than one programming language... (WIP, only bash atm). 11 | 12 | Maybe integrate in your own repo or automations. 13 | 14 | With this library and Nix now you can. See the [example](./example.nix) to get more details about how. If you nix-build it then it should generate a script on ./result that you can play with. 15 | 16 | 17 | # What works 18 | - Generation of bash scripts 19 | - Flag parsing 20 | - Subcommands 21 | - Prelude (setup code before anything but `set -eu` and shebang) 22 | - Your payload code just consume environment variables 23 | - Positional arguments. All input values are flag based so far. 24 | # What doesn't work 25 | - List of things. Each flag can only be provided once. 26 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? import {} 2 | }: 3 | pkgs.callPackage ./entry.nix {} 4 | -------------------------------------------------------------------------------- /entry.nix: -------------------------------------------------------------------------------- 1 | { lib 2 | , writeShellScript 3 | , pkgs 4 | }: 5 | config: 6 | let 7 | inherit (lib) types; 8 | module = lib.evalModules { 9 | modules = [ 10 | config 11 | ./modules 12 | ]; 13 | specialArgs = { 14 | inherit pkgs; 15 | }; 16 | }; 17 | out = module.config.target.bash.drv; 18 | in out.overrideAttrs (_: { 19 | passthru = { 20 | inherit (module) config options; 21 | inherit module; 22 | }; 23 | }) 24 | -------------------------------------------------------------------------------- /example.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? import {} }: 2 | let climod = pkgs.callPackage ./default.nix { inherit pkgs; }; 3 | in climod { 4 | name = "demo"; 5 | description = "Demo CLI generated"; 6 | action.bash = '' 7 | echo Hello, world 8 | echo $# 9 | while [ $# -gt 0 ]; do 10 | echo "$1" 11 | shift 12 | done 13 | ''; 14 | target.bash.prelude = '' 15 | echo "Starting..." 16 | ''; 17 | allowExtraArguments = true; 18 | subcommands.args.description = "Print args"; 19 | subcommands.args.allowExtraArguments = true; 20 | subcommands.args.action.bash = '' 21 | for line in "$@"; do 22 | echo $line 23 | done 24 | ''; 25 | subcommands.eoq.description = "Eoq subcommand"; 26 | subcommands.eoq.subcommands.greet.description = "Greets the user"; 27 | subcommands.eoq.subcommands.greet.action.bash = "echo Hello, $GREET_USER!"; 28 | subcommands.eoq.subcommands.greet.flags = [ 29 | { 30 | description = "User name"; 31 | keywords = ["-n" "--name" ]; 32 | variable = "GREET_USER"; 33 | } 34 | ]; 35 | } 36 | -------------------------------------------------------------------------------- /modules/common.nix: -------------------------------------------------------------------------------- 1 | { lib 2 | , config 3 | , ... 4 | }: 5 | let 6 | inherit (lib) types mkOption; 7 | inherit (types) str strMatching attrsOf listOf submodule nullOr nonEmptyListOf bool; 8 | flag = submodule ({config, ...}: { 9 | options = { 10 | keywords = mkOption { 11 | type = nonEmptyListOf (strMatching "-[a-zA-Z0-9]|-(-[a-z0-9]*)"); 12 | default = []; 13 | description = "Which keywords refer to this flag"; 14 | }; 15 | description = mkOption { 16 | type = str; 17 | default = ""; 18 | description = "Description of the flag value"; 19 | }; 20 | validator = mkOption { 21 | type = str; 22 | default = "any"; 23 | description = "Command to run passing the input to validate the flag value"; 24 | }; 25 | variable = mkOption { 26 | type = strMatching "[A-Z][A-Z_]*"; 27 | description = "Variable to store the result"; 28 | }; 29 | required = mkOption { 30 | type = bool; 31 | description = "Is the value required?"; 32 | default = false; 33 | }; 34 | }; 35 | }); 36 | command = { 37 | name = mkOption { 38 | type = strMatching "[a-zA-Z0-9_][a-zA-Z0-9_\\-]*"; 39 | default = "example"; 40 | description = "Name of the command shown on --help"; 41 | }; 42 | description = mkOption { 43 | type = str; 44 | default = "Example cli script generated with nix"; 45 | description = "Command description"; 46 | }; 47 | flags = mkOption { 48 | type = listOf flag; 49 | default = []; 50 | description = "Command flags"; 51 | }; 52 | subcommands = mkOption { 53 | type = attrsOf (submodule ({name, ...}: { 54 | options = command; 55 | config = { inherit name; }; 56 | })); 57 | default = {}; 58 | description = "Subcommands has all the attributes of commands, even subcommands..."; 59 | }; 60 | allowExtraArguments = mkOption { 61 | type = bool; 62 | default = false; 63 | description = "Allow the command to receive unmatched arguments"; 64 | }; 65 | action = mkOption { 66 | type = attrsOf str; 67 | default = { 68 | bash = "exit 0"; 69 | c = "exit(0);"; 70 | }; 71 | description = "Attr of the action code itself of the command or subcommand for each language that you want to support"; 72 | }; 73 | }; 74 | in { 75 | options = command; 76 | } 77 | -------------------------------------------------------------------------------- /modules/completions/default.nix: -------------------------------------------------------------------------------- 1 | { ... }: { 2 | # TODO: implement completions 3 | imports = [ 4 | ]; 5 | } 6 | -------------------------------------------------------------------------------- /modules/default.nix: -------------------------------------------------------------------------------- 1 | {config, ...}: 2 | { 3 | imports = [ 4 | ./common.nix 5 | ./generators 6 | ./completions 7 | ]; 8 | } 9 | -------------------------------------------------------------------------------- /modules/generators/bash/common.nix: -------------------------------------------------------------------------------- 1 | { lib, config, ... }: 2 | let 3 | inherit (lib) mkOption types optionalString mkIf; 4 | inherit (builtins) concatStringsSep length attrValues mapAttrs filter; 5 | in { 6 | options = { 7 | target.bash = { 8 | prelude = mkOption { 9 | type = types.lines; 10 | description = "Stuff that must be added before the argument parser"; 11 | default = ""; 12 | }; 13 | shebang = mkOption { 14 | type = types.str; 15 | description = "Script shebang"; 16 | default = "#!/usr/bin/env bash"; 17 | }; 18 | code = mkOption { 19 | type = types.lines; 20 | description = "Code output"; 21 | }; 22 | validators = mkOption { 23 | type = types.attrsOf types.str; 24 | description = "Parameter validators"; 25 | }; 26 | }; 27 | }; 28 | config = { 29 | target.bash.code = let 30 | buildHelp = cfg: let 31 | subcommandTree = cfg._subcommand; 32 | subcommandTree' = concatStringsSep " " subcommandTree; 33 | 34 | firstLine = "$(bold '${subcommandTree'}') ${cfg.description}"; 35 | firstLine' = "echo \"${firstLine}\""; 36 | subcommands = mapAttrs (k: v: v.description) cfg.subcommands; 37 | subcommands' = mapAttrs (k: v: '' 38 | printf "\t" 39 | printf "$(bold '${k}'): " 40 | echo '${v}' 41 | '') subcommands; 42 | subcommands'' = attrValues subcommands'; 43 | subcommands''' = concatStringsSep "\n" subcommands''; 44 | 45 | hasSubcommands = length (attrValues subcommands) > 0; 46 | 47 | flag2txt = flag: let 48 | keywordLine = flag.keywords; 49 | keywordLine' = concatStringsSep ", " keywordLine; 50 | in '' 51 | printf "\t" 52 | echo "$(bold '${keywordLine'}, ${flag.variable}') (${flag.validator}): ${flag.description}" 53 | ''; 54 | 55 | flags = cfg.flags; 56 | flags' = ['' 57 | printf "\t" 58 | echo "$(bold '-h, --help'): Show this help message" 59 | ''] ++ (map flag2txt flags); 60 | flags'' = concatStringsSep "\n" flags'; 61 | in '' 62 | ${firstLine'} 63 | ${optionalString hasSubcommands ''printf "\nSubcommands\n"''} 64 | ${subcommands'''} 65 | printf "\nFlags\n" 66 | ${flags''} 67 | exit 0 68 | ''; 69 | buildCommandTree = cfg: let 70 | help = buildHelp cfg; 71 | 72 | mkSubcommandHandler = k: v: let 73 | flags = cfg.flags ++ v.flags; 74 | _subcommand = cfg._subcommand ++ [ v.name ]; 75 | subcommandArgs = v // { inherit flags _subcommand; }; 76 | handler = buildCommandTree subcommandArgs; 77 | handler' = '' 78 | ${k}) 79 | shift 80 | ${handler} 81 | exit 0 82 | ;; 83 | ''; 84 | in handler'; 85 | 86 | mkFlagHandler = flag: 87 | let 88 | caseExpr = concatStringsSep " | " flag.keywords; 89 | varAttrExpr = ''${flag.variable}="$2"''; 90 | validateExprError = ''echo "flag '$flagkey' (${flag.variable}) doesn't pass the validation as a ${flag.validator}" ''; 91 | 92 | isBool = flag.validator == "bool"; 93 | validateExpr = optionalString (!isBool) ''validate_${flag.validator} "$2" || ${validateExprError}''; 94 | in '' 95 | ${caseExpr} ) 96 | ${optionalString isBool "export ${flag.variable}=1"} 97 | ${optionalString isBool "shift; continue"} 98 | 99 | if [ $# -eq 1 ]; then 100 | error "the flag '$flagkey' expects a value of type ${flag.validator} but found end of parameters" 101 | fi 102 | ${validateExpr} 103 | ${varAttrExpr} 104 | shift; shift 105 | break 106 | ;; 107 | ''; 108 | subcommands = cfg.subcommands; 109 | subcommands' = mapAttrs mkSubcommandHandler subcommands; 110 | subcommands'' = attrValues subcommands'; 111 | subcommands''' = concatStringsSep "\n" subcommands''; 112 | flags = cfg.flags; 113 | flags' = map mkFlagHandler flags; 114 | flags'' = concatStringsSep "\n" flags'; 115 | 116 | requiredFlags = filter (f: f.required) flags; 117 | requiredFlags' = map (f: f.variable) requiredFlags; 118 | requiredFlags'' = map (var: concatStringsSep "" [ 119 | ''echo "$'' var ''" > /dev/null # will fail if the variable is not defined'' 120 | ]) requiredFlags'; 121 | requiredFlags''' = concatStringsSep "\n" requiredFlags''; 122 | in '' 123 | if [ $# -gt 0 ]; then 124 | case "$1" in 125 | ${subcommands'''} 126 | esac 127 | fi 128 | ARGS=() 129 | while [ ! $# -eq 0 ]; do 130 | local flagkey="$1" 131 | 132 | case "$flagkey" in 133 | -h | --help) 134 | ${help} 135 | ;; 136 | ${flags''} 137 | *) 138 | ${if cfg.allowExtraArguments then '' 139 | ARGS+=("$1") 140 | shift 141 | '' else '' 142 | error "invalid keyword argument near '$flagkey'" 143 | ''} 144 | ;; 145 | esac 146 | done 147 | ${requiredFlags'''} 148 | function payload { 149 | ${cfg.action.bash} 150 | } 151 | payload "${"$"}{ARGS[@]}" 152 | exit 0 153 | ''; 154 | 155 | mkValidatorHandler = k: v: '' 156 | function validate_${k} { 157 | ${v} 158 | } 159 | ''; 160 | validators = config.target.bash.validators; 161 | validators' = mapAttrs mkValidatorHandler validators; 162 | validators'' = attrValues validators'; 163 | validators''' = concatStringsSep "\n" validators''; 164 | in '' 165 | ${config.target.bash.shebang} 166 | set -eu 167 | function error { 168 | echo "$(bold "error"): $@" >&2 169 | exit 1 170 | } 171 | function bold { 172 | if which tput >/dev/null 2>/dev/null; then 173 | printf "$(tput bold)$*$(tput sgr0)" 174 | else 175 | printf "$*" 176 | fi 177 | } 178 | ${config.target.bash.prelude} 179 | 180 | ${validators'''} 181 | 182 | function _main() { 183 | ${buildCommandTree (config // { 184 | _subcommand = [ config.name ]; 185 | })} 186 | } 187 | _main "$@" 188 | ''; 189 | }; 190 | } 191 | -------------------------------------------------------------------------------- /modules/generators/bash/default.nix: -------------------------------------------------------------------------------- 1 | {lib, config, ...}: { 2 | imports = [ 3 | ./validator.nix 4 | ./derivation.nix 5 | ./common.nix 6 | ]; 7 | } 8 | -------------------------------------------------------------------------------- /modules/generators/bash/derivation.nix: -------------------------------------------------------------------------------- 1 | {lib, config, pkgs, shellcheck, ...}: 2 | let 3 | inherit (lib) mkOption types; 4 | in 5 | { 6 | options = { 7 | target.bash.drv = mkOption { 8 | type = types.package; 9 | description = "Package using the shell script version as a binary"; 10 | }; 11 | }; 12 | config.target.bash.drv = pkgs.stdenv.mkDerivation { 13 | inherit (config) name; 14 | code = builtins.toFile "code" config.target.bash.code; 15 | 16 | dontUnpack = true; 17 | 18 | installPhase = '' 19 | mkdir $out/bin -p 20 | install -m 755 $code $out/bin/$name 21 | ''; 22 | 23 | checkInputs = [ shellcheck ]; 24 | checkPhase = '' 25 | shellcheck $out/bin/$name 26 | ''; 27 | meta.mainProgram = config.name; 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /modules/generators/bash/validator.nix: -------------------------------------------------------------------------------- 1 | { lib 2 | , ... 3 | }: 4 | let 5 | inherit (lib) types mkOption; 6 | inherit (types) str attrsOf submodule; 7 | in 8 | { 9 | config = { 10 | target.bash.validators = { 11 | any = "true"; 12 | fso = ''test -e "$1"''; 13 | file = ''test -f "$1"''; 14 | dir = ''test -d "$1"''; 15 | readable = ''test -r "$1"''; 16 | writable = ''test -w "$1"''; 17 | executable = ''test -x "$1"''; 18 | pipe = ''test -p "$1"''; 19 | socket = ''test -S "$1"''; 20 | not-empty = ''test -s "$1"''; 21 | int = ''echo $1 | grep -Eq "^-?[0-9]*$"''; 22 | float = ''echo $1 | grep -Eq "^-?[0-9]*.?[0-9]*$"''; 23 | }; 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /modules/generators/default.nix: -------------------------------------------------------------------------------- 1 | {...}: { 2 | imports = [ 3 | ./bash 4 | ]; 5 | } 6 | --------------------------------------------------------------------------------