Virtual Assistant Version 0.01

Build a Tiny Command-Line Virtual Assistant in Python (Step-by-Step)

Want a lightweight virtual assistant you can run in the terminal and extend with your own commands? In this post we’ll build a clean, single-file assistant called va.py.

We’ll start from a bare read a command, evaluate the comment, print the response and the loop.

then layer on a command system, persistence (notes & todos), helpful built-ins, and a tiny intent guesser.

You’ll end up with:

  • a va> prompt (the loop)

  • a decorator-based command registry (easy to add commands)

  • persistent notes & todos stored at ~/.va_state.json

  • help, history, clear, exit, and more

  • a simple keyword-based intent fallback

Requires: Python 3.9+ (tested on 3.10/3.11).
Run with: python va.py


1) Start

We begin with a loop that reads a line, splits it into a command and arguments, and exits cleanly on Ctrl+C / Ctrl+D.

#!/usr/bin/env python3
import shlex, sys

BANNER = """\
Command-line Virtual Assistant
Type 'help' to see commands. Ctrl+C or 'exit' to quit.
"""

def main():
    print(BANNER)
    while True:                # The loop
        try:
            line = input("va> ").strip()        # Get an input
        except (EOFError, KeyboardInterrupt):
            print()
            sys.exit(0)                         # exit the loop
        if not line:
            continue
        try:
            parts = shlex.split(line)           # Basic command parsing
        except ValueError as e:
            print(f"Parse error: {e}")
            continue
        cmd, *args = parts
        print(f"(debug) command={cmd} args={args}")    # temp print command

if __name__ == "__main__":
    main()
  • shlex.split handles quoted strings correctly.

  • We’ll replace the (debug) print with our real command handler next.


2) Add a Command Registry (with a Decorator)

We want commands like help, note, todo to be easy to add. A tiny registry + decorator does the trick.

COMMANDS = {}
ALIASES = {}

def command(name, *, aliases=(), help=""):
    def wrap(func):
        COMMANDS[name] = {"fn": func, "help": help.strip()}
        for a in aliases:
            ALIASES[a] = name
        return func
    return wrap

def resolve(cmd):
    if cmd in COMMANDS:
        return cmd
    if cmd in ALIASES:
        return ALIASES[cmd]
    return None

Update the main loop to use it:

cmd_token, *args = parts
cmd = resolve(cmd_token)
if not cmd:
    print(f"Unknown command: {cmd_token}. Try 'help'.")
    continue
COMMANDS[cmd]["fn"](args)

Now we can define commands like:

@command("say", help="say <text> — repeat your text")
def _say(args):
    if not args:
        print("Usage: say <text>")
        return
    print(" ".join(args))

3) Pretty Output & Time Helpers

A couple of niceties you’ll use everywhere.

import datetime as dt, textwrap

def now():
    return dt.datetime.now()

def echo_box(s: str):
    w = 72
    lines = textwrap.wrap(s, width=w) if "\n" not in s else s.splitlines()
    print("┌" + "─"*w + "┐")
    for line in lines:
        print("│" + line.ljust(w) + "│")
    print("└" + "─"*w + "┘")

Add a time command:

@command("time", aliases=("date",), help="time — show current date/time")
def _time(args):
    print(now().strftime("%Y-%m-%d %H:%M:%S"))

4) Persistence: Save Notes & Todos to Disk

We’ll keep state as JSON in the current working directory as the file .va_state.json.

import json, os
from pathlib import Path

from pathlib import Path
STATE_PATH = Path.cwd() / ".va_state.json"

def load_state():
    if STATE_PATH.exists():
        try:
            return json.loads(STATE_PATH.read_text())
        except Exception:
            pass
    return {"notes": [], "todos": [], "created": now().isoformat()}

def save_state(state):
    STATE_PATH.write_text(json.dumps(state, indent=2))

STATE = load_state()

5) Implement note and todo

Notes

This creates a note and saves it to a file located in the virtual assistants current working directory. This can also list notes that have been saved.
@command("note", aliases=("addnote","n"), help="""
note <text> — save a quick note
note list     — list notes
""")
def _note(args):
    if not args:
        print("Usage: note <text> | note list")
        return
    if args[0] == "list":
        if not STATE["notes"]:
            print("(no notes)")
        else:
            for i, n in enumerate(STATE["notes"], 1):
                print(f"{i}. {n['when']} — {n['text']}")
        return
    text = " ".join(args)
    STATE["notes"].append({"when": now().isoformat(timespec="seconds"), "text": text})
    save_state(STATE)
    print("Saved note.")

