alphai 0.1.1__py3-none-any.whl → 0.2.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.
@@ -0,0 +1,1173 @@
1
+ """Notebook commands for alphai CLI."""
2
+
3
+ import json
4
+ import sys
5
+ import webbrowser
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ import click
10
+ import httpx
11
+ from rich.console import Console
12
+ from rich.progress import Progress, SpinnerColumn, TextColumn
13
+ from rich.prompt import Confirm
14
+
15
+ from ..client import AlphAIClient
16
+ from ..config import Config
17
+ from ..utils import get_logger
18
+
19
+ console = Console()
20
+ logger = get_logger(__name__)
21
+
22
+
23
+ def get_api_client(config: Config) -> httpx.Client:
24
+ """Create an HTTP client for API calls."""
25
+ return httpx.Client(
26
+ base_url=config.api_url.rstrip('/api') if config.api_url.endswith('/api') else config.api_url,
27
+ headers={
28
+ "Authorization": f"Bearer {config.bearer_token}",
29
+ "Content-Type": "application/json",
30
+ },
31
+ timeout=30.0,
32
+ )
33
+
34
+
35
+ def _select_organization(client: AlphAIClient) -> Optional[str]:
36
+ """Interactively select an organization."""
37
+ import questionary
38
+
39
+ console.print("[yellow]Select an organization:[/yellow]")
40
+
41
+ with Progress(
42
+ SpinnerColumn(),
43
+ TextColumn("[progress.description]{task.description}"),
44
+ console=console
45
+ ) as progress:
46
+ task = progress.add_task("Fetching organizations...", total=None)
47
+ try:
48
+ orgs_data = client.get_organizations()
49
+ except Exception as e:
50
+ progress.update(task, completed=1)
51
+ console.print(f"[red]Error fetching organizations: {e}[/red]")
52
+ return None
53
+ progress.update(task, completed=1)
54
+
55
+ if not orgs_data:
56
+ console.print("[red]No organizations found. Please create one first.[/red]")
57
+ return None
58
+
59
+ org_choices = []
60
+ for org in orgs_data:
61
+ display_name = f"{org.name} ({org.slug})"
62
+ org_choices.append(questionary.Choice(title=display_name, value=org.slug))
63
+
64
+ selected = questionary.select(
65
+ "Organization:",
66
+ choices=org_choices,
67
+ style=questionary.Style([
68
+ ('question', 'bold'),
69
+ ('pointer', 'fg:#673ab7 bold'),
70
+ ('highlighted', 'fg:#673ab7 bold'),
71
+ ('selected', 'fg:#cc5454'),
72
+ ])
73
+ ).ask()
74
+
75
+ if selected:
76
+ org_name = next((o.name for o in orgs_data if o.slug == selected), selected)
77
+ console.print(f"[green]✓ {org_name}[/green]\n")
78
+
79
+ return selected
80
+
81
+
82
+ def _fetch_notebooks(config: Config, org_slug: str) -> List[Dict[str, Any]]:
83
+ """Fetch notebooks for an organization."""
84
+ with Progress(
85
+ SpinnerColumn(),
86
+ TextColumn("[progress.description]{task.description}"),
87
+ console=console
88
+ ) as progress:
89
+ task = progress.add_task("Fetching notebooks...", total=None)
90
+ try:
91
+ with get_api_client(config) as client:
92
+ response = client.get("/api/notebooks", params={"org_slug": org_slug, "limit": 50})
93
+ response.raise_for_status()
94
+ data = response.json()
95
+ except Exception as e:
96
+ progress.update(task, completed=1)
97
+ console.print(f"[red]Error fetching notebooks: {e}[/red]")
98
+ return []
99
+ progress.update(task, completed=1)
100
+
101
+ return data.get("notebooks", [])
102
+
103
+
104
+ def _select_notebook(notebooks: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
105
+ """Interactively select a notebook."""
106
+ import questionary
107
+
108
+ if not notebooks:
109
+ console.print("[yellow]No notebooks found in this organization.[/yellow]")
110
+ return None
111
+
112
+ # Build a lookup dict by ID
113
+ nb_by_id = {nb.get("id"): nb for nb in notebooks}
114
+
115
+ choices = []
116
+ for nb in notebooks:
117
+ visibility = "🌍" if nb.get("is_public") else "🔒"
118
+ title = nb.get("title", "Untitled")
119
+ slug = nb.get("slug", "")
120
+ nb_id = nb.get("id", "")
121
+ choices.append(questionary.Choice(
122
+ title=f"{visibility} {title} ({slug})",
123
+ value=nb_id # Use ID as value
124
+ ))
125
+
126
+ choices.append(questionary.Choice(title="← Back", value="__back__"))
127
+
128
+ selected_id = questionary.select(
129
+ "Select a notebook:",
130
+ choices=choices,
131
+ style=questionary.Style([
132
+ ('question', 'bold'),
133
+ ('pointer', 'fg:#673ab7 bold'),
134
+ ('highlighted', 'fg:#673ab7 bold'),
135
+ ])
136
+ ).ask()
137
+
138
+ if not selected_id or selected_id == "__back__":
139
+ return None
140
+
141
+ return nb_by_id.get(selected_id)
142
+
143
+
144
+ def _select_action(notebook: Dict[str, Any]) -> Optional[str]:
145
+ """Interactively select an action for the notebook."""
146
+ import questionary
147
+
148
+ is_public = notebook.get("is_public", False)
149
+
150
+ actions = [
151
+ questionary.Choice(title="👁 View content", value="view"),
152
+ questionary.Choice(title="ℹ️ Show info", value="info"),
153
+ questionary.Choice(title="🌐 Open in browser", value="browser"),
154
+ questionary.Choice(title="⬇️ Download", value="download"),
155
+ questionary.Choice(title="🏷 Manage tags", value="tags"),
156
+ questionary.Separator(),
157
+ ]
158
+
159
+ # Show only the relevant visibility toggle
160
+ if is_public:
161
+ actions.append(questionary.Choice(title="🔒 Make private", value="unpublish"))
162
+ else:
163
+ actions.append(questionary.Choice(title="🌍 Make public", value="publish"))
164
+
165
+ actions.extend([
166
+ questionary.Separator(),
167
+ questionary.Choice(title="🗑 Delete", value="delete"),
168
+ questionary.Separator(),
169
+ questionary.Choice(title="← Back", value="back"),
170
+ ])
171
+
172
+ return questionary.select(
173
+ "What would you like to do?",
174
+ choices=actions,
175
+ style=questionary.Style([
176
+ ('question', 'bold'),
177
+ ('pointer', 'fg:#673ab7 bold'),
178
+ ('highlighted', 'fg:#673ab7 bold'),
179
+ ])
180
+ ).ask()
181
+
182
+
183
+ def _execute_action(ctx: click.Context, notebook: Dict[str, Any], action: str) -> bool:
184
+ """Execute an action on a notebook. Returns True to continue, False to go back."""
185
+ config: Config = ctx.obj['config']
186
+ nb_id = notebook.get("id", "")
187
+ slug = notebook.get("slug", "")
188
+ org_slug = notebook.get("organizations", {}).get("slug", "")
189
+
190
+ if action == "view":
191
+ # Fetch full content
192
+ with Progress(
193
+ SpinnerColumn(),
194
+ TextColumn("[progress.description]{task.description}"),
195
+ console=console
196
+ ) as progress:
197
+ task = progress.add_task("Fetching notebook content...", total=None)
198
+ try:
199
+ with get_api_client(config) as client:
200
+ response = client.get(f"/api/notebooks/{nb_id}", params={"include_content": "true"})
201
+ response.raise_for_status()
202
+ data = response.json()
203
+ except Exception as e:
204
+ progress.update(task, completed=1)
205
+ console.print(f"[red]Error: {e}[/red]")
206
+ return True
207
+ progress.update(task, completed=1)
208
+
209
+ nb_data = data.get("notebook", {})
210
+ cells = nb_data.get("content", {}).get("cells", [])
211
+
212
+ if not cells:
213
+ console.print("[yellow]Notebook has no cells.[/yellow]")
214
+ return True
215
+
216
+ from ..notebook_renderer import interactive_cell_viewer
217
+ interactive_cell_viewer(cells, console)
218
+ return True
219
+
220
+ elif action == "info":
221
+ from ..notebook_renderer import display_notebook_info
222
+ display_notebook_info(notebook, console)
223
+ console.print()
224
+ return True
225
+
226
+ elif action == "browser":
227
+ if org_slug and slug:
228
+ url = f"{config.api_url.replace('/api', '')}/{org_slug}/~/notebooks/{slug}"
229
+ console.print(f"[cyan]Opening: {url}[/cyan]")
230
+ webbrowser.open(url)
231
+ return True
232
+
233
+ elif action == "download":
234
+ import questionary
235
+ default_name = f"{slug or 'notebook'}.ipynb"
236
+ filename = questionary.text(
237
+ "Save as:",
238
+ default=default_name
239
+ ).ask()
240
+
241
+ if filename:
242
+ try:
243
+ with get_api_client(config) as client:
244
+ response = client.get(f"/api/notebooks/{nb_id}/download")
245
+ response.raise_for_status()
246
+ with open(filename, 'w', encoding='utf-8') as f:
247
+ f.write(response.text)
248
+ console.print(f"[green]✓ Downloaded to {filename}[/green]\n")
249
+ except Exception as e:
250
+ console.print(f"[red]Error downloading: {e}[/red]\n")
251
+ return True
252
+
253
+ elif action == "tags":
254
+ import questionary
255
+ current_tags = [t.get("name", "") for t in notebook.get("tags", [])]
256
+ console.print(f"[dim]Current tags: {', '.join(current_tags) if current_tags else 'none'}[/dim]")
257
+
258
+ new_tags = questionary.text(
259
+ "Enter tags (comma-separated):",
260
+ default=", ".join(current_tags)
261
+ ).ask()
262
+
263
+ if new_tags is not None:
264
+ tag_list = [t.strip() for t in new_tags.split(',') if t.strip()]
265
+ try:
266
+ with get_api_client(config) as client:
267
+ response = client.patch(f"/api/notebooks/{nb_id}", json={"tags": tag_list})
268
+ response.raise_for_status()
269
+ console.print(f"[green]✓ Tags updated[/green]\n")
270
+ except Exception as e:
271
+ console.print(f"[red]Error updating tags: {e}[/red]\n")
272
+ return True
273
+
274
+ elif action == "publish":
275
+ try:
276
+ with get_api_client(config) as client:
277
+ response = client.patch(f"/api/notebooks/{nb_id}", json={"is_public": True})
278
+ response.raise_for_status()
279
+ console.print(f"[green]✓ Notebook published![/green]\n")
280
+ except Exception as e:
281
+ console.print(f"[red]Error: {e}[/red]\n")
282
+ return True
283
+
284
+ elif action == "unpublish":
285
+ try:
286
+ with get_api_client(config) as client:
287
+ response = client.patch(f"/api/notebooks/{nb_id}", json={"is_public": False})
288
+ response.raise_for_status()
289
+ console.print(f"[green]✓ Notebook unpublished![/green]\n")
290
+ except Exception as e:
291
+ console.print(f"[red]Error: {e}[/red]\n")
292
+ return True
293
+
294
+ elif action == "delete":
295
+ if Confirm.ask(f"[red]Delete '{notebook.get('title')}'? This cannot be undone.[/red]"):
296
+ try:
297
+ with get_api_client(config) as client:
298
+ response = client.delete(f"/api/notebooks/{nb_id}")
299
+ response.raise_for_status()
300
+ console.print(f"[green]✓ Notebook deleted[/green]\n")
301
+ return False # Go back to notebook list
302
+ except Exception as e:
303
+ console.print(f"[red]Error: {e}[/red]\n")
304
+ return True
305
+
306
+ elif action == "back":
307
+ return False
308
+
309
+ return True
310
+
311
+
312
+ def _select_org_action() -> Optional[str]:
313
+ """Select what to do in an organization."""
314
+ import questionary
315
+
316
+ actions = [
317
+ questionary.Choice(title="📂 Browse notebooks", value="browse"),
318
+ questionary.Choice(title="⬆️ Upload notebook", value="upload"),
319
+ questionary.Separator(),
320
+ questionary.Choice(title="← Back", value="back"),
321
+ ]
322
+
323
+ return questionary.select(
324
+ "What would you like to do?",
325
+ choices=actions,
326
+ style=questionary.Style([
327
+ ('question', 'bold'),
328
+ ('pointer', 'fg:#673ab7 bold'),
329
+ ('highlighted', 'fg:#673ab7 bold'),
330
+ ])
331
+ ).ask()
332
+
333
+
334
+ def _interactive_upload(ctx: click.Context, org_slug: str) -> None:
335
+ """Interactive notebook upload flow."""
336
+ import questionary
337
+
338
+ config: Config = ctx.obj['config']
339
+
340
+ # Get file path
341
+ file_path = questionary.path(
342
+ "Select notebook file:",
343
+ only_directories=False,
344
+ validate=lambda p: p.endswith('.ipynb') or "Must be a .ipynb file"
345
+ ).ask()
346
+
347
+ if not file_path:
348
+ return
349
+
350
+ path = Path(file_path)
351
+ if not path.exists():
352
+ console.print(f"[red]File not found: {file_path}[/red]")
353
+ return
354
+
355
+ # Read and validate notebook
356
+ try:
357
+ with open(path, 'r', encoding='utf-8') as f:
358
+ notebook_content = json.load(f)
359
+ if 'cells' not in notebook_content:
360
+ console.print("[red]Invalid notebook format.[/red]")
361
+ return
362
+ except json.JSONDecodeError:
363
+ console.print("[red]Invalid JSON in notebook file.[/red]")
364
+ return
365
+ except Exception as e:
366
+ console.print(f"[red]Error reading file: {e}[/red]")
367
+ return
368
+
369
+ # Get title
370
+ default_title = path.stem.replace('_', ' ').replace('-', ' ').title()
371
+ title = questionary.text(
372
+ "Title:",
373
+ default=default_title
374
+ ).ask()
375
+
376
+ if not title:
377
+ return
378
+
379
+ # Optional description
380
+ description = questionary.text(
381
+ "Description (optional):",
382
+ default=""
383
+ ).ask()
384
+
385
+ # Visibility
386
+ is_public = questionary.confirm(
387
+ "Make public?",
388
+ default=False
389
+ ).ask()
390
+
391
+ # Tags
392
+ tags_input = questionary.text(
393
+ "Tags (comma-separated, optional):",
394
+ default=""
395
+ ).ask()
396
+
397
+ tag_list = [t.strip() for t in tags_input.split(',') if t.strip()] if tags_input else []
398
+
399
+ # Upload
400
+ with Progress(
401
+ SpinnerColumn(),
402
+ TextColumn("[progress.description]{task.description}"),
403
+ console=console
404
+ ) as progress:
405
+ task = progress.add_task("Uploading notebook...", total=None)
406
+
407
+ try:
408
+ with get_api_client(config) as api_client:
409
+ payload = {
410
+ "org_slug": org_slug,
411
+ "title": title,
412
+ "content": notebook_content,
413
+ "is_public": is_public,
414
+ }
415
+ if description:
416
+ payload["description"] = description
417
+ if tag_list:
418
+ payload["tags"] = tag_list
419
+
420
+ response = api_client.post("/api/notebooks", json=payload)
421
+ response.raise_for_status()
422
+ data = response.json()
423
+ except httpx.HTTPStatusError as e:
424
+ progress.update(task, completed=1)
425
+ console.print(f"[red]Error: {e.response.status_code} - {e.response.text}[/red]")
426
+ return
427
+ except Exception as e:
428
+ progress.update(task, completed=1)
429
+ console.print(f"[red]Error uploading: {e}[/red]")
430
+ return
431
+
432
+ progress.update(task, completed=1)
433
+
434
+ notebook = data.get("notebook", {})
435
+ console.print(f"\n[green]✓ Uploaded successfully![/green]")
436
+ console.print(f" Title: {notebook.get('title')}")
437
+ console.print(f" Slug: {notebook.get('slug')}")
438
+ visibility = "🌍 Public" if notebook.get('is_public') else "🔒 Private"
439
+ console.print(f" Visibility: {visibility}")
440
+
441
+ slug = notebook.get("slug", "")
442
+ if org_slug and slug:
443
+ url = f"{config.api_url.replace('/api', '')}/{org_slug}/~/notebooks/{slug}"
444
+ console.print(f" URL: [cyan]{url}[/cyan]")
445
+ console.print()
446
+
447
+
448
+ def _interactive_mode(ctx: click.Context) -> None:
449
+ """Run the interactive notebook browser."""
450
+ config: Config = ctx.obj['config']
451
+ client: AlphAIClient = ctx.obj['client']
452
+
453
+ if not config.bearer_token:
454
+ console.print("[red]Error: Not logged in. Run 'alphai login' first.[/red]")
455
+ sys.exit(1)
456
+
457
+ console.print("[bold cyan]📓 Notebook Browser[/bold cyan]\n")
458
+
459
+ while True:
460
+ # Select organization
461
+ org_slug = _select_organization(client)
462
+ if not org_slug:
463
+ return
464
+
465
+ while True:
466
+ # Ask what to do in this org
467
+ org_action = _select_org_action()
468
+
469
+ if not org_action or org_action == "back":
470
+ console.print()
471
+ break
472
+
473
+ if org_action == "upload":
474
+ _interactive_upload(ctx, org_slug)
475
+ continue
476
+
477
+ # Browse notebooks
478
+ notebooks_list = _fetch_notebooks(config, org_slug)
479
+ notebook = _select_notebook(notebooks_list)
480
+
481
+ if not notebook:
482
+ continue # Back to org action menu
483
+
484
+ console.print(f"\n[bold]{notebook.get('title')}[/bold]")
485
+ console.print(f"[dim]{notebook.get('description', 'No description')}[/dim]\n")
486
+
487
+ while True:
488
+ action = _select_action(notebook)
489
+ if not action or action == "back":
490
+ console.print()
491
+ break
492
+
493
+ if not _execute_action(ctx, notebook, action):
494
+ break # Action requests going back
495
+
496
+ # Refresh notebook state after visibility changes
497
+ if action in ("publish", "unpublish"):
498
+ notebook["is_public"] = (action == "publish")
499
+
500
+
501
+ @click.group(invoke_without_command=True)
502
+ @click.pass_context
503
+ def notebooks(ctx: click.Context) -> None:
504
+ """Manage Jupyter notebooks.
505
+
506
+ Run without arguments for interactive mode, or use subcommands:
507
+
508
+ \b
509
+ Examples:
510
+ alphai nb # Interactive browser
511
+ alphai nb list
512
+ alphai nb view my-notebook
513
+ alphai nb upload analysis.ipynb --org my-org
514
+ """
515
+ if ctx.invoked_subcommand is None:
516
+ _interactive_mode(ctx)
517
+
518
+
519
+ @notebooks.command(name="list")
520
+ @click.option('--org', help='Organization slug')
521
+ @click.option('--search', help='Search in title and description')
522
+ @click.option('--tag', help='Filter by tag')
523
+ @click.option('--public', 'visibility', flag_value='public', help='Show only public notebooks')
524
+ @click.option('--private', 'visibility', flag_value='private', help='Show only private notebooks')
525
+ @click.option('--limit', default=20, type=int, help='Maximum results')
526
+ @click.pass_context
527
+ def list_notebooks(ctx: click.Context, org: Optional[str], search: Optional[str],
528
+ tag: Optional[str], visibility: Optional[str], limit: int) -> None:
529
+ """List notebooks in your organizations."""
530
+ config: Config = ctx.obj['config']
531
+
532
+ if not config.bearer_token:
533
+ console.print("[red]Error: Not logged in. Run 'alphai login' first.[/red]")
534
+ return
535
+
536
+ # Use current org if not specified
537
+ org_slug = org or config.current_org
538
+
539
+ with Progress(
540
+ SpinnerColumn(),
541
+ TextColumn("[progress.description]{task.description}"),
542
+ console=console
543
+ ) as progress:
544
+ task = progress.add_task("Fetching notebooks...", total=None)
545
+
546
+ try:
547
+ with get_api_client(config) as client:
548
+ params = {"limit": limit}
549
+ if org_slug:
550
+ params["org_slug"] = org_slug
551
+ if search:
552
+ params["search"] = search
553
+ if tag:
554
+ params["tag"] = tag
555
+ if visibility:
556
+ params["visibility"] = visibility
557
+
558
+ response = client.get("/api/notebooks", params=params)
559
+ response.raise_for_status()
560
+ data = response.json()
561
+ except httpx.HTTPStatusError as e:
562
+ progress.update(task, completed=1)
563
+ console.print(f"[red]Error: {e.response.status_code} - {e.response.text}[/red]")
564
+ return
565
+ except Exception as e:
566
+ progress.update(task, completed=1)
567
+ console.print(f"[red]Error fetching notebooks: {e}[/red]")
568
+ return
569
+
570
+ progress.update(task, completed=1)
571
+
572
+ notebooks_data = data.get("notebooks", [])
573
+
574
+ if not notebooks_data:
575
+ console.print("[yellow]No notebooks found.[/yellow]")
576
+ return
577
+
578
+ # Import here to avoid circular imports
579
+ from ..notebook_renderer import display_notebooks_table
580
+ display_notebooks_table(notebooks_data, console)
581
+
582
+ total = data.get("total", len(notebooks_data))
583
+ if total > limit:
584
+ console.print(f"\n[dim]Showing {len(notebooks_data)} of {total} notebooks. Use --limit to see more.[/dim]")
585
+
586
+
587
+ @notebooks.command()
588
+ @click.argument('identifier')
589
+ @click.option('--browser', '-b', is_flag=True, help='Open in web browser')
590
+ @click.pass_context
591
+ def info(ctx: click.Context, identifier: str, browser: bool) -> None:
592
+ """Show notebook information."""
593
+ config: Config = ctx.obj['config']
594
+
595
+ if not config.bearer_token:
596
+ console.print("[red]Error: Not logged in. Run 'alphai login' first.[/red]")
597
+ return
598
+
599
+ with Progress(
600
+ SpinnerColumn(),
601
+ TextColumn("[progress.description]{task.description}"),
602
+ console=console
603
+ ) as progress:
604
+ task = progress.add_task("Fetching notebook...", total=None)
605
+
606
+ try:
607
+ with get_api_client(config) as client:
608
+ response = client.get(f"/api/notebooks/{identifier}", params={"include_content": "false"})
609
+ response.raise_for_status()
610
+ data = response.json()
611
+ except httpx.HTTPStatusError as e:
612
+ progress.update(task, completed=1)
613
+ if e.response.status_code == 404:
614
+ console.print(f"[red]Notebook '{identifier}' not found.[/red]")
615
+ else:
616
+ console.print(f"[red]Error: {e.response.status_code} - {e.response.text}[/red]")
617
+ return
618
+ except Exception as e:
619
+ progress.update(task, completed=1)
620
+ console.print(f"[red]Error fetching notebook: {e}[/red]")
621
+ return
622
+
623
+ progress.update(task, completed=1)
624
+
625
+ notebook = data.get("notebook", {})
626
+
627
+ from ..notebook_renderer import display_notebook_info
628
+ display_notebook_info(notebook, console)
629
+
630
+ if browser:
631
+ org_slug = notebook.get("organizations", {}).get("slug", "")
632
+ slug = notebook.get("slug", "")
633
+ if org_slug and slug:
634
+ url = f"{config.api_url.replace('/api', '')}/{org_slug}/~/notebooks/{slug}"
635
+ console.print(f"\n[cyan]Opening in browser: {url}[/cyan]")
636
+ webbrowser.open(url)
637
+
638
+
639
+ @notebooks.command()
640
+ @click.argument('identifier')
641
+ @click.option('--browser', '-b', is_flag=True, help='Open in web browser instead')
642
+ @click.option('--static', '-s', is_flag=True, help='Show all cells at once (non-interactive)')
643
+ @click.pass_context
644
+ def view(ctx: click.Context, identifier: str, browser: bool, static: bool) -> None:
645
+ """View notebook content in the terminal.
646
+
647
+ By default, opens an interactive viewer where you can scroll through cells
648
+ using arrow keys. Press 'q' to quit.
649
+
650
+ Use --static to display all cells at once without interaction.
651
+ """
652
+ config: Config = ctx.obj['config']
653
+
654
+ if not config.bearer_token:
655
+ console.print("[red]Error: Not logged in. Run 'alphai login' first.[/red]")
656
+ return
657
+
658
+ if browser:
659
+ # Just open in browser, fetch minimal info
660
+ with Progress(
661
+ SpinnerColumn(),
662
+ TextColumn("[progress.description]{task.description}"),
663
+ console=console
664
+ ) as progress:
665
+ task = progress.add_task("Fetching notebook...", total=None)
666
+ try:
667
+ with get_api_client(config) as client:
668
+ response = client.get(f"/api/notebooks/{identifier}", params={"include_content": "false"})
669
+ response.raise_for_status()
670
+ data = response.json()
671
+ except httpx.HTTPStatusError as e:
672
+ progress.update(task, completed=1)
673
+ if e.response.status_code == 404:
674
+ console.print(f"[red]Notebook '{identifier}' not found.[/red]")
675
+ else:
676
+ console.print(f"[red]Error: {e.response.status_code} - {e.response.text}[/red]")
677
+ return
678
+ except Exception as e:
679
+ progress.update(task, completed=1)
680
+ console.print(f"[red]Error: {e}[/red]")
681
+ return
682
+ progress.update(task, completed=1)
683
+
684
+ notebook = data.get("notebook", {})
685
+ org_slug = notebook.get("organizations", {}).get("slug", "")
686
+ slug = notebook.get("slug", "")
687
+ if org_slug and slug:
688
+ url = f"{config.api_url.replace('/api', '')}/{org_slug}/~/notebooks/{slug}"
689
+ console.print(f"[cyan]Opening: {url}[/cyan]")
690
+ webbrowser.open(url)
691
+ return
692
+
693
+ with Progress(
694
+ SpinnerColumn(),
695
+ TextColumn("[progress.description]{task.description}"),
696
+ console=console
697
+ ) as progress:
698
+ task = progress.add_task("Fetching notebook...", total=None)
699
+
700
+ try:
701
+ with get_api_client(config) as client:
702
+ response = client.get(f"/api/notebooks/{identifier}", params={"include_content": "true"})
703
+ response.raise_for_status()
704
+ data = response.json()
705
+ except httpx.HTTPStatusError as e:
706
+ progress.update(task, completed=1)
707
+ if e.response.status_code == 404:
708
+ console.print(f"[red]Notebook '{identifier}' not found.[/red]")
709
+ else:
710
+ console.print(f"[red]Error: {e.response.status_code} - {e.response.text}[/red]")
711
+ return
712
+ except Exception as e:
713
+ progress.update(task, completed=1)
714
+ console.print(f"[red]Error fetching notebook: {e}[/red]")
715
+ return
716
+
717
+ progress.update(task, completed=1)
718
+
719
+ notebook = data.get("notebook", {})
720
+ cells = notebook.get("content", {}).get("cells", [])
721
+
722
+ if not cells:
723
+ console.print("[yellow]Notebook has no cells.[/yellow]")
724
+ return
725
+
726
+ if static:
727
+ from ..notebook_renderer import display_notebook_preview
728
+ display_notebook_preview(notebook, console)
729
+ else:
730
+ from ..notebook_renderer import interactive_cell_viewer
731
+ interactive_cell_viewer(cells, console)
732
+
733
+
734
+ @notebooks.command()
735
+ @click.argument('file_path', type=click.Path(exists=True))
736
+ @click.option('--org', required=True, help='Organization slug')
737
+ @click.option('--title', help='Notebook title (default: filename)')
738
+ @click.option('--description', help='Notebook description')
739
+ @click.option('--public', is_flag=True, help='Make notebook public')
740
+ @click.option('--tags', help='Comma-separated tags')
741
+ @click.pass_context
742
+ def upload(ctx: click.Context, file_path: str, org: str, title: Optional[str],
743
+ description: Optional[str], public: bool, tags: Optional[str]) -> None:
744
+ """Upload a local .ipynb file."""
745
+ config: Config = ctx.obj['config']
746
+
747
+ if not config.bearer_token:
748
+ console.print("[red]Error: Not logged in. Run 'alphai login' first.[/red]")
749
+ return
750
+
751
+ path = Path(file_path)
752
+ if not path.suffix == '.ipynb':
753
+ console.print("[red]Error: File must be a .ipynb file.[/red]")
754
+ return
755
+
756
+ # Read and validate the file
757
+ try:
758
+ with open(path, 'r', encoding='utf-8') as f:
759
+ notebook_content = json.load(f)
760
+
761
+ if 'cells' not in notebook_content:
762
+ console.print("[red]Error: Invalid notebook format.[/red]")
763
+ return
764
+ except json.JSONDecodeError:
765
+ console.print("[red]Error: Invalid JSON in notebook file.[/red]")
766
+ return
767
+ except Exception as e:
768
+ console.print(f"[red]Error reading file: {e}[/red]")
769
+ return
770
+
771
+ notebook_title = title or path.stem
772
+ tag_list = [t.strip() for t in tags.split(',')] if tags else []
773
+
774
+ with Progress(
775
+ SpinnerColumn(),
776
+ TextColumn("[progress.description]{task.description}"),
777
+ console=console
778
+ ) as progress:
779
+ task = progress.add_task("Uploading notebook...", total=None)
780
+
781
+ try:
782
+ with get_api_client(config) as client:
783
+ payload = {
784
+ "org_slug": org,
785
+ "title": notebook_title,
786
+ "content": notebook_content,
787
+ "is_public": public,
788
+ }
789
+ if description:
790
+ payload["description"] = description
791
+ if tag_list:
792
+ payload["tags"] = tag_list
793
+
794
+ response = client.post("/api/notebooks", json=payload)
795
+ response.raise_for_status()
796
+ data = response.json()
797
+ except httpx.HTTPStatusError as e:
798
+ progress.update(task, completed=1)
799
+ console.print(f"[red]Error: {e.response.status_code} - {e.response.text}[/red]")
800
+ return
801
+ except Exception as e:
802
+ progress.update(task, completed=1)
803
+ console.print(f"[red]Error uploading notebook: {e}[/red]")
804
+ return
805
+
806
+ progress.update(task, completed=1)
807
+
808
+ notebook = data.get("notebook", {})
809
+ console.print(f"[green]✓ Notebook uploaded successfully![/green]")
810
+ console.print(f" Title: {notebook.get('title')}")
811
+ console.print(f" Slug: {notebook.get('slug')}")
812
+ console.print(f" Visibility: {'Public' if notebook.get('is_public') else 'Private'}")
813
+
814
+ org_slug = notebook.get("organization", {}).get("slug", org)
815
+ slug = notebook.get("slug", "")
816
+ if org_slug and slug:
817
+ url = f"{config.api_url.replace('/api', '')}/{org_slug}/~/notebooks/{slug}"
818
+ console.print(f" URL: [cyan]{url}[/cyan]")
819
+
820
+
821
+ @notebooks.command()
822
+ @click.argument('identifier')
823
+ @click.option('--output', '-o', type=click.Path(), help='Output file path')
824
+ @click.pass_context
825
+ def download(ctx: click.Context, identifier: str, output: Optional[str]) -> None:
826
+ """Download a notebook as .ipynb file."""
827
+ config: Config = ctx.obj['config']
828
+
829
+ if not config.bearer_token:
830
+ console.print("[red]Error: Not logged in. Run 'alphai login' first.[/red]")
831
+ return
832
+
833
+ with Progress(
834
+ SpinnerColumn(),
835
+ TextColumn("[progress.description]{task.description}"),
836
+ console=console
837
+ ) as progress:
838
+ task = progress.add_task("Downloading notebook...", total=None)
839
+
840
+ try:
841
+ with get_api_client(config) as client:
842
+ response = client.get(f"/api/notebooks/{identifier}/download")
843
+ response.raise_for_status()
844
+
845
+ # Get filename from content-disposition or use identifier
846
+ content_disp = response.headers.get("content-disposition", "")
847
+ if "filename=" in content_disp:
848
+ filename = content_disp.split("filename=")[1].strip('"')
849
+ else:
850
+ filename = f"{identifier}.ipynb"
851
+
852
+ output_path = output or filename
853
+
854
+ with open(output_path, 'w', encoding='utf-8') as f:
855
+ f.write(response.text)
856
+
857
+ except httpx.HTTPStatusError as e:
858
+ progress.update(task, completed=1)
859
+ if e.response.status_code == 404:
860
+ console.print(f"[red]Notebook '{identifier}' not found.[/red]")
861
+ else:
862
+ console.print(f"[red]Error: {e.response.status_code} - {e.response.text}[/red]")
863
+ return
864
+ except Exception as e:
865
+ progress.update(task, completed=1)
866
+ console.print(f"[red]Error downloading notebook: {e}[/red]")
867
+ return
868
+
869
+ progress.update(task, completed=1)
870
+
871
+ console.print(f"[green]✓ Notebook downloaded to: {output_path}[/green]")
872
+
873
+
874
+ @notebooks.command()
875
+ @click.argument('identifier')
876
+ @click.option('--force', '-f', is_flag=True, help='Skip confirmation')
877
+ @click.pass_context
878
+ def delete(ctx: click.Context, identifier: str, force: bool) -> None:
879
+ """Delete a notebook."""
880
+ config: Config = ctx.obj['config']
881
+
882
+ if not config.bearer_token:
883
+ console.print("[red]Error: Not logged in. Run 'alphai login' first.[/red]")
884
+ return
885
+
886
+ if not force:
887
+ if not Confirm.ask(f"Are you sure you want to delete notebook '{identifier}'?"):
888
+ console.print("[yellow]Cancelled.[/yellow]")
889
+ return
890
+
891
+ with Progress(
892
+ SpinnerColumn(),
893
+ TextColumn("[progress.description]{task.description}"),
894
+ console=console
895
+ ) as progress:
896
+ task = progress.add_task("Deleting notebook...", total=None)
897
+
898
+ try:
899
+ with get_api_client(config) as client:
900
+ response = client.delete(f"/api/notebooks/{identifier}")
901
+ response.raise_for_status()
902
+ except httpx.HTTPStatusError as e:
903
+ progress.update(task, completed=1)
904
+ if e.response.status_code == 404:
905
+ console.print(f"[red]Notebook '{identifier}' not found.[/red]")
906
+ else:
907
+ console.print(f"[red]Error: {e.response.status_code} - {e.response.text}[/red]")
908
+ return
909
+ except Exception as e:
910
+ progress.update(task, completed=1)
911
+ console.print(f"[red]Error deleting notebook: {e}[/red]")
912
+ return
913
+
914
+ progress.update(task, completed=1)
915
+
916
+ console.print(f"[green]✓ Notebook deleted successfully.[/green]")
917
+
918
+
919
+ @notebooks.command()
920
+ @click.argument('identifier')
921
+ @click.pass_context
922
+ def publish(ctx: click.Context, identifier: str) -> None:
923
+ """Publish a notebook (make it public)."""
924
+ config: Config = ctx.obj['config']
925
+
926
+ if not config.bearer_token:
927
+ console.print("[red]Error: Not logged in. Run 'alphai login' first.[/red]")
928
+ return
929
+
930
+ with Progress(
931
+ SpinnerColumn(),
932
+ TextColumn("[progress.description]{task.description}"),
933
+ console=console
934
+ ) as progress:
935
+ task = progress.add_task("Publishing notebook...", total=None)
936
+
937
+ try:
938
+ with get_api_client(config) as client:
939
+ response = client.patch(f"/api/notebooks/{identifier}", json={"is_public": True})
940
+ response.raise_for_status()
941
+ except httpx.HTTPStatusError as e:
942
+ progress.update(task, completed=1)
943
+ console.print(f"[red]Error: {e.response.status_code} - {e.response.text}[/red]")
944
+ return
945
+ except Exception as e:
946
+ progress.update(task, completed=1)
947
+ console.print(f"[red]Error publishing notebook: {e}[/red]")
948
+ return
949
+
950
+ progress.update(task, completed=1)
951
+
952
+ console.print(f"[green]✓ Notebook published! It is now publicly visible.[/green]")
953
+
954
+
955
+ @notebooks.command()
956
+ @click.argument('identifier')
957
+ @click.pass_context
958
+ def unpublish(ctx: click.Context, identifier: str) -> None:
959
+ """Unpublish a notebook (make it private)."""
960
+ config: Config = ctx.obj['config']
961
+
962
+ if not config.bearer_token:
963
+ console.print("[red]Error: Not logged in. Run 'alphai login' first.[/red]")
964
+ return
965
+
966
+ with Progress(
967
+ SpinnerColumn(),
968
+ TextColumn("[progress.description]{task.description}"),
969
+ console=console
970
+ ) as progress:
971
+ task = progress.add_task("Unpublishing notebook...", total=None)
972
+
973
+ try:
974
+ with get_api_client(config) as client:
975
+ response = client.patch(f"/api/notebooks/{identifier}", json={"is_public": False})
976
+ response.raise_for_status()
977
+ except httpx.HTTPStatusError as e:
978
+ progress.update(task, completed=1)
979
+ console.print(f"[red]Error: {e.response.status_code} - {e.response.text}[/red]")
980
+ return
981
+ except Exception as e:
982
+ progress.update(task, completed=1)
983
+ console.print(f"[red]Error unpublishing notebook: {e}[/red]")
984
+ return
985
+
986
+ progress.update(task, completed=1)
987
+
988
+ console.print(f"[green]✓ Notebook unpublished. It is now private.[/green]")
989
+
990
+
991
+ @notebooks.command(name="tags")
992
+ @click.argument('identifier')
993
+ @click.option('--add', 'add_tags', help='Tags to add (comma-separated)')
994
+ @click.option('--remove', 'remove_tags', help='Tags to remove (comma-separated)')
995
+ @click.option('--set', 'set_tags', help='Set tags (replaces existing, comma-separated)')
996
+ @click.pass_context
997
+ def manage_tags(ctx: click.Context, identifier: str, add_tags: Optional[str],
998
+ remove_tags: Optional[str], set_tags: Optional[str]) -> None:
999
+ """Manage notebook tags."""
1000
+ config: Config = ctx.obj['config']
1001
+
1002
+ if not config.bearer_token:
1003
+ console.print("[red]Error: Not logged in. Run 'alphai login' first.[/red]")
1004
+ return
1005
+
1006
+ if set_tags:
1007
+ # Replace all tags
1008
+ new_tags = [t.strip() for t in set_tags.split(',') if t.strip()]
1009
+ else:
1010
+ # Get current tags first
1011
+ try:
1012
+ with get_api_client(config) as client:
1013
+ response = client.get(f"/api/notebooks/{identifier}", params={"include_content": "false"})
1014
+ response.raise_for_status()
1015
+ data = response.json()
1016
+ except Exception as e:
1017
+ console.print(f"[red]Error fetching notebook: {e}[/red]")
1018
+ return
1019
+
1020
+ current_tags = set(t.get("name", "") for t in data.get("notebook", {}).get("tags", []))
1021
+
1022
+ if add_tags:
1023
+ for tag in add_tags.split(','):
1024
+ current_tags.add(tag.strip())
1025
+
1026
+ if remove_tags:
1027
+ for tag in remove_tags.split(','):
1028
+ current_tags.discard(tag.strip())
1029
+
1030
+ new_tags = list(current_tags)
1031
+
1032
+ with Progress(
1033
+ SpinnerColumn(),
1034
+ TextColumn("[progress.description]{task.description}"),
1035
+ console=console
1036
+ ) as progress:
1037
+ task = progress.add_task("Updating tags...", total=None)
1038
+
1039
+ try:
1040
+ with get_api_client(config) as client:
1041
+ response = client.patch(f"/api/notebooks/{identifier}", json={"tags": new_tags})
1042
+ response.raise_for_status()
1043
+ except httpx.HTTPStatusError as e:
1044
+ progress.update(task, completed=1)
1045
+ console.print(f"[red]Error: {e.response.status_code} - {e.response.text}[/red]")
1046
+ return
1047
+ except Exception as e:
1048
+ progress.update(task, completed=1)
1049
+ console.print(f"[red]Error updating tags: {e}[/red]")
1050
+ return
1051
+
1052
+ progress.update(task, completed=1)
1053
+
1054
+ if new_tags:
1055
+ console.print(f"[green]✓ Tags updated: {', '.join(new_tags)}[/green]")
1056
+ else:
1057
+ console.print(f"[green]✓ All tags removed.[/green]")
1058
+
1059
+
1060
+ @notebooks.command()
1061
+ @click.argument('identifier')
1062
+ @click.option('--org', required=True, help='Target organization slug')
1063
+ @click.option('--title', help='Custom title for the fork')
1064
+ @click.pass_context
1065
+ def fork(ctx: click.Context, identifier: str, org: str, title: Optional[str]) -> None:
1066
+ """Fork a public notebook to your organization."""
1067
+ config: Config = ctx.obj['config']
1068
+
1069
+ if not config.bearer_token:
1070
+ console.print("[red]Error: Not logged in. Run 'alphai login' first.[/red]")
1071
+ return
1072
+
1073
+ with Progress(
1074
+ SpinnerColumn(),
1075
+ TextColumn("[progress.description]{task.description}"),
1076
+ console=console
1077
+ ) as progress:
1078
+ task = progress.add_task("Forking notebook...", total=None)
1079
+
1080
+ try:
1081
+ with get_api_client(config) as client:
1082
+ payload = {"org_slug": org}
1083
+ if title:
1084
+ payload["title"] = title
1085
+
1086
+ response = client.post(f"/api/notebooks/{identifier}/fork", json=payload)
1087
+ response.raise_for_status()
1088
+ data = response.json()
1089
+ except httpx.HTTPStatusError as e:
1090
+ progress.update(task, completed=1)
1091
+ if e.response.status_code == 404:
1092
+ console.print(f"[red]Notebook '{identifier}' not found.[/red]")
1093
+ elif e.response.status_code == 403:
1094
+ console.print(f"[red]Cannot fork: notebook is not public or you don't have access.[/red]")
1095
+ else:
1096
+ console.print(f"[red]Error: {e.response.status_code} - {e.response.text}[/red]")
1097
+ return
1098
+ except Exception as e:
1099
+ progress.update(task, completed=1)
1100
+ console.print(f"[red]Error forking notebook: {e}[/red]")
1101
+ return
1102
+
1103
+ progress.update(task, completed=1)
1104
+
1105
+ notebook = data.get("notebook", {})
1106
+ console.print(f"[green]✓ Notebook forked successfully![/green]")
1107
+ console.print(f" Title: {notebook.get('title')}")
1108
+ console.print(f" Slug: {notebook.get('slug')}")
1109
+
1110
+ org_slug = notebook.get("organization", {}).get("slug", org)
1111
+ slug = notebook.get("slug", "")
1112
+ if org_slug and slug:
1113
+ url = f"{config.api_url.replace('/api', '')}/{org_slug}/~/notebooks/{slug}"
1114
+ console.print(f" URL: [cyan]{url}[/cyan]")
1115
+
1116
+
1117
+ @notebooks.command()
1118
+ @click.argument('query')
1119
+ @click.option('--org', help='Search within organization')
1120
+ @click.option('--public', is_flag=True, help='Search only public notebooks')
1121
+ @click.option('--limit', default=20, type=int, help='Maximum results')
1122
+ @click.pass_context
1123
+ def search(ctx: click.Context, query: str, org: Optional[str], public: bool, limit: int) -> None:
1124
+ """Search notebooks by title and description."""
1125
+ config: Config = ctx.obj['config']
1126
+
1127
+ if not config.bearer_token:
1128
+ console.print("[red]Error: Not logged in. Run 'alphai login' first.[/red]")
1129
+ return
1130
+
1131
+ with Progress(
1132
+ SpinnerColumn(),
1133
+ TextColumn("[progress.description]{task.description}"),
1134
+ console=console
1135
+ ) as progress:
1136
+ task = progress.add_task(f"Searching for '{query}'...", total=None)
1137
+
1138
+ try:
1139
+ with get_api_client(config) as client:
1140
+ params = {"q": query, "limit": limit}
1141
+ if org:
1142
+ params["org_slug"] = org
1143
+ if public:
1144
+ params["public_only"] = "true"
1145
+
1146
+ response = client.get("/api/notebooks/search", params=params)
1147
+ response.raise_for_status()
1148
+ data = response.json()
1149
+ except httpx.HTTPStatusError as e:
1150
+ progress.update(task, completed=1)
1151
+ console.print(f"[red]Error: {e.response.status_code} - {e.response.text}[/red]")
1152
+ return
1153
+ except Exception as e:
1154
+ progress.update(task, completed=1)
1155
+ console.print(f"[red]Error searching notebooks: {e}[/red]")
1156
+ return
1157
+
1158
+ progress.update(task, completed=1)
1159
+
1160
+ notebooks_data = data.get("notebooks", [])
1161
+
1162
+ if not notebooks_data:
1163
+ console.print(f"[yellow]No notebooks found matching '{query}'.[/yellow]")
1164
+ return
1165
+
1166
+ console.print(f"\n[bold]Search results for '{query}':[/bold]\n")
1167
+
1168
+ from ..notebook_renderer import display_notebooks_table
1169
+ display_notebooks_table(notebooks_data, console)
1170
+
1171
+ total = data.get("total", len(notebooks_data))
1172
+ if total > limit:
1173
+ console.print(f"\n[dim]Showing {len(notebooks_data)} of {total} results.[/dim]")