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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: thoughtleaders-cli
3
- Version: 0.9.3
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=HtBnZuYCqtstGhptSV7Np9m3ZYi8GNZcrkpJixpN2mA,112
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=E5VAkLbpXmGVEy9DzK99FRKD2khmvYJVcYawN97K1zc,6013
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=pUJYYuT0q92_SwqCuMj3ozBthvchKuKL9v9U_eCQv20,4164
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=JHSmKdAUNtd-9zlKZhit9_BmhHXrEk0kQ-oSlQ0Eo6Y,2732
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=fPqqw5m561Tx-JkRR-RE_b_otSIMtTwPMUI0neqiyZ8,8997
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=ypimHsp45yYSWwTLywfYtPC2P_45X4arW0K3SFuwEXk,28398
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=MlM05A2LBDupXFroa0AsFQ5Yf1ouQa79wgfhXMt6yeA,466
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=z_XKy_K0iD7SNsFyKeoQP27cgY6thOmIyQDvTlUq_NM,66356
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.3.dist-info/METADATA,sha256=jQE0DTTvhbzQ42bXDTL_GIngJjyEapWefL7ypiE43ec,19083
111
- thoughtleaders_cli-0.9.3.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
112
- thoughtleaders_cli-0.9.3.dist-info/entry_points.txt,sha256=umZp-1BkGkHDG0bNZXpTXrjwW0HGf9IDFN40eAWuuvg,39
113
- thoughtleaders_cli-0.9.3.dist-info/licenses/LICENSE,sha256=RUfdfLsn6jygiyrnnVUHt6r4IPwr2rbDm9Kixgtu8fo,1071
114
- thoughtleaders_cli-0.9.3.dist-info/RECORD,,
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
@@ -1,3 +1,3 @@
1
1
  """ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence."""
2
2
 
3
- __version__ = "0.9.3"
3
+ __version__ = "0.9.4"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tl-cli",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "description": "ThoughtLeaders CLI — query sponsorship deals, channels, brands, uploads, and intelligence from the terminal",
5
5
  "author": {
6
6
  "name": "ThoughtLeaders",
@@ -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: new addresses are added with the
255
- internal CLI's `tl-internal channels add-email` command (superuser-only), which never
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
- response = self._client.request(
53
- method, path, params=params, json=json_body, headers=headers
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)
@@ -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 NOT the
103
- older reach / impression / send_date / is_tl_channel / creator_profile_id
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. Returns the number of skills installed.
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")
@@ -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