返回 CodeWhale
skill_cli.rs
根目录 / crates / tui / tests / integration / skill_cli.rs
1 //! Integration tests for the community-skill CLI flows (#140).
2 //!
3 //! Keep this file name free of `install`: Windows can treat unmanifested test
4 //! binaries with installer-like names as elevation-required programs.
5 //!
6 //! These tests exercise the full validation pipeline against a tiny in-process
7 //! HTTP server, so the network gate, download cap, tarball validation, atomic
8 //! rename, and `.installed-from` marker all run end-to-end. The module is
9 //! pulled in via `#[path]` includes (matching `integration_mock_llm.rs`) so we
10 //! get access to private helpers without a separate library crate.
11
12 use std::io::Write;
13 use std::path::Path;
14
15 use flate2::Compression;
16 use flate2::write::GzEncoder;
17 use tempfile::TempDir;
18 use tiny_http::{Method, Response, Server};
19
20 // Pull the production source files into this test binary so the test can
21 // reach `install`'s public surface without a dedicated library crate.
22 //
23 // `install.rs` references `crate::network_policy` and `super::package_digest`
24 // (sibling under `skills::`), so both are pulled in alongside `install`.
25
26 // Both `network_policy` and `install` resolve the home directory through
27 // `crate::config::effective_home_dir()` (#4757). `config/home.rs` is a leaf
28 // over `std`/`codewhale-paths` with no `crate::` references, so including it
29 // here gives this binary the real implementation; naming it `config` matches
30 // how those two files address it in the lib.
31
32 use crate::install::{InstallOutcome, InstallSource, UpdateResult};
33 use crate::network_policy::{DecisionToml, NetworkPolicy};
34
35 /// Construct a gzipped tarball from `(path, body)` pairs. Permissions are set
36 /// to 0o644 so umask differences across platforms don't perturb the bytes.
37 fn make_tarball(entries: &[(&str, &[u8])]) -> Vec<u8> {
38 let mut gz = GzEncoder::new(Vec::new(), Compression::default());
39 {
40 let mut builder = tar::Builder::new(&mut gz);
41 for (path, body) in entries {
42 let mut header = tar::Header::new_gnu();
43 header.set_size(body.len() as u64);
44 header.set_mode(0o644);
45 header.set_cksum();
46 builder
47 .append_data(&mut header, path, *body)
48 .expect("append_data");
49 }
50 builder.finish().expect("finish tar");
51 }
52 gz.finish().expect("finish gz")
53 }
54
55 fn skill_md(name: &str, description: &str) -> Vec<u8> {
56 format!(
57 "---\nname: {name}\ndescription: {description}\n---\n# {name}\n\nThis is a test skill.\n"
58 )
59 .into_bytes()
60 }
61
62 fn allow_all_policy() -> NetworkPolicy {
63 NetworkPolicy {
64 default: DecisionToml::Allow,
65 allow: Vec::new(),
66 deny: Vec::new(),
67 proxy: Vec::new(),
68 proxy_fake_ip_cidrs: Vec::new(),
69 audit: false,
70 }
71 }
72
73 fn deny_all_policy() -> NetworkPolicy {
74 NetworkPolicy {
75 default: DecisionToml::Deny,
76 allow: Vec::new(),
77 deny: Vec::new(),
78 proxy: Vec::new(),
79 proxy_fake_ip_cidrs: Vec::new(),
80 audit: false,
81 }
82 }
83
84 fn prompt_all_policy() -> NetworkPolicy {
85 NetworkPolicy {
86 default: DecisionToml::Prompt,
87 allow: Vec::new(),
88 deny: Vec::new(),
89 proxy: Vec::new(),
90 proxy_fake_ip_cidrs: Vec::new(),
91 audit: false,
92 }
93 }
94
95 /// Spawn a tiny HTTP server that serves `bytes` at any path with 200 OK and
96 /// returns the bound URL. The server replies to *every* request (we re-use it
97 /// across multiple installs in the same test).
98 fn spawn_tarball_server(
99 bytes: Vec<u8>,
100 ) -> (
101 String,
102 std::sync::mpsc::Sender<()>,
103 std::thread::JoinHandle<()>,
104 ) {
105 let server = Server::http("127.0.0.1:0").expect("bind ephemeral port");
106 let url = format!(
107 "http://{}/skill.tar.gz",
108 server.server_addr().to_ip().expect("ip addr")
109 );
110 let (shutdown_tx, shutdown_rx) = std::sync::mpsc::channel::<()>();
111 let handle = std::thread::spawn(move || {
112 loop {
113 // Poll-style with a small recv timeout so we can break out cleanly.
114 match server.recv_timeout(std::time::Duration::from_millis(100)) {
115 Ok(Some(req)) => {
116 if req.method() != &Method::Get {
117 continue;
118 }
119 let response = Response::from_data(bytes.clone());
120 let _ = req.respond(response);
121 }
122 Ok(None) => {}
123 Err(_) => break,
124 }
125 if shutdown_rx.try_recv().is_ok() {
126 break;
127 }
128 }
129 });
130 (url, shutdown_tx, handle)
131 }
132
133 fn shutdown(tx: std::sync::mpsc::Sender<()>, handle: std::thread::JoinHandle<()>) {
134 let _ = tx.send(());
135 let _ = handle.join();
136 }
137
138 #[tokio::test]
139 async fn install_happy_path_writes_skill_and_marker() {
140 let tarball = make_tarball(&[
141 (
142 "test-skill-main/SKILL.md",
143 &skill_md("test-skill", "Test skill"),
144 ),
145 ("test-skill-main/notes.txt", b"hello world"),
146 ]);
147 let (url, tx, handle) = spawn_tarball_server(tarball);
148
149 let tmp = TempDir::new().unwrap();
150 let policy = allow_all_policy();
151
152 let outcome = crate::install::install(
153 InstallSource::DirectUrl(url),
154 tmp.path(),
155 crate::install::DEFAULT_MAX_SIZE_BYTES,
156 &policy,
157 false,
158 )
159 .await
160 .expect("install ok");
161
162 let installed = match outcome {
163 InstallOutcome::Installed(s) => s,
164 other => panic!("expected Installed, got {other:?}"),
165 };
166 assert_eq!(installed.name, "test-skill");
167
168 let installed_dir = tmp.path().join("test-skill");
169 assert!(installed_dir.is_dir(), "skill dir created");
170 assert!(installed_dir.join("SKILL.md").is_file(), "SKILL.md present");
171 assert!(
172 installed_dir.join("notes.txt").is_file(),
173 "extra file present"
174 );
175 assert!(
176 installed_dir
177 .join(crate::install::INSTALLED_FROM_MARKER)
178 .is_file(),
179 ".installed-from marker present"
180 );
181 let marker = std::fs::read_to_string(installed_dir.join(crate::install::INSTALLED_FROM_MARKER))
182 .expect("read marker");
183 assert!(
184 marker.contains("\"schema_version\": 2") || marker.contains("\"schema_version\":2"),
185 "install must write metadata v2: {marker}"
186 );
187 assert!(
188 marker.contains("content_digest"),
189 "install must bind content_digest: {marker}"
190 );
191
192 shutdown(tx, handle);
193 }
194
195 #[tokio::test]
196 async fn install_rejects_path_traversal() {
197 // `tar::Builder::append_data` rejects `..` itself, so we craft the bad
198 // entry by writing the raw header bytes via `append`.
199 let mut gz = GzEncoder::new(Vec::new(), Compression::default());
200 {
201 let mut builder = tar::Builder::new(&mut gz);
202 let body = skill_md("test-skill", "T");
203 let mut hdr = tar::Header::new_gnu();
204 hdr.set_size(body.len() as u64);
205 hdr.set_mode(0o644);
206 hdr.set_cksum();
207 builder
208 .append_data(&mut hdr, "test-skill-main/SKILL.md", body.as_slice())
209 .unwrap();
210
211 // Path-traversal entry. The `tar` crate's `set_path` rejects `..`
212 // itself, so we patch the raw 100-byte name field in the header.
213 let evil_body: &[u8] = b"not gonna happen";
214 let mut evil_hdr = tar::Header::new_gnu();
215 evil_hdr.set_size(evil_body.len() as u64);
216 evil_hdr.set_mode(0o644);
217 // Write a name with a `..` directly into the legacy "name" field.
218 let bytes = evil_hdr.as_old_mut();
219 let evil_name = b"../etc/passwd";
220 bytes.name[..evil_name.len()].copy_from_slice(evil_name);
221 evil_hdr.set_cksum();
222 builder.append(&evil_hdr, evil_body).unwrap();
223 builder.finish().unwrap();
224 }
225 let tarball = gz.finish().unwrap();
226 let (url, tx, handle) = spawn_tarball_server(tarball);
227
228 let tmp = TempDir::new().unwrap();
229 let policy = allow_all_policy();
230 let err = crate::install::install(
231 InstallSource::DirectUrl(url),
232 tmp.path(),
233 crate::install::DEFAULT_MAX_SIZE_BYTES,
234 &policy,
235 false,
236 )
237 .await
238 .expect_err("path traversal must be rejected");
239 let msg = format!("{err:#}");
240 assert!(
241 msg.contains("escapes destination"),
242 "expected path-traversal error, got: {msg}"
243 );
244
245 shutdown(tx, handle);
246 }
247
248 #[tokio::test]
249 async fn install_rejects_oversized_tarball() {
250 let big = vec![b'a'; 256 * 1024]; // 256 KiB per file
251 let mut entries: Vec<(String, Vec<u8>)> = Vec::new();
252 entries.push((
253 "test-skill-main/SKILL.md".to_string(),
254 skill_md("test-skill", "T"),
255 ));
256 for i in 0..50 {
257 entries.push((format!("test-skill-main/big-{i}.bin"), big.clone()));
258 }
259 let entry_refs: Vec<(&str, &[u8])> = entries
260 .iter()
261 .map(|(p, b)| (p.as_str(), b.as_slice()))
262 .collect();
263 let tarball = make_tarball(&entry_refs);
264 let (url, tx, handle) = spawn_tarball_server(tarball);
265
266 let tmp = TempDir::new().unwrap();
267 let policy = allow_all_policy();
268 let small_cap = 1024 * 1024;
269 let err = crate::install::install(
270 InstallSource::DirectUrl(url),
271 tmp.path(),
272 small_cap,
273 &policy,
274 false,
275 )
276 .await
277 .expect_err("oversized must be rejected");
278 let msg = format!("{err:#}");
279 assert!(
280 msg.contains("too large") || msg.contains("exceed"),
281 "expected size cap error, got: {msg}"
282 );
283
284 shutdown(tx, handle);
285 }
286
287 #[tokio::test]
288 async fn install_rejects_missing_skill_md() {
289 let tarball = make_tarball(&[("repo-main/README.md", b"not a skill")]);
290 let (url, tx, handle) = spawn_tarball_server(tarball);
291
292 let tmp = TempDir::new().unwrap();
293 let policy = allow_all_policy();
294 let err = crate::install::install(
295 InstallSource::DirectUrl(url),
296 tmp.path(),
297 crate::install::DEFAULT_MAX_SIZE_BYTES,
298 &policy,
299 false,
300 )
301 .await
302 .expect_err("missing SKILL.md must be rejected");
303 assert!(format!("{err:#}").contains("missing SKILL.md"), "{err:#}");
304
305 shutdown(tx, handle);
306 }
307
308 #[tokio::test]
309 async fn install_accepts_claude_compatible_skill_directory_archive() {
310 let tarball = make_tarball(&[
311 (
312 "repo-main/.claude/skills/workflow-pack/SKILL.md",
313 &skill_md("workflow-pack", "Workflow pack"),
314 ),
315 (
316 "repo-main/.claude/skills/workflow-pack/scripts/check.sh",
317 b"echo ok",
318 ),
319 ("repo-main/README.md", b"outside the selected skill subtree"),
320 ]);
321 let (url, tx, handle) = spawn_tarball_server(tarball);
322
323 let tmp = TempDir::new().unwrap();
324 let policy = allow_all_policy();
325 let outcome = crate::install::install(
326 InstallSource::DirectUrl(url),
327 tmp.path(),
328 crate::install::DEFAULT_MAX_SIZE_BYTES,
329 &policy,
330 false,
331 )
332 .await
333 .expect("claude-compatible skill dir should install");
334 let installed = match outcome {
335 InstallOutcome::Installed(installed) => installed,
336 other => panic!("expected Installed, got {other:?}"),
337 };
338
339 assert_eq!(installed.name, "workflow-pack");
340 assert!(installed.path.join("SKILL.md").is_file());
341 assert!(installed.path.join("scripts/check.sh").is_file());
342 assert!(!installed.path.join("README.md").exists());
343
344 shutdown(tx, handle);
345 }
346
347 #[tokio::test]
348 async fn install_accepts_nested_workflow_pack_skill_directory() {
349 let tarball = make_tarball(&[
350 (
351 "repo-main/packages/superpowers/5.1.0/skills/using-superpowers/SKILL.md",
352 &skill_md("using-superpowers", "Use Superpowers workflow"),
353 ),
354 (
355 "repo-main/packages/superpowers/5.1.0/skills/using-superpowers/references/guide.md",
356 b"guide",
357 ),
358 ]);
359 let (url, tx, handle) = spawn_tarball_server(tarball);
360
361 let tmp = TempDir::new().unwrap();
362 let policy = allow_all_policy();
363 let outcome = crate::install::install(
364 InstallSource::DirectUrl(url),
365 tmp.path(),
366 crate::install::DEFAULT_MAX_SIZE_BYTES,
367 &policy,
368 false,
369 )
370 .await
371 .expect("nested workflow-pack skill dir should install");
372 let installed = match outcome {
373 InstallOutcome::Installed(installed) => installed,
374 other => panic!("expected Installed, got {other:?}"),
375 };
376
377 assert_eq!(installed.name, "using-superpowers");
378 assert!(installed.path.join("SKILL.md").is_file());
379 assert!(installed.path.join("references/guide.md").is_file());
380
381 shutdown(tx, handle);
382 }
383
384 #[tokio::test]
385 async fn install_rejects_multi_skill_claude_plugin_archive() {
386 let tarball = make_tarball(&[
387 (
388 "repo-main/.claude-plugin/plugin.json",
389 br#"{"name":"workflow-pack","version":"1.0.0"}"#,
390 ),
391 (
392 "repo-main/skills/plan/SKILL.md",
393 &skill_md("plan", "Planning skill"),
394 ),
395 (
396 "repo-main/skills/review/SKILL.md",
397 &skill_md("review", "Review skill"),
398 ),
399 ]);
400 let (url, tx, handle) = spawn_tarball_server(tarball);
401
402 let tmp = TempDir::new().unwrap();
403 let policy = allow_all_policy();
404 let err = crate::install::install(
405 InstallSource::DirectUrl(url),
406 tmp.path(),
407 crate::install::DEFAULT_MAX_SIZE_BYTES,
408 &policy,
409 false,
410 )
411 .await
412 .expect_err("multi-skill Claude plugin archive should not be flattened");
413 let msg = format!("{err:#}");
414 assert!(
415 msg.contains("Claude Code plugin archive contains multiple SKILL.md entries"),
416 "expected Claude plugin compatibility error, got: {msg}"
417 );
418 assert!(
419 std::fs::read_dir(tmp.path()).unwrap().next().is_none(),
420 "rejected plugin archive must not write an installed skill"
421 );
422
423 shutdown(tx, handle);
424 }
425
426 #[tokio::test]
427 async fn install_accepts_single_skill_subdirectory_archive() {
428 let tarball = make_tarball(&[
429 (
430 "repo-main/my-workflow/SKILL.md",
431 &skill_md("my-workflow", "Nested workflow"),
432 ),
433 ("repo-main/my-workflow/examples/example.md", b"example"),
434 ("repo-main/README.md", b"outside the selected skill subtree"),
435 ]);
436 let (url, tx, handle) = spawn_tarball_server(tarball);
437
438 let tmp = TempDir::new().unwrap();
439 let policy = allow_all_policy();
440 let outcome = crate::install::install(
441 InstallSource::DirectUrl(url),
442 tmp.path(),
443 crate::install::DEFAULT_MAX_SIZE_BYTES,
444 &policy,
445 false,
446 )
447 .await
448 .expect("single nested skill dir should install");
449 let installed = match outcome {
450 InstallOutcome::Installed(installed) => installed,
451 other => panic!("expected Installed, got {other:?}"),
452 };
453
454 assert_eq!(installed.name, "my-workflow");
455 assert!(installed.path.join("SKILL.md").is_file());
456 assert!(installed.path.join("examples/example.md").is_file());
457 assert!(!installed.path.join("README.md").exists());
458
459 shutdown(tx, handle);
460 }
461
462 #[tokio::test]
463 async fn install_rejects_missing_required_frontmatter() {
464 let tarball = make_tarball(&[("repo-main/SKILL.md", b"---\nname: test\n---\nbody\n")]);
465 let (url, tx, handle) = spawn_tarball_server(tarball);
466
467 let tmp = TempDir::new().unwrap();
468 let policy = allow_all_policy();
469 let err = crate::install::install(
470 InstallSource::DirectUrl(url),
471 tmp.path(),
472 crate::install::DEFAULT_MAX_SIZE_BYTES,
473 &policy,
474 false,
475 )
476 .await
477 .expect_err("missing description must be rejected");
478 assert!(format!("{err:#}").contains("description"), "{err:#}");
479
480 shutdown(tx, handle);
481 }
482
483 #[tokio::test]
484 async fn install_idempotent_then_uninstall_then_reinstall() {
485 let tarball_bytes =
486 make_tarball(&[("repo-main/SKILL.md", &skill_md("idem-skill", "Idempotent"))]);
487 let (url, tx, handle) = spawn_tarball_server(tarball_bytes);
488
489 let tmp = TempDir::new().unwrap();
490 let policy = allow_all_policy();
491
492 crate::install::install(
493 InstallSource::DirectUrl(url.clone()),
494 tmp.path(),
495 crate::install::DEFAULT_MAX_SIZE_BYTES,
496 &policy,
497 false,
498 )
499 .await
500 .expect("first install ok");
501
502 // Second install with `update = false` must reject.
503 let err = crate::install::install(
504 InstallSource::DirectUrl(url.clone()),
505 tmp.path(),
506 crate::install::DEFAULT_MAX_SIZE_BYTES,
507 &policy,
508 false,
509 )
510 .await
511 .expect_err("second install must reject");
512 let msg = format!("{err:#}");
513 assert!(
514 msg.contains("already installed"),
515 "expected already-installed error, got: {msg}"
516 );
517
518 // Uninstall then reinstall.
519 crate::install::uninstall("idem-skill", tmp.path()).expect("uninstall ok");
520 assert!(!tmp.path().join("idem-skill").exists());
521
522 crate::install::install(
523 InstallSource::DirectUrl(url),
524 tmp.path(),
525 crate::install::DEFAULT_MAX_SIZE_BYTES,
526 &policy,
527 false,
528 )
529 .await
530 .expect("reinstall ok");
531
532 assert!(tmp.path().join("idem-skill").join("SKILL.md").is_file());
533 shutdown(tx, handle);
534 }
535
536 #[tokio::test]
537 async fn update_no_change_returns_nochange_without_overwriting() {
538 let tarball_bytes =
539 make_tarball(&[("repo-main/SKILL.md", &skill_md("upd-skill", "Update test"))]);
540 let (url, tx, handle) = spawn_tarball_server(tarball_bytes);
541 let tmp = TempDir::new().unwrap();
542 let policy = allow_all_policy();
543
544 crate::install::install(
545 InstallSource::DirectUrl(url.clone()),
546 tmp.path(),
547 crate::install::DEFAULT_MAX_SIZE_BYTES,
548 &policy,
549 false,
550 )
551 .await
552 .unwrap();
553
554 // Patch the marker so update() re-fetches the same URL.
555 let marker_path = tmp
556 .path()
557 .join("upd-skill")
558 .join(crate::install::INSTALLED_FROM_MARKER);
559 let marker_body = std::fs::read_to_string(&marker_path).unwrap();
560 let mut marker_json: serde_json::Value = serde_json::from_str(&marker_body).unwrap();
561 marker_json["spec"] = serde_json::Value::String(url);
562 std::fs::write(&marker_path, marker_json.to_string()).unwrap();
563
564 // Capture mtime so we can confirm SKILL.md wasn't rewritten.
565 let skill_md_path = tmp.path().join("upd-skill").join("SKILL.md");
566 let mtime_before = std::fs::metadata(&skill_md_path)
567 .unwrap()
568 .modified()
569 .unwrap();
570
571 let result = crate::install::update(
572 "upd-skill",
573 tmp.path(),
574 crate::install::DEFAULT_MAX_SIZE_BYTES,
575 &policy,
576 )
577 .await
578 .expect("update ok");
579 assert!(matches!(result, UpdateResult::NoChange));
580
581 let mtime_after = std::fs::metadata(&skill_md_path)
582 .unwrap()
583 .modified()
584 .unwrap();
585 assert_eq!(mtime_before, mtime_after, "SKILL.md must not be rewritten");
586 shutdown(tx, handle);
587 }
588
589 #[tokio::test]
590 async fn install_with_deny_policy_returns_network_denied() {
591 let tmp = TempDir::new().unwrap();
592 let policy = deny_all_policy();
593 let outcome = crate::install::install(
594 InstallSource::DirectUrl("https://example.invalid/skill.tar.gz".to_string()),
595 tmp.path(),
596 crate::install::DEFAULT_MAX_SIZE_BYTES,
597 &policy,
598 false,
599 )
600 .await
601 .expect("policy outcome should be Ok");
602 match outcome {
603 InstallOutcome::NetworkDenied(host) => {
604 assert!(host.contains("example.invalid"), "got host {host}");
605 }
606 other => panic!("expected NetworkDenied, got {other:?}"),
607 }
608
609 // Verify the temp dir is untouched.
610 assert!(
611 std::fs::read_dir(tmp.path()).unwrap().next().is_none(),
612 "temp dir must be untouched"
613 );
614 }
615
616 #[tokio::test]
617 async fn install_with_prompt_policy_returns_needs_approval() {
618 let tmp = TempDir::new().unwrap();
619 let policy = prompt_all_policy();
620 let outcome = crate::install::install(
621 InstallSource::DirectUrl("https://example.invalid/skill.tar.gz".to_string()),
622 tmp.path(),
623 crate::install::DEFAULT_MAX_SIZE_BYTES,
624 &policy,
625 false,
626 )
627 .await
628 .expect("policy outcome should be Ok");
629 match outcome {
630 InstallOutcome::NeedsApproval(host) => {
631 assert!(host.contains("example.invalid"), "got host {host}");
632 }
633 other => panic!("expected NeedsApproval, got {other:?}"),
634 }
635 assert!(
636 std::fs::read_dir(tmp.path()).unwrap().next().is_none(),
637 "temp dir must be untouched on prompt"
638 );
639 }
640
641 #[tokio::test]
642 async fn install_rejects_symlink_entry() {
643 let mut gz = GzEncoder::new(Vec::new(), Compression::default());
644 {
645 let mut builder = tar::Builder::new(&mut gz);
646
647 let body = skill_md("link-skill", "x");
648 let mut hdr = tar::Header::new_gnu();
649 hdr.set_size(body.len() as u64);
650 hdr.set_mode(0o644);
651 hdr.set_cksum();
652 builder
653 .append_data(&mut hdr, "repo-main/SKILL.md", body.as_slice())
654 .unwrap();
655
656 let mut link_hdr = tar::Header::new_gnu();
657 link_hdr.set_entry_type(tar::EntryType::Symlink);
658 link_hdr.set_size(0);
659 link_hdr.set_mode(0o777);
660 builder
661 .append_link(&mut link_hdr, "repo-main/escape", Path::new("/etc/passwd"))
662 .unwrap();
663 builder.finish().unwrap();
664 }
665 let tarball = gz.finish().unwrap();
666 let (url, tx, handle) = spawn_tarball_server(tarball);
667
668 let tmp = TempDir::new().unwrap();
669 let policy = allow_all_policy();
670 let err = crate::install::install(
671 InstallSource::DirectUrl(url),
672 tmp.path(),
673 crate::install::DEFAULT_MAX_SIZE_BYTES,
674 &policy,
675 false,
676 )
677 .await
678 .expect_err("symlinks must be rejected");
679 assert!(format!("{err:#}").contains("symlink"), "{err:#}");
680
681 shutdown(tx, handle);
682 }
683
684 #[tokio::test]
685 async fn install_ignores_symlink_outside_selected_skill_root() {
686 let mut gz = GzEncoder::new(Vec::new(), Compression::default());
687 {
688 let mut builder = tar::Builder::new(&mut gz);
689
690 let mut link_hdr = tar::Header::new_gnu();
691 link_hdr.set_entry_type(tar::EntryType::Symlink);
692 link_hdr.set_size(0);
693 link_hdr.set_mode(0o777);
694 builder
695 .append_link(&mut link_hdr, "repo-main/AGENTS.md", Path::new("CLAUDE.md"))
696 .unwrap();
697
698 let body = skill_md("nested-skill", "Nested skill");
699 let mut hdr = tar::Header::new_gnu();
700 hdr.set_size(body.len() as u64);
701 hdr.set_mode(0o644);
702 hdr.set_cksum();
703 builder
704 .append_data(
705 &mut hdr,
706 "repo-main/skills/nested-skill/SKILL.md",
707 body.as_slice(),
708 )
709 .unwrap();
710
711 let notes = b"selected subtree only";
712 let mut notes_hdr = tar::Header::new_gnu();
713 notes_hdr.set_size(notes.len() as u64);
714 notes_hdr.set_mode(0o644);
715 notes_hdr.set_cksum();
716 builder
717 .append_data(
718 &mut notes_hdr,
719 "repo-main/skills/nested-skill/notes.txt",
720 notes.as_slice(),
721 )
722 .unwrap();
723
724 builder.finish().unwrap();
725 }
726 let tarball = gz.finish().unwrap();
727 let (url, tx, handle) = spawn_tarball_server(tarball);
728
729 let tmp = TempDir::new().unwrap();
730 let policy = allow_all_policy();
731 let outcome = crate::install::install(
732 InstallSource::DirectUrl(url),
733 tmp.path(),
734 crate::install::DEFAULT_MAX_SIZE_BYTES,
735 &policy,
736 false,
737 )
738 .await
739 .expect("repo-level symlink outside selected skill root should be ignored");
740 let installed = match outcome {
741 InstallOutcome::Installed(installed) => installed,
742 other => panic!("expected Installed, got {other:?}"),
743 };
744
745 assert_eq!(installed.name, "nested-skill");
746 assert!(installed.path.join("SKILL.md").exists());
747 assert!(installed.path.join("notes.txt").exists());
748 assert!(!installed.path.join("AGENTS.md").exists());
749
750 shutdown(tx, handle);
751 }
752
753 #[test]
754 fn uninstall_refuses_system_skill() {
755 let tmp = TempDir::new().unwrap();
756 let dir = tmp.path().join("system-skill");
757 std::fs::create_dir_all(&dir).unwrap();
758 let mut f = std::fs::File::create(dir.join("SKILL.md")).unwrap();
759 f.write_all(b"---\nname: system-skill\ndescription: x\n---\n")
760 .unwrap();
761 // No `.installed-from` marker — looks like a system skill.
762
763 let err = crate::install::uninstall("system-skill", tmp.path()).expect_err("must refuse");
764 assert!(format!("{err:#}").contains("not installed via"));
765 assert!(dir.exists(), "directory must be left alone");
766 }
767
767 lines RUST