CLI builder
This bash script generates a cli (command line interface) bash script from a definition file. The structure of the definition file is code header below. This script also generates an alias file which contains a set of alias commands which run each command in the definition file using the associated shortcut prepended with an '@'.
#!/usr/bin/env python3
"""CLI Builder - Generate bash CLI scripts from definition files"""
from datetime import datetime
import re, sys, os
from dataclasses import dataclass
from typing import List, Optional
C_CYA="\\x1b[96m"
C_GRE="\\x1b[92m"
C_MAG="\\x1b[95m"
C_WHI="\\x1b[97m"
C_DEF="\\x1b[0m"
HELP_TEXT = r"""
NAME
cli-builder - Generate CLI bash scripts from definition files.
USAGE
cli-builder [-d] <definition_filename>
OPTIONS
-d Run in debug mode
-h, --help Show this help
DESCRIPTION
This python script generates a cli (command line interface) bash script from a definition file.
The structure of the definition file is described below. This script also generates an alias
file which contains a set of alias commands which run each command in the definition file using
the associated shortcut prepended with an '@'.
Definition File Structure
-------------------------
# - a line starting with a # is a comment
= - a line starting with a = is a group description for the set of commands following this
line up until the next line starting with a = or the end of the file.
!! - commands following !! are command line completion commands. The command must return a list of
words which are used to complete the command. The command is run when the current or
previous word matches the command name or shortcut.
## - text following ## is a help description for the command. This text is shown when
the help command is run.
( \ - is the line continuation character. Any line ending \ is joined to the next line. )
All other lines in the file are command definitions. These lines are structured as follows:
name-1..name-n (shortcut) <param> [<opt_param] :: command \$1 \$2
Where:
name-1..name-n
A list of words which describe the command and which are typed to run the command.
shortcut
A single word which can also be typed to run the command. If the aliases are created
then the alias can also be run direct from the command as @alias.
param
A mandatory parameter. There can be 0 or more mandatory parameters.
opt_param
A optional parameter. There can me 0 or more optional parameters. The must always follow
the mandatory parameters and there can be no gaps. This means if optional parameter 3 is
passed in so must optional parameters 1 and 2.
::
Separates the command definition with the Linux command that is run.
command
Is an Linux command. Parameter values entered after the command are specified using there
position preceded by a \$. e.g \$1, \$2.
Example
sort_cli.def:
----------------------------------------------------------------
# FILE OPTIONS
sort file (sf) <filename> <sort_parameter> :: cat \$1 | sort \$2
----------------------------------------------------------------
This example sorts a file specified by a mandatory filename. An optional sort parameter can
be passed to modify the sort order. Once the cli is built the command can be run as follows.
Note: the name of the cli command is the same as the name of the definition file
(without the .def).
> sort_cli sort file my_file
> sort_cli sf my_file --ignore_case
> @sf my_file
AUTHOR
mjnurse - 2025
"""
@dataclass
class Cmd:
"""Represents a CLI command"""
keys: List[str]
shortcut: str
params: List[tuple[str, bool]] # (name, is_optional)
cmd: str
help: str
comp: str
@property
def all_keys(self): return ' '.join(self.keys)
@property
def num_mandatory(self): return sum(1 for _, opt in self.params if not opt)
@property
def usage_str(self):
params_str = ' '.join(f'[{p}]' if opt else f'<{p}>' for p, opt in self.params)
return f"{self.all_keys} ({self.shortcut}) {params_str}".strip()
@property
def opt_params(self):
return [p for p, opt in self.params if opt]
def parse_def(lines, title, cli_name):
"""Parse definition file into commands and groups"""
group = ""
cmds = []
title_str = ""
if title:
title_str = f'''
echo -e "{C_GRE}{"-"*len(title)}{C_DEF}"
echo -e "{C_GRE}{title}{C_DEF}"
echo -e "{C_GRE}{"-"*len(title)}{C_DEF}"
'''
title_str += f'''
echo -e "{C_GRE}gen:{datetime.now().strftime('%Y-%m-%d %H:%M')}{C_DEF}"
echo
'''
cmds.append(('cmd', Cmd(
keys=['help'],
shortcut=cli_name[0] + 'he',
params=[],
cmd=title_str + f'''
while IFS= read -r line; do echo -e "${{line}}${{CRESET}}"; done < <(egrep "usage=|section=" "$0" | grep -v "grep" | sed "s/.*usage=/ /; s/.*section=/\\x1b[92m/; s/\\"//g")''',
help='',
comp=''
), 'HELP'))
for line in (l.strip() for l in lines):
# Skip comments and empty lines
if not line or line.startswith('#'):
continue
# Group description
if line.startswith('='):
group = line[2:]
continue
# Direct command insertion
if line.startswith('cmd '):
cmds.append(('raw', line[4:]))
continue
# Command definition
if '::' not in line:
continue
# Extract parts
help_txt = re.search(r'##\s*(.+)', line)
comp_cmd = re.search(r'!!\s*([^#]+)', line)
line = re.sub(r'##.*|!!.*', '', line)
defn, cmd = (s.strip() for s in line.split('::', 1))
if cmd == '':
print(f'Warning: Command missing for definition: {defn}\n')
# Parse command definition
tokens = re.findall(r'\w+|\([^)]+\)|<[^>]+>|\[[^\]]+\]', defn)
keys, params, shortcut = [], [], ''
for tok in tokens:
if tok.startswith('('):
shortcut = cli_name[0] + tok.strip('()')
elif tok.startswith('['):
params.append((tok.strip('[]<>'), True))
elif tok.startswith('<'):
params.append((tok.strip('<>'), False))
else:
keys.append(tok)
# Auto-generate shortcut if not provided
if not shortcut:
shortcut = cli_name[0] + ''.join(k[0] for k in keys[:2])
cmds.append(('cmd', Cmd(
keys=keys,
shortcut=shortcut,
params=params,
cmd=cmd,
help=help_txt.group(1) if help_txt else '',
comp=comp_cmd.group(1) if comp_cmd else ''
), group))
return cmds
def gen_script(cli_name, cmds, debug=False):
"""Generate bash CLI script"""
script = f'''#!/usr/bin/env bash
debug_yn=n
[[ "$1" == "-d" ]] && {{ debug_yn=y; shift; }}
[[ "${{CLI_DEBUG^^}}" == "TRUE" ]] && debug_yn=y
C_CYA="{C_CYA}" C_GRE="{C_GRE}" C_MAG="{C_MAG}" C_WHI="{C_WHI}" C_DEF="{C_DEF}"
# param 1 - actual number of parameters
# param 2 - required number of parameters
# param 3 - incorrect parameters message
check_params() {{
[[ "$1" < "$2" ]] && {{ echo -e "$3"; exit; }}
}}
print_command() {{
[[ $debug_yn == y ]] && {{ echo "COMMAND: $*" | sed 's/"/\\"/g'; echo "COMMAND: $*" | sed 's/./-/g'; }}
}}
'''
aliases, comp_opts = [], []
current_group = ""
for item in cmds:
if item[0] == 'raw':
script += item[1] + '\n'
continue
cmd, group = item[1], item[2]
# Add group header if changed
if group != current_group:
script += f'section="{group}"\n'
current_group = group
# Generate command check
keys_template = ' '.join(f'${i+1}' for i in range(len(cmd.keys)))
usage = f"{C_MAG}{cmd.all_keys} {C_CYA}({cmd.shortcut}){C_WHI}"
for p, opt in cmd.params:
if opt:
usage += f" [{p}]"
else:
usage += f" <{p}>"
if cmd.help:
usage += f"{C_GRE} # {cmd.help}"
usage += f"{C_DEF}"
script += f'''
if [[ "{keys_template}" == "{cmd.all_keys}" || "$1" == "{cmd.shortcut}" ]]; then
[[ "$1" == "{cmd.shortcut}" ]] && shift || shift {len(cmd.keys)}
usage="{usage}"
check_params $# {cmd.num_mandatory} "Usage: $usage"
'''
if cmd.all_keys not in ['h', 'help']:
escaped_cmd = cmd.cmd.replace('"', '\\"')
script += f''' print_command " {escaped_cmd}"\n'''
script += f' {cmd.cmd}\n exit\nfi\n'
# Add alias
aliases.append(f"alias @{cmd.shortcut}='{cli_name} {cmd.shortcut}'")
# Add completion
if cmd.comp:
comp_opts.append(f'''
if [[ "$all" == "{cmd.all_keys}" || "$prev" == "{cmd.shortcut}" || "$prev" == "@{cmd.shortcut}" ]]; then
COMPREPLY=( $(compgen -W "$({cmd.comp})" -- "$cur") )
fi''')
# Add help command
script += f'''
if [[ "$1" == "" ]]; then
echo "No option passed"
else
echo "$*: invalid option"
fi
echo "Try \"{cli_name} help\" for more information."
'''
return script, aliases, comp_opts
def gen_alias_file(cli_name, aliases, comp_opts):
"""Generate alias and completion file"""
alias_names = ' '.join(a.split('=')[0][6:] for a in aliases)
return f'''# Completion function
_{cli_name}_complete() {{
local cur prev all
all=""
for ((i = 1; i < ${{#COMP_WORDS[@]}}; i++)); do
word="${{COMP_WORDS[i]}}"
[[ $word != -* ]] && all+="$word "
done
all="$(echo $all | xargs)"
cur="${{COMP_WORDS[COMP_CWORD]}}"
prev_step=1
prev="${{COMP_WORDS[COMP_CWORD-$prev_step]}}"
while [[ "${{prev:0:1}}" == "-" ]]; do
let prev_step=prev_step+1
prev="${{COMP_WORDS[COMP_CWORD-$prev_step]}}"
done
{''.join(comp_opts)}
}}
complete -F _{cli_name}_complete {cli_name} {alias_names}
# Shortcut aliases
{chr(10).join(aliases)}
'''
def gen_markdown_file(cli_name, cmds, title):
"""Generate markdown documentation file"""
md_content = f"# {title if title else cli_name.upper()}\n\n"
md_content += f"*Generated: {datetime.now().strftime('%Y-%m-%d %H:%M')}*\n\n"
current_group = ""
for item in cmds:
if item[0] == 'raw':
continue
cmd, group = item[1], item[2]
# Add group header if changed
if group != current_group and group:
md_content += f"\n## {group}\n\n"
current_group = group
elif group != current_group and not group:
current_group = group
# Format command usage
usage = f"{cmd.all_keys} ({cmd.shortcut})"
for p, opt in cmd.params:
if opt:
usage += f" [{p}]"
else:
usage += f" <{p}>"
# Add command entry
md_content += f"**`{usage}`**"
if cmd.help:
md_content += f" - {cmd.help}"
md_content += "\n\n"
return md_content
def main():
debug = '-d' in sys.argv
if debug:
sys.argv.remove('-d')
if len(sys.argv) < 2 or sys.argv[1] in ['-h', '--help']:
print(HELP_TEXT)
return
print(f"CLI Builder - Generating CLI scripts\n")
print(f"Processing: {', '.join(sys.argv[1:])}\n")
for arg in sys.argv[1:]:
def_file = arg.replace('.def', '')
if not os.path.exists(f'{def_file}.def'):
print(f'Error: Definition file "{def_file}.def" missing')
return
cli_name = def_file
# Parse definition file
with open(f'{def_file}.def') as f:
lines = [l.rstrip() for l in f]
# Handle line continuations, extract title
i = 0
title = ""
while i < len(lines):
if lines[i].startswith('@ '):
title = lines[i][2:].strip()
del lines[i]
continue
if lines[i].endswith('\\'):
lines[i] = lines[i][:-1] + lines[i+1].lstrip()
del lines[i+1]
else:
i += 1
cmds = parse_def(lines, title,cli_name)
if debug:
print("Commands")
print("--------")
for item in cmds:
if item[0] == 'cmd':
print(f"- {item[1].all_keys} ({item[1].shortcut})")
# Generate files
script, aliases, comp_opts = gen_script(cli_name, cmds, debug)
alias_content = gen_alias_file(cli_name, aliases, comp_opts)
markdown_content = gen_markdown_file(cli_name, cmds, title)
with open(cli_name, 'w') as f:
f.write(script)
os.chmod(cli_name, 0o755)
with open(f'{cli_name}.alias', 'w') as f:
f.write(alias_content)
with open(f'{cli_name}.md', 'w') as f:
f.write(markdown_content)
print(f"Generated {cli_name}, {cli_name}.alias, and {cli_name}.md")
if __name__ == '__main__':
main()