Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
580d952
feat: Optional password protection and MCP server for Claude Desktop
wesm Dec 28, 2025
676bda4
feat(mcp): Add categorization tools for Amazon workflow
wesm Dec 28, 2025
154a1dd
feat(mcp): Add security features and unit tests
wesm Dec 28, 2025
24e68f5
fix(mcp): Handle encrypted credentials gracefully
wesm Dec 28, 2025
914a565
fix(mcp): Address code review findings
wesm Jan 15, 2026
8001242
fix(ui): Fix CSS parse error in credential setup screen
wesm Jan 15, 2026
a58b14a
test: Add TUI e2e workflow tests for CSS validation and screen mounting
wesm Jan 15, 2026
cf67723
fix(css): Replace $text-muted with $primary in credential screen border
wesm Jan 15, 2026
356cfdd
feat(ui): Add percentage column to aggregate views
wesm Jan 19, 2026
d7e213e
fix(tests): Address code review findings in TUI workflow tests
wesm Jan 19, 2026
48ee6eb
fix(security): Address Review #1312 findings
wesm Jan 19, 2026
fde3c5c
fix(security): Address Review #1318 findings
wesm Jan 19, 2026
2cd5ece
fix(security): Address Review #1322 findings
wesm Jan 19, 2026
76aba26
Add LLM changelog script
wesm Jan 21, 2026
663aa04
fix(nix): Add mcp package and dependencies to flake.nix
wesm Jan 21, 2026
21efe4b
fix(nix): Correct SHA256 hashes for wheel packages
wesm Jan 21, 2026
d368458
fix(cache): Fix edits not persisting to cache in filtered view mode
wesm Jan 21, 2026
7285b2b
fix(ci): Fix ruff formatting and nix flake typing-inspection URL
wesm Jan 21, 2026
7da5fb0
fix(cache): Address Review #1580 findings - prevent data loss
wesm Jan 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 121 additions & 34 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,128 @@
pkgs = nixpkgs.legacyPackages.${system};
python = pkgs.python311;
pythonPackages = python.pkgs;

# Custom packages not in nixpkgs
oathtool = pythonPackages.buildPythonPackage rec {
pname = "oathtool";
version = "2.3.1";
pyproject = true;

src = pythonPackages.fetchPypi {
inherit pname version;
hash = "sha256-DfP22b9/cShz/fFETzPNWKa9W2h+0Eolar14OTrPLCU=";
};

build-system = with pythonPackages; [ setuptools setuptools-scm ];
dependencies = with pythonPackages; [ autocommand path ];
};

ynab = pythonPackages.buildPythonPackage rec {
pname = "ynab";
version = "1.9.0";
format = "wheel";

src = pkgs.fetchurl {
url = "https://files.pythonhosted.org/packages/b2/9c/0ccd11bcdf7522fcb2823fcd7ffbb48e3164d72caaf3f920c7b068347175/ynab-1.9.0-py3-none-any.whl";
hash = "sha256-cqwCGWBbQoAUloTs0P7DvXXZOHctZc3uqbPmahsvRw0=";
};

dependencies = with pythonPackages; [
urllib3
python-dateutil
pydantic
typing-extensions
certifi
];
};

httpx-sse = pythonPackages.buildPythonPackage rec {
pname = "httpx-sse";
version = "0.4.0";
format = "wheel";

src = pkgs.fetchurl {
url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl";
hash = "sha256-8ymvbq5X6qK9/ZYrQlJHZK9oB16oc3Ci3pIK9TQeMY8=";
};

dependencies = with pythonPackages; [ httpx ];
};

pydantic-settings = pythonPackages.buildPythonPackage rec {
pname = "pydantic-settings";
version = "2.7.1";
format = "wheel";

src = pkgs.fetchurl {
url = "https://files.pythonhosted.org/packages/b4/46/93416fdae86d40879714f72956ac14df9c7b76f7d41a4d68aa9f71a0028b/pydantic_settings-2.7.1-py3-none-any.whl";
hash = "sha256-WQvp5uJNBtszpCYoKe3vaCUA7wCFZalpxz051fi/s/0=";
};

dependencies = with pythonPackages; [ pydantic python-dotenv ];
};

