Skip to content

Latest commit

 

History

History
106 lines (87 loc) · 6.06 KB

File metadata and controls

106 lines (87 loc) · 6.06 KB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

What this is

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.

Commands

# 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/*.py

There 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.

Architecture

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) or suggest(path, config) and an optional HANDLES ("file"/"dir"/"both"). Return values are normalized into Suggestion objects (_normalize), sorted by Confidence (LOW/MEDIUM/HIGH/CERTAIN), de-duplicated, and a CERTAIN suggestion short-circuits remaining suggesters unless --ignore-certain is passed. A bad suggester script raises inside gather_suggestions and is caught per-script - one broken script degrades to an error message rather than crashing the run. See README.md for 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.