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 ADDED
@@ -0,0 +1,10 @@
1
+ loony-ref/
2
+ .venv/
3
+ __pycache__/
4
+ *.pyc
5
+ .env
6
+ .env.local
7
+ *.egg-info/
8
+ dist/
9
+ build/
10
+ .dlt/
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"
@@ -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
@@ -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)