1#![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
38const SKILLS_BETA: &[&str] = &["skills-2025-10-02"];
40
41#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum SkillSource {
49 Custom,
51 Anthropic,
53 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81#[non_exhaustive]
82pub enum SkillSourceFilter {
83 Custom,
85 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102#[non_exhaustive]
103pub struct Skill {
104 pub id: String,
107 #[serde(rename = "type", default = "default_skill_kind")]
109 pub kind: String,
110 pub display_title: String,
112 pub latest_version: String,
114 pub source: SkillSource,
116 pub created_at: String,
118 pub updated_at: String,
120}
121
122fn default_skill_kind() -> String {
123 "skill".to_owned()
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132#[non_exhaustive]
133pub struct SkillVersion {
134 pub id: String,
136 #[serde(rename = "type", default = "default_skill_version_kind")]
138 pub kind: String,
139 pub skill_id: String,
141 pub version: String,
145 pub name: String,
147 pub description: String,
149 pub directory: String,
151 pub created_at: String,
153}
154
155fn default_skill_version_kind() -> String {
156 "skill_version".to_owned()
157}
158
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161#[non_exhaustive]
162pub struct SkillDeleted {
163 pub id: String,
165 #[serde(rename = "type", default)]
167 pub kind: String,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175#[non_exhaustive]
176pub struct SkillVersionDeleted {
177 pub id: String,
179 #[serde(rename = "type", default)]
181 pub kind: String,
182}
183
184#[derive(Debug, Clone)]
193pub struct SkillFile {
194 pub path: String,
197 pub contents: Bytes,
199}
200
201impl SkillFile {
202 #[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 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#[derive(Debug, Clone, Default)]
236#[non_exhaustive]
237pub struct CreateSkillRequest {
238 pub display_title: Option<String>,
240 pub files: Vec<SkillFile>,
242}
243
244impl CreateSkillRequest {
245 #[must_use]
247 pub fn new() -> Self {
248 Self::default()
249 }
250
251 #[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 #[must_use]
260 pub fn file(mut self, f: SkillFile) -> Self {
261 self.files.push(f);
262 self
263 }
264
265 #[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#[derive(Debug, Clone, Default)]
291#[non_exhaustive]
292pub struct ListSkillsParams {
293 pub limit: Option<u32>,
295 pub page: Option<String>,
297 pub source: Option<SkillSourceFilter>,
299}
300
301impl ListSkillsParams {
302 #[must_use]
304 pub fn limit(mut self, limit: u32) -> Self {
305 self.limit = Some(limit);
306 self
307 }
308
309 #[must_use]
311 pub fn page(mut self, cursor: impl Into<String>) -> Self {
312 self.page = Some(cursor.into());
313 self
314 }
315
316 #[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#[derive(Debug, Clone, Default)]
340#[non_exhaustive]
341pub struct ListSkillVersionsParams {
342 pub limit: Option<u32>,
344 pub page: Option<String>,
346}
347
348impl ListSkillVersionsParams {
349 #[must_use]
351 pub fn limit(mut self, limit: u32) -> Self {
352 self.limit = Some(limit);
353 self
354 }
355
356 #[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
375pub 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 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 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 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 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 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 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 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 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 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 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}