thoughtleaders-cli 0.7.1__py3-none-any.whl → 0.7.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. {thoughtleaders_cli-0.7.1.dist-info → thoughtleaders_cli-0.7.2.dist-info}/METADATA +3 -2
  2. {thoughtleaders_cli-0.7.1.dist-info → thoughtleaders_cli-0.7.2.dist-info}/RECORD +33 -30
  3. {thoughtleaders_cli-0.7.1.dist-info → thoughtleaders_cli-0.7.2.dist-info}/WHEEL +1 -1
  4. tl_cli/__init__.py +1 -1
  5. tl_cli/_plugin/.claude-plugin/plugin.json +1 -1
  6. tl_cli/_plugin/skills/tl/SKILL.md +2 -0
  7. tl_cli/_plugin/skills/tl-keyword-research/SKILL.md +1 -0
  8. tl_cli/_plugin/skills/tl-save-report/SKILL.md +2 -0
  9. tl_cli/_plugin/skills/tl-top-partnerships/SKILL.md +101 -0
  10. tl_cli/_plugin/skills/tl-top-partnerships/scripts/top_partnerships.py +335 -0
  11. tl_cli/_typer_utils.py +23 -0
  12. tl_cli/auth/commands.py +2 -1
  13. tl_cli/commands/balance.py +2 -1
  14. tl_cli/commands/brands.py +2 -1
  15. tl_cli/commands/channels.py +2 -1
  16. tl_cli/commands/credits.py +2 -1
  17. tl_cli/commands/db.py +2 -1
  18. tl_cli/commands/deals.py +2 -1
  19. tl_cli/commands/describe.py +2 -1
  20. tl_cli/commands/doctor.py +2 -1
  21. tl_cli/commands/matches.py +2 -1
  22. tl_cli/commands/proposals.py +2 -1
  23. tl_cli/commands/recommender.py +2 -1
  24. tl_cli/commands/reports.py +2 -1
  25. tl_cli/commands/schema.py +2 -1
  26. tl_cli/commands/setup.py +2 -1
  27. tl_cli/commands/snapshots.py +2 -1
  28. tl_cli/commands/sponsorships.py +2 -1
  29. tl_cli/commands/uploads.py +2 -1
  30. tl_cli/commands/whoami.py +2 -1
  31. tl_cli/main.py +2 -0
  32. {thoughtleaders_cli-0.7.1.dist-info → thoughtleaders_cli-0.7.2.dist-info}/entry_points.txt +0 -0
  33. {thoughtleaders_cli-0.7.1.dist-info → thoughtleaders_cli-0.7.2.dist-info}/licenses/LICENSE +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: thoughtleaders-cli
