loony 0.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- loony-0.1.2/.gitignore +10 -0
- loony-0.1.2/PKG-INFO +10 -0
- loony-0.1.2/loony/__init__.py +1 -0
- loony-0.1.2/loony/api.py +56 -0
- loony-0.1.2/loony/auth.py +87 -0
- loony-0.1.2/loony/cli.py +280 -0
- loony-0.1.2/loony/config.py +77 -0
- loony-0.1.2/loony/init.py +297 -0
- loony-0.1.2/loony/skills.py +116 -0
- loony-0.1.2/loony/templates/claude/skills/loony-build-pipeline/SKILL.md +174 -0
- loony-0.1.2/loony/templates/claude/skills/loony-test-validate/SKILL.md +85 -0
- loony-0.1.2/loony/templates/codex/AGENTS.md +1 -0
- loony-0.1.2/loony/templates/cursor/loony.mdc +6 -0
- loony-0.1.2/loony/templates/shared/loony.md +215 -0
- loony-0.1.2/pyproject.toml +22 -0
loony-0.1.2/.gitignore
ADDED
loony-0.1.2/PKG-INFO
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: loony
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: CLI for loony — governed data services for agents
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: httpx>=0.28
|
|
7
|
+
Requires-Dist: pyyaml>=6.0
|
|
8
|
+
Requires-Dist: rich>=14.0
|
|
9
|
+
Requires-Dist: typer>=0.15
|
|
10
|
+
Requires-Dist: workos>=5.46
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.1"
|
loony-0.1.2/loony/api.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""HTTP client for talking to the control plane."""
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from . import config
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _get_client(env: str | None = None, timeout: int = 30) -> tuple[httpx.Client, dict]:
|
|
9
|
+
env_config = config.get_env_config(env)
|
|
10
|
+
endpoint = env_config.get("endpoint")
|
|
11
|
+
token = env_config.get("access_token")
|
|
12
|
+
|
|
13
|
+
if not endpoint:
|
|
14
|
+
raise ValueError(f"No endpoint configured for environment '{config.get_current_env()}'")
|
|
15
|
+
if not token:
|
|
16
|
+
raise ValueError("Not logged in. Run 'loony login' first.")
|
|
17
|
+
|
|
18
|
+
client = httpx.Client(
|
|
19
|
+
base_url=endpoint,
|
|
20
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
21
|
+
timeout=timeout,
|
|
22
|
+
)
|
|
23
|
+
return client, env_config
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def me(env: str | None = None) -> dict:
|
|
27
|
+
client, _ = _get_client(env)
|
|
28
|
+
resp = client.get("/api/v1/me")
|
|
29
|
+
resp.raise_for_status()
|
|
30
|
+
return resp.json()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def health(env: str | None = None) -> dict:
|
|
34
|
+
env_config = config.get_env_config(env)
|
|
35
|
+
endpoint = env_config.get("endpoint")
|
|
36
|
+
if not endpoint:
|
|
37
|
+
raise ValueError(f"No endpoint configured for environment '{config.get_current_env()}'")
|
|
38
|
+
|
|
39
|
+
resp = httpx.get(f"{endpoint}/healthz", timeout=10)
|
|
40
|
+
resp.raise_for_status()
|
|
41
|
+
return resp.json()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def deploy(name: str, payload: dict, env: str | None = None) -> dict:
|
|
45
|
+
# Large script uploads can take well over 30s — use a generous timeout.
|
|
46
|
+
client, _ = _get_client(env, timeout=300)
|
|
47
|
+
resp = client.post(f"/api/v1/services/{name}/deploy", json=payload)
|
|
48
|
+
resp.raise_for_status()
|
|
49
|
+
return resp.json()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def submit_feedback(payload: dict, env: str | None = None) -> dict:
|
|
53
|
+
client, _ = _get_client(env)
|
|
54
|
+
resp = client.post("/api/v1/feedback", json=payload)
|
|
55
|
+
resp.raise_for_status()
|
|
56
|
+
return resp.json()
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""WorkOS browser-redirect login flow.
|
|
2
|
+
|
|
3
|
+
The CLI handles the browser redirect and captures the auth code.
|
|
4
|
+
The control plane exchanges the code for tokens (it has the API key).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import http.server
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import webbrowser
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from . import config
|
|
14
|
+
|
|
15
|
+
REDIRECT_URI = "http://localhost:9876/callback"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def login(env: str | None = None):
|
|
19
|
+
"""Open browser for WorkOS login, send code to control plane, save credentials."""
|
|
20
|
+
env = env or config.get_current_env()
|
|
21
|
+
env_config = config.get_env_config(env)
|
|
22
|
+
|
|
23
|
+
endpoint = env_config.get("endpoint")
|
|
24
|
+
if not endpoint:
|
|
25
|
+
raise ValueError(f"No endpoint configured for environment '{env}'")
|
|
26
|
+
|
|
27
|
+
# Step 1: Get auth URL from control plane
|
|
28
|
+
resp = httpx.get(f"{endpoint}/auth/url", params={"redirect_uri": REDIRECT_URI}, timeout=10)
|
|
29
|
+
resp.raise_for_status()
|
|
30
|
+
auth_url = resp.json()["url"]
|
|
31
|
+
|
|
32
|
+
# Step 2: Open browser + capture callback code
|
|
33
|
+
result = {}
|
|
34
|
+
|
|
35
|
+
class CallbackHandler(http.server.BaseHTTPRequestHandler):
|
|
36
|
+
def do_GET(self):
|
|
37
|
+
query = urllib.parse.urlparse(self.path).query
|
|
38
|
+
params = urllib.parse.parse_qs(query)
|
|
39
|
+
|
|
40
|
+
if "code" in params:
|
|
41
|
+
result["code"] = params["code"][0]
|
|
42
|
+
self.send_response(200)
|
|
43
|
+
self.send_header("Content-Type", "text/html")
|
|
44
|
+
self.end_headers()
|
|
45
|
+
self.wfile.write(
|
|
46
|
+
b"<html><body style='font-family:system-ui;text-align:center;padding:60px'>"
|
|
47
|
+
b"<h1>Logged in!</h1><p>You can close this tab.</p></body></html>"
|
|
48
|
+
)
|
|
49
|
+
else:
|
|
50
|
+
error = params.get("error", ["unknown"])[0]
|
|
51
|
+
result["error"] = error
|
|
52
|
+
self.send_response(400)
|
|
53
|
+
self.send_header("Content-Type", "text/html")
|
|
54
|
+
self.end_headers()
|
|
55
|
+
self.wfile.write(f"<html><body>Error: {error}</body></html>".encode())
|
|
56
|
+
|
|
57
|
+
def log_message(self, format, *args):
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
server = http.server.HTTPServer(("localhost", 9876), CallbackHandler)
|
|
61
|
+
webbrowser.open(auth_url)
|
|
62
|
+
server.handle_request()
|
|
63
|
+
server.server_close()
|
|
64
|
+
|
|
65
|
+
if "error" in result:
|
|
66
|
+
raise RuntimeError(f"Login failed: {result['error']}")
|
|
67
|
+
|
|
68
|
+
# Step 3: Send code to control plane for exchange
|
|
69
|
+
resp = httpx.post(
|
|
70
|
+
f"{endpoint}/auth/callback",
|
|
71
|
+
json={"code": result["code"], "redirect_uri": REDIRECT_URI},
|
|
72
|
+
timeout=30,
|
|
73
|
+
)
|
|
74
|
+
resp.raise_for_status()
|
|
75
|
+
data = resp.json()
|
|
76
|
+
|
|
77
|
+
# Step 4: Save credentials
|
|
78
|
+
config.save_credentials(env, {
|
|
79
|
+
"user_id": data["user_id"],
|
|
80
|
+
"email": data["email"],
|
|
81
|
+
"first_name": data.get("first_name"),
|
|
82
|
+
"last_name": data.get("last_name"),
|
|
83
|
+
"access_token": data["access_token"],
|
|
84
|
+
"refresh_token": data.get("refresh_token"),
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
return data
|
loony-0.1.2/loony/cli.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Loony CLI — governed data services for agents."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from . import __version__, api, auth, config
|
|
10
|
+
from .init import init_project
|
|
11
|
+
from . import skills as skills_mod
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(
|
|
14
|
+
name="loony",
|
|
15
|
+
help="Governed data services for agents.",
|
|
16
|
+
no_args_is_help=True,
|
|
17
|
+
)
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.callback(invoke_without_command=True)
|
|
22
|
+
def main(
|
|
23
|
+
version: bool = typer.Option(False, "--version", "-v", help="Show version"),
|
|
24
|
+
):
|
|
25
|
+
if version:
|
|
26
|
+
console.print(f"loony {__version__}")
|
|
27
|
+
raise typer.Exit()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.command()
|
|
31
|
+
def init(
|
|
32
|
+
name: str = typer.Argument(..., help="Service name"),
|
|
33
|
+
agent: Optional[str] = typer.Option(None, "--agent", "-a", help="Agent type: claude, cursor, codex"),
|
|
34
|
+
update: bool = typer.Option(False, "--update", "-u", help="Update loony templates only, preserve user files"),
|
|
35
|
+
):
|
|
36
|
+
"""Scaffold a new pipeline project, or update loony templates."""
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
result = init_project(name, agent=agent, project_dir=Path.cwd(), update=update)
|
|
40
|
+
|
|
41
|
+
if update:
|
|
42
|
+
console.print(f"\n[green]✓[/green] Updated loony templates for [bold]{result['name']}[/bold]\n")
|
|
43
|
+
if result["updated"]:
|
|
44
|
+
console.print("[bold]Updated:[/bold]")
|
|
45
|
+
for f in result["updated"]:
|
|
46
|
+
console.print(f" [yellow]{f}[/yellow]")
|
|
47
|
+
else:
|
|
48
|
+
console.print("[dim]Everything up to date.[/dim]")
|
|
49
|
+
else:
|
|
50
|
+
console.print(f"\n[green]✓[/green] Created service [bold]{result['name']}[/bold]\n")
|
|
51
|
+
|
|
52
|
+
if result["created"]:
|
|
53
|
+
console.print("[bold]Core files:[/bold]")
|
|
54
|
+
for f in result["created"]:
|
|
55
|
+
console.print(f" {f}")
|
|
56
|
+
|
|
57
|
+
if result["skipped"]:
|
|
58
|
+
console.print(f"\n[dim]Skipped (already exist): {', '.join(result['skipped'])}[/dim]")
|
|
59
|
+
|
|
60
|
+
console.print(f"\n[bold]Next steps:[/bold]")
|
|
61
|
+
console.print(" 1. Edit schema.yaml — define your tables, columns, sync modes")
|
|
62
|
+
console.print(" 2. Write scripts in scripts/ — dlt scripts that export run(conn)")
|
|
63
|
+
console.print(" 3. Write transforms in transforms/ — SQL to clean and aggregate")
|
|
64
|
+
console.print(" 4. Run [bold]loony test[/bold] to validate")
|
|
65
|
+
console.print(" 5. Run [bold]loony deploy[/bold] to go live")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.command()
|
|
69
|
+
def login(
|
|
70
|
+
env: Optional[str] = typer.Option(None, "--env", "-e", help="Environment to login to"),
|
|
71
|
+
):
|
|
72
|
+
"""Authenticate with loony.dev via browser."""
|
|
73
|
+
env = env or config.get_current_env()
|
|
74
|
+
console.print(f"Logging into [bold]{env}[/bold]...")
|
|
75
|
+
console.print("Opening browser...")
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
user = auth.login(env)
|
|
79
|
+
console.print(f"\n[green]✓[/green] Logged in as [bold]{user['email']}[/bold]")
|
|
80
|
+
console.print(f" User ID: {user['user_id']}")
|
|
81
|
+
console.print(f" Environment: {env}")
|
|
82
|
+
except Exception as e:
|
|
83
|
+
console.print(f"\n[red]✗[/red] Login failed: {e}")
|
|
84
|
+
raise typer.Exit(1)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@app.command()
|
|
88
|
+
def logout(
|
|
89
|
+
env: Optional[str] = typer.Option(None, "--env", "-e", help="Environment to logout from"),
|
|
90
|
+
):
|
|
91
|
+
"""Clear stored credentials."""
|
|
92
|
+
env = env or config.get_current_env()
|
|
93
|
+
config.clear_credentials(env)
|
|
94
|
+
console.print(f"[green]✓[/green] Logged out from [bold]{env}[/bold]")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@app.command()
|
|
98
|
+
def env(
|
|
99
|
+
name: Optional[str] = typer.Argument(None, help="Environment to switch to"),
|
|
100
|
+
):
|
|
101
|
+
"""Show or switch the current environment."""
|
|
102
|
+
if name:
|
|
103
|
+
config.set_current_env(name)
|
|
104
|
+
console.print(f"[green]✓[/green] Switched to [bold]{name}[/bold]")
|
|
105
|
+
else:
|
|
106
|
+
current = config.get_current_env()
|
|
107
|
+
cfg = config.load()
|
|
108
|
+
envs = cfg.get("environments", {})
|
|
109
|
+
|
|
110
|
+
table = Table(title="Environments")
|
|
111
|
+
table.add_column("Name")
|
|
112
|
+
table.add_column("Endpoint")
|
|
113
|
+
table.add_column("User")
|
|
114
|
+
table.add_column("Active")
|
|
115
|
+
|
|
116
|
+
for env_name in set(config.DEFAULTS.keys()) | set(envs.keys()):
|
|
117
|
+
env_config = config.get_env_config(env_name)
|
|
118
|
+
endpoint = env_config.get("endpoint", "")
|
|
119
|
+
email = env_config.get("email", "")
|
|
120
|
+
active = "→" if env_name == current else ""
|
|
121
|
+
table.add_row(env_name, endpoint or "(not configured)", email or "(not logged in)", active)
|
|
122
|
+
|
|
123
|
+
console.print(table)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@app.command()
|
|
127
|
+
def status():
|
|
128
|
+
"""Check connection to control plane and show user info."""
|
|
129
|
+
current = config.get_current_env()
|
|
130
|
+
env_config = config.get_env_config()
|
|
131
|
+
|
|
132
|
+
console.print(f"Environment: [bold]{current}[/bold]")
|
|
133
|
+
console.print(f"Endpoint: {env_config.get('endpoint', '(not configured)')}")
|
|
134
|
+
console.print()
|
|
135
|
+
|
|
136
|
+
# Health check
|
|
137
|
+
try:
|
|
138
|
+
h = api.health()
|
|
139
|
+
console.print(f"[green]✓[/green] Control plane: {h.get('status', 'unknown')}")
|
|
140
|
+
except Exception as e:
|
|
141
|
+
console.print(f"[red]✗[/red] Control plane: {e}")
|
|
142
|
+
raise typer.Exit(1)
|
|
143
|
+
|
|
144
|
+
# Auth check
|
|
145
|
+
try:
|
|
146
|
+
user = api.me()
|
|
147
|
+
console.print(f"[green]✓[/green] Authenticated as: {user.get('email')}")
|
|
148
|
+
console.print(f" User ID: {user.get('user_id')}")
|
|
149
|
+
except ValueError as e:
|
|
150
|
+
console.print(f"[yellow]⚠[/yellow] {e}")
|
|
151
|
+
except Exception as e:
|
|
152
|
+
console.print(f"[red]✗[/red] Auth failed: {e}")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ─── Feedback ──────────────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
@app.command()
|
|
158
|
+
def feedback(
|
|
159
|
+
message: str = typer.Argument(..., help="Describe the issue or suggestion"),
|
|
160
|
+
include_traceback: bool = typer.Option(True, "--traceback/--no-traceback", help="Include recent Python traceback if available"),
|
|
161
|
+
):
|
|
162
|
+
"""Submit feedback or report a bug. Creates a tracked issue with full context."""
|
|
163
|
+
import platform
|
|
164
|
+
import sys
|
|
165
|
+
import traceback as tb_mod
|
|
166
|
+
|
|
167
|
+
console.print("[dim]Gathering context...[/dim]")
|
|
168
|
+
|
|
169
|
+
payload = {
|
|
170
|
+
"message": message,
|
|
171
|
+
"cli_version": __version__,
|
|
172
|
+
"python_version": platform.python_version(),
|
|
173
|
+
"os": f"{platform.system()} {platform.release()}",
|
|
174
|
+
"environment": config.get_current_env(),
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
# Capture last traceback if available
|
|
178
|
+
if include_traceback:
|
|
179
|
+
last_tb = tb_mod.format_exc()
|
|
180
|
+
if last_tb and last_tb.strip() != "NoneType: None":
|
|
181
|
+
payload["traceback"] = last_tb
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
result = api.submit_feedback(payload)
|
|
185
|
+
console.print(f"\n[green]✓[/green] Feedback submitted")
|
|
186
|
+
if result.get("issue_url"):
|
|
187
|
+
console.print(f" Tracking: {result['issue_url']}")
|
|
188
|
+
if result.get("issue_number"):
|
|
189
|
+
console.print(f" Issue #{result['issue_number']}")
|
|
190
|
+
except ValueError as e:
|
|
191
|
+
console.print(f"[red]✗[/red] {e}")
|
|
192
|
+
raise typer.Exit(1)
|
|
193
|
+
except Exception as e:
|
|
194
|
+
console.print(f"[red]✗[/red] Failed to submit feedback: {e}")
|
|
195
|
+
raise typer.Exit(1)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ─── Skills ────────────────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
skills_app = typer.Typer(help="Manage agent skills.")
|
|
201
|
+
app.add_typer(skills_app, name="skills")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@skills_app.callback(invoke_without_command=True)
|
|
205
|
+
def skills_default(ctx: typer.Context):
|
|
206
|
+
"""List available and installed skills."""
|
|
207
|
+
if ctx.invoked_subcommand is not None:
|
|
208
|
+
return
|
|
209
|
+
# Default: show list
|
|
210
|
+
_skills_list()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@skills_app.command("list")
|
|
214
|
+
def skills_list():
|
|
215
|
+
"""List available and installed skills."""
|
|
216
|
+
_skills_list()
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _skills_list():
|
|
220
|
+
from pathlib import Path
|
|
221
|
+
|
|
222
|
+
result = skills_mod.list_skills(Path.cwd())
|
|
223
|
+
|
|
224
|
+
table = Table(title="Skills")
|
|
225
|
+
table.add_column("Name")
|
|
226
|
+
table.add_column("Description")
|
|
227
|
+
table.add_column("Status")
|
|
228
|
+
|
|
229
|
+
for name, desc in result["available"].items():
|
|
230
|
+
if name in result["installed"]:
|
|
231
|
+
status = "[green]installed[/green]"
|
|
232
|
+
else:
|
|
233
|
+
status = "[dim]available[/dim]"
|
|
234
|
+
table.add_row(name, desc, status)
|
|
235
|
+
|
|
236
|
+
# Show user skills too
|
|
237
|
+
for name, kind in result["installed"].items():
|
|
238
|
+
if kind == "user":
|
|
239
|
+
table.add_row(name, "(user-created)", "[cyan]custom[/cyan]")
|
|
240
|
+
|
|
241
|
+
console.print(table)
|
|
242
|
+
|
|
243
|
+
if not result["agent"]:
|
|
244
|
+
console.print("\n[yellow]⚠[/yellow] No agent detected. Run 'loony init' first.")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@skills_app.command("add")
|
|
248
|
+
def skills_add(
|
|
249
|
+
name: str = typer.Argument(..., help="Skill name to add"),
|
|
250
|
+
agent: Optional[str] = typer.Option(None, "--agent", "-a", help="Agent type: claude, cursor, codex"),
|
|
251
|
+
):
|
|
252
|
+
"""Add a bundled skill to the project."""
|
|
253
|
+
from pathlib import Path
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
result = skills_mod.add_skill(name, agent=agent, project_dir=Path.cwd())
|
|
257
|
+
console.print(f"[green]✓[/green] {result['action'].title()} skill [bold]{result['name']}[/bold]")
|
|
258
|
+
console.print(f" {result['path']}")
|
|
259
|
+
except ValueError as e:
|
|
260
|
+
console.print(f"[red]✗[/red] {e}")
|
|
261
|
+
raise typer.Exit(1)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@skills_app.command("remove")
|
|
265
|
+
def skills_remove(
|
|
266
|
+
name: str = typer.Argument(..., help="Skill name to remove"),
|
|
267
|
+
):
|
|
268
|
+
"""Remove a skill from the project."""
|
|
269
|
+
from pathlib import Path
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
result = skills_mod.remove_skill(name, project_dir=Path.cwd())
|
|
273
|
+
console.print(f"[green]✓[/green] Removed skill [bold]{result['name']}[/bold]")
|
|
274
|
+
except ValueError as e:
|
|
275
|
+
console.print(f"[red]✗[/red] {e}")
|
|
276
|
+
raise typer.Exit(1)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
if __name__ == "__main__":
|
|
280
|
+
app()
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Manages ~/.loony/config.yaml — environments, credentials, current context."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
CONFIG_DIR = Path.home() / ".loony"
|
|
9
|
+
CONFIG_FILE = CONFIG_DIR / "config.yaml"
|
|
10
|
+
|
|
11
|
+
DEFAULTS = {
|
|
12
|
+
"staging": {
|
|
13
|
+
"endpoint": "https://control-plane-staging-f80a.up.railway.app",
|
|
14
|
+
"workos_client_id": "client_01KM7YSX929XY123SB97D5VP2T",
|
|
15
|
+
},
|
|
16
|
+
"production": {
|
|
17
|
+
"endpoint": "",
|
|
18
|
+
"workos_client_id": "",
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _ensure_dir():
|
|
24
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load() -> dict:
|
|
28
|
+
if not CONFIG_FILE.exists():
|
|
29
|
+
return {"current": "staging", "environments": {}}
|
|
30
|
+
with open(CONFIG_FILE) as f:
|
|
31
|
+
return yaml.safe_load(f) or {"current": "staging", "environments": {}}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def save(config: dict):
|
|
35
|
+
_ensure_dir()
|
|
36
|
+
with open(CONFIG_FILE, "w") as f:
|
|
37
|
+
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_current_env() -> str:
|
|
41
|
+
return os.environ.get("LOONY_ENV", load().get("current", "staging"))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_env_config(env: str | None = None) -> dict:
|
|
45
|
+
env = env or get_current_env()
|
|
46
|
+
config = load()
|
|
47
|
+
env_config = config.get("environments", {}).get(env, {})
|
|
48
|
+
defaults = DEFAULTS.get(env, {})
|
|
49
|
+
return {**defaults, **env_config}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def set_current_env(env: str):
|
|
53
|
+
config = load()
|
|
54
|
+
config["current"] = env
|
|
55
|
+
save(config)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def save_credentials(env: str, credentials: dict):
|
|
59
|
+
config = load()
|
|
60
|
+
if "environments" not in config:
|
|
61
|
+
config["environments"] = {}
|
|
62
|
+
if env not in config["environments"]:
|
|
63
|
+
config["environments"][env] = {}
|
|
64
|
+
config["environments"][env].update(credentials)
|
|
65
|
+
config["current"] = env
|
|
66
|
+
save(config)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def clear_credentials(env: str):
|
|
70
|
+
config = load()
|
|
71
|
+
envs = config.get("environments", {})
|
|
72
|
+
if env in envs:
|
|
73
|
+
envs[env].pop("access_token", None)
|
|
74
|
+
envs[env].pop("refresh_token", None)
|
|
75
|
+
envs[env].pop("user_id", None)
|
|
76
|
+
envs[env].pop("email", None)
|
|
77
|
+
save(config)
|