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 +0 -0
- src/cli.py +246 -0
- src/formatter.py +111 -0
- src/git.py +187 -0
- src/llm.py +110 -0
- src/models.py +47 -0
- src/setup.py +106 -0
- src/storage.py +85 -0
- wtf_dev-0.1.0.dist-info/METADATA +97 -0
- wtf_dev-0.1.0.dist-info/RECORD +12 -0
- wtf_dev-0.1.0.dist-info/WHEEL +4 -0
- wtf_dev-0.1.0.dist-info/entry_points.txt +2 -0
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,,
|