wtf-dev 0.1.0__py3-none-any.whl

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.
src/__init__.py ADDED
File without changes
src/cli.py ADDED
@@ -0,0 +1,246 @@
1
+ import json
2
+ from datetime import datetime
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ import pyperclip
7
+ import typer
8
+
9
+ from . import formatter, storage
10
+ from .git import (
11
+ find_git_repos,
12
+ get_commit_streak,
13
+ get_current_branch,
14
+ get_git_commits,
15
+ get_git_diff,
16
+ get_git_diff_stat,
17
+ get_git_user,
18
+ )
19
+ from .llm import analyze_commits, get_model
20
+ from .models import Commit, RepoSummary, StandupResult, TimeStats, WipSummary
21
+
22
+ app = typer.Typer(add_completion=False, invoke_without_command=True)
23
+
24
+
25
+ @app.command()
26
+ def setup():
27
+ from .setup import run_setup
28
+
29
+ run_setup()
30
+
31
+
32
+ @app.callback(invoke_without_command=True)
33
+ def main(
34
+ ctx: typer.Context,
35
+ dir: Optional[Path] = typer.Option(None, "--dir", "-d"),
36
+ here: bool = typer.Option(False, "--here", "-H"),
37
+ days: int = typer.Option(1, "--days", "-n"),
38
+ author: Optional[str] = typer.Option(None, "--author", "-a"),
39
+ copy: bool = typer.Option(False, "--copy", "-c"),
40
+ spending: bool = typer.Option(False, "--spending"),
41
+ history: bool = typer.Option(False, "--history"),
42
+ json_out: bool = typer.Option(False, "--json"),
43
+ ):
44
+ # if a subcommand was invoked, skip main logic
45
+ if ctx.invoked_subcommand is not None:
46
+ return
47
+
48
+ # check if configured, run setup if not
49
+ if not storage.is_configured():
50
+ formatter.console.print("[yellow]First time? Let's set up wtf.[/yellow]")
51
+ from .setup import run_setup
52
+
53
+ run_setup()
54
+ return
55
+
56
+ # handle --spending
57
+ if spending:
58
+ total = storage.get_total_spent()
59
+ formatter.render_spending(total)
60
+ return
61
+
62
+ # handle --history
63
+ if history:
64
+ past = storage.load_history()
65
+ for item in past:
66
+ formatter.console.print(f"[dim]{item.generated_at}[/dim]")
67
+ formatter.console.print(item.llm_response.summary)
68
+ formatter.console.print()
69
+ return
70
+
71
+ # main flow
72
+ scan_path = str(dir) if dir else "."
73
+ git_author = author or get_git_user() or "unknown"
74
+
75
+ # handle monday (show fri-sun)
76
+ if datetime.now().weekday() == 0:
77
+ days = max(days, 3)
78
+
79
+ # find repos and commits
80
+ if here:
81
+ repos = [scan_path]
82
+ else:
83
+ repos = find_git_repos(scan_path)
84
+
85
+ summaries = []
86
+ wip_summaries = []
87
+ all_commits = []
88
+
89
+ for repo_path in repos:
90
+ commits = get_commits(repo_path, git_author, f"{days} days ago")
91
+ branch = get_current_branch(repo_path)
92
+
93
+ if commits:
94
+ summaries.append(
95
+ RepoSummary(
96
+ name=Path(repo_path).name,
97
+ path=repo_path,
98
+ commits=commits,
99
+ branch=branch,
100
+ )
101
+ )
102
+ all_commits.extend(commits)
103
+
104
+ # gather wip (uncommitted changes)
105
+ diff_stat = get_git_diff_stat(repo_path)
106
+ if diff_stat:
107
+ diff = get_git_diff(repo_path)
108
+ wip_summaries.append(
109
+ WipSummary(
110
+ repo_name=Path(repo_path).name,
111
+ files_changed=diff_stat,
112
+ diff_preview=diff[:2000],
113
+ )
114
+ )
115
+
116
+ if not summaries and not wip_summaries:
117
+ formatter.console.print("[yellow]No commits found.[/yellow]")
118
+ raise typer.Exit()
119
+
120
+ # calculate time stats
121
+ time_stats = calculate_time_stats(all_commits)
122
+
123
+ # get streak
124
+ streak = get_commit_streak(scan_path, git_author) if summaries else 0
125
+
126
+ # call llm
127
+ commits_text = format_for_llm(summaries) if summaries else "No commits."
128
+ diff_text = format_wip_for_llm(wip_summaries) if wip_summaries else None
129
+
130
+ try:
131
+ llm_response, cost = analyze_commits(commits_text, diff_text)
132
+ storage.add_spending(cost, get_model())
133
+ except Exception as e:
134
+ formatter.console.print(f"[red]LLM error: {e}[/red]")
135
+ raise typer.Exit(1)
136
+
137
+ # build result
138
+ result = StandupResult(
139
+ repos=summaries,
140
+ llm_response=llm_response,
141
+ generated_at=datetime.now(),
142
+ cost_usd=cost,
143
+ wip=wip_summaries,
144
+ time_stats=time_stats,
145
+ streak=streak,
146
+ )
147
+
148
+ # save to history
149
+ storage.save_standup(result)
150
+
151
+ # output
152
+ if json_out:
153
+ print(
154
+ json.dumps(
155
+ {
156
+ "summary": result.llm_response.summary,
157
+ "roast": result.llm_response.roast,
158
+ "repos": [
159
+ {"name": r.name, "commits": len(r.commits)} for r in summaries
160
+ ],
161
+ },
162
+ indent=2,
163
+ )
164
+ )
165
+ else:
166
+ formatter.render(result)
167
+
168
+ # copy to clipboard
169
+ if copy:
170
+ pyperclip.copy(result.llm_response.summary)
171
+ formatter.render_copied()
172
+
173
+
174
+ def get_commits(repo_path: str, author: str, since: str) -> list[Commit]:
175
+ raw = get_git_commits(repo_path, author, since)
176
+ if not raw:
177
+ return []
178
+
179
+ commits = []
180
+ for line in raw.split("\n"):
181
+ if "|" in line:
182
+ parts = line.split("|")
183
+ commits.append(
184
+ Commit(
185
+ hash=parts[0],
186
+ message=parts[1],
187
+ date=parts[2] if len(parts) > 2 else "",
188
+ time=parts[3] if len(parts) > 3 else "",
189
+ repo_name=Path(repo_path).name,
190
+ )
191
+ )
192
+ return commits
193
+
194
+
195
+ def format_for_llm(summaries: list[RepoSummary]) -> str:
196
+ lines = []
197
+ for s in summaries:
198
+ lines.append(f"\n{s.name} ({len(s.commits)} commits):")
199
+ for c in s.commits:
200
+ lines.append(f" - {c.message}")
201
+ return "\n".join(lines)
202
+
203
+
204
+ def format_wip_for_llm(wip_summaries: list[WipSummary]) -> str:
205
+ lines = []
206
+ for wip in wip_summaries:
207
+ lines.append(f"\n{wip.repo_name} ({len(wip.files_changed)} files changed):")
208
+ for f in wip.files_changed[:10]:
209
+ lines.append(f" {f}")
210
+ if wip.diff_preview:
211
+ lines.append(f"\nDiff preview:\n{wip.diff_preview[:1500]}")
212
+ return "\n".join(lines)
213
+
214
+
215
+ def calculate_time_stats(commits: list[Commit]) -> TimeStats:
216
+ if not commits:
217
+ return TimeStats()
218
+
219
+ late_night = 0
220
+ early_morning = 0
221
+
222
+ for c in commits:
223
+ if c.time:
224
+ try:
225
+ # time is in ISO format like 2026-02-02T23:45:00+05:30
226
+ hour = int(c.time[11:13])
227
+ if hour >= 22 or hour < 5:
228
+ late_night += 1
229
+ elif hour < 7:
230
+ early_morning += 1
231
+ except (ValueError, IndexError):
232
+ pass
233
+
234
+ # estimate hours: rough heuristic (15 min per commit minimum)
235
+ estimated_hours = len(commits) * 0.25
236
+
237
+ return TimeStats(
238
+ total_commits=len(commits),
239
+ late_night_commits=late_night,
240
+ early_morning_commits=early_morning,
241
+ estimated_hours=estimated_hours,
242
+ )
243
+
244
+
245
+ if __name__ == "__main__":
246
+ app()
src/formatter.py ADDED
@@ -0,0 +1,111 @@
1
+ import io
2
+ import sys
3
+ from datetime import datetime
4
+
5
+ from rich.console import Console
6
+
7
+ from .models import RepoSummary, StandupResult, WipSummary
8
+
9
+ # force utf-8 for windows (skip during tests)
10
+ if sys.platform == "win32" and "pytest" not in sys.modules:
11
+ try:
12
+ sys.stdout = io.TextIOWrapper(
13
+ sys.stdout.buffer, encoding="utf-8", errors="replace"
14
+ )
15
+ except Exception:
16
+ pass
17
+
18
+ console = Console(force_terminal=True)
19
+
20
+
21
+ def render(result: StandupResult):
22
+ # header - clean, no box
23
+ date_str = datetime.now().strftime("%b %d, %Y")
24
+ console.print()
25
+ console.print(
26
+ f"[bold cyan]PREVIOUSLY ON YOUR CODE...[/bold cyan] [dim]{date_str}[/dim]"
27
+ )
28
+
29
+ # streak
30
+ if result.streak > 1:
31
+ console.print(f" [yellow]* {result.streak} day streak[/yellow]")
32
+
33
+ console.print("[dim]" + "─" * 60 + "[/dim]")
34
+ console.print()
35
+
36
+ # repos as trees
37
+ for repo in result.repos:
38
+ render_repo(repo)
39
+
40
+ # wip section
41
+ if result.wip:
42
+ render_wip(result.wip)
43
+
44
+ # branches
45
+ branches = list(set(r.branch for r in result.repos if r.branch))
46
+ if len(branches) > 1:
47
+ console.print(f" [dim]branches: {', '.join(branches)}[/dim]")
48
+ console.print()
49
+
50
+ # time stats
51
+ if result.time_stats and result.time_stats.late_night_commits > 0:
52
+ console.print(
53
+ f" [dim]* {result.time_stats.late_night_commits} late night commits[/dim]"
54
+ )
55
+ console.print()
56
+
57
+ # summary - clean, no box
58
+ console.print("[dim]" + "─" * 60 + "[/dim]")
59
+ console.print()
60
+ console.print(f" {result.llm_response.summary}")
61
+
62
+ # wip summary from llm
63
+ if result.llm_response.wip_summary:
64
+ console.print()
65
+ console.print(
66
+ f" [magenta]Currently working on:[/magenta] {result.llm_response.wip_summary}"
67
+ )
68
+
69
+ console.print()
70
+ console.print(f" [dim italic]{result.llm_response.roast}[/dim italic]")
71
+ console.print()
72
+
73
+
74
+ def render_repo(repo: RepoSummary):
75
+ # repo header line
76
+ commit_count = len(repo.commits)
77
+ branch_str = f" ({repo.branch})" if repo.branch else ""
78
+ console.print(
79
+ f" [bold]{repo.name}[/bold]{branch_str} [dim]─── {commit_count} commits[/dim]"
80
+ )
81
+
82
+ # tree of commits
83
+ for i, commit in enumerate(repo.commits):
84
+ is_last = i == len(repo.commits) - 1
85
+ prefix = " └─" if is_last else " ├─"
86
+ console.print(f" {prefix} [dim]{commit.message}[/dim]")
87
+
88
+ console.print()
89
+
90
+
91
+ def render_wip(wip_list: list[WipSummary]):
92
+ console.print(" [bold magenta][wip][/bold magenta]")
93
+ for wip in wip_list:
94
+ console.print(
95
+ f" {wip.repo_name} [dim]─── {len(wip.files_changed)} files changed[/dim]"
96
+ )
97
+ for i, f in enumerate(wip.files_changed[:5]):
98
+ is_last = i == len(wip.files_changed[:5]) - 1
99
+ prefix = " └─" if is_last else " ├─"
100
+ console.print(f" {prefix} [dim]{f}[/dim]")
101
+ if len(wip.files_changed) > 5:
102
+ console.print(f" [dim] ... and {len(wip.files_changed) - 5} more[/dim]")
103
+ console.print()
104
+
105
+
106
+ def render_spending(total: float):
107
+ console.print(f"[dim]Total API spending: ${total:.6f}[/dim]")
108
+
109
+
110
+ def render_copied():
111
+ console.print("[dim]copied to clipboard[/dim]")
src/git.py ADDED
@@ -0,0 +1,187 @@
1
+ import os
2
+ import subprocess
3
+
4
+
5
+ def find_git_repos(start_path):
6
+ # find all git repositories in directory and subdirectories
7
+ repos = []
8
+ for root, dirs, files in os.walk(start_path):
9
+ if ".git" in dirs:
10
+ repos.append(root)
11
+ # skip searching inside .git folders
12
+ dirs[:] = [d for d in dirs if d != ".git"]
13
+ return repos
14
+
15
+
16
+ def get_git_user():
17
+ # fetch configured git user name
18
+ try:
19
+ user = subprocess.check_output(["git", "config", "user.name"]).decode().strip()
20
+ except subprocess.CalledProcessError:
21
+ user = None
22
+ return user
23
+
24
+
25
+ def get_git_email():
26
+ # fetch configured git user email
27
+ try:
28
+ email = (
29
+ subprocess.check_output(["git", "config", "user.email"]).decode().strip()
30
+ )
31
+ except subprocess.CalledProcessError:
32
+ email = None
33
+ return email
34
+
35
+
36
+ def get_git_branch():
37
+ # fetch current git branch
38
+ try:
39
+ branch = (
40
+ subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"])
41
+ .decode()
42
+ .strip()
43
+ )
44
+ except subprocess.CalledProcessError:
45
+ branch = None
46
+ return branch
47
+
48
+
49
+ def get_git_status():
50
+ # fetch current git status
51
+ try:
52
+ status = (
53
+ subprocess.check_output(["git", "status", "--porcelain"]).decode().strip()
54
+ )
55
+ except subprocess.CalledProcessError:
56
+ status = None
57
+ return status
58
+
59
+
60
+ def get_git_commits(repo_path, author, start_date):
61
+ # fetch git commits for author and date range
62
+ # format: hash|message|date|iso_time
63
+ try:
64
+ commits = (
65
+ subprocess.check_output(
66
+ [
67
+ "git",
68
+ "log",
69
+ "--author",
70
+ author,
71
+ "--since",
72
+ start_date,
73
+ "--pretty=format:%h|%s|%ad|%aI",
74
+ "--date=short",
75
+ ],
76
+ cwd=repo_path,
77
+ )
78
+ .decode()
79
+ .strip()
80
+ )
81
+ except subprocess.CalledProcessError:
82
+ commits = None
83
+ return commits
84
+
85
+
86
+ def get_git_diff(repo_path: str) -> str:
87
+ # get uncommitted changes (staged + unstaged)
88
+ try:
89
+ diff = (
90
+ subprocess.check_output(
91
+ ["git", "diff", "HEAD"],
92
+ cwd=repo_path,
93
+ stderr=subprocess.DEVNULL,
94
+ )
95
+ .decode(errors="replace")
96
+ .strip()
97
+ )
98
+ except subprocess.CalledProcessError:
99
+ diff = ""
100
+ return diff
101
+
102
+
103
+ def get_git_diff_stat(repo_path: str) -> list[str]:
104
+ # get file change summary (M/A/D with filenames)
105
+ try:
106
+ status = (
107
+ subprocess.check_output(
108
+ ["git", "status", "--porcelain"],
109
+ cwd=repo_path,
110
+ )
111
+ .decode()
112
+ .strip()
113
+ )
114
+ except subprocess.CalledProcessError:
115
+ return []
116
+
117
+ if not status:
118
+ return []
119
+
120
+ files = []
121
+ for line in status.split("\n"):
122
+ if line.strip():
123
+ files.append(line.strip())
124
+ return files
125
+
126
+
127
+ def get_current_branch(repo_path: str) -> str:
128
+ # get current branch name
129
+ try:
130
+ branch = (
131
+ subprocess.check_output(
132
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
133
+ cwd=repo_path,
134
+ )
135
+ .decode()
136
+ .strip()
137
+ )
138
+ except subprocess.CalledProcessError:
139
+ branch = ""
140
+ return branch
141
+
142
+
143
+ def get_commit_streak(repo_path: str, author: str) -> int:
144
+ # count consecutive days with commits (including today)
145
+ from datetime import datetime, timedelta
146
+
147
+ try:
148
+ # get all commit dates for author in last 30 days
149
+ output = (
150
+ subprocess.check_output(
151
+ [
152
+ "git",
153
+ "log",
154
+ "--author",
155
+ author,
156
+ "--since",
157
+ "30 days ago",
158
+ "--pretty=format:%ad",
159
+ "--date=short",
160
+ ],
161
+ cwd=repo_path,
162
+ )
163
+ .decode()
164
+ .strip()
165
+ )
166
+ except subprocess.CalledProcessError:
167
+ return 0
168
+
169
+ if not output:
170
+ return 0
171
+
172
+ # get unique dates
173
+ dates = set(output.split("\n"))
174
+ today = datetime.now().date()
175
+
176
+ # count streak from today backwards
177
+ streak = 0
178
+ check_date = today
179
+ while True:
180
+ date_str = check_date.strftime("%Y-%m-%d")
181
+ if date_str in dates:
182
+ streak += 1
183
+ check_date -= timedelta(days=1)
184
+ else:
185
+ break
186
+
187
+ return streak
src/llm.py ADDED
@@ -0,0 +1,110 @@
1
+ import json
2
+
3
+ import requests
4
+
5
+ from . import storage
6
+ from .models import LLMResponse
7
+
8
+ OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
9
+ DEFAULT_MODEL = "anthropic/claude-3.5-sonnet"
10
+
11
+
12
+ def get_api_key() -> str:
13
+ # get api key from config
14
+ config = storage.load_config()
15
+ if config and config.get("api_key"):
16
+ return config["api_key"]
17
+ return ""
18
+
19
+
20
+ def get_model() -> str:
21
+ # get model from config
22
+ config = storage.load_config()
23
+ if config and config.get("model"):
24
+ return config["model"]
25
+ return DEFAULT_MODEL
26
+
27
+
28
+ SCHEMA = {
29
+ "name": "standup",
30
+ "strict": True,
31
+ "schema": {
32
+ "type": "object",
33
+ "properties": {
34
+ "summary": {
35
+ "type": "string",
36
+ "description": "Standup-ready summary, 2-3 sentences max",
37
+ },
38
+ "roast": {
39
+ "type": "string",
40
+ "description": "One snarky but friendly observation",
41
+ },
42
+ "wip_summary": {
43
+ "type": "string",
44
+ "description": "1 sentence about what uncommitted changes suggest you're working on. Empty string if no diff provided.",
45
+ },
46
+ },
47
+ "required": ["summary", "roast", "wip_summary"],
48
+ "additionalProperties": False,
49
+ },
50
+ }
51
+
52
+ SYSTEM_PROMPT = """You generate standup summaries from git commits.
53
+
54
+ Rules:
55
+ - Summary: 2-3 sentences about committed work, professional but casual
56
+ - Roast: One witty observation about patterns you notice
57
+ - WIP Summary: If diff/uncommitted changes provided, 1 sentence about what's being worked on. Empty if no diff.
58
+ - Be brief. No fluff.
59
+ - Notice: repeated fixes, vague commits, late night work, scattered focus
60
+ """
61
+
62
+
63
+ def analyze_commits(
64
+ commits_text: str, diff_text: str | None = None
65
+ ) -> tuple[LLMResponse, float]:
66
+ # call openrouter
67
+ api_key = get_api_key()
68
+ model = get_model()
69
+
70
+ # build user message with commits and optional diff
71
+ user_content = f"COMMITS:\n{commits_text}"
72
+ if diff_text:
73
+ user_content += f"\n\nUNCOMMITTED CHANGES (diff):\n{diff_text}"
74
+
75
+ payload = {
76
+ "model": model,
77
+ "messages": [
78
+ {"role": "system", "content": SYSTEM_PROMPT},
79
+ {"role": "user", "content": user_content},
80
+ ],
81
+ "response_format": {"type": "json_schema", "json_schema": SCHEMA},
82
+ }
83
+
84
+ # use deepinfra provider for gpt-oss model (cheap + fast)
85
+ if model == "openai/gpt-oss-120b":
86
+ payload["provider"] = {"order": ["DeepInfra"]}
87
+
88
+ response = requests.post(
89
+ OPENROUTER_URL,
90
+ headers={
91
+ "Authorization": f"Bearer {api_key}",
92
+ "Content-Type": "application/json",
93
+ },
94
+ json=payload,
95
+ )
96
+ response.raise_for_status()
97
+ data = response.json()
98
+
99
+ content = json.loads(data["choices"][0]["message"]["content"])
100
+ usage = data.get("usage", {})
101
+ cost = calc_cost(usage)
102
+
103
+ return LLMResponse(**content), cost
104
+
105
+
106
+ def calc_cost(usage: dict) -> float:
107
+ # gpt-oss-120b via deepinfra is very cheap
108
+ prompt = usage.get("prompt_tokens", 0) * 0.0000001
109
+ completion = usage.get("completion_tokens", 0) * 0.0000002
110
+ return prompt + completion
src/models.py ADDED
@@ -0,0 +1,47 @@
1
+ from datetime import datetime
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class Commit(BaseModel):
7
+ hash: str
8
+ message: str
9
+ date: str
10
+ time: str
11
+ repo_name: str
12
+
13
+
14
+ class RepoSummary(BaseModel):
15
+ name: str
16
+ path: str
17
+ commits: list[Commit]
18
+ branch: str = ""
19
+
20
+
21
+ class WipSummary(BaseModel):
22
+ repo_name: str
23
+ files_changed: list[str]
24
+ diff_preview: str
25
+
26
+
27
+ class TimeStats(BaseModel):
28
+ total_commits: int = 0
29
+ late_night_commits: int = 0
30
+ early_morning_commits: int = 0
31
+ estimated_hours: float = 0.0
32
+
33
+
34
+ class LLMResponse(BaseModel):
35
+ summary: str
36
+ roast: str
37
+ wip_summary: str | None = None
38
+
39
+
40
+ class StandupResult(BaseModel):
41
+ repos: list[RepoSummary]
42
+ llm_response: LLMResponse
43
+ generated_at: datetime
44
+ cost_usd: float
45
+ wip: list[WipSummary] = []
46
+ time_stats: TimeStats | None = None
47
+ streak: int = 0
src/setup.py ADDED
@@ -0,0 +1,106 @@
1
+ import requests
2
+ from InquirerPy import inquirer
3
+ from rich.console import Console
4
+ from rich.prompt import Prompt
5
+
6
+ from . import storage
7
+
8
+ console = Console()
9
+
10
+ # popular models to show in selection (cheap + fast first)
11
+ POPULAR_MODELS = [
12
+ "openai/gpt-oss-120b",
13
+ "anthropic/claude-sonnet-4",
14
+ "anthropic/claude-3.5-sonnet",
15
+ "openai/gpt-4o",
16
+ "openai/gpt-4o-mini",
17
+ "google/gemini-2.0-flash-001",
18
+ "meta-llama/llama-3.3-70b-instruct",
19
+ "deepseek/deepseek-chat-v3-0324",
20
+ "deepseek/deepseek-r1",
21
+ "qwen/qwen-2.5-72b-instruct",
22
+ "mistralai/mistral-large-2411",
23
+ ]
24
+
25
+
26
+ def fetch_models(api_key: str) -> list[dict]:
27
+ # fetch models from openrouter api
28
+ response = requests.get(
29
+ "https://openrouter.ai/api/v1/models",
30
+ headers={"Authorization": f"Bearer {api_key}"},
31
+ )
32
+ response.raise_for_status()
33
+ return response.json().get("data", [])
34
+
35
+
36
+ def filter_models(models: list[dict]) -> list[dict]:
37
+ # filter to popular models and sort by our preferred order
38
+ model_map = {m["id"]: m for m in models}
39
+ filtered = []
40
+ for model_id in POPULAR_MODELS:
41
+ if model_id in model_map:
42
+ filtered.append(model_map[model_id])
43
+ return filtered
44
+
45
+
46
+ def format_price(pricing: dict) -> str:
47
+ # format pricing as $X/$Y (input/output per 1M tokens)
48
+ prompt = float(pricing.get("prompt", 0)) * 1_000_000
49
+ completion = float(pricing.get("completion", 0)) * 1_000_000
50
+ return f"${prompt:.2f}/${completion:.2f}"
51
+
52
+
53
+ def run_setup():
54
+ # main setup flow
55
+ console.print("\n[bold]wtf setup[/bold]\n")
56
+
57
+ # prompt for api key
58
+ console.print("Get your API key from: [link]https://openrouter.ai/keys[/link]\n")
59
+ api_key = Prompt.ask("Enter your OpenRouter API key", password=True)
60
+
61
+ if not api_key.strip():
62
+ console.print("[red]API key cannot be empty.[/red]")
63
+ return
64
+
65
+ # validate by fetching models
66
+ console.print("\n[dim]Validating...[/dim]", end=" ")
67
+ try:
68
+ models = fetch_models(api_key)
69
+ console.print("[green]done[/green]\n")
70
+ except requests.exceptions.HTTPError as e:
71
+ if e.response.status_code == 401:
72
+ console.print("[red]invalid key[/red]")
73
+ else:
74
+ console.print(f"[red]error: {e}[/red]")
75
+ return
76
+ except Exception as e:
77
+ console.print(f"[red]error: {e}[/red]")
78
+ return
79
+
80
+ # filter to popular models
81
+ filtered = filter_models(models)
82
+ if not filtered:
83
+ filtered = models[:15]
84
+
85
+ # build choices for interactive picker
86
+ choices = []
87
+ for model in filtered:
88
+ price = format_price(model.get("pricing", {}))
89
+ label = f"{model['id']:<42} {price}"
90
+ choices.append({"name": label, "value": model["id"]})
91
+
92
+ # interactive model picker with arrow keys
93
+ selected_model = inquirer.select(
94
+ message="Select a model:",
95
+ choices=choices,
96
+ default=choices[0]["value"],
97
+ pointer="›",
98
+ ).execute()
99
+
100
+ # save config
101
+ storage.save_config(api_key, selected_model)
102
+
103
+ console.print(
104
+ f"\n[green]Setup complete![/green] Using [bold]{selected_model}[/bold]"
105
+ )
106
+ console.print("[dim]Run `wtf` to get started.[/dim]\n")
src/storage.py ADDED
@@ -0,0 +1,85 @@
1
+ import json
2
+ from datetime import datetime
3
+ from pathlib import Path
4
+
5
+ from .models import StandupResult
6
+
7
+ WTF_DIR = Path.home() / ".wtf"
8
+ HISTORY_DIR = WTF_DIR / "history"
9
+ SPENDING_FILE = WTF_DIR / "spending.json"
10
+ CONFIG_FILE = WTF_DIR / "config.json"
11
+
12
+
13
+ def init_storage():
14
+ # create directories if they don't exist
15
+ WTF_DIR.mkdir(exist_ok=True)
16
+ HISTORY_DIR.mkdir(exist_ok=True)
17
+
18
+
19
+ def save_standup(result: StandupResult):
20
+ # save standup to history
21
+ init_storage()
22
+ filename = f"{datetime.now().strftime('%Y-%m-%d_%H%M%S')}.json"
23
+ path = HISTORY_DIR / filename
24
+ path.write_text(result.model_dump_json(indent=2), encoding="utf-8")
25
+
26
+
27
+ def load_history(limit: int = 10) -> list[StandupResult]:
28
+ # load recent standups
29
+ init_storage()
30
+ files = sorted(HISTORY_DIR.glob("*.json"), reverse=True)[:limit]
31
+ results = []
32
+ for f in files:
33
+ try:
34
+ results.append(
35
+ StandupResult.model_validate_json(f.read_text(encoding="utf-8"))
36
+ )
37
+ except Exception:
38
+ # skip invalid files
39
+ continue
40
+ return results
41
+
42
+
43
+ def add_spending(cost: float, model: str):
44
+ # append spending record
45
+ init_storage()
46
+ records = load_spending()
47
+ records.append(
48
+ {"timestamp": datetime.now().isoformat(), "model": model, "cost": cost}
49
+ )
50
+ SPENDING_FILE.write_text(json.dumps(records, indent=2), encoding="utf-8")
51
+
52
+
53
+ def get_total_spent() -> float:
54
+ # calculate cumulative spending
55
+ records = load_spending()
56
+ return sum(r.get("cost", 0) for r in records)
57
+
58
+
59
+ def load_spending() -> list:
60
+ if not SPENDING_FILE.exists():
61
+ return []
62
+ return json.loads(SPENDING_FILE.read_text(encoding="utf-8"))
63
+
64
+
65
+ def save_config(api_key: str, model: str):
66
+ # save api key and model to config file
67
+ init_storage()
68
+ config = {"api_key": api_key, "model": model}
69
+ CONFIG_FILE.write_text(json.dumps(config, indent=2), encoding="utf-8")
70
+
71
+
72
+ def load_config() -> dict | None:
73
+ # load config from file
74
+ if not CONFIG_FILE.exists():
75
+ return None
76
+ try:
77
+ return json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
78
+ except Exception:
79
+ return None
80
+
81
+
82
+ def is_configured() -> bool:
83
+ # check if api key is configured
84
+ config = load_config()
85
+ return config is not None and bool(config.get("api_key"))
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: wtf-dev
3
+ Version: 0.1.0
4
+ Summary: What did I work on? A snarky standup generator.
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: inquirerpy>=0.3.4
7
+ Requires-Dist: pydantic-settings>=2.12.0
8
+ Requires-Dist: pydantic>=2.12.5
9
+ Requires-Dist: pyperclip>=1.11.0
10
+ Requires-Dist: python-dotenv>=1.2.1
11
+ Requires-Dist: requests>=2.32.5
12
+ Requires-Dist: rich>=14.3.1
13
+ Requires-Dist: typer>=0.21.1
14
+ Description-Content-Type: text/markdown
15
+
16
+ # wtf-dev
17
+
18
+ A CLI tool that tells you what you worked on - with personality.
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ pip install wtf-dev
24
+ ```
25
+
26
+ ## Setup
27
+
28
+ ```bash
29
+ wtf setup
30
+ ```
31
+
32
+ This will prompt for your OpenRouter API key and let you pick a model.
33
+
34
+ ## Usage
35
+
36
+ ```bash
37
+ # what did I do today?
38
+ wtf
39
+
40
+ # look back N days
41
+ wtf --days 3
42
+
43
+ # only current repo
44
+ wtf --here
45
+
46
+ # copy to clipboard
47
+ wtf --copy
48
+ ```
49
+
50
+ ## Features
51
+
52
+ - **Standup summary** - LLM-generated summary of your commits
53
+ - **WIP tracking** - Shows uncommitted changes + what you're currently working on
54
+ - **Streak counter** - Track your commit streak
55
+ - **Late night detection** - Spots those 2am coding sessions
56
+ - **Branch context** - Shows which branches you touched
57
+ - **History** - View past standups with `wtf --history`
58
+ - **Cost tracking** - Track API spending with `wtf --spending`
59
+
60
+ ## Output
61
+
62
+ ```
63
+ PREVIOUSLY ON YOUR CODE... Feb 02, 2026
64
+ * 5 day streak
65
+ ────────────────────────────────────────────────────────────
66
+
67
+ ai-platform (main) ─── 2 commits
68
+ ├─ feat(sdr): add langsmith tracing
69
+ └─ feat(sdr): add automatic follow-up
70
+
71
+ [wip]
72
+ ai-platform ─── 3 files changed
73
+ ├─ M src/api/routes.py
74
+ └─ A src/new_feature.py
75
+
76
+ ────────────────────────────────────────────────────────────
77
+
78
+ Added LangSmith tracing and automatic follow-up for stale
79
+ conversations in the SDR pipeline.
80
+
81
+ Currently working on: Adding new API routes for validation.
82
+
83
+ Two features down, infinite bugs to go.
84
+ ```
85
+
86
+ ## Flags
87
+
88
+ | Flag | Short | Description |
89
+ |------|-------|-------------|
90
+ | `--dir PATH` | `-d` | Scan a specific directory |
91
+ | `--here` | `-H` | Only current repo |
92
+ | `--days N` | `-n` | Look back N days (default: 1) |
93
+ | `--author NAME` | `-a` | Filter by author |
94
+ | `--copy` | `-c` | Copy to clipboard |
95
+ | `--history` | | View past standups |
96
+ | `--spending` | | Show API costs |
97
+ | `--json` | | Output as JSON |
@@ -0,0 +1,12 @@
1
+ src/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ src/cli.py,sha256=fUu7qNE-Ds_7q5lLGpOFKGzwbEy44u5QheL0Q0a0pO4,7188
3
+ src/formatter.py,sha256=ugcK1yRTBnJhDvKLFyk_6zObYHHa_2PTQdcFl7fQmSg,3306
4
+ src/git.py,sha256=GcAdMGFLoi9VPYWsGkj8vEZu3xHvd-2PfLPxb_vJSy4,4847
5
+ src/llm.py,sha256=JVeUgN72UyRcq7NGPkyxwR6fxAB75tLOaCiHJlRvNlw,3307
6
+ src/models.py,sha256=vyPOKJHqV2aRAxpKq8QXyXzTOjXO7F9SREkGXUw2J-A,909
7
+ src/setup.py,sha256=B42Q_HE4pUiUD4KT6-yjuRUyaBsC66zGmqLBnrGANrM,3185
8
+ src/storage.py,sha256=k7ZxoDvAiC627qmjyDB5VNjXKV1gkALd8oim0rFnPeo,2414
9
+ wtf_dev-0.1.0.dist-info/METADATA,sha256=e9MVp1Tj7KlmlWERzAIwytPzQSzi_-irlclOfYpK-hs,2548
10
+ wtf_dev-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
11
+ wtf_dev-0.1.0.dist-info/entry_points.txt,sha256=v7-4hGNrcQvwi7y499ExK1_1PCnNMijBSfFUj5dsRMQ,36
12
+ wtf_dev-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ wtf = src.cli:app