thoughtleaders-cli 0.9.3__py3-none-any.whl → 0.9.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {thoughtleaders_cli-0.9.3.dist-info → thoughtleaders_cli-0.9.4.dist-info}/METADATA +16 -1
- {thoughtleaders_cli-0.9.3.dist-info → thoughtleaders_cli-0.9.4.dist-info}/RECORD +15 -13
- tl_cli/__init__.py +1 -1
- tl_cli/_plugin/.claude-plugin/plugin.json +1 -1
- tl_cli/_plugin/skills/tl/SKILL.md +17 -3
- tl_cli/client/http.py +12 -8
- tl_cli/commands/balance.py +6 -0
- tl_cli/commands/db.py +5 -3
- tl_cli/commands/setup.py +30 -1
- tl_cli/commands/skills.py +437 -0
- tl_cli/main.py +9 -0
- tl_cli/skill_registry.py +312 -0
- {thoughtleaders_cli-0.9.3.dist-info → thoughtleaders_cli-0.9.4.dist-info}/WHEEL +0 -0
- {thoughtleaders_cli-0.9.3.dist-info → thoughtleaders_cli-0.9.4.dist-info}/entry_points.txt +0 -0
- {thoughtleaders_cli-0.9.3.dist-info → thoughtleaders_cli-0.9.4.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: thoughtleaders-cli
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.4
|
|
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
|
|
@@ -284,6 +284,21 @@ The plugin ships several focused skills (installed by all the `tl setup *` comma
|
|
|
284
284
|
- **`tl-views-guarantee`** — sizes a multi-video sponsorship buy for a channel, returning the video bundle size, views guarantee, and likelihood to hit.
|
|
285
285
|
- **`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`).
|
|
286
286
|
|
|
287
|
+
## Distributed skills
|
|
288
|
+
|
|
289
|
+
Beyond the bundled set above, an organization can be granted additional skills that aren't part of a CLI release — `tl skill` fetches and installs them on demand into the same directories `tl setup` uses (Claude Code's standalone skills directory, OpenCode's skills directory, and the directory shared by Gemini and Codex), so every supported agent picks them up automatically.
|
|
290
|
+
|
|
291
|
+
```bash
|
|
292
|
+
tl skill list # skills available to your organization, with installed/latest versions
|
|
293
|
+
tl skill list --all # full catalog (full-access accounts only)
|
|
294
|
+
tl skill download my-skill # fetch and install into every AI-agent skill directory
|
|
295
|
+
tl skill download my-skill --force # overwrite a directory tl doesn't already manage
|
|
296
|
+
tl skill update # refresh every downloaded skill to its latest version
|
|
297
|
+
tl skill remove my-skill # uninstall a downloaded skill
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
A directory `tl skill download` doesn't already manage (no prior `tl`-managed install there) is left alone unless `--force` is passed — it never overwrites a skill you installed some other way. `tl` warns once a day, on any command, when a downloaded skill has a newer version available. `tl setup` and self-update re-syncs also respect these directories: a downloaded skill is never rmtree'd or overwritten, even if its name collides with a bundled skill.
|
|
301
|
+
|
|
287
302
|
## Output Formats
|
|
288
303
|
|
|
289
304
|
By default, output is a styled table in the terminal and JSON when piped.
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
tl_cli/__init__.py,sha256=
|
|
1
|
+
tl_cli/__init__.py,sha256=vSA_gOKoNEuGKTTDXEGFTK9NgDyrC9oLOwpvRELGmLg,112
|
|
2
2
|
tl_cli/_completions.py,sha256=kOyEUqC26vbYvyXWi513WX8fF73qQLR5WWuRSe_wqyk,164
|
|
3
3
|
tl_cli/_typer_utils.py,sha256=ZiZsCVmEznPvBw-dYbr3tu3zWZ0iN6kjoQmK3gMqD28,860
|
|
4
4
|
tl_cli/config.py,sha256=UV_OYTXuQnAIqbi_oVCXx0hhIdZWR678RRapVv51UwQ,1859
|
|
5
5
|
tl_cli/filters.py,sha256=Yjj04lONGGmtCAIYN5i6KD12mAnUKEZ7i9heeWtdj7o,2952
|
|
6
6
|
tl_cli/hints.py,sha256=cT8kuDtkAZqwXkc2RV0Yg_abofK-g9UiXwTTBunX78U,1557
|
|
7
|
-
tl_cli/main.py,sha256=
|
|
7
|
+
tl_cli/main.py,sha256=ZCzbgemmKlcIVlk_6TVznXooSsTBAjQ01k1olwQLhJQ,6435
|
|
8
8
|
tl_cli/query_history.py,sha256=vA5avDU1DBZDWSb-w2ZxEZxV2MD21cqwzfYjXawOWZI,5540
|
|
9
9
|
tl_cli/self_update.py,sha256=zLPCSzPQTHRAcTZTBBopRxAs5Bg-B7Ag_7PxfN94QOI,18800
|
|
10
|
+
tl_cli/skill_registry.py,sha256=slB3K0tI_-mrPT7Eswzq2Sjlo8Wcwk5nDy0w9yQh6mM,11957
|
|
10
11
|
tl_cli/auth/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
12
|
tl_cli/auth/commands.py,sha256=CkCaKFb-xUwhCeIL92EC4-odiaSpLI1bmgg5tDCrclM,7387
|
|
12
13
|
tl_cli/auth/login.py,sha256=AxdQ8LOZd1uZhFXPyaiGB_Hk0RiVuX7t37gkjgoEOCM,11568
|
|
@@ -14,16 +15,16 @@ tl_cli/auth/pkce.py,sha256=4Q6Ip-TeZFNG9c3swXNi4gH7mdMkltKa62gZZNybt8U,658
|
|
|
14
15
|
tl_cli/auth/token_store.py,sha256=TcZnUol4-8r0jMEJhOPmABCX12_5RkAln2xfWPNdmHk,3275
|
|
15
16
|
tl_cli/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
17
|
tl_cli/client/errors.py,sha256=HEAEeFIgw_n_ZE9bkRRw6OPyqmwGhixzNN2bAxYj7wg,3246
|
|
17
|
-
tl_cli/client/http.py,sha256=
|
|
18
|
+
tl_cli/client/http.py,sha256=vcrNMJ5Z91-DXYq-vBH55Miii3QRnq2X1kIllMQ8yw8,4565
|
|
18
19
|
tl_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
19
20
|
tl_cli/commands/_comments_common.py,sha256=x91WPSpDdV8AwE8Mwx4VtZQKWXBufsnBnC1lFD0Tzys,4522
|
|
20
|
-
tl_cli/commands/balance.py,sha256=
|
|
21
|
+
tl_cli/commands/balance.py,sha256=OubiqknmLKREUzk2V0FZFtqgb4_dqQMR4V5-ifz-wfk,3150
|
|
21
22
|
tl_cli/commands/brands.py,sha256=uogOfAdEbpjqiGlv4L2T1UC-Iz10G6fkRhVsDBOjRIY,15340
|
|
22
23
|
tl_cli/commands/bulk_import.py,sha256=d4y1k_lD52LPJcCqXxEmyHIqcIwomZgbjqs1_QxPjeQ,4536
|
|
23
24
|
tl_cli/commands/changelog.py,sha256=D1PtDdHpawTlWqUHjKzVmv9yXLSU915UVmI3dZzEwyA,4241
|
|
24
25
|
tl_cli/commands/channels.py,sha256=gs6jHaxi6WjB3D8jnJETFVvkTXpqzlWR-W4JkBeG3oY,20406
|
|
25
26
|
tl_cli/commands/credits.py,sha256=2xCht2e420LmaFBKNdKoMz8GlTh31qSWSlJAnVzoZic,7308
|
|
26
|
-
tl_cli/commands/db.py,sha256=
|
|
27
|
+
tl_cli/commands/db.py,sha256=RcuGYzmTs5KMk6RgYao3oV-j883dG7IonTQclZg7UZs,9148
|
|
27
28
|
tl_cli/commands/deals.py,sha256=ZK9yneInsC6DXoCPS65oyLoVR0eRW1xdRlEN7oRp1pc,2174
|
|
28
29
|
tl_cli/commands/describe.py,sha256=3lURv4NllM5qPeMEBbejrIxiMzsyTwpJIz211-wuGCU,12362
|
|
29
30
|
tl_cli/commands/doctor.py,sha256=KUKglwhMc7B26XXy_3M0LkHu7wqfFO5T0YPHO1SH1VY,9024
|
|
@@ -33,7 +34,8 @@ tl_cli/commands/proposals.py,sha256=IXx9DsR7lPWRM4QvKB65t7xKNAK3H6ax9LIlIj8ydEs,
|
|
|
33
34
|
tl_cli/commands/recommender.py,sha256=BZfviKAPhpZah0zaSyH5RDa23fG8QAkUnG5FDhOdi4I,21324
|
|
34
35
|
tl_cli/commands/reports.py,sha256=7CL2atr0Y4XSE8U8kbuZ5-M-m3E9PKNZ0N_0Fbd_OkQ,26388
|
|
35
36
|
tl_cli/commands/schema.py,sha256=rmfg5pvKHa5kQi59YBIHCMzBNawmYPi2tBMcZP7Vrxw,6432
|
|
36
|
-
tl_cli/commands/setup.py,sha256=
|
|
37
|
+
tl_cli/commands/setup.py,sha256=p71pfwbIlMJoq2TaOfy35_absD9VMSX792FlapmQv2Y,29933
|
|
38
|
+
tl_cli/commands/skills.py,sha256=4Vg5ph6T7T3WcIqIZc83EJPhtB79FE7fMej9uVhElqc,15884
|
|
37
39
|
tl_cli/commands/snapshots.py,sha256=exlxFwMcM1BCiDTcE9dZfaSdRO-sxaTxtWzJmEoUVWI,3434
|
|
38
40
|
tl_cli/commands/sponsorships.py,sha256=PdWCm9E4mrs_qlHtckMp99JmuRP94hthlqoWMi0k4tc,10471
|
|
39
41
|
tl_cli/commands/uploads.py,sha256=Tf9tqAEm9FGe3A7sr_EDX9OzdNInCmrWNr10wWGuMUo,1526
|
|
@@ -42,7 +44,7 @@ tl_cli/commands/workflows.py,sha256=ctduYa--Md6zbs8kQwO6mSyR9o1KB1UpNOaANuupDfU,
|
|
|
42
44
|
tl_cli/output/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
43
45
|
tl_cli/output/formatter.py,sha256=2FOsZb9aGcjKAGvumtEyS63cjIk47JcQGNM0K7Gpdp8,23413
|
|
44
46
|
tl_cli/_plugin/.claude-plugin/marketplace.json,sha256=l56PMmyjfGXNGlV30wRyOAe74B6gJNCVNCxgsBbSNxc,446
|
|
45
|
-
tl_cli/_plugin/.claude-plugin/plugin.json,sha256=
|
|
47
|
+
tl_cli/_plugin/.claude-plugin/plugin.json,sha256=lu-QQlhwxsIG1s6N8GFNEBGMfYek73Al0_1uTolvsC4,466
|
|
46
48
|
tl_cli/_plugin/agents/keyword-context-classifier.md,sha256=8Pk6WqJKuF-ERkBh54Aa-9t60CvjdOIH0RurFlG-T7I,4325
|
|
47
49
|
tl_cli/_plugin/agents/keyword-entity-resolver.md,sha256=fOAeTZa2-h7te8dEXLFcmiL2tj6IXwnQ0ChUrym3yFc,5619
|
|
48
50
|
tl_cli/_plugin/agents/keyword-relevance-validator.md,sha256=uPUqobAVT7xq-brRHtNaFSTns-lYyCu3MwTuU5tn538,3127
|
|
@@ -52,7 +54,7 @@ tl_cli/_plugin/hooks/hooks.json,sha256=FSWibw1xAjA-suFV3fR8btIb2kQ82LQ08otTr-Npm
|
|
|
52
54
|
tl_cli/_plugin/hooks/scripts/load-tl-skill.mjs,sha256=EBsyZ-caei-CBJsRtqzJXJs_20O3H22MuVmDpu96umo,805
|
|
53
55
|
tl_cli/_plugin/hooks/scripts/post-usage.sh,sha256=WVvZLkZik6lbeZ20Kh-wgm4JkRFHFN0Uwl4C8S3Y0sY,759
|
|
54
56
|
tl_cli/_plugin/hooks/scripts/pre-check.sh,sha256=inHopPXQ9h-KRcHuebEIQ-1EZmsTaHhAAMCFQ0MyiEQ,820
|
|
55
|
-
tl_cli/_plugin/skills/tl/SKILL.md,sha256=
|
|
57
|
+
tl_cli/_plugin/skills/tl/SKILL.md,sha256=mFgTCMcYkEvXCO0f3uqFaQcHOJ2rJh6iZ46AdkziuVc,67959
|
|
56
58
|
tl_cli/_plugin/skills/tl/references/business-glossary.md,sha256=YZ_UWygtAZB8Mfh9NPG2ld0OnmgknSBzBOpVZwN-wzs,19287
|
|
57
59
|
tl_cli/_plugin/skills/tl/references/elasticsearch-schema.md,sha256=x7ZUYGbR6FJG02BSA9BjveoiC4X-7VsYxhLPBK4IH_c,23195
|
|
58
60
|
tl_cli/_plugin/skills/tl/references/firebolt-schema.md,sha256=tysPKBxqQFRzfKi2vmEhHXn8PGaznep7yaKwSWZY3Jo,11110
|
|
@@ -107,8 +109,8 @@ tl_cli/_plugin/skills/tl-top-partnerships/SKILL.md,sha256=jOMMr40XRnAZv-oRLeyKJT
|
|
|
107
109
|
tl_cli/_plugin/skills/tl-top-partnerships/scripts/top_partnerships.py,sha256=OqhoyvFe1zrtrStChcam7oPYgFnSbGHC3WAgLhLIC3w,13738
|
|
108
110
|
tl_cli/_plugin/skills/tl-views-guarantee/SKILL.md,sha256=IH7q1WJDWri9TWJMiga1FMGJO_GKSbWwaDS6CVNZ9c0,9270
|
|
109
111
|
tl_cli/_plugin/skills/tl-views-guarantee/scripts/vg.py,sha256=Qp5poinHEqh9374anq0bLtlxj2YL6ipBicaT960-Cws,15825
|
|
110
|
-
thoughtleaders_cli-0.9.
|
|
111
|
-
thoughtleaders_cli-0.9.
|
|
112
|
-
thoughtleaders_cli-0.9.
|
|
113
|
-
thoughtleaders_cli-0.9.
|
|
114
|
-
thoughtleaders_cli-0.9.
|
|
112
|
+
thoughtleaders_cli-0.9.4.dist-info/METADATA,sha256=-BYaTIApwQ4wVdRWEc99Dr-7BhfIpCHK7d858ufwoFg,20457
|
|
113
|
+
thoughtleaders_cli-0.9.4.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
114
|
+
thoughtleaders_cli-0.9.4.dist-info/entry_points.txt,sha256=umZp-1BkGkHDG0bNZXpTXrjwW0HGf9IDFN40eAWuuvg,39
|
|
115
|
+
thoughtleaders_cli-0.9.4.dist-info/licenses/LICENSE,sha256=RUfdfLsn6jygiyrnnVUHt6r4IPwr2rbDm9Kixgtu8fo,1071
|
|
116
|
+
thoughtleaders_cli-0.9.4.dist-info/RECORD,,
|
tl_cli/__init__.py
CHANGED
|
@@ -251,9 +251,8 @@ contact field:
|
|
|
251
251
|
|
|
252
252
|
The channel's full email archive (`all_emails` — a JSON object keyed by email address,
|
|
253
253
|
each value recording when and where the address was found) is **not editable here**;
|
|
254
|
-
sending it returns a 400. It is append-only
|
|
255
|
-
|
|
256
|
-
modifies or removes existing entries.
|
|
254
|
+
sending it returns a 400. It is append-only and managed through an internal-only
|
|
255
|
+
process that never modifies or removes existing entries — not available via this CLI.
|
|
257
256
|
|
|
258
257
|
**Profile notes.** `tl profiles update` (superuser only) edits a brand/publisher profile's
|
|
259
258
|
**`superuser_notes`** — the internal free-text notes field on the customer record
|
|
@@ -503,6 +502,21 @@ tl changelog since v0.4.10 # Notes from v0.4.10 to latest
|
|
|
503
502
|
tl changelog --md > CHANGELOG.md # Capture for a doc
|
|
504
503
|
```
|
|
505
504
|
|
|
505
|
+
#### Distributed skills — beyond the bundled set
|
|
506
|
+
|
|
507
|
+
An organization can be granted additional skills that aren't part of a CLI release. `tl skill` fetches and installs them on demand into the same directories `tl setup` uses (Claude Code's standalone skills directory, OpenCode's skills directory, and the directory shared by Gemini and Codex), so every supported agent picks them up automatically — no restart or reconfiguration needed beyond re-reading the skill listing.
|
|
508
|
+
|
|
509
|
+
```bash
|
|
510
|
+
tl skill list # Skills available to your org, with installed/latest versions (free)
|
|
511
|
+
tl skill list --all # Full catalog (full-access accounts only)
|
|
512
|
+
tl skill download <name> # Fetch and install into every AI-agent skill directory on this machine
|
|
513
|
+
tl skill download <name> --force # Overwrite a directory tl doesn't already manage
|
|
514
|
+
tl skill update # Refresh every downloaded skill to its latest version
|
|
515
|
+
tl skill remove <name> # Uninstall a downloaded skill
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
If a user asks to find, add, or refresh a skill by capability ("is there a skill for X", "get me the latest Y skill"), check `tl skill list` before saying no — the bundled set isn't the whole catalog. `tl skill download` never overwrites a directory it doesn't already manage unless `--force` is passed, so it's safe to run without clobbering a manually-installed skill of the same name. `tl` warns once a day, on any command, when a downloaded skill has a newer version available — surface that nag to the user rather than silently ignoring it, and offer to run `tl skill update`.
|
|
519
|
+
|
|
506
520
|
#### Channel & video discovery — pick the path for the question shape
|
|
507
521
|
|
|
508
522
|
Four first-class paths, each with a different signal. **Pick by the SHAPE of the user's question, not by habit.** "Recommender first" is the right default only for path 2 — for paths 1, 3, and 4 the recommender is the wrong tool.
|
tl_cli/client/http.py
CHANGED
|
@@ -23,8 +23,8 @@ class TLClient:
|
|
|
23
23
|
},
|
|
24
24
|
)
|
|
25
25
|
|
|
26
|
-
def get(self, path: str, params: dict | None = None) -> dict:
|
|
27
|
-
return self._request("GET", path, params=params)
|
|
26
|
+
def get(self, path: str, params: dict | None = None, timeout: float | None = None) -> dict:
|
|
27
|
+
return self._request("GET", path, params=params, timeout=timeout)
|
|
28
28
|
|
|
29
29
|
def post(self, path: str, json_body: dict | None = None) -> dict:
|
|
30
30
|
return self._request("POST", path, json_body=json_body)
|
|
@@ -38,20 +38,24 @@ class TLClient:
|
|
|
38
38
|
path: str,
|
|
39
39
|
params: dict | None = None,
|
|
40
40
|
json_body: dict | None = None,
|
|
41
|
+
timeout: float | None = None,
|
|
41
42
|
) -> dict:
|
|
42
43
|
headers = self._auth_headers()
|
|
44
|
+
# Only overrides the client's default timeout when the caller asks
|
|
45
|
+
# for one — passing `timeout=None` through to httpx means "no
|
|
46
|
+
# timeout", not "use the default", so it's omitted entirely here.
|
|
47
|
+
request_kwargs = {"params": params, "json": json_body, "headers": headers}
|
|
48
|
+
if timeout is not None:
|
|
49
|
+
request_kwargs["timeout"] = timeout
|
|
43
50
|
|
|
44
|
-
response = self._client.request(
|
|
45
|
-
method, path, params=params, json=json_body, headers=headers
|
|
46
|
-
)
|
|
51
|
+
response = self._client.request(method, path, **request_kwargs)
|
|
47
52
|
|
|
48
53
|
# On 401, try refreshing the token once
|
|
49
54
|
if response.status_code == 401:
|
|
50
55
|
headers = self._refresh_and_get_headers()
|
|
51
56
|
if headers:
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
)
|
|
57
|
+
request_kwargs["headers"] = headers
|
|
58
|
+
response = self._client.request(method, path, **request_kwargs)
|
|
55
59
|
|
|
56
60
|
if response.status_code >= 400:
|
|
57
61
|
detail = self._extract_detail(response)
|
tl_cli/commands/balance.py
CHANGED
|
@@ -44,6 +44,12 @@ def balance(
|
|
|
44
44
|
allow_overage = data.get("allow_overage", False)
|
|
45
45
|
|
|
46
46
|
console.print(f"\n[bold]Credit Balance:[/bold] [cyan]{balance_val}[/cyan] credits")
|
|
47
|
+
# Older servers return only the combined balance; newer ones split it
|
|
48
|
+
# into the plan-granted (top-up) pool and the purchased pool.
|
|
49
|
+
topup_val = data.get("topup_balance")
|
|
50
|
+
purchased_val = data.get("purchased_balance")
|
|
51
|
+
if topup_val is not None and purchased_val is not None:
|
|
52
|
+
console.print(f"[dim]Plan (top-up) credits: {topup_val} · Purchased: {purchased_val}[/dim]")
|
|
47
53
|
if allow_overage:
|
|
48
54
|
console.print("[dim]Overage: enabled[/dim]")
|
|
49
55
|
|
tl_cli/commands/db.py
CHANGED
|
@@ -99,9 +99,11 @@ def pg_cmd(
|
|
|
99
99
|
Common traps — check before writing SQL:
|
|
100
100
|
|
|
101
101
|
- Column names follow the current schema: subscribers, projected_views,
|
|
102
|
-
scheduled_date, is_tpp, advertiser_profile_id, views_guarantee
|
|
103
|
-
older reach / impression / send_date /
|
|
104
|
-
/ impressions_guarantee.
|
|
102
|
+
scheduled_date, is_tpp, advertiser_profile_id, views_guarantee, url (the
|
|
103
|
+
channel's own URL) — NOT the older reach / impression / send_date /
|
|
104
|
+
is_tl_channel / creator_profile_id / impressions_guarantee / channel_url.
|
|
105
|
+
- thoughtleaders_adlink has no bare `url` column — the live sponsored-video
|
|
106
|
+
URL is `media_url`.
|
|
105
107
|
- The sponsorship table (thoughtleaders_adlink) hides its relations behind
|
|
106
108
|
joins: no channel_id/channel_name (join thoughtleaders_adspot via
|
|
107
109
|
ad_spot_id, then thoughtleaders_channel) and no brand_id (join
|
tl_cli/commands/setup.py
CHANGED
|
@@ -21,6 +21,7 @@ from pytoon import encode as toon_encode
|
|
|
21
21
|
from rich.console import Console
|
|
22
22
|
|
|
23
23
|
from tl_cli import __version__
|
|
24
|
+
from tl_cli.skill_registry import is_marked_for
|
|
24
25
|
|
|
25
26
|
app = typer.Typer(cls=AlphaSortedTyperGroup, help="Set up integrations (Claude Code, OpenCode, Gemini, Codex)")
|
|
26
27
|
console = Console(stderr=True)
|
|
@@ -201,6 +202,20 @@ def check_plugin_version() -> list[str]:
|
|
|
201
202
|
return warnings
|
|
202
203
|
|
|
203
204
|
|
|
205
|
+
def _warn_marker_skip(path: Path, name: str) -> None:
|
|
206
|
+
"""Print the standard warning when setup leaves a tl-managed skill dir alone.
|
|
207
|
+
|
|
208
|
+
Shared by every place in this module that would otherwise rmtree/copytree
|
|
209
|
+
over a skill directory — a directory carrying a valid `.tl-skill.json`
|
|
210
|
+
marker was installed by `tl skill download` and must never be clobbered
|
|
211
|
+
by a bundled-skill sync, regardless of name collision or content match.
|
|
212
|
+
"""
|
|
213
|
+
console.print(
|
|
214
|
+
f" [yellow]![/yellow] skipping {path}: managed by 'tl skill' downloads — "
|
|
215
|
+
f"remove with [cyan]tl skill remove {name}[/cyan] if you want the bundled version"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
204
219
|
def _install_standalone_skills(plugin_root: Path) -> int:
|
|
205
220
|
"""Copy skills and commands to ~/.claude/ for non-namespaced invocation.
|
|
206
221
|
|
|
@@ -214,6 +229,9 @@ def _install_standalone_skills(plugin_root: Path) -> int:
|
|
|
214
229
|
for skill_dir in skills_src.iterdir():
|
|
215
230
|
if skill_dir.is_dir() and (skill_dir / "SKILL.md").is_file():
|
|
216
231
|
dst = CLAUDE_SKILLS_DIR / skill_dir.name
|
|
232
|
+
if dst.exists() and is_marked_for(dst, skill_dir.name):
|
|
233
|
+
_warn_marker_skip(dst, skill_dir.name)
|
|
234
|
+
continue
|
|
217
235
|
if dst.exists():
|
|
218
236
|
shutil.rmtree(dst)
|
|
219
237
|
shutil.copytree(skill_dir, dst)
|
|
@@ -279,6 +297,11 @@ def _remove_matching_standalone_skills(plugin_root: Path) -> tuple[int, int]:
|
|
|
279
297
|
standalone = CLAUDE_SKILLS_DIR / skill_dir.name
|
|
280
298
|
if not standalone.is_dir():
|
|
281
299
|
continue
|
|
300
|
+
if is_marked_for(standalone, skill_dir.name):
|
|
301
|
+
# tl-managed download — excluded from deletion candidates entirely,
|
|
302
|
+
# regardless of whether its content happens to match the bundled skill.
|
|
303
|
+
_warn_marker_skip(standalone, skill_dir.name)
|
|
304
|
+
continue
|
|
282
305
|
if _trees_identical(skill_dir, standalone):
|
|
283
306
|
shutil.rmtree(standalone)
|
|
284
307
|
removed += 1
|
|
@@ -537,7 +560,10 @@ def _install_skill_trees(plugin_root: Path, target_dir: Path) -> int:
|
|
|
537
560
|
(OpenCode, Gemini, Codex) — each agent reads skills from a different
|
|
538
561
|
base directory, so we just parameterise on that. A `.tl-version`
|
|
539
562
|
stamp is written into the target so `check_plugin_version()` can
|
|
540
|
-
detect drift later.
|
|
563
|
+
detect drift later. A destination carrying a `.tl-skill.json` marker
|
|
564
|
+
for this skill name is a `tl skill download` install — it's skipped
|
|
565
|
+
(with a warning) rather than clobbered, and isn't counted in the
|
|
566
|
+
returned total. Returns the number of skills installed.
|
|
541
567
|
"""
|
|
542
568
|
count = 0
|
|
543
569
|
skills_src = plugin_root / "skills"
|
|
@@ -545,6 +571,9 @@ def _install_skill_trees(plugin_root: Path, target_dir: Path) -> int:
|
|
|
545
571
|
for skill_dir in skills_src.iterdir():
|
|
546
572
|
if skill_dir.is_dir() and (skill_dir / "SKILL.md").is_file():
|
|
547
573
|
dst = target_dir / skill_dir.name
|
|
574
|
+
if dst.exists() and is_marked_for(dst, skill_dir.name):
|
|
575
|
+
_warn_marker_skip(dst, skill_dir.name)
|
|
576
|
+
continue
|
|
548
577
|
if dst.exists():
|
|
549
578
|
shutil.rmtree(dst)
|
|
550
579
|
shutil.copytree(skill_dir, dst)
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
"""tl skill — Download, list, update, and remove server-distributed skills.
|
|
2
|
+
|
|
3
|
+
Distinct from the bundled skill set `tl setup claude|opencode|gemini|codex`
|
|
4
|
+
installs: these are skills an organization has been granted access to on
|
|
5
|
+
the server, fetched and installed on demand.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import shutil
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
|
|
16
|
+
from tl_cli._typer_utils import AlphaSortedTyperGroup
|
|
17
|
+
from tl_cli.client.errors import ApiError, handle_api_error
|
|
18
|
+
from tl_cli.client.http import get_client
|
|
19
|
+
from tl_cli.commands.setup import AGENTS_SKILLS_DIR, CLAUDE_SKILLS_DIR, OPENCODE_SKILLS_DIR
|
|
20
|
+
from tl_cli.output.formatter import detect_format, output
|
|
21
|
+
from tl_cli.skill_registry import (
|
|
22
|
+
InvalidSkillNameError,
|
|
23
|
+
PathSafetyError,
|
|
24
|
+
install_skill_tree,
|
|
25
|
+
is_marked_for,
|
|
26
|
+
read_registry,
|
|
27
|
+
read_staleness_cache,
|
|
28
|
+
validate_files,
|
|
29
|
+
validate_skill_name,
|
|
30
|
+
write_registry,
|
|
31
|
+
write_staleness_cache,
|
|
32
|
+
write_staleness_failure,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
app = typer.Typer(cls=AlphaSortedTyperGroup, help="Download, list, update, and remove distributed skills")
|
|
36
|
+
console = Console(stderr=True)
|
|
37
|
+
|
|
38
|
+
COLUMNS = ["name", "latest_version", "installed_version", "status", "description"]
|
|
39
|
+
|
|
40
|
+
# Kept well under the client's default 30s timeout: this call runs on every
|
|
41
|
+
# `tl` invocation in the background, so a hung/slow server should fail fast
|
|
42
|
+
# rather than stall the user's actual command for up to 30s.
|
|
43
|
+
STALENESS_CHECK_TIMEOUT_SECONDS = 5.0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _install_targets() -> list[Path]:
|
|
47
|
+
"""Every skill-directory root a downloaded skill installs into.
|
|
48
|
+
|
|
49
|
+
Mirrors the harness targets `tl setup` uses for the bundled skill set:
|
|
50
|
+
Claude Code's standalone skills directory (the plugin/marketplace
|
|
51
|
+
channel only ships the bundled set via GitHub releases and can't
|
|
52
|
+
deliver a single downloaded skill on its own), OpenCode's skills
|
|
53
|
+
directory, and the directory shared by Gemini and Codex. Reused
|
|
54
|
+
directly from `tl_cli.commands.setup` rather than duplicated here.
|
|
55
|
+
"""
|
|
56
|
+
return [CLAUDE_SKILLS_DIR, OPENCODE_SKILLS_DIR, AGENTS_SKILLS_DIR]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _truncate(text: str, limit: int = 80) -> str:
|
|
60
|
+
if len(text) <= limit:
|
|
61
|
+
return text
|
|
62
|
+
return text[: limit - 1] + "…"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _install_one(
|
|
66
|
+
dest: Path,
|
|
67
|
+
*,
|
|
68
|
+
name: str,
|
|
69
|
+
version: str,
|
|
70
|
+
checksum: str,
|
|
71
|
+
files: dict[str, str],
|
|
72
|
+
force: bool,
|
|
73
|
+
) -> dict:
|
|
74
|
+
"""Install a skill tree into a single target directory.
|
|
75
|
+
|
|
76
|
+
A destination that already exists and carries our marker for `name` is
|
|
77
|
+
always replaced. A destination that exists without our marker is
|
|
78
|
+
refused unless `force` is set.
|
|
79
|
+
"""
|
|
80
|
+
if dest.exists() and not is_marked_for(dest, name):
|
|
81
|
+
if not force:
|
|
82
|
+
return {
|
|
83
|
+
"path": str(dest),
|
|
84
|
+
"installed": False,
|
|
85
|
+
"action": "refused",
|
|
86
|
+
"reason": "already exists and is not managed by tl (use --force to overwrite)",
|
|
87
|
+
}
|
|
88
|
+
action = "overwritten"
|
|
89
|
+
elif dest.exists():
|
|
90
|
+
action = "replaced"
|
|
91
|
+
else:
|
|
92
|
+
action = "installed"
|
|
93
|
+
|
|
94
|
+
install_skill_tree(files, dest, name=name, version=version, checksum=checksum)
|
|
95
|
+
return {"path": str(dest), "installed": True, "action": action}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _fetch_skill(name: str) -> dict:
|
|
99
|
+
"""GET /skills/<name>/ and return its `results` block."""
|
|
100
|
+
client = get_client()
|
|
101
|
+
try:
|
|
102
|
+
data = client.get(f"/skills/{name}/")
|
|
103
|
+
finally:
|
|
104
|
+
client.close()
|
|
105
|
+
return data.get("results", data)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _download_and_install(name: str, *, force: bool) -> dict:
|
|
109
|
+
"""Fetch a skill and install it to every target directory.
|
|
110
|
+
|
|
111
|
+
Shared by `tl skill download` and `tl skill update` — `update` always
|
|
112
|
+
passes `force=False`: a directory that already carries our marker for
|
|
113
|
+
this skill is replaced regardless of `force`, so the flag only matters
|
|
114
|
+
for a directory `tl` doesn't recognize as its own, which `update`
|
|
115
|
+
should never silently clobber.
|
|
116
|
+
"""
|
|
117
|
+
validate_skill_name(name)
|
|
118
|
+
result = _fetch_skill(name)
|
|
119
|
+
version = result["version"]
|
|
120
|
+
checksum = result["checksum"]
|
|
121
|
+
files = result["files"]
|
|
122
|
+
|
|
123
|
+
validate_files(files)
|
|
124
|
+
|
|
125
|
+
targets: list[dict] = []
|
|
126
|
+
installed_count = 0
|
|
127
|
+
for target_root in _install_targets():
|
|
128
|
+
dest = target_root / name
|
|
129
|
+
outcome = _install_one(dest, name=name, version=version, checksum=checksum, files=files, force=force)
|
|
130
|
+
targets.append(outcome)
|
|
131
|
+
if outcome["installed"]:
|
|
132
|
+
installed_count += 1
|
|
133
|
+
|
|
134
|
+
if installed_count:
|
|
135
|
+
registry = read_registry()
|
|
136
|
+
registry.setdefault("skills", {})[name] = {
|
|
137
|
+
"version": version,
|
|
138
|
+
"checksum": checksum,
|
|
139
|
+
"paths": [t["path"] for t in targets if t["installed"]],
|
|
140
|
+
"installed_at": datetime.now(timezone.utc).isoformat(),
|
|
141
|
+
}
|
|
142
|
+
write_registry(registry)
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
"name": name,
|
|
146
|
+
"version": version,
|
|
147
|
+
"checksum": checksum,
|
|
148
|
+
"targets": targets,
|
|
149
|
+
"installed_count": installed_count,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def check_skill_staleness() -> str | None:
|
|
154
|
+
"""Per-run staleness nag for downloaded skills.
|
|
155
|
+
|
|
156
|
+
Entirely best-effort: any failure (empty registry, no auth, network
|
|
157
|
+
down, corrupt cache, server error) silently returns None — this must
|
|
158
|
+
never break or slow down normal CLI usage. Only hits the network when
|
|
159
|
+
the cache is empty or older than 24h; otherwise reuses the cached
|
|
160
|
+
result. A *failed* network call is itself cached for a short backoff
|
|
161
|
+
window (`STALENESS_FAILURE_TTL_SECONDS`) so an unreachable/slow server
|
|
162
|
+
doesn't force a retry — and its accompanying httpx timeout — on every
|
|
163
|
+
single `tl` invocation; a success later overwrites that stamp and
|
|
164
|
+
resumes the normal 24h cache. Returns at most one short line naming
|
|
165
|
+
every skill that has drifted from its latest available version, or
|
|
166
|
+
None when nothing has (or the check was skipped/failed).
|
|
167
|
+
"""
|
|
168
|
+
try:
|
|
169
|
+
registry = read_registry()
|
|
170
|
+
installed = registry.get("skills", {})
|
|
171
|
+
if not installed:
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
cache = read_staleness_cache()
|
|
175
|
+
if cache is not None:
|
|
176
|
+
if cache.get("failed"):
|
|
177
|
+
return None
|
|
178
|
+
results = cache["results"]
|
|
179
|
+
else:
|
|
180
|
+
client = get_client()
|
|
181
|
+
try:
|
|
182
|
+
names = ",".join(sorted(installed.keys()))
|
|
183
|
+
data = client.get("/skills/versions/", params={"names": names}, timeout=STALENESS_CHECK_TIMEOUT_SECONDS)
|
|
184
|
+
except Exception:
|
|
185
|
+
write_staleness_failure()
|
|
186
|
+
return None
|
|
187
|
+
finally:
|
|
188
|
+
client.close()
|
|
189
|
+
results = data.get("results", {})
|
|
190
|
+
write_staleness_cache(results)
|
|
191
|
+
|
|
192
|
+
outdated = sorted(name for name, info in installed.items() if results.get(name) and results.get(name) != info.get("version"))
|
|
193
|
+
if not outdated:
|
|
194
|
+
return None
|
|
195
|
+
return f"{len(outdated)} skill(s) outdated ({', '.join(outdated)}) — run `tl skill update`"
|
|
196
|
+
except Exception:
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# --- Typer app -------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@app.callback(invoke_without_command=True)
|
|
204
|
+
def skill(ctx: typer.Context) -> None:
|
|
205
|
+
"""Download, list, update, and remove skills distributed to your organization."""
|
|
206
|
+
if ctx.invoked_subcommand is None:
|
|
207
|
+
ctx.invoke(list_cmd, all_=False, json_output=False, csv_output=False, md_output=False, toon_output=False)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@app.command("list")
|
|
211
|
+
def list_cmd(
|
|
212
|
+
all_: bool = typer.Option(False, "--all", help="List the full catalog (full-access accounts only)"),
|
|
213
|
+
json_output: bool = typer.Option(False, "--json", help="JSON output"),
|
|
214
|
+
csv_output: bool = typer.Option(False, "--csv", help="CSV output"),
|
|
215
|
+
md_output: bool = typer.Option(False, "--md", help="Markdown output"),
|
|
216
|
+
toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
|
|
217
|
+
) -> None:
|
|
218
|
+
"""List skills available to your organization (free).
|
|
219
|
+
|
|
220
|
+
Examples:
|
|
221
|
+
tl skill list
|
|
222
|
+
tl skill list --all # full catalog (full-access accounts only)
|
|
223
|
+
"""
|
|
224
|
+
fmt = detect_format(json_output, csv_output, md_output, toon_output)
|
|
225
|
+
client = get_client()
|
|
226
|
+
try:
|
|
227
|
+
params = {"all": "1"} if all_ else None
|
|
228
|
+
data = client.get("/skills/", params=params)
|
|
229
|
+
except ApiError as e:
|
|
230
|
+
handle_api_error(e)
|
|
231
|
+
return
|
|
232
|
+
finally:
|
|
233
|
+
client.close()
|
|
234
|
+
|
|
235
|
+
registry = read_registry()
|
|
236
|
+
installed = registry.get("skills", {})
|
|
237
|
+
|
|
238
|
+
rows = []
|
|
239
|
+
for item in data.get("results", []):
|
|
240
|
+
name = item.get("name")
|
|
241
|
+
latest = item.get("version")
|
|
242
|
+
inst = installed.get(name)
|
|
243
|
+
installed_version = inst["version"] if inst else None
|
|
244
|
+
outdated = bool(inst and latest and installed_version != latest)
|
|
245
|
+
rows.append(
|
|
246
|
+
{
|
|
247
|
+
"name": name,
|
|
248
|
+
"latest_version": latest,
|
|
249
|
+
"installed_version": installed_version or "—",
|
|
250
|
+
"status": "outdated" if outdated else "",
|
|
251
|
+
"description": _truncate(item.get("description") or ""),
|
|
252
|
+
}
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
if not rows:
|
|
256
|
+
if fmt == "json":
|
|
257
|
+
print(json.dumps({"results": []}, indent=2))
|
|
258
|
+
else:
|
|
259
|
+
console.print("[dim]No skills available for your organization.[/dim]")
|
|
260
|
+
return
|
|
261
|
+
|
|
262
|
+
envelope = {
|
|
263
|
+
"results": rows,
|
|
264
|
+
"total": len(rows),
|
|
265
|
+
"usage": data.get("usage"),
|
|
266
|
+
"_breadcrumbs": data.get("_breadcrumbs", []),
|
|
267
|
+
}
|
|
268
|
+
output(envelope, fmt, columns=COLUMNS, title="Skills")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@app.command("download")
|
|
272
|
+
def download_cmd(
|
|
273
|
+
name: str = typer.Argument(..., help="Skill name"),
|
|
274
|
+
force: bool = typer.Option(False, "--force", help="Overwrite an existing directory that isn't tl-managed"),
|
|
275
|
+
json_output: bool = typer.Option(False, "--json", help="JSON output"),
|
|
276
|
+
toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
|
|
277
|
+
) -> None:
|
|
278
|
+
"""Download a skill and install it into every AI-agent skill directory on this machine.
|
|
279
|
+
|
|
280
|
+
Installs into Claude Code's standalone skills directory, OpenCode's
|
|
281
|
+
skills directory, and the directory shared by Gemini and Codex — the
|
|
282
|
+
same targets `tl setup` uses for the bundled skill set. A destination
|
|
283
|
+
that already exists and isn't tracked by a previous `tl skill download`
|
|
284
|
+
is refused unless --force is passed; a destination tl previously
|
|
285
|
+
installed is always replaced with the new version.
|
|
286
|
+
|
|
287
|
+
Examples:
|
|
288
|
+
tl skill download my-skill
|
|
289
|
+
tl skill download my-skill --force
|
|
290
|
+
"""
|
|
291
|
+
fmt = detect_format(json_output, False, False, toon_output)
|
|
292
|
+
try:
|
|
293
|
+
result = _download_and_install(name, force=force)
|
|
294
|
+
except InvalidSkillNameError as e:
|
|
295
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
296
|
+
raise typer.Exit(1)
|
|
297
|
+
except ApiError as e:
|
|
298
|
+
handle_api_error(e)
|
|
299
|
+
return
|
|
300
|
+
except PathSafetyError as e:
|
|
301
|
+
console.print(f"[red]Error:[/red] server response contains an unsafe path: {e}")
|
|
302
|
+
raise typer.Exit(1)
|
|
303
|
+
|
|
304
|
+
if fmt == "json":
|
|
305
|
+
print(json.dumps(result, indent=2))
|
|
306
|
+
else:
|
|
307
|
+
for t in result["targets"]:
|
|
308
|
+
if t["installed"]:
|
|
309
|
+
console.print(f" [green]✓[/green] {t['action']}: {t['path']}")
|
|
310
|
+
else:
|
|
311
|
+
console.print(f" [red]✗[/red] {t['action']}: {t['path']} — {t['reason']}")
|
|
312
|
+
console.print()
|
|
313
|
+
|
|
314
|
+
if result["installed_count"] == 0:
|
|
315
|
+
console.print(f"[red]{name} was not installed to any location.[/red]")
|
|
316
|
+
raise typer.Exit(1)
|
|
317
|
+
|
|
318
|
+
if fmt != "json":
|
|
319
|
+
console.print(f"installed {name} v{result['version']} to {result['installed_count']} location(s)")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@app.command("update")
|
|
323
|
+
def update_cmd(
|
|
324
|
+
json_output: bool = typer.Option(False, "--json", help="JSON output"),
|
|
325
|
+
toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
|
|
326
|
+
) -> None:
|
|
327
|
+
"""Refresh every downloaded skill to its latest available version.
|
|
328
|
+
|
|
329
|
+
Skills no longer available to your organization are reported, not
|
|
330
|
+
deleted — remove them explicitly with `tl skill remove <name>`.
|
|
331
|
+
|
|
332
|
+
Examples:
|
|
333
|
+
tl skill update
|
|
334
|
+
"""
|
|
335
|
+
fmt = detect_format(json_output, False, False, toon_output)
|
|
336
|
+
registry = read_registry()
|
|
337
|
+
installed = registry.get("skills", {})
|
|
338
|
+
if not installed:
|
|
339
|
+
if fmt == "json":
|
|
340
|
+
print(json.dumps({"updated": [], "gone": [], "unchanged": [], "failed": []}, indent=2))
|
|
341
|
+
else:
|
|
342
|
+
console.print("[dim]No downloaded skills to update.[/dim]")
|
|
343
|
+
return
|
|
344
|
+
|
|
345
|
+
client = get_client()
|
|
346
|
+
try:
|
|
347
|
+
names = ",".join(sorted(installed.keys()))
|
|
348
|
+
data = client.get("/skills/versions/", params={"names": names})
|
|
349
|
+
except ApiError as e:
|
|
350
|
+
handle_api_error(e)
|
|
351
|
+
return
|
|
352
|
+
finally:
|
|
353
|
+
client.close()
|
|
354
|
+
|
|
355
|
+
latest_versions = data.get("results", {})
|
|
356
|
+
updated: list[str] = []
|
|
357
|
+
gone: list[str] = []
|
|
358
|
+
unchanged: list[str] = []
|
|
359
|
+
failed: list[dict] = []
|
|
360
|
+
|
|
361
|
+
for name in sorted(installed.keys()):
|
|
362
|
+
latest = latest_versions.get(name)
|
|
363
|
+
current = installed[name].get("version")
|
|
364
|
+
if not latest:
|
|
365
|
+
gone.append(name)
|
|
366
|
+
continue
|
|
367
|
+
if latest == current:
|
|
368
|
+
unchanged.append(name)
|
|
369
|
+
continue
|
|
370
|
+
try:
|
|
371
|
+
_download_and_install(name, force=False)
|
|
372
|
+
updated.append(name)
|
|
373
|
+
except (ApiError, PathSafetyError, InvalidSkillNameError) as e:
|
|
374
|
+
failed.append({"name": name, "error": str(e)})
|
|
375
|
+
|
|
376
|
+
if fmt == "json":
|
|
377
|
+
print(json.dumps({"updated": updated, "gone": gone, "unchanged": unchanged, "failed": failed}, indent=2))
|
|
378
|
+
return
|
|
379
|
+
|
|
380
|
+
if updated:
|
|
381
|
+
console.print(f"[green]Updated:[/green] {', '.join(updated)}")
|
|
382
|
+
if gone:
|
|
383
|
+
console.print(f"[yellow]No longer available to your organization:[/yellow] {', '.join(gone)}")
|
|
384
|
+
console.print(" Run [bold]tl skill remove <name>[/bold] to clean these up.")
|
|
385
|
+
if unchanged:
|
|
386
|
+
console.print(f"[dim]Already up to date: {', '.join(unchanged)}[/dim]")
|
|
387
|
+
if failed:
|
|
388
|
+
console.print(f"[red]Failed:[/red] {', '.join(f['name'] for f in failed)}")
|
|
389
|
+
if not (updated or gone or unchanged or failed):
|
|
390
|
+
console.print("[dim]Nothing to update.[/dim]")
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
@app.command("remove")
|
|
394
|
+
def remove_cmd(
|
|
395
|
+
name: str = typer.Argument(..., help="Skill name"),
|
|
396
|
+
json_output: bool = typer.Option(False, "--json", help="JSON output"),
|
|
397
|
+
toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
|
|
398
|
+
) -> None:
|
|
399
|
+
"""Remove a downloaded skill from every location tl installed it.
|
|
400
|
+
|
|
401
|
+
Only deletes a directory if it still carries the `.tl-skill.json`
|
|
402
|
+
marker for this skill — a directory that's been manually replaced is
|
|
403
|
+
left untouched and reported as skipped.
|
|
404
|
+
|
|
405
|
+
Examples:
|
|
406
|
+
tl skill remove my-skill
|
|
407
|
+
"""
|
|
408
|
+
fmt = detect_format(json_output, False, False, toon_output)
|
|
409
|
+
registry = read_registry()
|
|
410
|
+
installed = registry.get("skills", {})
|
|
411
|
+
entry = installed.get(name)
|
|
412
|
+
if entry is None:
|
|
413
|
+
console.print(f"[yellow]{name} is not in the local skill registry.[/yellow]")
|
|
414
|
+
raise typer.Exit(1)
|
|
415
|
+
|
|
416
|
+
removed: list[str] = []
|
|
417
|
+
skipped: list[str] = []
|
|
418
|
+
for path_str in entry.get("paths", []):
|
|
419
|
+
path = Path(path_str)
|
|
420
|
+
if is_marked_for(path, name):
|
|
421
|
+
shutil.rmtree(path)
|
|
422
|
+
removed.append(path_str)
|
|
423
|
+
else:
|
|
424
|
+
skipped.append(path_str)
|
|
425
|
+
|
|
426
|
+
del installed[name]
|
|
427
|
+
write_registry(registry)
|
|
428
|
+
|
|
429
|
+
if fmt == "json":
|
|
430
|
+
print(json.dumps({"name": name, "removed": removed, "skipped": skipped}, indent=2))
|
|
431
|
+
return
|
|
432
|
+
|
|
433
|
+
for p in removed:
|
|
434
|
+
console.print(f" [green]✓[/green] removed: {p}")
|
|
435
|
+
for p in skipped:
|
|
436
|
+
console.print(f" [yellow]![/yellow] skipped (not tl-managed anymore): {p}")
|
|
437
|
+
console.print(f"{name} removed from registry ({len(removed)} location(s) deleted)")
|
tl_cli/main.py
CHANGED
|
@@ -32,6 +32,7 @@ from tl_cli.commands.doctor import app as doctor_app
|
|
|
32
32
|
from tl_cli.commands.reports import app as reports_app
|
|
33
33
|
from tl_cli.commands.workflows import app as workflows_app
|
|
34
34
|
from tl_cli.commands.setup import app as setup_app
|
|
35
|
+
from tl_cli.commands.skills import app as skills_app, check_skill_staleness
|
|
35
36
|
from tl_cli.commands.snapshots import app as snapshots_app
|
|
36
37
|
from tl_cli.commands.uploads import app as uploads_app
|
|
37
38
|
from tl_cli.commands.whoami import app as whoami_app
|
|
@@ -82,10 +83,18 @@ def main(
|
|
|
82
83
|
for warn in check_plugin_version():
|
|
83
84
|
Console(stderr=True).print(f"[yellow]{warn}[/yellow]")
|
|
84
85
|
|
|
86
|
+
# Skip for `skill` invocations themselves — the staleness nag would be
|
|
87
|
+
# noise while the user is already running the command that fixes it.
|
|
88
|
+
if "skill" not in sys.argv:
|
|
89
|
+
warn = check_skill_staleness()
|
|
90
|
+
if warn:
|
|
91
|
+
Console(stderr=True).print(f"[yellow]{warn}[/yellow]")
|
|
92
|
+
|
|
85
93
|
|
|
86
94
|
# System
|
|
87
95
|
app.add_typer(auth_app, name="auth")
|
|
88
96
|
app.add_typer(setup_app, name="setup")
|
|
97
|
+
app.add_typer(skills_app, name="skill")
|
|
89
98
|
|
|
90
99
|
# Data commands (primary interface)
|
|
91
100
|
app.add_typer(sponsorships_app, name="sponsorships")
|
tl_cli/skill_registry.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Local bookkeeping for downloaded skills: registry, install marker, path
|
|
2
|
+
safety, and atomic install.
|
|
3
|
+
|
|
4
|
+
Three files this module owns:
|
|
5
|
+
|
|
6
|
+
- ``~/.config/tl/skills.json`` — the registry of every skill `tl skill
|
|
7
|
+
download` has installed, and where.
|
|
8
|
+
- ``.tl-skill.json`` — written inside each installed skill directory so a
|
|
9
|
+
later run can tell "this directory is ours" apart from a user's own
|
|
10
|
+
skill of the same name. `tl setup` / `tl update`'s cleanup routines are
|
|
11
|
+
expected to import ``is_marked_for`` so they never touch a marker-bearing
|
|
12
|
+
directory (see module docstring note at the bottom).
|
|
13
|
+
- ``~/.config/tl/skills-check.json`` — a 24h cache for the per-run
|
|
14
|
+
staleness check in ``tl_cli.commands.skills``.
|
|
15
|
+
|
|
16
|
+
Kept separate from ``tl_cli/commands/skills.py`` (the Typer app) so the
|
|
17
|
+
install/registry primitives have no dependency on the HTTP client or Typer —
|
|
18
|
+
that keeps them trivially importable from `tl_cli/commands/setup.py` and
|
|
19
|
+
`tl_cli/self_update.py`, whose cleanup routines need `is_marked_for` to skip
|
|
20
|
+
directories `tl skill download` manages (tracked as follow-up work; not part
|
|
21
|
+
of this change).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import ntpath
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import shutil
|
|
29
|
+
import time
|
|
30
|
+
import uuid
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
from tl_cli.config import CONFIG_DIR
|
|
34
|
+
|
|
35
|
+
REGISTRY_PATH = CONFIG_DIR / "skills.json"
|
|
36
|
+
MARKER_FILENAME = ".tl-skill.json"
|
|
37
|
+
STALENESS_CACHE_PATH = CONFIG_DIR / "skills-check.json"
|
|
38
|
+
STALENESS_TTL_SECONDS = 24 * 3600
|
|
39
|
+
# Backoff window after a *failed* staleness check (unreachable/slow server):
|
|
40
|
+
# much shorter than the 24h success TTL, so the network gets retried again
|
|
41
|
+
# reasonably soon, but not on literally every single `tl` invocation.
|
|
42
|
+
STALENESS_FAILURE_TTL_SECONDS = 3600
|
|
43
|
+
|
|
44
|
+
SKILL_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
|
|
45
|
+
SKILL_NAME_MAX_LENGTH = 64
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class PathSafetyError(ValueError):
|
|
49
|
+
"""A server-supplied relative file path failed safety validation."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class InvalidSkillNameError(ValueError):
|
|
53
|
+
"""A user-typed skill name doesn't match the allowed name shape."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def validate_skill_name(name: str) -> None:
|
|
57
|
+
"""Raise `InvalidSkillNameError` unless `name` is a safe skill identifier.
|
|
58
|
+
|
|
59
|
+
Checked before any network call or path construction: `download`/`update`
|
|
60
|
+
build `target_root / name` directly from this value, so a name shaped
|
|
61
|
+
like a path (e.g. containing `/`, `..`, or backslashes) must never reach
|
|
62
|
+
that join.
|
|
63
|
+
"""
|
|
64
|
+
if not isinstance(name, str) or len(name) > SKILL_NAME_MAX_LENGTH or not SKILL_NAME_RE.match(name):
|
|
65
|
+
raise InvalidSkillNameError(
|
|
66
|
+
f"invalid skill name: {name!r} — expected lowercase letters, digits, and hyphens "
|
|
67
|
+
f"(starting with a letter or digit), {SKILL_NAME_MAX_LENGTH} characters or fewer"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# --- Path safety -------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def validate_relpath(relpath: str) -> None:
|
|
75
|
+
"""Raise `PathSafetyError` unless `relpath` is a safe relative POSIX path.
|
|
76
|
+
|
|
77
|
+
Defense in depth: the server validates the same rules before a skill
|
|
78
|
+
bundle is ever stored, but a client-side check costs nothing and
|
|
79
|
+
protects against a compromised or buggy server response before a
|
|
80
|
+
single byte is written to disk.
|
|
81
|
+
"""
|
|
82
|
+
if not isinstance(relpath, str) or not relpath:
|
|
83
|
+
raise PathSafetyError(f"empty or non-string file path: {relpath!r}")
|
|
84
|
+
if "\\" in relpath:
|
|
85
|
+
raise PathSafetyError(f"backslash not allowed in path: {relpath!r}")
|
|
86
|
+
if "\x00" in relpath:
|
|
87
|
+
raise PathSafetyError(f"NUL byte not allowed in path: {relpath!r}")
|
|
88
|
+
if relpath.startswith("/"):
|
|
89
|
+
raise PathSafetyError(f"absolute path not allowed: {relpath!r}")
|
|
90
|
+
# A Windows drive-qualified path ('C:/x', 'C:x') is absolute on Windows, so
|
|
91
|
+
# joining it to the install directory would discard that directory. This is
|
|
92
|
+
# pure string logic and is correct regardless of the host OS.
|
|
93
|
+
if ntpath.splitdrive(relpath)[0]:
|
|
94
|
+
raise PathSafetyError(f"drive-qualified path not allowed: {relpath!r}")
|
|
95
|
+
for segment in relpath.split("/"):
|
|
96
|
+
if segment in ("", ".", ".."):
|
|
97
|
+
raise PathSafetyError(f"unsafe path segment in {relpath!r}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def validate_files(files: dict[str, str]) -> None:
|
|
101
|
+
"""Validate every path in a `{relpath: content}` skill bundle upfront.
|
|
102
|
+
|
|
103
|
+
Called before any target directory is touched, so a single bad path
|
|
104
|
+
aborts the whole install rather than leaving some targets written and
|
|
105
|
+
others not.
|
|
106
|
+
"""
|
|
107
|
+
for relpath, content in files.items():
|
|
108
|
+
validate_relpath(relpath)
|
|
109
|
+
if not isinstance(content, str):
|
|
110
|
+
raise PathSafetyError(f"file content for {relpath!r} must be text")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _resolve_within(target_dir: Path, relpath: str) -> Path:
|
|
114
|
+
"""Resolve `relpath` under `target_dir`, asserting containment.
|
|
115
|
+
|
|
116
|
+
Second, independent check (path-resolution-based rather than
|
|
117
|
+
string-based) applied at write time as the ultimate safety net.
|
|
118
|
+
"""
|
|
119
|
+
validate_relpath(relpath)
|
|
120
|
+
dest = (target_dir / relpath).resolve()
|
|
121
|
+
if not dest.is_relative_to(target_dir.resolve()):
|
|
122
|
+
raise PathSafetyError(f"path escapes target directory: {relpath!r}")
|
|
123
|
+
return dest
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# --- Marker --------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def read_marker(path: Path) -> dict | None:
|
|
130
|
+
"""Read and validate `.tl-skill.json` in `path`. None if absent/invalid."""
|
|
131
|
+
marker_path = path / MARKER_FILENAME
|
|
132
|
+
try:
|
|
133
|
+
data = json.loads(marker_path.read_text(encoding="utf-8"))
|
|
134
|
+
except (OSError, json.JSONDecodeError):
|
|
135
|
+
return None
|
|
136
|
+
if not isinstance(data, dict):
|
|
137
|
+
return None
|
|
138
|
+
if not all(isinstance(data.get(k), str) for k in ("name", "version", "checksum", "managed_by")):
|
|
139
|
+
return None
|
|
140
|
+
return data
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def is_marked_for(path: Path, name: str) -> bool:
|
|
144
|
+
"""True if `path` is a directory carrying a valid tl-managed marker for `name`.
|
|
145
|
+
|
|
146
|
+
This is the safety predicate every mutation in this module (and, per
|
|
147
|
+
the module docstring, `tl setup` / `tl update`'s cleanup routines)
|
|
148
|
+
must check before touching an existing directory: unmarked dirs are
|
|
149
|
+
never assumed to be ours.
|
|
150
|
+
"""
|
|
151
|
+
if not path.is_dir():
|
|
152
|
+
return False
|
|
153
|
+
marker = read_marker(path)
|
|
154
|
+
return marker is not None and marker.get("managed_by") == "tl" and marker.get("name") == name
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def write_marker(path: Path, *, name: str, version: str, checksum: str) -> None:
|
|
158
|
+
marker = {"name": name, "version": version, "checksum": checksum, "managed_by": "tl"}
|
|
159
|
+
(path / MARKER_FILENAME).write_text(json.dumps(marker), encoding="utf-8")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# --- Atomic install --------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def install_skill_tree(
|
|
166
|
+
files: dict[str, str],
|
|
167
|
+
target_dir: Path,
|
|
168
|
+
*,
|
|
169
|
+
name: str,
|
|
170
|
+
version: str,
|
|
171
|
+
checksum: str,
|
|
172
|
+
) -> None:
|
|
173
|
+
"""Atomically write `files` (relpath -> text) into `target_dir`.
|
|
174
|
+
|
|
175
|
+
Writes the whole tree to a sibling temp directory first (same
|
|
176
|
+
filesystem, so the final rename is atomic), adds the `.tl-skill.json`
|
|
177
|
+
marker, then swaps: any existing `target_dir` is moved aside, the temp
|
|
178
|
+
directory takes its place, and the old one is deleted. On any failure
|
|
179
|
+
the temp directory is cleaned up and `target_dir` is left exactly as
|
|
180
|
+
it was — nothing partially written.
|
|
181
|
+
|
|
182
|
+
Callers are expected to have already run `validate_files` on the whole
|
|
183
|
+
bundle; the per-file `_resolve_within` check here is a second,
|
|
184
|
+
independent safety net, not the primary gate.
|
|
185
|
+
"""
|
|
186
|
+
parent = target_dir.parent
|
|
187
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
188
|
+
tmp_dir = parent / f".{name}.tmp-{uuid.uuid4().hex[:8]}"
|
|
189
|
+
try:
|
|
190
|
+
tmp_dir.mkdir(parents=True)
|
|
191
|
+
for relpath, content in files.items():
|
|
192
|
+
dest = _resolve_within(tmp_dir, relpath)
|
|
193
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
194
|
+
dest.write_text(content, encoding="utf-8")
|
|
195
|
+
write_marker(tmp_dir, name=name, version=version, checksum=checksum)
|
|
196
|
+
except Exception:
|
|
197
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
198
|
+
raise
|
|
199
|
+
|
|
200
|
+
old_dir = parent / f".{name}.old-{uuid.uuid4().hex[:8]}"
|
|
201
|
+
moved_old = False
|
|
202
|
+
if target_dir.exists():
|
|
203
|
+
target_dir.rename(old_dir)
|
|
204
|
+
moved_old = True
|
|
205
|
+
try:
|
|
206
|
+
tmp_dir.rename(target_dir)
|
|
207
|
+
except Exception:
|
|
208
|
+
if moved_old:
|
|
209
|
+
old_dir.rename(target_dir)
|
|
210
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
211
|
+
raise
|
|
212
|
+
if moved_old:
|
|
213
|
+
shutil.rmtree(old_dir, ignore_errors=True)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# --- Atomic JSON write -----------------------------------------------------
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _atomic_write_json(path: Path, data: dict, *, indent: int | None = None) -> None:
|
|
220
|
+
"""Write `data` as JSON to `path` via a temp file + `os.replace`.
|
|
221
|
+
|
|
222
|
+
Serialization happens before any file is touched, so a bad `data` (e.g.
|
|
223
|
+
non-serializable content) raises without disturbing `path`. The payload
|
|
224
|
+
is then written to a sibling temp file and swapped in with `os.replace`
|
|
225
|
+
— atomic on the same filesystem — so a crash mid-write can never leave
|
|
226
|
+
`path` half-written; the previous content survives untouched until the
|
|
227
|
+
swap succeeds.
|
|
228
|
+
"""
|
|
229
|
+
payload = json.dumps(data, indent=indent)
|
|
230
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
231
|
+
tmp_path = path.parent / f".{path.name}.tmp-{uuid.uuid4().hex[:8]}"
|
|
232
|
+
try:
|
|
233
|
+
tmp_path.write_text(payload, encoding="utf-8")
|
|
234
|
+
os.replace(tmp_path, path)
|
|
235
|
+
except OSError:
|
|
236
|
+
tmp_path.unlink(missing_ok=True)
|
|
237
|
+
raise
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# --- Registry --------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def read_registry() -> dict:
|
|
244
|
+
"""Read `skills.json`, tolerating a missing or corrupt file."""
|
|
245
|
+
try:
|
|
246
|
+
data = json.loads(REGISTRY_PATH.read_text(encoding="utf-8"))
|
|
247
|
+
except (OSError, json.JSONDecodeError):
|
|
248
|
+
return {"skills": {}}
|
|
249
|
+
if not isinstance(data, dict) or not isinstance(data.get("skills"), dict):
|
|
250
|
+
return {"skills": {}}
|
|
251
|
+
return data
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def write_registry(data: dict) -> None:
|
|
255
|
+
_atomic_write_json(REGISTRY_PATH, data, indent=2)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# --- Staleness cache ---------------------------------------------------
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def read_staleness_cache() -> dict | None:
|
|
262
|
+
"""Return the cached `/skills/versions/` outcome, success or failure.
|
|
263
|
+
|
|
264
|
+
A successful check is cached for `STALENESS_TTL_SECONDS` (24h) and
|
|
265
|
+
returns the cached `results` dict. A *failed* check (network error,
|
|
266
|
+
unreachable/slow server) is cached for the much shorter
|
|
267
|
+
`STALENESS_FAILURE_TTL_SECONDS` backoff window and returned as
|
|
268
|
+
`{"checked_at": ..., "failed": True}` — callers must check `failed`
|
|
269
|
+
before touching `results`. Returns None once either window has
|
|
270
|
+
expired, or the file is missing/corrupt/malshaped.
|
|
271
|
+
"""
|
|
272
|
+
try:
|
|
273
|
+
cache = json.loads(STALENESS_CACHE_PATH.read_text(encoding="utf-8"))
|
|
274
|
+
except (OSError, json.JSONDecodeError):
|
|
275
|
+
return None
|
|
276
|
+
if not isinstance(cache, dict):
|
|
277
|
+
return None
|
|
278
|
+
checked_at = cache.get("checked_at")
|
|
279
|
+
if not isinstance(checked_at, (int, float)):
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
if cache.get("failed"):
|
|
283
|
+
if time.time() - checked_at >= STALENESS_FAILURE_TTL_SECONDS:
|
|
284
|
+
return None
|
|
285
|
+
return cache
|
|
286
|
+
|
|
287
|
+
if time.time() - checked_at >= STALENESS_TTL_SECONDS:
|
|
288
|
+
return None
|
|
289
|
+
if not isinstance(cache.get("results"), dict):
|
|
290
|
+
return None
|
|
291
|
+
return cache
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def write_staleness_cache(results: dict) -> None:
|
|
295
|
+
try:
|
|
296
|
+
_atomic_write_json(STALENESS_CACHE_PATH, {"checked_at": time.time(), "results": results})
|
|
297
|
+
except OSError:
|
|
298
|
+
pass
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def write_staleness_failure() -> None:
|
|
302
|
+
"""Record a failed staleness check so the next run backs off the network.
|
|
303
|
+
|
|
304
|
+
Called instead of `write_staleness_cache` when the `/skills/versions/`
|
|
305
|
+
call itself fails — writing this stamp is what makes subsequent `tl`
|
|
306
|
+
invocations skip the network (and its timeout) for
|
|
307
|
+
`STALENESS_FAILURE_TTL_SECONDS`, instead of retrying on every run.
|
|
308
|
+
"""
|
|
309
|
+
try:
|
|
310
|
+
_atomic_write_json(STALENESS_CACHE_PATH, {"checked_at": time.time(), "failed": True})
|
|
311
|
+
except OSError:
|
|
312
|
+
pass
|
|
File without changes
|
|
File without changes
|
|
File without changes
|