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.
Files changed (110) hide show
  1. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/.claude-plugin/plugin.json +1 -1
  2. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/PKG-INFO +1 -1
  3. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/pyproject.toml +1 -1
  4. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl/SKILL.md +13 -12
  5. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl/references/elasticsearch-schema.md +22 -16
  6. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl/references/firebolt-schema.md +1 -1
  7. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl/references/postgres-schema.md +2 -2
  8. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/__init__.py +1 -1
  9. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/channels.py +6 -3
  10. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/setup.py +177 -25
  11. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/self_update.py +10 -2
  12. thoughtleaders_cli-0.7.12/tests/test_setup.py +193 -0
  13. thoughtleaders_cli-0.7.10/tests/test_setup.py +0 -41
  14. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/.claude-plugin/marketplace.json +0 -0
  15. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/.github/workflows/python-publish.yml +0 -0
  16. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/.gitignore +0 -0
  17. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/AGENTS.md +0 -0
  18. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/API.md +0 -0
  19. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/CLAUDE.md +0 -0
  20. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/LICENSE +0 -0
  21. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/README.md +0 -0
  22. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/agents/tl-analyst.md +0 -0
  23. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/agents/youtube-comment-classifier.md +0 -0
  24. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/hooks/hooks.json +0 -0
  25. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/hooks/scripts/load-tl-skill.mjs +0 -0
  26. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/hooks/scripts/post-usage.sh +0 -0
  27. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/hooks/scripts/pre-check.sh +0 -0
  28. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl/references/business-glossary.md +0 -0
  29. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-channel-authenticity/.gitignore +0 -0
  30. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-channel-authenticity/SKILL.md +0 -0
  31. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-channel-authenticity/references/comment-patterns.md +0 -0
  32. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-channel-authenticity/references/peer-cohort.md +0 -0
  33. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-channel-authenticity/references/red-flags.md +0 -0
  34. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-channel-authenticity/references/scoring.md +0 -0
  35. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-channel-authenticity/scripts/_io_utf8.py +0 -0
  36. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-channel-authenticity/scripts/analyze_channel.py +0 -0
  37. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-channel-authenticity/scripts/anomaly_detector.py +0 -0
  38. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-channel-authenticity/scripts/comment_analyzer.py +0 -0
  39. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-channel-authenticity/scripts/comment_scraper.py +0 -0
  40. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-channel-authenticity/scripts/engagement_ratios.py +0 -0
  41. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-channel-authenticity/scripts/peer_cohort.py +0 -0
  42. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-channel-authenticity/scripts/report.py +0 -0
  43. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-channel-authenticity/scripts/resolve_channel.py +0 -0
  44. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-channel-authenticity/scripts/score.py +0 -0
  45. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-channel-authenticity/scripts/tl_cli.py +0 -0
  46. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-channel-authenticity/scripts/video_integrity.py +0 -0
  47. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-channel-authenticity/scripts/view_curves.py +0 -0
  48. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-keyword-research/SKILL.md +0 -0
  49. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-keyword-research/scripts/probe.py +0 -0
  50. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-save-report/SKILL.md +0 -0
  51. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-save-report/references/columns_brands.md +0 -0
  52. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-save-report/references/columns_channels.md +0 -0
  53. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-save-report/references/columns_content.md +0 -0
  54. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-save-report/references/columns_sponsorships.md +0 -0
  55. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-save-report/references/intelligence_filterset_schema.json +0 -0
  56. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-save-report/references/intelligence_widget_schema.json +0 -0
  57. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-save-report/references/report_glossary.md +0 -0
  58. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-save-report/references/sortable_columns.json +0 -0
  59. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-save-report/references/sponsorship_filterset_schema.json +0 -0
  60. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-save-report/references/sponsorship_widget_schema.json +0 -0
  61. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-save-report/references/widgets.md +0 -0
  62. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-top-partnerships/SKILL.md +0 -0
  63. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-top-partnerships/scripts/top_partnerships.py +0 -0
  64. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-views-guarantee/SKILL.md +0 -0
  65. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/skills/tl-views-guarantee/scripts/vg.py +0 -0
  66. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/_completions.py +0 -0
  67. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/_typer_utils.py +0 -0
  68. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/auth/__init__.py +0 -0
  69. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/auth/commands.py +0 -0
  70. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/auth/login.py +0 -0
  71. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/auth/pkce.py +0 -0
  72. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/auth/token_store.py +0 -0
  73. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/client/__init__.py +0 -0
  74. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/client/errors.py +0 -0
  75. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/client/http.py +0 -0
  76. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/__init__.py +0 -0
  77. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/_comments_common.py +0 -0
  78. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/balance.py +0 -0
  79. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/brands.py +0 -0
  80. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/bulk_import.py +0 -0
  81. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/changelog.py +0 -0
  82. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/credits.py +0 -0
  83. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/db.py +0 -0
  84. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/deals.py +0 -0
  85. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/describe.py +0 -0
  86. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/doctor.py +0 -0
  87. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/matches.py +0 -0
  88. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/proposals.py +0 -0
  89. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/recommender.py +0 -0
  90. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/reports.py +0 -0
  91. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/schema.py +0 -0
  92. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/snapshots.py +0 -0
  93. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/sponsorships.py +0 -0
  94. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/uploads.py +0 -0
  95. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/commands/whoami.py +0 -0
  96. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/config.py +0 -0
  97. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/filters.py +0 -0
  98. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/hints.py +0 -0
  99. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/main.py +0 -0
  100. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/output/__init__.py +0 -0
  101. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/src/tl_cli/output/formatter.py +0 -0
  102. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/tests/__init__.py +0 -0
  103. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/tests/test_auth.py +0 -0
  104. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/tests/test_describe.py +0 -0
  105. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/tests/test_filters.py +0 -0
  106. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/tests/test_http_auth.py +0 -0
  107. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/tests/test_output.py +0 -0
  108. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/tests/test_reports.py +0 -0
  109. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/tests/test_sponsorships.py +0 -0
  110. {thoughtleaders_cli-0.7.10 → thoughtleaders_cli-0.7.12}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tl-cli",
