deepctl-cmd-init 0.0.2__tar.gz → 0.0.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepctl-cmd-init
3
- Version: 0.0.2
3
+ Version: 0.0.3
4
4
  Summary: Init command for deepctl — scaffold Deepgram starter apps
5
5
  Author-email: Deepgram <devrel@deepgram.com>
6
6
  Maintainer-email: Deepgram <devrel@deepgram.com>
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "deepctl-cmd-init"
7
- version = "0.0.2" # x-release-please-version
7
+ version = "0.0.3" # x-release-please-version
8
8
  description = "Init command for deepctl — scaffold Deepgram starter apps"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -0,0 +1,415 @@
1
+ """Init command for deepctl — scaffold Deepgram starter apps."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from deepctl_core import (
9
+ AuthManager,
10
+ BaseCommand,
11
+ Config,
12
+ DeepgramClient,
13
+ )
14
+ from deepctl_core.output import _agentic
15
+ from rich.console import Console
16
+ from rich.table import Table
17
+
18
+ from . import lifecycle, templates_api
19
+ from .models import InitResult, TemplateDetail, TemplateInfo
20
+
21
+ console = Console()
22
+ status_console = Console(stderr=True)
23
+
24
+
25
+ class InitCommand(BaseCommand):
26
+ """Scaffold a Deepgram starter app from the templates gallery."""
27
+
28
+ name = "init"
29
+ help = (
30
+ "Browse, clone, and set up Deepgram starter apps.\n\n"
31
+ "Fetches templates from the Deepgram templates gallery. "
32
+ "Run with no arguments for an interactive search-and-select picker, "
33
+ "or pass a template name directly to clone immediately."
34
+ )
35
+ short_help = "Scaffold a Deepgram starter app (alpha)"
36
+
37
+ requires_auth = False
38
+ requires_project = False
39
+ ci_friendly = True
40
+
41
+ examples = [
42
+ "dg init",
43
+ "dg init --search python",
44
+ "dg init node-transcription",
45
+ "dg init node-transcription --dir ./my-app",
46
+ "dg init node-transcription --install",
47
+ "dg init node-transcription --no-install",
48
+ "dg init --list",
49
+ "dg init --list --search flask",
50
+ ]
51
+ agent_help = (
52
+ "Scaffold a Deepgram starter app from the templates gallery. "
53
+ "Pass the template name/slug directly (e.g. 'node-transcription'). "
54
+ "Use --list to enumerate available templates. "
55
+ "After cloning, runs make check-prereqs then make init automatically. "
56
+ "Use --no-install to skip setup. "
57
+ "Requires git, node, npm, make, and curl to be installed."
58
+ )
59
+
60
+ def get_arguments(self) -> list[dict[str, Any]]:
61
+ """Get command arguments and options."""
62
+ return [
63
+ {
64
+ "name": "template",
65
+ "help": "Template name/slug to clone (e.g. node-transcription)",
66
+ "type": str,
67
+ "required": False,
68
+ "default": None,
69
+ },
70
+ {
71
+ "names": ["--dir", "-d"],
72
+ "help": "Target directory (default: ./{template-name})",
73
+ "type": str,
74
+ "is_option": True,
75
+ },
76
+ {
77
+ "names": ["--search", "-s"],
78
+ "help": "Filter templates by keyword",
79
+ "type": str,
80
+ "is_option": True,
81
+ },
82
+ {
83
+ "names": ["--list", "-l"],
84
+ "help": "List available templates and exit",
85
+ "is_flag": True,
86
+ "is_option": True,
87
+ },
88
+ {
89
+ "names": ["--install"],
90
+ "help": "Run setup (make check-prereqs && make init) without prompting",
91
+ "is_flag": True,
92
+ "is_option": True,
93
+ },
94
+ {
95
+ "names": ["--no-install"],
96
+ "help": "Skip setup step without prompting",
97
+ "is_flag": True,
98
+ "is_option": True,
99
+ },
100
+ {
101
+ "names": ["--start"],
102
+ "help": "Run start step after install without prompting",
103
+ "is_flag": True,
104
+ "is_option": True,
105
+ },
106
+ {
107
+ "names": ["--no-start"],
108
+ "help": "Skip start step without prompting",
109
+ "is_flag": True,
110
+ "is_option": True,
111
+ },
112
+ ]
113
+
114
+ def handle(
115
+ self,
116
+ config: Config,
117
+ auth_manager: AuthManager,
118
+ client: DeepgramClient,
119
+ **kwargs: Any,
120
+ ) -> Any:
121
+ """Handle the init command."""
122
+ template_slug: str | None = kwargs.get("template")
123
+ target_dir: str | None = kwargs.get("dir")
124
+ search: str | None = kwargs.get("search")
125
+ list_mode: bool = kwargs.get("list", False)
126
+ do_install: bool = kwargs.get("install", False)
127
+ no_install: bool = kwargs.get("no_install", False)
128
+ do_start: bool = kwargs.get("start", False)
129
+ no_start: bool = kwargs.get("no_start", False)
130
+
131
+ # ── List mode ──────────────────────────────────────────────────────────
132
+ if list_mode:
133
+ return self._handle_list(search)
134
+
135
+ # ── Prerequisite check ─────────────────────────────────────────────────
136
+ # Check before any clone attempt so users get a clear error with install
137
+ # hints rather than a confusing git/make error mid-setup.
138
+ missing = lifecycle.check_prereqs()
139
+ if missing:
140
+ console.print("[red bold]Missing required tools:[/red bold]")
141
+ for _key, name, hint in missing:
142
+ console.print(f" [red]✗[/red] [bold]{name}[/bold]")
143
+ console.print(f" {hint}")
144
+ console.print()
145
+ console.print(
146
+ "[dim]Install the tools above and re-run [bold]dg init[/bold].[/dim]"
147
+ )
148
+ return InitResult(
149
+ status="error",
150
+ message=f"Missing tools: {', '.join(k for k, _, _ in missing)}",
151
+ )
152
+
153
+ # ── Template selection ─────────────────────────────────────────────────
154
+ if template_slug:
155
+ detail = self._fetch_template(template_slug)
156
+ if not detail:
157
+ return InitResult(
158
+ status="error", message=f"Template not found: {template_slug}"
159
+ )
160
+ elif _agentic:
161
+ # In agent mode, require an explicit template name
162
+ return InitResult(
163
+ status="error",
164
+ message=(
165
+ "In agent mode, pass the template slug directly, e.g.: "
166
+ "dg init node-transcription\n"
167
+ "Use dg init --list to see available templates."
168
+ ),
169
+ )
170
+ else:
171
+ detail = self._interactive_pick(search)
172
+ if not detail:
173
+ return InitResult(status="cancelled", message="No template selected")
174
+
175
+ # ── Resolve target directory ───────────────────────────────────────────
176
+ dest = Path(target_dir) if target_dir else Path(f"./{detail.name}")
177
+ dest = dest.resolve()
178
+
179
+ # ── Confirm ────────────────────────────────────────────────────────────
180
+ self._print_template_summary(detail)
181
+ console.print(f"[dim]Destination:[/dim] {dest}")
182
+ console.print()
183
+
184
+ if not _agentic and not self.confirm("Clone this template?", default=True):
185
+ return InitResult(status="cancelled", message="Clone cancelled")
186
+
187
+ # ── Clone ──────────────────────────────────────────────────────────────
188
+ github_url = detail.links.get("github")
189
+ if not github_url:
190
+ return InitResult(status="error", message="Template has no GitHub URL")
191
+
192
+ console.print()
193
+ with console.status("[blue]Cloning repository...[/blue]"):
194
+ try:
195
+ lifecycle.clone_template(github_url, dest)
196
+ except RuntimeError as e:
197
+ return InitResult(status="error", message=str(e))
198
+
199
+ console.print(f"[green]✓[/green] Cloned to [cyan]{dest}[/cyan]")
200
+ console.print(
201
+ "[dim]The cloned directory is a full git repository — "
202
+ "you can add your own remote and push.[/dim]"
203
+ )
204
+
205
+ # ── Inject API key ─────────────────────────────────────────────────────
206
+ try:
207
+ api_key = auth_manager.get_api_key()
208
+ if api_key:
209
+ lifecycle.inject_env(dest, api_key)
210
+ except Exception:
211
+ pass # Auth is optional
212
+
213
+ # ── Setup (make check-prereqs && make init) ────────────────────────────
214
+ installed = False
215
+ has_makefile = (dest / "Makefile").exists()
216
+
217
+ if not no_install:
218
+ if _agentic or do_install:
219
+ run_setup = True
220
+ elif has_makefile:
221
+ console.print()
222
+ run_setup = self.confirm(
223
+ "Run setup? (make check-prereqs && make init)", default=True
224
+ )
225
+ else:
226
+ run_setup = False
227
+
228
+ if run_setup:
229
+ console.print()
230
+ if has_makefile:
231
+ installed = lifecycle.run_make_init(dest)
232
+ elif detail.config and detail.config.install:
233
+ # Fallback: TOML lifecycle for repos without a Makefile
234
+ installed = lifecycle.run_lifecycle_step(
235
+ detail.config.install, dest
236
+ )
237
+
238
+ # ── Start step (optional) ──────────────────────────────────────────────
239
+ started = False
240
+ if installed and detail.config and detail.config.start and not no_start:
241
+ if _agentic or do_start:
242
+ run_start = True
243
+ else:
244
+ console.print()
245
+ run_start = self.confirm("Run start step?", default=False)
246
+
247
+ if run_start:
248
+ console.print()
249
+ started = lifecycle.run_lifecycle_step(detail.config.start, dest)
250
+
251
+ # ── Summary ────────────────────────────────────────────────────────────
252
+ console.print()
253
+ console.print(
254
+ f"[green bold]Done![/green bold] "
255
+ f"[cyan]{detail.title}[/cyan] is ready at [cyan]{dest}[/cyan]"
256
+ )
257
+ console.print()
258
+
259
+ if not installed and has_makefile:
260
+ console.print(f"[dim]To set up: cd {dest.name} && make init[/dim]")
261
+ if not started and detail.config and detail.config.start:
262
+ console.print(f"[dim]To start: cd {dest.name} && make start[/dim]")
263
+
264
+ return InitResult(
265
+ status="success",
266
+ message=f"Cloned {detail.name}",
267
+ template=detail.name,
268
+ directory=str(dest),
269
+ installed=installed,
270
+ started=started,
271
+ )
272
+
273
+ def output_result(self, result: Any, config: Config) -> None:
274
+ """Only emit structured output when an explicit --output format is set."""
275
+ from deepctl_core.output import get_output_format
276
+
277
+ if get_output_format() in ("default",):
278
+ return
279
+ super().output_result(result, config)
280
+
281
+ # ── List ──────────────────────────────────────────────────────────────────
282
+
283
+ def _handle_list(self, search: str | None) -> list[dict[str, Any]]:
284
+ """List templates and return structured data."""
285
+ with console.status("[blue]Fetching templates...[/blue]"):
286
+ response = templates_api.list_templates(search=search)
287
+
288
+ templates = response.items
289
+ if not templates:
290
+ console.print("[yellow]No templates found[/yellow]")
291
+ return []
292
+
293
+ self._print_templates_table(templates)
294
+ console.print(f"\n[dim]{len(templates)} template(s)[/dim]")
295
+ return [t.model_dump() for t in templates]
296
+
297
+ # ── Interactive picker ────────────────────────────────────────────────────
298
+
299
+ def _interactive_pick(self, search: str | None) -> TemplateDetail | None:
300
+ """Interactive search-and-select picker.
301
+
302
+ The user can type a number to select, or type a search term to filter
303
+ and re-display the list. Loops until a selection is made or cancelled.
304
+ """
305
+ with console.status("[blue]Fetching templates...[/blue]"):
306
+ response = templates_api.list_templates()
307
+
308
+ all_templates = response.items
309
+ if not all_templates:
310
+ console.print("[yellow]No templates found[/yellow]")
311
+ return None
312
+
313
+ # Apply initial search filter if provided
314
+ filtered = (
315
+ templates_api.filter_templates(all_templates, search)
316
+ if search
317
+ else all_templates
318
+ )
319
+
320
+ selected: TemplateInfo | None = None
321
+
322
+ while selected is None:
323
+ self._print_templates_table(filtered)
324
+ console.print()
325
+
326
+ if len(filtered) == 0:
327
+ console.print("[yellow]No templates match your search.[/yellow]")
328
+ filtered = all_templates
329
+ continue
330
+
331
+ console.print(
332
+ "[dim]Type a [bold]number[/bold] to select, "
333
+ "or a [bold]search term[/bold] to filter. "
334
+ "Ctrl+C to cancel.[/dim]"
335
+ )
336
+ try:
337
+ raw = input("> ").strip()
338
+ except (EOFError, KeyboardInterrupt):
339
+ console.print()
340
+ return None
341
+
342
+ if not raw:
343
+ if len(filtered) == 1:
344
+ selected = filtered[0]
345
+ continue
346
+
347
+ # Try numeric selection
348
+ try:
349
+ idx = int(raw)
350
+ if 1 <= idx <= len(filtered):
351
+ selected = filtered[idx - 1]
352
+ else:
353
+ console.print(
354
+ f"[yellow]Enter a number between 1 and {len(filtered)}.[/yellow]"
355
+ )
356
+ except ValueError:
357
+ # Treat as search term
358
+ new_filtered = templates_api.filter_templates(all_templates, raw)
359
+ if not new_filtered:
360
+ console.print(
361
+ f"[yellow]No templates match '{raw}'. Showing all.[/yellow]"
362
+ )
363
+ filtered = all_templates
364
+ else:
365
+ filtered = new_filtered
366
+ console.print()
367
+
368
+ console.print()
369
+ with console.status(f"[blue]Fetching {selected.name}...[/blue]"):
370
+ return templates_api.get_template(selected.name)
371
+
372
+ def _fetch_template(self, slug: str) -> TemplateDetail | None:
373
+ """Fetch template detail by slug."""
374
+ try:
375
+ with console.status(f"[blue]Fetching {slug}...[/blue]"):
376
+ return templates_api.get_template(slug)
377
+ except Exception as e:
378
+ console.print(f"[red]Error:[/red] {e}")
379
+ return None
380
+
381
+ # ── Rendering ─────────────────────────────────────────────────────────────
382
+
383
+ def _print_templates_table(self, templates: list[TemplateInfo]) -> None:
384
+ """Print a numbered table of templates."""
385
+ table = Table(show_header=True, header_style="bold blue")
386
+ table.add_column("#", style="dim", width=4, justify="right")
387
+ table.add_column("Name")
388
+ table.add_column("Language")
389
+ table.add_column("Framework")
390
+ table.add_column("Use case")
391
+ table.add_column("Description", no_wrap=False, max_width=50)
392
+
393
+ for i, t in enumerate(templates, 1):
394
+ table.add_row(
395
+ str(i),
396
+ t.name,
397
+ t.language,
398
+ t.framework or "—",
399
+ t.category or "—",
400
+ t.description,
401
+ )
402
+
403
+ console.print(table)
404
+
405
+ def _print_template_summary(self, detail: TemplateDetail) -> None:
406
+ """Print a one-line summary of the selected template."""
407
+ console.print()
408
+ console.print(f"[bold]{detail.title}[/bold] [dim]{detail.name}[/dim]")
409
+ console.print(f"[dim]{detail.description}[/dim]")
410
+ lang_line = f"Language: [cyan]{detail.language}[/cyan]"
411
+ if detail.framework:
412
+ lang_line += f" Framework: [cyan]{detail.framework}[/cyan]"
413
+ if detail.stats.stars:
414
+ lang_line += f" ★ {detail.stats.stars}"
415
+ console.print(lang_line)
@@ -0,0 +1,204 @@
1
+ """Clone template repos and run lifecycle steps from deepgram.toml."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ from typing import TYPE_CHECKING
8
+
9
+ from rich.console import Console
10
+
11
+ if TYPE_CHECKING:
12
+ from pathlib import Path
13
+
14
+ from .models import LifecycleStep
15
+
16
+ console = Console()
17
+
18
+ # ── Prerequisite tools ────────────────────────────────────────────────────────
19
+
20
+ #: Each entry: tool key → (display name, install hint)
21
+ PREREQS: dict[str, tuple[str, str]] = {
22
+ "git": (
23
+ "git",
24
+ "https://git-scm.com/downloads",
25
+ ),
26
+ "node": (
27
+ "Node.js",
28
+ "https://nodejs.org",
29
+ ),
30
+ "npm": (
31
+ "npm",
32
+ "bundled with Node.js — reinstall from https://nodejs.org",
33
+ ),
34
+ "make": (
35
+ "make",
36
+ "macOS: xcode-select --install | Linux: sudo apt-get install make",
37
+ ),
38
+ "curl": (
39
+ "curl",
40
+ "macOS: brew install curl | Linux: sudo apt-get install curl",
41
+ ),
42
+ }
43
+
44
+
45
+ def check_prereqs() -> list[tuple[str, str, str]]:
46
+ """Check for required tools.
47
+
48
+ Returns:
49
+ List of ``(key, display_name, install_hint)`` tuples for every missing tool.
50
+ Empty list means all tools are present.
51
+ """
52
+ missing: list[tuple[str, str, str]] = []
53
+ for key, (name, hint) in PREREQS.items():
54
+ if shutil.which(key) is None:
55
+ missing.append((key, name, hint))
56
+ return missing
57
+
58
+
59
+ # ── Git clone ─────────────────────────────────────────────────────────────────
60
+
61
+
62
+ def clone_template(github_url: str, target_dir: Path) -> None:
63
+ """Clone a template repository, keeping the .git directory intact.
64
+
65
+ The cloned directory is a fully functional git repository so users can
66
+ commit changes, add their own remote, and push to their own repo.
67
+
68
+ Args:
69
+ github_url: Full GitHub URL to clone.
70
+ target_dir: Local directory to clone into (must not exist).
71
+
72
+ Raises:
73
+ RuntimeError: If the directory already exists or git clone fails.
74
+ """
75
+ if target_dir.exists():
76
+ raise RuntimeError(f"Directory already exists: {target_dir}")
77
+
78
+ result = subprocess.run(
79
+ ["git", "clone", github_url, str(target_dir)],
80
+ capture_output=True,
81
+ text=True,
82
+ )
83
+ if result.returncode != 0:
84
+ raise RuntimeError(f"git clone failed:\n{result.stderr.strip()}")
85
+
86
+
87
+ # ── .env injection ────────────────────────────────────────────────────────────
88
+
89
+
90
+ def inject_env(target_dir: Path, api_key: str) -> None:
91
+ """Create or update DEEPGRAM_API_KEY in .env inside target_dir.
92
+
93
+ Args:
94
+ target_dir: Directory containing the cloned template.
95
+ api_key: Deepgram API key to inject.
96
+ """
97
+ env_path = target_dir / ".env"
98
+
99
+ if env_path.exists():
100
+ content = env_path.read_text()
101
+ if "DEEPGRAM_API_KEY=" in content and "DEEPGRAM_API_KEY=$" not in content:
102
+ console.print("[dim]DEEPGRAM_API_KEY already set in .env[/dim]")
103
+ return
104
+ # Replace placeholder or append
105
+ if "DEEPGRAM_API_KEY=" in content:
106
+ lines = content.splitlines()
107
+ new_lines = [
108
+ f"DEEPGRAM_API_KEY={api_key}"
109
+ if ln.startswith("DEEPGRAM_API_KEY=")
110
+ else ln
111
+ for ln in lines
112
+ ]
113
+ env_path.write_text("\n".join(new_lines) + "\n")
114
+ else:
115
+ with open(env_path, "a") as f:
116
+ if not content.endswith("\n"):
117
+ f.write("\n")
118
+ f.write(f"DEEPGRAM_API_KEY={api_key}\n")
119
+ else:
120
+ env_path.write_text(f"DEEPGRAM_API_KEY={api_key}\n")
121
+
122
+ console.print("[green]✓[/green] Injected DEEPGRAM_API_KEY into .env")
123
+
124
+
125
+ # ── make init ─────────────────────────────────────────────────────────────────
126
+
127
+
128
+ def run_make_init(dest: Path) -> bool:
129
+ """Run ``make check-prereqs && make init`` in the cloned directory.
130
+
131
+ Used for starter repos that ship a Makefile with these standard targets.
132
+
133
+ Args:
134
+ dest: The cloned project directory.
135
+
136
+ Returns:
137
+ True if both commands succeeded, False otherwise.
138
+ """
139
+ makefile = dest / "Makefile"
140
+ if not makefile.exists():
141
+ return False
142
+
143
+ for target in ("check-prereqs", "init"):
144
+ result = subprocess.run(
145
+ ["make", target],
146
+ cwd=str(dest),
147
+ )
148
+ if result.returncode != 0:
149
+ console.print(
150
+ f"\n[red]✗[/red] [bold]make {target}[/bold] failed "
151
+ f"(exit {result.returncode})"
152
+ )
153
+ if target == "check-prereqs":
154
+ console.print(
155
+ "[dim]Fix the missing prerequisites above and run "
156
+ f"[bold]make init[/bold] inside [cyan]{dest.name}[/cyan][/dim]"
157
+ )
158
+ else:
159
+ console.print(
160
+ f"[dim]Re-run manually: cd {dest.name} && make init[/dim]"
161
+ )
162
+ return False
163
+
164
+ return True
165
+
166
+
167
+ # ── TOML lifecycle (fallback) ─────────────────────────────────────────────────
168
+
169
+
170
+ def run_lifecycle_step(step: LifecycleStep, cwd: Path) -> bool:
171
+ """Execute a lifecycle step from deepgram.toml.
172
+
173
+ Used as a fallback for repos that don't have a Makefile.
174
+
175
+ Args:
176
+ step: The lifecycle step definition.
177
+ cwd: Working directory for command execution.
178
+
179
+ Returns:
180
+ True if the step succeeded, False otherwise.
181
+ """
182
+ if step.command is None:
183
+ return True
184
+
185
+ commands = step.command if isinstance(step.command, list) else [step.command]
186
+
187
+ for cmd in commands:
188
+ if step.message:
189
+ console.print(f"[blue]{step.message}[/blue]")
190
+ else:
191
+ console.print(f"[dim]Running: {cmd}[/dim]")
192
+
193
+ result = subprocess.run(
194
+ cmd,
195
+ cwd=str(cwd),
196
+ shell=True,
197
+ )
198
+ if result.returncode != 0:
199
+ console.print(
200
+ f"[red]✗[/red] Command failed (exit {result.returncode}): {cmd}"
201
+ )
202
+ return False
203
+
204
+ return True
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepctl-cmd-init
3
- Version: 0.0.2
3
+ Version: 0.0.3
4
4
  Summary: Init command for deepctl — scaffold Deepgram starter apps
