gitcast 1.0.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.
- ai/__init__.py +0 -0
- ai/formatter.py +59 -0
- ai/generator.py +604 -0
- ai/prompts.py +197 -0
- ai/viral_patterns.py +75 -0
- api/__init__.py +0 -0
- api/analytics.py +48 -0
- api/auth.py +49 -0
- api/auth_middleware.py +129 -0
- api/auth_routes.py +117 -0
- api/monitoring.py +56 -0
- api/payload.py +253 -0
- api/ratelimit.py +9 -0
- api/routes.py +1565 -0
- api/server.py +162 -0
- api/validators.py +101 -0
- assets/__init__.py +1 -0
- assets/favicon-16x16.png +0 -0
- assets/favicon-32x32.png +0 -0
- assets/favicon-64x64.png +0 -0
- assets/favicon.ico +0 -0
- assets/icon.png +0 -0
- cli/.env.example +26 -0
- cli/__init__.py +1 -0
- cli/gitcast.py +79 -0
- config/__init__.py +0 -0
- config/settings.py +213 -0
- core/__init__.py +0 -0
- core/capture.py +258 -0
- core/codebase_reader.py +90 -0
- core/framing.py +86 -0
- core/hotkey.py +21 -0
- core/log_stream.py +50 -0
- core/ocr.py +173 -0
- core/screenshot_session.py +274 -0
- core/security.py +126 -0
- core/tray.py +54 -0
- gitcast-1.0.0.dist-info/LICENSE +21 -0
- gitcast-1.0.0.dist-info/METADATA +67 -0
- gitcast-1.0.0.dist-info/RECORD +61 -0
- gitcast-1.0.0.dist-info/WHEEL +5 -0
- gitcast-1.0.0.dist-info/entry_points.txt +2 -0
- gitcast-1.0.0.dist-info/top_level.txt +10 -0
- publisher/__init__.py +0 -0
- publisher/clipboard.py +44 -0
- publisher/twitter.py +100 -0
- storage/__init__.py +0 -0
- storage/cleanup.py +60 -0
- storage/engagement.py +114 -0
- storage/insights.py +203 -0
- storage/key_manager.py +45 -0
- storage/logger.py +208 -0
- storage/metrics.py +119 -0
- storage/sprint.py +40 -0
- storage/streak.py +0 -0
- storage/supabase_client.py +25 -0
- storage/tone_memory.py +139 -0
- ui/__init__.py +0 -0
- web/__init__.py +1 -0
- web/index.html +4994 -0
- web/landing.html +925 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import sys
|
|
3
|
+
import webbrowser
|
|
4
|
+
import json
|
|
5
|
+
import threading
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.live import Live
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
|
|
12
|
+
from rich.prompt import Prompt
|
|
13
|
+
from rich import box
|
|
14
|
+
from pynput import keyboard
|
|
15
|
+
from core.capture import capture_active_window, detect_working_directory, get_git_diff
|
|
16
|
+
from core.ocr import run_ocr
|
|
17
|
+
from core.security import scan_for_secrets, delete_capture
|
|
18
|
+
from config.settings import STORAGE_DIR, CURRENT_DRAFT, get_project_narrative
|
|
19
|
+
|
|
20
|
+
console = Console()
|
|
21
|
+
|
|
22
|
+
class ScreenshotSession:
|
|
23
|
+
def __init__(self):
|
|
24
|
+
self.screenshots = []
|
|
25
|
+
self.max_shots = 6
|
|
26
|
+
self.done = False
|
|
27
|
+
self.cancelled = False
|
|
28
|
+
self.working_dir = detect_working_directory()
|
|
29
|
+
self.git_diff = get_git_diff(self.working_dir)
|
|
30
|
+
self.accent_color = "#6366f1"
|
|
31
|
+
self._lock = threading.Lock()
|
|
32
|
+
self._processing = False
|
|
33
|
+
|
|
34
|
+
def run(self):
|
|
35
|
+
"""Starts the interactive terminal session."""
|
|
36
|
+
self.show_header()
|
|
37
|
+
|
|
38
|
+
# Start hotkey listener
|
|
39
|
+
with keyboard.Listener(on_press=self.on_press) as listener:
|
|
40
|
+
try:
|
|
41
|
+
while not self.done and not self.cancelled:
|
|
42
|
+
if len(self.screenshots) >= self.max_shots:
|
|
43
|
+
if not self._processing:
|
|
44
|
+
console.print(f"\n[bold {self.accent_color}]Limit of {self.max_shots} reached. Moving to final review...[/bold {self.accent_color}]")
|
|
45
|
+
time.sleep(1.5)
|
|
46
|
+
self.done = True
|
|
47
|
+
break
|
|
48
|
+
time.sleep(0.1)
|
|
49
|
+
except KeyboardInterrupt:
|
|
50
|
+
self.cancelled = True
|
|
51
|
+
|
|
52
|
+
# Stop listener to free up stdin for Prompts
|
|
53
|
+
listener.stop()
|
|
54
|
+
|
|
55
|
+
if self.cancelled:
|
|
56
|
+
console.print("\n[red]Session cancelled. No screenshots saved.[/red]")
|
|
57
|
+
return []
|
|
58
|
+
|
|
59
|
+
# Remove any screenshots that might have failed
|
|
60
|
+
self.screenshots = [s for s in self.screenshots if s.get("path")]
|
|
61
|
+
|
|
62
|
+
if not self.screenshots:
|
|
63
|
+
console.print("[yellow]No screenshots captured.[/yellow]")
|
|
64
|
+
return []
|
|
65
|
+
|
|
66
|
+
# BATCH REVIEW & TAGGING
|
|
67
|
+
# Now we ask for purposes and keep/discard all at once
|
|
68
|
+
self.review_and_tag_shots()
|
|
69
|
+
|
|
70
|
+
if self.screenshots:
|
|
71
|
+
self.finish_session()
|
|
72
|
+
return self.screenshots
|
|
73
|
+
else:
|
|
74
|
+
console.print("[yellow]All shots discarded. Nothing to post.[/yellow]")
|
|
75
|
+
return []
|
|
76
|
+
|
|
77
|
+
def show_header(self):
|
|
78
|
+
header = Panel(
|
|
79
|
+
f"Capture up to [bold]{self.max_shots}[/bold] screenshots. Tag them all at the end.\n\n"
|
|
80
|
+
f"[bold {self.accent_color}]Ctrl+S[/bold {self.accent_color}] → [white]Capture Now (Instant)[/white]\n"
|
|
81
|
+
f"[bold {self.accent_color}]Ctrl+D[/bold {self.accent_color}] → [white]Done & Review[/white]\n"
|
|
82
|
+
f"[bold {self.accent_color}]Ctrl+C[/bold {self.accent_color}] → [white]Cancel[/white]",
|
|
83
|
+
title=f"[bold {self.accent_color}]GITCAST[/bold {self.accent_color}] · Rapid Capture Mode",
|
|
84
|
+
expand=False,
|
|
85
|
+
border_style=self.accent_color,
|
|
86
|
+
box=box.ROUNDED,
|
|
87
|
+
padding=(1, 2)
|
|
88
|
+
)
|
|
89
|
+
console.print(header)
|
|
90
|
+
|
|
91
|
+
def on_press(self, key):
|
|
92
|
+
"""Handles session hotkeys."""
|
|
93
|
+
try:
|
|
94
|
+
# Ctrl+S (\x13)
|
|
95
|
+
if hasattr(key, 'char') and key.char == '\x13':
|
|
96
|
+
if self._processing:
|
|
97
|
+
# Very brief message so it doesn't clutter
|
|
98
|
+
console.print("[dim]...waiting for current capture...[/dim]", end="\r")
|
|
99
|
+
else:
|
|
100
|
+
threading.Thread(target=self.take_screenshot, daemon=True).start()
|
|
101
|
+
|
|
102
|
+
# Ctrl+D (\x04)
|
|
103
|
+
elif hasattr(key, 'char') and key.char == '\x04':
|
|
104
|
+
if not self._processing:
|
|
105
|
+
self.done = True
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
# Ctrl+C (\x03)
|
|
109
|
+
elif hasattr(key, 'char') and key.char == '\x03':
|
|
110
|
+
self.cancelled = True
|
|
111
|
+
return False
|
|
112
|
+
except Exception:
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
def take_screenshot(self):
|
|
116
|
+
"""Captures window silently and adds to queue."""
|
|
117
|
+
with self._lock:
|
|
118
|
+
self._processing = True
|
|
119
|
+
try:
|
|
120
|
+
# Minimal 1s countdown for focus shift
|
|
121
|
+
for i in range(1, 0, -1):
|
|
122
|
+
time.sleep(0.5)
|
|
123
|
+
|
|
124
|
+
shot = capture_active_window(delay=0.1)
|
|
125
|
+
if not shot["success"]:
|
|
126
|
+
console.print(f"[red]Capture failed: {shot['error']}[/red]")
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
# OCR and Security (Silent progress)
|
|
130
|
+
ocr = run_ocr(shot["path"])
|
|
131
|
+
security = scan_for_secrets(ocr["text"] or ocr["raw_text"])
|
|
132
|
+
|
|
133
|
+
if not security["clean"]:
|
|
134
|
+
console.print(f"\n[red]⚠️ Shot {len(self.screenshots)+1} blocked (sensitive content).[/red]")
|
|
135
|
+
delete_capture(shot["path"])
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
# Add to list with default purpose
|
|
139
|
+
screenshot_data = {
|
|
140
|
+
"path": shot["path"],
|
|
141
|
+
"purpose": "code",
|
|
142
|
+
"ocr_text": ocr["text"] or ocr["raw_text"],
|
|
143
|
+
"confidence": ocr["confidence"],
|
|
144
|
+
"timestamp": shot["timestamp"],
|
|
145
|
+
"index": len(self.screenshots) + 1
|
|
146
|
+
}
|
|
147
|
+
self.screenshots.append(screenshot_data)
|
|
148
|
+
|
|
149
|
+
# Non-blocking success message
|
|
150
|
+
console.print(f"[bold green]✓[/bold green] Captured Shot {len(self.screenshots)} · [dim]Press Ctrl+S for more or Ctrl+D to finish[/dim]")
|
|
151
|
+
|
|
152
|
+
finally:
|
|
153
|
+
self._processing = False
|
|
154
|
+
|
|
155
|
+
def review_and_tag_shots(self):
|
|
156
|
+
"""Final phase where user tags and picks shots."""
|
|
157
|
+
console.print(Panel(
|
|
158
|
+
"REVIEW & TAG YOUR SESSION\n[dim]Assign purposes and decide what to keep[/dim]",
|
|
159
|
+
style=f"bold {self.accent_color}",
|
|
160
|
+
border_style=self.accent_color,
|
|
161
|
+
box=box.DOUBLE
|
|
162
|
+
))
|
|
163
|
+
|
|
164
|
+
keeps = [True] * len(self.screenshots)
|
|
165
|
+
|
|
166
|
+
while True:
|
|
167
|
+
table = Table(show_header=True, border_style=self.accent_color, box=box.ROUNDED)
|
|
168
|
+
table.add_column("ID", justify="center")
|
|
169
|
+
table.add_column("Preview", width=40)
|
|
170
|
+
table.add_column("Purpose")
|
|
171
|
+
table.add_column("Status")
|
|
172
|
+
|
|
173
|
+
for i, (s, keep) in enumerate(zip(self.screenshots, keeps), 1):
|
|
174
|
+
status = "[green]Keep[/green]" if keep else "[red]Discard[/red]"
|
|
175
|
+
check = "[bold green]✓[/bold green]" if keep else "[ ]"
|
|
176
|
+
preview = (s["ocr_text"][:37].replace("\n", " ") + "...") if s["ocr_text"] else "No text"
|
|
177
|
+
table.add_row(f"{check} {i}", preview, f"[bold]{s['purpose']}[/bold]", status)
|
|
178
|
+
|
|
179
|
+
console.print(table)
|
|
180
|
+
console.print("[dim]Commands: [ID] to toggle status | [pID] to set purpose (e.g. p1) | [d] done[/dim]")
|
|
181
|
+
action = Prompt.ask("Action", default="d")
|
|
182
|
+
|
|
183
|
+
if action.lower() == 'd':
|
|
184
|
+
break
|
|
185
|
+
|
|
186
|
+
# Purpose setting (e.g. p1)
|
|
187
|
+
if action.lower().startswith('p') and len(action) > 1:
|
|
188
|
+
try:
|
|
189
|
+
idx = int(action[1:]) - 1
|
|
190
|
+
if 0 <= idx < len(self.screenshots):
|
|
191
|
+
purpose = Prompt.ask(
|
|
192
|
+
f"Purpose for Shot {idx+1}",
|
|
193
|
+
choices=["code", "terminal", "browser", "result", "other"],
|
|
194
|
+
default=self.screenshots[idx]["purpose"]
|
|
195
|
+
)
|
|
196
|
+
self.screenshots[idx]["purpose"] = purpose
|
|
197
|
+
except ValueError:
|
|
198
|
+
pass
|
|
199
|
+
continue
|
|
200
|
+
|
|
201
|
+
# Toggle keep/discard
|
|
202
|
+
try:
|
|
203
|
+
idx = int(action) - 1
|
|
204
|
+
if 0 <= idx < len(keeps):
|
|
205
|
+
keeps[idx] = not keeps[idx]
|
|
206
|
+
except ValueError:
|
|
207
|
+
console.print("[red]Invalid command.[/red]")
|
|
208
|
+
|
|
209
|
+
# Final cleanup
|
|
210
|
+
final_shots = []
|
|
211
|
+
for s, keep in zip(self.screenshots, keeps):
|
|
212
|
+
if keep:
|
|
213
|
+
final_shots.append(s)
|
|
214
|
+
else:
|
|
215
|
+
delete_capture(s["path"])
|
|
216
|
+
|
|
217
|
+
self.screenshots = final_shots
|
|
218
|
+
|
|
219
|
+
def finish_session(self):
|
|
220
|
+
"""Saves session and opens dashboard."""
|
|
221
|
+
console.print(Panel(
|
|
222
|
+
"[bold green]✓ Session Complete[/bold green]\n"
|
|
223
|
+
"Opening dashboard to finish your post...",
|
|
224
|
+
border_style="green",
|
|
225
|
+
box=box.ROUNDED,
|
|
226
|
+
padding=(1, 2)
|
|
227
|
+
))
|
|
228
|
+
|
|
229
|
+
self.save_session_and_generate()
|
|
230
|
+
webbrowser.open("http://localhost:8000")
|
|
231
|
+
|
|
232
|
+
def save_session_and_generate(self):
|
|
233
|
+
"""Background generation of post variations."""
|
|
234
|
+
from api.payload import build_payload
|
|
235
|
+
from ai.generator import generate_posts
|
|
236
|
+
import asyncio
|
|
237
|
+
|
|
238
|
+
payload = build_payload(
|
|
239
|
+
raw_thought="",
|
|
240
|
+
ocr_result={},
|
|
241
|
+
capture_result={"working_dir": self.working_dir, "git_diff": self.git_diff, "screenshot": self.screenshots[0]},
|
|
242
|
+
multi_screenshots=self.screenshots
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
draft_data = {
|
|
246
|
+
"payload": payload,
|
|
247
|
+
"variations": {},
|
|
248
|
+
"timestamp": payload["timestamp"],
|
|
249
|
+
"status": "generating"
|
|
250
|
+
}
|
|
251
|
+
with open(CURRENT_DRAFT, "w", encoding="utf-8") as f:
|
|
252
|
+
json.dump(draft_data, f, indent=4)
|
|
253
|
+
|
|
254
|
+
def run_gen():
|
|
255
|
+
try:
|
|
256
|
+
loop = asyncio.new_event_loop()
|
|
257
|
+
asyncio.set_event_loop(loop)
|
|
258
|
+
variations = loop.run_until_complete(generate_posts(payload))
|
|
259
|
+
draft_data["variations"] = variations
|
|
260
|
+
draft_data["status"] = "ready"
|
|
261
|
+
with open(CURRENT_DRAFT, "w", encoding="utf-8") as f:
|
|
262
|
+
json.dump(draft_data, f, indent=4)
|
|
263
|
+
except Exception as e:
|
|
264
|
+
from core.log_stream import stream_log
|
|
265
|
+
stream_log("Generator", "ERROR", f"Hotkey background generation failed: {e}")
|
|
266
|
+
draft_data["status"] = "failed"
|
|
267
|
+
with open(CURRENT_DRAFT, "w", encoding="utf-8") as f:
|
|
268
|
+
json.dump(draft_data, f, indent=4)
|
|
269
|
+
|
|
270
|
+
threading.Thread(target=run_gen, daemon=True).start()
|
|
271
|
+
|
|
272
|
+
if __name__ == "__main__":
|
|
273
|
+
session = ScreenshotSession()
|
|
274
|
+
session.run()
|
core/security.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from cryptography.fernet import Fernet
|
|
5
|
+
from config.settings import ENCRYPTION_KEY_PATH
|
|
6
|
+
from api.analytics import track
|
|
7
|
+
|
|
8
|
+
# [Security] module for data protection and sensitive content scanning
|
|
9
|
+
|
|
10
|
+
def generate_key():
|
|
11
|
+
"""Creates a new Fernet key and saves it to ENCRYPTION_KEY_PATH."""
|
|
12
|
+
key = Fernet.generate_key()
|
|
13
|
+
ENCRYPTION_KEY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
with open(ENCRYPTION_KEY_PATH, "wb") as key_file:
|
|
15
|
+
key_file.write(key)
|
|
16
|
+
|
|
17
|
+
# Add to .gitignore if not already present
|
|
18
|
+
gitignore_path = Path(".gitignore")
|
|
19
|
+
if gitignore_path.exists():
|
|
20
|
+
with open(gitignore_path, "r") as f:
|
|
21
|
+
content = f.read()
|
|
22
|
+
if ".secret_key" not in content:
|
|
23
|
+
with open(gitignore_path, "a") as f:
|
|
24
|
+
f.write("\n.secret_key\n")
|
|
25
|
+
print("[Security] Added .secret_key to .gitignore")
|
|
26
|
+
|
|
27
|
+
return key
|
|
28
|
+
|
|
29
|
+
def load_key():
|
|
30
|
+
"""Loads the existing key or generates a new one."""
|
|
31
|
+
if not ENCRYPTION_KEY_PATH.exists():
|
|
32
|
+
return generate_key()
|
|
33
|
+
with open(ENCRYPTION_KEY_PATH, "rb") as key_file:
|
|
34
|
+
return key_file.read()
|
|
35
|
+
|
|
36
|
+
def encrypt_file(path: str):
|
|
37
|
+
"""Encrypts a file in place using Fernet symmetric encryption."""
|
|
38
|
+
key = load_key()
|
|
39
|
+
f = Fernet(key)
|
|
40
|
+
with open(path, "rb") as file:
|
|
41
|
+
file_data = file.read()
|
|
42
|
+
encrypted_data = f.encrypt(file_data)
|
|
43
|
+
with open(path, "wb") as file:
|
|
44
|
+
file.write(encrypted_data)
|
|
45
|
+
print(f"[Security] Encrypted: {path}")
|
|
46
|
+
|
|
47
|
+
def decrypt_file(path: str) -> bytes:
|
|
48
|
+
"""Decrypts a file and returns the bytes without writing to disk."""
|
|
49
|
+
key = load_key()
|
|
50
|
+
f = Fernet(key)
|
|
51
|
+
with open(path, "rb") as file:
|
|
52
|
+
encrypted_data = file.read()
|
|
53
|
+
return f.decrypt(encrypted_data)
|
|
54
|
+
|
|
55
|
+
def scan_for_secrets(ocr_text: str) -> dict:
|
|
56
|
+
"""
|
|
57
|
+
Scans OCR text for potential sensitive patterns.
|
|
58
|
+
Returns {"clean": bool, "matches": list}
|
|
59
|
+
"""
|
|
60
|
+
patterns = [
|
|
61
|
+
r"KEY\s*=\s*['\"]?([a-zA-Z0-9_\-]{20,})['\"]?",
|
|
62
|
+
r"TOKEN\s*=\s*['\"]?([a-zA-Z0-9_\-]{20,})['\"]?",
|
|
63
|
+
r"SECRET\s*=\s*['\"]?([a-zA-Z0-9_\-]{20,})['\"]?",
|
|
64
|
+
r"PASSWORD\s*=\s*['\"]?([a-zA-Z0-9_\-]{20,})['\"]?",
|
|
65
|
+
r"Bearer\s+([a-zA-Z0-9_\-\.]{20,})",
|
|
66
|
+
r"sk-[a-zA-Z0-9]{20,}",
|
|
67
|
+
r"pk-[a-zA-Z0-9]{20,}",
|
|
68
|
+
r"[a-fA-F0-9]{32,}", # Long hex strings
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
matches = []
|
|
72
|
+
for pattern in patterns:
|
|
73
|
+
found = re.findall(pattern, ocr_text, re.IGNORECASE)
|
|
74
|
+
if found:
|
|
75
|
+
matches.extend(found)
|
|
76
|
+
|
|
77
|
+
unique_matches = list(set(matches))
|
|
78
|
+
if unique_matches:
|
|
79
|
+
track("sensitive_content_blocked", {"pattern_count": len(unique_matches)})
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
"clean": len(matches) == 0,
|
|
83
|
+
"matches": unique_matches
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
def delete_capture(screenshot_path: str):
|
|
87
|
+
"""Securely deletes a screenshot from disk."""
|
|
88
|
+
try:
|
|
89
|
+
path = Path(screenshot_path)
|
|
90
|
+
if path.exists():
|
|
91
|
+
# Overwrite with random data before deleting (basic secure delete)
|
|
92
|
+
size = path.stat().st_size
|
|
93
|
+
with open(path, "wb") as f:
|
|
94
|
+
f.write(os.urandom(size))
|
|
95
|
+
path.unlink()
|
|
96
|
+
print(f"[Security] Securely deleted: {screenshot_path}")
|
|
97
|
+
except Exception as e:
|
|
98
|
+
print(f"[Security] Error deleting {screenshot_path}: {e}")
|
|
99
|
+
|
|
100
|
+
if __name__ == "__main__":
|
|
101
|
+
print("=== SECURITY MODULE TEST ===")
|
|
102
|
+
|
|
103
|
+
# Test encryption/decryption
|
|
104
|
+
test_file = "test_security.png"
|
|
105
|
+
with open(test_file, "wb") as f:
|
|
106
|
+
f.write(b"fake image data")
|
|
107
|
+
|
|
108
|
+
print("Original data: b'fake image data'")
|
|
109
|
+
encrypt_file(test_file)
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
decrypted = decrypt_file(test_file)
|
|
113
|
+
print(f"Decrypted data: {decrypted}")
|
|
114
|
+
assert decrypted == b"fake image data"
|
|
115
|
+
print("Encryption/Decryption test: PASSED")
|
|
116
|
+
finally:
|
|
117
|
+
if os.path.exists(test_file):
|
|
118
|
+
os.remove(test_file)
|
|
119
|
+
|
|
120
|
+
# Test secret scanning
|
|
121
|
+
test_text = "My API key is KEY=sk-1234567890abcdef1234567890 and my token is Bearer some-long-token-string-here"
|
|
122
|
+
scan_results = scan_for_secrets(test_text)
|
|
123
|
+
print(f"Scan results: {scan_results}")
|
|
124
|
+
assert not scan_results["clean"]
|
|
125
|
+
assert len(scan_results["matches"]) >= 2
|
|
126
|
+
print("Secret scanning test: PASSED")
|
core/tray.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import pystray
|
|
2
|
+
import os
|
|
3
|
+
from PIL import Image, ImageDraw
|
|
4
|
+
from pystray import MenuItem as item
|
|
5
|
+
import webbrowser
|
|
6
|
+
from .hotkey import start_hotkey_listener, stop_hotkey_listener
|
|
7
|
+
|
|
8
|
+
_icon = None
|
|
9
|
+
|
|
10
|
+
def create_image():
|
|
11
|
+
# Generate a simple icon
|
|
12
|
+
width = 64
|
|
13
|
+
height = 64
|
|
14
|
+
color1 = (0, 128, 255)
|
|
15
|
+
color2 = (255, 255, 255)
|
|
16
|
+
image = Image.new('RGB', (width, height), color1)
|
|
17
|
+
dc = ImageDraw.Draw(image)
|
|
18
|
+
dc.rectangle(
|
|
19
|
+
(width // 4, height // 4, width * 3 // 4, height * 3 // 4),
|
|
20
|
+
fill=color2
|
|
21
|
+
)
|
|
22
|
+
return image
|
|
23
|
+
|
|
24
|
+
def run_tray(trigger_callback):
|
|
25
|
+
global _icon
|
|
26
|
+
# Start the hotkey listener
|
|
27
|
+
start_hotkey_listener(trigger_callback)
|
|
28
|
+
|
|
29
|
+
def on_quit(icon, item):
|
|
30
|
+
stop_tray()
|
|
31
|
+
os._exit(0)
|
|
32
|
+
|
|
33
|
+
def trigger_action(icon, item):
|
|
34
|
+
trigger_callback()
|
|
35
|
+
|
|
36
|
+
def open_dashboard(icon, item):
|
|
37
|
+
webbrowser.open("http://127.0.0.1:8000")
|
|
38
|
+
|
|
39
|
+
image = create_image()
|
|
40
|
+
menu = pystray.Menu(
|
|
41
|
+
item('Dashboard', open_dashboard),
|
|
42
|
+
item('Trigger Capture (Ctrl+Alt+S)', trigger_action),
|
|
43
|
+
item('Quit', on_quit)
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
_icon = pystray.Icon("gitcast", image, "Gitcast", menu)
|
|
47
|
+
_icon.run()
|
|
48
|
+
|
|
49
|
+
def stop_tray():
|
|
50
|
+
global _icon
|
|
51
|
+
stop_hotkey_listener()
|
|
52
|
+
if _icon:
|
|
53
|
+
_icon.stop()
|
|
54
|
+
print("[Tray] Icon stopped.")
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Timilehin Agoro
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: gitcast
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: git diff → published post. under 60 seconds.
|
|
5
|
+
Home-page: https://github.com/drizzy765/gitcast
|
|
6
|
+
Author: Timilehin Agoro
|
|
7
|
+
Author-email: agorotimilehi05@gmail.com
|
|
8
|
+
License: MIT
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: Pillow
|
|
13
|
+
Requires-Dist: cryptography
|
|
14
|
+
Requires-Dist: fastapi
|
|
15
|
+
Requires-Dist: groq
|
|
16
|
+
Requires-Dist: httpx
|
|
17
|
+
Requires-Dist: keyboard
|
|
18
|
+
Requires-Dist: limits
|
|
19
|
+
Requires-Dist: mss
|
|
20
|
+
Requires-Dist: posthog
|
|
21
|
+
Requires-Dist: pynput
|
|
22
|
+
Requires-Dist: pystray
|
|
23
|
+
Requires-Dist: pytesseract
|
|
24
|
+
Requires-Dist: python-dotenv
|
|
25
|
+
Requires-Dist: python-jose[cryptography]
|
|
26
|
+
Requires-Dist: rich
|
|
27
|
+
Requires-Dist: sentry-sdk[fastapi]
|
|
28
|
+
Requires-Dist: slowapi
|
|
29
|
+
Requires-Dist: supabase
|
|
30
|
+
Requires-Dist: tweepy
|
|
31
|
+
Requires-Dist: uvicorn
|
|
32
|
+
|
|
33
|
+
# Gitcast
|
|
34
|
+
|
|
35
|
+
On-demand, privacy-first desktop utility that captures active window context,
|
|
36
|
+
extracts code changes, and generates platform-ready X (Twitter) posts with one keystroke.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install gitcast
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Setup
|
|
45
|
+
|
|
46
|
+
1. Install Tesseract OCR:
|
|
47
|
+
- Windows: https://github.com/UB-Mannheim/tesseract/wiki
|
|
48
|
+
- Mac: `brew install tesseract`
|
|
49
|
+
- Linux: `sudo apt install tesseract-ocr`
|
|
50
|
+
|
|
51
|
+
2. Add your API keys:
|
|
52
|
+
```bash
|
|
53
|
+
gitcast --setup
|
|
54
|
+
```
|
|
55
|
+
(opens `.env` file for editing)
|
|
56
|
+
|
|
57
|
+
3. Run:
|
|
58
|
+
```bash
|
|
59
|
+
gitcast
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Dashboard opens at http://127.0.0.1:8000
|
|
63
|
+
Press Ctrl+Shift+P from anywhere to capture.
|
|
64
|
+
|
|
65
|
+
## Stack
|
|
66
|
+
- Python 3.11+
|
|
67
|
+
- FastAPI, pynput, mss, Tesseract, Groq API, Tweepy
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
ai/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
ai/formatter.py,sha256=1bfPCzRiOsJ_M2_aOJsb2xCkx-SFUK2bVBCpZCRRdo4,2338
|
|
3
|
+
ai/generator.py,sha256=25qcPlRATAU3CpeojDQhTFS8RbhlsckAnjItBWLU1KY,24443
|
|
4
|
+
ai/prompts.py,sha256=uBa8FQsbd7TlUhrqe-MMku83sHr_ZmCcb1zkFO6jeP8,10304
|
|
5
|
+
ai/viral_patterns.py,sha256=tm7236kOAwtqtAp5fiQs4tEPrq_KE9Y1EUrZNyolh8c,3879
|
|
6
|
+
api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
api/analytics.py,sha256=3MfmZmP2kt77BZ5FQ7ce13TMQ2vJYbiDCMrBcx7sQyA,1198
|
|
8
|
+
api/auth.py,sha256=DM2ZtXHOCvZbRjaxp336B3mlzYKDUhSlXP06GQSOhvM,1498
|
|
9
|
+
api/auth_middleware.py,sha256=RuHD8YxHV_SizRXXKDpqDw2j5SHN5k6_N4erfakCdlU,4075
|
|
10
|
+
api/auth_routes.py,sha256=fYMAbL3DKyD4sANhFkCcAqveCgnSCPkYI_FH9nMaSp4,4023
|
|
11
|
+
api/monitoring.py,sha256=iWvqPYUiq6BATd1DMRKuYent38LJOUnW8oZWs_jH24s,1559
|
|
12
|
+
api/payload.py,sha256=jLxMZMM3s0-ZpqO0s662bY8cfHjgm7MVKUVaXmVX7jU,9651
|
|
13
|
+
api/ratelimit.py,sha256=7EeFRlsvQFc1mOnvd5BBzabT98pbbhkS61ujP4CputM,253
|
|
14
|
+
api/routes.py,sha256=DlKHeeYVzpdKamz3tfKNkyr3lbtKEOvAoWDq9uw7dlU,57814
|
|
15
|
+
api/server.py,sha256=N9f0qHksNiFCp6-c6yAJ1ClkBORXjupTAxaAeIUlC3g,5499
|
|
16
|
+
api/validators.py,sha256=ENVAw9AWGAS3g4Ro3UKFGRQdrCwOdxOyRQ8ggmqIdg4,2907
|
|
17
|
+
assets/__init__.py,sha256=L582TvLegqQlplr9qawaiDbbFBQT6HuZxvNZygQieoY,93
|
|
18
|
+
assets/favicon-16x16.png,sha256=3xo2x9dQmGsdJvWpCEK5d8WveCxW9wm1yS-7dN1GpIg,272
|
|
19
|
+
assets/favicon-32x32.png,sha256=91im3NSOf4sDViP9i2GfoTcfiKbuWwK3BOqguG62rVo,613
|
|
20
|
+
assets/favicon-64x64.png,sha256=VRwOkiPyjiCtA4Lu4b8PwjuWtWLmGatv5B6JkQA3UCc,1292
|
|
21
|
+
assets/favicon.ico,sha256=VUkA-33GjavIqwoKtQEHBNuUFwqCuWp0i3lmbeX3sj4,2916
|
|
22
|
+
assets/icon.png,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
23
|
+
cli/.env.example,sha256=0msnLTS8DyYpT9t-BuUgMWjADgvkIHdLAIqL0ex6OJE,955
|
|
24
|
+
cli/__init__.py,sha256=QHJaTQVcqGsvBTXZQN2GFSjZLYQImJg5Xwqj2DzR5IA,14
|
|
25
|
+
cli/gitcast.py,sha256=217btuKYK6ysC5Bi6bFKjqJ-qfVsdHxpn0ow8sSuCXE,2824
|
|
26
|
+
config/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
27
|
+
config/settings.py,sha256=YNGPQLHGYj6Rst_1wUr0EhtHhOeKLGj0zSWThHHonQo,6406
|
|
28
|
+
core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
29
|
+
core/capture.py,sha256=QUg_j_M6RahKoLpNejpLId3Gj287af49jVlfqnxwrqE,8783
|
|
30
|
+
core/codebase_reader.py,sha256=7WkboJ3emE_6InN97vQpOu83vExO-iVaPkf3xDx1BHc,3503
|
|
31
|
+
core/framing.py,sha256=pGZQNWmYTINa-eDyUbY1NCkIwVZzuUmmnzrmR9kN9cg,2923
|
|
32
|
+
core/hotkey.py,sha256=V22jTfWdo1pgszY83uT75DrMr4wsbA_rIJKxw8R4Tmg,438
|
|
33
|
+
core/log_stream.py,sha256=aWo5F2cGVSwuGBg4AtrdGAcSm2qpLBcp-_caRfjm6nM,1227
|
|
34
|
+
core/ocr.py,sha256=P30R7lVHvvF-8lKGTKXgu-6r6lTenORt8ENiD0g76P8,6130
|
|
35
|
+
core/screenshot_session.py,sha256=9xZ8akYYc7WmK-FQeLuAwCEOY23iViSylf5RhUjlew4,10869
|
|
36
|
+
core/security.py,sha256=JU6d03pmgcZno3eozPjUar307xCOud8CcxAhvDRNQ5s,4258
|
|
37
|
+
core/tray.py,sha256=Gam9lylleXZYr-3OHJ7QxBfNj3hVpU5Nh_193I13XDc,1293
|
|
38
|
+
publisher/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
39
|
+
publisher/clipboard.py,sha256=oQrPPl9wZgtAd4DBz3HpgHSaLFpsNFHtoV9JT5vIkLE,1626
|
|
40
|
+
publisher/twitter.py,sha256=QUara_G-8jTPTkEYTGFrUbQxjSZCMLwRtPIdJNwRmfo,3355
|
|
41
|
+
storage/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
42
|
+
storage/cleanup.py,sha256=9UWuH4mjK2SySfOeYdOs1vB4Yq1ixLGFDWdT_bRT5Nc,2221
|
|
43
|
+
storage/engagement.py,sha256=m00ecVngQu6UElsd-Ax9s32ITUkT4Bf_RB-TqImM1BA,4120
|
|
44
|
+
storage/insights.py,sha256=i2TQRbJ7Tf6HEPDgCiCCGzYGklDcnGRC62ofN18ADms,7882
|
|
45
|
+
storage/key_manager.py,sha256=D2ceDV5judPaPP6aANwqE0Xa6w8DLc73qB3LYKbcfeE,1278
|
|
46
|
+
storage/logger.py,sha256=-J4r1ELkCxjWuUNsZbBQZ8gaN1vZSbEdNXDuVPZ3lPc,6496
|
|
47
|
+
storage/metrics.py,sha256=r1ZKr2OP82-Os-tyMPchZhaqO_6I5q7M60Z_hwfdiks,3976
|
|
48
|
+
storage/sprint.py,sha256=dsVIMe0BWhxV65popfg2zjjRoDWB6mJS-bl9FJ5y4Ww,1019
|
|
49
|
+
storage/streak.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
50
|
+
storage/supabase_client.py,sha256=IBaCcmWfj49aOS1jUdyhKkDzaDw1BEaH4LZ4LLORK0U,791
|
|
51
|
+
storage/tone_memory.py,sha256=hr6xOO20GhBwhZam_OHLRAypM0gKphTeXAnU3BZFb3g,4721
|
|
52
|
+
ui/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
53
|
+
web/__init__.py,sha256=eOuhaq0eKt3yb_CUJcLmQZorRDsoZ0c08yG4FgPrPzw,90
|
|
54
|
+
web/index.html,sha256=ap1AI_03XihVgDxh-YWE84OocXrYgkCRii3TSjuVFCY,210179
|
|
55
|
+
web/landing.html,sha256=ouCDxNpaFzM-hKsylty-T8JPXPBXP0q5c-SU_RXFpco,34469
|
|
56
|
+
gitcast-1.0.0.dist-info/LICENSE,sha256=BekKKCXOSkFiRzT17cdBpdSqjKhfXkNAYhroTwa1Y78,1072
|
|
57
|
+
gitcast-1.0.0.dist-info/METADATA,sha256=s4WUmFX_Fk-vqBI8YOVb_Dg4r1fts4gH1f-a0WevzOM,1508
|
|
58
|
+
gitcast-1.0.0.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
|
|
59
|
+
gitcast-1.0.0.dist-info/entry_points.txt,sha256=Qu4EahnnTiLiONI6OfXoanLMf2n-vhamtcDvqvAni_Q,45
|
|
60
|
+
gitcast-1.0.0.dist-info/top_level.txt,sha256=43Ln1zzXEtb4ZLGBypsfgc3N1bkEc3z3P7eo7hNvnjk,55
|
|
61
|
+
gitcast-1.0.0.dist-info/RECORD,,
|
publisher/__init__.py
ADDED
|
File without changes
|
publisher/clipboard.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import webbrowser
|
|
2
|
+
from api.analytics import track
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def copy_to_clipboard(post_text: str) -> dict:
|
|
6
|
+
"""Copy post text to the system clipboard using tkinter."""
|
|
7
|
+
try:
|
|
8
|
+
import tkinter as tk
|
|
9
|
+
|
|
10
|
+
root = tk.Tk()
|
|
11
|
+
root.withdraw()
|
|
12
|
+
root.clipboard_clear()
|
|
13
|
+
root.clipboard_append(post_text)
|
|
14
|
+
root.update() # required for clipboard to persist after destroy
|
|
15
|
+
root.destroy()
|
|
16
|
+
print("[Publisher] Text copied to clipboard.")
|
|
17
|
+
track("post_copied", {"platform": "clipboard"})
|
|
18
|
+
return {"success": True, "method": "clipboard"}
|
|
19
|
+
except Exception as e:
|
|
20
|
+
print(f"[Publisher] Clipboard copy failed: {e}")
|
|
21
|
+
return {"success": False, "error": str(e)}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def open_x_compose(post_text: str) -> dict:
|
|
25
|
+
"""Copy text to clipboard then open X compose page in the default browser."""
|
|
26
|
+
try:
|
|
27
|
+
clip_result = copy_to_clipboard(post_text)
|
|
28
|
+
if not clip_result["success"]:
|
|
29
|
+
return clip_result
|
|
30
|
+
|
|
31
|
+
compose_url = "https://twitter.com/compose/tweet"
|
|
32
|
+
webbrowser.open(compose_url)
|
|
33
|
+
print(f"[Publisher] Opened {compose_url} — paste your post from clipboard.")
|
|
34
|
+
return {"success": True, "method": "clipboard"}
|
|
35
|
+
except Exception as e:
|
|
36
|
+
print(f"[Publisher] Failed to open X compose: {e}")
|
|
37
|
+
return {"success": False, "error": str(e)}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
if __name__ == "__main__":
|
|
41
|
+
sample = "🚀 Just shipped a new feature! Building in public with Context Engine. #buildinpublic #devtools"
|
|
42
|
+
print("[Publisher] Testing open_x_compose...")
|
|
43
|
+
result = open_x_compose(sample)
|
|
44
|
+
print(f"[Publisher] Result: {result}")
|