thoughtleaders-cli 0.7.10__tar.gz → 0.7.12__tar.gz
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.7.10 → thoughtleaders_cli-0.7.12}/.claude-plugin/plugin.json +1 -1
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/PKG-INFO +1 -1
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/pyproject.toml +1 -1
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl/SKILL.md +13 -12
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl/references/elasticsearch-schema.md +22 -16
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl/references/firebolt-schema.md +1 -1
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl/references/postgres-schema.md +2 -2
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/__init__.py +1 -1
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/channels.py +6 -3
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/setup.py +177 -25
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/self_update.py +10 -2
- thoughtleaders_cli-0.7.12/tests/test_setup.py +193 -0
- thoughtleaders_cli-0.7.10/tests/test_setup.py +0 -41
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/.claude-plugin/marketplace.json +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/.github/workflows/python-publish.yml +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/.gitignore +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/AGENTS.md +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/API.md +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/CLAUDE.md +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/LICENSE +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/README.md +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/agents/tl-analyst.md +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/agents/youtube-comment-classifier.md +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/hooks/hooks.json +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/hooks/scripts/load-tl-skill.mjs +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/hooks/scripts/post-usage.sh +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/hooks/scripts/pre-check.sh +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl/references/business-glossary.md +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-channel-authenticity/.gitignore +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-channel-authenticity/SKILL.md +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-channel-authenticity/references/comment-patterns.md +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-channel-authenticity/references/peer-cohort.md +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-channel-authenticity/references/red-flags.md +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-channel-authenticity/references/scoring.md +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-channel-authenticity/scripts/_io_utf8.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-channel-authenticity/scripts/analyze_channel.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-channel-authenticity/scripts/anomaly_detector.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-channel-authenticity/scripts/comment_analyzer.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-channel-authenticity/scripts/comment_scraper.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-channel-authenticity/scripts/engagement_ratios.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-channel-authenticity/scripts/peer_cohort.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-channel-authenticity/scripts/report.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-channel-authenticity/scripts/resolve_channel.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-channel-authenticity/scripts/score.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-channel-authenticity/scripts/tl_cli.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-channel-authenticity/scripts/video_integrity.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-channel-authenticity/scripts/view_curves.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-keyword-research/SKILL.md +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-keyword-research/scripts/probe.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-save-report/SKILL.md +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-save-report/references/columns_brands.md +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-save-report/references/columns_channels.md +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-save-report/references/columns_content.md +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-save-report/references/columns_sponsorships.md +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-save-report/references/intelligence_filterset_schema.json +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-save-report/references/intelligence_widget_schema.json +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-save-report/references/report_glossary.md +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-save-report/references/sortable_columns.json +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-save-report/references/sponsorship_filterset_schema.json +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-save-report/references/sponsorship_widget_schema.json +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-save-report/references/widgets.md +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-top-partnerships/SKILL.md +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-top-partnerships/scripts/top_partnerships.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-views-guarantee/SKILL.md +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-views-guarantee/scripts/vg.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/_completions.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/_typer_utils.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/auth/__init__.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/auth/commands.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/auth/login.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/auth/pkce.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/auth/token_store.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/client/__init__.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/client/errors.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/client/http.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/__init__.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/_comments_common.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/balance.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/brands.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/bulk_import.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/changelog.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/credits.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/db.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/deals.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/describe.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/doctor.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/matches.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/proposals.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/recommender.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/reports.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/schema.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/snapshots.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/sponsorships.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/uploads.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/whoami.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/config.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/filters.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/hints.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/main.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/output/__init__.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/output/formatter.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/tests/__init__.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/tests/test_auth.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/tests/test_describe.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/tests/test_filters.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/tests/test_http_auth.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/tests/test_output.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/tests/test_reports.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/tests/test_sponsorships.py +0 -0
- {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: thoughtleaders-cli
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.12
|
|
4
4
|
Summary: ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence
|
|
5
5
|
Project-URL: Homepage, https://thoughtleaders.io
|
|
6
6
|
Project-URL: Repository, https://github.com/ThoughtLeaders-io/thoughtleaders-cli
|
|
@@ -9,7 +9,7 @@ description: |
|
|
|
9
9
|
|
|
10
10
|
## Core Principles
|
|
11
11
|
|
|
12
|
-
Run the `tl` CLI to query ThoughtLeaders' sponsorship platform data. Use it to answer questions about deals, channels, brands, uploads, metrics, etc. Use raw database queries via `tl db pg|fb|es` for everything.
|
|
12
|
+
Run the `tl` CLI to query ThoughtLeaders' sponsorship platform data. Use it to answer questions about deals, channels, brands, uploads, metrics, etc. Use raw database queries via `tl db pg|fb|es` for everything. One exception: resolving a named channel or brand (name, YouTube URL, @handle, video URL) to an ID is always `tl channels find` / `tl brands find` — never `ILIKE` on names.
|
|
13
13
|
|
|
14
14
|
If doing a database query, follow this recipe:
|
|
15
15
|
|
|
@@ -25,13 +25,13 @@ If doing a database query, follow this recipe:
|
|
|
25
25
|
```bash
|
|
26
26
|
tl db pg "SELECT id, weighted_price FROM thoughtleaders_adlink
|
|
27
27
|
WHERE publish_status = 3 AND price > 5000
|
|
28
|
-
LIMIT
|
|
28
|
+
LIMIT 10000 OFFSET 0" --json \
|
|
29
29
|
| jq '.results[] | {id, price: .weighted_price}'
|
|
30
30
|
```
|
|
31
31
|
- **`yq`** — same idea for YAML/TOML, useful when reading config files or `--md` blocks.
|
|
32
32
|
- **`rg`** — fast text search across CLI output, transcripts, and the codebase. Better than `grep` for searching large `--csv` exports or transcript dumps from ES.
|
|
33
33
|
```bash
|
|
34
|
-
tl db es '{"size":
|
|
34
|
+
tl db es '{"size":10000,"query":{"term":{"channel.id":5607}},"_source":["id","transcript"]}' --json | rg -o "NordVPN[^.]*"
|
|
35
35
|
```
|
|
36
36
|
- **`duckdb`** — embedded analytical SQL over CSV/JSON files. Use when you need joins, aggregations, or window functions across multiple `tl` exports without spinning up a database.
|
|
37
37
|
```bash
|
|
@@ -42,13 +42,13 @@ If doing a database query, follow this recipe:
|
|
|
42
42
|
JOIN thoughtleaders_brand b ON b.id = pb.brand_id
|
|
43
43
|
WHERE al.publish_status = 3
|
|
44
44
|
AND al.purchase_date >= '2026-01-01'
|
|
45
|
-
LIMIT
|
|
45
|
+
LIMIT 10000 OFFSET 0" --csv > deals.csv
|
|
46
46
|
duckdb -c "SELECT brand, SUM(price) AS revenue FROM 'deals.csv' GROUP BY brand ORDER BY revenue DESC LIMIT 10"
|
|
47
47
|
```
|
|
48
48
|
|
|
49
49
|
The pattern is always: server-side narrowing first (usually by filters in the `tl db` query, but could be from similarity / recommender searches), then shell tool to shape the result, then read only the final summary into context. If `tl doctor` reports any of these as missing, ask the user to install them.
|
|
50
50
|
|
|
51
|
-
Always assume there will be more than 1 page of results. You MUST always pass `LIMIT` and `OFFSET` to every `tl db pg|fb|es` query (and use the response envelope's `next_offset` / breadcrumbs to walk forward) so the entire data set is retrieved.
|
|
51
|
+
Always assume there will be more than 1 page of results. You MUST always pass `LIMIT` and `OFFSET` to every `tl db pg|fb|es` query (and use the response envelope's `next_offset` / breadcrumbs to walk forward) so the entire data set is retrieved. Prefer large pages (up to the engine's cap) to minimize round-trips; the per-engine page-size caps are documented in each engine's schema reference under `references/`.
|
|
52
52
|
|
|
53
53
|
**Counts, totals, and breakdowns: aggregate in the query engine — never page through records to count them.** A "how many / total / average / per-X" question is ONE aggregation query, not N pages of rows summed in your head:
|
|
54
54
|
- `tl db pg` — `SELECT COUNT(*) …`, or `SELECT col, COUNT(*) AS n … GROUP BY col ORDER BY n DESC`. Also `SUM`/`AVG`/`MIN`/`MAX`/`date_trunc`. Returns one/few rows regardless of table size. (`LIMIT`/`OFFSET` still required — an aggregate is one row, so `LIMIT 1 OFFSET 0` is fine.)
|
|
@@ -95,7 +95,8 @@ Other key concepts:
|
|
|
95
95
|
- **MBN** (Media Buying Network) — the brand-side counterpart to MSN: brand profiles that have opted in to receive proposed sponsorships. A profile is in the MBN group if the `profile.media_buying_network_join_date` field is not null.
|
|
96
96
|
- **TPP** (ThoughtLeaders Partner Program, a.k.a. "TL channels") — the ~170 channels TL has the closest working relationship with. A channel is in the TPP group if `channel.is_tl_channel` is True. **Prefer TPP channels when booking**: they respond fastest, are the easiest to close, and don't need an outreach round-trip — treat them as immediately bookable. TPP is a strict subset of MSN, so the same booking rules (one active mention adspot, etc.) apply.
|
|
97
97
|
- **`demographics_updated_at`** (on channels) — If non-null, the channel has demographics screenshots on file. If null, no demographics screenshots have been uploaded. Use this to check whether a channel has demographics data from screenshots.
|
|
98
|
-
- **`
|
|
98
|
+
- **`reach`** (on channels) — subscriber count. ⚠️ Despite the name, this is NOT ad-industry "reach" (unique audience exposed). There is no `subscribers` field — `reach` is it.
|
|
99
|
+
- **`impression`** (on channels) — projected views per video on that channel. Forward-looking estimate. May be null when not yet computed. ⚠️ NOT actual views and NOT ad-industry "impressions" (ads served).
|
|
99
100
|
- **`views`** (on sponsorships) — actual view count of the sold and published sponsored video, accessible when `article_id` is set.
|
|
100
101
|
- **`impressions_guarantee`** (on sponsorships) — projected/guaranteed impressions for the sponsorship. Numeric.
|
|
101
102
|
- **Sponsorship detail fields** (returned by `tl sponsorships show <id> --json`) — the detail payload includes `integration` (raw int), `publish_count`, `common_name`, `outreach_email`, nested `publisher` (`first_name`, `last_name`, `email`), nested `brand_contact` (`first_name`, `last_name`, `email`), and `brand.organization_name`. Use these when generating IOs, contracts, or outreach.
|
|
@@ -147,7 +148,7 @@ Unless the user specifically asks for running a specific report or showing the r
|
|
|
147
148
|
|
|
148
149
|
1. **Discover first**: Use `tl schema pg`, `tl schema es`, and `tl schema fb` to find information about the main database (pg), the articles / uploads database (es), and the channel metrics database (fb).
|
|
149
150
|
2. **Check credits**: Run `tl balance --json` before expensive queries. Warn the user if a query will cost many credits.
|
|
150
|
-
3. **Decide the method of discovery**: If the user
|
|
151
|
+
3. **Decide the method of discovery**: If the user named a specific channel, brand, or creator (a name, YouTube URL, @handle, or video URL), resolve it to an ID with `tl channels find` / `tl brands find` before anything else. If the user wants to explore certain topics, use the recommender commands. If it's more about filtering, construct a query for PG or ES.
|
|
151
152
|
4. **Always use --json**: Parse JSON output for multi-step analysis.
|
|
152
153
|
5. **Chain commands**: For complex questions, chain multiple `tl` commands, shell commands, and other tools.
|
|
153
154
|
6. **Format results**: When the user asks for a list or tabular data, present the results as a well-formatted markdown table. Pick the most relevant columns and use clear headers. Sort the result by relevant criteria - if the user asked for "top performers", order by the performance metric; if the user asked for "most recent", sort by the pertinent date desc.
|
|
@@ -445,7 +446,7 @@ If unsure about what information to find where, read the [references/postgresql-
|
|
|
445
446
|
| Pre-insert validation queries (joining `adspot ↔ channel ↔ profile ↔ org` to confirm MSN, integration=1, persona, plan) | **Available** via `tl db pg`. | One SELECT joining the four tables. Use `thoughtleaders_channel.media_selling_network_join_date IS NOT NULL` for MSN, `thoughtleaders_adspot.integration = 1` for mention adspots, `thoughtleaders_profile.persona` for the persona code (see persona constants in `references/postgres-schema.md`). |
|
|
446
447
|
| Firebolt cross-table or join queries; filtering on non-indexed columns in WHERE | **Unavailable** — not accepted. | Fetch a wider slice keyed on `channel_id` (and optionally `id`), filter the rest in `jq`/Python. |
|
|
447
448
|
| ES `query_string`, `regexp`, `wildcard`, `fuzzy`, `more_like_this`, parent/child joins; any `script_*`; multiple aggregations in one body | **Unavailable** — not accepted. | Rewrite using `term`/`terms`/`match`/`bool`/`nested`. For multi-agg dashboards, run multiple `tl db es` calls and combine client-side. For "similar"-style queries, try `tl channels similar` / `tl brands similar` (server-implemented similarity search). |
|
|
448
|
-
| ES deep pagination beyond `from+size = 10,000` | **
|
|
449
|
+
| ES deep pagination beyond `from+size = 10,000` | **Available** via `search_after` (stateless cursor); `scroll` and `pit` remain unavailable. | Sort with a unique tiebreaker (e.g. `id`), then pass the response envelope's `next_search_after` back as `search_after` in the next call, keeping `query`/`sort` identical and `from` at 0. See the ES reference's *Deep pagination* section. |
|
|
449
450
|
| ES index introspection (`_cat/indices`, mappings) | **Unavailable** — only `_search` is wired. | Read [references/elasticsearch-schema.md](references/elasticsearch-schema.md). It's manually maintained — update it when you discover new fields. |
|
|
450
451
|
| Schema introspection on Postgres (`information_schema.columns`, `pg_class`, …) | **Partial** — catalog-resolving casts and many `pg_*` helpers are blocked. | Use `tl schema pg` for the live table/column listing, or read [references/postgres-schema.md](references/postgres-schema.md). |
|
|
451
452
|
|
|
@@ -485,7 +486,7 @@ tl channels find "MrBeast"
|
|
|
485
486
|
tl brands find "NordVPN"
|
|
486
487
|
```
|
|
487
488
|
|
|
488
|
-
|
|
489
|
+
`tl channels find` resolves spacing/typo variants on its own ("Deco Destiny" → "DecoDestiny") via YouTube lookups and fuzzy similarity matching — no need to retry with hand-made name variations. A real channel that isn't in the index yet gets queued for analysis automatically (the response says to check back in ~24 hours). A plain "Not found" means even YouTube couldn't find it — treat that as the answer.
|
|
489
490
|
|
|
490
491
|
**Path 2. Curated tag / category / demographic** — user named a topic that maps cleanly to a recommender tag (`"Cooking"`, `"Tech"`, `"USA share"`, content categories, format hints). Use the recommender — it ranks channels by how strongly they load on a tag, returning ranked similarity scores instead of forcing exact equality. It also returns matching brand profiles alongside the channels — useful when the user wants to know "who buys this kind of inventory."
|
|
491
492
|
|
|
@@ -559,7 +560,7 @@ For per-country share beyond the recommender's "USA share" tag, use the `demogra
|
|
|
559
560
|
|
|
560
561
|
**MSN status (`media_selling_network_join_date`) is scrubbed from the advertiser sandbox view.** Raw SQL can't filter on it from an advertiser context. For MSN-only / non-MSN lookups, run the same raw SQL with `media_selling_network_join_date IS [NOT] NULL` from a context that has access to it (full-access role), or rely on the recommender's MSN-aware filters: `tl recommender top-channels "<tag>" msn:yes|no|all`.
|
|
561
562
|
|
|
562
|
-
**Anti-pattern: defaulting to `ILIKE` on `channel_name` for off-tag topic queries.** If the question is "channels about X" where X is a topic / concept / niche (not a literal substring you expect in channel names), reach for path 3 (`tl-keyword-research`), not `WHERE channel_name ILIKE '%X%'`. Channel-name `ILIKE` misses channels whose name doesn't literally contain X but whose content does; the keyword-research skill catches them via `title` / `summary` / `transcript`. Use `channel_name ILIKE` only when you actually expect the channel's name to contain the term (e.g. `"Crypto"` in `"My Happy Crypto"`) as a supplementary signal alongside path 3, not as a replacement for it.
|
|
563
|
+
**Anti-pattern: defaulting to `ILIKE` on `channel_name` for off-tag topic queries.** If the question is "channels about X" where X is a topic / concept / niche (not a literal substring you expect in channel names), reach for path 3 (`tl-keyword-research`), not `WHERE channel_name ILIKE '%X%'`. Channel-name `ILIKE` misses channels whose name doesn't literally contain X but whose content does; the keyword-research skill catches them via `title` / `summary` / `transcript`. Use `channel_name ILIKE` only when you actually expect the channel's name to contain the term (e.g. `"Crypto"` in `"My Happy Crypto"`) as a supplementary signal alongside path 3, not as a replacement for it. And for a *named entity* — a specific creator or channel — don't start with `ILIKE` at all: run `tl channels find "<name>"` first (path 1). Fall back to `ILIKE` name variations only if the resolver finds nothing, and treat a clean "Not found" from the resolver as the likely answer (the channel probably isn't in the index) rather than a cue for ever-broader scans.
|
|
563
564
|
|
|
564
565
|
### Output flags
|
|
565
566
|
- `--json` — structured JSON output format (use this for parsing)
|
|
@@ -617,7 +618,7 @@ tl db pg "SELECT al.id, al.weighted_price, al.purchase_date, b.name AS brand
|
|
|
617
618
|
WHERE al.publish_status = 3
|
|
618
619
|
AND al.purchase_date >= '2026-01-01'
|
|
619
620
|
ORDER BY al.purchase_date DESC
|
|
620
|
-
LIMIT
|
|
621
|
+
LIMIT 10000 OFFSET 0" --json
|
|
621
622
|
```
|
|
622
623
|
|
|
623
624
|
### Brand sponsorship history — what channels does Nike sponsor?
|
|
@@ -769,7 +770,7 @@ tl db pg "SELECT al.id, c.channel_name, c.demographic_device_primary, c.demograp
|
|
|
769
770
|
WHERE al.publish_status = 3
|
|
770
771
|
AND c.demographic_device_primary = 'mobile'
|
|
771
772
|
AND c.demographic_usa_share >= 60
|
|
772
|
-
LIMIT
|
|
773
|
+
LIMIT 10000 OFFSET 0" --json
|
|
773
774
|
```
|
|
774
775
|
|
|
775
776
|
### "Find channels similar to one I know" (similarity recommender):
|
{thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl/references/elasticsearch-schema.md
RENAMED
|
@@ -21,19 +21,20 @@ Output flags: `--json`, `--csv`, `--md`, `--toon`. The CLI flattens hits into ro
|
|
|
21
21
|
|
|
22
22
|
See the output of `tl db es`" for the object schema. Highlights:
|
|
23
23
|
|
|
24
|
-
- **Top-level keys** accepted: `query`, `aggs`/`aggregations`, `sort`, `_source`, `size`, `from`, `track_total_hits`, `highlight`, `fields`, `min_score`, `
|
|
25
|
-
- `size` ≤
|
|
24
|
+
- **Top-level keys** accepted: `query`, `aggs`/`aggregations`, `sort`, `_source`, `size`, `from`, `search_after`, `track_total_hits`, `highlight`, `fields`, `min_score`, `timeout`, `collapse`, `post_filter`. Anything else (incl. `scroll`, `pit`, `runtime_mappings`, `knn`) is not accepted.
|
|
25
|
+
- `size` ≤ 10,000. `from + size` ≤ 10,000 — to page past 10,000 hits use `search_after` (see *Deep pagination* below), not `from`.
|
|
26
|
+
- `search_after` must be a non-empty array of ≤ 10 scalar sort values, requires an explicit `sort`, and `from` must be 0 or omitted.
|
|
26
27
|
- **Accepted query types** include `term`/`terms`/`match`/`bool`/`nested`/`range`/`exists`/`match_phrase`. `query_string`, `regexp`, `wildcard`, `fuzzy`, `more_like_this`, `has_child`, `has_parent`, `parent_id` are not accepted.
|
|
27
28
|
- **No scripts** — any key whose name contains `script` is not accepted.
|
|
28
29
|
- **At most one aggregation total** counted recursively (top-level + sub-agg = 2 = not accepted). Run multiple calls for multi-metric work.
|
|
29
30
|
|
|
30
31
|
### ElasticSearch document structure ("articles")
|
|
31
32
|
|
|
32
|
-
The `doc_type
|
|
33
|
+
The `doc_type` join field distinguishes video uploads ("articles") from channel data — channel docs are parents, article docs are their children. Filter with `{"term": {"doc_type": "article"}}` or `{"term": {"doc_type": "channel"}}`. ⚠️ Term-querying `doc_type.name` matches nothing — even though article docs' `_source` shows `doc_type` as an object with a `name` key, that's join-field syntax, not a queryable subfield.
|
|
33
34
|
|
|
34
35
|
#### Upload/video Fields (selected — 73 total)
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
Filter with `{"term": {"doc_type": "article"}}`.
|
|
37
38
|
|
|
38
39
|
| Field | Type | Description |
|
|
39
40
|
|-------|------|-------------|
|
|
@@ -80,7 +81,7 @@ Distinguished by `doc_type.name="article"`.
|
|
|
80
81
|
|
|
81
82
|
#### Channel Fields
|
|
82
83
|
|
|
83
|
-
|
|
84
|
+
Filter with `{"term": {"doc_type": "channel"}}`.
|
|
84
85
|
|
|
85
86
|
Contains a denormalized subset of the PostgreSQL channel data.
|
|
86
87
|
|
|
@@ -90,10 +91,10 @@ Contains a denormalized subset of the PostgreSQL channel data.
|
|
|
90
91
|
|-------|------|-------------|
|
|
91
92
|
| `name` | text | Channel name |
|
|
92
93
|
| `channel` | object | Channel metadata (nested on article docs) |
|
|
93
|
-
| `reach` | long | Subscriber count |
|
|
94
|
-
| `impression` | long |
|
|
95
|
-
| `impression_live` | long |
|
|
96
|
-
| `impression_shorts` | long |
|
|
94
|
+
| `reach` | long | Subscriber count. ⚠️ NOT ad-industry "reach" (unique audience exposed) — this is the channel's subscriber count. |
|
|
95
|
+
| `impression` | long | Projected views per longform video — forward-looking estimate. ⚠️ NOT actual views and NOT ad-industry "impressions"; for actual views see `total_views` / the video docs. |
|
|
96
|
+
| `impression_live` | long | Projected views per live stream (forward-looking estimate) |
|
|
97
|
+
| `impression_shorts` | long | Projected views per short (forward-looking estimate) |
|
|
97
98
|
| `is_tl_channel` | boolean | TPP partner channel |
|
|
98
99
|
| `is_active` | boolean | Channel is active |
|
|
99
100
|
| `media_selling_network_join_date` | date | MSN join date |
|
|
@@ -215,25 +216,30 @@ tl db es '{
|
|
|
215
216
|
|
|
216
217
|
For more dimensions, run multiple `tl db es` calls and join client-side.
|
|
217
218
|
|
|
218
|
-
### Deep pagination
|
|
219
|
+
### Deep pagination — `search_after`
|
|
220
|
+
|
|
221
|
+
`from + size` is capped at 10,000, and the stateful cursors (`scroll`, `pit`) are not accepted. To page past 10,000 hits, use the stateless `search_after` cursor: sort deterministically with a unique tiebreaker (the `id` field — not `_id`), then pass each response's `next_search_after` envelope value back as `search_after` in the next request, keeping the same `query` and `sort`:
|
|
219
222
|
|
|
220
223
|
```bash
|
|
221
|
-
# First page
|
|
224
|
+
# First page
|
|
222
225
|
tl db es '{
|
|
223
|
-
"size":
|
|
226
|
+
"size": 10000,
|
|
224
227
|
"query": {"term": {"channel.id": 12345}},
|
|
225
|
-
"sort": [{"publication_date": "
|
|
228
|
+
"sort": [{"publication_date": "asc"}, {"id": "asc"}]
|
|
226
229
|
}'
|
|
230
|
+
# → envelope includes "next_search_after": ["2025-09-14", "12345:abc123"]
|
|
227
231
|
|
|
228
|
-
#
|
|
232
|
+
# Next page — identical query & sort, plus the cursor
|
|
229
233
|
tl db es '{
|
|
230
|
-
"size":
|
|
234
|
+
"size": 10000,
|
|
231
235
|
"query": {"term": {"channel.id": 12345}},
|
|
232
|
-
"sort": [{"publication_date": "
|
|
236
|
+
"sort": [{"publication_date": "asc"}, {"id": "asc"}],
|
|
233
237
|
"search_after": ["2025-09-14", "12345:abc123"]
|
|
234
238
|
}'
|
|
235
239
|
```
|
|
236
240
|
|
|
241
|
+
Repeat until a page comes back short (`next_search_after` is absent on an empty page). Pages are not a consistent snapshot — concurrent indexing can occasionally duplicate or skip a boundary row, which is fine for analytics sweeps. Date-range windowing (filtering by `publication_date` ranges) remains a good alternative when you want resumable, idempotent slices.
|
|
242
|
+
|
|
237
243
|
## Text analyzer behavior
|
|
238
244
|
|
|
239
245
|
`text` fields on article docs (`title`, `summary`, `transcript`) appear to use the `standard` analyzer (tokenize + lowercase, no stemmer, no English-possessive filter), so inflections, plurals, and possessives are each indexed as distinct terms. For example: `bitcoin` (4,466,300) vs `bitcoins` (489,262). For stemming-style recall, expand the query side with a `bool.should` over the variants.
|
{thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl/references/firebolt-schema.md
RENAMED
|
@@ -130,7 +130,7 @@ tl db pg "SELECT al.id, al.article_id, s.channel_id
|
|
|
130
130
|
WHERE al.publish_status = 3
|
|
131
131
|
AND b.name = 'Nike'
|
|
132
132
|
AND al.article_id IS NOT NULL
|
|
133
|
-
LIMIT
|
|
133
|
+
LIMIT 10000 OFFSET 0" --json \
|
|
134
134
|
| jq -r '.results[] | "\(.channel_id):\(.article_id)"'
|
|
135
135
|
|
|
136
136
|
# Or videos via Elasticsearch content search
|
{thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl/references/postgres-schema.md
RENAMED
|
@@ -315,7 +315,7 @@ FROM thoughtleaders_adlink
|
|
|
315
315
|
WHERE publish_status = 3
|
|
316
316
|
AND purchase_date >= date_trunc('month', CURRENT_DATE)
|
|
317
317
|
ORDER BY purchase_date DESC
|
|
318
|
-
LIMIT
|
|
318
|
+
LIMIT 10000 OFFSET 0
|
|
319
319
|
```
|
|
320
320
|
|
|
321
321
|
**MSN channel joins this month:**
|
|
@@ -324,7 +324,7 @@ SELECT id, channel_name, media_selling_network_join_date
|
|
|
324
324
|
FROM thoughtleaders_channel
|
|
325
325
|
WHERE media_selling_network_join_date >= date_trunc('month', CURRENT_DATE)
|
|
326
326
|
ORDER BY media_selling_network_join_date DESC
|
|
327
|
-
LIMIT
|
|
327
|
+
LIMIT 10000 OFFSET 0
|
|
328
328
|
```
|
|
329
329
|
|
|
330
330
|
**A specific sponsorship info with brand and channel name:**
|
|
@@ -296,7 +296,9 @@ def find_cmd(
|
|
|
296
296
|
"""Resolve a string to a single channel.
|
|
297
297
|
|
|
298
298
|
Accepts:
|
|
299
|
-
- A partial channel name or slug (
|
|
299
|
+
- A partial channel name or slug (substring match, falling back to
|
|
300
|
+
fuzzy similarity — spacing/typo variants like "Deco Destiny" still
|
|
301
|
+
resolve a channel named "DecoDestiny")
|
|
300
302
|
- A YouTube channel URL (https://youtube.com/channel/UC...,
|
|
301
303
|
https://youtube.com/@handle, /c/<name>, /user/<name>)
|
|
302
304
|
- A raw YouTube channel ID (UC...) or @handle
|
|
@@ -308,8 +310,9 @@ def find_cmd(
|
|
|
308
310
|
`{"id": ..., "name": ...}`).
|
|
309
311
|
|
|
310
312
|
Ambiguous matches return an error with candidate IDs and names.
|
|
311
|
-
If the input is a YouTube URL
|
|
312
|
-
|
|
313
|
+
If the input is a YouTube URL — or a name that YouTube resolves to
|
|
314
|
+
a channel not yet in the index — it is queued for analysis; check
|
|
315
|
+
back in about 24 hours.
|
|
313
316
|
|
|
314
317
|
Examples:
|
|
315
318
|
tl channels find "MrBeast"
|
|
@@ -7,9 +7,12 @@ whenever either `gemini` or `codex` is on PATH. Behaviour follows the
|
|
|
7
7
|
OpenCode pattern (full per-skill tree copy, .tl-version stamp).
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
+
import filecmp
|
|
10
11
|
import json
|
|
12
|
+
import os
|
|
11
13
|
import shutil
|
|
12
14
|
import subprocess
|
|
15
|
+
import sys
|
|
13
16
|
from pathlib import Path
|
|
14
17
|
|
|
15
18
|
import typer
|
|
@@ -41,6 +44,19 @@ OPENCODE_SKILLS_DIR = Path.home() / ".config" / "opencode" / "skills"
|
|
|
41
44
|
AGENTS_SKILLS_DIR = Path.home() / ".agents" / "skills"
|
|
42
45
|
AGENTS_SKILLS_BINARIES = ("gemini", "codex")
|
|
43
46
|
|
|
47
|
+
# Personal-command shim that keeps the short `/tl` invocation working when the
|
|
48
|
+
# skills are provided (namespaced) by the installed plugin. Plugin skills and
|
|
49
|
+
# commands are always invoked as `/tl-cli:<name>`; this one-file pointer in
|
|
50
|
+
# ~/.claude/commands/ restores plain `/tl` without duplicating any skill
|
|
51
|
+
# content, so plugin updates flow through automatically.
|
|
52
|
+
TL_COMMAND_SHIM = """\
|
|
53
|
+
---
|
|
54
|
+
description: ThoughtLeaders data analyst — shortcut for the tl-cli plugin's tl skill
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
Invoke the `tl-cli:tl` skill with this request: $ARGUMENTS
|
|
58
|
+
"""
|
|
59
|
+
|
|
44
60
|
|
|
45
61
|
def _find_plugin_root() -> Path | None:
|
|
46
62
|
"""Locate the plugin assets directory.
|
|
@@ -60,9 +76,68 @@ def _find_plugin_root() -> Path | None:
|
|
|
60
76
|
return None
|
|
61
77
|
|
|
62
78
|
|
|
79
|
+
def _newest_desktop_claude(base: Path, exe: str) -> Path | None:
|
|
80
|
+
"""Pick the highest-version claude binary bundled by the Claude desktop app.
|
|
81
|
+
|
|
82
|
+
The desktop app keeps versioned copies under `<base>/<version>/<exe>`
|
|
83
|
+
(e.g. `%APPDATA%/Claude/claude-code/2.1.170/claude.exe`).
|
|
84
|
+
"""
|
|
85
|
+
if not base.is_dir():
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
def _version_key(d: Path) -> tuple[int, ...]:
|
|
89
|
+
try:
|
|
90
|
+
return tuple(int(part) for part in d.name.split("."))
|
|
91
|
+
except ValueError:
|
|
92
|
+
return (0,)
|
|
93
|
+
|
|
94
|
+
for version_dir in sorted(base.iterdir(), key=_version_key, reverse=True):
|
|
95
|
+
candidate = version_dir / exe
|
|
96
|
+
if candidate.is_file():
|
|
97
|
+
return candidate
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
63
101
|
def _find_claude_binary() -> str | None:
|
|
64
|
-
"""Find the claude binary on PATH.
|
|
65
|
-
|
|
102
|
+
"""Find the claude binary on PATH, falling back to known install locations.
|
|
103
|
+
|
|
104
|
+
On Windows the Claude Code installers often don't end up on the PATH of
|
|
105
|
+
the shell running `tl` (stale PATH, PowerShell-only profile changes), so
|
|
106
|
+
after `shutil.which` we probe the documented install targets directly:
|
|
107
|
+
the native installer (`~/.local/bin`), the npm global prefix, and the
|
|
108
|
+
binaries bundled with the Claude desktop app. When `tl` itself runs
|
|
109
|
+
inside a Claude Code session, `CLAUDE_CODE_EXECPATH` points straight at
|
|
110
|
+
the running binary and wins.
|
|
111
|
+
"""
|
|
112
|
+
env_exec = os.environ.get("CLAUDE_CODE_EXECPATH")
|
|
113
|
+
if env_exec and Path(env_exec).is_file():
|
|
114
|
+
return env_exec
|
|
115
|
+
found = shutil.which("claude")
|
|
116
|
+
if found:
|
|
117
|
+
return found
|
|
118
|
+
home = Path.home()
|
|
119
|
+
if sys.platform == "win32":
|
|
120
|
+
appdata = Path(os.environ.get("APPDATA", str(home / "AppData" / "Roaming")))
|
|
121
|
+
candidates = [
|
|
122
|
+
home / ".local" / "bin" / "claude.exe",
|
|
123
|
+
appdata / "npm" / "claude.cmd",
|
|
124
|
+
_newest_desktop_claude(appdata / "Claude" / "claude-code", "claude.exe"),
|
|
125
|
+
]
|
|
126
|
+
else:
|
|
127
|
+
desktop_base = (
|
|
128
|
+
home / "Library" / "Application Support" / "Claude" / "claude-code"
|
|
129
|
+
if sys.platform == "darwin"
|
|
130
|
+
else home / ".config" / "Claude" / "claude-code"
|
|
131
|
+
)
|
|
132
|
+
candidates = [
|
|
133
|
+
home / ".local" / "bin" / "claude",
|
|
134
|
+
home / ".claude" / "local" / "claude",
|
|
135
|
+
_newest_desktop_claude(desktop_base, "claude"),
|
|
136
|
+
]
|
|
137
|
+
for candidate in candidates:
|
|
138
|
+
if candidate is not None and candidate.is_file():
|
|
139
|
+
return str(candidate)
|
|
140
|
+
return None
|
|
66
141
|
|
|
67
142
|
|
|
68
143
|
def _run_claude(args: list[str], claude_bin: str) -> tuple[bool, str]:
|
|
@@ -156,6 +231,62 @@ def _install_standalone_skills(plugin_root: Path) -> int:
|
|
|
156
231
|
return count
|
|
157
232
|
|
|
158
233
|
|
|
234
|
+
def _install_command_shim() -> Path:
|
|
235
|
+
"""Write the `/tl` shim command to ~/.claude/commands/tl.md."""
|
|
236
|
+
CLAUDE_COMMANDS_DIR.mkdir(parents=True, exist_ok=True)
|
|
237
|
+
dst = CLAUDE_COMMANDS_DIR / "tl.md"
|
|
238
|
+
dst.write_text(TL_COMMAND_SHIM, encoding="utf-8")
|
|
239
|
+
return dst
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _trees_identical(a: Path, b: Path) -> bool:
|
|
243
|
+
"""True if two directory trees contain the same files with the same contents.
|
|
244
|
+
|
|
245
|
+
Python runtime artifacts (`__pycache__/`, `*.pyc`) are ignored — skills
|
|
246
|
+
that ship scripts grow them when the scripts run, and they shouldn't make
|
|
247
|
+
an otherwise-pristine copy look user-modified.
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
def _files(root: Path) -> list[Path]:
|
|
251
|
+
return sorted(
|
|
252
|
+
p.relative_to(root)
|
|
253
|
+
for p in root.rglob("*")
|
|
254
|
+
if p.is_file() and p.suffix != ".pyc" and "__pycache__" not in p.parts
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
a_files = _files(a)
|
|
258
|
+
if a_files != _files(b):
|
|
259
|
+
return False
|
|
260
|
+
return all(filecmp.cmp(a / rel, b / rel, shallow=False) for rel in a_files)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _remove_matching_standalone_skills(plugin_root: Path) -> tuple[int, int]:
|
|
264
|
+
"""Remove standalone copies in ~/.claude/skills/ that match the plugin's skills.
|
|
265
|
+
|
|
266
|
+
Earlier versions of `tl setup claude` copied every bundled skill into
|
|
267
|
+
~/.claude/skills/. Now that the plugin provides them, those copies are
|
|
268
|
+
redundant — but a copy is only deleted when its tree is byte-identical
|
|
269
|
+
to the bundled skill, so user-modified copies are never touched.
|
|
270
|
+
Returns (removed, kept_modified).
|
|
271
|
+
"""
|
|
272
|
+
removed = kept = 0
|
|
273
|
+
skills_src = plugin_root / "skills"
|
|
274
|
+
if not skills_src.is_dir():
|
|
275
|
+
return removed, kept
|
|
276
|
+
for skill_dir in skills_src.iterdir():
|
|
277
|
+
if not (skill_dir.is_dir() and (skill_dir / "SKILL.md").is_file()):
|
|
278
|
+
continue
|
|
279
|
+
standalone = CLAUDE_SKILLS_DIR / skill_dir.name
|
|
280
|
+
if not standalone.is_dir():
|
|
281
|
+
continue
|
|
282
|
+
if _trees_identical(skill_dir, standalone):
|
|
283
|
+
shutil.rmtree(standalone)
|
|
284
|
+
removed += 1
|
|
285
|
+
else:
|
|
286
|
+
kept += 1
|
|
287
|
+
return removed, kept
|
|
288
|
+
|
|
289
|
+
|
|
159
290
|
def _bundled_skill_blurbs(plugin_root: Path) -> list[tuple[str, str]]:
|
|
160
291
|
"""Read (name, tl-blurb) for each bundled skill, for the setup summary.
|
|
161
292
|
|
|
@@ -192,18 +323,19 @@ def _bundled_skill_blurbs(plugin_root: Path) -> list[tuple[str, str]]:
|
|
|
192
323
|
|
|
193
324
|
|
|
194
325
|
def _print_manual_instructions() -> None:
|
|
195
|
-
"""Print manual install instructions when
|
|
326
|
+
"""Print manual install instructions when the plugin couldn't be installed."""
|
|
196
327
|
console.print()
|
|
197
|
-
console.print("[yellow]Claude Code
|
|
328
|
+
console.print("[yellow]The Claude Code plugin could not be installed automatically.[/yellow]")
|
|
198
329
|
console.print()
|
|
199
|
-
console.print("
|
|
330
|
+
console.print(f"The skills were installed to {CLAUDE_SKILLS_DIR} instead — restart")
|
|
331
|
+
console.print("Claude Code and they will be available (e.g. [cyan]/tl[/cyan]).")
|
|
332
|
+
console.print()
|
|
333
|
+
console.print("To install the full plugin, run these commands inside Claude Code:")
|
|
200
334
|
console.print()
|
|
201
335
|
console.print(f" [cyan]/plugin marketplace add {MARKETPLACE_SOURCE}[/cyan]")
|
|
202
336
|
console.print(f" [cyan]/plugin install {PLUGIN_KEY}[/cyan]")
|
|
203
337
|
console.print()
|
|
204
|
-
console.print("
|
|
205
|
-
console.print()
|
|
206
|
-
console.print(f" [cyan]claude --plugin-dir /path/to/tl-cli[/cyan]")
|
|
338
|
+
console.print("then re-run [cyan]tl setup claude[/cyan] to clean up the standalone copies.")
|
|
207
339
|
|
|
208
340
|
|
|
209
341
|
@app.command("claude")
|
|
@@ -214,8 +346,10 @@ def setup_claude(
|
|
|
214
346
|
"""Install the TL CLI plugin for Claude Code.
|
|
215
347
|
|
|
216
348
|
Registers the ThoughtLeaders marketplace, installs the tl-cli plugin,
|
|
217
|
-
and
|
|
218
|
-
|
|
349
|
+
and adds a /tl shim command so the plugin's tl skill can be invoked
|
|
350
|
+
without the plugin namespace. Standalone skill copies in ~/.claude/skills
|
|
351
|
+
are only installed as a fallback when the plugin can't be installed;
|
|
352
|
+
unmodified copies left by earlier versions are removed.
|
|
219
353
|
|
|
220
354
|
Examples:
|
|
221
355
|
tl setup claude
|
|
@@ -249,10 +383,9 @@ def setup_claude(
|
|
|
249
383
|
# Check claude binary
|
|
250
384
|
claude_bin = _find_claude_binary()
|
|
251
385
|
if not claude_bin:
|
|
252
|
-
#
|
|
253
|
-
console.print(" [yellow]![/yellow] claude binary not found
|
|
386
|
+
# Fall back to standalone skill copies when the plugin can't be installed
|
|
387
|
+
console.print(" [yellow]![/yellow] claude binary not found")
|
|
254
388
|
_install_standalone_skills_step(plugin_root)
|
|
255
|
-
console.print()
|
|
256
389
|
_print_manual_instructions()
|
|
257
390
|
raise SystemExit(1)
|
|
258
391
|
|
|
@@ -271,6 +404,7 @@ def setup_claude(
|
|
|
271
404
|
_run_claude(["plugin", "marketplace", "update", MARKETPLACE_NAME], claude_bin)
|
|
272
405
|
else:
|
|
273
406
|
console.print(f" [red]✗[/red] Marketplace registration failed: {output}")
|
|
407
|
+
_install_standalone_skills_step(plugin_root)
|
|
274
408
|
_print_manual_instructions()
|
|
275
409
|
raise SystemExit(1)
|
|
276
410
|
|
|
@@ -284,12 +418,20 @@ def setup_claude(
|
|
|
284
418
|
console.print(f" [green]✓[/green] Plugin already installed: {PLUGIN_KEY}")
|
|
285
419
|
else:
|
|
286
420
|
console.print(f" [red]✗[/red] Plugin installation failed: {output}")
|
|
287
|
-
|
|
288
|
-
|
|
421
|
+
_install_standalone_skills_step(plugin_root)
|
|
422
|
+
_print_manual_instructions()
|
|
289
423
|
raise SystemExit(1)
|
|
290
424
|
|
|
291
|
-
# Step 3:
|
|
292
|
-
|
|
425
|
+
# Step 3: /tl shim command + cleanup of standalone copies from older versions
|
|
426
|
+
console.print("[bold]Installing /tl shortcut...[/bold]")
|
|
427
|
+
shim = _install_command_shim()
|
|
428
|
+
console.print(f" [green]✓[/green] /tl command installed: {shim}")
|
|
429
|
+
removed, kept = _remove_matching_standalone_skills(plugin_root)
|
|
430
|
+
if removed:
|
|
431
|
+
console.print(f" [green]✓[/green] Removed {removed} standalone skill(s) now provided by the plugin")
|
|
432
|
+
if kept:
|
|
433
|
+
console.print(f" [yellow]![/yellow] Kept {kept} modified standalone skill(s) in {CLAUDE_SKILLS_DIR}")
|
|
434
|
+
console.print(" These differ from the plugin's versions and shadow nothing — remove manually if unwanted.")
|
|
293
435
|
|
|
294
436
|
# Write version stamp
|
|
295
437
|
version_dir = CLAUDE_PLUGINS_DIR / "tl-cli"
|
|
@@ -303,20 +445,20 @@ def setup_claude(
|
|
|
303
445
|
blurbs = _bundled_skill_blurbs(plugin_root)
|
|
304
446
|
width = max((len(name) for name, _ in blurbs), default=0)
|
|
305
447
|
for name, blurb in blurbs:
|
|
306
|
-
console.print(f" [cyan]/{name}[/cyan]{' ' * (width - len(name))} — {blurb}")
|
|
448
|
+
console.print(f" [cyan]/{PLUGIN_NAME}:{name}[/cyan]{' ' * (width - len(name))} — {blurb}")
|
|
307
449
|
console.print()
|
|
308
|
-
console.print("Try it:")
|
|
450
|
+
console.print("Try it (restart Claude Code first):")
|
|
309
451
|
console.print(" [cyan]/tl Which channels did we sponsor in Q1?[/cyan]")
|
|
310
452
|
console.print()
|
|
311
453
|
console.print("[dim]To update, run: tl setup claude[/dim]")
|
|
312
454
|
|
|
313
455
|
|
|
314
456
|
def _install_standalone_skills_step(plugin_root: Path) -> None:
|
|
315
|
-
"""Install standalone skills and print status."""
|
|
316
|
-
console.print("[bold]Installing skills
|
|
457
|
+
"""Install standalone skills (plugin-less fallback) and print status."""
|
|
458
|
+
console.print("[bold]Installing standalone skills (plugin fallback)...[/bold]")
|
|
317
459
|
count = _install_standalone_skills(plugin_root)
|
|
318
460
|
if count > 0:
|
|
319
|
-
console.print(f" [green]✓[/green] Installed {count} skills/commands to
|
|
461
|
+
console.print(f" [green]✓[/green] Installed {count} skills/commands to {CLAUDE_HOME}")
|
|
320
462
|
else:
|
|
321
463
|
console.print(" [yellow]![/yellow] No skills found to install")
|
|
322
464
|
|
|
@@ -362,9 +504,19 @@ def _setup_noninteractive(fmt: str = "json") -> None:
|
|
|
362
504
|
result["marketplace_registered"] = False
|
|
363
505
|
result["plugin_installed"] = False
|
|
364
506
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
507
|
+
if result["plugin_installed"]:
|
|
508
|
+
# Plugin provides the skills; install the /tl shim and clean up
|
|
509
|
+
# unmodified standalone copies left by earlier versions.
|
|
510
|
+
_install_command_shim()
|
|
511
|
+
removed, kept = _remove_matching_standalone_skills(plugin_root)
|
|
512
|
+
result["command_shim_installed"] = True
|
|
513
|
+
result["standalone_skills_installed"] = 0
|
|
514
|
+
result["standalone_skills_removed"] = removed
|
|
515
|
+
result["standalone_skills_kept_modified"] = kept
|
|
516
|
+
else:
|
|
517
|
+
# Fallback: standalone skill copies so Claude Code still gets /tl
|
|
518
|
+
result["command_shim_installed"] = False
|
|
519
|
+
result["standalone_skills_installed"] = _install_standalone_skills(plugin_root)
|
|
368
520
|
|
|
369
521
|
# Write version stamp
|
|
370
522
|
version_dir = CLAUDE_PLUGINS_DIR / "tl-cli"
|
|
@@ -22,6 +22,7 @@ import urllib.request
|
|
|
22
22
|
from pathlib import Path
|
|
23
23
|
|
|
24
24
|
from tl_cli import __version__
|
|
25
|
+
from tl_cli.commands.setup import _find_claude_binary
|
|
25
26
|
|
|
26
27
|
CACHE_DIR = Path.home() / ".cache" / "tl-cli"
|
|
27
28
|
CACHE_PATH = CACHE_DIR / "version-check.json"
|
|
@@ -210,8 +211,12 @@ def _spawn_detached_windows_upgrade(cmd: list[str], latest: str) -> bool:
|
|
|
210
211
|
"set RC=%ERRORLEVEL%\r\n"
|
|
211
212
|
f'echo [tl-cli upgrader] exit code %RC% >> "{log_path}"\r\n'
|
|
212
213
|
"if not %RC%==0 goto end\r\n"
|
|
214
|
+
"set CLAUDE_FOUND=0\r\n"
|
|
213
215
|
"where claude >NUL 2>&1\r\n"
|
|
214
|
-
"if not errorlevel 1
|
|
216
|
+
"if not errorlevel 1 set CLAUDE_FOUND=1\r\n"
|
|
217
|
+
'if exist "%USERPROFILE%\\.local\\bin\\claude.exe" set CLAUDE_FOUND=1\r\n'
|
|
218
|
+
'if exist "%APPDATA%\\npm\\claude.cmd" set CLAUDE_FOUND=1\r\n'
|
|
219
|
+
"if %CLAUDE_FOUND%==1 (\r\n"
|
|
215
220
|
f' echo [tl-cli upgrader] re-syncing claude skills >> "{log_path}"\r\n'
|
|
216
221
|
f' tl setup claude --json >> "{log_path}" 2>&1\r\n'
|
|
217
222
|
")\r\n"
|
|
@@ -411,7 +416,10 @@ def _resync_integrations() -> None:
|
|
|
411
416
|
("gemini", "gemini"),
|
|
412
417
|
("codex", "codex"),
|
|
413
418
|
):
|
|
414
|
-
|
|
419
|
+
# claude is often installed off-PATH (native installer, npm prefix);
|
|
420
|
+
# use the same probing the setup command uses.
|
|
421
|
+
found = _find_claude_binary() if binary == "claude" else shutil.which(binary)
|
|
422
|
+
if not found:
|
|
415
423
|
continue
|
|
416
424
|
print(f"[tl-cli] re-syncing {tool} skills…", file=sys.stderr)
|
|
417
425
|
try:
|