Jarvis · Chapter 11

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.

Goal

Reorganise into a proper package, add command-line arguments, and install it so you can type `jarvis` from anywhere.

about 30 minutes

Why 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> 
🧭 Making it work outside the virtual environment

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

✅ Checkpoint

jarvis --help prints usage. jarvis what time is it? answers and exits. Plain jarvis opens an interactive session, from any folder on your machine.

+150 XP