Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 18 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Normalize line endings in the repo to LF; check out as LF on all platforms.
* text=auto eol=lf

# Windows scripts must use CRLF so they work when double-clicked or run from cmd.exe
*.ps1 text eol=crlf
*.cmd text eol=crlf
*.bat text eol=crlf

# Shell scripts must always use LF — CRLF breaks the shebang and set -e
*.sh text eol=lf

# Binary assets — no line-ending translation
*.icns binary
*.ico binary
*.png binary
*.jpg binary
*.gif binary
*.pdf binary
31 changes: 31 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: CI

on:
push:
branches: ["**"]
pull_request:

jobs:
import-check:
name: Import check (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [macos-latest, windows-latest]
runs-on: ${{ matrix.os }}

steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install dependencies
run: pip install -r requirements.txt

- name: Verify core imports
run: |
python -c "from server import app; print('server: ok')"
python -c "import paths; print('paths:', paths.APP_DATA_DIR)"
python -c "import config; print('config: ok')"
226 changes: 132 additions & 94 deletions README.md

Large diffs are not rendered by default.

File renamed without changes.
9 changes: 9 additions & 0 deletions Start Butterfly Effect - Windows.cmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
@echo off
:: Butterfly Effect -- Windows launcher
:: Double-click this file or run it from cmd.exe to start the app.
::
:: chcp 65001 sets the console to UTF-8 (code page 65001) so that Unicode
:: characters written by PowerShell and subprocesses (block chars, ellipsis,
:: box-drawing lines, etc.) render correctly instead of showing as Gua garbage.
chcp 65001 > nul
powershell.exe -ExecutionPolicy Bypass -NoProfile -File "%~dp0run.ps1" %*
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.9.4-beta
0.9.5-beta
8 changes: 4 additions & 4 deletions ai_advisor.py
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,7 @@ def _build_user_prompt(
# Load user corrections — injected early so they take priority
user_context_section = []
if _USER_CONTEXT_FILE.exists():
ctx = _USER_CONTEXT_FILE.read_text().strip()
ctx = _USER_CONTEXT_FILE.read_text(encoding='utf-8').strip()
if ctx:
user_context_section = [
"",
Expand Down Expand Up @@ -510,7 +510,7 @@ def get_ai_insights(
print("Run: python ai_daily.py")
sys.exit(1)

insights = json.loads(INSIGHTS_FILE.read_text())
insights = json.loads(INSIGHTS_FILE.read_text(encoding='utf-8'))
generated = insights.get("generated_at", "unknown")
print(f"\n{'='*60}")
print(f"AI Insights (generated: {generated})")
Expand All @@ -537,13 +537,13 @@ def get_ai_insights(
print(f"\nSurplus alerts:")
for s in surpluses:
print(f" {s.get('period', '?')}: ${s.get('estimated_surplus', 0):,.2f}")
print(f" {s.get('suggested_allocation', '')}")
print(f" -> {s.get('suggested_allocation', '')}")

flags = insights.get("risk_flags", [])
if flags:
print(f"\nRisk flags:")
for f in flags:
print(f" {f}")
print(f" [!] {f}")

notes = insights.get("seasonal_notes", "")
if notes:
Expand Down
8 changes: 4 additions & 4 deletions ai_daily.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
def _load_config() -> dict:
if not _CONFIG_PATH.exists():
raise RuntimeError("config.yaml not found.")
return yaml.safe_load(_CONFIG_PATH.read_text())
return yaml.safe_load(_CONFIG_PATH.read_text(encoding='utf-8'))


def run(dry_run: bool = False) -> None:
Expand Down Expand Up @@ -127,8 +127,8 @@ def run(dry_run: bool = False) -> None:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)

_INSIGHTS_FILE.write_text(json.dumps(insights, indent=2, default=str))
print(f"[{datetime.now().strftime('%H:%M:%S')}] Insights written to {_INSIGHTS_FILE.name}")
_INSIGHTS_FILE.write_text(json.dumps(insights, indent=2, default=str), encoding='utf-8')
print(f"[{datetime.now().strftime('%H:%M:%S')}] [OK] Insights written to {_INSIGHTS_FILE.name}")

# Print summary
print(f"\nNarrative: {insights.get('narrative', '')[:200]}")
Expand All @@ -142,7 +142,7 @@ def run(dry_run: bool = False) -> None:
if flags:
print(f"Risk flags: {len(flags)}")
for f in flags:
print(f" {f}")
print(f" [!] {f}")


if __name__ == "__main__":
Expand Down
8 changes: 6 additions & 2 deletions butterfly-effect.spec
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ a = Analysis(
hiddenimports=[
*playwright_hidden,
*webview_hidden,
'webview.platforms.cocoa', # macOS platform — not auto-detected
# Platform-specific pywebview backend — not auto-detected by PyInstaller
'webview.platforms.cocoa' if sys.platform == 'darwin' else
'webview.platforms.edgechromium' if sys.platform == 'win32' else
'webview.platforms.gtk',
# AI providers
'anthropic',
'openai',
Expand Down Expand Up @@ -86,7 +89,8 @@ exe = EXE(
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=str(SRC / 'static' / 'icon.icns') if sys.platform == 'darwin' else None,
icon=(str(SRC / 'static' / 'icon.icns') if sys.platform == 'darwin' else
str(SRC / 'static' / 'icon.ico') if sys.platform == 'win32' else None),
)

coll = COLLECT(
Expand Down
4 changes: 2 additions & 2 deletions calendar_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def _load_ics_url() -> str:
config_path = Path(__file__).parent / "config.yaml"
if not config_path.exists():
raise RuntimeError("config.yaml not found.")
config = yaml.safe_load(config_path.read_text())
config = yaml.safe_load(config_path.read_text(encoding='utf-8'))
url = (config.get("calendar", {}).get("ics_url", "") or "").strip()
if not url or "PASTE_YOUR" in url:
raise RuntimeError(
Expand Down Expand Up @@ -177,7 +177,7 @@ def get_events(horizon_days: int = 45) -> list[dict]:

if __name__ == "__main__":
config_path = Path(__file__).parent / "config.yaml"
config = yaml.safe_load(config_path.read_text()) if config_path.exists() else {}
config = yaml.safe_load(config_path.read_text(encoding='utf-8')) if config_path.exists() else {}
horizon = config.get("forecast", {}).get("horizon_days", 45)

try:
Expand Down
10 changes: 5 additions & 5 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ def _load_config() -> dict:
raise RuntimeError(
"config.yaml not found. Copy config.yaml.example and fill in values."
)
return yaml.safe_load(_CONFIG_PATH.read_text())
return yaml.safe_load(_CONFIG_PATH.read_text(encoding='utf-8'))


def _save_config(config: dict) -> None:
Expand Down Expand Up @@ -100,7 +100,7 @@ def _env_key_status(key: str) -> str:
return "configured"
# Fallback: read .env file directly
if _ENV_PATH.exists():
for line in _ENV_PATH.read_text().splitlines():
for line in _ENV_PATH.read_text(encoding='utf-8').splitlines():
if line.startswith(f"{key}="):
val = line[len(f"{key}="):].strip().strip('"').strip("'")
if val:
Expand All @@ -111,7 +111,7 @@ def _env_key_status(key: str) -> str:

def _update_env_key(key: str, value: str) -> None:
"""Update or add key=value in .env; also update os.environ in-memory."""
lines = _ENV_PATH.read_text().splitlines() if _ENV_PATH.exists() else []
lines = _ENV_PATH.read_text(encoding='utf-8').splitlines() if _ENV_PATH.exists() else []
found = False
for i, line in enumerate(lines):
if line.startswith(f"{key}="):
Expand All @@ -130,7 +130,7 @@ def _delete_env_key(key: str) -> None:
Used for privacy opt-out — leaves no empty key line behind.
"""
if _ENV_PATH.exists():
lines = [l for l in _ENV_PATH.read_text().splitlines()
lines = [l for l in _ENV_PATH.read_text(encoding='utf-8').splitlines()
if not l.startswith(f"{key}=")]
_atomic_write(_ENV_PATH, "\n".join(lines) + ("\n" if lines else ""))
os.environ.pop(key, None)
Expand All @@ -142,7 +142,7 @@ def _read_env_value(key: str) -> str:
if val:
return val
if _ENV_PATH.exists():
for line in _ENV_PATH.read_text().splitlines():
for line in _ENV_PATH.read_text(encoding='utf-8').splitlines():
if line.startswith(f"{key}="):
return line[len(f"{key}="):].strip().strip('"').strip("'")
return ""
7 changes: 5 additions & 2 deletions forecast_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ def _get_forecast_data(config: dict) -> dict:
if config.get("demo_mode"):
_demo = _BASE_DIR / "demo" / "forecast_data.json"
if _demo.exists():
data = json.loads(_demo.read_text())
data = json.loads(_demo.read_text(encoding='utf-8'))
_cache["data"] = data
_cache["ts"] = time.time()
return data
Expand Down Expand Up @@ -352,7 +352,10 @@ def _matches_override(desc: str) -> bool:
_fetched_dt = datetime.now()
else:
_fetched_dt = datetime.now()
result["refreshed_at"] = _fetched_dt.strftime("%A %b %-d, %Y at %-I:%M %p")
# %-d and %-I are POSIX-only (Linux/macOS); use datetime attributes for cross-platform
# zero-stripping so this works on Windows too.
_hour = _fetched_dt.strftime("%I:%M %p").lstrip("0") # "01:30 PM" → "1:30 PM"
result["refreshed_at"] = f"{_fetched_dt.strftime('%A %b')} {_fetched_dt.day}, {_fetched_dt.year} at {_hour}"
result["horizon_days"] = horizon
result["has_ai_predictions"] = bool(predicted_events)

Expand Down
9 changes: 8 additions & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,10 +234,17 @@ def main():
import socket as _socket
with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as _s:
if _s.connect_ex(('127.0.0.1', port)) == 0:
if sys.platform == 'win32':
_kill_hint = (
f'netstat -ano | findstr :{port} '
f'# note the PID, then: taskkill /PID <pid> /F'
)
else:
_kill_hint = f'lsof -ti :{port} | xargs kill -9'
print(
f'\nERROR: Port {port} is already in use.\n'
f'The app may already be running at http://localhost:{port}\n'
f'If not, run: lsof -ti :{port} | xargs kill -9\n',
f'If not, run: {_kill_hint}\n',
file=sys.stderr,
)
sys.exit(1)
Expand Down
6 changes: 3 additions & 3 deletions monarch_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ async def _handle(response):
return
if self.debug:
keys = list(body.keys()) if isinstance(body, dict) else type(body).__name__
print(f" JSON keys: {keys}")
print(f" -> JSON keys: {keys}")
if isinstance(body, dict) and "data" in body:
self._responses.append(body["data"])

Expand Down Expand Up @@ -281,7 +281,7 @@ async def _do_login(page, context):

# Save browser state — all future runs will be headless
await context.storage_state(path=str(BROWSER_STATE_FILE))
print(" Session saved future runs will be headless.")
print("[OK] Session saved - future runs will be headless.")


async def _fetch_all(checking_account_id: str, history_days: int, _retried: bool = False):
Expand Down Expand Up @@ -658,7 +658,7 @@ def list_accounts(debug: bool = False):
args = parser.parse_args()

config_path = Path(__file__).parent / "config.yaml"
config = yaml.safe_load(config_path.read_text()) if config_path.exists() else {}
config = yaml.safe_load(config_path.read_text(encoding='utf-8')) if config_path.exists() else {}
account_id = config.get("monarch", {}).get("checking_account_id", "")

if args.list_accounts:
Expand Down
2 changes: 2 additions & 0 deletions paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@

if platform.system() == 'Darwin':
APP_DATA_DIR = Path.home() / 'Library' / 'Application Support' / 'Butterfly Effect'
elif platform.system() == 'Windows':
APP_DATA_DIR = Path.home() / 'AppData' / 'Roaming' / 'Butterfly Effect'
else:
# Linux: follow XDG Base Directory specification
_xdg = os.environ.get('XDG_DATA_HOME', '')
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
flask
pywebview
pywebview; sys_platform == "darwin" # macOS PyInstaller bundle only (main.py/WKWebView); not used in source-run path
playwright
icalendar<7 # 7.x ships pre-compiled .pyc in wheels; breaks pip 25.x on Python 3.14
requests
Expand Down
Loading
Loading