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)