sse-starlette = pythonPackages.buildPythonPackage rec {
pname = "sse-starlette";
version = "2.2.1";
format = "wheel";

src = pkgs.fetchurl {
url = "https://files.pythonhosted.org/packages/d9/e0/5b8bd393f27f4a62461c5cf2479c75a2cc2ffa330976f9f00f5f6e4f50eb/sse_starlette-2.2.1-py3-none-any.whl";
hash = "sha256-ZBCj07oMiednXUwnOjAdZGScA6XvHKEB8QtH+JX9Dpk=";
};

dependencies = with pythonPackages; [ starlette anyio ];
};

typing-inspection = pythonPackages.buildPythonPackage rec {
pname = "typing-inspection";
version = "0.4.0";
format = "wheel";

src = pkgs.fetchurl {
url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl";
hash = "sha256-UOclWfzSpjZ6Gfen5hDmr8ufrJQMZQKQ7tiT1hOGgy8=";
};

dependencies = with pythonPackages; [ typing-extensions ];
};

mcp = pythonPackages.buildPythonPackage rec {
pname = "mcp";
version = "1.25.0";
format = "wheel";

src = pkgs.fetchurl {
url = "https://files.pythonhosted.org/packages/e2/fc/6dc7659c2ae5ddf280477011f4213a74f806862856b796ef08f028e664bf/mcp-1.25.0-py3-none-any.whl";
hash = "sha256-s3w4FEpmat0IYmFMx57Cdul9cqqMom1iKBjU4ni5cho=";
};

dependencies = with pythonPackages; [
anyio
httpx
jsonschema
pydantic
pyjwt
starlette
typing-extensions
uvicorn
python-multipart
python-dotenv
typer
] ++ [
httpx-sse
pydantic-settings
sse-starlette
typing-inspection
];
};
in
{
packages = {
default = pythonPackages.buildPythonApplication {
pname = "moneyflow";
version = "0.5.3";
version = "0.8.1";
format = "pyproject";

src = ./.;
Expand All @@ -35,39 +151,10 @@
textual
cryptography
python-dateutil
# oathtool - pure Python TOTP generator (not in nixpkgs)
(buildPythonPackage rec {
pname = "oathtool";
version = "2.3.1";
pyproject = true;

src = fetchPypi {
inherit pname version;
hash = "sha256-DfP22b9/cShz/fFETzPNWKa9W2h+0Eolar14OTrPLCU=";
};

build-system = [ setuptools setuptools-scm ];
dependencies = [ autocommand path ];
})
# ynab - YNAB API client (not in nixpkgs) - using pre-built wheel
(buildPythonPackage rec {
pname = "ynab";
version = "1.9.0";
format = "wheel";

src = pkgs.fetchurl {
url = "https://files.pythonhosted.org/packages/b2/9c/0ccd11bcdf7522fcb2823fcd7ffbb48e3164d72caaf3f920c7b068347175/ynab-1.9.0-py3-none-any.whl";
hash = "sha256-cqwCGWBbQoAUloTs0P7DvXXZOHctZc3uqbPmahsvRw0=";
};

dependencies = [
urllib3
python-dateutil
pydantic
typing-extensions
certifi
];
})
] ++ [
oathtool
ynab
mcp
];

# Skip tests during build (can be run separately)
Expand Down
30 changes: 23 additions & 7 deletions moneyflow/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,8 +304,7 @@ async def on_mount(self) -> None:
self.query_one("#loading", LoadingIndicator).display = False
self.query_one("#loading-status", Static).display = False

# Attempt to use saved session or show login prompt
# Must run in a worker to use push_screen with wait_for_dismiss
# Start data initialization in a worker
self.run_worker(self.initialize_data(), exclusive=True)
except Exception as e:
# Try to show error to user
Expand Down Expand Up @@ -361,9 +360,10 @@ def _initialize_managers(
backend_type=backend_type,
)

# Initialize cache manager (only if encryption key available)
# Backends like Amazon don't have encryption keys and don't need caching
if self.cache_path is not None and self.encryption_key is not None:
# Initialize cache manager for backends that support caching
# cache_path is None for backends like Amazon that don't need caching
# encryption_key can be None for plaintext credentials (CacheManager supports both modes)
if self.cache_path is not None:
# Determine cache directory
if self.cache_path == "":
# Default cache location - use profile-specific or legacy location
Expand Down Expand Up @@ -461,7 +461,15 @@ async def _handle_credentials(self):
logger.debug(f"Credentials exist: {cred_manager.credentials_exist()}")

if cred_manager.credentials_exist():
# Show unlock screen
# Check if credentials are encrypted or plaintext
if cred_manager.is_plaintext():
# Plaintext credentials - load directly without unlock screen
logger.debug("Loading plaintext credentials (no encryption)")
creds, _ = cred_manager.load_credentials()
self.encryption_key = None # No encryption key for plaintext
return creds

# Encrypted credentials - show unlock screen
result = await self.push_screen(CredentialUnlockScreen(), wait_for_dismiss=True)

if result is None:
Expand Down Expand Up @@ -593,7 +601,15 @@ async def _handle_account_selection(self):

return account.id, profile_dir, creds

# Load existing credentials
# Check if credentials are plaintext (no encryption)
if cred_manager.is_plaintext():
# Plaintext credentials - load directly without unlock screen
logger.debug(f"Loading plaintext credentials for account {account.id}")
creds, _ = cred_manager.load_credentials()
self.encryption_key = None # No encryption key for plaintext
return account.id, profile_dir, creds

# Encrypted credentials - show unlock screen
creds = await self.push_screen(
CredentialUnlockScreen(profile_dir=profile_dir), wait_for_dismiss=True
)
Expand Down
56 changes: 50 additions & 6 deletions moneyflow/app_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -1651,11 +1651,10 @@ def handle_commit_result(
bulk_merchant_renames,
)

# Clear pending edits on success
self.data_manager.pending_edits.clear()
logger.info("Cleared pending edits")

# Update cache with edited data (if caching is enabled)
# NOTE: Must happen BEFORE clearing pending_edits because `edits` parameter
# is the same list object (passed by reference), so clearing pending_edits
# would empty the edits list before we can use it for cache updates!
if self.cache_manager and cache_filters:
try:
if is_filtered_view:
Expand All @@ -1664,10 +1663,51 @@ def handle_commit_result(
hot_df = self.cache_manager.load_hot_cache()
cold_df = self.cache_manager.load_cold_cache()
if hot_df is None or cold_df is None:
# This can happen if cache files don't exist, are corrupted,
# or encryption mode changed since they were created.
# We'll try to recover by applying edits to whichever tier
# loaded successfully, and preserve the other.
logger.warning(
"Filtered view detected but cache tiers are unavailable; "
"skipping cache update to avoid corruption"
"Filtered view: cache tier(s) unavailable (hot=%s, cold=%s). "
"Attempting partial update to preserve edits.",
hot_df is not None,
cold_df is not None,
)
if hot_df is not None:
updated_hot = CommitOrchestrator.apply_edits_to_dataframe(
hot_df,
edits,
self.data_manager.categories,
self.data_manager.apply_category_groups,
bulk_merchant_renames,
)
self.cache_manager.save_hot_cache(
hot_df=updated_hot,
categories=self.data_manager.categories,
category_groups=self.data_manager.category_groups,
)
logger.info("Updated hot cache with edits (cold unavailable)")
if cold_df is not None:
updated_cold = CommitOrchestrator.apply_edits_to_dataframe(
cold_df,
edits,
self.data_manager.categories,
self.data_manager.apply_category_groups,
bulk_merchant_renames,
)
self.cache_manager.save_cold_cache(cold_df=updated_cold)
logger.info("Updated cold cache with edits (hot unavailable)")
if hot_df is None and cold_df is None:
# Neither tier available - cache is corrupted or missing.
# In filtered view, data_manager.df only contains the filtered
# subset, so we CANNOT safely save it as the full cache (would
# lose historical data). Just log the error - edits are already
# saved to backend, so next --refresh will restore consistency.
logger.error(
"Neither cache tier could be loaded in filtered view! "
"Cache may be corrupted. Edits saved to backend but not to "
"local cache. Use --refresh to rebuild cache from backend."
)
else:
logger.info("Filtered view detected - updating cached tiers with edits")
updated_hot = CommitOrchestrator.apply_edits_to_dataframe(
Expand Down Expand Up @@ -1704,6 +1744,10 @@ def handle_commit_result(
# Cache update failed - not critical, just log
logger.warning(f"Cache update failed: {e}", exc_info=True)

# Clear pending edits on success (after cache update to preserve edits list)
self.data_manager.pending_edits.clear()
logger.info("Cleared pending edits")

# Refresh to show updated data (smooth update)
# Note: View already restored in app.py before commit started
logger.debug(
Expand Down
Loading