skilark 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.
- skilark-0.1.0/.gitignore +27 -0
- skilark-0.1.0/PKG-INFO +22 -0
- skilark-0.1.0/pyproject.toml +48 -0
- skilark-0.1.0/src/skilark_cli/__init__.py +1 -0
- skilark-0.1.0/src/skilark_cli/answer_checker.py +30 -0
- skilark-0.1.0/src/skilark_cli/client.py +107 -0
- skilark-0.1.0/src/skilark_cli/commands/__init__.py +0 -0
- skilark-0.1.0/src/skilark_cli/commands/config.py +54 -0
- skilark-0.1.0/src/skilark_cli/commands/status.py +29 -0
- skilark-0.1.0/src/skilark_cli/commands/today.py +138 -0
- skilark-0.1.0/src/skilark_cli/config_store.py +62 -0
- skilark-0.1.0/src/skilark_cli/display.py +208 -0
- skilark-0.1.0/src/skilark_cli/main.py +51 -0
- skilark-0.1.0/src/skilark_cli/onboarding.py +76 -0
- skilark-0.1.0/tests/__init__.py +0 -0
- skilark-0.1.0/tests/test_answer_checker.py +35 -0
- skilark-0.1.0/tests/test_client.py +199 -0
- skilark-0.1.0/tests/test_config_cmd.py +136 -0
- skilark-0.1.0/tests/test_config_store.py +92 -0
- skilark-0.1.0/tests/test_display.py +338 -0
- skilark-0.1.0/tests/test_main.py +59 -0
- skilark-0.1.0/tests/test_onboarding.py +44 -0
- skilark-0.1.0/tests/test_status.py +65 -0
- skilark-0.1.0/tests/test_today.py +248 -0
- skilark-0.1.0/uv.lock +300 -0
skilark-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.pyc
|
|
3
|
+
*.pyo
|
|
4
|
+
.venv/
|
|
5
|
+
*.egg-info/
|
|
6
|
+
dist/
|
|
7
|
+
.env
|
|
8
|
+
.pytest_cache/
|
|
9
|
+
.ruff_cache/
|
|
10
|
+
|
|
11
|
+
# Go binary
|
|
12
|
+
api/job-pulse-api
|
|
13
|
+
|
|
14
|
+
# Large data files
|
|
15
|
+
data.dump
|
|
16
|
+
schema.sql
|
|
17
|
+
|
|
18
|
+
# Generated site
|
|
19
|
+
site/dist/
|
|
20
|
+
|
|
21
|
+
# Terraform
|
|
22
|
+
terraform/.terraform/
|
|
23
|
+
terraform/*.tfstate
|
|
24
|
+
terraform/*.tfstate.backup
|
|
25
|
+
terraform/*.tfvars
|
|
26
|
+
terraform/.terraform.lock.hcl
|
|
27
|
+
*.old
|
skilark-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: skilark
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Daily coding challenges from market intelligence
|
|
5
|
+
Project-URL: Homepage, https://skilark.com
|
|
6
|
+
Author-email: Skilark <skilark.feedback@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Keywords: challenges,cli,coding,learning,practice
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Education
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Requires-Dist: httpx>=0.27
|
|
20
|
+
Requires-Dist: inquirerpy>=0.3
|
|
21
|
+
Requires-Dist: pyyaml>=6.0
|
|
22
|
+
Requires-Dist: rich>=13.0
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "skilark"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Daily coding challenges from market intelligence"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Skilark", email = "skilark.feedback@gmail.com" },
|
|
9
|
+
]
|
|
10
|
+
keywords = ["coding", "challenges", "practice", "cli", "learning"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Environment :: Console",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Topic :: Education",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"httpx>=0.27",
|
|
24
|
+
"rich>=13.0",
|
|
25
|
+
"InquirerPy>=0.3",
|
|
26
|
+
"pyyaml>=6.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://skilark.com"
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
skilark = "skilark_cli.main:main"
|
|
34
|
+
|
|
35
|
+
[build-system]
|
|
36
|
+
requires = ["hatchling"]
|
|
37
|
+
build-backend = "hatchling.build"
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.wheel]
|
|
40
|
+
packages = ["src/skilark_cli"]
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
testpaths = ["tests"]
|
|
44
|
+
|
|
45
|
+
[dependency-groups]
|
|
46
|
+
dev = [
|
|
47
|
+
"pytest>=8.0",
|
|
48
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def normalize(s: str) -> str:
|
|
5
|
+
"""Normalize answer string for comparison.
|
|
6
|
+
|
|
7
|
+
Strips whitespace, lowercases, removes bracket/paren wrappers,
|
|
8
|
+
normalizes commas to spaces, and collapses internal whitespace.
|
|
9
|
+
This lets users write answers in natural forms like "(1, 2)" or
|
|
10
|
+
"[1, 2, 3]" and still match the canonical expected value "1 2 3".
|
|
11
|
+
"""
|
|
12
|
+
s = s.strip().lower()
|
|
13
|
+
# Remove common wrappers: parentheses, brackets, braces
|
|
14
|
+
s = re.sub(r'[\[\]\(\)\{\}]', '', s)
|
|
15
|
+
# Normalize commas to spaces
|
|
16
|
+
s = s.replace(',', ' ')
|
|
17
|
+
# Collapse multiple spaces
|
|
18
|
+
s = re.sub(r'\s+', ' ', s)
|
|
19
|
+
return s.strip()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def check_answer(user_answer: str, expected: str) -> bool:
|
|
23
|
+
"""Check if user's answer matches expected, with normalization.
|
|
24
|
+
|
|
25
|
+
Returns False for blank input so callers can distinguish
|
|
26
|
+
"no answer given" from a wrong answer without special-casing.
|
|
27
|
+
"""
|
|
28
|
+
if not user_answer.strip():
|
|
29
|
+
return False
|
|
30
|
+
return normalize(user_answer) == normalize(expected)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SkilarkClient: thin httpx wrapper for the Skilark API.
|
|
3
|
+
|
|
4
|
+
Every method maps 1-to-1 to a REST endpoint. The client is synchronous
|
|
5
|
+
because the CLI is a short-lived interactive process — async overhead is
|
|
6
|
+
not warranted here.
|
|
7
|
+
|
|
8
|
+
The `transport` constructor argument exists solely for testing: pass an
|
|
9
|
+
httpx.MockTransport to intercept calls without hitting the network.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SkilarkClient:
|
|
16
|
+
"""HTTP client for the Skilark API.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
base_url: Root URL of the API (e.g. "https://api.skilark.com").
|
|
20
|
+
user_id: UUID for the authenticated user. When set, every request
|
|
21
|
+
carries an ``X-Skilark-User`` header so the server can
|
|
22
|
+
associate actions with the right account.
|
|
23
|
+
transport: Optional httpx transport override, used in tests.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
base_url: str,
|
|
29
|
+
user_id: str | None = None,
|
|
30
|
+
transport: httpx.BaseTransport | None = None,
|
|
31
|
+
):
|
|
32
|
+
kwargs: dict = {"base_url": base_url, "timeout": 15.0}
|
|
33
|
+
if transport is not None:
|
|
34
|
+
kwargs["transport"] = transport
|
|
35
|
+
self._http = httpx.Client(**kwargs)
|
|
36
|
+
self._user_id = user_id
|
|
37
|
+
|
|
38
|
+
def _headers(self) -> dict:
|
|
39
|
+
"""Return request headers that identify the current user, if known."""
|
|
40
|
+
h: dict = {}
|
|
41
|
+
if self._user_id:
|
|
42
|
+
h["X-Skilark-User"] = self._user_id
|
|
43
|
+
return h
|
|
44
|
+
|
|
45
|
+
def create_user(self, topics: list[str]) -> dict:
|
|
46
|
+
"""Register a new CLI user and return the created user object.
|
|
47
|
+
|
|
48
|
+
Called once during ``skilark setup``. The returned dict contains
|
|
49
|
+
at minimum ``id`` (UUID) and ``topics``.
|
|
50
|
+
"""
|
|
51
|
+
resp = self._http.post("/cli/users", json={"topics": topics})
|
|
52
|
+
resp.raise_for_status()
|
|
53
|
+
return resp.json()
|
|
54
|
+
|
|
55
|
+
def get_next_challenge(self, topics: list[str]) -> dict | None:
|
|
56
|
+
"""Fetch the next unseen challenge for the given topics.
|
|
57
|
+
|
|
58
|
+
Returns None when the server responds with 404, meaning there are no
|
|
59
|
+
more challenges available right now (the user has exhausted today's
|
|
60
|
+
pool or there are simply no challenges for the requested topics).
|
|
61
|
+
"""
|
|
62
|
+
resp = self._http.get(
|
|
63
|
+
"/cli/challenges/next",
|
|
64
|
+
params={"topics": ",".join(topics)},
|
|
65
|
+
headers=self._headers(),
|
|
66
|
+
)
|
|
67
|
+
if resp.status_code == 404:
|
|
68
|
+
return None
|
|
69
|
+
resp.raise_for_status()
|
|
70
|
+
return resp.json()
|
|
71
|
+
|
|
72
|
+
def complete_challenge(
|
|
73
|
+
self,
|
|
74
|
+
challenge_id: str,
|
|
75
|
+
answer: str,
|
|
76
|
+
correct: bool,
|
|
77
|
+
self_corrected: bool,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Record the user's attempt for a challenge.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
challenge_id: The challenge UUID returned by get_next_challenge.
|
|
83
|
+
answer: The raw text the user typed.
|
|
84
|
+
correct: Whether the system judged the answer correct.
|
|
85
|
+
self_corrected: Whether the user self-assessed after seeing the
|
|
86
|
+
expected answer (used for partial-credit scoring).
|
|
87
|
+
"""
|
|
88
|
+
resp = self._http.post(
|
|
89
|
+
f"/cli/challenges/{challenge_id}/complete",
|
|
90
|
+
json={
|
|
91
|
+
"answer": answer,
|
|
92
|
+
"correct": correct,
|
|
93
|
+
"self_corrected": self_corrected,
|
|
94
|
+
},
|
|
95
|
+
headers=self._headers(),
|
|
96
|
+
)
|
|
97
|
+
resp.raise_for_status()
|
|
98
|
+
|
|
99
|
+
def get_stats(self) -> dict:
|
|
100
|
+
"""Return practice statistics for the current user.
|
|
101
|
+
|
|
102
|
+
The returned dict contains at minimum ``streak`` and
|
|
103
|
+
``total_completed``.
|
|
104
|
+
"""
|
|
105
|
+
resp = self._http.get("/cli/users/me/stats", headers=self._headers())
|
|
106
|
+
resp.raise_for_status()
|
|
107
|
+
return resp.json()
|
|
File without changes
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""
|
|
2
|
+
`skilark config` command.
|
|
3
|
+
|
|
4
|
+
Allows the user to interactively update their topic preferences.
|
|
5
|
+
Presents a pre-populated checkbox prompt showing current selections,
|
|
6
|
+
then persists any changes to the config file on disk.
|
|
7
|
+
Exits early with a friendly message if the user has not yet run setup.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from InquirerPy import inquirer
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
from skilark_cli.config_store import ConfigStore
|
|
14
|
+
from skilark_cli.onboarding import TOPIC_CHOICES
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run() -> None:
|
|
20
|
+
"""Interactively update the user's topic preferences."""
|
|
21
|
+
config_store = ConfigStore()
|
|
22
|
+
|
|
23
|
+
if config_store.is_first_run():
|
|
24
|
+
console.print("[dim]Run 'skilark today' first to get started.[/dim]")
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
config = config_store.load()
|
|
28
|
+
current_topics = set(config.get("topics", []))
|
|
29
|
+
|
|
30
|
+
console.print(f"\n[bold]Current topics:[/bold] {', '.join(sorted(current_topics))}\n")
|
|
31
|
+
|
|
32
|
+
choices = [
|
|
33
|
+
{"name": t["name"], "value": t["value"], "enabled": t["value"] in current_topics}
|
|
34
|
+
for t in TOPIC_CHOICES
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
selected = inquirer.checkbox(
|
|
38
|
+
message="Update topics (space to toggle, enter to confirm):",
|
|
39
|
+
choices=choices,
|
|
40
|
+
validate=lambda result: len(result) >= 1,
|
|
41
|
+
invalid_message="Pick at least one topic.",
|
|
42
|
+
).execute()
|
|
43
|
+
|
|
44
|
+
config_store.update_topics(selected)
|
|
45
|
+
|
|
46
|
+
added = set(selected) - current_topics
|
|
47
|
+
removed = current_topics - set(selected)
|
|
48
|
+
if added:
|
|
49
|
+
console.print(f"[green]+ Added:[/green] {', '.join(sorted(added))}")
|
|
50
|
+
if removed:
|
|
51
|
+
console.print(f"[red]- Removed:[/red] {', '.join(sorted(removed))}")
|
|
52
|
+
if not added and not removed:
|
|
53
|
+
console.print("[dim]No changes.[/dim]")
|
|
54
|
+
console.print()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""
|
|
2
|
+
`skilark status` command.
|
|
3
|
+
|
|
4
|
+
Fetches and displays the user's learning statistics (streak, weekly
|
|
5
|
+
progress, per-topic breakdown, last session) from the Skilark API.
|
|
6
|
+
Exits early with a friendly message if the user has not yet run setup.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from skilark_cli.client import SkilarkClient
|
|
12
|
+
from skilark_cli.config_store import ConfigStore
|
|
13
|
+
from skilark_cli.display import render_stats
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run() -> None:
|
|
19
|
+
"""Display the current user's learning statistics."""
|
|
20
|
+
config_store = ConfigStore()
|
|
21
|
+
|
|
22
|
+
if config_store.is_first_run():
|
|
23
|
+
console.print("[dim]Run 'skilark today' first to get started.[/dim]")
|
|
24
|
+
return
|
|
25
|
+
|
|
26
|
+
config = config_store.load()
|
|
27
|
+
client = SkilarkClient(base_url=config["api_url"], user_id=config["user_id"])
|
|
28
|
+
stats = client.get_stats()
|
|
29
|
+
render_stats(console, stats)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""
|
|
2
|
+
today command — the core challenge loop.
|
|
3
|
+
|
|
4
|
+
Entry point: `skilark` or `skilark today`
|
|
5
|
+
|
|
6
|
+
Fetches the next unseen challenge, prompts for an answer, checks it,
|
|
7
|
+
records the result, and offers another round. The interactive loop is
|
|
8
|
+
extracted into `run_challenge_loop()` so tests can call it directly
|
|
9
|
+
without touching ConfigStore or performing network I/O.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.prompt import Confirm, Prompt
|
|
16
|
+
|
|
17
|
+
from skilark_cli.answer_checker import check_answer
|
|
18
|
+
from skilark_cli.client import SkilarkClient
|
|
19
|
+
from skilark_cli.config_store import ConfigStore
|
|
20
|
+
from skilark_cli.display import render_challenge, render_hint, render_result
|
|
21
|
+
from skilark_cli.onboarding import run_onboarding
|
|
22
|
+
|
|
23
|
+
console = Console()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run() -> None:
|
|
27
|
+
"""Entry point called from main.py for `skilark today`."""
|
|
28
|
+
config_store = ConfigStore()
|
|
29
|
+
|
|
30
|
+
if config_store.is_first_run():
|
|
31
|
+
run_onboarding(config_store, api_url="https://api.skilark.com")
|
|
32
|
+
|
|
33
|
+
config = config_store.load()
|
|
34
|
+
client = SkilarkClient(base_url=config["api_url"], user_id=config["user_id"])
|
|
35
|
+
|
|
36
|
+
# Use total_completed to drive the displayed day counter. If the stats
|
|
37
|
+
# endpoint is unavailable (e.g. no network), fall back to day 1 silently
|
|
38
|
+
# so the user can still practice.
|
|
39
|
+
try:
|
|
40
|
+
stats = client.get_stats()
|
|
41
|
+
day = stats.get("total_completed", 0) + 1
|
|
42
|
+
except Exception:
|
|
43
|
+
day = 1
|
|
44
|
+
|
|
45
|
+
run_challenge_loop(client, topics=config["topics"], day=day)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def run_challenge_loop(client: SkilarkClient, topics: list[str], day: int) -> None:
|
|
49
|
+
"""Interactive challenge loop.
|
|
50
|
+
|
|
51
|
+
Fetches one challenge at a time, handles the answer/hint/skip/quit
|
|
52
|
+
interactions, records completions, and asks whether to continue.
|
|
53
|
+
|
|
54
|
+
Separated from `run()` so tests can inject a mock client and drive the
|
|
55
|
+
loop via patched Rich prompts without touching the filesystem or network.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
client: SkilarkClient instance (or mock in tests).
|
|
59
|
+
topics: Topic filter passed to get_next_challenge.
|
|
60
|
+
day: Starting day number displayed in the challenge header.
|
|
61
|
+
"""
|
|
62
|
+
while True:
|
|
63
|
+
challenge = client.get_next_challenge(topics=topics)
|
|
64
|
+
if not challenge:
|
|
65
|
+
console.print(
|
|
66
|
+
"\n[dim]No more challenges available for your topics. "
|
|
67
|
+
"Try adding more![/dim]\n"
|
|
68
|
+
)
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
render_challenge(console, challenge, day=day)
|
|
72
|
+
|
|
73
|
+
# Hints may arrive as a JSON string or as an already-parsed list.
|
|
74
|
+
# Normalise to a list here so the rest of the loop is clean.
|
|
75
|
+
hints = challenge.get("hints") or []
|
|
76
|
+
if isinstance(hints, str):
|
|
77
|
+
try:
|
|
78
|
+
hints = json.loads(hints)
|
|
79
|
+
except (json.JSONDecodeError, TypeError):
|
|
80
|
+
hints = []
|
|
81
|
+
|
|
82
|
+
hint_idx = 0
|
|
83
|
+
|
|
84
|
+
while True:
|
|
85
|
+
answer = Prompt.ask(" Your answer")
|
|
86
|
+
|
|
87
|
+
if answer.lower() == "h":
|
|
88
|
+
if hint_idx < len(hints):
|
|
89
|
+
render_hint(console, hints[hint_idx])
|
|
90
|
+
hint_idx += 1
|
|
91
|
+
else:
|
|
92
|
+
console.print("[dim] No more hints.[/dim]\n")
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
if answer.lower() == "s":
|
|
96
|
+
console.print("[dim] Skipped.[/dim]\n")
|
|
97
|
+
break
|
|
98
|
+
|
|
99
|
+
if answer.lower() == "q":
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
correct = check_answer(answer, challenge["expected_answer"])
|
|
103
|
+
self_corrected = False
|
|
104
|
+
|
|
105
|
+
if not correct:
|
|
106
|
+
render_result(
|
|
107
|
+
console,
|
|
108
|
+
correct=False,
|
|
109
|
+
expected_answer=challenge["expected_answer"],
|
|
110
|
+
explanation=challenge["explanation"],
|
|
111
|
+
deep_link=challenge["deep_link"],
|
|
112
|
+
)
|
|
113
|
+
self_corrected = Confirm.ask(
|
|
114
|
+
" Were you actually right?", default=False
|
|
115
|
+
)
|
|
116
|
+
if self_corrected:
|
|
117
|
+
correct = True
|
|
118
|
+
else:
|
|
119
|
+
render_result(
|
|
120
|
+
console,
|
|
121
|
+
correct=True,
|
|
122
|
+
explanation=challenge["explanation"],
|
|
123
|
+
deep_link=challenge["deep_link"],
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
client.complete_challenge(
|
|
127
|
+
challenge["id"],
|
|
128
|
+
answer=answer,
|
|
129
|
+
correct=correct,
|
|
130
|
+
self_corrected=self_corrected,
|
|
131
|
+
)
|
|
132
|
+
break
|
|
133
|
+
|
|
134
|
+
day += 1
|
|
135
|
+
|
|
136
|
+
if not Confirm.ask("\n Another?", default=True):
|
|
137
|
+
console.print("[dim] See you tomorrow.[/dim]\n")
|
|
138
|
+
return
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ConfigStore: reads and writes ~/.skilark/config.yaml.
|
|
3
|
+
|
|
4
|
+
The config file is created on first setup and contains:
|
|
5
|
+
user_id - UUID assigned by the API on registration
|
|
6
|
+
topics - list of skill topics the user cares about
|
|
7
|
+
api_url - base URL for the Skilark API (default: production)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
DEFAULT_API_URL = "https://api.skilark.com"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConfigStore:
|
|
18
|
+
"""Manages the on-disk configuration for the Skilark CLI.
|
|
19
|
+
|
|
20
|
+
Uses a configurable config_dir so that tests can point at a tmp_path
|
|
21
|
+
instead of touching the real ~/.skilark directory.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, config_dir: Path | None = None):
|
|
25
|
+
self.config_dir = config_dir or Path.home() / ".skilark"
|
|
26
|
+
self.config_file = self.config_dir / "config.yaml"
|
|
27
|
+
|
|
28
|
+
def is_first_run(self) -> bool:
|
|
29
|
+
"""Return True if no config file exists yet (i.e. not yet set up)."""
|
|
30
|
+
return not self.config_file.exists()
|
|
31
|
+
|
|
32
|
+
def save(
|
|
33
|
+
self,
|
|
34
|
+
user_id: str,
|
|
35
|
+
topics: list[str],
|
|
36
|
+
api_url: str = DEFAULT_API_URL,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Write a fresh config file, creating the directory if needed.
|
|
39
|
+
|
|
40
|
+
Overwrites any existing config entirely — intended for initial setup
|
|
41
|
+
or a full reset.
|
|
42
|
+
"""
|
|
43
|
+
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
data = {"user_id": user_id, "topics": topics, "api_url": api_url}
|
|
45
|
+
self.config_file.write_text(yaml.dump(data, default_flow_style=False))
|
|
46
|
+
|
|
47
|
+
def load(self) -> dict:
|
|
48
|
+
"""Load and return the config as a plain dict.
|
|
49
|
+
|
|
50
|
+
Raises FileNotFoundError if is_first_run() is True (file not yet
|
|
51
|
+
created). Callers should check is_first_run() before calling load().
|
|
52
|
+
"""
|
|
53
|
+
return yaml.safe_load(self.config_file.read_text())
|
|
54
|
+
|
|
55
|
+
def update_topics(self, topics: list[str]) -> None:
|
|
56
|
+
"""Replace the stored topic list without touching other fields.
|
|
57
|
+
|
|
58
|
+
Raises FileNotFoundError if config has not been created yet.
|
|
59
|
+
"""
|
|
60
|
+
config = self.load()
|
|
61
|
+
config["topics"] = topics
|
|
62
|
+
self.config_file.write_text(yaml.dump(config, default_flow_style=False))
|