Skip to main content

claude_api/
skills.rs

1//! The Skills API (beta).
2//!
3//! Skills are reusable named bundles of files (with a required
4//! `SKILL.md` at the bundle root) that Claude can load on demand. A
5//! [`Skill`] is the named container and a [`SkillVersion`] is one
6//! immutable snapshot of its files.
7//!
8//! # Beta
9//!
10//! Every Skills method automatically sends
11//! `anthropic-beta: skills-2025-10-02`. Override the beta version on
12//! the [`Client`] builder if a newer revision is current.
13//!
14//! # Endpoints
15//!
16//! | Method | Path | Function |
17//! |---|---|---|
18//! | `POST` | `/v1/skills` | [`Skills::create`] |
19//! | `GET` | `/v1/skills` | [`Skills::list`] |
20//! | `GET` | `/v1/skills/{skill_id}` | [`Skills::get`] |
21//! | `DELETE` | `/v1/skills/{skill_id}` | [`Skills::delete`] |
22//! | `POST` | `/v1/skills/{skill_id}/versions` | [`Skills::create_version`] |
23//! | `GET` | `/v1/skills/{skill_id}/versions` | [`Skills::list_versions`] |
24//! | `GET` | `/v1/skills/{skill_id}/versions/{version}` | [`Skills::get_version`] |
25//! | `DELETE` | `/v1/skills/{skill_id}/versions/{version}` | [`Skills::delete_version`] |
26
27#![cfg(feature = "skills")]
28
29use std::path::Path;
30
31use bytes::Bytes;
32use serde::{Deserialize, Serialize};
33
34use crate::client::Client;
35use crate::error::{Error, Result};
36use crate::pagination::PaginatedNextPage;
37
38/// Beta version tag attached to every Skills API request.
39const SKILLS_BETA: &[&str] = &["skills-2025-10-02"];
40
41// =====================================================================
42// Wire types
43// =====================================================================
44
45/// Source of a skill (who created it). Open-string enum: unknown
46/// values fall through to [`Self::Other`].
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum SkillSource {
49    /// Created by a user in the calling organization.
50    Custom,
51    /// Built-in skill maintained by Anthropic.
52    Anthropic,
53    /// Forward-compat fallback for unknown source values.
54    Other(String),
55}
56
57impl Serialize for SkillSource {
58    fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
59        s.serialize_str(match self {
60            Self::Custom => "custom",
61            Self::Anthropic => "anthropic",
62            Self::Other(v) => v,
63        })
64    }
65}
66
67impl<'de> Deserialize<'de> for SkillSource {
68    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
69        let s = String::deserialize(d)?;
70        Ok(match s.as_str() {
71            "custom" => Self::Custom,
72            "anthropic" => Self::Anthropic,
73            _ => Self::Other(s),
74        })
75    }
76}
77
78/// Subset of [`SkillSource`] valid as the `source` query filter on
79/// `GET /v1/skills`.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81#[non_exhaustive]
82pub enum SkillSourceFilter {
83    /// Filter to user-created skills.
84    Custom,
85    /// Filter to Anthropic-provided skills.
86    Anthropic,
87}
88
89impl SkillSourceFilter {
90    fn as_str(self) -> &'static str {
91        match self {
92            Self::Custom => "custom",
93            Self::Anthropic => "anthropic",
94        }
95    }
96}
97
98/// A skill resource. The `latest_version` field points to a
99/// [`SkillVersion::version`] string -- pass it to
100/// [`Skills::get_version`].
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102#[non_exhaustive]
103pub struct Skill {
104    /// Unique identifier (e.g. `skill_01JABC...`). The format may
105    /// change over time.
106    pub id: String,
107    /// Wire `type`; always `"skill"`.
108    #[serde(rename = "type", default = "default_skill_kind")]
109    pub kind: String,
110    /// Human-readable label (not sent to the model in the prompt).
111    pub display_title: String,
112    /// Most recent version timestamp string (e.g. `"1759178010641129"`).
113    pub latest_version: String,
114    /// Who created the skill.
115    pub source: SkillSource,
116    /// ISO-8601 creation timestamp.
117    pub created_at: String,
118    /// ISO-8601 last-update timestamp.
119    pub updated_at: String,
120}
121
122fn default_skill_kind() -> String {
123    "skill".to_owned()
124}
125
126/// One immutable snapshot of a [`Skill`]'s file bundle.
127///
128/// `name` and `description` are extracted from the bundle's `SKILL.md`
129/// at upload time; `directory` is the top-level directory name in the
130/// upload.
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132#[non_exhaustive]
133pub struct SkillVersion {
134    /// Unique identifier for the version (e.g. `skillver_01JABC...`).
135    pub id: String,
136    /// Wire `type`; always `"skill_version"`.
137    #[serde(rename = "type", default = "default_skill_version_kind")]
138    pub kind: String,
139    /// Parent skill ID.
140    pub skill_id: String,
141    /// Version identifier; a Unix-epoch-millis string
142    /// (e.g. `"1759178010641129"`). Pass this to
143    /// [`Skills::get_version`] / [`Skills::delete_version`].
144    pub version: String,
145    /// Human-readable name from the uploaded `SKILL.md` front-matter.
146    pub name: String,
147    /// Description from the uploaded `SKILL.md`.
148    pub description: String,
149    /// Top-level directory name extracted from the uploaded files.
150    pub directory: String,
151    /// ISO-8601 creation timestamp.
152    pub created_at: String,
153}
154
155fn default_skill_version_kind() -> String {
156    "skill_version".to_owned()
157}
158
159/// Confirmation returned by `DELETE /v1/skills/{id}`.
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161#[non_exhaustive]
162pub struct SkillDeleted {
163    /// ID of the deleted skill.
164    pub id: String,
165    /// Wire `type`; typically `"skill_deleted"`.
166    #[serde(rename = "type", default)]
167    pub kind: String,
168}
169
170/// Confirmation returned by `DELETE /v1/skills/{id}/versions/{version}`.
171///
172/// Note: unlike [`SkillDeleted`], `id` here is the *version timestamp
173/// string* (e.g. `"1759178010641129"`), not a `skillver_*` identifier.
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175#[non_exhaustive]
176pub struct SkillVersionDeleted {
177    /// Version timestamp of the deleted skill version.
178    pub id: String,
179    /// Wire `type`; typically `"skill_version_deleted"`.
180    #[serde(rename = "type", default)]
181    pub kind: String,
182}
183
184// =====================================================================
185// Multipart upload helpers
186// =====================================================================
187
188/// One file in a skill upload. The `path` is the relative path inside
189/// the bundle and **must include the top-level directory**, e.g.
190/// `"my-skill/SKILL.md"`. Every upload must contain a `SKILL.md` at
191/// the directory root.
192#[derive(Debug, Clone)]
193pub struct SkillFile {
194    /// Relative path inside the bundle, including the top-level
195    /// directory (e.g. `"my-skill/SKILL.md"`).
196    pub path: String,
197    /// Raw file bytes.
198    pub contents: Bytes,
199}
200
201impl SkillFile {
202    /// Build from a path string + raw bytes.
203    #[must_use]
204    pub fn new(path: impl Into<String>, contents: impl Into<Bytes>) -> Self {
205        Self {
206            path: path.into(),
207            contents: contents.into(),
208        }
209    }
210
211    /// Read a file from disk. The bundle path is the file's name
212    /// component; for nested layouts, build [`SkillFile`] manually with
213    /// the full relative path.
214    pub async fn from_path(path: impl AsRef<Path>) -> Result<Self> {
215        let path = path.as_ref();
216        let bundle_path = path
217            .file_name()
218            .and_then(|s| s.to_str())
219            .ok_or_else(|| {
220                Error::InvalidConfig(format!("invalid filename in path {}", path.display()))
221            })?
222            .to_owned();
223        let contents = tokio::fs::read(path).await?;
224        Ok(Self {
225            path: bundle_path,
226            contents: Bytes::from(contents),
227        })
228    }
229}
230
231/// Body for [`Skills::create`].
232///
233/// Use [`Self::new`] then chain [`Self::display_title`] and
234/// [`Self::file`] / [`Self::files`].
235#[derive(Debug, Clone, Default)]
236#[non_exhaustive]
237pub struct CreateSkillRequest {
238    /// Human-readable label (not sent to the model).
239    pub display_title: Option<String>,
240    /// Files to upload. Must include a `SKILL.md` at the bundle root.
241    pub files: Vec<SkillFile>,
242}
243
244impl CreateSkillRequest {
245    /// Empty request.
246    #[must_use]
247    pub fn new() -> Self {
248        Self::default()
249    }
250
251    /// Set the display title.
252    #[must_use]
253    pub fn display_title(mut self, t: impl Into<String>) -> Self {
254        self.display_title = Some(t.into());
255        self
256    }
257
258    /// Append one file to the upload.
259    #[must_use]
260    pub fn file(mut self, f: SkillFile) -> Self {
261        self.files.push(f);
262        self
263    }
264
265    /// Append many files.
266    #[must_use]
267    pub fn files(mut self, fs: impl IntoIterator<Item = SkillFile>) -> Self {
268        self.files.extend(fs);
269        self
270    }
271}
272
273fn build_form(display_title: Option<&str>, files: &[SkillFile]) -> reqwest::multipart::Form {
274    let mut form = reqwest::multipart::Form::new();
275    if let Some(t) = display_title {
276        form = form.text("display_title", t.to_owned());
277    }
278    for f in files {
279        let part = reqwest::multipart::Part::bytes(f.contents.to_vec()).file_name(f.path.clone());
280        form = form.part("files", part);
281    }
282    form
283}
284
285// =====================================================================
286// List query params
287// =====================================================================
288
289/// Query parameters for `GET /v1/skills`.
290#[derive(Debug, Clone, Default)]
291#[non_exhaustive]
292pub struct ListSkillsParams {
293    /// Page size (1..=100, default 20).
294    pub limit: Option<u32>,
295    /// Opaque cursor from a previous page's `next_page`.
296    pub page: Option<String>,
297    /// Filter by source.
298    pub source: Option<SkillSourceFilter>,
299}
300
301impl ListSkillsParams {
302    /// Set the page size.
303    #[must_use]
304    pub fn limit(mut self, limit: u32) -> Self {
305        self.limit = Some(limit);
306        self
307    }
308
309    /// Set the pagination cursor.
310    #[must_use]
311    pub fn page(mut self, cursor: impl Into<String>) -> Self {
312        self.page = Some(cursor.into());
313        self
314    }
315
316    /// Filter by source.
317    #[must_use]
318    pub fn source(mut self, source: SkillSourceFilter) -> Self {
319        self.source = Some(source);
320        self
321    }
322
323    fn to_query(&self) -> Vec<(&'static str, String)> {
324        let mut q = Vec::new();
325        if let Some(l) = self.limit {
326            q.push(("limit", l.to_string()));
327        }
328        if let Some(p) = &self.page {
329            q.push(("page", p.clone()));
330        }
331        if let Some(s) = self.source {
332            q.push(("source", s.as_str().to_owned()));
333        }
334        q
335    }
336}
337
338/// Query parameters for `GET /v1/skills/{skill_id}/versions`.
339#[derive(Debug, Clone, Default)]
340#[non_exhaustive]
341pub struct ListSkillVersionsParams {
342    /// Page size (1..=1000, default 20).
343    pub limit: Option<u32>,
344    /// Opaque cursor from a previous page's `next_page`.
345    pub page: Option<String>,
346}
347
348impl ListSkillVersionsParams {
349    /// Set the page size.
350    #[must_use]
351    pub fn limit(mut self, limit: u32) -> Self {
352        self.limit = Some(limit);
353        self
354    }
355
356    /// Set the pagination cursor.
357    #[must_use]
358    pub fn page(mut self, cursor: impl Into<String>) -> Self {
359        self.page = Some(cursor.into());
360        self
361    }
362
363    fn to_query(&self) -> Vec<(&'static str, String)> {
364        let mut q = Vec::new();
365        if let Some(l) = self.limit {
366            q.push(("limit", l.to_string()));
367        }
368        if let Some(p) = &self.page {
369            q.push(("page", p.clone()));
370        }
371        q
372    }
373}
374
375// =====================================================================
376// Namespace handle
377// =====================================================================
378
379/// Namespace handle for the Skills API.
380///
381/// Obtained via [`Client::skills`].
382pub struct Skills<'a> {
383    client: &'a Client,
384}
385
386impl<'a> Skills<'a> {
387    pub(crate) fn new(client: &'a Client) -> Self {
388        Self { client }
389    }
390
391    /// `POST /v1/skills` -- create a new skill from a multipart upload.
392    ///
393    /// Multipart bodies are single-use; this method does not retry.
394    pub async fn create(&self, request: CreateSkillRequest) -> Result<Skill> {
395        let form = build_form(request.display_title.as_deref(), &request.files);
396        let builder = self
397            .client
398            .request_builder(reqwest::Method::POST, "/v1/skills")
399            .multipart(form);
400        self.client.execute(builder, SKILLS_BETA).await
401    }
402
403    /// `GET /v1/skills` -- one page of skills.
404    pub async fn list(&self, params: ListSkillsParams) -> Result<PaginatedNextPage<Skill>> {
405        let query = params.to_query();
406        self.client
407            .execute_with_retry(
408                || {
409                    let mut req = self
410                        .client
411                        .request_builder(reqwest::Method::GET, "/v1/skills");
412                    for (k, v) in &query {
413                        req = req.query(&[(k, v)]);
414                    }
415                    req
416                },
417                SKILLS_BETA,
418            )
419            .await
420    }
421
422    /// `GET /v1/skills/{skill_id}` -- fetch a single skill.
423    pub async fn get(&self, skill_id: &str) -> Result<Skill> {
424        let path = format!("/v1/skills/{skill_id}");
425        self.client
426            .execute_with_retry(
427                || self.client.request_builder(reqwest::Method::GET, &path),
428                SKILLS_BETA,
429            )
430            .await
431    }
432
433    /// `DELETE /v1/skills/{skill_id}` -- delete a skill and all its
434    /// versions.
435    pub async fn delete(&self, skill_id: &str) -> Result<SkillDeleted> {
436        let path = format!("/v1/skills/{skill_id}");
437        self.client
438            .execute_with_retry(
439                || self.client.request_builder(reqwest::Method::DELETE, &path),
440                SKILLS_BETA,
441            )
442            .await
443    }
444
445    /// `POST /v1/skills/{skill_id}/versions` -- upload a new version.
446    ///
447    /// The version inherits its `display_title` from the parent skill;
448    /// the request body carries only the file bundle.
449    ///
450    /// Multipart bodies are single-use; this method does not retry.
451    pub async fn create_version(
452        &self,
453        skill_id: &str,
454        files: Vec<SkillFile>,
455    ) -> Result<SkillVersion> {
456        let form = build_form(None, &files);
457        let path = format!("/v1/skills/{skill_id}/versions");
458        let builder = self
459            .client
460            .request_builder(reqwest::Method::POST, &path)
461            .multipart(form);
462        self.client.execute(builder, SKILLS_BETA).await
463    }
464
465    /// `GET /v1/skills/{skill_id}/versions` -- one page of versions.
466    pub async fn list_versions(
467        &self,
468        skill_id: &str,
469        params: ListSkillVersionsParams,
470    ) -> Result<PaginatedNextPage<SkillVersion>> {
471        let path = format!("/v1/skills/{skill_id}/versions");
472        let query = params.to_query();
473        self.client
474            .execute_with_retry(
475                || {
476                    let mut req = self.client.request_builder(reqwest::Method::GET, &path);
477                    for (k, v) in &query {
478                        req = req.query(&[(k, v)]);
479                    }
480                    req
481                },
482                SKILLS_BETA,
483            )
484            .await
485    }
486
487    /// `GET /v1/skills/{skill_id}/versions/{version}` -- fetch a single
488    /// version.
489    pub async fn get_version(&self, skill_id: &str, version: &str) -> Result<SkillVersion> {
490        let path = format!("/v1/skills/{skill_id}/versions/{version}");
491        self.client
492            .execute_with_retry(
493                || self.client.request_builder(reqwest::Method::GET, &path),
494                SKILLS_BETA,
495            )
496            .await
497    }
498
499    /// `DELETE /v1/skills/{skill_id}/versions/{version}` -- delete one
500    /// version.
501    pub async fn delete_version(
502        &self,
503        skill_id: &str,
504        version: &str,
505    ) -> Result<SkillVersionDeleted> {
506        let path = format!("/v1/skills/{skill_id}/versions/{version}");
507        self.client
508            .execute_with_retry(
509                || self.client.request_builder(reqwest::Method::DELETE, &path),
510                SKILLS_BETA,
511            )
512            .await
513    }
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519    use pretty_assertions::assert_eq;
520    use serde_json::json;
521    use wiremock::matchers::{header_exists, method, path, query_param};
522    use wiremock::{Mock, MockServer, ResponseTemplate};
523
524    fn client_for(mock: &MockServer) -> Client {
525        Client::builder()
526            .api_key("sk-ant-test")
527            .base_url(mock.uri())
528            .build()
529            .unwrap()
530    }
531
532    fn skill_json(id: &str) -> serde_json::Value {
533        json!({
534            "id": id,
535            "type": "skill",
536            "display_title": "My Custom Skill",
537            "latest_version": "1759178010641129",
538            "source": "custom",
539            "created_at": "2024-10-30T23:58:27.427722Z",
540            "updated_at": "2024-10-30T23:58:27.427722Z"
541        })
542    }
543
544    fn skill_version_json(id: &str) -> serde_json::Value {
545        json!({
546            "id": id,
547            "type": "skill_version",
548            "skill_id": "skill_S1",
549            "version": "1759178010641129",
550            "name": "my-skill",
551            "description": "A custom skill",
552            "directory": "my-skill",
553            "created_at": "2024-10-30T23:58:27.427722Z"
554        })
555    }
556
557    #[test]
558    fn skill_source_round_trips_known_values() {
559        let custom: SkillSource = serde_json::from_str(r#""custom""#).unwrap();
560        assert_eq!(custom, SkillSource::Custom);
561        let anthr: SkillSource = serde_json::from_str(r#""anthropic""#).unwrap();
562        assert_eq!(anthr, SkillSource::Anthropic);
563        assert_eq!(
564            serde_json::to_string(&SkillSource::Custom).unwrap(),
565            r#""custom""#
566        );
567    }
568
569    #[test]
570    fn skill_source_falls_through_to_other_for_unknown_values() {
571        let s: SkillSource = serde_json::from_str(r#""partner""#).unwrap();
572        assert_eq!(s, SkillSource::Other("partner".into()));
573        // round-trip
574        assert_eq!(serde_json::to_string(&s).unwrap(), r#""partner""#);
575    }
576
577    #[tokio::test]
578    async fn create_sends_multipart_with_skills_beta_and_decodes_skill() {
579        let mock = MockServer::start().await;
580        Mock::given(method("POST"))
581            .and(path("/v1/skills"))
582            .and(header_exists("anthropic-beta"))
583            .respond_with(ResponseTemplate::new(200).set_body_json(skill_json("skill_C1")))
584            .mount(&mock)
585            .await;
586
587        let client = client_for(&mock);
588        let req = CreateSkillRequest::new()
589            .display_title("My Custom Skill")
590            .file(SkillFile::new("my-skill/SKILL.md", &b"# my-skill\n"[..]));
591        let s = client.skills().create(req).await.unwrap();
592        assert_eq!(s.id, "skill_C1");
593        assert_eq!(s.source, SkillSource::Custom);
594
595        let recv = &mock.received_requests().await.unwrap()[0];
596        let beta = recv
597            .headers
598            .get("anthropic-beta")
599            .unwrap()
600            .to_str()
601            .unwrap();
602        assert!(beta.contains("skills-2025-10-02"), "{beta}");
603        let ct = recv.headers.get("content-type").unwrap().to_str().unwrap();
604        assert!(ct.starts_with("multipart/form-data"), "{ct}");
605    }
606
607    #[tokio::test]
608    async fn list_passes_limit_page_source_query_params() {
609        let mock = MockServer::start().await;
610        Mock::given(method("GET"))
611            .and(path("/v1/skills"))
612            .and(query_param("limit", "5"))
613            .and(query_param("page", "page_abc"))
614            .and(query_param("source", "anthropic"))
615            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
616                "data": [skill_json("skill_L1")],
617                "has_more": true,
618                "next_page": "page_def"
619            })))
620            .mount(&mock)
621            .await;
622
623        let client = client_for(&mock);
624        let page = client
625            .skills()
626            .list(
627                ListSkillsParams::default()
628                    .limit(5)
629                    .page("page_abc")
630                    .source(SkillSourceFilter::Anthropic),
631            )
632            .await
633            .unwrap();
634        assert_eq!(page.data.len(), 1);
635        assert!(page.has_more);
636        assert_eq!(page.next_cursor(), Some("page_def"));
637    }
638
639    #[tokio::test]
640    async fn get_decodes_single_skill() {
641        let mock = MockServer::start().await;
642        Mock::given(method("GET"))
643            .and(path("/v1/skills/skill_G1"))
644            .respond_with(ResponseTemplate::new(200).set_body_json(skill_json("skill_G1")))
645            .mount(&mock)
646            .await;
647
648        let client = client_for(&mock);
649        let s = client.skills().get("skill_G1").await.unwrap();
650        assert_eq!(s.id, "skill_G1");
651        assert_eq!(s.kind, "skill");
652        assert_eq!(s.latest_version, "1759178010641129");
653    }
654
655    #[tokio::test]
656    async fn delete_returns_skill_deleted_envelope() {
657        let mock = MockServer::start().await;
658        Mock::given(method("DELETE"))
659            .and(path("/v1/skills/skill_D1"))
660            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
661                "id": "skill_D1",
662                "type": "skill_deleted"
663            })))
664            .mount(&mock)
665            .await;
666
667        let client = client_for(&mock);
668        let confirm = client.skills().delete("skill_D1").await.unwrap();
669        assert_eq!(confirm.id, "skill_D1");
670        assert_eq!(confirm.kind, "skill_deleted");
671    }
672
673    #[tokio::test]
674    async fn create_version_sends_files_only_multipart() {
675        let mock = MockServer::start().await;
676        Mock::given(method("POST"))
677            .and(path("/v1/skills/skill_S1/versions"))
678            .respond_with(
679                ResponseTemplate::new(200).set_body_json(skill_version_json("skillver_V1")),
680            )
681            .mount(&mock)
682            .await;
683
684        let client = client_for(&mock);
685        let v = client
686            .skills()
687            .create_version(
688                "skill_S1",
689                vec![SkillFile::new(
690                    "my-skill/SKILL.md",
691                    &b"# updated skill\n"[..],
692                )],
693            )
694            .await
695            .unwrap();
696        assert_eq!(v.id, "skillver_V1");
697        assert_eq!(v.skill_id, "skill_S1");
698        assert_eq!(v.version, "1759178010641129");
699        assert_eq!(v.directory, "my-skill");
700
701        let recv = &mock.received_requests().await.unwrap()[0];
702        let body = String::from_utf8_lossy(&recv.body);
703        assert!(
704            !body.contains("name=\"display_title\""),
705            "create_version must not include display_title"
706        );
707        assert!(body.contains("name=\"files\""), "{body}");
708    }
709
710    #[tokio::test]
711    async fn list_versions_passes_skill_id_and_query() {
712        let mock = MockServer::start().await;
713        Mock::given(method("GET"))
714            .and(path("/v1/skills/skill_S1/versions"))
715            .and(query_param("limit", "50"))
716            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
717                "data": [skill_version_json("skillver_LV1")],
718                "has_more": false,
719                "next_page": null
720            })))
721            .mount(&mock)
722            .await;
723
724        let client = client_for(&mock);
725        let page = client
726            .skills()
727            .list_versions("skill_S1", ListSkillVersionsParams::default().limit(50))
728            .await
729            .unwrap();
730        assert_eq!(page.data.len(), 1);
731        assert!(!page.has_more);
732        assert_eq!(page.next_cursor(), None);
733    }
734
735    #[tokio::test]
736    async fn get_version_decodes_single_version() {
737        let mock = MockServer::start().await;
738        Mock::given(method("GET"))
739            .and(path("/v1/skills/skill_S1/versions/1759178010641129"))
740            .respond_with(
741                ResponseTemplate::new(200).set_body_json(skill_version_json("skillver_GV1")),
742            )
743            .mount(&mock)
744            .await;
745
746        let client = client_for(&mock);
747        let v = client
748            .skills()
749            .get_version("skill_S1", "1759178010641129")
750            .await
751            .unwrap();
752        assert_eq!(v.id, "skillver_GV1");
753        assert_eq!(v.kind, "skill_version");
754    }
755
756    #[tokio::test]
757    async fn delete_version_id_is_version_timestamp_not_skillver() {
758        let mock = MockServer::start().await;
759        Mock::given(method("DELETE"))
760            .and(path("/v1/skills/skill_S1/versions/1759178010641129"))
761            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
762                "id": "1759178010641129",
763                "type": "skill_version_deleted"
764            })))
765            .mount(&mock)
766            .await;
767
768        let client = client_for(&mock);
769        let confirm = client
770            .skills()
771            .delete_version("skill_S1", "1759178010641129")
772            .await
773            .unwrap();
774        // Note: the delete-version endpoint returns the *version* string in
775        // `id`, not the `skillver_*` identifier. This is intentional per
776        // the API spec.
777        assert_eq!(confirm.id, "1759178010641129");
778        assert_eq!(confirm.kind, "skill_version_deleted");
779    }
780
781    #[tokio::test]
782    async fn skill_file_from_path_reads_disk_and_uses_filename_as_path() {
783        let dir = std::env::temp_dir();
784        let p = dir.join(format!("claude_api_skill_{}_SKILL.md", std::process::id()));
785        tokio::fs::write(&p, b"# from disk\n").await.unwrap();
786        let f = SkillFile::from_path(&p).await.unwrap();
787        assert!(f.path.ends_with("_SKILL.md"));
788        assert_eq!(&f.contents[..], b"# from disk\n");
789        tokio::fs::remove_file(&p).await.ok();
790    }
791}