name: add-python-command description: Add or update a subcommand on one of ishlib's Python CLI tools (ishfiles, isholate, or any future tool). Use when the user asks to "add a new command", "add a subcommand", "wire up ishfiles/isholate with a new command", or "update the subcommand". Covers both new subcommands and edits to existing ones. Enforces the project's argparse/subparser rule from CLAUDE.md. argument-hint: "TOOL COMMAND-NAME # e.g. isholate restart"
Add or update a Python CLI subcommand
Every CLI in src/pyishlib/ uses argparse subcommands — never flag-based
dispatch. This skill walks through the four-way update every new or changed
subcommand needs.
Before you start: read the relevant CLI's current cli.py and one
nearby subcommand implementation so the new one matches style. Also
skim the "Python CLI Tools" section of CLAUDE.md.
Where each tool lives
| Tool | Parser | Subcommand implementations |
|---|---|---|
ishfiles | ishfiles/cli.py | One module per command in ishfiles/commands/<name>.py. |
isholate | isholate/cli.py | Functions in isholate/container.py (or a sibling). |
Other (future) Python CLIs follow the same split.
The four-way update
A subcommand is always four things kept in sync. Land them in the same commit.
1. Implementation
- ishfiles: create
src/pyishlib/ishfiles/commands/<name>.pywithregister(subparsers)andrun(cfg)functions.registerattaches the subparser;runtakes theIshConfigobject and returnsint. Read paths/constants fromcfg, never from module-level imports. - isholate: add a named function in
container.py(or a sibling module if it's large). It takes explicit kwargs — notargparse.Namespace— so it's easy to unit-test. - Output goes through
log = logging.getLogger(__name__). Only a command's product output (a diff, a table, a path) may useprint(). See the Logging section inCLAUDE.md.
2. Parser wiring in cli.py
- In
build_parser(), add a subparser viasub.add_parser("<name>", help="...", description="..."). - Attach the common
-v/-qflags via the shared helper (e.g._add_common_args(p)). Do not attach them to the top-level parser — argparse subparser defaults would clobber top-level values. Theparents=[common]pattern has the same bug and must not be used for-v/-q. - Add subcommand-specific flags with full
help=...text. - Use
parser.add_mutually_exclusive_group()for mutex flag sets. - For passthrough command args, use
nargs=argparse.REMAINDERas the last positional on the subparser (not the top-level parser).
3. Dispatch in main()
Match args.subcommand and call the implementation with explicit kwargs:
if args.subcommand == "<name>":
return <implementation>(
username,
option=args.option,
...
)
Don't pass the whole args namespace unless the implementation genuinely
needs most of it (the run subcommand in isholate is the one exception —
it forwards a lot of state to launch_and_exec).
4. Tests in pytest/python/test_<tool>.py
Hermetic environment.
pytest/conftest.pyreplacesos.environwholesale at session start, keeping onlyPATH/HOME/TMPDIRfrom the host and injecting fixed git identity and/dev/nullgit config vars. Subprocesses spawned withoutenv=automatically inherit this clean environment — no per-test env setup needed. To override a var for a specific test, buildenv = os.environ.copy()and extend it. SeeCLAUDE.md §Hermetic subprocess environment.
At minimum:
- Parser defaults — a test that
parse_args(["<name>"])sets the expected defaults. - Each flag — one test per non-trivial flag confirming it parses.
- Mutex groups — a test that conflicting flags raise
SystemExit. - Dispatch — a test that
cli_main(["<name>", ...])calls the implementation with the right kwargs (useunittest.mock.patchon thecli.py-level name, e.g.pyishlib.isholate.cli.<impl>). - Doesn't dispatch other things — when useful, assert that unrelated
implementations (
launch_and_exec, etc.) are NOT called.
Run just the affected test module while iterating:
pytest pytest/python/test_<tool>.py -q
Updating an existing subcommand
Same four-way update. Treat flag renames, removed flags, changed defaults,
and changed dispatch kwargs as CLI-breaking: migrate the tests in the same
commit. Don't leave dead --old-name aliases as compat shims unless the
user asks for them explicitly.
Breaking CLI changes
Call out the break in the commit message so users who pull the change can update their invocations.
Quick checklist
- Implementation module/function with docstring
- Subparser in
build_parser()withhelp=anddescription= - Common args via shared helper (NOT on top-level parser)
- Dispatch branch in
main() - Parser-shape tests (defaults + each flag)
- Mutex-group tests (if any mutex flags)
- Dispatch test with mocked implementation
- README / docs swept for old invocation shapes (if renaming)
-
pytest pytest/python/test_<tool>.pypasses -
ruff check src/andruff format --check src/clean