3
- "version": "0.7.10",
3
+ "version": "0.7.12",
4
4
  "description": "ThoughtLeaders CLI — query sponsorship deals, channels, brands, uploads, and intelligence from the terminal",
5
5
  "author": {
6
6
  "name": "ThoughtLeaders",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: thoughtleaders-cli
3
- Version: 0.7.10
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "thoughtleaders-cli"
7
- version = "0.7.10"
7
+ version = "0.7.12"
8
8
  description = "ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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 500 OFFSET 0" --json \
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":500,"query":{"term":{"channel.id":5607}},"_source":["id","transcript"]}' --json | rg -o "NordVPN[^.]*"
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 500 OFFSET 0" --csv > deals.csv
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. The maximum number of rows per page is present in the output of `whoami`.
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
- - **`impression`** (on channels) — projected views per video on that channel. Forward-looking estimate. May be null when not yet computed.
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 want to explore certain topics, use the recommender commands. If it's more about filtering, construct a query for PG or ES.
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` | **Unavailable** via raw `scroll` and `pit` aren't allowlisted; `search_after` is allowed but `from` is still capped. | Use `search_after` with `sort` to walk past 10k. For huge sweeps, narrow with `publication_date` ranges. |
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
- If finding channels and brands fail, try variation on the name with or without whitespace.
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 500 OFFSET 0" --json
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 500 OFFSET 0" --json
773
+ LIMIT 10000 OFFSET 0" --json
773
774
  ```
774
775
 
775
776
  ### "Find channels similar to one I know" (similarity recommender):
@@ -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`, `search_after`, `timeout`, `collapse`, `post_filter`. Anything else (incl. `scroll`, `pit`, `runtime_mappings`, `knn`) is not accepted.
25
- - `size` ≤ 500. `from + size` ≤ 10,000. Use `search_after` to page deeper.
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.name` field in ES objects determins between records for video uploads and for channel data.
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
- Distinguished by `doc_type.name="article"`.
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
- Distinguished by `doc_type.name="channel"`.
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 | View count |
95
- | `impression_live` | long | Live view count |
96
- | `impression_shorts` | long | Shorts view count |
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 via `search_after`
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 — sort must include a tiebreaker on _id for stability
224
+ # First page
222
225
  tl db es '{
223
- "size": 500,
226
+ "size": 10000,
224
227
  "query": {"term": {"channel.id": 12345}},
225
- "sort": [{"publication_date": "desc"}, {"_id": "asc"}]
228
+ "sort": [{"publication_date": "asc"}, {"id": "asc"}]
226
229
  }'
230
+ # → envelope includes "next_search_after": ["2025-09-14", "12345:abc123"]
227
231
 
228
- # Subsequent pagespass the last hit's sort values as search_after
232
+ # Next pageidentical query & sort, plus the cursor
229
233
  tl db es '{
230
- "size": 500,
234
+ "size": 10000,
231
235
  "query": {"term": {"channel.id": 12345}},
232
- "sort": [{"publication_date": "desc"}, {"_id": "asc"}],
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.
@@ -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 500 OFFSET 0" --json \
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
@@ -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 500 OFFSET 0
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 500 OFFSET 0
327
+ LIMIT 10000 OFFSET 0
328
328
  ```
329
329
 
330
330
  **A specific sponsorship info with brand and channel name:**
@@ -1,3 +1,3 @@
1
1
  """ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence."""
2
2
 
3
- __version__ = "0.7.10"
3
+ __version__ = "0.7.12"
@@ -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 (ILIKE match)
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 and no channel matches, the URL is
312
- queued for scraping; retry the command later.
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
- return shutil.which("claude")
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 claude binary is not found."""
326
+ """Print manual install instructions when the plugin couldn't be installed."""
196
327
  console.print()
197
- console.print("[yellow]Claude Code binary not found on PATH.[/yellow]")
328
+ console.print("[yellow]The Claude Code plugin could not be installed automatically.[/yellow]")
198
329
  console.print()
199
- console.print("Install Claude Code first, then run these commands inside Claude Code:")
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("Or start Claude Code with the plugin loaded directly:")
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 copies skills/commands to ~/.claude/ for short /tl invocation.
218
- If the claude binary is not on PATH, prints manual instructions.
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
- # Still install standalone skills even without claude binary
253
- console.print(" [yellow]![/yellow] claude binary not found on PATH")
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
- console.print(" Try running inside Claude Code:")
288
- console.print(f" [cyan]/plugin install {PLUGIN_KEY}[/cyan]")
421
+ _install_standalone_skills_step(plugin_root)
422
+ _print_manual_instructions()
289
423
  raise SystemExit(1)
290
424
 
291
- # Step 3: Install standalone skills for short /tl invocation
292
- _install_standalone_skills_step(plugin_root)
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 for /tl shortcut...[/bold]")
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 ~/.claude/")
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
- # Always install standalone skills
366
- count = _install_standalone_skills(plugin_root)
367
- result["standalone_skills_installed"] = count
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 (\r\n"
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
- if not shutil.which(binary):
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: