lc-crusher 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,85 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ # A docs-only change is not a release.
7
+ paths-ignore: [README.md]
8
+ workflow_dispatch:
9
+
10
+ jobs:
11
+ lint:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: astral-sh/setup-uv@v4
16
+ - name: Check formatting
17
+ run: uv run --only-group dev black --check src/
18
+
19
+ publish:
20
+ needs: lint
21
+ runs-on: ubuntu-latest
22
+ permissions:
23
+ contents: write # needed to push git tags
24
+
25
+ steps:
26
+ - uses: actions/checkout@v4
27
+ with:
28
+ fetch-depth: 0 # full history for tag detection
29
+
30
+ - uses: astral-sh/setup-uv@v4
31
+
32
+ - name: Get next patch version
33
+ id: version
34
+ run: |
35
+ # Fetch latest published version from PyPI
36
+ LATEST=$(python3 -c "
37
+ import urllib.request, json
38
+ try:
39
+ with urllib.request.urlopen('https://pypi.org/pypi/lc-crusher/json') as r:
40
+ print(json.loads(r.read())['info']['version'])
41
+ except Exception:
42
+ print('')
43
+ ")
44
+ echo "Latest on PyPI: ${LATEST:-none}"
45
+
46
+ if [ -z "$LATEST" ]; then
47
+ # First release: publish the version declared in pyproject.toml.
48
+ NEW=$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml)
49
+ else
50
+ IFS='.' read -ra V <<< "$LATEST"
51
+ NEW="${V[0]}.${V[1]}.$((V[2] + 1))"
52
+ fi
53
+ echo "Next version: $NEW"
54
+ echo "version=$NEW" >> "$GITHUB_OUTPUT"
55
+
56
+ - name: Bump version
57
+ run: |
58
+ VERSION="${{ steps.version.outputs.version }}"
59
+ sed -i "s/^version = \".*\"/version = \"${VERSION}\"/" pyproject.toml
60
+ sed -i "s/^__version__ = \".*\"/__version__ = \"${VERSION}\"/" src/lc/__init__.py
61
+ grep "^version" pyproject.toml
62
+
63
+ - name: Build
64
+ run: uv build
65
+
66
+ - name: Publish
67
+ run: uv publish
68
+ env:
69
+ UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
70
+
71
+ - name: Tag release
72
+ run: |
73
+ git config user.name "github-actions[bot]"
74
+ git config user.email "github-actions[bot]@users.noreply.github.com"
75
+ git tag "v${{ steps.version.outputs.version }}"
76
+ git push origin "v${{ steps.version.outputs.version }}"
77
+
78
+ - name: Create GitHub Release
79
+ env:
80
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
81
+ run: |
82
+ VERSION="${{ steps.version.outputs.version }}"
83
+ gh release create "v${VERSION}" dist/*.whl dist/*.tar.gz \
84
+ --title "lc-crusher v${VERSION}" \
85
+ --notes "Install: pipx install lc-crusher==${VERSION} | https://pypi.org/project/lc-crusher/${VERSION}/"
@@ -0,0 +1,5 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ .DS_Store
5
+ dist/
@@ -0,0 +1,8 @@
1
+ repos:
2
+ - repo: local
3
+ hooks:
4
+ - id: black
5
+ name: black
6
+ entry: uv run black
7
+ language: system
8
+ types: [python]
@@ -0,0 +1,52 @@
1
+ Metadata-Version: 2.5
2
+ Name: lc-crusher
3
+ Version: 0.1.0
4
+ Summary: lc (LeetCode Crusher): pick an unsolved problem, borrow a community Python solution, verify it, submit it.
5
+ Classifier: Environment :: Console
6
+ Classifier: Operating System :: MacOS
7
+ Classifier: Programming Language :: Python :: 3
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: curl-cffi>=0.7
10
+ Requires-Dist: openpyxl>=3.1
11
+ Requires-Dist: playwright>=1.45
12
+ Requires-Dist: rich>=13.7
13
+ Requires-Dist: typer>=0.12
14
+ Description-Content-Type: text/markdown
15
+
16
+ # lc — LeetCode Crusher
17
+
18
+ A CLI that picks unsolved LeetCode problems, finds a top-voted community Python
19
+ solution, verifies it against the examples with Run, and submits it.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pipx install lc-crusher
25
+ ```
26
+
27
+ Requires Python 3.10+ and Google Chrome (used only for signing in).
28
+
29
+ ## Usage
30
+
31
+ ```bash
32
+ lc login # sign in in a Chrome window; session saved to ~/.config/lc
33
+ lc whoami # signed-in user and solved counts
34
+ lc pick -n 10 # next unsolved problems
35
+ lc crush --dry-run # verify the next problem without submitting
36
+ lc crush -n 5 -d easy # solve 5 unsolved easy problems
37
+ lc now # run the scheduled job immediately (50 problems)
38
+ lc schedule install daily:23:00 # run every day at 23:00 (macOS launchd)
39
+ lc schedule status | remove
40
+ ```
41
+
42
+ Every accepted problem is appended to `~/Documents/lc-solved.xlsx`, one tab per
43
+ date. Problems a previous run solved or gave up on are never retried unless you
44
+ pass `--retry-failed`.
45
+
46
+ LeetCode allows one Run/Submit at a time per account, so problems are solved
47
+ sequentially.
48
+
49
+ ## Disclaimer
50
+
51
+ Automated submissions violate LeetCode's Terms of Service and can get an account
52
+ flagged or banned. Use at your own risk.
@@ -0,0 +1,37 @@
1
+ # lc — LeetCode Crusher
2
+
3
+ A CLI that picks unsolved LeetCode problems, finds a top-voted community Python
4
+ solution, verifies it against the examples with Run, and submits it.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ pipx install lc-crusher
10
+ ```
11
+
12
+ Requires Python 3.10+ and Google Chrome (used only for signing in).
13
+
14
+ ## Usage
15
+
16
+ ```bash
17
+ lc login # sign in in a Chrome window; session saved to ~/.config/lc
18
+ lc whoami # signed-in user and solved counts
19
+ lc pick -n 10 # next unsolved problems
20
+ lc crush --dry-run # verify the next problem without submitting
21
+ lc crush -n 5 -d easy # solve 5 unsolved easy problems
22
+ lc now # run the scheduled job immediately (50 problems)
23
+ lc schedule install daily:23:00 # run every day at 23:00 (macOS launchd)
24
+ lc schedule status | remove
25
+ ```
26
+
27
+ Every accepted problem is appended to `~/Documents/lc-solved.xlsx`, one tab per
28
+ date. Problems a previous run solved or gave up on are never retried unless you
29
+ pass `--retry-failed`.
30
+
31
+ LeetCode allows one Run/Submit at a time per account, so problems are solved
32
+ sequentially.
33
+
34
+ ## Disclaimer
35
+
36
+ Automated submissions violate LeetCode's Terms of Service and can get an account
37
+ flagged or banned. Use at your own risk.
@@ -0,0 +1,35 @@
1
+ [project]
2
+ name = "lc-crusher"
3
+ version = "0.1.0"
4
+ description = "lc (LeetCode Crusher): pick an unsolved problem, borrow a community Python solution, verify it, submit it."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ classifiers = [
8
+ "Environment :: Console",
9
+ "Operating System :: MacOS",
10
+ "Programming Language :: Python :: 3",
11
+ ]
12
+ dependencies = [
13
+ "curl_cffi>=0.7",
14
+ "openpyxl>=3.1",
15
+ "playwright>=1.45",
16
+ "rich>=13.7",
17
+ "typer>=0.12",
18
+ ]
19
+
20
+ [project.scripts]
21
+ lc = "lc.cli:app"
22
+
23
+ [dependency-groups]
24
+ dev = ["black>=24.0", "pre-commit>=3.7"]
25
+
26
+ [tool.black]
27
+ line-length = 120
28
+ target-version = ["py310"]
29
+
30
+ [build-system]
31
+ requires = ["hatchling"]
32
+ build-backend = "hatchling.build"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["src/lc"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,92 @@
1
+ import json
2
+ import shutil
3
+ import socket
4
+ import subprocess
5
+ import time
6
+ import urllib.request
7
+ from pathlib import Path
8
+
9
+ from .client import BASE, LeetCode
10
+ from .config import CONFIG_DIR
11
+
12
+ LOGIN_URL = f"{BASE}/accounts/login/"
13
+ REQUIRED = ("LEETCODE_SESSION", "csrftoken")
14
+ PROFILE_DIR = CONFIG_DIR / "chrome-profile"
15
+ CHROME_PATHS = [
16
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
17
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
18
+ ]
19
+
20
+
21
+ def _chrome() -> str:
22
+ for path in CHROME_PATHS:
23
+ if Path(path).exists():
24
+ return path
25
+ for name in ("google-chrome", "chromium", "chromium-browser"):
26
+ if found := shutil.which(name):
27
+ return found
28
+ raise RuntimeError("Google Chrome not found")
29
+
30
+
31
+ def _free_port() -> int:
32
+ with socket.socket() as s:
33
+ s.bind(("127.0.0.1", 0))
34
+ return s.getsockname()[1]
35
+
36
+
37
+ def _tab_urls(port: int) -> list[str]:
38
+ try:
39
+ with urllib.request.urlopen(f"http://127.0.0.1:{port}/json/list", timeout=2) as r:
40
+ return [t.get("url", "") for t in json.load(r) if t.get("type") == "page"]
41
+ except OSError:
42
+ return []
43
+
44
+
45
+ def _signed_in(cookies: dict[str, str]) -> bool:
46
+ try:
47
+ return LeetCode(cookies).whoami()["isSignedIn"]
48
+ except Exception:
49
+ return False
50
+
51
+
52
+ def browser_login(timeout: int = 300) -> dict[str, str]:
53
+ """Open an ordinary Chrome window and wait for the user to sign in by hand.
54
+
55
+ Chrome is launched as a plain process (no automation flags) with a dedicated
56
+ profile, and nothing attaches to it until a tab sits on leetcode.com outside
57
+ the login pages. Google refuses sign-in in automation-controlled browsers,
58
+ so the login itself must be untouched. The profile persists, so later
59
+ logins are usually instant.
60
+ """
61
+ from playwright.sync_api import sync_playwright
62
+
63
+ PROFILE_DIR.mkdir(parents=True, exist_ok=True)
64
+ port = _free_port()
65
+ proc = subprocess.Popen(
66
+ [
67
+ _chrome(),
68
+ f"--remote-debugging-port={port}",
69
+ f"--user-data-dir={PROFILE_DIR}",
70
+ "--no-first-run",
71
+ "--no-default-browser-check",
72
+ LOGIN_URL,
73
+ ],
74
+ stdout=subprocess.DEVNULL,
75
+ stderr=subprocess.DEVNULL,
76
+ )
77
+ try:
78
+ deadline = time.monotonic() + timeout
79
+ while time.monotonic() < deadline:
80
+ time.sleep(2)
81
+ urls = _tab_urls(port)
82
+ if not any(u.startswith(BASE) and "/accounts/" not in u for u in urls):
83
+ continue
84
+ with sync_playwright() as p:
85
+ browser = p.chromium.connect_over_cdp(f"http://127.0.0.1:{port}")
86
+ cookies = {c["name"]: c["value"] for c in browser.contexts[0].cookies(BASE)}
87
+ found = {k: cookies.get(k, "") for k in REQUIRED}
88
+ if all(found.values()) and _signed_in(found):
89
+ return found
90
+ finally:
91
+ proc.terminate()
92
+ raise TimeoutError(f"No signed-in LeetCode session after {timeout}s")
@@ -0,0 +1,325 @@
1
+ import fcntl
2
+ import getpass
3
+ import json
4
+ import subprocess
5
+ import time
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.syntax import Syntax
13
+ from rich.table import Table
14
+
15
+ from . import config
16
+ from .client import LeetCode
17
+ from .crusher import crush
18
+ from .sheet import append_solved
19
+
20
+ app = typer.Typer(help="lc — LeetCode Crusher", no_args_is_help=True, add_completion=False)
21
+ console = Console()
22
+
23
+
24
+ def _client(auth: bool = True) -> LeetCode:
25
+ cookies = config.load_session()
26
+ if auth and not cookies:
27
+ console.print("[red]Not logged in.[/] Run [bold]lc login[/] first.")
28
+ raise typer.Exit(1)
29
+ return LeetCode(cookies)
30
+
31
+
32
+ def _signed_in(lc: LeetCode) -> dict:
33
+ me = lc.whoami()
34
+ if not me["isSignedIn"]:
35
+ console.print("[red]Session expired.[/] Run [bold]lc login[/] again.")
36
+ raise typer.Exit(1)
37
+ return me
38
+
39
+
40
+ @app.command()
41
+ def login(
42
+ manual: bool = typer.Option(False, "--manual", help="Paste cookies instead of opening a browser."),
43
+ timeout: int = typer.Option(300, help="Seconds to wait for you to finish signing in."),
44
+ ):
45
+ """Sign in to LeetCode and save the session locally."""
46
+ if manual:
47
+ cookies = {
48
+ "LEETCODE_SESSION": getpass.getpass("LEETCODE_SESSION: ").strip(),
49
+ "csrftoken": getpass.getpass("csrftoken: ").strip(),
50
+ }
51
+ else:
52
+ from .auth import browser_login
53
+
54
+ console.print("Opening LeetCode — sign in in the browser window; it closes by itself.")
55
+ cookies = browser_login(timeout)
56
+
57
+ me = LeetCode(cookies).whoami()
58
+ if not me["isSignedIn"]:
59
+ console.print("[red]Those cookies are not signed in.[/]")
60
+ raise typer.Exit(1)
61
+ config.save_session(cookies)
62
+ console.print(f"[green]Logged in as[/] [bold]{me['username']}[/]")
63
+
64
+
65
+ @app.command()
66
+ def logout():
67
+ """Forget the saved session."""
68
+ config.clear_session()
69
+ console.print("Logged out.")
70
+
71
+
72
+ @app.command()
73
+ def whoami():
74
+ """Show the signed-in user and solved counts."""
75
+ lc = _client()
76
+ me = _signed_in(lc)
77
+ counts = lc.solved_counts(me["username"])
78
+ premium = " (premium)" if me["isPremium"] else ""
79
+ console.print(f"[bold]{me['username']}[/]{premium} solved: " + " ".join(f"{k}={v}" for k, v in counts.items()))
80
+
81
+
82
+ @app.command()
83
+ def pick(
84
+ difficulty: Optional[str] = typer.Option(None, "-d", "--difficulty", help="easy | medium | hard"),
85
+ count: int = typer.Option(10, "-n", help="How many to list."),
86
+ ):
87
+ """List the next unsolved problems."""
88
+ lc = _client()
89
+ me = _signed_in(lc)
90
+ table = Table("#", "title", "difficulty")
91
+ for i, q in enumerate(lc.unsolved(difficulty, include_paid=me["isPremium"])):
92
+ if i >= count:
93
+ break
94
+ table.add_row(q["questionFrontendId"], f"{q['title']} [dim]({q['titleSlug']})[/]", q["difficulty"])
95
+ console.print(table)
96
+
97
+
98
+ @app.command("crush")
99
+ def crush_cmd(
100
+ slug: Optional[str] = typer.Argument(None, help="Problem slug (e.g. two-sum). Default: next unsolved."),
101
+ count: int = typer.Option(1, "-n", "--count", help="How many unsolved problems to crush."),
102
+ difficulty: Optional[str] = typer.Option(None, "-d", "--difficulty", help="easy | medium | hard"),
103
+ dry_run: bool = typer.Option(False, "--dry-run", help="Verify with Run but do not Submit."),
104
+ show_code: bool = typer.Option(False, "--show-code", help="Print the code that was used."),
105
+ delay: float = typer.Option(10.0, help="Seconds to wait between problems."),
106
+ max_articles: int = typer.Option(30, help="Solution articles to scan per problem."),
107
+ max_submits: int = typer.Option(3, help="Submit attempts per problem before skipping."),
108
+ sheet: Path = typer.Option(config.SHEET_FILE, help="Excel workbook to log solved problems to (one tab per date)."),
109
+ retry_failed: bool = typer.Option(False, help="Retry problems a previous run gave up on."),
110
+ notify: bool = typer.Option(False, help="Show a macOS notification when finished."),
111
+ ):
112
+ """Pick unsolved problem(s), find a working Python solution, run it, submit it."""
113
+ run_started = datetime.now()
114
+ if not _acquire_run_lock():
115
+ console.print("[yellow]Another lc crush is already running.[/] LeetCode allows one at a time.")
116
+ raise typer.Exit(1)
117
+ try:
118
+ lc = _client()
119
+ me = _signed_in(lc)
120
+ except typer.Exit:
121
+ if notify:
122
+ _notify("lc crush failed", "Not logged in or session expired. Run lc login.")
123
+ raise
124
+
125
+ attempted = set() if (slug or retry_failed) else config.attempted_slugs()
126
+ slugs = (
127
+ [slug]
128
+ if slug
129
+ else (
130
+ q["titleSlug"]
131
+ for q in lc.unsolved(difficulty, include_paid=me["isPremium"])
132
+ if q["titleSlug"] not in attempted
133
+ )
134
+ )
135
+ target = 1 if slug else count
136
+ done = failed = 0
137
+ console.print(f"[dim]{run_started:%Y-%m-%d %H:%M} — target {target}, logging to {sheet}[/]")
138
+
139
+ for s in slugs:
140
+ if done >= target:
141
+ break
142
+ try:
143
+ p = lc.problem(s)
144
+ except Exception as e:
145
+ console.print(f"[yellow]skip {s}[/]: {e}")
146
+ continue
147
+
148
+ console.rule(f"[cyan]\\[{done}/{target}][/] [bold]{p.frontend_id}. {p.title}[/] [dim]{p.difficulty}[/]")
149
+ try:
150
+ out = crush(lc, p, console.print, max_articles=max_articles, max_submits=max_submits, dry_run=dry_run)
151
+ except Exception as e:
152
+ console.print(f"[red]error[/]: {e}")
153
+ failed += 1
154
+ continue
155
+
156
+ if out.code and show_code:
157
+ console.print(Syntax(out.code, "python", theme="ansi_dark"))
158
+ if out.accepted:
159
+ r = out.result
160
+ done += 1
161
+ console.print(
162
+ f"[bold green]ACCEPTED[/] [cyan]{done}/{target}[/] runtime {r.get('status_runtime')} "
163
+ f"(beats {r.get('runtime_percentile') or 0:.1f}%), memory {r.get('status_memory')}"
164
+ )
165
+ try:
166
+ append_solved(
167
+ sheet,
168
+ run_started,
169
+ datetime.now(),
170
+ {
171
+ "frontend_id": p.frontend_id,
172
+ "title": p.title,
173
+ "slug": p.slug,
174
+ "difficulty": p.difficulty,
175
+ "runtime": r.get("status_runtime", ""),
176
+ "memory": r.get("status_memory", ""),
177
+ },
178
+ )
179
+ except OSError as e:
180
+ console.print(f"[yellow]could not write sheet[/]: {e}")
181
+ elif out.verified and dry_run:
182
+ done += 1
183
+ console.print(f"[cyan]verified {done}/{target} (dry run, not submitted)[/]")
184
+ else:
185
+ console.print(f"[red]skipped[/]: {out.reason} [cyan]({done}/{target})[/]")
186
+ failed += 1
187
+
188
+ config.append_history(
189
+ {
190
+ "at": datetime.now(timezone.utc).isoformat(),
191
+ "slug": p.slug,
192
+ "frontend_id": p.frontend_id,
193
+ "title": p.title,
194
+ "difficulty": p.difficulty,
195
+ "accepted": out.accepted,
196
+ "dry_run": dry_run,
197
+ "runs": out.runs,
198
+ "submits": out.submits,
199
+ "reason": out.reason,
200
+ }
201
+ )
202
+ if slug is None and done < target:
203
+ time.sleep(delay)
204
+
205
+ console.rule()
206
+ console.print(f"crushed [green]{done}[/], skipped [red]{failed}[/]")
207
+ if notify:
208
+ _notify("lc crush finished", f"Solved {done}/{target}, skipped {failed}")
209
+ if done < target:
210
+ raise typer.Exit(1)
211
+
212
+
213
+ _run_lock = None
214
+
215
+
216
+ def _acquire_run_lock() -> bool:
217
+ """Hold an exclusive flock for the life of the process; the OS releases it on exit."""
218
+ global _run_lock
219
+ config.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
220
+ _run_lock = (config.CONFIG_DIR / "crush.lock").open("w")
221
+ try:
222
+ fcntl.flock(_run_lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
223
+ return True
224
+ except BlockingIOError:
225
+ return False
226
+
227
+
228
+ @app.command()
229
+ def now(
230
+ background: bool = typer.Option(
231
+ False, "--bg", help="Run through launchd in the background; output goes to the log."
232
+ ),
233
+ ):
234
+ """Run the scheduled crush right now, with the schedule's count and sheet."""
235
+ from . import schedule
236
+
237
+ st = schedule.status()
238
+ if background:
239
+ if st is None:
240
+ console.print("No schedule installed. Run [bold]lc schedule install[/] first.")
241
+ raise typer.Exit(1)
242
+ schedule.run_now()
243
+ console.print(f"Started in background. Follow with: tail -f {config.LOG_DIR / 'crush.log'}")
244
+ return
245
+
246
+ args = st["plist"]["ProgramArguments"] if st else []
247
+ count = int(args[args.index("-n") + 1]) if "-n" in args else 50
248
+ sheet = Path(args[args.index("--sheet") + 1]) if "--sheet" in args else config.SHEET_FILE
249
+ crush_cmd(
250
+ slug=None,
251
+ count=count,
252
+ difficulty=None,
253
+ dry_run=False,
254
+ show_code=False,
255
+ delay=10.0,
256
+ max_articles=30,
257
+ max_submits=3,
258
+ sheet=sheet,
259
+ retry_failed=False,
260
+ notify=True,
261
+ )
262
+
263
+
264
+ def _notify(title: str, message: str) -> None:
265
+ script = f"display notification {json.dumps(message)} with title {json.dumps(title)}"
266
+ subprocess.run(["osascript", "-e", script], capture_output=True)
267
+
268
+
269
+ schedule_app = typer.Typer(help="Run lc crush automatically every day.", no_args_is_help=True)
270
+ app.add_typer(schedule_app, name="schedule")
271
+
272
+
273
+ @schedule_app.command("install")
274
+ def schedule_install(
275
+ spec: str = typer.Argument("daily:23:00", help="When to run: daily:HH:MM (24h). Several times: daily:09:00,21:00"),
276
+ count: int = typer.Option(50, "-n", "--count", help="Unique problems to solve per run."),
277
+ sheet: Path = typer.Option(config.SHEET_FILE, help="Excel workbook to log solved problems to."),
278
+ ):
279
+ """Install (or replace) the schedule, e.g. `lc schedule install daily:23:00`."""
280
+ from . import schedule
281
+
282
+ kind, _, times = spec.partition(":")
283
+ if kind != "daily" or not times:
284
+ console.print(f"[red]Bad schedule[/] {spec!r}. Use daily:HH:MM, e.g. daily:23:00")
285
+ raise typer.Exit(1)
286
+ parsed = []
287
+ for t in times.split(","):
288
+ try:
289
+ h, m = (int(x) for x in t.strip().split(":"))
290
+ except ValueError:
291
+ h = m = -1
292
+ if not (0 <= h < 24 and 0 <= m < 60):
293
+ console.print(f"[red]Bad time[/] {t!r}. Use 24h HH:MM, e.g. 23:00")
294
+ raise typer.Exit(1)
295
+ parsed.append((h, m))
296
+ path = schedule.install(parsed, count, sheet.expanduser().resolve())
297
+ console.print(f"[green]Scheduled[/] {count} problems daily at {times} ({path})")
298
+ console.print(f"Logs: {config.LOG_DIR / 'crush.log'}")
299
+
300
+
301
+ @schedule_app.command("status")
302
+ def schedule_status():
303
+ """Show the installed schedule."""
304
+ from . import schedule
305
+
306
+ st = schedule.status()
307
+ if st is None:
308
+ console.print("No schedule installed.")
309
+ return
310
+ p = st["plist"]
311
+ times = ", ".join(f"{t['Hour']:02d}:{t['Minute']:02d}" for t in p["StartCalendarInterval"])
312
+ console.print(f"{'[green]loaded[/]' if st['loaded'] else '[red]not loaded[/]'} at {times}")
313
+ console.print(" " + " ".join(p["ProgramArguments"]))
314
+
315
+
316
+ @schedule_app.command("remove")
317
+ def schedule_remove():
318
+ """Remove the daily schedule."""
319
+ from . import schedule
320
+
321
+ console.print("Removed." if schedule.remove() else "No schedule installed.")
322
+
323
+
324
+ if __name__ == "__main__":
325
+ app()