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,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
@@ -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()