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.
Files changed (61) hide show
  1. ai/__init__.py +0 -0
  2. ai/formatter.py +59 -0
  3. ai/generator.py +604 -0
  4. ai/prompts.py +197 -0
  5. ai/viral_patterns.py +75 -0
  6. api/__init__.py +0 -0
  7. api/analytics.py +48 -0
  8. api/auth.py +49 -0
  9. api/auth_middleware.py +129 -0
  10. api/auth_routes.py +117 -0
  11. api/monitoring.py +56 -0
  12. api/payload.py +253 -0
  13. api/ratelimit.py +9 -0
  14. api/routes.py +1565 -0
  15. api/server.py +162 -0
  16. api/validators.py +101 -0
  17. assets/__init__.py +1 -0
  18. assets/favicon-16x16.png +0 -0
  19. assets/favicon-32x32.png +0 -0
  20. assets/favicon-64x64.png +0 -0
  21. assets/favicon.ico +0 -0
  22. assets/icon.png +0 -0
  23. cli/.env.example +26 -0
  24. cli/__init__.py +1 -0
  25. cli/gitcast.py +79 -0
  26. config/__init__.py +0 -0
  27. config/settings.py +213 -0
  28. core/__init__.py +0 -0
  29. core/capture.py +258 -0
  30. core/codebase_reader.py +90 -0
  31. core/framing.py +86 -0
  32. core/hotkey.py +21 -0
  33. core/log_stream.py +50 -0
  34. core/ocr.py +173 -0
  35. core/screenshot_session.py +274 -0
  36. core/security.py +126 -0
  37. core/tray.py +54 -0
  38. gitcast-1.0.0.dist-info/LICENSE +21 -0
  39. gitcast-1.0.0.dist-info/METADATA +67 -0
  40. gitcast-1.0.0.dist-info/RECORD +61 -0
  41. gitcast-1.0.0.dist-info/WHEEL +5 -0
  42. gitcast-1.0.0.dist-info/entry_points.txt +2 -0
  43. gitcast-1.0.0.dist-info/top_level.txt +10 -0
  44. publisher/__init__.py +0 -0
  45. publisher/clipboard.py +44 -0
  46. publisher/twitter.py +100 -0
  47. storage/__init__.py +0 -0
  48. storage/cleanup.py +60 -0
  49. storage/engagement.py +114 -0
  50. storage/insights.py +203 -0
  51. storage/key_manager.py +45 -0
  52. storage/logger.py +208 -0
  53. storage/metrics.py +119 -0
  54. storage/sprint.py +40 -0
  55. storage/streak.py +0 -0
  56. storage/supabase_client.py +25 -0
  57. storage/tone_memory.py +139 -0
  58. ui/__init__.py +0 -0
  59. web/__init__.py +1 -0
  60. web/index.html +4994 -0
  61. web/landing.html +925 -0
