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.splithandles 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
@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
@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)
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
searchcommand 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
Post a Comment