Todos

This creates a todo list and saves it to a file located in the virtual assistants current working directory. This can also list items that are on the todo list.
@command("todo", aliases=("t","task"), help="""
todo add <text>     — add a todo
todo list           — list todos
todo done <number>  — mark done (by number)
""")
def _todo(args):
    if not args or args[0] not in ("add","list","done"):
        print("Usage:\n  todo add <text>\n  todo list\n  todo done <number>")
        return
    sub = args[0]
    if sub == "add":
        text = " ".join(args[1:]).strip()
        if not text:
            print("Provide a task description.")
            return
        STATE["todos"].append({"text": text, "done": False,
                               "created": now().isoformat(timespec="seconds")})
        save_state(STATE)
        print("Added.")
    elif sub == "list":
        if not STATE["todos"]:
            print("(no todos)")
            return
        for i, t in enumerate(STATE["todos"], 1):
            box = "[x]" if t["done"] else "[ ]"
            print(f"{i:2}. {box} {t['text']}")
    elif sub == "done":
        if len(args) < 2 or not args[1].isdigit():
            print("Provide the todo number to mark done.")
            return
        idx = int(args[1]) - 1
        if idx < 0 or idx >= len(STATE["todos"]):
            print("Invalid number.")
            return
        STATE["todos"][idx]["done"] = True
        save_state(STATE)
        print("Marked done.")

6) Help, History, Clear, Exit

A few essentials for a pleasant CLI, we can add history of commands made, a clear the screen function, exit command and also a help feature to list all the commands that can be made.

HISTORY = []

@command("help", aliases=("?",), help="""
help [command] — show help (for all commands or a specific one)
""")
def _help(args):
    if args:
        cmd = resolve(args[0])
        if not cmd:
            print(f"No such command: {args[0]}")
            return
        h = COMMANDS[cmd]["help"] or "(no help text)"
        echo_box(f"{cmd}\n\n{h}")
        return
    echo_box("Commands:")
    names = sorted(COMMANDS)
    width = max(len(n) for n in names)
    for n in names:
        h = COMMANDS[n]["help"].splitlines()[0] if COMMANDS[n]["help"] else ""
        print(f"  {n.ljust(width)}  {h}")

@command("history", help="history — show recent commands")
def _history(args):
    for i, line in enumerate(HISTORY[-50:], 1):
        print(f"{i:2}: {line}")

@command("clear", help="clear — clear the screen")
def _clear(args):
    os.system("cls" if os.name == "nt" else "clear")

@command("exit", aliases=("quit","q"), help="exit — leave the assistant")
def _exit(args):
    print("Bye!")
    sys.exit(0)

And record input lines in the main loop:

HISTORY.append(line)

7) A Tiny Intent Fallback (Keyword Guess)

If the user types something like “remember to buy milk”, we can guess they meant note.

INTENT_MAP = [
    ({"note","remember","jot"}, "note"),
    ({"task","todo","remind"}, "todo"),
    ({"time","date","clock"}, "time"),
    ({"repeat","say","echo"}, "say"),
    ({"help"}, "help"),
]

def guess_intent(tokens):
    words = set(w.lower() for w in tokens)
    for keys, cmd in INTENT_MAP:
        if keys & words:
            return cmd
    return None

Wire it into the main loop where we previously handled unknown commands:

cmd = resolve(cmd_token)
if not cmd:
    guess = guess_intent(parts)
    if guess:
        cmd = guess
        # pass through args, lightly cleaned
        args = [a for a in args if a.lower() not in ("please","me","a","the")]
        if cmd == "note" and not args:
            args = [" ".join(parts)]  # treat whole line as the note
    else:
        print(f"Unknown command: {cmd_token}. Try 'help'.")
        continue

8) Final va.py (Full Listing)

Try putting this all together yourself or check it out on my GitHub

9) Try It Out

python va.py
va> note Buy 74LS258 chips
va> note list
va> todo add Finish 6502 Part 3 post
va> todo list
va> time
va> help todo
va> exit

10) Where to Go Next

  • New commands: add with @command("name", aliases=(...), help="...").

  • Timers/alarms: schedule in-process timers (warn: they end when you exit).

  • Plugin loader: import commands from a plugins/ folder.

  • Search: add a search command to find text across notes/todos.

  • Better NLP: replace the keyword intent with a small rules engine.

This is a basic example of what can be done and I hope to expand upon it in future and add more functionality and document this process along the way. 

Comments

Popular posts from this blog

Math Behind Logic Gates

6502 - Part 2 Reset and Clock Circuit

Building a 6502 NOP Test