api/server.py ADDED
@@ -0,0 +1,162 @@
1
+ import uvicorn
2
+ from fastapi import FastAPI, Request, HTTPException
3
+ from fastapi.middleware.cors import CORSMiddleware
4
+ from fastapi.staticfiles import StaticFiles
5
+ from fastapi.responses import FileResponse, Response
6
+ from fastapi.responses import JSONResponse
7
+ from slowapi.errors import RateLimitExceeded
8
+ from slowapi.middleware import SlowAPIMiddleware
9
+ from api.routes import router
10
+ from api.auth_routes import router as auth_router
11
+ from config.settings import missing_api_keys, BASE_DIR, STORAGE_DIR, CONFIG_DIR
12
+ from api.monitoring import init_sentry
13
+ from api.ratelimit import limiter
14
+ from api.auth import get_token
15
+
16
+ # ── App setup ─────────────────────────────────────────────────────────────────
17
+
18
+ init_sentry()
19
+
20
+ app = FastAPI(
21
+ title="Gitcast",
22
+ description="Local AI server for build-in-public post generation",
23
+ version="0.1.0",
24
+ docs_url="/docs",
25
+ redoc_url=None,
26
+ )
27
+ app.state.limiter = limiter
28
+
29
+
30
+ @app.exception_handler(RateLimitExceeded)
31
+ async def rate_limit_handler(request: Request, exc: RateLimitExceeded):
32
+ retry_after = 60
33
+ headers = getattr(exc, "headers", None) or {}
34
+ try:
35
+ retry_after = int(headers.get("Retry-After", retry_after))
36
+ except (TypeError, ValueError):
37
+ retry_after = 60
38
+ headers["Retry-After"] = str(retry_after)
39
+ return JSONResponse(
40
+ status_code=429,
41
+ content={
42
+ "success": False,
43
+ "error": "rate limit exceeded",
44
+ "retry_after": retry_after,
45
+ "message": f"[!!] slow down. retry in {retry_after}s.",
46
+ },
47
+ headers=headers,
48
+ )
49
+
50
+ # allow the UI layer to call the API from localhost
51
+ app.add_middleware(
52
+ CORSMiddleware,
53
+ allow_origins=["*"], # Relax for local dev
54
+ allow_methods=["*"],
55
+ allow_headers=["*"],
56
+ )
57
+ app.add_middleware(SlowAPIMiddleware)
58
+
59
+ try:
60
+ from sentry_sdk.integrations.asgi import SentryAsgiMiddleware
61
+
62
+ app.add_middleware(SentryAsgiMiddleware)
63
+ except Exception:
64
+ pass
65
+
66
+ # Serve captured images without exposing local data files or Python modules.
67
+ app.mount(
68
+ "/storage/data/screenshots",
69
+ StaticFiles(directory=str(STORAGE_DIR / "screenshots")),
70
+ name="screenshots",
71
+ )
72
+
73
+ app.mount(
74
+ "/assets",
75
+ StaticFiles(directory=str(BASE_DIR / "assets")),
76
+ name="assets",
77
+ )
78
+
79
+
80
+ # register routes
81
+ app.include_router(router, prefix="/api")
82
+ app.include_router(auth_router, prefix="/auth")
83
+
84
+
85
+ # ── Startup Event ─────────────────────────────────────────────────────────────
86
+
87
+ @app.on_event("startup")
88
+ def startup_event():
89
+ print(f"\n[Auth] Session Token: {get_token()}")
90
+ print("[Server] Starting Gitcast API on http://127.0.0.1:8000")
91
+
92
+
93
+ # ── Serve Frontend ────────────────────────────────────────────────────────────
94
+
95
+ @app.get("/")
96
+ async def read_landing():
97
+ return FileResponse(BASE_DIR / "web" / "landing.html")
98
+
99
+
100
+ @app.get("/landing")
101
+ async def read_landing_alias():
102
+ return FileResponse(BASE_DIR / "web" / "landing.html")
103
+
104
+
105
+ @app.get("/app")
106
+ async def read_app():
107
+ return FileResponse(BASE_DIR / "web" / "index.html")
108
+
109
+
110
+ @app.get("/favicon.ico")
111
+ async def favicon():
112
+ return FileResponse(BASE_DIR / "assets" / "favicon.ico")
113
+
114
+
115
+ # ── Health check ──────────────────────────────────────────────────────────────
116
+
117
+ @app.get("/health")
118
+ def health_check():
119
+ missing = missing_api_keys()
120
+ return {
121
+ "status": "ok",
122
+ "missing_api_keys": missing,
123
+ "ready": len(missing) == 0,
124
+ }
125
+
126
+
127
+ # ── Auth token retrieval (Localhost only) ───────────────────────────────────────
128
+
129
+ @app.get("/api/token")
130
+ def get_session_token(request: Request):
131
+ client_host = request.client.host if request.client else None
132
+ if client_host not in ["127.0.0.1", "localhost", "::1"]:
133
+ raise HTTPException(status_code=403, detail="Forbidden: Access allowed only from localhost")
134
+ return {"token": get_token()}
135
+
136
+
137
+ # ── Entry point ───────────────────────────────────────────────────────────────
138
+
139
+ def start_server():
140
+ """Starts the FastAPI server. Called from main.py in a background thread."""
141
+ # Write session token to config/session_token.txt
142
+ try:
143
+ token_file = CONFIG_DIR / "session_token.txt"
144
+ token_file.write_text(get_token(), encoding="utf-8")
145
+ except Exception as e:
146
+ print(f"[Server] Failed to write session token to file: {e}")
147
+
148
+ print(f"\n[Auth] Session Token: {get_token()}")
149
+ print("[Server] Starting Gitcast API on http://127.0.0.1:8000")
150
+ uvicorn.run(
151
+ "api.server:app",
152
+ host="127.0.0.1",
153
+ port=8000,
154
+ log_level="warning", # keep console clean — only show warnings and errors
155
+ reload=False,
156
+ )
157
+
158
+
159
+ if __name__ == "__main__":
160
+ print("[Server] Starting Gitcast API on http://127.0.0.1:8000")
161
+ print("[Server] Docs available at http://127.0.0.1:8000/docs")
162
+ start_server()
api/validators.py ADDED
@@ -0,0 +1,101 @@
1
+ import logging
2
+ import re
3
+ from pathlib import Path
4
+
5
+ from fastapi import HTTPException
6
+
7
+ from config.settings import BASE_DIR, STORAGE_DIR
8
+
9
+
10
+ CONTROL_CHARS = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
11
+ HTML_TAGS = re.compile(r"<[^>]*>")
12
+ EMAIL_REGEX = re.compile(r"^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$", re.IGNORECASE)
13
+ INJECTION_PATTERNS = [
14
+ "ignore previous instructions",
15
+ "ignore all instructions",
16
+ "system prompt",
17
+ "you are now",
18
+ "disregard",
19
+ "forget everything",
20
+ "new instructions",
21
+ ]
22
+
23
+
24
+ def sanitize_text(text: str) -> str:
25
+ if text is None:
26
+ return ""
27
+ clean = str(text).strip()
28
+ clean = CONTROL_CHARS.sub("", clean)
29
+ clean = HTML_TAGS.sub("", clean)
30
+ return clean[:2000]
31
+
32
+
33
+ def sanitize_path(path: str) -> str:
34
+ if not path:
35
+ return ""
36
+
37
+ raw = str(path).strip()
38
+ if re.search(r"(^|[\\/])\.\.([\\/]|$)", raw) or raw in {".", ".."}:
39
+ raise HTTPException(status_code=400, detail="Invalid path traversal attempt")
40
+
41
+ storage_root = STORAGE_DIR.resolve()
42
+ candidate = Path(raw)
43
+ if not candidate.is_absolute():
44
+ if raw.replace("\\", "/").startswith("storage/"):
45
+ candidate = (BASE_DIR / raw).resolve()
46
+ else:
47
+ candidate = (storage_root / raw).resolve()
48
+ else:
49
+ candidate = candidate.resolve()
50
+
51
+ try:
52
+ candidate.relative_to(storage_root)
53
+ except ValueError:
54
+ raise HTTPException(status_code=400, detail="Path must stay within storage directory")
55
+
56
+ return str(candidate)
57
+
58
+
59
+ def validate_email(email: str) -> bool:
60
+ if not email or len(email) > 254:
61
+ return False
62
+ return bool(EMAIL_REGEX.match(email.strip()))
63
+
64
+
65
+ def validate_api_key(key: str, provider: str) -> bool:
66
+ value = (key or "").strip()
67
+ name = (provider or "").strip().lower()
68
+ if not value:
69
+ return False
70
+
71
+ if name == "groq":
72
+ return value.startswith("gsk_") and len(value) >= 40
73
+ if name == "gemini":
74
+ return len(value) >= 30
75
+ if name == "openai":
76
+ return value.startswith("sk-") and len(value) >= 40
77
+ return len(value) >= 20
78
+
79
+
80
+ def check_prompt_injection(text: str) -> dict:
81
+ raw = text or ""
82
+ lowered = raw.lower()
83
+ flagged = [phrase for phrase in INJECTION_PATTERNS if phrase in lowered]
84
+ if not flagged:
85
+ return {"safe": True, "reason": ""}
86
+
87
+ logging.warning("[Validators] prompt injection phrase(s) removed: %s", ", ".join(flagged))
88
+ sanitized = raw
89
+ for phrase in flagged:
90
+ sanitized = re.sub(re.escape(phrase), "", sanitized, flags=re.IGNORECASE)
91
+
92
+ return {
93
+ "safe": False,
94
+ "reason": f"Removed prompt injection phrase(s): {', '.join(flagged)}",
95
+ "sanitized": sanitize_text(sanitized),
96
+ }
97
+
98
+
99
+ if __name__ == "__main__":
100
+ print("[Validators] email valid:", validate_email("founder@example.com"))
101
+ print("[Validators] clean:", sanitize_text(" <b>hello</b>\x00 "))
assets/__init__.py ADDED
@@ -0,0 +1 @@
1
+ # Make assets directory a Python package so it gets packaged and installed to site-packages.
Binary file
Binary file
Binary file
assets/favicon.ico ADDED
Binary file
assets/icon.png ADDED
File without changes
cli/.env.example ADDED
@@ -0,0 +1,26 @@
1
+ # Groq — primary AI inference
2
+ GROQ_API_KEY=your_groq_api_key_here
3
+
4
+ # DeepSeek & Moonshot — specialized/free-tier fallbacks
5
+ DEEPSEEK_API_KEY=your_deepseek_api_key_here
6
+ MOONSHOT_API_KEY=your_moonshot_api_key_here
7
+ CEREBRAS_API_KEY=your_cerebras_api_key_here
8
+ OPENROUTER_API_KEY=your_openrouter_api_key_here
9
+
10
+ # Gemini — vision fallback (when OCR confidence is too low)
11
+ GEMINI_API_KEY=your_gemini_api_key_here
12
+
13
+ # Optional model overrides
14
+ GROQ_MODEL=llama-3.3-70b-versatile
15
+ DEEPSEEK_MODEL=deepseek-chat
16
+ MOONSHOT_MODEL=moonshot-v1-8k
17
+ GEMINI_MODEL=gemini-2.0-flash
18
+ CEREBRAS_MODEL=llama-3.3-70b
19
+ OPENROUTER_MODEL=meta-llama/llama-3.3-70b-instruct:free
20
+
21
+ # X (Twitter) API v2 — all five are required for posting
22
+ TWITTER_API_KEY=your_twitter_api_key_here
23
+ TWITTER_API_SECRET=your_twitter_api_secret_here
24
+ TWITTER_ACCESS_TOKEN=your_twitter_access_token_here
25
+ TWITTER_ACCESS_SECRET=your_twitter_access_secret_here
26
+ TWITTER_BEARER_TOKEN=your_twitter_bearer_token_here
cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ # cli package
cli/gitcast.py ADDED
@@ -0,0 +1,79 @@
1
+ import sys
2
+ import os
3
+ import shutil
4
+ import subprocess
5
+ import httpx
6
+ import webbrowser
7
+
8
+ def main():
9
+ if "--setup" in sys.argv:
10
+ env_path = os.path.join(
11
+ os.path.dirname(__file__), '..', '.env')
12
+ example_path = os.path.join(
13
+ os.path.dirname(__file__), '..', '.env.example')
14
+ if not os.path.exists(example_path):
15
+ example_path = os.path.join(
16
+ os.path.dirname(__file__), '.env.example')
17
+
18
+ if not os.path.exists(env_path):
19
+ if os.path.exists(example_path):
20
+ shutil.copy(example_path, env_path)
21
+ else:
22
+ # If neither is found, create an empty or minimal env file
23
+ with open(env_path, 'w') as f:
24
+ f.write("# Gitcast Environment Variables\nGROQ_API_KEY=\nGEMINI_API_KEY=\n")
25
+
26
+ try:
27
+ if sys.platform == "win32":
28
+ subprocess.run(["notepad", env_path])
29
+ elif sys.platform == "darwin":
30
+ subprocess.run(["open", "-t", env_path])
31
+ else:
32
+ try:
33
+ subprocess.run(["xdg-open", env_path])
34
+ except Exception:
35
+ print(f"[Gitcast] Created/configured .env at: {env_path}")
36
+ print("Please open it in an editor to add your API keys.")
37
+ except Exception as e:
38
+ print(f"[Gitcast] Created/configured .env at: {env_path}")
39
+ print(f"Could not automatically open editor: {e}")
40
+ sys.exit(0)
41
+
42
+ if len(sys.argv) < 2:
43
+ print("Usage:")
44
+ print(" gitcast \"your thought here\" -> Quick single capture")
45
+ print(" gitcast capture -> Start interactive multi-shot session")
46
+ print(" gitcast --setup -> Setup environment variables")
47
+ sys.exit(1)
48
+
49
+ command = sys.argv[1]
50
+
51
+ if command == "capture":
52
+ from core.screenshot_session import ScreenshotSession
53
+ session = ScreenshotSession()
54
+ session.run()
55
+ return
56
+
57
+ thought = " ".join(sys.argv[1:])
58
+ print(f"[Gitcast] Initializing capture with thought: '{thought}'")
59
+
60
+ try:
61
+ # Call the internal trigger endpoint
62
+ response = httpx.post(
63
+ "http://127.0.0.1:8000/api/cli/trigger",
64
+ json={"thought": thought},
65
+ timeout=30
66
+ )
67
+
68
+ if response.status_code == 200:
69
+ print("[Gitcast] Context captured. Opening Draft Room...")
70
+ webbrowser.open("http://127.0.0.1:8000")
71
+ else:
72
+ print(f"[Gitcast Error] Failed to trigger: {response.text}")
73
+
74
+ except Exception as e:
75
+ print(f"[Gitcast Error] Connection failed: {e}")
76
+ print("Is Gitcast running? (python -m gitcast or python main.py)")
77
+
78
+ if __name__ == "__main__":
79
+ main()
config/__init__.py ADDED
File without changes
config/settings.py ADDED
@@ -0,0 +1,213 @@
1
+ import json
2
+ import os
3
+ from pathlib import Path
4
+
5
+ from dotenv import load_dotenv
6
+
7
+
8
+ BASE_DIR = Path(__file__).resolve().parent.parent
9
+ CONFIG_DIR = BASE_DIR / "config"
10
+ load_dotenv(BASE_DIR / ".env")
11
+ STORAGE_DIR = BASE_DIR / "storage" / "data"
12
+ POSTHOG_API_KEY = os.getenv("POSTHOG_API_KEY", "")
13
+ SENTRY_DSN = os.getenv("SENTRY_DSN", "")
14
+ SUPABASE_URL = os.getenv("SUPABASE_URL", "")
15
+ SUPABASE_SERVICE_KEY = os.getenv("SUPABASE_SERVICE_KEY", "")
16
+ SUPABASE_ANON_KEY = os.getenv("SUPABASE_ANON_KEY", "")
17
+ SUPABASE_JWT_SECRET = os.getenv("SUPABASE_JWT_SECRET", "")
18
+ SUPABASE_JWT_AUDIENCE = os.getenv("SUPABASE_JWT_AUDIENCE", "authenticated")
19
+ APP_BASE_URL = os.getenv("APP_BASE_URL", "http://127.0.0.1:8000")
20
+ WAITLIST_FILE = STORAGE_DIR / "waitlist.txt"
21
+ METRICS_LOG = STORAGE_DIR / "metrics_log.json"
22
+ PROMPTS_FILE = STORAGE_DIR / "prompts.json"
23
+ CURRENT_DRAFT = STORAGE_DIR / "current_draft.json"
24
+ SPRINT_LOG = STORAGE_DIR / "sprint_log.txt"
25
+ POST_LOG = STORAGE_DIR / "post_log.json"
26
+ TONE_LOG = STORAGE_DIR / "tone_log.json"
27
+ ENGAGEMENT_LOG = STORAGE_DIR / "engagement_log.json"
28
+ ENCRYPTION_KEY_PATH = CONFIG_DIR / ".secret_key"
29
+ SETTINGS_FILE = CONFIG_DIR / "user_settings.json"
30
+ screenshot_retention_hours = 24
31
+
32
+ STORAGE_DIR.mkdir(parents=True, exist_ok=True)
33
+
34
+
35
+ API_KEY_ENV_MAP = {
36
+ "groq": "GROQ_API_KEY",
37
+ "gemini": "GEMINI_API_KEY",
38
+ "kimi": "MOONSHOT_API_KEY",
39
+ "cerebras": "CEREBRAS_API_KEY",
40
+ "openrouter": "OPENROUTER_API_KEY",
41
+ }
42
+
43
+ GROQ_API_KEY = ""
44
+ MOONSHOT_API_KEY = ""
45
+ GEMINI_API_KEY = ""
46
+ CEREBRAS_API_KEY = ""
47
+ OPENROUTER_API_KEY = ""
48
+ GROQ_MODEL = ""
49
+ MOONSHOT_MODEL = ""
50
+ GEMINI_MODEL = ""
51
+ CEREBRAS_MODEL = ""
52
+ OPENROUTER_MODEL = ""
53
+
54
+
55
+ def reload_api_keys() -> None:
56
+ global GROQ_API_KEY
57
+ global MOONSHOT_API_KEY
58
+ global GEMINI_API_KEY
59
+ global CEREBRAS_API_KEY
60
+ global OPENROUTER_API_KEY
61
+ global GROQ_MODEL
62
+ global MOONSHOT_MODEL
63
+ global GEMINI_MODEL
64
+ global CEREBRAS_MODEL
65
+ global OPENROUTER_MODEL
66
+
67
+ load_dotenv(BASE_DIR / ".env", override=True)
68
+ GROQ_API_KEY = os.getenv("GROQ_API_KEY", "")
69
+ MOONSHOT_API_KEY = os.getenv("MOONSHOT_API_KEY", "")
70
+ GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
71
+ CEREBRAS_API_KEY = os.getenv("CEREBRAS_API_KEY", "")
72
+ OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "")
73
+ GROQ_MODEL = os.getenv("GROQ_MODEL", "llama-3.3-70b-versatile")
74
+ MOONSHOT_MODEL = os.getenv("MOONSHOT_MODEL", "moonshot-v1-8k")
75
+ GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.0-flash")
76
+ CEREBRAS_MODEL = os.getenv("CEREBRAS_MODEL", "gpt-oss-120b")
77
+ OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "qwen/qwen3-coder:free")
78
+
79
+
80
+ reload_api_keys()
81
+
82
+ # ── AI Provider Routing ───────────────────────────────────────────────────────
83
+
84
+ # Maps format_key -> preferred provider alias
85
+ # Providers: 'groq', 'kimi', 'gemini', 'cerebras', 'openrouter'
86
+ AI_ROUTING_MAP = {
87
+ "article": "kimi", # Better for long context
88
+ "linkedin": "groq", # Fast
89
+ "deep_tech": "groq", # Fast
90
+ "shitpost": "groq", # Fast
91
+ "sprint_summary": "kimi",
92
+ "default": "groq"
93
+ }
94
+ TWITTER_API_KEY = os.getenv("TWITTER_API_KEY", "")
95
+ TWITTER_API_SECRET = os.getenv("TWITTER_API_SECRET", "")
96
+ TWITTER_ACCESS_TOKEN = os.getenv("TWITTER_ACCESS_TOKEN", "")
97
+ TWITTER_ACCESS_SECRET = os.getenv("TWITTER_ACCESS_SECRET", "")
98
+ TWITTER_BEARER_TOKEN = os.getenv("TWITTER_BEARER_TOKEN", "")
99
+
100
+
101
+ DEFAULTS = {
102
+ "project_narrative": "",
103
+ "sprint_mode": False,
104
+ "tone_memory_enabled": True,
105
+ "ocr_confidence_threshold": 60,
106
+ "post_char_limit": 280,
107
+ "twitter_plan": "free",
108
+ "onboarding_complete": False,
109
+ }
110
+
111
+
112
+ def load_settings() -> dict:
113
+ if not SETTINGS_FILE.exists():
114
+ save_settings(DEFAULTS)
115
+ return DEFAULTS.copy()
116
+ try:
117
+ with open(SETTINGS_FILE, "r", encoding="utf-8") as file:
118
+ stored = json.load(file)
119
+ return {**DEFAULTS, **stored}
120
+ except (json.JSONDecodeError, OSError):
121
+ return DEFAULTS.copy()
122
+
123
+
124
+ def save_settings(settings: dict) -> None:
125
+ SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
126
+ with open(SETTINGS_FILE, "w", encoding="utf-8") as file:
127
+ json.dump(settings, file, indent=2)
128
+
129
+
130
+ def get(key: str):
131
+ return load_settings().get(key, DEFAULTS.get(key))
132
+
133
+
134
+ def set(key: str, value) -> None:
135
+ settings = load_settings()
136
+ settings[key] = value
137
+ save_settings(settings)
138
+
139
+
140
+ def get_project_narrative() -> str:
141
+ return str(get("project_narrative") or "")
142
+
143
+
144
+ def set_project_narrative(narrative: str) -> None:
145
+ set("project_narrative", narrative.strip())
146
+
147
+
148
+ def is_sprint_mode() -> bool:
149
+ return bool(get("sprint_mode"))
150
+
151
+
152
+ def toggle_sprint_mode() -> bool:
153
+ current = is_sprint_mode()
154
+ set("sprint_mode", not current)
155
+ return not current
156
+
157
+
158
+ def is_tone_memory_enabled() -> bool:
159
+ return bool(get("tone_memory_enabled"))
160
+
161
+
162
+ def get_ocr_threshold() -> int:
163
+ return int(get("ocr_confidence_threshold") or DEFAULTS["ocr_confidence_threshold"])
164
+
165
+
166
+ def get_tesseract_cmd() -> str:
167
+ configured = os.getenv("TESSERACT_CMD", "").strip()
168
+ if configured:
169
+ return configured
170
+ return "tesseract"
171
+
172
+
173
+ def is_onboarding_complete() -> bool:
174
+ return bool(get("onboarding_complete"))
175
+
176
+
177
+ def complete_onboarding() -> None:
178
+ set("onboarding_complete", True)
179
+
180
+
181
+ def get_twitter_plan() -> str:
182
+ return str(get("twitter_plan") or "free")
183
+
184
+
185
+ def set_twitter_plan(plan: str) -> None:
186
+ if plan.lower() in ["free", "basic", "premium"]:
187
+ set("twitter_plan", plan.lower())
188
+
189
+
190
+ def validate_api_keys() -> dict:
191
+ return {
192
+ "groq": bool(GROQ_API_KEY),
193
+ "kimi": bool(MOONSHOT_API_KEY),
194
+ "gemini": bool(GEMINI_API_KEY),
195
+ "cerebras": bool(CEREBRAS_API_KEY),
196
+ "openrouter": bool(OPENROUTER_API_KEY),
197
+ "twitter_api_key": bool(TWITTER_API_KEY),
198
+ "twitter_api_secret": bool(TWITTER_API_SECRET),
199
+ "twitter_access_token": bool(TWITTER_ACCESS_TOKEN),
200
+ "twitter_access_secret": bool(TWITTER_ACCESS_SECRET),
201
+ "twitter_bearer_token": bool(TWITTER_BEARER_TOKEN),
202
+ }
203
+
204
+
205
+ def missing_api_keys() -> list:
206
+ return [key for key, present in validate_api_keys().items() if not present]
207
+
208
+
209
+ def ai_provider_key_status() -> dict:
210
+ return {
211
+ provider: bool(os.getenv(env_name, "").strip())
212
+ for provider, env_name in API_KEY_ENV_MAP.items()
213
+ }
core/__init__.py ADDED
File without changes