This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
org-assistant is an interactive TUI (Python + curses) for triaging files/folders out
of an "inbox" directory into the filesystem, with pluggable "suggester" scripts that
propose destinations and an optional decision-tree flow for manual sorting. Packaged with
pyproject.toml; runtime deps are platformdirs (config-folder resolution) and, on
Windows only, windows-curses. Send2Trash is an optional extra (.[trash]) for the
trash-delete action only.
# Install (editable) - gives you the `org-assistant` command
python -m venv .venv && source .venv/bin/activate
pip install -e ".[trash]" # drop ".[trash]" to skip the Send2Trash extra
# Or run uninstalled straight from a checkout
python -m org_assistant ~/inbox
# Run the app (config folder resolves -c/--config-folder > ./.org-assistant > OS default;
# copy cfgs/example to whichever location applies to bootstrap it)
org-assistant ~/inbox
org-assistant ~/inbox -c cfgs/work # use a different config folder
# Diagnostics / non-interactive
org-assistant -ls # list loaded suggester scripts + load errors
org-assistant -s # summarize manual moves from the logs
org-assistant ~/inbox --ignore-certain # run all suggesters, ignoring CERTAIN short-circuit
# Tests (stdlib unittest, no deps)
python -m unittest discover -s tests -t .
python -m unittest tests.test_decision # one module
python -m unittest tests.test_decision.DecisionTreeTests.test_x # one test
# Syntax-check without running
python -m py_compile org_assistant/*.pyThere is no build step and no linter configured. The ui.py curses tests are skipped
automatically where curses isn't available (e.g. Windows without windows-curses). CI
(.github/workflows/tests.yml) runs the suite on Python 3.11–3.13 on every push/PR.
Config folders are the unit of configuration, not global state. Everything for one
inbox setup - config.toml, suggesters/, decision_tree.toml, ignored.json,
logs/ - lives together under a single directory, located anywhere on disk.
resolve_config_folder() (org_assistant/config.py) picks which one in priority order:
explicit -c/--config-folder > ./.org-assistant in the cwd > the OS default
(platformdirs.user_config_dir, e.g. ~/.config/org-assistant on Linux). Config.load()
resolves that folder into a Config dataclass (with a used_os_default flag cli.py
uses to print the path when it wasn't explicit or cwd-local) that every other module reads
from - there is no other source of settings. cfgs/example in the repo is only a tracked
template to copy from; it plays no role in resolution.
Strict separation between pure logic and curses. actionlog.py, actions.py,
config.py, decision.py, fileinfo.py, and suggesters.py are all pure data/IO with
no curses import, which is why they're unit-testable without a terminal. ui.py is the
only module that touches curses, and cli.py's main() imports it lazily so that non-UI
commands (-ls, -s) never need a terminal at all.
The suggester plugin system (suggesters.py) dynamically loads every *.py file in
a config folder's suggesters/ directory via importlib.util, alphabetically by
filename (hence the 10_, 20_, 30_ prefix convention in cfgs/example/suggesters/
- load/run order is filename order). Each script defines
suggest(path)orsuggest(path, config)and an optionalHANDLES("file"/"dir"/"both"). Return values are normalized intoSuggestionobjects (_normalize), sorted byConfidence(LOW/MEDIUM/HIGH/CERTAIN), de-duplicated, and aCERTAINsuggestion short-circuits remaining suggesters unless--ignore-certainis passed. A bad suggester script raises insidegather_suggestionsand is caught per-script - one broken script degrades to an error message rather than crashing the run. SeeREADME.mdfor the full suggester return-value contract (str / tuple / dict forms) - it's the plugin API contract, not an implementation detail.
The decision tree (decision.py) is a separate, independent way to reach a
destination - a TOML-defined graph of Nodes (a question + Choices), each choice
either goto-ing another node or terminating at a target folder that opens the manual
folder browser. load_tree() validates the whole graph up front (undefined root,
dangling gotos, choices needing exactly one of goto/target) and returns errors
rather than raising, so a broken decision_tree.toml degrades to "feature disabled +
error shown on the startup screen" instead of crashing.
ui.py drives one screen at a time over an entry (decide_entry), building a single
navigable option list out of: suggester suggestions + manual browse + help (decision
tree) + skip + delete + ignore. Folders can be "descended into" (_process_directory,
called recursively) to organize their contents item-by-item instead of moving the whole
folder - Backspace ascends back out. This recursion, plus the main per-root loop
(_main_loop), is the actual control flow of the app; cli.py's main() is just
argument parsing and wiring dependencies together before calling ui.run().
Two independent action paths converge on actions.py and actionlog.py. Both
suggester-driven moves and manual/decision-tree moves end up calling the same
move_entry/send_to_trash primitives, but are logged separately (logs/manual.jsonl
vs logs/suggester.jsonl) via ActionLog, keyed by the via string
("manual"/"help"/"suggester:<name>"). This split is intentional: -s summarizes
only the manual log, on the premise that recurring manual patterns are candidates for new
suggester scripts (this is the intended feedback loop between using the tool and writing
suggesters).
Confidence is an IntEnum (Confidence in suggesters.py) - comparisons and
sorting rely on its integer ordering (CERTAIN = 4 is highest), so treat it as ordered
data, not just labels, when touching suggestion-sorting code.