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
tl_cli/commands/setup.py
ADDED
|
@@ -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")
|