From Script to Real Program 📦
Everything works, but it is one long file you run with python3 chat.py from one specific folder. This chapter turns it into software: something with a shape, settings, arguments and a name.
Reorganise into a proper package, add command-line arguments, and install it so you can type `jarvis` from anywhere.
about 30 minutesWhy bother
A single file is genuinely fine at 100 lines. Yours is heading past 300, and it mixes four separate concerns: settings, memory, tools and the conversation loop. Splitting them means you can change how memory works without reading the tool code, and it is the difference between a script you wrote and a program you maintain.
Also, honestly: typing jarvis instead of
python3 ~/projects/jarvis/chat.py is the moment it stops feeling like an
exercise.
The shape
jarvis/
├── .venv/ (not committed)
├── .gitignore
├── persona.txt how it behaves, editable without code
├── notes/ your documents (not committed)
├── sessions/ saved conversations (not committed)
├── pyproject.toml how to install it
└── jarvis/
├── __init__.py
├── __main__.py so `python -m jarvis` works
├── config.py settings in one place
├── memory.py load, save, trim
├── tools.py the registry and your tools
└── chat.py the loop that ties it together
Four modules, each with one job. Lesson 20 covers imports and packages if the
__init__.py is unfamiliar.
Settings in one place
Constants scattered through a program are a slow-motion bug. Gather them:
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class Config:
"""Every setting in one place, with sensible defaults.
frozen=True makes it immutable: nothing deep in the program can
quietly change your spending cap halfway through a session.
"""
model: str = "claude-haiku-4-5"
max_tokens: int = 1000
keep_pairs: int = 10
daily_limit_usd: float = 0.50
max_tool_rounds: int = 5
notes_dir: str = "notes"
@classmethod
def from_env(cls):
"""Let environment variables override any default."""
return cls(
model=os.environ.get("JARVIS_MODEL", cls.model),
max_tokens=int(os.environ.get("JARVIS_MAX_TOKENS", cls.max_tokens)),
keep_pairs=int(os.environ.get("JARVIS_KEEP_PAIRS", cls.keep_pairs)),
daily_limit_usd=float(os.environ.get("JARVIS_DAILY_LIMIT", cls.daily_limit_usd)),
)
cfg = Config()
print("model :", cfg.model)
print("daily limit:", cfg.daily_limit_usd)
try:
cfg.daily_limit_usd = 999.0
except Exception as exc:
print("cannot be changed at runtime:", type(exc).__name__)
model : claude-haiku-4-5
daily limit: 0.5
cannot be changed at runtime: FrozenInstanceError
frozen=True is doing something specific: it makes the config immutable, so
no function deep in the call stack can quietly raise your own spending cap. Settings you
can change from anywhere are settings you cannot reason about.
from_env means you can try a bigger model for one session without editing
anything:
JARVIS_MODEL=claude-sonnet-5 jarvis
Command-line arguments
argparse is in the standard library and gives you a real interface,
including --help, for about ten lines:
import argparse
def parse_args(argv=None):
parser = argparse.ArgumentParser(
prog="jarvis",
description="A personal assistant that runs on your own machine.",
)
parser.add_argument("question", nargs="*",
help="Ask one question and exit. Omit for an interactive session.")
parser.add_argument("--model", help="Override the model for this run.")
parser.add_argument("--fresh", action="store_true",
help="Start with empty memory, ignoring saved history.")
parser.add_argument("--no-notes", action="store_true",
help="Do not search your notes for this run.")
return parser.parse_args(argv)
# one-shot mode
a = parse_args(["what", "time", "is", "it?"])
print("question:", " ".join(a.question))
print("interactive:", not a.question)
# interactive with overrides
b = parse_args(["--fresh", "--model", "claude-sonnet-5"])
print("fresh:", b.fresh, "| model:", b.model, "| interactive:", not b.question)
question: what time is it?
interactive: False
fresh: True | model: claude-sonnet-5 | interactive: True
The nargs="*" on question is what enables both modes: words
after the command mean "answer this and exit", nothing means "open an interactive
session". One-shot mode is the one you will actually use most, because it lets you ask a
quick question without leaving what you were doing.
Installing it as a command
A small pyproject.toml is all it takes:
[project]
name = "jarvis"
version = "0.1.0"
description = "A personal assistant that runs on my own machine"
requires-python = ">=3.10"
dependencies = ["anthropic"]
[project.scripts]
jarvis = "jarvis.chat:main"
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
The important part is [project.scripts]. It says: make a command called
jarvis that runs the main function in jarvis/chat.py.
Install it in editable mode so your edits take effect immediately:
pip install -e .
Now, from any folder, with the virtual environment active:
$ jarvis what is the capital of Peru?
Lima.
$ jarvis
Jarvis ready. Ctrl-C to leave.
you>
The jarvis command only exists while .venv is active, which
is a bit annoying for something you want constantly. The clean fix is
pipx, which installs a
command into its own isolated environment and puts it on your PATH permanently:
pipx install -e . from the project folder. That is how most Python
command-line tools are meant to be installed.
Where the files should actually live
One wrinkle you will hit immediately: if notes/ and
sessions/ are relative paths, they resolve against wherever you happen to be
standing when you run the command. Ask a question from your Documents folder and it looks
for notes there.
Fix it by anchoring to your home directory rather than the working directory:
Path.home() / ".jarvis" / "notes". That is the convention almost every
command-line tool follows, and it means jarvis behaves identically from
anywhere.
If it went wrong
ModuleNotFoundError: No module named 'jarvis'You ranpip install -e .from the wrong folder. It must be the one containingpyproject.toml.jarvis: command not foundThe virtual environment is not active, or the install failed. Check withpip show jarvis.- It cannot find your notes any more Exactly the relative-path
problem above. Anchor to
Path.home().
jarvis --help prints usage. jarvis what time is it? answers and exits. Plain jarvis opens an interactive session, from any folder on your machine.