5
5
  Author-email: Deepgram <devrel@deepgram.com>
6
6
  Maintainer-email: Deepgram <devrel@deepgram.com>
@@ -1,321 +0,0 @@
1
- """Init command for deepctl — scaffold Deepgram starter apps."""
2
-
3
- from __future__ import annotations
4
-
5
- from pathlib import Path
6
- from typing import Any
7
-
8
- import click
9
- from deepctl_core import (
10
- AuthManager,
11
- BaseCommand,
12
- Config,
13
- DeepgramClient,
14
- )
15
- from rich.console import Console
16
- from rich.table import Table
17
-
18
- from . import lifecycle, templates_api
19
- from .models import InitResult, TemplateDetail, TemplateInfo
20
-
21
- console = Console()
22
-
23
-
24
- class InitCommand(BaseCommand):
25
- """Scaffold a Deepgram starter app from the templates gallery."""
26
-
27
- name = "init"
28
- help = (
29
- "Browse, clone, and set up Deepgram starter apps. "
30
- "Fetches templates from the Deepgram templates gallery and "
31
- "optionally runs install/start lifecycle steps."
32
- )
33
- short_help = "Scaffold a Deepgram starter app"
34
-
35
- requires_auth = False
36
- requires_project = False
37
- ci_friendly = True
38
-
39
- examples = [
40
- "dg init",
41
- "dg init --search python",
42
- "dg init node-transcription",
43
- "dg init node-transcription --dir ./my-app",
44
- "dg init node-transcription --install --start",
45
- "dg init --list",
46
- "dg init --list --search flask",
47
- ]
48
- agent_help = (
49
- "Scaffold a Deepgram starter app from the templates gallery. "
50
- "Can list available templates, search/filter them, or clone a "
51
- "specific template by name. After cloning, optionally runs "
52
- "install and start lifecycle steps defined in deepgram.toml. "
53
- "If authenticated, injects DEEPGRAM_API_KEY into .env automatically."
54
- )
55
-
56
- def get_arguments(self) -> list[dict[str, Any]]:
57
- """Get command arguments and options."""
58
- return [
59
- {
60
- "name": "template",
61
- "help": "Template name/slug to clone directly",
62
- "type": str,
63
- "required": False,
64
- "default": None,
65
- },
66
- {
67
- "names": ["--dir", "-d"],
68
- "help": "Target directory (default: ./{template-name})",
69
- "type": str,
70
- "is_option": True,
71
- },
72
- {
73
- "names": ["--search", "-s"],
74
- "help": "Filter templates by search term",
75
- "type": str,
76
- "is_option": True,
77
- },
78
- {
79
- "names": ["--list", "-l"],
80
- "help": "List matching templates and exit",
81
- "is_flag": True,
82
- "is_option": True,
83
- },
84
- {
85
- "names": ["--install"],
86
- "help": "Run install step without prompting",
87
- "is_flag": True,
88
- "is_option": True,
89
- },
90
- {
91
- "names": ["--start"],
92
- "help": "Run start step without prompting",
93
- "is_flag": True,
94
- "is_option": True,
95
- },
96
- {
97
- "names": ["--no-install"],
98
- "help": "Skip install step without prompting",
99
- "is_flag": True,
100
- "is_option": True,
101
- },
102
- {
103
- "names": ["--no-start"],
104
- "help": "Skip start step without prompting",
105
- "is_flag": True,
106
- "is_option": True,
107
- },
108
- ]
109
-
110
- def handle(
111
- self,
112
- config: Config,
113
- auth_manager: AuthManager,
114
- client: DeepgramClient,
115
- **kwargs: Any,
116
- ) -> Any:
117
- """Handle the init command."""
118
- template_slug: str | None = kwargs.get("template")
119
- target_dir: str | None = kwargs.get("dir")
120
- search: str | None = kwargs.get("search")
121
- list_mode: bool = kwargs.get("list", False)
122
- do_install: bool = kwargs.get("install", False)
123
- do_start: bool = kwargs.get("start", False)
124
- no_install: bool = kwargs.get("no_install", False)
125
- no_start: bool = kwargs.get("no_start", False)
126
-
127
- # --- List mode ---
128
- if list_mode:
129
- return self._handle_list(search)
130
-
131
- # --- Direct template selection ---
132
- if template_slug:
133
- detail = self._fetch_template(template_slug)
134
- if not detail:
135
- return InitResult(
136
- status="error", message=f"Template not found: {template_slug}"
137
- )
138
- else:
139
- # --- Interactive picker ---
140
- detail = self._interactive_pick(search)
141
- if not detail:
142
- return InitResult(status="cancelled", message="No template selected")
143
-
144
- # Resolve target directory
145
- dest = Path(target_dir) if target_dir else Path(f"./{detail.name}")
146
- dest = dest.resolve()
147
-
148
- # Confirm selection
149
- self._print_template_summary(detail)
150
- console.print(f"[dim]Directory:[/dim] {dest}")
151
- console.print()
152
-
153
- if not self.confirm("Clone this template?", default=True):
154
- return InitResult(status="cancelled", message="Clone cancelled")
155
-
156
- # Clone
157
- github_url = detail.links.get("github")
158
- if not github_url:
159
- return InitResult(status="error", message="Template has no GitHub URL")
160
-
161
- console.print()
162
- with console.status("[blue]Cloning template...[/blue]"):
163
- lifecycle.clone_template(github_url, dest)
164
- console.print(f"[green]Cloned to {dest}[/green]")
165
-
166
- # Inject API key if authenticated
167
- try:
168
- api_key = auth_manager.get_api_key()
169
- if api_key:
170
- lifecycle.inject_env(dest, api_key)
171
- except Exception:
172
- pass # Auth is optional
173
-
174
- installed = False
175
- started = False
176
-
177
- # Run lifecycle steps if config exists
178
- if detail.config:
179
- # Install step
180
- if detail.config.install:
181
- should_install = do_install or (
182
- not no_install and self.confirm("Run install step?", default=True)
183
- )
184
- if should_install:
185
- console.print()
186
- installed = lifecycle.run_lifecycle_step(
187
- detail.config.install, dest
188
- )
189
-
190
- # Start step
191
- if detail.config.start:
192
- should_start = do_start or (
193
- not no_start and self.confirm("Run start step?", default=True)
194
- )
195
- if should_start:
196
- console.print()
197
- started = lifecycle.run_lifecycle_step(detail.config.start, dest)
198
-
199
- # Summary
200
- console.print()
201
- console.print(
202
- f"[green bold]Done![/green bold] Your app is ready at [cyan]{dest}[/cyan]"
203
- )
204
- if not installed and detail.config and detail.config.install:
205
- console.print(
206
- f"[dim]To install: cd {dest.name} && run the install command[/dim]"
207
- )
208
- if not started and detail.config and detail.config.start:
209
- console.print(
210
- f"[dim]To start: cd {dest.name} && run the start command[/dim]"
211
- )
212
-
213
- return InitResult(
214
- status="success",
215
- message=f"Cloned {detail.name}",
216
- template=detail.name,
217
- directory=str(dest),
218
- installed=installed,
219
- started=started,
220
- )
221
-
222
- def output_result(self, result: Any, config: Config) -> None:
223
- """Only emit structured output when an explicit --output format is set."""
224
- from deepctl_core.output import get_output_format
225
-
226
- output_format = get_output_format()
227
-
228
- # Default format: the Rich table / console messages are already printed,
229
- # so skip the automatic JSON dump.
230
- if output_format in ("default",):
231
- return
232
-
233
- # Explicit --output json/yaml/table/csv: delegate to base
234
- super().output_result(result, config)
235
-
236
- def _handle_list(self, search: str | None) -> list[dict[str, Any]]:
237
- """List templates and return structured data."""
238
- with console.status("[blue]Fetching templates...[/blue]"):
239
- response = templates_api.list_templates(search=search)
240
-
241
- templates = response.items
242
- if not templates:
243
- console.print("[yellow]No templates found[/yellow]")
244
- return []
245
-
246
- self._print_templates_table(templates)
247
- console.print(f"\n[dim]{len(templates)} template(s) found[/dim]")
248
-
249
- return [t.model_dump() for t in templates]
250
-
251
- def _interactive_pick(self, search: str | None) -> TemplateDetail | None:
252
- """Show interactive picker and return selected template detail."""
253
- with console.status("[blue]Fetching templates...[/blue]"):
254
- response = templates_api.list_templates(search=search)
255
-
256
- templates = response.items
257
- if not templates:
258
- console.print("[yellow]No templates found[/yellow]")
259
- return None
260
-
261
- self._print_templates_table(templates)
262
- console.print()
263
-
264
- try:
265
- choice = click.prompt(
266
- "Select a template",
267
- type=click.IntRange(1, len(templates)),
268
- )
269
- except (click.Abort, EOFError):
270
- return None
271
-
272
- selected = templates[choice - 1]
273
- console.print()
274
-
275
- # Fetch full detail
276
- with console.status(f"[blue]Fetching {selected.name}...[/blue]"):
277
- return templates_api.get_template(selected.name)
278
-
279
- def _fetch_template(self, slug: str) -> TemplateDetail | None:
280
- """Fetch template detail by slug, handling errors."""
281
- try:
282
- with console.status(f"[blue]Fetching {slug}...[/blue]"):
283
- return templates_api.get_template(slug)
284
- except Exception as e:
285
- console.print(f"[red]Error:[/red] {e}")
286
- return None
287
-
288
- def _print_templates_table(self, templates: list[TemplateInfo]) -> None:
289
- """Print a numbered table of templates."""
290
- table = Table(show_header=True, header_style="bold blue")
291
- table.add_column("#", style="dim", width=4, justify="right")
292
- table.add_column("Name")
293
- table.add_column("Language")
294
- table.add_column("Framework")
295
- table.add_column("Category")
296
-
297
- for i, t in enumerate(templates, 1):
298
- table.add_row(
299
- str(i),
300
- t.name,
301
- t.language,
302
- t.framework or "—",
303
- t.category or "—",
304
- )
305
-
306
- console.print(table)
307
-
308
- def _print_template_summary(self, detail: TemplateDetail) -> None:
309
- """Print a summary of the selected template."""
310
- console.print()
311
- console.print(f"[bold]{detail.title}[/bold]")
312
- console.print(f"[dim]{detail.description}[/dim]")
313
- console.print(
314
- f"Language: [cyan]{detail.language}[/cyan]"
315
- + (
316
- f" Framework: [cyan]{detail.framework}[/cyan]"
317
- if detail.framework
318
- else ""
319
- )
320
- + (f" Stars: {detail.stats.stars}" if detail.stats.stars else "")
321
- )
@@ -1,104 +0,0 @@
1
- """Clone template repos and run lifecycle steps from deepgram.toml."""
2
-
3
- from __future__ import annotations
4
-
5
- import shutil
6
- import subprocess
7
- from typing import TYPE_CHECKING
8
-
9
- from rich.console import Console
10
-
11
- if TYPE_CHECKING:
12
- from pathlib import Path
13
-
14
- from .models import LifecycleStep
15
-
16
- console = Console()
17
-
18
-
19
- def clone_template(github_url: str, target_dir: Path) -> None:
20
- """Clone a template repo with depth 1, then remove .git.
21
-
22
- Args:
23
- github_url: Full GitHub URL to clone.
24
- target_dir: Local directory to clone into.
25
-
26
- Raises:
27
- RuntimeError: If git clone fails.
28
- """
29
- if target_dir.exists():
30
- raise RuntimeError(f"Directory already exists: {target_dir}")
31
-
32
- result = subprocess.run(
33
- ["git", "clone", "--depth", "1", github_url, str(target_dir)],
34
- capture_output=True,
35
- text=True,
36
- )
37
- if result.returncode != 0:
38
- raise RuntimeError(f"git clone failed: {result.stderr.strip()}")
39
-
40
- # Remove .git so the user starts fresh
41
- git_dir = target_dir / ".git"
42
- if git_dir.exists():
43
- shutil.rmtree(git_dir)
44
-
45
-
46
- def inject_env(target_dir: Path, api_key: str) -> None:
47
- """Create or append DEEPGRAM_API_KEY to .env in target_dir.
48
-
49
- Args:
50
- target_dir: Directory containing the cloned template.
51
- api_key: Deepgram API key to inject.
52
- """
53
- env_path = target_dir / ".env"
54
-
55
- # Check if key already present
56
- if env_path.exists():
57
- content = env_path.read_text()
58
- if "DEEPGRAM_API_KEY=" in content:
59
- console.print("[dim]DEEPGRAM_API_KEY already set in .env, skipping[/dim]")
60
- return
61
- # Append with newline separator
62
- with open(env_path, "a") as f:
63
- if not content.endswith("\n"):
64
- f.write("\n")
65
- f.write(f"DEEPGRAM_API_KEY={api_key}\n")
66
- else:
67
- env_path.write_text(f"DEEPGRAM_API_KEY={api_key}\n")
68
-
69
- console.print("[green]Injected DEEPGRAM_API_KEY into .env[/green]")
70
-
71
-
72
- def run_lifecycle_step(step: LifecycleStep, cwd: Path) -> bool:
73
- """Execute a lifecycle step from deepgram.toml.
74
-
75
- Args:
76
- step: The lifecycle step definition.
77
- cwd: Working directory for command execution.
78
-
79
- Returns:
80
- True if the step succeeded, False otherwise.
81
- """
82
- if step.command is None:
83
- return True
84
-
85
- commands = step.command if isinstance(step.command, list) else [step.command]
86
-
87
- for cmd in commands:
88
- if step.message:
89
- console.print(f"[blue]{step.message}[/blue]")
90
- else:
91
- console.print(f"[dim]Running: {cmd}[/dim]")
92
-
93
- result = subprocess.run(
94
- cmd,
95
- cwd=str(cwd),
96
- shell=True,
97
- )
98
- if result.returncode != 0:
99
- console.print(
100
- f"[red]Command failed (exit {result.returncode}):[/red] {cmd}"
101
- )
102
- return False
103
-
104
- return True