From 5e5f8ad5fbc361a3c8aeb10cf85fbaf09c03807e Mon Sep 17 00:00:00 2001 From: justin Date: Fri, 22 May 2026 13:54:30 -0400 Subject: [PATCH 1/3] Restore claude-plan-usage-desktop.py from fix/claude-permanent-firefox Script was installed in the LaunchAgent plist but never merged to main, so it went missing after switching branches. Cherry-picked from f7f4a88. Co-Authored-By: Claude Sonnet 4.6 --- scripts/claude-plan-usage-desktop.py | 308 +++++++++++++++++++++++++++ 1 file changed, 308 insertions(+) create mode 100755 scripts/claude-plan-usage-desktop.py diff --git a/scripts/claude-plan-usage-desktop.py b/scripts/claude-plan-usage-desktop.py new file mode 100755 index 0000000..15cd0a0 --- /dev/null +++ b/scripts/claude-plan-usage-desktop.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +"""Read Claude plan usage by replaying Claude Desktop's session cookies. + +Claude Desktop is Electron + Chromium and stores cookies at + ~/Library/Application Support/Claude/Cookies +encrypted with AES-128-CBC under a key derived from the macOS Keychain +entry service="Claude Safe Storage", account="Claude Key" (the same +scheme Chrome uses for its "Chrome Safe Storage" entry). + +This is the preferred scrape source because Claude Desktop typically +stays logged in indefinitely, so the session cookies stay fresh without +manual maintenance. + +One-time setup: the first run triggers a Keychain prompt — click +"Always Allow" so subsequent (background) runs can read the password +silently. + +Usage: + ./scripts/claude-plan-usage-desktop.py # scrape + write cache + ./scripts/claude-plan-usage-desktop.py print # print only, no write +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import sqlite3 +import subprocess +import sys +import tempfile +import urllib.error +import urllib.request +from datetime import datetime, timezone +from pathlib import Path + +from Crypto.Cipher import AES +from Crypto.Protocol.KDF import PBKDF2 + +CONFIG_DIR = Path.home() / ".config" / "codexmeter" +CACHE_PATH = Path( + os.getenv( + "CODEXMETER_CLAUDE_PLAN_USAGE_FILE", str(CONFIG_DIR / "claude-plan-usage.json") + ) +) +HEALTH_PATH = Path( + os.getenv( + "CODEXMETER_CLAUDE_SCRAPE_HEALTH", str(CONFIG_DIR / "claude-scrape-health.json") + ) +) +COOKIE_DB = Path( + os.getenv( + "CODEXMETER_CLAUDE_DESKTOP_COOKIES", + str(Path.home() / "Library/Application Support/Claude/Cookies"), + ) +) +KEYCHAIN_SERVICE = os.getenv("CODEXMETER_CLAUDE_KEYCHAIN_SERVICE", "Claude Safe Storage") +KEYCHAIN_ACCOUNT = os.getenv("CODEXMETER_CLAUDE_KEYCHAIN_ACCOUNT", "Claude Key") +DEFAULT_UA = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36" +) + + +def keychain_password() -> str: + proc = subprocess.run( + [ + "security", + "find-generic-password", + "-w", + "-s", + KEYCHAIN_SERVICE, + "-a", + KEYCHAIN_ACCOUNT, + ], + check=False, + capture_output=True, + text=True, + ) + if proc.returncode != 0 or not proc.stdout.strip(): + raise SystemExit( + f"Could not read '{KEYCHAIN_SERVICE}' / '{KEYCHAIN_ACCOUNT}' from " + f"Keychain (exit {proc.returncode}, stderr={proc.stderr.strip()!r}). " + "On first run, macOS prompts to allow access — click 'Always Allow' " + "and try again. If the prompt didn't appear, run the same security " + "command in Terminal manually so the GUI dialog can show." + ) + return proc.stdout.strip() + + +def derive_key(password: str) -> bytes: + return PBKDF2(password.encode("utf-8"), b"saltysalt", dkLen=16, count=1003) + + +def decrypt_value(key: bytes, encrypted: bytes) -> str: + cipher = AES.new(key, AES.MODE_CBC, IV=b" " * 16) + plain = cipher.decrypt(encrypted[3:]) # strip 'v10' prefix + pad = plain[-1] + if 1 <= pad <= 16: + plain = plain[:-pad] + # Some Chromium versions prepend a SHA256 of the host before the value. + try: + return plain.decode("utf-8") + except UnicodeDecodeError: + return plain[32:].decode("utf-8") + + +def read_claude_cookies() -> dict[str, str]: + if not COOKIE_DB.exists(): + raise SystemExit(f"Claude Desktop cookie DB not found at {COOKIE_DB}") + password = keychain_password() + key = derive_key(password) + + with tempfile.NamedTemporaryFile(suffix=".sqlite", delete=False) as tmp: + tmp_path = Path(tmp.name) + try: + shutil.copy(COOKIE_DB, tmp_path) + conn = sqlite3.connect(tmp_path) + cookies: dict[str, str] = {} + for name, value, encrypted in conn.execute( + "SELECT name, value, encrypted_value FROM cookies " + "WHERE host_key LIKE '%claude.ai%'" + ): + try: + if encrypted and encrypted[:3] == b"v10": + cookies[name] = decrypt_value(key, encrypted) + elif value: + cookies[name] = value + except Exception as exc: # noqa: BLE001 + # Don't let a single corrupt cookie kill the whole pull. + print(f"warn: decrypt {name!r} failed: {exc}", file=sys.stderr) + conn.close() + return cookies + finally: + try: + tmp_path.unlink() + except OSError: + pass + + +def claude_org_id() -> str: + env = os.getenv("CODEXMETER_CLAUDE_ORG_ID") + if env: + return env + profile = Path.home() / ".claude.json" + if profile.exists(): + try: + data = json.loads(profile.read_text(errors="replace")) + org = (data.get("oauthAccount") or {}).get("organizationUuid") + if org: + return str(org) + except (OSError, json.JSONDecodeError): + pass + raise SystemExit( + "Set CODEXMETER_CLAUDE_ORG_ID or sign in to Claude Code so that " + "~/.claude.json has oauthAccount.organizationUuid." + ) + + +def fetch_usage(cookies: dict[str, str]) -> dict: + org = claude_org_id() + url = f"https://claude.ai/api/organizations/{org}/usage" + cookie_str = "; ".join(f"{k}={v}" for k, v in cookies.items()) + req = urllib.request.Request( + url, + headers={ + "Cookie": cookie_str, + "User-Agent": DEFAULT_UA, + "Accept": "application/json", + "Referer": "https://claude.ai/settings/usage", + "Accept-Language": "en-US,en;q=0.5", + }, + ) + with urllib.request.urlopen(req, timeout=12) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def minutes_until_iso(iso: str | None) -> int: + if not iso: + return -1 + try: + when = datetime.fromisoformat(iso.replace("Z", "+00:00")) + except ValueError: + return -1 + return max(0, round((when - datetime.now(timezone.utc)).total_seconds() / 60)) + + +def normalize(data: dict) -> dict: + five = data.get("five_hour") if isinstance(data.get("five_hour"), dict) else {} + week = data.get("seven_day") if isinstance(data.get("seven_day"), dict) else {} + return { + "source": "claude_api_desktop_cookies", + "captured_at": datetime.now().astimezone().isoformat(timespec="seconds"), + "session": { + "used_pct": int(round(float(five.get("utilization") or 0))), + "reset_mins": minutes_until_iso(five.get("resets_at")), + }, + "weekly": { + "used_pct": int(round(float(week.get("utilization") or 0))), + "reset_mins": minutes_until_iso(week.get("resets_at")), + }, + } + + +def write_atomic(path: Path, payload: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(payload, separators=(",", ":"))) + tmp.replace(path) + + +def write_health(ok: bool, message: str, payload: dict | None = None) -> None: + health = { + "ok": ok, + "source": "claude_desktop", + "checked_at": datetime.now().astimezone().isoformat(timespec="seconds"), + "message": message, + } + if payload: + health["last_payload"] = payload + try: + write_atomic(HEALTH_PATH, health) + except OSError: + pass + + +def try_fallback() -> int: + """Defer to the Firefox scrape if it exists and is callable.""" + fallback = Path(__file__).parent / "claude-plan-usage-firefox.py" + if not fallback.exists(): + return 1 + print( + f"claude-desktop scrape failed; falling back to {fallback.name}", + file=sys.stderr, + ) + proc = subprocess.run([sys.executable, str(fallback)], check=False) + return proc.returncode + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument( + "command", + nargs="?", + default="scrape", + choices=["scrape", "print"], + ) + ap.add_argument( + "--no-fallback", + action="store_true", + help="Don't try the Firefox script if this one fails.", + ) + args = ap.parse_args() + + try: + cookies = read_claude_cookies() + except SystemExit as exc: + msg = f"cookie read failed: {exc}" + write_health(False, msg) + if args.no_fallback: + raise + print(msg, file=sys.stderr) + return try_fallback() + except Exception as exc: # noqa: BLE001 + msg = f"cookie read failed: {type(exc).__name__}: {exc}" + write_health(False, msg) + if args.no_fallback: + raise SystemExit(msg) from exc + print(msg, file=sys.stderr) + return try_fallback() + + if "sessionKey" not in cookies: + msg = "Claude Desktop has no claude.ai sessionKey cookie" + write_health(False, msg) + if args.no_fallback: + raise SystemExit(msg) + print(msg, file=sys.stderr) + return try_fallback() + + try: + data = fetch_usage(cookies) + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace")[:300] + msg = f"claude.ai/api HTTP {exc.code}: {body}" + write_health(False, msg) + if args.no_fallback: + raise SystemExit(msg) from exc + print(msg, file=sys.stderr) + return try_fallback() + except Exception as exc: # noqa: BLE001 + msg = f"claude.ai/api request failed: {type(exc).__name__}: {exc}" + write_health(False, msg) + if args.no_fallback: + raise SystemExit(msg) from exc + print(msg, file=sys.stderr) + return try_fallback() + + payload = normalize(data) + if args.command == "scrape": + write_atomic(CACHE_PATH, payload) + write_health(True, "ok", payload) + print(json.dumps(payload, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 8b11add79627090558da43cf301a53ed2d0d8117 Mon Sep 17 00:00:00 2001 From: justin Date: Fri, 22 May 2026 13:54:35 -0400 Subject: [PATCH 2/3] iOS project: remove UIRequiresFullScreen, processing background mode, team ID Xcode cleanup: drop UIRequiresFullScreen (allows slide-over/split view), remove the processing background mode no longer needed, strip hardcoded DEVELOPMENT_TEAM so signing is driven by local credentials. Co-Authored-By: Claude Sonnet 4.6 --- .../CodexMeterApp.xcodeproj/project.pbxproj | 16 ++++------------ .../xcschemes/CodexMeterApp.xcscheme | 16 ++++++++++++---- .../xcschemes/CodexMeterAppTests.xcscheme | 16 ++++++++++++---- ios/CodexMeterApp/CodexMeterApp/Info.plist | 3 --- 4 files changed, 28 insertions(+), 23 deletions(-) diff --git a/ios/CodexMeterApp/CodexMeterApp.xcodeproj/project.pbxproj b/ios/CodexMeterApp/CodexMeterApp.xcodeproj/project.pbxproj index 4cc68c9..fd5ff3d 100644 --- a/ios/CodexMeterApp/CodexMeterApp.xcodeproj/project.pbxproj +++ b/ios/CodexMeterApp/CodexMeterApp.xcodeproj/project.pbxproj @@ -51,17 +51,17 @@ /* Begin PBXFileReference section */ 01A85EEA0A75B3D031413E90 /* CodexMeterApp.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CodexMeterApp.entitlements; sourceTree = ""; }; 272BDB845C5D721F8B2329B5 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; - 413F794033A5A34330A8B4DA /* CodexMeterAppWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CodexMeterAppWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 413F794033A5A34330A8B4DA /* CodexMeterAppWidget.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = CodexMeterAppWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 6E2BB9DD56DD774EC8E7034C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 6F0BC3573F66A4836E448C92 /* MDNSBrowser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MDNSBrowser.swift; sourceTree = ""; }; - 744BA474583F58F78D03244B /* CodexMeterAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CodexMeterAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 744BA474583F58F78D03244B /* CodexMeterAppTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = CodexMeterAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 79F45A0EE1F102187D9268AB /* MeterDiscoveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterDiscoveryTests.swift; sourceTree = ""; }; 7EE906304FAD5C66673F7722 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; B40854E30881C891D1F40B3A /* CodexMeterAppWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CodexMeterAppWidget.entitlements; sourceTree = ""; }; C8180B6886C50FA4DEA8992D /* BLEManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEManager.swift; sourceTree = ""; }; E664385FA963DE22DD0B46BC /* UsageWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageWidget.swift; sourceTree = ""; }; EC0040FEB18C46FF8FA88AF2 /* MeterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterViewModel.swift; sourceTree = ""; }; - F014AD1C9A1D49FC558FACB3 /* CodexMeterApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CodexMeterApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + F014AD1C9A1D49FC558FACB3 /* CodexMeterApp.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = CodexMeterApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; FE995ECA438F57D71DA72F0F /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -199,7 +199,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; - LastUpgradeCheck = 2650; + LastUpgradeCheck = 1600; TargetAttributes = { 2DA90E0FD071B0F94B375F91 = { ProvisioningStyle = Automatic; @@ -294,7 +294,6 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = CodexMeterApp/CodexMeterApp.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; - DEVELOPMENT_TEAM = C4QAE6N4HS; INFOPLIST_FILE = CodexMeterApp/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -309,8 +308,6 @@ isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; - DEVELOPMENT_TEAM = C4QAE6N4HS; GENERATE_INFOPLIST_FILE = YES; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -328,7 +325,6 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = CodexMeterAppWidget/CodexMeterAppWidget.entitlements; - DEVELOPMENT_TEAM = C4QAE6N4HS; INFOPLIST_FILE = CodexMeterAppWidget/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -428,7 +424,6 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -450,7 +445,6 @@ PRODUCT_BUNDLE_IDENTIFIER = com.codexmeter.ios; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; - STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -496,7 +490,6 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -511,7 +504,6 @@ PRODUCT_BUNDLE_IDENTIFIER = com.codexmeter.ios; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; - STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.0; diff --git a/ios/CodexMeterApp/CodexMeterApp.xcodeproj/xcshareddata/xcschemes/CodexMeterApp.xcscheme b/ios/CodexMeterApp/CodexMeterApp.xcodeproj/xcshareddata/xcschemes/CodexMeterApp.xcscheme index e7c7a31..07f8ff0 100644 --- a/ios/CodexMeterApp/CodexMeterApp.xcodeproj/xcshareddata/xcschemes/CodexMeterApp.xcscheme +++ b/ios/CodexMeterApp/CodexMeterApp.xcodeproj/xcshareddata/xcschemes/CodexMeterApp.xcscheme @@ -1,10 +1,11 @@ + LastUpgradeVersion = "1600" + version = "1.7"> + buildImplicitDependencies = "YES" + runPostActionsOnFailure = "NO"> + codeCoverageEnabled = "YES" + onlyGenerateCoverageForSpecifiedTargets = "NO"> + + + + + + diff --git a/ios/CodexMeterApp/CodexMeterApp.xcodeproj/xcshareddata/xcschemes/CodexMeterAppTests.xcscheme b/ios/CodexMeterApp/CodexMeterApp.xcodeproj/xcshareddata/xcschemes/CodexMeterAppTests.xcscheme index e43b48f..6741630 100644 --- a/ios/CodexMeterApp/CodexMeterApp.xcodeproj/xcshareddata/xcschemes/CodexMeterAppTests.xcscheme +++ b/ios/CodexMeterApp/CodexMeterApp.xcodeproj/xcshareddata/xcschemes/CodexMeterAppTests.xcscheme @@ -1,10 +1,11 @@ + LastUpgradeVersion = "1600" + version = "1.7"> + buildImplicitDependencies = "YES" + runPostActionsOnFailure = "NO"> + codeCoverageEnabled = "YES" + onlyGenerateCoverageForSpecifiedTargets = "NO"> + + + + + + bluetooth-central fetch - processing UILaunchScreen - UIRequiresFullScreen - From a45543d13f870af3993551e6dd728bdd9b91b3d6 Mon Sep 17 00:00:00 2001 From: justin Date: Fri, 22 May 2026 13:59:05 -0400 Subject: [PATCH 3/3] Fix P1/P2 review: add pycryptodome dep, forward command to Firefox fallback - requirements.txt: add pycryptodome>=3.0 so fresh installs can import Crypto - try_fallback(): accept and forward the command arg so `print` mode stays read-only even when falling through to the Firefox script Co-Authored-By: Claude Sonnet 4.6 --- requirements.txt | 1 + scripts/claude-plan-usage-desktop.py | 17 ++++++++++------- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/requirements.txt b/requirements.txt index b8dc90d..b68a52f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ bleak>=0.22 +pycryptodome>=3.0 pyserial>=3.5 certifi>=2024.2.2 pytest>=7.0 diff --git a/scripts/claude-plan-usage-desktop.py b/scripts/claude-plan-usage-desktop.py index 15cd0a0..a1f19c4 100755 --- a/scripts/claude-plan-usage-desktop.py +++ b/scripts/claude-plan-usage-desktop.py @@ -225,7 +225,7 @@ def write_health(ok: bool, message: str, payload: dict | None = None) -> None: pass -def try_fallback() -> int: +def try_fallback(command: str = "scrape") -> int: """Defer to the Firefox scrape if it exists and is callable.""" fallback = Path(__file__).parent / "claude-plan-usage-firefox.py" if not fallback.exists(): @@ -234,7 +234,10 @@ def try_fallback() -> int: f"claude-desktop scrape failed; falling back to {fallback.name}", file=sys.stderr, ) - proc = subprocess.run([sys.executable, str(fallback)], check=False) + cmd = [sys.executable, str(fallback)] + if command != "scrape": + cmd.append(command) + proc = subprocess.run(cmd, check=False) return proc.returncode @@ -261,14 +264,14 @@ def main() -> int: if args.no_fallback: raise print(msg, file=sys.stderr) - return try_fallback() + return try_fallback(args.command) except Exception as exc: # noqa: BLE001 msg = f"cookie read failed: {type(exc).__name__}: {exc}" write_health(False, msg) if args.no_fallback: raise SystemExit(msg) from exc print(msg, file=sys.stderr) - return try_fallback() + return try_fallback(args.command) if "sessionKey" not in cookies: msg = "Claude Desktop has no claude.ai sessionKey cookie" @@ -276,7 +279,7 @@ def main() -> int: if args.no_fallback: raise SystemExit(msg) print(msg, file=sys.stderr) - return try_fallback() + return try_fallback(args.command) try: data = fetch_usage(cookies) @@ -287,14 +290,14 @@ def main() -> int: if args.no_fallback: raise SystemExit(msg) from exc print(msg, file=sys.stderr) - return try_fallback() + return try_fallback(args.command) except Exception as exc: # noqa: BLE001 msg = f"claude.ai/api request failed: {type(exc).__name__}: {exc}" write_health(False, msg) if args.no_fallback: raise SystemExit(msg) from exc print(msg, file=sys.stderr) - return try_fallback() + return try_fallback(args.command) payload = normalize(data) if args.command == "scrape":