The full code is available as a link in the end
Intro
In this post I would like to show you how to build a simple app that manages directories with aliases.
What I mean by that? An app that will do the following
- Set aliases on a given directory
- Navigate to that directory by calling the alias name
- Delete or update aliases
- Set workdir as a central goto point
- Set bash autocompletion for our fun app
When this is done you we will be able to set aliases like this
[user@hostname]$ cd /some/pathA
[user@hostname pathA]$ goto set aliasNameA
[user@hostname pathA]$ cd /some/other/path
[user@hostname pathB]$ goto set aliasNameB
[user@hostname pathB]$ goto aliasNameA
[user@hostname pathA]$
Also we will be able to set a workdir that we can use as a relative point of movement
[user@hostname]$ ls /some/path
dirA dirB dirC someFile ...
[user@hostname]$ goto set-pdir /some/path
[user@hostname]$ goto dirA
[user@hostname dirA]$ goto dirB
[user@hostname dirB]$
We will implement this in two sections.
- Build the core app that will manage our aliases state
- Create a bash script that will use the core app
I am using python since it is faster to develop with and it is a standard tool used by linux engineers, and hence python as a dependency is not an issue (for the majority of cases at least)
Core App for working with our state
The state app will be responsible for the following
- Write/Read/Update/Delete aliases to disk
- Export aliase information back to the caller
- List aliases
First create goto directory
mkdir goto
Let us create a file called state.py and start with our main class GotoState
#!/usr/bin/env python3
from os import path
class GotoState(LoadConfig):
def __init__(self):
self.rootDir = path.dirname(path.abspath(__file__))
self.stateEnvFile = self.rootDir + '/.aliases.yml'
if __name__ == "__main__":
gotoState = GotoState()
Inside init we are exporting the rootDir with value the absolute path to the goto workdir. The reason we did that is because we want stateEnvFile to be relative to the state.py file and not relative to the invokation’s path.
Next we will add the code for creating and updating the .aliases.yml file. But before we do that, let us first install pyyaml which will be the only dependency for this app (not considering python3, pip and bash)
Create a local directory called modules
mkdir modules
Install pyyaml in modules
pip install --target=modules/ pyyaml
pip3 freeze | grep 'PyYAML==' > requirements.txt
cat > setup.sh <<EOF
pip install -r requirements.txt --target=modules/
EOF
Update state.py with the following code
#!/usr/bin/env python3
from sys import path as syspath
syspath.insert(0, 'modules')
from sys import argv, stdout
from os import path
import yaml
class LoadConfig():
def read_conf(self, docFile):
stateFile = docFile
with open(stateFile, 'r') as _STREAM:
try:
_CFG = yaml.safe_load(_STREAM)
except yaml.YAMLError as _EXC:
raise
return _CFG
def update_conf(self, docFile, docCont):
stateFile = docFile
with open(stateFile, 'w') as file:
yaml.dump(docCont, file)
class GotoState(LoadConfig):
def __init__(self):
self.rootDir = path.dirname(path.abspath(__file__))
self.stateEnvFile = self.rootDir + '/.aliases.yml'
if path.isfile(self.stateEnvFile):
self.stateEnv = self.read_conf(docFile=self.stateEnvFile)
else:
self.stateEnv = {
'aliases': {},
'workdir': self.rootDir,
}
self.update_conf(
docFile=self.stateEnvFile,
docCont=self.stateEnv
)
if __name__ == "__main__":
gotoState = GotoState()
- Note on lines (2 - 3) we are pushing to syspath.index 0 our local modules
- On lines (11 - 25) we define the classfunction that will read and update our state file
- On lines (33 - 43) we simply check if the file exists, in cases it does not we create it and populate it with a directory
Now that we have the code to create and update our state file we can add some methods to work with it
First we will add the add method for inserting new items in the state file
Append to the GotoState class the following code
def check_paths(self, p, **kwargs):
if p.endswith("/") and len(p) > 1:
p = p[:len(p)-1]
if kwargs["opts"].get("show", False):
print(p)
else:
return p
def add(self, alias, p):
cpath = self.check_paths(p, opts={})
self.stateEnv["aliases"][alias] = cpath
self.update_conf(
docFile=self.stateEnvFile,
docCont=self.stateEnv
)
Initially we define check_paths method which simply trims any trailing / from the path. The condition is there because the method will also be called from the caller sometimes, therefore we need to send a result back.
Then we define the add method which simple calls check_paths and then updates the state file with the gievn path
To test this, first add the following in the name == main section at the end
if __name__ == "__main__":
gotoState = GotoState()
if argv[1] == "add":
gotoState.add(argv[2], argv[3])
elif argv[1] == "check_paths":
gotoState.check_paths(argv[2], opts={"show": True})
We could start developing argparse but I think it is too much for this app since
- it will have few methods
- it will only be called from the caller script
Let’s test add
python state.py add root /
cat .aliases.yml
aliases:
root: /
workdir: /home/user/test
Next we will add the list_aliases method
def list_aliases(self):
aliases = ["%s::::%s" % (x, self.stateEnv["aliases"][x]) for x in self.stateEnv["aliases"]]
print(",".join(aliases))
The reason I decided to join the alias name and its path with :::: is in order to make the caller able to split the string and easily derive the name and path. There is no special perpuse for selecting ::::, I just did.
Also add the respective if condition under main
if __name__ == "__main__":
gotoState = GotoState()
if argv[1] == "add":
gotoState.add(argv[2], argv[3])
elif argv[1] == "check_paths":
gotoState.check_paths(argv[2], opts={"show": True})
elif argv[1] == "list":
gotoState.list_aliases()
Testing list
python state.py list
root::::/
Next we add the rm method for removing aliases from the state file
Method Code:
def rm(self, alias):
if self.stateEnv["aliases"].get(alias):
self.stateEnv["aliases"].pop(alias)
self.update_conf(
docFile=self.stateEnvFile,
docCont=self.stateEnv
)
else:
print("Alias: %s does not exist" % alias)
Respective main argv condition
if __name__ == "__main__":
gotoState = GotoState()
if argv[1] == "add":
gotoState.add(argv[2], argv[3])
elif argv[1] == "check_paths":
gotoState.check_paths(argv[2], opts={"show": True})
elif argv[1] == "list":
gotoState.list_aliases()
elif argv[1] == "rm":
gotoState.rm(argv[2])
Next we add the check and get methods
- check: Checks if an alias exists
- get: returns back to the caller the alias path
Methods Code:
def get(self, alias):
print(self.stateEnv["aliases"].get(alias, None))
def check(self, alias):
get_alias = self.stateEnv["aliases"].get(alias, None)
if get_alias:
print("%s::::%s" % (True, get_alias))
else:
print("%s::::%s" % (False, get_alias))
Here again I am adding :::: as a separator for the caller
Respective main argv condition
if __name__ == "__main__":
gotoState = GotoState()
if argv[1] == "add":
gotoState.add(argv[2], argv[3])
elif argv[1] == "check_paths":
gotoState.check_paths(argv[2], opts={"show": True})
elif argv[1] == "list":
gotoState.list_aliases()
elif argv[1] == "rm":
gotoState.rm(argv[2])
elif argv[1] == "get":
gotoState.get(argv[2])
elif argv[1] == "check":
gotoState.check(argv[2])
Testing rm check and get
python3 state.py check root
True::::/
python3 state.py get root
/
python3 state.py rm root
python state.py list
Finally we will add the last two methods set_workdir and get_workdir. At the begining we claimed that we will be able to set a project dir and navigate through its directories freely.
Methods Code:
def set_workdir(self, path):
cpath = self.check_paths(path, opts={})
self.stateEnv["workdir"] = cpath
self.update_conf(
docFile=self.stateEnvFile,
docCont=self.stateEnv
)
def get_workdir(self):
workdir = self.stateEnv.get("workdir", None)
if workdir == None:
self.set_workdir(self.rootDir)
print(self.rootDir)
else:
print(workdir)
Respective main argv condition
if __name__ == "__main__":
gotoState = GotoState()
if argv[1] == "add":
gotoState.add(argv[2], argv[3])
elif argv[1] == "check_paths":
gotoState.check_paths(argv[2], opts={"show": True})
elif argv[1] == "list":
gotoState.list_aliases()
elif argv[1] == "rm":
gotoState.rm(argv[2])
elif argv[1] == "get":
gotoState.get(argv[2])
elif argv[1] == "check":
gotoState.check(argv[2])
elif argv[1] == "set_workdir":
gotoState.set_workdir(argv[2])
elif argv[1] == "get_workdir":
gotoState.get_workdir()
We will not test these for now because they do not have a value right now without the caller script. For convenience the whole code of state.py is here State.py Code
We next move to the caller script
The Goto script
The goto.sh script will do the following
- Define the goto core code for calling state.py
- Define a help menu
- Define bash auto completion
without futher ado, let us create goto.sh and add the following code
#!/usr/bin/env bash
## Get project's directory
__GOTO_SOURCE="${BASH_SOURCE[0]}"
while [ -h "${__GOTO_SOURCE}" ]; do
__GOTO_DIR="$( cd -P "$( dirname "${__GOTO_SOURCE}" )" >/dev/null 2>&1 && pwd )"
__GOTO_SOURCE="$(readlink "${__GOTO_SOURCE}")"
[[ ${__GOTO_SOURCE} != /* ]] && __GOTO_SOURCE="${__GOTO_DIR}/${__GOTO_SOURCE}"
done
__GOTO_WORKDIR="$( cd -P "$( dirname "${__GOTO_SOURCE}" )" >/dev/null 2>&1 && pwd )"
__GOTO_SCRIPT_NAME=$(basename "${BASH_SOURCE[0]}")
## Function for printing multiple strings
function goto_user_info() {
for m in "${@}"; do
echo "${m}"
done
}
The code before the function is some code I found in the past somewhere in the web which exports the absolute pathe to the goto.sh script following symlinks also.
Sadly I do not remember the name of the fellow to give him credits. But to explain it a bit further, without symlinks, the code would have looked like this
__GOTO_DIR="$( cd -P "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
Regarding the goto_user_info function, I added it because I will be able later on to print a list of strings without having to type that for loop
Next I am adding the whole goto functionality without the bash auto completion
## Project's direcctory
## Default: ${__GOTO_WORKDIR}. To change this, issue goto set-pdir /some/path
export _goto_workdir="$(python3 ${__GOTO_WORKDIR}/state.py get_workdir)"
## Simple help menu
function __goto_help() {
echo -e "Goto -- help menu\n"
echo -e " alias\t\tChange directory to the given alias name"
echo -e " set <alias>\tSet an alias to this location"
echo -e " rm <alias>\tRemove alias from list"
echo -e " list\t\tList saved aliases"
echo -e " show-projects\tGet projects"
echo -e " project\tNavigate to an available project"
echo -e " set-pdir\tSet a new project path"
echo -e " help\t\tShow this message and exit"
echo -e "\n"
}
## _goto cd function
function _goto() {
is_alias="$(python3 ${__GOTO_WORKDIR}/state.py check ${1})"
if [[ "${is_alias%%::::*}" == "True" ]]; then
cd "${is_alias##*::::}"
else
subpath="$(python3 "${__GOTO_WORKDIR}/state.py" "check_paths" "${1}")"
if [[ ! -d "${_goto_projectdir}/${subpath}" ]]; then
echo "Error: ${subpath} is not a directory or it does not exist"
return 1
fi
echo "Changing directory to --- ${_goto_workdir}/${subpath}"
cd "${_goto_projectdir}/${subpath}"
fi
}
## goto routine
function goto() {
if [[ "${#}" -eq "0" ]]; then
__goto_help
return 0
fi
while [[ "${#}" -gt "0" ]]; do
case "${1}" in
"alias")
if [[ -z "${2}" ]]; then
echo "You need to give an alias name"
return 1
fi
echo "Changing directory alias --- ${2}"
cd "$(python3 "${__GOTO_WORKDIR}/state.py" "alias" "${2}")"
shift 2;;
"set")
if [[ -z "${2}" ]]; then
echo "You need to give an alias name"
return 1
fi
python3 "${__GOTO_WORKDIR}/state.py" "add" "${2}" "${PWD}"
shift 2;;
"list")
aliases="$(python3 ${__GOTO_WORKDIR}/state.py list)"
echo "Aliases"
_IFS="${IFS}"
IFS=","
for i in ${aliases}; do
echo -e " ${i%%::::*} @ ${i##*::::}"
done
IFS="${_IFS}"
shift 1
return 0;;
"rm")
python3 "${__GOTO_WORKDIR}/state.py" "rm" "${2}"
shift 2;;
"show-projects")
echo "Projects"
goto_user_info $(ls "${_goto_projectdir}")
shift 1;;
"set-pdir")
if [[ -z "${2}" ]]; then
echo "What dir?"
return 1
fi
python3 "${__GOTO_WORKDIR}/state.py" "set_workdir" "${2}"
export _goto_projectdir="$(python3 ${__GOTO_WORKDIR}/state.py get_workdir)"
shift 2;;
"project")
if [[ -z "${2}" ]]; then
echo "Which project?"
return 1
fi
_goto "${2}"
shift 2;;
"help")
__goto_help
return 0;;
*)
_goto "${@}"
return 0;;
esac
done
}
Ok so, lines (2) defines the project dir which I am exporting as global
Note: Please do not confuse the application’s workdir (the path where I am writing the code right now) with the project’s work dir. which will be the directory that will be used by goto to freely navigate through its child directories without having to add the parents prefix
Lines (5 - 16) a simple help menu
Lines (19 - 32) the _goto function which will run a cd $1
The _goto function
- checks if $1 is an alias
- if it is an alias, then it executes cd
- if not, it expects that it is a child dir of project. It then does a simple check and exec cd if the dir exists
Lines (34 - 100) a simple bash argparse
We can go on and check the goto app now but I will also add the completion first since this post is getting way big and I do not want to add more optional lines (and I am getting tired typing :P)
Bash Autocompletion
Ok so the autocompleter will help us have:
- The sweet :tab:tab suggestion that we all like
- Autocomplete our commands
- Autocomplete dynamically any alias that is being add
- Autocomplete dynamically child dirs that will be added in the project
The ugly code
_goto_completions_dir() {
local cur prev
cur=${COMP_WORDS[COMP_CWORD]}
prev=${COMP_WORDS[COMP_CWORD-1]}
aliases="$(python3 ${__GOTO_WORKDIR}/state.py list)"
calianses=""
_IFS="${IFS}"
IFS=","
for i in ${aliases}; do
calianses="${calianses} ${i%%::::*}"
done
IFS="${_IFS}"
case ${COMP_CWORD} in
1)
COMPREPLY=($(compgen -W "alias set list rm project show-projects set-pdir help ${calianses}" -- ${cur}))
;;
2)
case ${prev} in
"project" | "show-projects")
for i in $(ls "${_goto_projectdir}"); do
if [[ -d "${_goto_projectdir}/${i}" ]]; then
COMPREPLY+=($(compgen -W "${i}" -- "${cur}"))
fi
done;;
"rm" | "alias")
aliases="$(python3 ${__GOTO_WORKDIR}/state.py list)"
_IFS="${IFS}"
IFS=","
for i in ${aliases}; do
COMPREPLY+=($(compgen -W "${i%%::::*}" -- "${cur}"))
done
IFS="${_IFS}";;
esac;;
*)
COMPREPLY=();;
esac
}
complete -F _goto_completions_dir goto
For convenience the whole code of goto.sh is here Goto.sh Code
Installing goto
First get the code
git clone https://github.com/ulfox/goto.git
Get dependenceis
cd goto
bash setup.sh
The installation is very trivial, since all we have to do is add in our ..bashrc. the following line
If we assume you installed moved the goto app under /opt/goto, then
source /opt/goto/goto/goto.sh
Once you add the above line in your .bashrc do a hot env reload
source ~/.bashrc
Check that goto has been installed succsessfully
[user@hostname]$ goto
Goto -- help menu
alias Change directory to the given alias name
set <alias> Set an alias to this location
rm <alias> Remove alias from list
list List saved aliases
show-projects Get projects
project Navigate to an available project
set-pdir Set a new project path
help Show this message and exit
Goto Showcase
Set some aliases
[user@hostname]$ pwd
[user@hostname]$ /home/user
[user@hostname]$ goto set h
[user@hostname]$ goto list
Aliases
h @ /home/user
[user@hostname]$ mkdir -vp path-1/subpath-{1,2/subpath-1,3}
mkdir: created directory 'path-1'
mkdir: created directory 'path-1/subpath-1'
mkdir: created directory 'path-1/subpath-2'
mkdir: created directory 'path-1/subpath-2/subpath-1'
mkdir: created directory 'path-1/subpath-3'
[user@hostname]$ cd path-1
[user@hostname path-1]$ goto set p1
[user@hostname path-1]$ cd subpath-1
[user@hostname subpath-1]$ goto set p1s1
[user@hostname subpath-1]$ cd ../subpath-2/subpath-1
[user@hostname subpath-1]$ goto set p1s2s1
[user@hostname subpath-1]$ goto list
Aliases
h @ /home/user
p1 @ /home/user/path-1
p1s2s1 @ /home/user/path-1/subpath2/subpath-1
p1s1 @ /home/user/path-1/subpath-1
Start navigating with aliases
[user@hostname subpath-1]$ goto p1
[user@hostname path-1]$ goto p1s2s1
[user@hostname subpath-1]$ goto p1s1
[user@hostname subpath-1]$
Alias autocompletion
[user@hostname subpath-1]$ goto alias :tab:tab
h p1 p1s2s1 p1s1
[user@hostname subpath-1]$ goto alias p1s :tab:tab
h p1 p1s2s1 p1s1
[user@hostname subpath-1]$ goto alias p1
[user@hostname path-1]$
Alias removal
[user@hostname path-1]$ goto rm :tab:tab
h p1 p1s2s1 p1s1
[user@hostname subpath-1]$ goto rm p1s2s1
I will create some testing directories to display the project dir
[user@hostname subpath-1]$ goto h
[user@hostname]$ mkdir -vp path-2/subpath-{1,2}
mkdir: created directory 'path-2'
mkdir: created directory 'path-2/subpath-1'
mkdir: created directory 'path-2/subpath-2'
[user@hostname]$ goto set-pdir /home/user/path-2 ## this needs to be absolute sadly
[user@hostname]$ goto show-projects :tab:tab ## this is a project list, it will not cd to that child
subpath-1 subpath-2
[user@hostname subpath-1]$ goto subpath-1
Changing directory to --- /home/user/path-2/subpath-1 ## indication that this was not an alias
[user@hostname subpath-1]$ pwd
/home/user/path-2/subpath-1
[user@hostname subpath-1]$ mkdir -p ../subpath-2/a/b/c/d
[user@hostname subpath-1]$ goto subpath-2/a/b/c/d
Changing directory to --- /home/user/path-2/subpath-2/a/b/c/d
[user@hostname d]$ goto project :tab:tab ## project child dirs autocompletion (this will cd to the selected project)
subpath-1 subpath-2
Full code repo Goto
I hope you enjoyed reading (plese report any bugs & typos)