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.
- thoughtleaders_cli-0.5.0.dist-info/METADATA +215 -0
- thoughtleaders_cli-0.5.0.dist-info/RECORD +59 -0
- thoughtleaders_cli-0.5.0.dist-info/WHEEL +4 -0
- thoughtleaders_cli-0.5.0.dist-info/entry_points.txt +2 -0
- thoughtleaders_cli-0.5.0.dist-info/licenses/LICENSE +21 -0
- tl_cli/__init__.py +3 -0
- tl_cli/_completions.py +4 -0
- tl_cli/_plugin/.claude-plugin/marketplace.json +17 -0
- tl_cli/_plugin/.claude-plugin/plugin.json +12 -0
- tl_cli/_plugin/agents/tl-analyst.md +66 -0
- tl_cli/_plugin/commands/tl-balance.md +10 -0
- tl_cli/_plugin/commands/tl-brands.md +16 -0
- tl_cli/_plugin/commands/tl-channels.md +31 -0
- tl_cli/_plugin/commands/tl-reports.md +16 -0
- tl_cli/_plugin/commands/tl-sponsorships.md +23 -0
- tl_cli/_plugin/commands/tl.md +28 -0
- tl_cli/_plugin/hooks/hooks.json +26 -0
- tl_cli/_plugin/hooks/scripts/post-usage.sh +26 -0
- tl_cli/_plugin/hooks/scripts/pre-check.sh +30 -0
- tl_cli/_plugin/skills/tl/SKILL.md +413 -0
- tl_cli/_plugin/skills/tl/references/business-glossary.md +159 -0
- tl_cli/_plugin/skills/tl/references/elasticsearch-schema.md +259 -0
- tl_cli/_plugin/skills/tl/references/firebolt-schema.md +208 -0
- tl_cli/_plugin/skills/tl/references/postgres-schema.md +269 -0
- tl_cli/auth/__init__.py +0 -0
- tl_cli/auth/commands.py +49 -0
- tl_cli/auth/login.py +328 -0
- tl_cli/auth/pkce.py +21 -0
- tl_cli/auth/token_store.py +98 -0
- tl_cli/client/__init__.py +0 -0
- tl_cli/client/errors.py +72 -0
- tl_cli/client/http.py +109 -0
- tl_cli/commands/__init__.py +0 -0
- tl_cli/commands/ask.py +54 -0
- tl_cli/commands/balance.py +68 -0
- tl_cli/commands/brands.py +174 -0
- tl_cli/commands/changelog.py +119 -0
- tl_cli/commands/channels.py +291 -0
- tl_cli/commands/comments.py +63 -0
- tl_cli/commands/db.py +104 -0
- tl_cli/commands/deals.py +52 -0
- tl_cli/commands/describe.py +166 -0
- tl_cli/commands/doctor.py +70 -0
- tl_cli/commands/matches.py +69 -0
- tl_cli/commands/proposals.py +69 -0
- tl_cli/commands/reports.py +346 -0
- tl_cli/commands/schema.py +55 -0
- tl_cli/commands/setup.py +401 -0
- tl_cli/commands/snapshots.py +93 -0
- tl_cli/commands/sponsorships.py +193 -0
- tl_cli/commands/uploads.py +84 -0
- tl_cli/commands/whoami.py +206 -0
- tl_cli/config.py +55 -0
- tl_cli/filters.py +88 -0
- tl_cli/hints.py +53 -0
- tl_cli/main.py +209 -0
- tl_cli/output/__init__.py +0 -0
- tl_cli/output/formatter.py +436 -0
- tl_cli/self_update.py +173 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Secure token storage using OS keychain (keyring) with file fallback."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import keyring
|
|
9
|
+
from keyring.errors import NoKeyringError
|
|
10
|
+
|
|
11
|
+
from tl_cli.config import ensure_config_dir
|
|
12
|
+
|
|
13
|
+
SERVICE_NAME = "tl-cli"
|
|
14
|
+
FALLBACK_FILE = "credentials.json"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class StoredTokens:
|
|
19
|
+
"""Tokens stored in the keychain."""
|
|
20
|
+
|
|
21
|
+
access_token: str
|
|
22
|
+
refresh_token: str | None
|
|
23
|
+
expires_at: float # Unix timestamp
|
|
24
|
+
email: str | None = None
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def is_expired(self) -> bool:
|
|
28
|
+
# 5-minute buffer before actual expiry
|
|
29
|
+
return time.time() > (self.expires_at - 300)
|
|
30
|
+
|
|
31
|
+
def to_json(self) -> str:
|
|
32
|
+
return json.dumps({
|
|
33
|
+
"access_token": self.access_token,
|
|
34
|
+
"refresh_token": self.refresh_token,
|
|
35
|
+
"expires_at": self.expires_at,
|
|
36
|
+
"email": self.email,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def from_json(cls, data: str) -> "StoredTokens":
|
|
41
|
+
parsed = json.loads(data)
|
|
42
|
+
return cls(
|
|
43
|
+
access_token=parsed["access_token"],
|
|
44
|
+
refresh_token=parsed.get("refresh_token"),
|
|
45
|
+
expires_at=parsed["expires_at"],
|
|
46
|
+
email=parsed.get("email"),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def save_tokens(tokens: StoredTokens) -> None:
|
|
51
|
+
"""Save tokens to the OS keychain, falling back to encrypted file."""
|
|
52
|
+
data = tokens.to_json()
|
|
53
|
+
try:
|
|
54
|
+
keyring.set_password(SERVICE_NAME, "tokens", data)
|
|
55
|
+
except (NoKeyringError, Exception):
|
|
56
|
+
_save_to_file(data)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def load_tokens() -> StoredTokens | None:
|
|
60
|
+
"""Load tokens from the OS keychain or fallback file."""
|
|
61
|
+
try:
|
|
62
|
+
data = keyring.get_password(SERVICE_NAME, "tokens")
|
|
63
|
+
if data:
|
|
64
|
+
return StoredTokens.from_json(data)
|
|
65
|
+
except (NoKeyringError, Exception):
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
return _load_from_file()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def clear_tokens() -> None:
|
|
72
|
+
"""Remove stored tokens from keychain and fallback file."""
|
|
73
|
+
try:
|
|
74
|
+
keyring.delete_password(SERVICE_NAME, "tokens")
|
|
75
|
+
except (NoKeyringError, Exception):
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
fallback = ensure_config_dir() / FALLBACK_FILE
|
|
79
|
+
if fallback.exists():
|
|
80
|
+
fallback.unlink()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _save_to_file(data: str) -> None:
|
|
84
|
+
"""Fallback: save to config dir (less secure than keychain)."""
|
|
85
|
+
path = ensure_config_dir() / FALLBACK_FILE
|
|
86
|
+
path.write_text(data)
|
|
87
|
+
path.chmod(0o600)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _load_from_file() -> StoredTokens | None:
|
|
91
|
+
"""Fallback: load from config dir."""
|
|
92
|
+
path = ensure_config_dir() / FALLBACK_FILE
|
|
93
|
+
if not path.exists():
|
|
94
|
+
return None
|
|
95
|
+
try:
|
|
96
|
+
return StoredTokens.from_json(path.read_text())
|
|
97
|
+
except (json.JSONDecodeError, KeyError):
|
|
98
|
+
return None
|
|
File without changes
|
tl_cli/client/errors.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""User-friendly error handling for API responses."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
import traceback
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
err = Console(stderr=True)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ApiError(Exception):
|
|
13
|
+
"""Raised when the API returns a non-success status."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, status_code: int, detail: str, raw: dict | None = None, url: str | None = None, response_text: str | None = None):
|
|
16
|
+
self.status_code = status_code
|
|
17
|
+
self.detail = detail
|
|
18
|
+
self.raw = raw
|
|
19
|
+
self.url = url
|
|
20
|
+
self.response_text = response_text
|
|
21
|
+
super().__init__(f"HTTP {status_code}: {detail}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _print_debug(error: ApiError) -> None:
|
|
25
|
+
"""Print detailed debug info for an API error."""
|
|
26
|
+
from tl_cli.config import debug
|
|
27
|
+
|
|
28
|
+
if not debug:
|
|
29
|
+
return
|
|
30
|
+
err.print(f"\n[dim]--- debug ---[/dim]")
|
|
31
|
+
if error.url:
|
|
32
|
+
err.print(f"[dim]URL: {error.url}[/dim]")
|
|
33
|
+
err.print(f"[dim]HTTP {error.status_code}: {error.detail}[/dim]")
|
|
34
|
+
if error.response_text:
|
|
35
|
+
err.print(f"[dim]Response body:[/dim]")
|
|
36
|
+
err.print(f"[dim]{error.response_text}[/dim]")
|
|
37
|
+
err.print(f"[dim]Traceback:[/dim]")
|
|
38
|
+
err.print(f"[dim]{''.join(traceback.format_exception(error))}[/dim]")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def handle_api_error(error: ApiError) -> None:
|
|
42
|
+
"""Print a user-friendly error message and exit with the right code."""
|
|
43
|
+
if error.status_code == 401:
|
|
44
|
+
err.print("[red]Authentication required.[/red] Run: tl auth login")
|
|
45
|
+
_print_debug(error)
|
|
46
|
+
sys.exit(2)
|
|
47
|
+
elif error.status_code == 402:
|
|
48
|
+
err.print("[red]Insufficient credits.[/red]")
|
|
49
|
+
err.print("Deposit more at: https://app.thoughtleaders.io/settings/billing")
|
|
50
|
+
_print_debug(error)
|
|
51
|
+
sys.exit(4)
|
|
52
|
+
elif error.status_code == 403:
|
|
53
|
+
err.print(f"[red]Access denied:[/red] {error.detail}")
|
|
54
|
+
err.print("Your plan may not include access to this resource.")
|
|
55
|
+
_print_debug(error)
|
|
56
|
+
sys.exit(1)
|
|
57
|
+
elif error.status_code == 404:
|
|
58
|
+
err.print(f"[yellow]Not found:[/yellow] {error.detail}")
|
|
59
|
+
_print_debug(error)
|
|
60
|
+
sys.exit(1)
|
|
61
|
+
elif error.status_code == 429:
|
|
62
|
+
err.print("[yellow]Rate limited.[/yellow] Please wait and try again.")
|
|
63
|
+
_print_debug(error)
|
|
64
|
+
sys.exit(3)
|
|
65
|
+
elif error.status_code >= 500:
|
|
66
|
+
err.print(f"[red]Server error ({error.status_code}):[/red] {error.detail}")
|
|
67
|
+
_print_debug(error)
|
|
68
|
+
sys.exit(3)
|
|
69
|
+
else:
|
|
70
|
+
err.print(f"[red]Error ({error.status_code}):[/red] {error.detail}")
|
|
71
|
+
_print_debug(error)
|
|
72
|
+
sys.exit(1)
|
tl_cli/client/http.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Authenticated HTTP client for the TL CLI API."""
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from tl_cli import __version__
|
|
6
|
+
from tl_cli.auth.login import refresh_access_token
|
|
7
|
+
from tl_cli.auth.token_store import load_tokens
|
|
8
|
+
from tl_cli.client.errors import ApiError
|
|
9
|
+
from tl_cli.config import get_config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TLClient:
|
|
13
|
+
"""HTTP client that handles auth injection, token refresh, and error mapping."""
|
|
14
|
+
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
self._config = get_config()
|
|
17
|
+
self._client = httpx.Client(
|
|
18
|
+
base_url=self._config.cli_api_base,
|
|
19
|
+
timeout=30.0,
|
|
20
|
+
headers={
|
|
21
|
+
"User-Agent": f"tl-cli/{__version__}",
|
|
22
|
+
"X-TL-Client": f"cli/{__version__}",
|
|
23
|
+
},
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
def get(self, path: str, params: dict | None = None) -> dict:
|
|
27
|
+
return self._request("GET", path, params=params)
|
|
28
|
+
|
|
29
|
+
def post(self, path: str, json_body: dict | None = None) -> dict:
|
|
30
|
+
return self._request("POST", path, json_body=json_body)
|
|
31
|
+
|
|
32
|
+
def _request(
|
|
33
|
+
self,
|
|
34
|
+
method: str,
|
|
35
|
+
path: str,
|
|
36
|
+
params: dict | None = None,
|
|
37
|
+
json_body: dict | None = None,
|
|
38
|
+
) -> dict:
|
|
39
|
+
headers = self._auth_headers()
|
|
40
|
+
|
|
41
|
+
response = self._client.request(
|
|
42
|
+
method, path, params=params, json=json_body, headers=headers
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# On 401, try refreshing the token once
|
|
46
|
+
if response.status_code == 401:
|
|
47
|
+
headers = self._refresh_and_get_headers()
|
|
48
|
+
if headers:
|
|
49
|
+
response = self._client.request(
|
|
50
|
+
method, path, params=params, json=json_body, headers=headers
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
if response.status_code >= 400:
|
|
54
|
+
detail = self._extract_detail(response)
|
|
55
|
+
try:
|
|
56
|
+
raw = response.json() if response.text else None
|
|
57
|
+
except Exception:
|
|
58
|
+
raw = None
|
|
59
|
+
raise ApiError(
|
|
60
|
+
response.status_code, detail, raw=raw,
|
|
61
|
+
url=str(response.url), response_text=response.text,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
return response.json()
|
|
65
|
+
|
|
66
|
+
def _auth_headers(self) -> dict[str, str]:
|
|
67
|
+
"""Get authorization headers from API key or stored tokens."""
|
|
68
|
+
# API key takes priority (for CI/scripts)
|
|
69
|
+
if self._config.api_key:
|
|
70
|
+
return {"Authorization": f"Bearer {self._config.api_key}"}
|
|
71
|
+
|
|
72
|
+
tokens = load_tokens()
|
|
73
|
+
if not tokens:
|
|
74
|
+
raise ApiError(401, "Not authenticated. Run: tl auth login")
|
|
75
|
+
|
|
76
|
+
if tokens.is_expired and tokens.refresh_token:
|
|
77
|
+
tokens = refresh_access_token(tokens.refresh_token)
|
|
78
|
+
|
|
79
|
+
return {"Authorization": f"Bearer {tokens.access_token}"}
|
|
80
|
+
|
|
81
|
+
def _refresh_and_get_headers(self) -> dict[str, str] | None:
|
|
82
|
+
"""Try to refresh the token. Returns new headers or None."""
|
|
83
|
+
tokens = load_tokens()
|
|
84
|
+
if not tokens or not tokens.refresh_token:
|
|
85
|
+
return None
|
|
86
|
+
try:
|
|
87
|
+
new_tokens = refresh_access_token(tokens.refresh_token)
|
|
88
|
+
return {"Authorization": f"Bearer {new_tokens.access_token}"}
|
|
89
|
+
except SystemExit:
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
def _extract_detail(self, response: httpx.Response) -> str:
|
|
93
|
+
"""Extract error detail from response body."""
|
|
94
|
+
try:
|
|
95
|
+
data = response.json()
|
|
96
|
+
return data.get("detail", data.get("error", str(data)))
|
|
97
|
+
except Exception:
|
|
98
|
+
text = response.text or ""
|
|
99
|
+
if text.lstrip().startswith("<!") or text.lstrip().startswith("<html"):
|
|
100
|
+
return f"HTTP {response.status_code} (non-JSON response from server)"
|
|
101
|
+
return text or f"HTTP {response.status_code}"
|
|
102
|
+
|
|
103
|
+
def close(self) -> None:
|
|
104
|
+
self._client.close()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def get_client() -> TLClient:
|
|
108
|
+
"""Get a configured TL API client."""
|
|
109
|
+
return TLClient()
|
|
File without changes
|
tl_cli/commands/ask.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""tl ask — Natural language query (AI fallback for users without an agent)."""
|
|
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="Natural language data queries (AI-powered fallback)")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.callback(invoke_without_command=True)
|
|
13
|
+
def ask(
|
|
14
|
+
ctx: typer.Context,
|
|
15
|
+
question: str = typer.Argument(..., help="Your question in plain English"),
|
|
16
|
+
llm_key: str | None = typer.Option(
|
|
17
|
+
None, "--llm-key", envvar="TL_LLM_KEY",
|
|
18
|
+
help="Your own LLM API key (waives surcharge)",
|
|
19
|
+
),
|
|
20
|
+
json_output: bool = typer.Option(False, "--json", help="JSON output"),
|
|
21
|
+
csv_output: bool = typer.Option(False, "--csv", help="CSV output"),
|
|
22
|
+
md_output: bool = typer.Option(False, "--md", help="Markdown output"),
|
|
23
|
+
toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
|
|
24
|
+
limit: int = typer.Option(50, "--limit", "-l", help="Max results"),
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Ask a question about your data in plain English.
|
|
27
|
+
|
|
28
|
+
This is an optional fallback for users who don't have an AI agent (like Claude Code).
|
|
29
|
+
If you have Claude Code installed, use the /tl slash command instead — it's free and
|
|
30
|
+
uses your own Claude to translate questions into structured tl commands.
|
|
31
|
+
|
|
32
|
+
Credits: result credits + 2/result surcharge (waived with --llm-key).
|
|
33
|
+
|
|
34
|
+
Examples:
|
|
35
|
+
tl ask "show me all sold sponsorships for Nike in Q1"
|
|
36
|
+
tl ask "which channels had the most views last month" --llm-key sk-...
|
|
37
|
+
"""
|
|
38
|
+
if ctx.invoked_subcommand is not None:
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
fmt = detect_format(json_output, csv_output, md_output, toon_output)
|
|
42
|
+
|
|
43
|
+
body: dict = {"query": question, "limit": limit}
|
|
44
|
+
if llm_key:
|
|
45
|
+
body["llm_key"] = llm_key
|
|
46
|
+
|
|
47
|
+
client = get_client()
|
|
48
|
+
try:
|
|
49
|
+
data = client.post("/ask", json_body=body)
|
|
50
|
+
output(data, fmt, title="Results")
|
|
51
|
+
except ApiError as e:
|
|
52
|
+
handle_api_error(e)
|
|
53
|
+
finally:
|
|
54
|
+
client.close()
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""tl balance — Show credit balance and recent usage."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from tl_cli.client.errors import ApiError, handle_api_error
|
|
10
|
+
from tl_cli.client.http import get_client
|
|
11
|
+
from tl_cli.output.formatter import detect_format
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(help="Credit balance and usage (free)")
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@app.callback(invoke_without_command=True)
|
|
18
|
+
def balance(
|
|
19
|
+
ctx: typer.Context,
|
|
20
|
+
json_output: bool = typer.Option(False, "--json", help="JSON output"),
|
|
21
|
+
toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Show your credit balance and recent usage (free, no credits).
|
|
24
|
+
|
|
25
|
+
Examples:
|
|
26
|
+
tl balance
|
|
27
|
+
tl balance --json
|
|
28
|
+
"""
|
|
29
|
+
if ctx.invoked_subcommand is not None:
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
fmt = detect_format(json_output, False, False, toon_output)
|
|
33
|
+
|
|
34
|
+
client = get_client()
|
|
35
|
+
try:
|
|
36
|
+
data = client.get("/balance")
|
|
37
|
+
|
|
38
|
+
if fmt == "json":
|
|
39
|
+
print(json.dumps(data, indent=2, default=str))
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
balance_val = data.get("balance", 0)
|
|
43
|
+
allow_overage = data.get("allow_overage", False)
|
|
44
|
+
|
|
45
|
+
console.print(f"\n[bold]Credit Balance:[/bold] [cyan]{balance_val}[/cyan] credits")
|
|
46
|
+
if allow_overage:
|
|
47
|
+
console.print("[dim]Overage: enabled[/dim]")
|
|
48
|
+
|
|
49
|
+
recent = data.get("recent_usage", [])
|
|
50
|
+
if recent:
|
|
51
|
+
table = Table(title="Recent Usage")
|
|
52
|
+
table.add_column("Date")
|
|
53
|
+
table.add_column("Resource")
|
|
54
|
+
table.add_column("Results", justify="right")
|
|
55
|
+
table.add_column("Credits", justify="right")
|
|
56
|
+
for entry in recent[:10]:
|
|
57
|
+
table.add_row(
|
|
58
|
+
entry.get("date", ""),
|
|
59
|
+
entry.get("resource", ""),
|
|
60
|
+
str(entry.get("results_count", "")),
|
|
61
|
+
str(entry.get("credits_charged", "")),
|
|
62
|
+
)
|
|
63
|
+
console.print(table)
|
|
64
|
+
|
|
65
|
+
except ApiError as e:
|
|
66
|
+
handle_api_error(e)
|
|
67
|
+
finally:
|
|
68
|
+
client.close()
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""tl brands — Brand detail and sponsorship history."""
|
|
2
|
+
|
|
3
|
+
import urllib.parse
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from tl_cli.client.errors import ApiError, handle_api_error
|
|
10
|
+
from tl_cli.client.http import get_client
|
|
11
|
+
from tl_cli.hints import detail_hint
|
|
12
|
+
from tl_cli.output.formatter import detect_format, output, output_single
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(help="Brand intelligence (detail, sponsorship history, channel mentions)")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@app.callback(invoke_without_command=True)
|
|
18
|
+
def brands(ctx: typer.Context) -> None:
|
|
19
|
+
"""Brands — detail and sponsorship history."""
|
|
20
|
+
if ctx.invoked_subcommand is None:
|
|
21
|
+
ctx.get_help()
|
|
22
|
+
raise typer.Exit()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _handle_brand_api_error(e: ApiError) -> None:
|
|
26
|
+
"""Print a candidates list for ambiguous brand name matches."""
|
|
27
|
+
if e.status_code == 400 and isinstance(e.raw, dict) and e.raw.get("candidates"):
|
|
28
|
+
err = Console(stderr=True)
|
|
29
|
+
err.print(f"[yellow]{e.detail}[/yellow]")
|
|
30
|
+
err.print()
|
|
31
|
+
err.print("[bold]Candidates:[/bold]")
|
|
32
|
+
err.print(f" {'brand_id':>10} {'website':<30} name")
|
|
33
|
+
err.print(f" {'-' * 10} {'-' * 30} {'-' * 40}")
|
|
34
|
+
for c in e.raw["candidates"]:
|
|
35
|
+
err.print(f" {c['brand_id']:>10} {c.get('website', ''):<30} {c['name']}")
|
|
36
|
+
raise typer.Exit(1)
|
|
37
|
+
handle_api_error(e)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@app.command("show")
|
|
41
|
+
def show_cmd(
|
|
42
|
+
query: str = typer.Argument(..., help="Brand name or numeric ID"),
|
|
43
|
+
json_output: bool = typer.Option(False, "--json", help="JSON output"),
|
|
44
|
+
csv_output: bool = typer.Option(False, "--csv", help="CSV output (flattens nested fields)"),
|
|
45
|
+
toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Show brand detail by name or ID.
|
|
48
|
+
|
|
49
|
+
Accepts either a numeric brand ID or a partial name. Names that
|
|
50
|
+
match more than one brand return an error with candidate IDs.
|
|
51
|
+
|
|
52
|
+
Examples:
|
|
53
|
+
tl brands show Nike
|
|
54
|
+
tl brands show 21416
|
|
55
|
+
"""
|
|
56
|
+
fmt = detect_format(json_output, csv_output, False, toon_output)
|
|
57
|
+
encoded_query = urllib.parse.quote(query, safe="")
|
|
58
|
+
client = get_client()
|
|
59
|
+
try:
|
|
60
|
+
data = client.get(f"/brands/{encoded_query}")
|
|
61
|
+
for r in (data.get("results", []) if isinstance(data.get("results"), list) else []):
|
|
62
|
+
r["brand_id"] = r.pop("id", None)
|
|
63
|
+
output_single(data, fmt)
|
|
64
|
+
if fmt == "table" and data.get("show_cta"):
|
|
65
|
+
record = data.get("results", data)
|
|
66
|
+
if isinstance(record, list) and record:
|
|
67
|
+
record = record[0]
|
|
68
|
+
if isinstance(record, dict):
|
|
69
|
+
hint = detail_hint(client, brand=record.get("name"))
|
|
70
|
+
if hint:
|
|
71
|
+
Console(stderr=True).print(f"\n[yellow]{hint}[/yellow]")
|
|
72
|
+
except ApiError as e:
|
|
73
|
+
_handle_brand_api_error(e)
|
|
74
|
+
finally:
|
|
75
|
+
client.close()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@app.command("history")
|
|
79
|
+
def history_cmd(
|
|
80
|
+
query: str = typer.Argument(..., help="Brand name or numeric ID"),
|
|
81
|
+
channel: int | None = typer.Option(None, "--channel", "-c", help="Filter to a specific channel"),
|
|
82
|
+
json_output: bool = typer.Option(False, "--json", help="JSON output"),
|
|
83
|
+
csv_output: bool = typer.Option(False, "--csv", help="CSV output"),
|
|
84
|
+
md_output: bool = typer.Option(False, "--md", help="Markdown output"),
|
|
85
|
+
toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
|
|
86
|
+
limit: int = typer.Option(50, "--limit", "-l", help="Max results"),
|
|
87
|
+
offset: int = typer.Option(0, "--offset", help="Pagination offset"),
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Show a brand's sponsorship history (videos where the brand was detected).
|
|
90
|
+
|
|
91
|
+
Requires an Intelligence plan.
|
|
92
|
+
|
|
93
|
+
Examples:
|
|
94
|
+
tl brands history Nike # Nike's sponsorship history
|
|
95
|
+
tl brands history 21416 # By brand ID
|
|
96
|
+
tl brands history Nike --channel 12345 # Nike mentions on a specific channel
|
|
97
|
+
"""
|
|
98
|
+
fmt = detect_format(json_output, csv_output, md_output, toon_output)
|
|
99
|
+
|
|
100
|
+
params: dict[str, str] = {"limit": str(limit), "offset": str(offset)}
|
|
101
|
+
if channel is not None:
|
|
102
|
+
params["channel_id"] = str(channel)
|
|
103
|
+
|
|
104
|
+
encoded_query = urllib.parse.quote(query, safe="")
|
|
105
|
+
client = get_client()
|
|
106
|
+
try:
|
|
107
|
+
data = client.get(f"/brands/{encoded_query}/history", params=params)
|
|
108
|
+
brand_name = data.get("brand", {}).get("name", query)
|
|
109
|
+
output(
|
|
110
|
+
data,
|
|
111
|
+
fmt,
|
|
112
|
+
columns=["video_id", "title", "channel_id", "channel", "views", "publication_date", "is_tl"],
|
|
113
|
+
title=f"Brand History: {brand_name}",
|
|
114
|
+
)
|
|
115
|
+
except ApiError as e:
|
|
116
|
+
handle_api_error(e)
|
|
117
|
+
finally:
|
|
118
|
+
client.close()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
SIMILAR_COLUMNS = ["score", "brand_id", "brand_name", "website", "mbn"]
|
|
122
|
+
SIMILAR_COLUMN_CONFIG = {
|
|
123
|
+
"score": {"justify": "right"},
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _format_score(results: list[dict]) -> list[dict]:
|
|
128
|
+
"""Convert raw cosine score (0.0-1.0) to percentage string."""
|
|
129
|
+
for row in results:
|
|
130
|
+
score = row.get("score")
|
|
131
|
+
if isinstance(score, (int, float)):
|
|
132
|
+
row["score"] = f"{score * 100:.1f}%"
|
|
133
|
+
return results
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@app.command("similar")
|
|
137
|
+
def similar_cmd(
|
|
138
|
+
query: str = typer.Argument(..., help="Brand name or numeric ID"),
|
|
139
|
+
json_output: bool = typer.Option(False, "--json", help="JSON output"),
|
|
140
|
+
csv_output: bool = typer.Option(False, "--csv", help="CSV output"),
|
|
141
|
+
md_output: bool = typer.Option(False, "--md", help="Markdown output"),
|
|
142
|
+
toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
|
|
143
|
+
limit: int = typer.Option(20, "--limit", "-l", help="Max results (1-100)"),
|
|
144
|
+
) -> None:
|
|
145
|
+
"""Find brands similar to a given one (by ID or name).
|
|
146
|
+
|
|
147
|
+
Costs 50 credits per call. Intelligence plan required.
|
|
148
|
+
|
|
149
|
+
Examples:
|
|
150
|
+
tl brands similar Nike
|
|
151
|
+
tl brands similar 6037
|
|
152
|
+
tl brands similar 6037 mbn:yes --limit 10
|
|
153
|
+
"""
|
|
154
|
+
fmt = detect_format(json_output, csv_output, md_output, toon_output)
|
|
155
|
+
encoded_query = urllib.parse.quote(query, safe="")
|
|
156
|
+
params: dict[str, str] = {"limit": str(limit)}
|
|
157
|
+
|
|
158
|
+
client = get_client()
|
|
159
|
+
try:
|
|
160
|
+
data = client.get(f"/brands/{encoded_query}/similar", params=params)
|
|
161
|
+
brand_name = data.get("brand", {}).get("name", query)
|
|
162
|
+
if fmt in ("table", "md"):
|
|
163
|
+
_format_score(data.get("results", []))
|
|
164
|
+
output(
|
|
165
|
+
data,
|
|
166
|
+
fmt,
|
|
167
|
+
columns=SIMILAR_COLUMNS,
|
|
168
|
+
title=f"Brands similar to {brand_name}",
|
|
169
|
+
column_config=SIMILAR_COLUMN_CONFIG,
|
|
170
|
+
)
|
|
171
|
+
except ApiError as e:
|
|
172
|
+
_handle_brand_api_error(e)
|
|
173
|
+
finally:
|
|
174
|
+
client.close()
|