thoughtleaders-cli 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. thoughtleaders_cli-0.5.0.dist-info/METADATA +215 -0
  2. thoughtleaders_cli-0.5.0.dist-info/RECORD +59 -0
  3. thoughtleaders_cli-0.5.0.dist-info/WHEEL +4 -0
  4. thoughtleaders_cli-0.5.0.dist-info/entry_points.txt +2 -0
  5. thoughtleaders_cli-0.5.0.dist-info/licenses/LICENSE +21 -0
  6. tl_cli/__init__.py +3 -0
  7. tl_cli/_completions.py +4 -0
  8. tl_cli/_plugin/.claude-plugin/marketplace.json +17 -0
  9. tl_cli/_plugin/.claude-plugin/plugin.json +12 -0
  10. tl_cli/_plugin/agents/tl-analyst.md +66 -0
  11. tl_cli/_plugin/commands/tl-balance.md +10 -0
  12. tl_cli/_plugin/commands/tl-brands.md +16 -0
  13. tl_cli/_plugin/commands/tl-channels.md +31 -0
  14. tl_cli/_plugin/commands/tl-reports.md +16 -0
  15. tl_cli/_plugin/commands/tl-sponsorships.md +23 -0
  16. tl_cli/_plugin/commands/tl.md +28 -0
  17. tl_cli/_plugin/hooks/hooks.json +26 -0
  18. tl_cli/_plugin/hooks/scripts/post-usage.sh +26 -0
  19. tl_cli/_plugin/hooks/scripts/pre-check.sh +30 -0
  20. tl_cli/_plugin/skills/tl/SKILL.md +413 -0
  21. tl_cli/_plugin/skills/tl/references/business-glossary.md +159 -0
  22. tl_cli/_plugin/skills/tl/references/elasticsearch-schema.md +259 -0
  23. tl_cli/_plugin/skills/tl/references/firebolt-schema.md +208 -0
  24. tl_cli/_plugin/skills/tl/references/postgres-schema.md +269 -0
  25. tl_cli/auth/__init__.py +0 -0
  26. tl_cli/auth/commands.py +49 -0
  27. tl_cli/auth/login.py +328 -0
  28. tl_cli/auth/pkce.py +21 -0
  29. tl_cli/auth/token_store.py +98 -0
  30. tl_cli/client/__init__.py +0 -0
  31. tl_cli/client/errors.py +72 -0
  32. tl_cli/client/http.py +109 -0
  33. tl_cli/commands/__init__.py +0 -0
  34. tl_cli/commands/ask.py +54 -0
  35. tl_cli/commands/balance.py +68 -0
  36. tl_cli/commands/brands.py +174 -0
  37. tl_cli/commands/changelog.py +119 -0
  38. tl_cli/commands/channels.py +291 -0
  39. tl_cli/commands/comments.py +63 -0
  40. tl_cli/commands/db.py +104 -0
  41. tl_cli/commands/deals.py +52 -0
  42. tl_cli/commands/describe.py +166 -0
  43. tl_cli/commands/doctor.py +70 -0
  44. tl_cli/commands/matches.py +69 -0
  45. tl_cli/commands/proposals.py +69 -0
  46. tl_cli/commands/reports.py +346 -0
  47. tl_cli/commands/schema.py +55 -0
  48. tl_cli/commands/setup.py +401 -0
  49. tl_cli/commands/snapshots.py +93 -0
  50. tl_cli/commands/sponsorships.py +193 -0
  51. tl_cli/commands/uploads.py +84 -0
  52. tl_cli/commands/whoami.py +206 -0
  53. tl_cli/config.py +55 -0
  54. tl_cli/filters.py +88 -0
  55. tl_cli/hints.py +53 -0
  56. tl_cli/main.py +209 -0
  57. tl_cli/output/__init__.py +0 -0
  58. tl_cli/output/formatter.py +436 -0
  59. tl_cli/self_update.py +173 -0
@@ -0,0 +1,401 @@
1
+ """tl setup — Install Claude Code plugin and other integrations."""
2
+
3
+ import json
4
+ import shutil
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ from rich.console import Console
10
+
11
+ from tl_cli import __version__
12
+
13
+ app = typer.Typer(help="Set up integrations (Claude Code, OpenCode)")
14
+ console = Console(stderr=True)
15
+
16
+ MARKETPLACE_SOURCE = "ThoughtLeaders-io/thoughtleaders-cli"
17
+ MARKETPLACE_NAME = "thoughtleaders-plugins"
18
+ PLUGIN_NAME = "tl-cli"
19
+ PLUGIN_KEY = f"{PLUGIN_NAME}@{MARKETPLACE_NAME}"
20
+
21
+ CLAUDE_HOME = Path.home() / ".claude"
22
+ CLAUDE_PLUGINS_DIR = CLAUDE_HOME / "plugins"
23
+ CLAUDE_SKILLS_DIR = CLAUDE_HOME / "skills"
24
+ CLAUDE_COMMANDS_DIR = CLAUDE_HOME / "commands"
25
+
26
+ OPENCODE_SKILLS_DIR = Path.home() / ".config" / "opencode" / "skills"
27
+
28
+
29
+ def _find_plugin_root() -> Path | None:
30
+ """Locate the plugin assets directory.
31
+
32
+ Tries two locations:
33
+ 1. _plugin/ inside the installed package (pip/pipx installs via hatch force-include)
34
+ 2. Repo root relative to this file (editable installs)
35
+ """
36
+ bundled = Path(__file__).resolve().parent.parent / "_plugin"
37
+ if (bundled / ".claude-plugin" / "plugin.json").is_file():
38
+ return bundled
39
+
40
+ repo_root = Path(__file__).resolve().parent.parent.parent.parent
41
+ if (repo_root / ".claude-plugin" / "plugin.json").is_file():
42
+ return repo_root
43
+
44
+ return None
45
+
46
+
47
+ def _find_claude_binary() -> str | None:
48
+ """Find the claude binary on PATH."""
49
+ return shutil.which("claude")
50
+
51
+
52
+ def _run_claude(args: list[str], claude_bin: str) -> tuple[bool, str]:
53
+ """Run a claude CLI command and return (success, output)."""
54
+ try:
55
+ result = subprocess.run(
56
+ [claude_bin] + args,
57
+ capture_output=True,
58
+ text=True,
59
+ timeout=120,
60
+ )
61
+ output = result.stdout.strip()
62
+ if result.returncode != 0:
63
+ output = result.stderr.strip() or output
64
+ return result.returncode == 0, output
65
+ except subprocess.TimeoutExpired:
66
+ return False, "Command timed out"
67
+ except Exception as e:
68
+ return False, str(e)
69
+
70
+
71
+ def _get_installed_plugin_version() -> str | None:
72
+ """Try to read the installed plugin version from the cache."""
73
+ version_file = CLAUDE_PLUGINS_DIR / "tl-cli" / ".version"
74
+ if version_file.exists():
75
+ return version_file.read_text().strip()
76
+ return None
77
+
78
+
79
+ def check_plugin_version() -> list[str]:
80
+ """Check if installed plugin versions match CLI version.
81
+
82
+ Returns a list of warning messages for outdated installs. Empty if all OK.
83
+ """
84
+ warnings = []
85
+
86
+ # Claude Code
87
+ claude_version_file = CLAUDE_PLUGINS_DIR / "tl-cli" / ".version"
88
+ if claude_version_file.exists():
89
+ installed = claude_version_file.read_text().strip()
90
+ if installed != __version__:
91
+ warnings.append(f"Claude Code plugin is outdated (v{installed} vs CLI v{__version__}). Run 'tl setup claude' to update.")
92
+
93
+ # OpenCode
94
+ opencode_version_file = OPENCODE_SKILLS_DIR / ".tl-version"
95
+ if opencode_version_file.exists():
96
+ installed = opencode_version_file.read_text().strip()
97
+ if installed != __version__:
98
+ warnings.append(f"OpenCode skill is outdated (v{installed} vs CLI v{__version__}). Run 'tl setup opencode' to update.")
99
+
100
+ return warnings
101
+
102
+
103
+ def _install_standalone_skills(plugin_root: Path) -> int:
104
+ """Copy skills and commands to ~/.claude/ for non-namespaced invocation.
105
+
106
+ Returns the number of items installed.
107
+ """
108
+ count = 0
109
+
110
+ # Skills: skills/<name>/SKILL.md → ~/.claude/skills/<name>/SKILL.md
111
+ skills_src = plugin_root / "skills"
112
+ if skills_src.is_dir():
113
+ for skill_dir in skills_src.iterdir():
114
+ if skill_dir.is_dir() and (skill_dir / "SKILL.md").is_file():
115
+ dst = CLAUDE_SKILLS_DIR / skill_dir.name
116
+ if dst.exists():
117
+ shutil.rmtree(dst)
118
+ shutil.copytree(skill_dir, dst)
119
+ count += 1
120
+
121
+ # Commands: commands/<name>.md → ~/.claude/commands/<name>.md
122
+ commands_src = plugin_root / "commands"
123
+ if commands_src.is_dir():
124
+ CLAUDE_COMMANDS_DIR.mkdir(parents=True, exist_ok=True)
125
+ for cmd_file in commands_src.glob("*.md"):
126
+ dst = CLAUDE_COMMANDS_DIR / cmd_file.name
127
+ shutil.copy2(cmd_file, dst)
128
+ count += 1
129
+
130
+ return count
131
+
132
+
133
+ def _print_manual_instructions() -> None:
134
+ """Print manual install instructions when claude binary is not found."""
135
+ console.print()
136
+ console.print("[yellow]Claude Code binary not found on PATH.[/yellow]")
137
+ console.print()
138
+ console.print("Install Claude Code first, then run these commands inside Claude Code:")
139
+ console.print()
140
+ console.print(f" [cyan]/plugin marketplace add {MARKETPLACE_SOURCE}[/cyan]")
141
+ console.print(f" [cyan]/plugin install {PLUGIN_KEY}[/cyan]")
142
+ console.print()
143
+ console.print("Or start Claude Code with the plugin loaded directly:")
144
+ console.print()
145
+ console.print(f" [cyan]claude --plugin-dir /path/to/tl-cli[/cyan]")
146
+
147
+
148
+ @app.command("claude")
149
+ def setup_claude(
150
+ json_output: bool = typer.Option(False, "--json", help="JSON output (non-interactive)"),
151
+ ) -> None:
152
+ """Install the TL CLI plugin for Claude Code.
153
+
154
+ Registers the ThoughtLeaders marketplace, installs the tl-cli plugin,
155
+ and copies skills/commands to ~/.claude/ for short /tl invocation.
156
+ If the claude binary is not on PATH, prints manual instructions.
157
+
158
+ Examples:
159
+ tl setup claude
160
+ tl setup claude --json
161
+ """
162
+ if json_output:
163
+ _setup_noninteractive()
164
+ return
165
+
166
+ console.print()
167
+ console.print(f"[bold]tl-cli[/bold] v{__version__} — Claude Code Plugin Setup")
168
+ console.print()
169
+
170
+ # Check tl is on PATH
171
+ tl_bin = shutil.which("tl")
172
+ if tl_bin:
173
+ console.print(f" [green]✓[/green] tl CLI found: {tl_bin}")
174
+ else:
175
+ console.print(" [red]✗[/red] tl CLI not found on PATH")
176
+ console.print(" Claude Code's Bash tool won't be able to run tl commands.")
177
+ console.print(" Install with: [cyan]pipx install thoughtleaders-cli[/cyan]")
178
+
179
+ # Find plugin assets
180
+ plugin_root = _find_plugin_root()
181
+ if plugin_root is None:
182
+ console.print(" [red]✗[/red] Plugin assets not found")
183
+ console.print(" Try reinstalling: [cyan]pipx install thoughtleaders-cli[/cyan]")
184
+ raise SystemExit(1)
185
+ console.print(f" [green]✓[/green] Plugin assets found: {plugin_root}")
186
+
187
+ # Check claude binary
188
+ claude_bin = _find_claude_binary()
189
+ if not claude_bin:
190
+ # Still install standalone skills even without claude binary
191
+ console.print(" [yellow]![/yellow] claude binary not found on PATH")
192
+ _install_standalone_skills_step(plugin_root)
193
+ console.print()
194
+ _print_manual_instructions()
195
+ raise SystemExit(1)
196
+
197
+ console.print(f" [green]✓[/green] claude binary found: {claude_bin}")
198
+ console.print()
199
+
200
+ # Step 1: Register marketplace
201
+ console.print("[bold]Registering marketplace...[/bold]")
202
+ ok, output = _run_claude(["plugin", "marketplace", "add", MARKETPLACE_SOURCE], claude_bin)
203
+ if ok:
204
+ console.print(f" [green]✓[/green] Marketplace registered: {MARKETPLACE_NAME}")
205
+ else:
206
+ if "already" in output.lower() or "exists" in output.lower():
207
+ console.print(f" [green]✓[/green] Marketplace already registered: {MARKETPLACE_NAME}")
208
+ console.print(" Updating marketplace...")
209
+ _run_claude(["plugin", "marketplace", "update", MARKETPLACE_NAME], claude_bin)
210
+ else:
211
+ console.print(f" [red]✗[/red] Marketplace registration failed: {output}")
212
+ _print_manual_instructions()
213
+ raise SystemExit(1)
214
+
215
+ # Step 2: Install plugin
216
+ console.print("[bold]Installing plugin...[/bold]")
217
+ ok, output = _run_claude(["plugin", "install", PLUGIN_KEY], claude_bin)
218
+ if ok:
219
+ console.print(f" [green]✓[/green] Plugin installed: {PLUGIN_KEY}")
220
+ else:
221
+ if "already" in output.lower():
222
+ console.print(f" [green]✓[/green] Plugin already installed: {PLUGIN_KEY}")
223
+ else:
224
+ console.print(f" [red]✗[/red] Plugin installation failed: {output}")
225
+ console.print(" Try running inside Claude Code:")
226
+ console.print(f" [cyan]/plugin install {PLUGIN_KEY}[/cyan]")
227
+ raise SystemExit(1)
228
+
229
+ # Step 3: Install standalone skills for short /tl invocation
230
+ _install_standalone_skills_step(plugin_root)
231
+
232
+ # Write version stamp
233
+ version_dir = CLAUDE_PLUGINS_DIR / "tl-cli"
234
+ version_dir.mkdir(parents=True, exist_ok=True)
235
+ (version_dir / ".version").write_text(__version__)
236
+
237
+ console.print()
238
+ console.print("[green]Setup complete![/green]")
239
+ console.print()
240
+ console.print("Available skills in Claude Code:")
241
+ console.print(" [cyan]/tl[/cyan] — data analyst (smart query router)")
242
+ console.print(" [cyan]/tl-sponsorships[/cyan] — sponsorship lookup")
243
+ console.print(" [cyan]/tl-brands[/cyan] — brand intelligence")
244
+ console.print(" [cyan]/tl-channels[/cyan] — channel search")
245
+ console.print(" [cyan]/tl-reports[/cyan] — saved reports")
246
+ console.print(" [cyan]/tl-balance[/cyan] — credit balance")
247
+ console.print()
248
+ console.print("Try it:")
249
+ console.print(" [cyan]/tl Which channels did we sponsor in Q1?[/cyan]")
250
+ console.print()
251
+ console.print("[dim]To update, run: tl setup claude[/dim]")
252
+
253
+
254
+ def _install_standalone_skills_step(plugin_root: Path) -> None:
255
+ """Install standalone skills and print status."""
256
+ console.print("[bold]Installing skills for /tl shortcut...[/bold]")
257
+ count = _install_standalone_skills(plugin_root)
258
+ if count > 0:
259
+ console.print(f" [green]✓[/green] Installed {count} skills/commands to ~/.claude/")
260
+ else:
261
+ console.print(" [yellow]![/yellow] No skills found to install")
262
+
263
+
264
+ def _setup_noninteractive() -> None:
265
+ """Non-interactive setup for --json/agent usage."""
266
+ result = {
267
+ "cli_version": __version__,
268
+ "marketplace_source": MARKETPLACE_SOURCE,
269
+ "marketplace_name": MARKETPLACE_NAME,
270
+ "plugin_key": PLUGIN_KEY,
271
+ }
272
+
273
+ plugin_root = _find_plugin_root()
274
+ if plugin_root is None:
275
+ result["status"] = "error"
276
+ result["error"] = "Plugin assets not found"
277
+ print(json.dumps(result, indent=2))
278
+ raise SystemExit(1)
279
+
280
+ claude_bin = _find_claude_binary()
281
+
282
+ # Register marketplace + install plugin (if claude binary available)
283
+ if claude_bin:
284
+ ok, output = _run_claude(["plugin", "marketplace", "add", MARKETPLACE_SOURCE], claude_bin)
285
+ if not ok and "already" not in output.lower() and "exists" not in output.lower():
286
+ result["marketplace_registered"] = False
287
+ else:
288
+ result["marketplace_registered"] = True
289
+ _run_claude(["plugin", "marketplace", "update", MARKETPLACE_NAME], claude_bin)
290
+
291
+ ok, output = _run_claude(["plugin", "install", PLUGIN_KEY], claude_bin)
292
+ result["plugin_installed"] = ok or "already" in output.lower()
293
+ else:
294
+ result["marketplace_registered"] = False
295
+ result["plugin_installed"] = False
296
+
297
+ # Always install standalone skills
298
+ count = _install_standalone_skills(plugin_root)
299
+ result["standalone_skills_installed"] = count
300
+
301
+ # Write version stamp
302
+ version_dir = CLAUDE_PLUGINS_DIR / "tl-cli"
303
+ version_dir.mkdir(parents=True, exist_ok=True)
304
+ (version_dir / ".version").write_text(__version__)
305
+
306
+ result["status"] = "ok"
307
+ print(json.dumps(result, indent=2))
308
+
309
+
310
+ # --- OpenCode setup ---
311
+
312
+
313
+ def _install_opencode_skills(plugin_root: Path) -> int:
314
+ """Copy skills to ~/.config/opencode/skills/ for OpenCode discovery.
315
+
316
+ Returns the number of skills installed.
317
+ """
318
+ count = 0
319
+ skills_src = plugin_root / "skills"
320
+ if skills_src.is_dir():
321
+ for skill_dir in skills_src.iterdir():
322
+ if skill_dir.is_dir() and (skill_dir / "SKILL.md").is_file():
323
+ dst = OPENCODE_SKILLS_DIR / skill_dir.name
324
+ if dst.exists():
325
+ shutil.rmtree(dst)
326
+ shutil.copytree(skill_dir, dst)
327
+ count += 1
328
+ return count
329
+
330
+
331
+ @app.command("opencode")
332
+ def setup_opencode(
333
+ json_output: bool = typer.Option(False, "--json", help="JSON output (non-interactive)"),
334
+ ) -> None:
335
+ """Install the TL CLI skills for OpenCode.
336
+
337
+ Copies skill files to ~/.config/opencode/skills/ so OpenCode's
338
+ agent can discover and use them automatically.
339
+
340
+ Examples:
341
+ tl setup opencode
342
+ tl setup opencode --json
343
+ """
344
+ if json_output:
345
+ result = {"cli_version": __version__}
346
+ plugin_root = _find_plugin_root()
347
+ if plugin_root is None:
348
+ result["status"] = "error"
349
+ result["error"] = "Plugin assets not found"
350
+ print(json.dumps(result, indent=2))
351
+ raise SystemExit(1)
352
+ count = _install_opencode_skills(plugin_root)
353
+ OPENCODE_SKILLS_DIR.mkdir(parents=True, exist_ok=True)
354
+ (OPENCODE_SKILLS_DIR / ".tl-version").write_text(__version__)
355
+ result["skills_installed"] = count
356
+ result["status"] = "ok"
357
+ print(json.dumps(result, indent=2))
358
+ return
359
+
360
+ console.print()
361
+ console.print(f"[bold]tl-cli[/bold] v{__version__} — OpenCode Setup")
362
+ console.print()
363
+
364
+ # Check tl is on PATH
365
+ tl_bin = shutil.which("tl")
366
+ if tl_bin:
367
+ console.print(f" [green]✓[/green] tl CLI found: {tl_bin}")
368
+ else:
369
+ console.print(" [red]✗[/red] tl CLI not found on PATH")
370
+ console.print(" OpenCode's Bash tool won't be able to run tl commands.")
371
+ console.print(" Install with: [cyan]pipx install git+https://github.com/ThoughtLeaders-io/thoughtleaders-cli.git[/cyan]")
372
+
373
+ # Find plugin assets
374
+ plugin_root = _find_plugin_root()
375
+ if plugin_root is None:
376
+ console.print(" [red]✗[/red] Plugin assets not found")
377
+ console.print(" Try reinstalling: [cyan]pipx install --force git+https://github.com/ThoughtLeaders-io/thoughtleaders-cli.git[/cyan]")
378
+ raise SystemExit(1)
379
+ console.print(f" [green]✓[/green] Plugin assets found: {plugin_root}")
380
+ console.print()
381
+
382
+ # Install skills
383
+ console.print("[bold]Installing skills...[/bold]")
384
+ count = _install_opencode_skills(plugin_root)
385
+ if count > 0:
386
+ console.print(f" [green]✓[/green] Installed {count} skill(s) to {OPENCODE_SKILLS_DIR}/")
387
+ else:
388
+ console.print(" [yellow]![/yellow] No skills found to install")
389
+ raise SystemExit(1)
390
+
391
+ # Write version stamp
392
+ OPENCODE_SKILLS_DIR.mkdir(parents=True, exist_ok=True)
393
+ (OPENCODE_SKILLS_DIR / ".tl-version").write_text(__version__)
394
+
395
+ console.print()
396
+ console.print("[green]Setup complete![/green]")
397
+ console.print()
398
+ console.print("OpenCode will automatically discover the tl skill.")
399
+ console.print("The agent can use it when you ask about sponsorships, deals, channels, or brands.")
400
+ console.print()
401
+ console.print("[dim]To update, run: tl setup opencode[/dim]")
@@ -0,0 +1,93 @@
1
+ """tl snapshots — Firebolt time-series metrics for channels and videos."""
2
+
3
+ import typer
4
+
5
+ from tl_cli.client.errors import ApiError, handle_api_error
6
+ from tl_cli.client.http import get_client
7
+ from tl_cli.output.formatter import detect_format, output
8
+
9
+ app = typer.Typer(help="Historical metrics snapshots (Firebolt time-series)")
10
+
11
+
12
+ @app.command("channel")
13
+ def channel_snapshots(
14
+ channel_id: int = typer.Argument(..., help="Channel ID"),
15
+ since: str | None = typer.Option(None, "--since", help="Start date (YYYY-MM-DD)"),
16
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
17
+ csv_output: bool = typer.Option(False, "--csv", help="CSV output"),
18
+ md_output: bool = typer.Option(False, "--md", help="Markdown output"),
19
+ toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
20
+ limit: int = typer.Option(100, "--limit", "-l", help="Max data points"),
21
+ ) -> None:
22
+ """Channel metrics over time (subscribers, total views).
23
+
24
+ Requires a paid plan.
25
+
26
+ Examples:
27
+ tl snapshots channel 12345
28
+ tl snapshots channel 12345 --since 2025-01-01
29
+ """
30
+ fmt = detect_format(json_output, csv_output, md_output, toon_output)
31
+
32
+ params: dict[str, str] = {"limit": str(limit)}
33
+ if since:
34
+ params["since"] = since
35
+
36
+ client = get_client()
37
+ try:
38
+ data = client.get(f"/snapshots/channel/{channel_id}", params=params)
39
+ output(
40
+ data,
41
+ fmt,
42
+ columns=["scrape_date", "reach", "total_views"],
43
+ title=f"Channel {channel_id} Metrics",
44
+ )
45
+ except ApiError as e:
46
+ handle_api_error(e)
47
+ finally:
48
+ client.close()
49
+
50
+
51
+ @app.command("video")
52
+ def video_snapshots(
53
+ video_id: str = typer.Argument(..., help="Video/article ID"),
54
+ channel: int = typer.Option(
55
+ ..., "--channel", "-c",
56
+ help="Channel ID (required — Firebolt needs this for fast queries)",
57
+ ),
58
+ since: str | None = typer.Option(None, "--since", help="Start date (YYYY-MM-DD)"),
59
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
60
+ csv_output: bool = typer.Option(False, "--csv", help="CSV output"),
61
+ md_output: bool = typer.Option(False, "--md", help="Markdown output"),
62
+ toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
63
+ limit: int = typer.Option(100, "--limit", "-l", help="Max data points"),
64
+ ) -> None:
65
+ """Video view curve over time (views, likes, comments by age).
66
+
67
+ --channel is required because Firebolt's primary index is (channel_id, id).
68
+ Without it, queries scan 7.4 billion rows.
69
+
70
+ Requires a paid plan.
71
+
72
+ Examples:
73
+ tl snapshots video dQw4w9WgXcQ --channel 12345
74
+ """
75
+ fmt = detect_format(json_output, csv_output, md_output, toon_output)
76
+
77
+ params: dict[str, str] = {"channel_id": str(channel), "limit": str(limit)}
78
+ if since:
79
+ params["since"] = since
80
+
81
+ client = get_client()
82
+ try:
83
+ data = client.get(f"/snapshots/video/{video_id}", params=params)
84
+ output(
85
+ data,
86
+ fmt,
87
+ columns=["scrape_date", "age", "view_count", "like_count", "comment_count"],
88
+ title=f"Video {video_id} View Curve",
89
+ )
90
+ except ApiError as e:
91
+ handle_api_error(e)
92
+ finally:
93
+ client.close()
@@ -0,0 +1,193 @@
1
+ """tl sponsorships — List, show, and create sponsorships."""
2
+
3
+ from typing import Optional
4
+
5
+ import typer
6
+ from rich.console import Console
7
+
8
+ from tl_cli.client.errors import handle_api_error, ApiError
9
+ from tl_cli.client.http import get_client
10
+ from tl_cli.filters import parse_filters
11
+ from tl_cli.hints import detail_hint
12
+ from tl_cli.output.formatter import detect_format, output, output_single
13
+
14
+ COLUMNS = ["sponsorship_id", "created_at", "brand_id", "brand", "channel_id", "channel", "article_id", "views", "impressions_guarantee", "status", "price", "cost", "cpm", "owner_sales_email"]
15
+ COLUMN_CONFIG = {
16
+ "price": {"justify": "right"},
17
+ "cost": {"justify": "right"},
18
+ "views": {"justify": "right"},
19
+ "impressions_guarantee": {"justify": "right"},
20
+ "cpm": {"justify": "right"},
21
+ }
22
+
23
+
24
+ def _format_results(results: list[dict]) -> list[dict]:
25
+ """Clean up sponsorship results for display."""
26
+ for row in results:
27
+ sd = row.get("send_date")
28
+ if sd and isinstance(sd, str) and "T" in sd:
29
+ row["send_date"] = sd[:10]
30
+ for field in ("price", "cost", "impressions_guarantee"):
31
+ val = row.get(field)
32
+ if val is not None:
33
+ try:
34
+ row[field] = str(int(float(val)))
35
+ except (ValueError, TypeError):
36
+ pass
37
+ return results
38
+
39
+
40
+ def do_list(
41
+ args: list[str],
42
+ fmt: str,
43
+ limit: int,
44
+ offset: int,
45
+ *,
46
+ default_status: str | None = None,
47
+ title: str = "Sponsorships",
48
+ ) -> None:
49
+ """Shared list logic with optional default status filter."""
50
+ filters = parse_filters(args)
51
+
52
+ # Status values that are equivalent to each shortcut's default
53
+ _EQUIVALENT_STATUSES = {
54
+ "deal": {"deal", "sold"},
55
+ "match": {"match", "matched"},
56
+ "proposal": {"proposal", "proposed", "pending", "outreach"},
57
+ }
58
+
59
+ if default_status and "status" in filters:
60
+ allowed = _EQUIVALENT_STATUSES.get(default_status, {default_status})
61
+ if filters["status"] not in allowed:
62
+ Console(stderr=True).print(
63
+ f"[red]Error:[/red] The [bold]{title.lower()}[/bold] command does not accept status:{filters['status']}.\n"
64
+ f"Use [bold]tl sponsorships list[/bold] for finer-grained status filtering."
65
+ )
66
+ raise typer.Exit(1)
67
+
68
+ if default_status:
69
+ filters.setdefault("status", default_status)
70
+
71
+ client = get_client()
72
+ try:
73
+ params = {**filters, "limit": str(limit), "offset": str(offset)}
74
+ data = client.get("/sponsorships", params=params)
75
+ if "results" in data:
76
+ data["results"] = _format_results(data["results"])
77
+ for r in data["results"]:
78
+ r["sponsorship_id"] = r.pop("id", None)
79
+ output(data, fmt, columns=COLUMNS, title=title, column_config=COLUMN_CONFIG)
80
+ except ApiError as e:
81
+ handle_api_error(e)
82
+ finally:
83
+ client.close()
84
+
85
+
86
+ def do_show(item_id: str, fmt: str) -> None:
87
+ """Shared show logic."""
88
+ client = get_client()
89
+ try:
90
+ data = client.get(f"/sponsorships/{item_id}")
91
+ for r in (data.get("results", []) if isinstance(data.get("results"), list) else []):
92
+ r["sponsorship_id"] = r.pop("id", None)
93
+ output_single(data, fmt)
94
+ if fmt == "table" and data.get("show_cta"):
95
+ record = data.get("results", data)
96
+ if isinstance(record, list) and record:
97
+ record = record[0]
98
+ if isinstance(record, dict):
99
+ hint = detail_hint(client, brand=record.get("brand"), channel=record.get("channel"))
100
+ if hint:
101
+ Console(stderr=True).print(f"\n[yellow]{hint}[/yellow]")
102
+ except ApiError as e:
103
+ handle_api_error(e)
104
+ finally:
105
+ client.close()
106
+
107
+
108
+ def do_create(
109
+ channel: int,
110
+ brand: int,
111
+ price: float | None,
112
+ fmt: str,
113
+ status: str | None = None,
114
+ ) -> None:
115
+ """Shared create logic."""
116
+ body: dict = {"channel_id": channel, "brand_id": brand}
117
+ if price is not None:
118
+ body["price"] = price
119
+ if status is not None:
120
+ body["status"] = status
121
+
122
+ client = get_client()
123
+ try:
124
+ data = client.post("/sponsorships", json_body=body)
125
+ output_single(data, fmt)
126
+ except ApiError as e:
127
+ handle_api_error(e)
128
+ finally:
129
+ client.close()
130
+
131
+
132
+ # --- Typer app ---
133
+
134
+ app = typer.Typer(help="Sponsorships (deals, matches, proposals)")
135
+
136
+
137
+ @app.callback(invoke_without_command=True)
138
+ def sponsorships(ctx: typer.Context) -> None:
139
+ """Sponsorships — the centre of attention in ThoughtLeaders."""
140
+ if ctx.invoked_subcommand is None:
141
+ ctx.invoke(list_cmd, args=[], json_output=False, csv_output=False, md_output=False, limit=50, offset=0)
142
+
143
+
144
+ @app.command("list")
145
+ def list_cmd(
146
+ args: list[str] = typer.Argument(None, help="Filters (key:value pairs). Run 'tl describe show sponsorships' for available filters."),
147
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
148
+ csv_output: bool = typer.Option(False, "--csv", help="CSV output"),
149
+ md_output: bool = typer.Option(False, "--md", help="Markdown output"),
150
+ toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
151
+ limit: int = typer.Option(50, "--limit", "-l", help="Max results"),
152
+ offset: int = typer.Option(0, "--offset", help="Pagination offset"),
153
+ ) -> None:
154
+ """List sponsorships with optional filters.
155
+
156
+ Examples:
157
+ tl sponsorships list # List recent sponsorships
158
+ tl sponsorships list status:sold brand:"Nike" # Filter sponsorships
159
+ """
160
+ fmt = detect_format(json_output, csv_output, md_output, toon_output)
161
+ do_list(args or [], fmt, limit, offset)
162
+
163
+
164
+ @app.command("show")
165
+ def show_cmd(
166
+ item_id: str = typer.Argument(..., help="Sponsorship ID"),
167
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
168
+ toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
169
+ ) -> None:
170
+ """Show sponsorship detail by ID.
171
+
172
+ Examples:
173
+ tl sponsorships show 12345
174
+ """
175
+ fmt = detect_format(json_output, False, False, toon_output)
176
+ do_show(item_id, fmt)
177
+
178
+
179
+ @app.command("create")
180
+ def create_cmd(
181
+ channel: int = typer.Option(..., "--channel", "-c", help="Channel ID"),
182
+ brand: int = typer.Option(..., "--brand", "-b", help="Brand ID"),
183
+ price: Optional[float] = typer.Option(None, "--price", "-p", help="Deal price"),
184
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
185
+ toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
186
+ ) -> None:
187
+ """Create a new sponsorship proposal (free, no credits charged).
188
+
189
+ Examples:
190
+ tl sponsorships create --channel 1 --brand 2
191
+ """
192
+ fmt = detect_format(json_output, False, False, toon_output)
193
+ do_create(channel, brand, price, fmt, status="proposed")