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,84 @@
1
+ """tl uploads — List and show video uploads."""
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.filters import parse_filters
8
+ from tl_cli.output.formatter import detect_format, output, output_single
9
+
10
+ app = typer.Typer(help="Video uploads (YouTube content from Elasticsearch)")
11
+
12
+
13
+ @app.callback(invoke_without_command=True)
14
+ def uploads(ctx: typer.Context) -> None:
15
+ """Video uploads from YouTube (Elasticsearch)."""
16
+ if ctx.invoked_subcommand is None:
17
+ ctx.invoke(list_cmd, args=[], json_output=False, csv_output=False, md_output=False, limit=50, offset=0)
18
+
19
+
20
+ @app.command("list")
21
+ def list_cmd(
22
+ args: list[str] = typer.Argument(None, help="Filters (key:value pairs). Run 'tl describe show uploads' for available filters."),
23
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
24
+ csv_output: bool = typer.Option(False, "--csv", help="CSV output"),
25
+ md_output: bool = typer.Option(False, "--md", help="Markdown output"),
26
+ toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
27
+ limit: int = typer.Option(50, "--limit", "-l", help="Max results"),
28
+ offset: int = typer.Option(0, "--offset", help="Pagination offset"),
29
+ ) -> None:
30
+ """List video uploads with optional filters.
31
+
32
+ Examples:
33
+ tl uploads list # List recent uploads
34
+ tl uploads list channel:12345 type:longform # Filter uploads
35
+ """
36
+ fmt = detect_format(json_output, csv_output, md_output, toon_output)
37
+ filters = parse_filters(args or [])
38
+
39
+ client = get_client()
40
+ try:
41
+ params = {**filters, "limit": str(limit), "offset": str(offset)}
42
+ data = client.get("/uploads", params=params)
43
+ for r in data.get("results", []):
44
+ r["upload_id"] = r.pop("id", None)
45
+ output(
46
+ data,
47
+ fmt,
48
+ columns=["upload_id", "title", "channel", "views", "publication_date", "content_type"],
49
+ title="Uploads",
50
+ )
51
+ except ApiError as e:
52
+ handle_api_error(e)
53
+ finally:
54
+ client.close()
55
+
56
+
57
+ @app.command("show")
58
+ def show_cmd(
59
+ ids: list[str] = typer.Argument(..., help="One or more upload IDs"),
60
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
61
+ toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
62
+ ) -> None:
63
+ """Show details for one or more uploads by ID.
64
+
65
+ IDs can contain colons (e.g. 1174310:0BehkmVa7ak).
66
+
67
+ Examples:
68
+ tl uploads show 0BehkmVa7ak
69
+ tl uploads show 1174310:0BehkmVa7ak
70
+ tl uploads show 0BehkmVa7ak dQw4w9WgXcQ
71
+ """
72
+ fmt = detect_format(json_output, False, False, toon_output)
73
+
74
+ client = get_client()
75
+ try:
76
+ for upload_id in ids:
77
+ data = client.get(f"/uploads/{upload_id}")
78
+ for r in (data.get("results", []) if isinstance(data.get("results"), list) else []):
79
+ r["upload_id"] = r.pop("id", None)
80
+ output_single(data, fmt)
81
+ except ApiError as e:
82
+ handle_api_error(e)
83
+ finally:
84
+ client.close()
@@ -0,0 +1,206 @@
1
+ """tl whoami — Show information about the logged-in user."""
2
+
3
+ import json
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.panel import Panel
8
+ from rich.table import Table
9
+ from rich.text import Text
10
+
11
+ from tl_cli.client.errors import handle_api_error, ApiError
12
+ from tl_cli.client.http import get_client
13
+ from tl_cli.output.formatter import detect_format
14
+
15
+ app = typer.Typer(help="Show current user, profile, org, and brands (free)")
16
+
17
+
18
+ def _render_whoami(data: dict) -> None:
19
+ """Rich-formatted whoami output."""
20
+ console = Console()
21
+ user = data.get("user", {})
22
+ profile = data.get("profile", {})
23
+ org = data.get("organization", {})
24
+ profiles = data.get("associated_profiles", [])
25
+ brands = data.get("brands", [])
26
+
27
+ # --- User ---
28
+ name = f"{user.get('first_name', '')} {user.get('last_name', '')}".strip()
29
+ title = Text()
30
+ title.append(name or user.get("email", ""), style="bold cyan")
31
+ if name:
32
+ title.append(f" {user.get('email', '')}", style="dim")
33
+
34
+ flags = profile.get("flags", [])
35
+ persona = profile.get("persona")
36
+
37
+ lines = Text()
38
+ if persona:
39
+ lines.append(f"Persona: ", style="dim")
40
+ lines.append(persona, style="bold")
41
+ lines.append("\n")
42
+ if flags:
43
+ lines.append("Flags: ", style="dim")
44
+ lines.append(", ".join(flags), style="green")
45
+ lines.append("\n")
46
+ lines.append("Paid: ", style="dim")
47
+ lines.append("yes" if profile.get("is_paid") else "no", style="green" if profile.get("is_paid") else "yellow")
48
+ lines.append("\n")
49
+ lines.append("Joined: ", style="dim")
50
+ lines.append(user.get("date_joined", "")[:10])
51
+
52
+ console.print(Panel(lines, title=title, border_style="cyan"))
53
+
54
+ # --- Organization ---
55
+ org_lines = Text()
56
+ org_lines.append(org.get("name", ""), style="bold")
57
+ plan = org.get("plan")
58
+ if plan:
59
+ org_lines.append(f" ({plan})", style="dim")
60
+ org_lines.append("\n")
61
+ if org.get("is_managed_services"):
62
+ org_lines.append("Managed services", style="magenta")
63
+ org_lines.append("\n")
64
+ start = org.get("contract_start_date")
65
+ end = org.get("contract_end_date")
66
+ if start or end:
67
+ org_lines.append("Contract: ", style="dim")
68
+ org_lines.append(f"{start or '?'} → {end or '?'}")
69
+
70
+ console.print(Panel(org_lines, title="Organization", border_style="blue"))
71
+
72
+ # --- Associated Profiles ---
73
+ if profiles:
74
+ table = Table(title="Profiles in Organization", border_style="dim", show_lines=False)
75
+ table.add_column("Name", style="bold")
76
+ table.add_column("Email")
77
+ table.add_column("Flags", style="green")
78
+ for p in profiles:
79
+ table.add_row(
80
+ p.get("name", ""),
81
+ p.get("email", ""),
82
+ ", ".join(p.get("flags", [])),
83
+ )
84
+ console.print(table)
85
+
86
+ # --- Brands (grouped by brand, emails comma-separated) ---
87
+ if brands:
88
+ grouped: dict[int, dict] = {}
89
+ for b in brands:
90
+ bid = b.get("id")
91
+ if bid not in grouped:
92
+ grouped[bid] = {"id": bid, "name": b.get("name", ""), "website": b.get("website", ""), "emails": []}
93
+ email = b.get("profile_email", "")
94
+ if email and email not in grouped[bid]["emails"]:
95
+ grouped[bid]["emails"].append(email)
96
+
97
+ table = Table(title="Brands in Organization", border_style="dim", show_lines=False)
98
+ table.add_column("ID", style="dim")
99
+ table.add_column("Name", style="bold yellow")
100
+ table.add_column("Website")
101
+ table.add_column("Profile Emails", style="dim")
102
+ for g in grouped.values():
103
+ table.add_row(
104
+ str(g["id"]),
105
+ g["name"],
106
+ g["website"],
107
+ ", ".join(g["emails"]),
108
+ )
109
+ console.print(table)
110
+
111
+
112
+ def _render_whoami_md(data: dict) -> None:
113
+ """Markdown-formatted whoami output."""
114
+ user = data.get("user", {})
115
+ profile = data.get("profile", {})
116
+ org = data.get("organization", {})
117
+ profiles = data.get("associated_profiles", [])
118
+ brands = data.get("brands", [])
119
+
120
+ name = f"{user.get('first_name', '')} {user.get('last_name', '')}".strip()
121
+ print(f"# {name or user.get('email', '')}\n")
122
+ if name:
123
+ print(f"- **Email:** {user.get('email', '')}")
124
+ persona = profile.get("persona")
125
+ if persona:
126
+ print(f"- **Persona:** {persona}")
127
+ flags = profile.get("flags", [])
128
+ if flags:
129
+ print(f"- **Flags:** {', '.join(flags)}")
130
+ print(f"- **Paid:** {'yes' if profile.get('is_paid') else 'no'}")
131
+ print(f"- **Joined:** {user.get('date_joined', '')[:10]}")
132
+
133
+ print(f"\n## Organization: {org.get('name', '')}\n")
134
+ plan = org.get("plan")
135
+ if plan:
136
+ print(f"- **Plan:** {plan}")
137
+ if org.get("is_managed_services"):
138
+ print("- **Managed services:** yes")
139
+ start = org.get("contract_start_date")
140
+ end = org.get("contract_end_date")
141
+ if start or end:
142
+ print(f"- **Contract:** {start or '?'} → {end or '?'}")
143
+
144
+ if profiles:
145
+ print("\n## Profiles in Organization\n")
146
+ print("| Name | Email | Flags |")
147
+ print("| --- | --- | --- |")
148
+ for p in profiles:
149
+ print(f"| {p.get('name', '')} | {p.get('email', '')} | {', '.join(p.get('flags', []))} |")
150
+
151
+ if brands:
152
+ grouped: dict[int, dict] = {}
153
+ for b in brands:
154
+ bid = b.get("id")
155
+ if bid not in grouped:
156
+ grouped[bid] = {"id": bid, "name": b.get("name", ""), "website": b.get("website", ""), "emails": []}
157
+ email = b.get("profile_email", "")
158
+ if email and email not in grouped[bid]["emails"]:
159
+ grouped[bid]["emails"].append(email)
160
+
161
+ print("\n## Brands in Organization\n")
162
+ print("| ID | Name | Website | Profile Emails |")
163
+ print("| --- | --- | --- | --- |")
164
+ for g in grouped.values():
165
+ print(f"| {g['id']} | {g['name']} | {g['website']} | {', '.join(g['emails'])} |")
166
+
167
+
168
+ @app.callback(invoke_without_command=True)
169
+ def whoami(
170
+ ctx: typer.Context,
171
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
172
+ md_output: bool = typer.Option(False, "--md", help="Markdown output"),
173
+ toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
174
+ ) -> None:
175
+ """Show information about the logged-in user.
176
+
177
+ Displays user details, profile flags, organization, associated
178
+ profiles, and brands (for buyers).
179
+
180
+ Examples:
181
+ tl whoami # Pretty-printed info
182
+ tl whoami --json # Full JSON response
183
+ tl whoami --md # Markdown output
184
+ """
185
+ if ctx.invoked_subcommand is not None:
186
+ return
187
+
188
+ fmt = detect_format(json_output, False, md_output, toon_output)
189
+
190
+ client = get_client()
191
+ try:
192
+ data = client.get("/whoami")
193
+
194
+ if fmt == "json":
195
+ print(json.dumps(data, indent=2, default=str))
196
+ elif fmt == "toon":
197
+ from toon_format import encode
198
+ print(encode(data))
199
+ elif fmt == "md":
200
+ _render_whoami_md(data)
201
+ else:
202
+ _render_whoami(data)
203
+ except ApiError as e:
204
+ handle_api_error(e)
205
+ finally:
206
+ client.close()
tl_cli/config.py ADDED
@@ -0,0 +1,55 @@
1
+ """Configuration management for the TL CLI."""
2
+
3
+ import os
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+
7
+ # Default API base URL
8
+ DEFAULT_API_URL = "https://app.thoughtleaders.io"
9
+
10
+ # Auth0 defaults (CLI-specific application)
11
+ DEFAULT_AUTH0_DOMAIN = "dev-mq73b7zhdhwvgae1.us.auth0.com"
12
+ DEFAULT_AUTH0_CLIENT_ID = "BWTaMBWRP0wxWjPXbSa9FHhbz7RKfURu" # Set when Auth0 app is created, not secret
13
+ DEFAULT_AUTH0_AUDIENCE = "https://app.thoughtleaders.io/mcp" # No relation to the MCP API, just uses the same OAuth0 "audience" config
14
+ DEFAULT_AUTH0_CALLBACK_PORT = 8484 # Fixed port — must match Auth0 allowed callback URLs
15
+
16
+ # Config directory
17
+ CONFIG_DIR = Path.home() / ".config" / "tl"
18
+ CONFIG_FILE = CONFIG_DIR / "config.json"
19
+
20
+
21
+ @dataclass
22
+ class Config:
23
+ """Runtime configuration resolved from env vars, config file, and defaults."""
24
+
25
+ api_url: str = field(default_factory=lambda: os.environ.get("TL_API_URL", DEFAULT_API_URL))
26
+ api_key: str | None = field(default_factory=lambda: os.environ.get("TL_API_KEY"))
27
+ auth0_domain: str = field(
28
+ default_factory=lambda: os.environ.get("TL_AUTH0_DOMAIN", DEFAULT_AUTH0_DOMAIN)
29
+ )
30
+ auth0_client_id: str = field(
31
+ default_factory=lambda: os.environ.get("TL_AUTH0_CLIENT_ID", DEFAULT_AUTH0_CLIENT_ID)
32
+ )
33
+ auth0_audience: str = field(
34
+ default_factory=lambda: os.environ.get("TL_AUTH0_AUDIENCE", DEFAULT_AUTH0_AUDIENCE)
35
+ )
36
+ llm_key: str | None = field(default_factory=lambda: os.environ.get("TL_LLM_KEY"))
37
+
38
+ @property
39
+ def cli_api_base(self) -> str:
40
+ return f"{self.api_url.rstrip('/')}/api/cli/v1"
41
+
42
+
43
+ # Global flags, set by options on the root command
44
+ debug: bool = False
45
+
46
+
47
+ def get_config() -> Config:
48
+ """Get the current configuration."""
49
+ return Config()
50
+
51
+
52
+ def ensure_config_dir() -> Path:
53
+ """Ensure the config directory exists and return it."""
54
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
55
+ return CONFIG_DIR
tl_cli/filters.py ADDED
@@ -0,0 +1,88 @@
1
+ """Parse key:value filter pairs from CLI arguments.
2
+
3
+ This module only handles parsing — it does not know which filters are valid
4
+ for which resource. Each command module validates its own filters.
5
+
6
+ Examples:
7
+ parse_filters(["status:sold", 'brand:"Nike"', "created-at:2026-01"])
8
+ → {"status": "sold", "brand": "Nike", "created-at": "2026-01"}
9
+ """
10
+
11
+ import datetime
12
+ import re
13
+ import sys
14
+
15
+ DATE_FILTER_KEYS = {
16
+ # uploads (publication_date lower bound)
17
+ "since",
18
+ # sponsorships date filters — see RESOURCES['sponsorships'].filters.
19
+ # For each field, <prefix>:<date> filters within that date/period,
20
+ # and <prefix>-start/-end:<date> give inclusive lower/upper bounds.
21
+ "created-at", "created-at-start", "created-at-end",
22
+ "publish-date", "publish-date-start", "publish-date-end",
23
+ "purchase-date", "purchase-date-start", "purchase-date-end",
24
+ "send-date", "send-date-start", "send-date-end",
25
+ }
26
+
27
+ DATE_KEYWORDS = {
28
+ "today": lambda: datetime.date.today(),
29
+ "yesterday": lambda: datetime.date.today() - datetime.timedelta(days=1),
30
+ "tomorrow": lambda: datetime.date.today() + datetime.timedelta(days=1),
31
+ }
32
+
33
+
34
+ def parse_filters(args: list[str]) -> dict[str, str]:
35
+ """Parse a list of key:value filter strings into a dict.
36
+
37
+ Supports:
38
+ key:value → {"key": "value"}
39
+ key:"quoted value" → {"key": "quoted value"}
40
+ key:'quoted value' → {"key": "quoted value"}
41
+ key: → {"key": ""} (empty value — endpoint decides semantics,
42
+ e.g. `owner-sales:` → no owner assigned)
43
+
44
+ Returns a dict of filter_name → filter_value. Prints an error and exits
45
+ if a filter is malformed.
46
+ """
47
+ filters: dict[str, str] = {}
48
+
49
+ for arg in args:
50
+ match = re.match(r'^([a-zA-Z_-]+):(.*)$', arg)
51
+ if not match:
52
+ print(f"Error: invalid filter '{arg}'. Expected format: key:value", file=sys.stderr)
53
+ raise SystemExit(1)
54
+
55
+ key = match.group(1)
56
+ value = match.group(2)
57
+
58
+ # Strip surrounding quotes
59
+ if (value.startswith('"') and value.endswith('"')) or (
60
+ value.startswith("'") and value.endswith("'")
61
+ ):
62
+ value = value[1:-1]
63
+
64
+ if key in DATE_FILTER_KEYS:
65
+ resolved = DATE_KEYWORDS.get(value.lower())
66
+ if resolved:
67
+ value = resolved().isoformat()
68
+
69
+ filters[key] = value
70
+
71
+ return filters
72
+
73
+
74
+ def split_id_and_filters(args: list[str]) -> tuple[str | None, dict[str, str]]:
75
+ """Split args into an optional leading ID and remaining filters.
76
+
77
+ If the first arg doesn't contain ':', it's treated as an ID.
78
+ Everything else is parsed as filters.
79
+
80
+ Returns (id_or_none, filters_dict).
81
+ """
82
+ if not args:
83
+ return None, {}
84
+
85
+ if ":" not in args[0]:
86
+ return args[0], parse_filters(args[1:])
87
+
88
+ return None, parse_filters(args)
tl_cli/hints.py ADDED
@@ -0,0 +1,53 @@
1
+ """Detail-view CTA hints, personalized via whoami."""
2
+
3
+ from tl_cli.client.http import TLClient
4
+
5
+
6
+ def detail_hint(
7
+ client: TLClient,
8
+ *,
9
+ brand: str | None = None,
10
+ channel: str | None = None,
11
+ ) -> str | None:
12
+ """Build a CTA hint for a detail view.
13
+
14
+ - Sponsorship detail: both brand and channel come from the record.
15
+ - Channel detail: channel from the record, brand = user's org (if buyer).
16
+ - Brand detail: brand from the record, channel = user's org (if seller).
17
+
18
+ Returns None when both sides can't be determined.
19
+ """
20
+ _u = "[underline]"
21
+ _uu = "[/underline]"
22
+ email = f"{_u}info@thoughtleaders.io{_uu}"
23
+
24
+ if brand and channel:
25
+ return (
26
+ f"We can make the sponsorship between {_u}{brand}{_uu} and {_u}{channel}{_uu} work!"
27
+ f" Contact us at {email}"
28
+ )
29
+
30
+ # Need the counterparty from whoami
31
+ try:
32
+ data = client.get("/whoami")
33
+ except Exception:
34
+ return None
35
+
36
+ flags = data.get("profile", {}).get("flags", [])
37
+ org = data.get("organization", {}).get("name")
38
+ if not org:
39
+ return None
40
+
41
+ if channel and not brand and "advertiser" in flags:
42
+ return (
43
+ f"We can make the sponsorship between {_u}{org}{_uu} and {_u}{channel}{_uu} work!"
44
+ f" Contact us at {email}"
45
+ )
46
+
47
+ if brand and not channel and "publisher" in flags:
48
+ return (
49
+ f"We can make the sponsorship between {_u}{brand}{_uu} and {_u}{org}{_uu} work!"
50
+ f" Contact us at {email}"
51
+ )
52
+
53
+ return None