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,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
|