3
- Version: 0.7.1
3
+ Version: 0.7.2
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
@@ -68,7 +68,7 @@ ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligenc
68
68
  - [duckdb](https://duckdb.org/)
69
69
  - [yt-dlp](https://github.com/yt-dlp/yt-dlp)
70
70
 
71
- For automated installs on MacOS, prefer installing Homebrew and use the pipx package manager, but ask the user if they have the admin access (sudo) password first. If not, proceed by using `uv` and installing everything locally.
71
+ For automated installs on MacOS, prefer installing Python and the requirements on Homebrew and use the pipx package manager, but ask the user if they have the admin access (sudo) password first. If not, proceed by using `uv`.
72
72
 
73
73
  ## Install
74
74
 
@@ -280,6 +280,7 @@ The plugin ships several focused skills (installed by all the `tl setup *` comma
280
280
  - **`tl-report-builder`** — builds TL reports (channels / brands / sponsorships / videos) from natural-language requests. Produces an in-chat preview by default; saves a real campaign when the user is explicit ("save", "create the report").
281
281
  - **`tl-import`** / **`bulk-import`** — superuser-only; bulk-add or exclude lists of channels, brands, videos, or sponsorships against a report.
282
282
  - **`tl-views-guarantee`** — sizes a multi-video sponsorship buy for a channel, returning the video bundle size, views guarantee, and likelihood to hit.
283
+ - **`tl-top-partnerships`** — brand-user performance report. Ranks a brand's sold sponsorships by live eCPM vs the sold-date projection, aggregates per channel, and delivers a two-tab Google Sheet ("By Deal" / "By Channel") via `gws`. Uses only public CLI commands (`tl whoami`, `tl sponsorships list`).
283
284
 
284
285
  ## Output Formats
285
286
 
@@ -1,12 +1,13 @@
1
- tl_cli/__init__.py,sha256=qheEAJU-hUoE234IhRTFfabmqGg2OSLfwFC7YP2Qzbo,112
1
+ tl_cli/__init__.py,sha256=w380YYlqGtwTYG78LJyZu5ljV8MmIBzKLk-R9Kq8Apw,112
2
2
  tl_cli/_completions.py,sha256=kOyEUqC26vbYvyXWi513WX8fF73qQLR5WWuRSe_wqyk,164
3
+ tl_cli/_typer_utils.py,sha256=ZiZsCVmEznPvBw-dYbr3tu3zWZ0iN6kjoQmK3gMqD28,860
3
4
  tl_cli/config.py,sha256=UV_OYTXuQnAIqbi_oVCXx0hhIdZWR678RRapVv51UwQ,1859
4
5
  tl_cli/filters.py,sha256=jymgt21vl2d67yTXD_ceRIxTn6H6OYI5-QvQyE4Y4z0,2937
5
6
  tl_cli/hints.py,sha256=cT8kuDtkAZqwXkc2RV0Yg_abofK-g9UiXwTTBunX78U,1557
6
- tl_cli/main.py,sha256=mCPERzboCOJXa_uMix-qS-1eENpUH89Goi8VQO20tg0,5721
7
+ tl_cli/main.py,sha256=A_8b2SQjBKATxrjO7AGC5Ab1QWlP35gGo4TWzYZtlOM,5806
7
8
  tl_cli/self_update.py,sha256=akXOWYgBX2otyaVlx9CDl04gG2s_hYigE2Vkpubt0SA,18302
8
9
  tl_cli/auth/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
- tl_cli/auth/commands.py,sha256=PfTSb1vE0QY5VD0a3o8hI7ofE12pxKLwlMRQkWRI5k0,6204
10
+ tl_cli/auth/commands.py,sha256=NtM5WGnReXrpZ3bysNudUYT_TcRRPE0Ho1EwbRgM_Ws,6285
10
11
  tl_cli/auth/finalize.py,sha256=74x8U39dK4nhEnIUMKhX5rNsn1Qjjm8or1n1nUH0SbQ,3346
11
12
  tl_cli/auth/login.py,sha256=6Jhfw7_eXGxZvUfNP33AZPRmnqmu_scvgt4AcOydsrE,10665
12
13
  tl_cli/auth/pkce.py,sha256=4Q6Ip-TeZFNG9c3swXNi4gH7mdMkltKa62gZZNybt8U,658
@@ -16,37 +17,37 @@ tl_cli/client/errors.py,sha256=KcTIieHtiEeBBDXTtEBSWsMcx2qaQskMyc_2_0l9WM4,2597
16
17
  tl_cli/client/http.py,sha256=pUJYYuT0q92_SwqCuMj3ozBthvchKuKL9v9U_eCQv20,4164
17
18
  tl_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
19
  tl_cli/commands/_comments_common.py,sha256=x91WPSpDdV8AwE8Mwx4VtZQKWXBufsnBnC1lFD0Tzys,4522
19
- tl_cli/commands/balance.py,sha256=zUrlWUAcsgq1L0Z1dZzvmwSC-sOQd-WlT9j5zGqzZpc,2651
20
- tl_cli/commands/brands.py,sha256=eRN7XcOTjnLiKgJx8I2s1ar_opcIDZhbglypVY-qkbc,12362
20
+ tl_cli/commands/balance.py,sha256=JHSmKdAUNtd-9zlKZhit9_BmhHXrEk0kQ-oSlQ0Eo6Y,2732
21
+ tl_cli/commands/brands.py,sha256=rvYRrhkDhHDlvFpWrf8TtGbBt8KyNxHcVodjjFNpmHY,12443
21
22
  tl_cli/commands/bulk_import.py,sha256=d4y1k_lD52LPJcCqXxEmyHIqcIwomZgbjqs1_QxPjeQ,4536
22
23
  tl_cli/commands/changelog.py,sha256=D1PtDdHpawTlWqUHjKzVmv9yXLSU915UVmI3dZzEwyA,4241
23
- tl_cli/commands/channels.py,sha256=XEmdMNXde5oOL4LhpVPVx2MYmEKSrfvkcBXgBUECPLg,17136
24
- tl_cli/commands/credits.py,sha256=JFZCZ__v5KZdsGvJ3ypZVer8I2nGVQRu2VIXu7vlN4Q,7227
25
- tl_cli/commands/db.py,sha256=K1_EAokTMdwjbzEdkOpOQVtdErSJW_p6k_7cCdYKpvE,4972
26
- tl_cli/commands/deals.py,sha256=9-boF_HVO6xQ5ULPxatWRxbCIwMjzKCbCUb12tEwAe0,2093
27
- tl_cli/commands/describe.py,sha256=6rN-s2NY2kFkgxuOsJZoB-furb0iZq1WXs9R-mHJFAg,12151
28
- tl_cli/commands/doctor.py,sha256=DZvfQN8LRSg3CsnUyI3nlTqTY2za4EHISdavBAWwKzU,8943
29
- tl_cli/commands/matches.py,sha256=C4_kWRpTGu2lduDmrFpgi-D0Q4MuPc_3Cc76Ud2_i1g,2805
30
- tl_cli/commands/proposals.py,sha256=MWohD2UdEVRIVzYX-5uKlKE8U56nQu4OJMHbiaXuM_s,2837
31
- tl_cli/commands/recommender.py,sha256=Yw7WaKW5OBh5rCkxURGbeuHfp1vCA37qE0fBF_bX53M,18841
32
- tl_cli/commands/reports.py,sha256=rcJEjGEunf-6j5kNwQBNP2GKoJsufiyRBoExV0CQA70,22462
33
- tl_cli/commands/schema.py,sha256=ejL_7zln_Hh8Z43ZxA7VsjndLZNL82GCLH8VHTTZbVQ,5904
34
- tl_cli/commands/setup.py,sha256=bY7ZpkJnx3YuoF-M57djilFW8rC_U6XIg1CLCozxjKI,21839
35
- tl_cli/commands/snapshots.py,sha256=7zl8iQhKqmGSVXKOP39Q1jZV8N1boy7cQoKQHLJ_ROE,3347
36
- tl_cli/commands/sponsorships.py,sha256=VBDrJ84hLFcdMFFy3nBrRoWB5RR1LrJtV5zMsB5gl5c,10319
37
- tl_cli/commands/uploads.py,sha256=mUvsUX5H7_rf1Xko0jmo5yChz5vXubojWWnGMfVfidM,1445
38
- tl_cli/commands/whoami.py,sha256=T9Bdr794Hlmu2d_1rY52_QSzXPvS3RTJc5YYO4Axhd0,7785
24
+ tl_cli/commands/channels.py,sha256=EgwTzweKsQEerBTwiWGCP1myCTW3KQFu4Om2j4Htfzk,17217
25
+ tl_cli/commands/credits.py,sha256=2xCht2e420LmaFBKNdKoMz8GlTh31qSWSlJAnVzoZic,7308
26
+ tl_cli/commands/db.py,sha256=rdIQrxT7sdrPEnBbByNHvPr2X6iIg-wb19X9bWYwDRc,5053
27
+ tl_cli/commands/deals.py,sha256=ZK9yneInsC6DXoCPS65oyLoVR0eRW1xdRlEN7oRp1pc,2174
28
+ tl_cli/commands/describe.py,sha256=Ox2B1hoVuJ6pJc_x5BJjAhEJhlae-el9zwoJKutw3X8,12232
29
+ tl_cli/commands/doctor.py,sha256=KUKglwhMc7B26XXy_3M0LkHu7wqfFO5T0YPHO1SH1VY,9024
30
+ tl_cli/commands/matches.py,sha256=K5o6B8FLECp7825dU4W3X8n-wuXvGJz57xpQPXeXQ-0,2886
31
+ tl_cli/commands/proposals.py,sha256=khsjorluIfgrJ22DiwzIAFcYD4JbirjkOBz1KuQ0Sdk,2918
32
+ tl_cli/commands/recommender.py,sha256=DIRvnSbV2TwvVUgA5luGJ7uQUOjHx2CULFsZVaqUYw4,18922
33
+ tl_cli/commands/reports.py,sha256=1QDJN6FrUmWCuvaMxN884_bVdy-l7anBuS2jkCZF1VQ,22543
34
+ tl_cli/commands/schema.py,sha256=GCBEE4fDatQhVasLKKr7bkGhELRZ0scYm_hUCbDYmuA,5985
35
+ tl_cli/commands/setup.py,sha256=QST6DCSxJHLFNX1UUJHwZ3hTDTnySATaV2Tc2JsdYYY,21920
36
+ tl_cli/commands/snapshots.py,sha256=mWxnZI_UBzbZZHsA3uP0Q7Gt2XsLLoRD5dRNGt-mGUE,3428
37
+ tl_cli/commands/sponsorships.py,sha256=MWjyaReMMhmVKAbrCBCVw_J6dzkML_TIo_2kyPOYQOM,10400
38
+ tl_cli/commands/uploads.py,sha256=Tf9tqAEm9FGe3A7sr_EDX9OzdNInCmrWNr10wWGuMUo,1526
39
+ tl_cli/commands/whoami.py,sha256=aUXwBRwh1vAGrvz8CKGfHYtEOKJCIDfwrGesKAwYZMk,7866
39
40
  tl_cli/output/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
40
41
  tl_cli/output/formatter.py,sha256=pqlKmb2nZ1Z2e1A9m8l5mgVemJinVAP4in1tUzFWHno,22522
41
42
  tl_cli/_plugin/.claude-plugin/marketplace.json,sha256=l56PMmyjfGXNGlV30wRyOAe74B6gJNCVNCxgsBbSNxc,446
42
- tl_cli/_plugin/.claude-plugin/plugin.json,sha256=g5iR6MaPe2-Bp5koMopkkYWL7N53ZoAeCjAecU6v1MM,466
43
+ tl_cli/_plugin/.claude-plugin/plugin.json,sha256=_Im3uBChUbEhtgMa7p_Efg10yDoSSS0GtrAKAL_E6ME,466
43
44
  tl_cli/_plugin/agents/tl-analyst.md,sha256=6J3X3NANkWg6OOUCvNirkN4ulIk80KSumPncDUBt75E,6761
44
45
  tl_cli/_plugin/agents/youtube-comment-classifier.md,sha256=S5lr_htA98FIX0su8FJ2ntiHfbdK8OB2NQKC4lTnQcw,2178
45
46
  tl_cli/_plugin/hooks/hooks.json,sha256=FSWibw1xAjA-suFV3fR8btIb2kQ82LQ08otTr-NpmFw,835
46
47
  tl_cli/_plugin/hooks/scripts/load-tl-skill.mjs,sha256=EBsyZ-caei-CBJsRtqzJXJs_20O3H22MuVmDpu96umo,805
47
48
  tl_cli/_plugin/hooks/scripts/post-usage.sh,sha256=WVvZLkZik6lbeZ20Kh-wgm4JkRFHFN0Uwl4C8S3Y0sY,759
48
49
  tl_cli/_plugin/hooks/scripts/pre-check.sh,sha256=E9KeuXy6yeHEBOnOFW4hDW-Et-Dbp1Oh--3WXKfOX78,898
49
- tl_cli/_plugin/skills/tl/SKILL.md,sha256=ZkaaPZZGuJhEHaXp2CsxMDb9BvyKrO9dyC8K40E9DUM,58115
50
+ tl_cli/_plugin/skills/tl/SKILL.md,sha256=fKxHgoe0oX1vFwVFUTzF95h4fq5fAmFlodqZr7cdbeA,58174
50
51
  tl_cli/_plugin/skills/tl/references/business-glossary.md,sha256=FCS-qBOGpdJCmHdglRGRjAuTQAtzpxJNpMkEWThuvlI,17779
51
52
  tl_cli/_plugin/skills/tl/references/elasticsearch-schema.md,sha256=OpHvixZ8UcZYJd8GdwgumryFyKwPAxj3AvPkl1QreMY,9316
52
53
  tl_cli/_plugin/skills/tl/references/firebolt-schema.md,sha256=KagpSWWEWIRfsAWz271PvAqVbSPvWLoogWhCA_XFSZw,10642
@@ -71,7 +72,7 @@ tl_cli/_plugin/skills/tl-channel-authenticity/scripts/tl_cli.py,sha256=lzW3l9TBt
71
72
  tl_cli/_plugin/skills/tl-channel-authenticity/scripts/video_integrity.py,sha256=Pj9cZkFHX-fwfmkSjicy_B04fQ3TW3gLrGliHwCwX5g,11120
72
73
  tl_cli/_plugin/skills/tl-channel-authenticity/scripts/view_curves.py,sha256=wx4cgi_HSOBDEy9bI_8RGnAFpY8q8xHOlF7DYojmi0c,4447
73
74
  tl_cli/_plugin/skills/tl-import/SKILL.md,sha256=k0-2yCpQ75yXk_wGrgSkInQeUeUp6-lne3vRXGcef8A,20245
74
- tl_cli/_plugin/skills/tl-keyword-research/SKILL.md,sha256=YPWG4ILsPnCMRuePlkY2oqvMDvIqBkwEA8DgsbpfepQ,10425
75
+ tl_cli/_plugin/skills/tl-keyword-research/SKILL.md,sha256=xV_efeBwKpjHX1VleHDyr5vqpOhsP-EXH8UqN_SIIrE,10484
75
76
  tl_cli/_plugin/skills/tl-keyword-research/scripts/probe.py,sha256=bM6p-AyaSOp8EpV9oxBVtMwolWCXjU48MisaEROeGZs,4796
76
77
  tl_cli/_plugin/skills/tl-report-builder/SKILL.md,sha256=wGm7V0C8vqJAqWBfJOtGAAp19EI4xIL2TdY-KPxgLdU,144690
77
78
  tl_cli/_plugin/skills/tl-report-builder/examples/e2e_findings.md,sha256=9JD8BA8QrW6d7TlWX6_vGVSvpo_EGPl5gihmSK86tiI,17453
@@ -94,7 +95,7 @@ tl_cli/_plugin/skills/tl-report-builder/tools/sample_judge.md,sha256=Gl-wBNhsjni
94
95
  tl_cli/_plugin/skills/tl-report-builder/tools/similar_channels.md,sha256=ta1Q-GelJ_nFaYFWXDMhhgkTXRRYUSxhgjKdAeHtCrY,2071
95
96
  tl_cli/_plugin/skills/tl-report-builder/tools/topic_matcher.md,sha256=KIuqCFm_zcgQcxDRoAisFnN64P9M-ueKfy8qmWIgYAg,13395
96
97
  tl_cli/_plugin/skills/tl-report-builder/tools/widget_builder.md,sha256=da2dJN7M1j9QXtlEPs0CGUp5GHbO-tsgMuJRIxQAtAY,14392
97
- tl_cli/_plugin/skills/tl-save-report/SKILL.md,sha256=xS8ayH6b3YI43osiHYYNmPseM1pGkzDgMP6bu8dj19U,36917
98
+ tl_cli/_plugin/skills/tl-save-report/SKILL.md,sha256=B3sv_G3pfIkqPLReez6RAwy5Dx6Nf4kz1C7UdaIAZLo,36976
98
99
  tl_cli/_plugin/skills/tl-save-report/references/columns_brands.md,sha256=H5G308GOzMWvWw6smkPGdb2CBuuxkrojCpJgWS9X4sA,3559
99
100
  tl_cli/_plugin/skills/tl-save-report/references/columns_channels.md,sha256=g57ET_VI611lB7fVrGwYFvTDqMkNB63OmmfrnbMZF1I,4684
100
101
  tl_cli/_plugin/skills/tl-save-report/references/columns_content.md,sha256=Y9YL5sm7zK44vSHTXOV4NY7nSOcJ9i3D0HFCo1g63WU,3466
@@ -106,10 +107,12 @@ tl_cli/_plugin/skills/tl-save-report/references/sortable_columns.json,sha256=WxA
106
107
  tl_cli/_plugin/skills/tl-save-report/references/sponsorship_filterset_schema.json,sha256=MlbxtBwCArdCmKynrZU6UL0Ve5Dp15tx6iIQJ5xhivY,10753
107
108
  tl_cli/_plugin/skills/tl-save-report/references/sponsorship_widget_schema.json,sha256=bCCl1yF9PBdJndvlT6Ek5UbDYHMvufByKNDN1aunpHE,9530
108
109
  tl_cli/_plugin/skills/tl-save-report/references/widgets.md,sha256=OFN6FOMjkBqiFN5EHP_pUqrp6Zqou5JuAXJ75cHWyyE,9798
110
+ tl_cli/_plugin/skills/tl-top-partnerships/SKILL.md,sha256=hvH05hIaGlc0RfTE0GLBtDiB6043TmdIYOOczfOEqLM,6517
111
+ tl_cli/_plugin/skills/tl-top-partnerships/scripts/top_partnerships.py,sha256=_13W6-HuD_jtl7AWQQcZQ0SQO9qODMymlcL-1s4-VwU,13248
109
112
  tl_cli/_plugin/skills/tl-views-guarantee/SKILL.md,sha256=IH7q1WJDWri9TWJMiga1FMGJO_GKSbWwaDS6CVNZ9c0,9270
110
113
  tl_cli/_plugin/skills/tl-views-guarantee/scripts/vg.py,sha256=Qp5poinHEqh9374anq0bLtlxj2YL6ipBicaT960-Cws,15825
111
- thoughtleaders_cli-0.7.1.dist-info/METADATA,sha256=R5s_D2PZeJMy_96dWj9S22R4rfaGWCJ-Wse-UUjo8kU,18149
112
- thoughtleaders_cli-0.7.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
113
- thoughtleaders_cli-0.7.1.dist-info/entry_points.txt,sha256=umZp-1BkGkHDG0bNZXpTXrjwW0HGf9IDFN40eAWuuvg,39
114
- thoughtleaders_cli-0.7.1.dist-info/licenses/LICENSE,sha256=RUfdfLsn6jygiyrnnVUHt6r4IPwr2rbDm9Kixgtu8fo,1071
115
- thoughtleaders_cli-0.7.1.dist-info/RECORD,,
114
+ thoughtleaders_cli-0.7.2.dist-info/METADATA,sha256=7BN2D4RB5Aq2zWM6krJwjpR6IAFOZczDvjnUeN67q0A,18452
115
+ thoughtleaders_cli-0.7.2.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
116
+ thoughtleaders_cli-0.7.2.dist-info/entry_points.txt,sha256=umZp-1BkGkHDG0bNZXpTXrjwW0HGf9IDFN40eAWuuvg,39
117
+ thoughtleaders_cli-0.7.2.dist-info/licenses/LICENSE,sha256=RUfdfLsn6jygiyrnnVUHt6r4IPwr2rbDm9Kixgtu8fo,1071
118
+ thoughtleaders_cli-0.7.2.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.29.0
2
+ Generator: hatchling 1.30.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
tl_cli/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence."""
2
2
 
3
- __version__ = "0.7.1"
3
+ __version__ = "0.7.2"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tl-cli",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "ThoughtLeaders CLI — query sponsorship deals, channels, brands, uploads, and intelligence from the terminal",
5
5
  "author": {
6
6
  "name": "ThoughtLeaders",
@@ -434,6 +434,8 @@ If unsure about what information to find where, read the [references/postgresql-
434
434
 
435
435
  If a user asks for one of the **Unavailable** items, say so explicitly and propose the closest `tl`-based approximation rather than silently degrading.
436
436
 
437
+ If the user requests a chart, create it as a SVG graphic.
438
+
437
439
  ### Discovery & system
438
440
  ```bash
439
441
  tl describe # List all resources with credit costs (free)
@@ -164,3 +164,4 @@ Each probe is `size:0` + `track_total_hits:true` with no aggregations — no row
164
164
  5. `keywords` array is sorted descending by `count`.
165
165
  6. Each entry has exactly `keyword` (string) and `count` (integer).
166
166
  7. The seed keyword(s) appear in the output.
167
+ 8. If the user requests a chart, create it as a SVG graphic
@@ -503,6 +503,8 @@ Echo the saved URL + ID, plus a follow-up offer for refinement:
503
503
 
504
504
  The follow-up offer matters because **FilterSet changes (keywords, demographics, M2M lists) can't be patched in place** via `tl reports update` — they require saving a new variant. Surface that limitation only if the user actually asks to change FilterSet fields.
505
505
 
506
+ If the user requests a chart, create it as a SVG graphic.
507
+
506
508
  ### On failure
507
509
 
508
510
  If the command exits non-zero, the CLI prints the error on stderr (shape: `Error (NNN): <detail>` for most codes; specific lines for 401/402/403). **Surface the error verbatim** — do NOT silently report success.
@@ -0,0 +1,101 @@
1
+ ---
2
+ name: tl-top-partnerships
3
+ description: External brand-user performance report. Ranks a brand's sponsorships by effective CPM once the sponsored videos went live, and compares live eCPM against the sold-date projection. Use whenever a brand user asks "which of my sponsorships performed best", "top partnerships this year", "best ROI deals", "effective CPM on my deals", "which sponsorships overperformed", "/top-partnerships", or any variation of "show me my best-performing sponsorships". This is the brand-side equivalent of internal performance reporting — fire it eagerly any time a brand wants to look back at their booked deals through a performance lens, even if they don't say the words "CPM" or "eCPM".
4
+ ---
5
+
6
+ # Top Partnerships (Brand-side)
7
+
8
+ Helps a brand look back at their sold sponsorships and see which ones delivered the lowest effective CPM (eCPM) once the videos went live, vs the projection at sale.
9
+
10
+ ## Triggers
11
+
12
+ - `/top-partnerships` — defaults to calendar YTD
13
+ - `/top-partnerships <range>` — e.g. `/top-partnerships 2025`, `/top-partnerships "last 12 months"`, `/top-partnerships "Q1 2026"`
14
+ - Natural language: "top partnerships this year", "best sponsorships", "which deals performed best", "effective CPM on my deals", "show me my best ROI sponsorships"
15
+
16
+ ## What this skill computes
17
+
18
+ For every sold sponsorship the brand has where the video has actually gone live (has a `publish_date` and a non-null live `views` count):
19
+
20
+ - **Sold-date eCPM** = `price / projected_views_at_purchase_date * 1000`
21
+ - The projection captured on the adlink at the moment the deal was sold. This is the eCPM the brand "agreed to."
22
+ - **Live eCPM** = `price / views * 1000`
23
+ - The actual eCPM now that the video has accumulated views.
24
+ - **View ratio** = `views / projected_views_at_purchase_date`
25
+ - >1 means the video out-delivered its projection.
26
+ - **Delta** = `live_eCPM - sold_date_eCPM`
27
+ - Negative delta = the deal got *cheaper* per view than promised (good for the brand). Positive delta = the deal underdelivered.
28
+
29
+ It also pulls **future bookings** — any sponsorship with status sold / proposal_approved / pending and a send date strictly after today — and tags each deal and each channel with the earliest future send date, or "Re-book - no future spot" if none exists. This turns the report into an actionable list, not just a backward look.
30
+
31
+ ## Output
32
+
33
+ A Google Sheet with two tabs, owned by the caller's Google account:
34
+
35
+ - **By Deal** — one row per sponsorship, ranked by live eCPM (best first). Columns: rank, channel, title, video_url, send_date, publish_date, price, promised_views, live_views, view_ratio, sold_date_ecpm, live_ecpm, delta_ecpm, measurable, next_booking.
36
+ - **By Channel** — one row per channel, aggregated across all that channel's deals in range. Combined live eCPM is `sum(price) / sum(live_views) * 1000` (volume-weighted, not an average of CPMs). Sorted by combined live eCPM. Columns: channel, deals, measurable_deals, total_price_usd, total_promised_views, total_live_views, view_ratio, sold_date_ecpm, live_ecpm, delta_ecpm, next_booking.
37
+
38
+ In chat: a short summary + top-10 channels table + the sheet URL.
39
+
40
+ ## Workflow
41
+
42
+ ### Step 1 — Resolve the brand
43
+
44
+ Run `tl whoami --json` and read the `brands` array.
45
+
46
+ - One brand → use it silently.
47
+ - Zero brands → tell the user this skill is for brand-user profiles and stop.
48
+ - Multiple brands → ask which one. Don't guess.
49
+
50
+ ### Step 2 — Resolve the time range
51
+
52
+ Default = calendar YTD (Jan 1 of the current year through today).
53
+
54
+ Accept these forms in the user's input:
55
+
56
+ - `2025` or `"2024"` → that full calendar year
57
+ - `"last 12 months"` → trailing 12 months ending today
58
+ - `"Q1 2026"`, `"Q4 2025"` → that quarter
59
+ - `"YTD"` → explicit current YTD
60
+ - Anything else → ask the user to clarify, don't silently pick
61
+
62
+ Convert to a `send-date-start` / `send-date-end` pair (YYYY-MM-DD strings). Use `send_date` as the time anchor because that is when the sponsorship actually ran for the brand — purchase_date can be months earlier.
63
+
64
+ ### Step 3 — Run the script
65
+
66
+ ```bash
67
+ python3 <SKILL_DIR>/scripts/top_partnerships.py \
68
+ --brand "<BRAND_NAME>" \
69
+ --send-date-start <YYYY-MM-DD> \
70
+ --send-date-end <YYYY-MM-DD>
71
+ ```
72
+
73
+ `<SKILL_DIR>` resolves to this skill's directory at invocation time (same convention as `tl-views-guarantee`, `tl-keyword-research`).
74
+
75
+ The script does everything: pulls sold deals in range (paginated), pulls all future bookings, computes per-deal and per-channel metrics, creates a Google Sheet with two tabs, shares it back to the caller, and prints a markdown summary plus the sheet URL.
76
+
77
+ It uses `tl` for data and `gws` for sheet creation. Both must be on PATH and authed.
78
+
79
+ ### Step 4 — Present the result
80
+
81
+ Take the script's stdout as-is. It already contains:
82
+
83
+ 1. **Summary line** — total sold deals, measurable count, median live eCPM, count overperforming.
84
+ 2. **Top 10 channels by combined live eCPM** — markdown table with the Next booking column bolded when it says "Re-book."
85
+ 3. **Sheet URL** — point the user at the two tabs.
86
+
87
+ If more than half the top-10 channels show "Re-book", call that out in one sentence as the headline action item. If most of the top channels already have follow-ups booked, congratulate briefly and stop.
88
+
89
+ Keep the writeup tight. No em dashes, no "just wanted to", no hedging. The data does the talking.
90
+
91
+ ## Brand-user mode notes
92
+
93
+ - This skill assumes a brand-user `tl` auth. It uses only public CLI commands (`tl whoami`, `tl sponsorships list`) — no `tl db pg` and no Elasticsearch.
94
+ - The `tl sponsorships list` endpoint already filters to deals the calling profile is allowed to see, so passing `brand:"<name>"` is a belt-and-braces filter rather than a privacy boundary.
95
+ - View counts come from TL's own tracking on the `views` field returned by the CLI. They're the same numbers the brand sees in the TL dashboard, so the eCPMs are reconcilable with what they see in-app.
96
+ - Don't include creators' contact emails, internal notes, or owner_* fields in the brand-facing output. The script already drops them from the CSV.
97
+
98
+ ## Edge cases worth mentioning to the user (only if they apply)
99
+
100
+ - A deal that ran very recently (last 14-28 days) may show a misleadingly high Live eCPM because views are still accumulating. Mention this only if more than half the top-10 deals have a send date inside the last 28 days.
101
+ - If the brand has zero measurable deals in the range, say so plainly and suggest broadening the range (e.g., last 12 months).
@@ -0,0 +1,335 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Pull a brand's sold sponsorships in a date range, compute live vs sold-date eCPM,
4
+ also pull future bookings, build per-deal and per-channel views, upload a
5
+ two-tab Google Sheet, and print a top-10 markdown summary.
6
+ """
7
+
8
+ import argparse
9
+ import json
10
+ import re
11
+ import subprocess
12
+ import sys
13
+ from datetime import date, timedelta
14
+ from pathlib import Path
15
+
16
+
17
+ def slugify(s: str) -> str:
18
+ return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-") or "brand"
19
+
20
+
21
+ def to_float(x):
22
+ if x is None or x == "":
23
+ return None
24
+ try:
25
+ return float(x)
26
+ except (TypeError, ValueError):
27
+ return None
28
+
29
+
30
+ def to_int(x):
31
+ f = to_float(x)
32
+ return int(f) if f is not None else None
33
+
34
+
35
+ def youtube_url(article_id):
36
+ if not article_id or ":" not in article_id:
37
+ return ""
38
+ vid = article_id.split(":", 1)[1]
39
+ return f"https://www.youtube.com/watch?v={vid}"
40
+
41
+
42
+ def fmt_money(x):
43
+ return f"${x:,.0f}" if x is not None else "n/a"
44
+
45
+
46
+ def fmt_int(x):
47
+ return f"{x:,}" if x is not None else "n/a"
48
+
49
+
50
+ def fmt_ratio(x):
51
+ return f"{x:.2f}x" if x is not None else "n/a"
52
+
53
+
54
+ def fmt_cpm(x):
55
+ return f"${x:,.2f}" if x is not None else "n/a"
56
+
57
+
58
+ def tl_list(*args) -> list[dict]:
59
+ rows: list[dict] = []
60
+ limit = 200
61
+ offset = 0
62
+ while True:
63
+ out = subprocess.run(
64
+ ["tl", "sponsorships", "list", *args,
65
+ "--limit", str(limit), "--offset", str(offset), "--json"],
66
+ capture_output=True, text=True, check=True,
67
+ ).stdout
68
+ data = json.loads(out)
69
+ page = data.get("results", data) if isinstance(data, dict) else data
70
+ if not page:
71
+ break
72
+ rows.extend(page)
73
+ if len(page) < limit:
74
+ break
75
+ offset += limit
76
+ return rows
77
+
78
+
79
+ def fetch_future_bookings(brand: str) -> dict[str, dict]:
80
+ """Return channel -> {send_date, status} for the earliest future booking per channel.
81
+ Future = send_date strictly after today, status in {sold, proposal_approved, pending}.
82
+ """
83
+ today = date.today()
84
+ cutoff = (today + timedelta(days=1)).isoformat()
85
+ end = (today + timedelta(days=365 * 2)).isoformat()
86
+ rows: list[dict] = []
87
+ for status in ("sold", "proposal_approved", "pending"):
88
+ rows.extend(tl_list(
89
+ f"brand:{brand}",
90
+ f"status:{status}",
91
+ f"send-date-start:{cutoff}",
92
+ f"send-date-end:{end}",
93
+ ))
94
+ by_channel: dict[str, dict] = {}
95
+ for r in rows:
96
+ ch = r.get("channel")
97
+ sd = r.get("send_date")
98
+ st = r.get("status")
99
+ if not ch or not sd:
100
+ continue
101
+ if ch not in by_channel or sd < by_channel[ch]["send_date"]:
102
+ by_channel[ch] = {"send_date": sd, "status": st}
103
+ return by_channel
104
+
105
+
106
+ def build_deal_rows(raw: list[dict], future: dict[str, dict]) -> list[dict]:
107
+ out = []
108
+ for r in raw:
109
+ price = to_float(r.get("price"))
110
+ promised = to_int(r.get("projected_views_at_purchase_date"))
111
+ views = to_int(r.get("views"))
112
+ publish_date = r.get("publish_date")
113
+ live_cpm = (price / views * 1000) if (price and views) else None
114
+ sold_cpm = (price / promised * 1000) if (price and promised) else None
115
+ ratio = (views / promised) if (views and promised) else None
116
+ delta = (live_cpm - sold_cpm) if (live_cpm is not None and sold_cpm is not None) else None
117
+ measurable = bool(publish_date and views)
118
+ ch = r.get("channel")
119
+ f = future.get(ch)
120
+ next_booking = f"{f['send_date']} ({f['status']})" if f else "Re-book - no future spot"
121
+ out.append({
122
+ "channel": ch,
123
+ "title": (r.get("title") or "").strip(),
124
+ "video_url": youtube_url(r.get("article_id")),
125
+ "send_date": r.get("send_date"),
126
+ "publish_date": publish_date,
127
+ "price": price,
128
+ "price_currency": r.get("price_currency", "USD"),
129
+ "promised_views": promised,
130
+ "live_views": views,
131
+ "view_ratio": ratio,
132
+ "sold_date_ecpm": sold_cpm,
133
+ "live_ecpm": live_cpm,
134
+ "delta_ecpm": delta,
135
+ "measurable": measurable,
136
+ "next_booking": next_booking,
137
+ })
138
+ return out
139
+
140
+
141
+ def build_channel_rows(deals: list[dict], future: dict[str, dict]) -> list[dict]:
142
+ agg: dict[str, dict] = {}
143
+ for d in deals:
144
+ ch = d["channel"] or "(unknown)"
145
+ a = agg.setdefault(ch, {"deals": 0, "measurable": 0, "price": 0.0, "promised": 0.0, "live": 0.0})
146
+ a["deals"] += 1
147
+ a["price"] += d["price"] or 0
148
+ if d["promised_views"]:
149
+ a["promised"] += d["promised_views"]
150
+ if d["live_views"]:
151
+ a["live"] += d["live_views"]
152
+ a["measurable"] += 1
153
+ out = []
154
+ for ch, a in agg.items():
155
+ live_cpm = (a["price"] / a["live"] * 1000) if (a["live"] and a["price"]) else None
156
+ sold_cpm = (a["price"] / a["promised"] * 1000) if (a["promised"] and a["price"]) else None
157
+ ratio = (a["live"] / a["promised"]) if a["promised"] and a["live"] else None
158
+ delta = (live_cpm - sold_cpm) if (live_cpm is not None and sold_cpm is not None) else None
159
+ f = future.get(ch)
160
+ next_booking = f"{f['send_date']} ({f['status']})" if f else "Re-book - no future spot"
161
+ out.append({
162
+ "channel": ch,
163
+ "deals": a["deals"],
164
+ "measurable_deals": a["measurable"],
165
+ "total_price": a["price"],
166
+ "total_promised": int(a["promised"]),
167
+ "total_live": int(a["live"]),
168
+ "view_ratio": ratio,
169
+ "sold_date_ecpm": sold_cpm,
170
+ "live_ecpm": live_cpm,
171
+ "delta_ecpm": delta,
172
+ "next_booking": next_booking,
173
+ })
174
+ out.sort(key=lambda r: (r["live_ecpm"] is None, r["live_ecpm"] if r["live_ecpm"] is not None else 0))
175
+ return out
176
+
177
+
178
+ def whoami_email() -> str | None:
179
+ try:
180
+ out = subprocess.run(["tl", "whoami", "--json"], capture_output=True, text=True, check=True).stdout
181
+ return json.loads(out).get("user", {}).get("email")
182
+ except Exception:
183
+ return None
184
+
185
+
186
+ def gws(cmd: list[str], params: dict | None = None, body: dict | None = None) -> dict:
187
+ args = ["gws", *cmd]
188
+ if params is not None:
189
+ args += ["--params", json.dumps(params)]
190
+ if body is not None:
191
+ args += ["--json", json.dumps(body)]
192
+ r = subprocess.run(args, capture_output=True, text=True, check=True)
193
+ # gws may print non-JSON preamble like "Using keyring backend: keyring" — find the JSON block
194
+ out = r.stdout
195
+ start = out.find("{")
196
+ return json.loads(out[start:]) if start >= 0 else {}
197
+
198
+
199
+ def upload_sheet(brand: str, deals: list[dict], channels: list[dict], rankable: list[dict]) -> str:
200
+ title = f"{brand} Top Partnerships ({deals[0]['send_date'][:4] if deals else 'no-data'})"
201
+
202
+ # 1) Create empty spreadsheet via Sheets API
203
+ sheet = gws(["sheets", "spreadsheets", "create"], body={
204
+ "properties": {"title": title},
205
+ "sheets": [
206
+ {"properties": {"title": "By Deal"}},
207
+ {"properties": {"title": "By Channel"}},
208
+ ],
209
+ })
210
+ sid = sheet["spreadsheetId"]
211
+
212
+ # 2) Write By Deal
213
+ deal_header = ["rank", "channel", "title", "video_url", "send_date", "publish_date",
214
+ "price", "promised_views", "live_views", "view_ratio",
215
+ "sold_date_ecpm", "live_ecpm", "delta_ecpm", "measurable", "next_booking"]
216
+ rank_ids = {id(d): i + 1 for i, d in enumerate(rankable)}
217
+ # Order: ranked first (best live_ecpm), then unranked-measurable, then unmeasurable
218
+ ranked_set = {id(d) for d in rankable}
219
+ measurable_unranked = [d for d in deals if d["measurable"] and id(d) not in ranked_set]
220
+ unmeasurable = [d for d in deals if not d["measurable"]]
221
+ ordered = rankable + measurable_unranked + unmeasurable
222
+
223
+ deal_values = [deal_header]
224
+ for d in ordered:
225
+ deal_values.append([
226
+ rank_ids.get(id(d), ""),
227
+ d["channel"] or "", d["title"], d["video_url"], d["send_date"] or "",
228
+ (d["publish_date"] or "")[:10], d["price"] or "",
229
+ d["promised_views"] or "", d["live_views"] or "",
230
+ round(d["view_ratio"], 4) if d["view_ratio"] is not None else "",
231
+ round(d["sold_date_ecpm"], 4) if d["sold_date_ecpm"] is not None else "",
232
+ round(d["live_ecpm"], 4) if d["live_ecpm"] is not None else "",
233
+ round(d["delta_ecpm"], 4) if d["delta_ecpm"] is not None else "",
234
+ "TRUE" if d["measurable"] else "FALSE",
235
+ d["next_booking"],
236
+ ])
237
+ gws(["sheets", "spreadsheets", "values", "update"],
238
+ params={"spreadsheetId": sid, "range": f"'By Deal'!A1:O{len(deal_values)}",
239
+ "valueInputOption": "RAW"},
240
+ body={"values": deal_values})
241
+
242
+ # 3) Write By Channel
243
+ ch_header = ["channel", "deals", "measurable_deals", "total_price_usd",
244
+ "total_promised_views", "total_live_views", "view_ratio",
245
+ "sold_date_ecpm", "live_ecpm", "delta_ecpm", "next_booking"]
246
+ ch_values = [ch_header]
247
+ for c in channels:
248
+ ch_values.append([
249
+ c["channel"], c["deals"], c["measurable_deals"],
250
+ round(c["total_price"], 2), c["total_promised"], c["total_live"],
251
+ round(c["view_ratio"], 4) if c["view_ratio"] is not None else "",
252
+ round(c["sold_date_ecpm"], 4) if c["sold_date_ecpm"] is not None else "",
253
+ round(c["live_ecpm"], 4) if c["live_ecpm"] is not None else "",
254
+ round(c["delta_ecpm"], 4) if c["delta_ecpm"] is not None else "",
255
+ c["next_booking"],
256
+ ])
257
+ gws(["sheets", "spreadsheets", "values", "update"],
258
+ params={"spreadsheetId": sid, "range": f"'By Channel'!A1:K{len(ch_values)}",
259
+ "valueInputOption": "RAW"},
260
+ body={"values": ch_values})
261
+
262
+ # 4) Share with the caller (writer, no email)
263
+ email = whoami_email()
264
+ if email:
265
+ try:
266
+ gws(["drive", "permissions", "create"],
267
+ params={"fileId": sid, "sendNotificationEmail": False},
268
+ body={"role": "writer", "type": "user", "emailAddress": email})
269
+ except Exception:
270
+ pass
271
+
272
+ return f"https://docs.google.com/spreadsheets/d/{sid}/edit"
273
+
274
+
275
+ def main():
276
+ ap = argparse.ArgumentParser()
277
+ ap.add_argument("--brand", required=True)
278
+ ap.add_argument("--send-date-start", required=True)
279
+ ap.add_argument("--send-date-end", required=True)
280
+ ap.add_argument("--top", type=int, default=10)
281
+ args = ap.parse_args()
282
+
283
+ raw = tl_list(
284
+ "status:sold", f"brand:{args.brand}",
285
+ f"send-date-start:{args.send_date_start}", f"send-date-end:{args.send_date_end}",
286
+ )
287
+ future = fetch_future_bookings(args.brand)
288
+ deals = build_deal_rows(raw, future)
289
+ channels = build_channel_rows(deals, future)
290
+
291
+ rankable = [d for d in deals if d["measurable"] and d["live_ecpm"] is not None and d["sold_date_ecpm"] is not None]
292
+ rankable.sort(key=lambda d: d["live_ecpm"])
293
+
294
+ measurable_count = sum(1 for d in deals if d["measurable"] and d["live_ecpm"] is not None)
295
+ unmeasurable_count = sum(1 for d in deals if not d["measurable"])
296
+
297
+ print("## Summary\n")
298
+ print(f"- Sold sponsorships in range: **{len(deals)}**")
299
+ print(f"- Measurable (video live with views): **{measurable_count}**")
300
+ if rankable:
301
+ median = sorted(d["live_ecpm"] for d in rankable)[len(rankable) // 2]
302
+ overperformed = sum(1 for d in rankable if d["delta_ecpm"] is not None and d["delta_ecpm"] < 0)
303
+ print(f"- Median live eCPM: **{fmt_cpm(median)}**")
304
+ print(f"- Deals that overperformed: **{overperformed} / {len(rankable)}**")
305
+ print()
306
+
307
+ if channels:
308
+ ranked_channels = [c for c in channels if c["live_ecpm"] is not None]
309
+ print(f"## Top {min(args.top, len(ranked_channels))} channels by combined live eCPM\n")
310
+ print("| # | Channel | Deals | Total spend | Total live views | View ratio | Live eCPM | Delta | Next booking |")
311
+ print("|---|---------|-------|-------------|------------------|------------|-----------|-------|--------------|")
312
+ for i, c in enumerate(ranked_channels[: args.top], 1):
313
+ next_cell = c["next_booking"]
314
+ if next_cell.startswith("Re-book"):
315
+ next_cell = f"**{next_cell}**"
316
+ print(
317
+ f"| {i} | {c['channel']} | {c['deals']} | {fmt_money(c['total_price'])} | "
318
+ f"{fmt_int(c['total_live'])} | {fmt_ratio(c['view_ratio'])} | "
319
+ f"{fmt_cpm(c['live_ecpm'])} | {fmt_cpm(c['delta_ecpm'])} | {next_cell} |"
320
+ )
321
+ print()
322
+
323
+ if unmeasurable_count:
324
+ print(f"_{unmeasurable_count} deals in range are not yet measurable (video not live or no view data yet). They appear in the sheet but not in the ranking._\n")
325
+
326
+ if deals:
327
+ url = upload_sheet(args.brand, deals, channels, rankable)
328
+ print(f"**Google Sheet:** {url}")
329
+ print("Two tabs — *By Deal* (one row per sponsorship) and *By Channel* (aggregated, one row per channel).")
330
+ else:
331
+ print("_No deals found in this range._")
332
+
333
+
334
+ if __name__ == "__main__":
335
+ main()
tl_cli/_typer_utils.py ADDED
@@ -0,0 +1,23 @@
1
+ """Shared Typer customizations for the tl CLI.
2
+
3
+ The single export here, ``AlphaSortedTyperGroup``, makes ``--help`` render
4
+ its subcommands alphabetically instead of in registration order. It is
5
+ applied via ``cls=AlphaSortedTyperGroup`` on every ``typer.Typer(...)``
6
+ instantiation in the project so the behavior is consistent at every help
7
+ level (``tl --help``, ``tl brands --help``, ``tl db --help``, etc.).
8
+ """
9
+
10
+ import typer
11
+ from typer.core import TyperGroup
12
+
13
+
14
+ class AlphaSortedTyperGroup(TyperGroup):
15
+ """Render subcommands in alphabetical order on ``--help``.
16
+
17
+ Typer / Click default ``list_commands`` to insertion order; users
18
+ looking at long help listings want them sorted so the command they
19
+ are after is easy to find.
20
+ """
21
+
22
+ def list_commands(self, ctx: typer.Context) -> list[str]:
23
+ return sorted(super().list_commands(ctx))
tl_cli/auth/commands.py CHANGED
@@ -4,6 +4,7 @@ import sys
4
4
  import time
5
5
 
6
6
  import typer
7
+ from tl_cli._typer_utils import AlphaSortedTyperGroup
7
8
  from rich.console import Console
8
9
  from rich.prompt import Prompt
9
10
 
@@ -11,7 +12,7 @@ from tl_cli.auth.finalize import finalize_signup
11
12
  from tl_cli.auth.login import login_browser, login_device_code
12
13
  from tl_cli.auth.token_store import KIND_API_KEY, StoredTokens, clear_tokens, load_tokens, save_tokens
13
14
 
14
- app = typer.Typer(help="Authentication commands")
15
+ app = typer.Typer(cls=AlphaSortedTyperGroup, help="Authentication commands")
15
16
  console = Console(stderr=True)
16
17
 
17
18
 
@@ -3,6 +3,7 @@
3
3
  import json
4
4
 
5
5
  import typer
6
+ from tl_cli._typer_utils import AlphaSortedTyperGroup
6
7
  from rich.console import Console
7
8
  from rich.table import Table
8
9
 
@@ -10,7 +11,7 @@ from tl_cli.client.errors import ApiError, handle_api_error
10
11
  from tl_cli.client.http import get_client
11
12
  from tl_cli.output.formatter import detect_format
12
13
 
13
- app = typer.Typer(help="Credit balance and usage (free)")
14
+ app = typer.Typer(cls=AlphaSortedTyperGroup, help="Credit balance and usage (free)")
14
15
  console = Console()
15
16
 
16
17
 
tl_cli/commands/brands.py CHANGED
@@ -9,6 +9,7 @@ import json as _json
9
9
  import urllib.parse
10
10
 
11
11
  import typer
12
+ from tl_cli._typer_utils import AlphaSortedTyperGroup
12
13
 
13
14
  from rich.console import Console
14
15
 
@@ -18,7 +19,7 @@ from tl_cli.commands._comments_common import register_comment_commands
18
19
  from tl_cli.hints import detail_hint
19
20
  from tl_cli.output.formatter import detect_format, output, output_single
20
21
 
21
- app = typer.Typer(help="Brand intelligence (detail, find, similar)")
22
+ app = typer.Typer(cls=AlphaSortedTyperGroup, help="Brand intelligence (detail, find, similar)")
22
23
  register_comment_commands(app, "brand", "brand")
23
24
 
24
25
 
@@ -4,6 +4,7 @@ import json as _json
4
4
  import urllib.parse
5
5
 
6
6
  import typer
7
+ from tl_cli._typer_utils import AlphaSortedTyperGroup
7
8
  from rich.console import Console
8
9
 
9
10
  from tl_cli.client.errors import ApiError, handle_api_error
@@ -13,7 +14,7 @@ from tl_cli.filters import parse_filters
13
14
  from tl_cli.hints import detail_hint
14
15
  from tl_cli.output.formatter import detect_format, output, output_single
15
16
 
16
- app = typer.Typer(help="YouTube channels (detail and similar-channel recommendations)")
17
+ app = typer.Typer(cls=AlphaSortedTyperGroup, help="YouTube channels (detail and similar-channel recommendations)")
17
18
  register_comment_commands(app, "channel", "channel")
18
19
 
19
20
  _HISTORY_DEPRECATION = (
@@ -18,6 +18,7 @@ import webbrowser
18
18
  from decimal import Decimal, InvalidOperation
19
19
 
20
20
  import typer
21
+ from tl_cli._typer_utils import AlphaSortedTyperGroup
21
22
  from pytoon import encode as toon_encode
22
23
  from rich.console import Console
23
24
  from rich.prompt import Prompt
@@ -27,7 +28,7 @@ from tl_cli.client.errors import ApiError, handle_api_error
27
28
  from tl_cli.client.http import get_client
28
29
  from tl_cli.output.formatter import detect_format
29
30
 
30
- app = typer.Typer(help="Buy credits and view top-up history (free)")
31
+ app = typer.Typer(cls=AlphaSortedTyperGroup, help="Buy credits and view top-up history (free)")
31
32
  console = Console()
32
33
  err = Console(stderr=True)
33
34
 
tl_cli/commands/db.py CHANGED
@@ -4,12 +4,13 @@ import json
4
4
  import sys
5
5
 
6
6
  import typer
7
+ from tl_cli._typer_utils import AlphaSortedTyperGroup
7
8
 
8
9
  from tl_cli.client.errors import ApiError, handle_api_error
9
10
  from tl_cli.client.http import get_client
10
11
  from tl_cli.output.formatter import detect_format, output, output_pricing_estimate
11
12
 
12
- app = typer.Typer(help="Raw read-only queries against PostgreSQL, Firebolt, or Elasticsearch (full-access only)")
13
+ app = typer.Typer(cls=AlphaSortedTyperGroup, help="Raw read-only queries against PostgreSQL, Firebolt, or Elasticsearch (full-access only)")
13
14
 
14
15
 
15
16
  def _read_query(query: str | None) -> str:
tl_cli/commands/deals.py CHANGED
@@ -3,11 +3,12 @@
3
3
  from typing import Optional
4
4
 
5
5
  import typer
6
+ from tl_cli._typer_utils import AlphaSortedTyperGroup
6
7
 
7
8
  from tl_cli.commands.sponsorships import do_create, do_list, do_show
8
9
  from tl_cli.output.formatter import detect_format
9
10
 
10
- app = typer.Typer(help="Deals — agreed-upon sponsorships (shortcut for sponsorships status:deal)")
11
+ app = typer.Typer(cls=AlphaSortedTyperGroup, help="Deals — agreed-upon sponsorships (shortcut for sponsorships status:deal)")
11
12
 
12
13
 
13
14
  @app.callback(invoke_without_command=True)
@@ -3,6 +3,7 @@
3
3
  import json
4
4
 
5
5
  import typer
6
+ from tl_cli._typer_utils import AlphaSortedTyperGroup
6
7
  from rich.console import Console
7
8
  from rich.table import Table
8
9
 
@@ -10,7 +11,7 @@ from tl_cli.client.errors import ApiError, handle_api_error
10
11
  from tl_cli.client.http import get_client
11
12
  from tl_cli.output.formatter import _fmt_credits, detect_format
12
13
 
13
- app = typer.Typer(help="Discover available resources, fields, filters, and credit costs")
14
+ app = typer.Typer(cls=AlphaSortedTyperGroup, help="Discover available resources, fields, filters, and credit costs")
14
15
  console = Console()
15
16
 
16
17
 
tl_cli/commands/doctor.py CHANGED
@@ -6,6 +6,7 @@ import statistics
6
6
  import time
7
7
 
8
8
  import typer
9
+ from tl_cli._typer_utils import AlphaSortedTyperGroup
9
10
  from rich.console import Console
10
11
  from rich.table import Table
11
12
 
@@ -77,7 +78,7 @@ _RECOMMENDED_TOOLS: tuple[tuple[str, str, dict[str, str]], ...] = (
77
78
  ),
78
79
  )
79
80
 
80
- app = typer.Typer(help="Health check (auth, connectivity, version)")
81
+ app = typer.Typer(cls=AlphaSortedTyperGroup, help="Health check (auth, connectivity, version)")
81
82
  console = Console()
82
83
 
83
84
 
@@ -3,11 +3,12 @@
3
3
  from typing import Optional
4
4
 
5
5
  import typer
6
+ from tl_cli._typer_utils import AlphaSortedTyperGroup
6
7
 
7
8
  from tl_cli.commands.sponsorships import do_create, do_list, do_show
8
9
  from tl_cli.output.formatter import detect_format
9
10
 
10
- app = typer.Typer(help="Matches — possible brand-channel pairings (shortcut for sponsorships status:match)")
11
+ app = typer.Typer(cls=AlphaSortedTyperGroup, help="Matches — possible brand-channel pairings (shortcut for sponsorships status:match)")
11
12
 
12
13
 
13
14
  @app.callback(invoke_without_command=True)
@@ -3,11 +3,12 @@
3
3
  from typing import Optional
4
4
 
5
5
  import typer
6
+ from tl_cli._typer_utils import AlphaSortedTyperGroup
6
7
 
7
8
  from tl_cli.commands.sponsorships import do_create, do_list, do_show
8
9
  from tl_cli.output.formatter import detect_format
9
10
 
10
- app = typer.Typer(help="Proposals — matches proposed to both sides (shortcut for sponsorships status:proposal)")
11
+ app = typer.Typer(cls=AlphaSortedTyperGroup, help="Proposals — matches proposed to both sides (shortcut for sponsorships status:proposal)")
11
12
 
12
13
 
13
14
  @app.callback(invoke_without_command=True)
@@ -13,6 +13,7 @@ For 1:1 similarity use `tl channels similar` and `tl brands similar`.
13
13
  import urllib.parse
14
14
 
15
15
  import typer
16
+ from tl_cli._typer_utils import AlphaSortedTyperGroup
16
17
  from rich.console import Console
17
18
 
18
19
  from tl_cli.client.errors import ApiError, handle_api_error
@@ -20,7 +21,7 @@ from tl_cli.client.http import get_client
20
21
  from tl_cli.filters import parse_filters
21
22
  from tl_cli.output.formatter import detect_format, output, output_single
22
23
 
23
- app = typer.Typer(help="Recommender (similarity tags, top-channels/profiles/brands, similarity-profile inspection, profile→channel and channel→brand similarity)")
24
+ app = typer.Typer(cls=AlphaSortedTyperGroup, help="Recommender (similarity tags, top-channels/profiles/brands, similarity-profile inspection, profile→channel and channel→brand similarity)")
24
25
 
25
26
 
26
27
  TOP_CHANNEL_COLUMNS = ["value", "channel_id", "channel_name", "slug"]
@@ -4,6 +4,7 @@ import json
4
4
  import time
5
5
 
6
6
  import typer
7
+ from tl_cli._typer_utils import AlphaSortedTyperGroup
7
8
  from pytoon import encode as toon_encode
8
9
  from rich.console import Console
9
10
  from rich.panel import Panel
@@ -13,7 +14,7 @@ from tl_cli.client.errors import ApiError, handle_api_error
13
14
  from tl_cli.client.http import get_client
14
15
  from tl_cli.output.formatter import detect_format, output, output_single
15
16
 
16
- app = typer.Typer(help="Saved reports (list, run, create, update)")
17
+ app = typer.Typer(cls=AlphaSortedTyperGroup, help="Saved reports (list, run, create, update)")
17
18
  err = Console(stderr=True)
18
19
 
19
20
  # Report type labels matching Django's ReportType enum
tl_cli/commands/schema.py CHANGED
@@ -4,6 +4,7 @@ import json
4
4
  import re
5
5
 
6
6
  import typer
7
+ from tl_cli._typer_utils import AlphaSortedTyperGroup
7
8
  import yaml
8
9
  from pytoon import encode as toon_encode
9
10
  from rich.console import Console
@@ -14,7 +15,7 @@ from rich.tree import Tree
14
15
  from tl_cli.client.errors import ApiError, handle_api_error
15
16
  from tl_cli.client.http import get_client
16
17
 
17
- app = typer.Typer(help="Show schema documentation for raw db queries (`tl db pg|fb|es`)")
18
+ app = typer.Typer(cls=AlphaSortedTyperGroup, help="Show schema documentation for raw db queries (`tl db pg|fb|es`)")
18
19
  console = Console()
19
20
 
20
21
  # Pulls the YAML body out of the server's ```yaml … ``` fenced block. Any
tl_cli/commands/setup.py CHANGED
@@ -13,12 +13,13 @@ import subprocess
13
13
  from pathlib import Path
14
14
 
15
15
  import typer
16
+ from tl_cli._typer_utils import AlphaSortedTyperGroup
16
17
  from pytoon import encode as toon_encode
17
18
  from rich.console import Console
18
19
 
19
20
  from tl_cli import __version__
20
21
 
21
- app = typer.Typer(help="Set up integrations (Claude Code, OpenCode, Gemini, Codex)")
22
+ app = typer.Typer(cls=AlphaSortedTyperGroup, help="Set up integrations (Claude Code, OpenCode, Gemini, Codex)")
22
23
  console = Console(stderr=True)
23
24
 
24
25
  MARKETPLACE_SOURCE = "ThoughtLeaders-io/thoughtleaders-cli"
@@ -1,12 +1,13 @@
1
1
  """tl snapshots — Firebolt time-series metrics for channels and videos."""
2
2
 
3
3
  import typer
4
+ from tl_cli._typer_utils import AlphaSortedTyperGroup
4
5
 
5
6
  from tl_cli.client.errors import ApiError, handle_api_error
6
7
  from tl_cli.client.http import get_client
7
8
  from tl_cli.output.formatter import detect_format, output
8
9
 
9
- app = typer.Typer(help="Historical metrics snapshots (Firebolt time-series)")
10
+ app = typer.Typer(cls=AlphaSortedTyperGroup, help="Historical metrics snapshots (Firebolt time-series)")
10
11
 
11
12
 
12
13
  @app.command("channel")
@@ -4,6 +4,7 @@ import json as _json
4
4
  from typing import Optional
5
5
 
6
6
  import typer
7
+ from tl_cli._typer_utils import AlphaSortedTyperGroup
7
8
  from rich.console import Console
8
9
 
9
10
  from tl_cli.client.errors import handle_api_error, ApiError
@@ -137,7 +138,7 @@ def do_create_body(body: dict, fmt: str) -> None:
137
138
 
138
139
  # --- Typer app ---
139
140
 
140
- app = typer.Typer(help="Sponsorships (deals, matches, proposals)")
141
+ app = typer.Typer(cls=AlphaSortedTyperGroup, help="Sponsorships (deals, matches, proposals)")
141
142
 
142
143
 
143
144
  @app.callback(invoke_without_command=True)
@@ -1,13 +1,14 @@
1
1
  """tl uploads — Show video uploads by ID."""
2
2
 
3
3
  import typer
4
+ from tl_cli._typer_utils import AlphaSortedTyperGroup
4
5
 
5
6
  from tl_cli.client.errors import ApiError, handle_api_error
6
7
  from tl_cli.client.http import get_client
7
8
  from tl_cli.commands._comments_common import register_comment_commands
8
9
  from tl_cli.output.formatter import detect_format, output_single
9
10
 
10
- app = typer.Typer(help="Video uploads (YouTube content from Elasticsearch)")
11
+ app = typer.Typer(cls=AlphaSortedTyperGroup, help="Video uploads (YouTube content from Elasticsearch)")
11
12
  register_comment_commands(app, "upload", "upload")
12
13
 
13
14
 
tl_cli/commands/whoami.py CHANGED
@@ -3,6 +3,7 @@
3
3
  import json
4
4
 
5
5
  import typer
6
+ from tl_cli._typer_utils import AlphaSortedTyperGroup
6
7
  from pytoon import encode as toon_encode
7
8
  from rich.console import Console
8
9
  from rich.panel import Panel
@@ -13,7 +14,7 @@ from tl_cli.client.errors import handle_api_error, ApiError
13
14
  from tl_cli.client.http import get_client
14
15
  from tl_cli.output.formatter import detect_format
15
16
 
16
- app = typer.Typer(help="Show current user, profile, org, and brands (free)")
17
+ app = typer.Typer(cls=AlphaSortedTyperGroup, help="Show current user, profile, org, and brands (free)")
17
18
 
18
19
 
19
20
  def _render_whoami(data: dict) -> None:
tl_cli/main.py CHANGED
@@ -10,6 +10,7 @@ import typer
10
10
  from rich.console import Console
11
11
 
12
12
  from tl_cli import __version__
13
+ from tl_cli._typer_utils import AlphaSortedTyperGroup
13
14
  from tl_cli import config as tl_config
14
15
  from tl_cli.auth.commands import app as auth_app
15
16
  from tl_cli.commands.balance import app as balance_app
@@ -38,6 +39,7 @@ app = typer.Typer(
38
39
  help=f"ThoughtLeaders CLI v{__version__} — query sponsorship data, channels, brands, and intelligence.",
39
40
  no_args_is_help=True,
40
41
  rich_markup_mode="rich",
42
+ cls=AlphaSortedTyperGroup,
41
43
  )
42
44
 
43
45