返回 DeepSeek-TUI-2026
skills.rs
根目录 / crates / tui / src / commands / skills.rs
1 //! Skills commands: skills, skill
2
3 use std::fmt::Write;
4
5 use crate::network_policy::NetworkPolicy;
6 use crate::skills::SkillRegistry;
7 use crate::skills::install::{
8 self, DEFAULT_MAX_SIZE_BYTES, DEFAULT_REGISTRY_URL, InstallOutcome, InstallSource,
9 RegistryFetchResult, SkillSyncOutcome, SyncResult, UpdateResult,
10 };
11 use crate::tui::app::App;
12 use crate::tui::history::HistoryCell;
13
14 use super::CommandResult;
15
16 fn render_skill_warnings(registry: &SkillRegistry) -> String {
17 if registry.warnings().is_empty() {
18 return String::new();
19 }
20
21 let mut out = String::new();
22 let _ = writeln!(out, "\nWarnings ({}):", registry.warnings().len());
23 for warning in registry.warnings() {
24 let _ = writeln!(out, " - {warning}");
25 }
26 out
27 }
28
29 /// List all available skills. Pass `--remote` (or `remote`) to fetch the
30 /// curated registry instead of scanning the local skills directory.
31 /// Pass `sync` to pull the registry index and download all skills to the
32 /// local cache (`~/.deepseek/cache/skills/`).
33 pub fn list_skills(app: &mut App, arg: Option<&str>) -> CommandResult {
34 if let Some(arg) = arg {
35 let trimmed = arg.trim();
36 if trimmed == "--remote" || trimmed == "remote" {
37 return list_remote_skills(app);
38 }
39 if trimmed == "sync" || trimmed == "--sync" {
40 return sync_skills(app);
41 }
42 if !trimmed.is_empty() {
43 return CommandResult::error("Usage: /skills [--remote|sync]");
44 }
45 }
46 let skills_dir = app.skills_dir.clone();
47 let registry = SkillRegistry::discover(&skills_dir);
48 let warnings = render_skill_warnings(&registry);
49
50 if registry.is_empty() {
51 let msg = format!(
52 "No skills found.\n\n\
53 Skills location: {}\n\n\
54 To add skills, create directories with SKILL.md files:\n \
55 {}/my-skill/SKILL.md\n\n\
56 Format:\n \
57 ---\n \
58 name: my-skill\n \
59 description: What this skill does\n \
60 allowed-tools: read_file, list_dir\n \
61 ---\n\n \
62 <instructions here>{warnings}",
63 skills_dir.display(),
64 skills_dir.display()
65 );
66 return CommandResult::message(msg);
67 }
68
69 let mut output = format!("Available skills ({}):\n", registry.len());
70 output.push_str("─────────────────────────────\n");
71 for skill in registry.list() {
72 let _ = writeln!(output, " /{} - {}", skill.name, skill.description);
73 }
74 let _ = write!(
75 output,
76 "\nUse /skill <name> to run a skill\nSkills location: {}{}",
77 skills_dir.display(),
78 warnings
79 );
80
81 CommandResult::message(output)
82 }
83
84 /// Run a specific skill — activates skill for next user message, or
85 /// dispatches a sub-command (`install`, `update`, `uninstall`, `trust`).
86 /// Try to run a skill by exact name (used for unified slash-command namespace, #435).
87 /// Returns None when no skill with that name exists, so the caller can try other sources.
88 pub fn run_skill_by_name(app: &mut App, name: &str, _arg: Option<&str>) -> Option<CommandResult> {
89 let skills_dir = app.skills_dir.clone();
90 let registry = crate::skills::SkillRegistry::discover(&skills_dir);
91 if registry.get(name).is_some() {
92 Some(activate_skill(app, name))
93 } else {
94 None
95 }
96 }
97
98 pub fn run_skill(app: &mut App, name: Option<&str>) -> CommandResult {
99 let raw = match name {
100 Some(n) => n.trim(),
101 None => {
102 return CommandResult::error(
103 "Usage: /skill <name>\n\nSubcommands:\n /skill install <github:owner/repo|https://…|<registry-name>>\n /skill update <name>\n /skill uninstall <name>\n /skill trust <name>",
104 );
105 }
106 };
107
108 // Sub-command dispatch happens before the activation path so users can't
109 // accidentally activate a skill literally named "install".
110 let mut iter = raw.splitn(2, char::is_whitespace);
111 let head = iter.next().unwrap_or("").trim();
112 let rest = iter.next().unwrap_or("").trim();
113 match head {
114 "install" => return install_skill(app, rest),
115 "update" => return update_skill(app, rest),
116 "uninstall" => return uninstall_skill(app, rest),
117 "trust" => return trust_skill(app, rest),
118 _ => {}
119 }
120
121 activate_skill(app, raw)
122 }
123
124 fn activate_skill(app: &mut App, name: &str) -> CommandResult {
125 // `/skill new` is a friendly alias for `/skill skill-creator`.
126 let name = if name == "new" { "skill-creator" } else { name };
127
128 let skills_dir = app.skills_dir.clone();
129 let registry = SkillRegistry::discover(&skills_dir);
130
131 if let Some(skill) = registry.get(name) {
132 let instruction = format!(
133 "You are now using a skill. Follow these instructions:\n\n# Skill: {}\n\n{}\n\n---\n\nNow respond to the user's request following the above skill instructions.",
134 skill.name, skill.body
135 );
136
137 app.add_message(HistoryCell::System {
138 content: format!("Activated skill: {}\n\n{}", skill.name, skill.description),
139 });
140
141 app.active_skill = Some(instruction);
142
143 CommandResult::message(format!(
144 "Skill '{}' activated.\n\nDescription: {}\n\nType your request and the skill instructions will be applied.",
145 skill.name, skill.description
146 ))
147 } else {
148 let available: Vec<String> = registry.list().iter().map(|s| s.name.clone()).collect();
149 let warnings = render_skill_warnings(&registry);
150
151 if available.is_empty() {
152 CommandResult::error(format!(
153 "Skill '{name}' not found. No skills installed.\n\nUse /skills to see how to add skills.{warnings}"
154 ))
155 } else {
156 CommandResult::error(format!(
157 "Skill '{}' not found.\n\nAvailable skills: {}{}",
158 name,
159 available.join(", "),
160 warnings
161 ))
162 }
163 }
164 }
165
166 // ─── /skill install ────────────────────────────────────────────────────────
167
168 fn install_skill(app: &mut App, spec: &str) -> CommandResult {
169 if spec.is_empty() {
170 return CommandResult::error(
171 "Usage: /skill install <github:owner/repo|https://…|<registry-name>>",
172 );
173 }
174 let source = match InstallSource::parse(spec) {
175 Ok(s) => s,
176 Err(err) => return CommandResult::error(format!("Invalid install source: {err}")),
177 };
178 let skills_dir = app.skills_dir.clone();
179 let (network, max_size, registry_url) = installer_settings(app);
180
181 let outcome = run_async(async move {
182 install::install_with_registry(
183 source,
184 &skills_dir,
185 max_size,
186 &network,
187 false,
188 &registry_url,
189 )
190 .await
191 });
192
193 match outcome {
194 Ok(InstallOutcome::Installed(installed)) => {
195 app.refresh_skill_cache();
196 let path_str = path_or_default(&installed.path);
197 CommandResult::message(format!(
198 "Installed skill '{}' from {}.\nLocation: {}\n\nRun /skills to see it in the list.",
199 installed.name, spec, path_str
200 ))
201 }
202 Ok(InstallOutcome::NeedsApproval(host)) => {
203 CommandResult::error(needs_approval_message(&host))
204 }
205 Ok(InstallOutcome::NetworkDenied(host)) => {
206 CommandResult::error(network_denied_message(&host))
207 }
208 Err(err) => CommandResult::error(format!("Install failed: {err:#}")),
209 }
210 }
211
212 // ─── /skill update ─────────────────────────────────────────────────────────
213
214 fn update_skill(app: &mut App, name: &str) -> CommandResult {
215 if name.is_empty() {
216 return CommandResult::error("Usage: /skill update <name>");
217 }
218 let skills_dir = app.skills_dir.clone();
219 let (network, max_size, registry_url) = installer_settings(app);
220 let owned_name = name.to_string();
221 let outcome = run_async(async move {
222 install::update_with_registry(&owned_name, &skills_dir, max_size, &network, &registry_url)
223 .await
224 });
225
226 match outcome {
227 Ok(UpdateResult::NoChange) => {
228 CommandResult::message(format!("Skill '{name}': no upstream change."))
229 }
230 Ok(UpdateResult::Updated(installed)) => CommandResult::message(format!(
231 "Skill '{}' updated. Location: {}",
232 installed.name,
233 path_or_default(&installed.path)
234 )),
235 Ok(UpdateResult::NeedsApproval(host)) => {
236 CommandResult::error(needs_approval_message(&host))
237 }
238 Ok(UpdateResult::NetworkDenied(host)) => {
239 CommandResult::error(network_denied_message(&host))
240 }
241 Err(err) => CommandResult::error(format!("Update failed: {err:#}")),
242 }
243 }
244
245 // ─── /skill uninstall ──────────────────────────────────────────────────────
246
247 fn uninstall_skill(app: &mut App, name: &str) -> CommandResult {
248 if name.is_empty() {
249 return CommandResult::error("Usage: /skill uninstall <name>");
250 }
251 match install::uninstall(name, &app.skills_dir) {
252 Ok(()) => {
253 app.refresh_skill_cache();
254 CommandResult::message(format!("Removed skill '{name}'."))
255 }
256 Err(err) => CommandResult::error(format!("Uninstall failed: {err:#}")),
257 }
258 }
259
260 // ─── /skill trust ──────────────────────────────────────────────────────────
261
262 fn trust_skill(app: &mut App, name: &str) -> CommandResult {
263 if name.is_empty() {
264 return CommandResult::error("Usage: /skill trust <name>");
265 }
266 match install::trust(name, &app.skills_dir) {
267 Ok(()) => CommandResult::message(format!(
268 "Marked skill '{name}' as trusted. Tools that consult the .trusted marker may now invoke its scripts/."
269 )),
270 Err(err) => CommandResult::error(format!("Trust failed: {err:#}")),
271 }
272 }
273
274 // ─── /skills --remote ──────────────────────────────────────────────────────
275
276 /// List skills available in the configured curated registry.
277 pub fn list_remote_skills(app: &mut App) -> CommandResult {
278 let (network, _max_size, registry_url) = installer_settings(app);
279 let registry = run_async(async move { install::fetch_registry(&network, &registry_url).await });
280 match registry {
281 Ok(RegistryFetchResult::Loaded(doc)) => {
282 if doc.skills.is_empty() {
283 return CommandResult::message("Registry is empty.");
284 }
285 let mut out = format!("Available remote skills ({}):\n", doc.skills.len());
286 out.push_str("─────────────────────────────\n");
287 for (name, entry) in &doc.skills {
288 let _ = writeln!(
289 out,
290 " {name} — {} (source: {})",
291 entry.description.clone().unwrap_or_default(),
292 entry.source
293 );
294 }
295 let _ = write!(out, "\nInstall with: /skill install <name>");
296 CommandResult::message(out)
297 }
298 Ok(RegistryFetchResult::NeedsApproval(host)) => {
299 CommandResult::error(needs_approval_message(&host))
300 }
301 Ok(RegistryFetchResult::Denied(host)) => {
302 CommandResult::error(network_denied_message(&host))
303 }
304 Err(err) => CommandResult::error(format!("Failed to fetch registry: {err:#}")),
305 }
306 }
307
308 // ─── /skills sync ──────────────────────────────────────────────────────────
309
310 /// Fetch the remote registry index and download every listed skill into the
311 /// local cache (`~/.deepseek/cache/skills/<name>/`).
312 ///
313 /// For each skill the sync checks the cached ETag / SHA-256 before
314 /// downloading so unchanged skills are skipped in O(1) network round-trips.
315 fn sync_skills(app: &mut App) -> CommandResult {
316 let (network, max_size, registry_url) = installer_settings(app);
317 let cache_dir = install::default_cache_skills_dir();
318
319 let result = run_async(async move {
320 install::sync_registry(&network, &registry_url, &cache_dir, max_size).await
321 });
322
323 match result {
324 Ok(SyncResult::RegistryDenied(host)) => CommandResult::error(network_denied_message(&host)),
325 Ok(SyncResult::RegistryNeedsApproval(host)) => {
326 CommandResult::error(needs_approval_message(&host))
327 }
328 Ok(SyncResult::Done { outcomes }) => {
329 let total = outcomes.len();
330 let mut downloaded = 0usize;
331 let mut fresh = 0usize;
332 let mut failed = 0usize;
333 let mut out = String::from("Registry sync complete.\n\n");
334
335 for outcome in &outcomes {
336 match outcome {
337 SkillSyncOutcome::Downloaded { name, path } => {
338 downloaded += 1;
339 let _ = writeln!(out, " [+] {name} — downloaded to {}", path.display());
340 }
341 SkillSyncOutcome::Fresh { name } => {
342 fresh += 1;
343 let _ = writeln!(out, " [=] {name} — already up to date");
344 }
345 SkillSyncOutcome::Failed { name, reason } => {
346 failed += 1;
347 let _ = writeln!(out, " [!] {name} — failed: {reason}");
348 }
349 SkillSyncOutcome::Denied { name, host } => {
350 failed += 1;
351 let _ = writeln!(out, " [x] {name} — network denied ({host})");
352 }
353 SkillSyncOutcome::NeedsApproval { name, host } => {
354 failed += 1;
355 let _ = writeln!(
356 out,
357 " [?] {name} — needs approval for {host} (run `/network allow {host}` then retry)"
358 );
359 }
360 }
361 }
362
363 let _ = write!(
364 out,
365 "\n{total} skill(s) processed: {downloaded} downloaded, {fresh} up-to-date, {failed} failed."
366 );
367
368 CommandResult::message(out)
369 }
370 Err(err) => CommandResult::error(format!("Sync failed: {err:#}")),
371 }
372 }
373
374 // ─── helpers ───────────────────────────────────────────────────────────────
375
376 /// Read the active config knobs for the installer.
377 ///
378 /// We load `Config::load` on demand because [`App`] does not carry a `Config`
379 /// field — and loading is cheap (small TOML file) compared to the network
380 /// round-trip the install/update operation will incur next. If the config
381 /// fails to parse, we fall back to defaults so the user still gets a
382 /// network-gated install rather than a silent crash.
383 fn installer_settings(_app: &App) -> (NetworkPolicy, u64, String) {
384 let cfg = crate::config::Config::load(None, None).unwrap_or_default();
385 let network = cfg
386 .network
387 .clone()
388 .map(|policy| policy.into_runtime())
389 .unwrap_or_default();
390 let skills_cfg = cfg.skills.as_ref();
391 let max_size = skills_cfg
392 .and_then(|s| s.max_install_size_bytes)
393 .unwrap_or(DEFAULT_MAX_SIZE_BYTES);
394 let registry_url = skills_cfg
395 .and_then(|s| s.registry_url.clone())
396 .unwrap_or_else(|| DEFAULT_REGISTRY_URL.to_string());
397 (network, max_size, registry_url)
398 }
399
400 fn run_async<F, T>(future: F) -> T
401 where
402 F: std::future::Future<Output = T>,
403 {
404 // We're on the TUI's thread, which is part of the multi-threaded runtime.
405 // `block_in_place` + `Handle::current().block_on` is the pattern used by
406 // `commands/cycle.rs` to bridge sync slash-command handlers back into the
407 // async ecosystem.
408 tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future))
409 }
410
411 fn path_or_default(path: &std::path::Path) -> String {
412 path.file_name()
413 .map(|n| {
414 // Display with parent so the user sees the full skill location.
415 // We intentionally use `display()` here because it's just for
416 // user-facing output, not for path comparisons.
417 let parent = path
418 .parent()
419 .map(|p| p.display().to_string())
420 .unwrap_or_default();
421 if parent.is_empty() {
422 n.to_string_lossy().to_string()
423 } else {
424 format!("{parent}/{}", n.to_string_lossy())
425 }
426 })
427 .unwrap_or_else(|| path.display().to_string())
428 }
429
430 fn needs_approval_message(host: &str) -> String {
431 format!(
432 "Network policy requires approval for {host}.\n\
433 Add it to your allow list with `/network allow {host}` (or set [network].default = \"allow\" in ~/.deepseek/config.toml), then retry."
434 )
435 }
436
437 fn network_denied_message(host: &str) -> String {
438 format!(
439 "Network policy denied access to {host}.\n\
440 Remove the deny entry from ~/.deepseek/config.toml under [network] or contact your administrator."
441 )
442 }
443
444 #[cfg(test)]
445 mod tests {
446 use super::*;
447 use crate::config::Config;
448 use crate::tui::app::{App, TuiOptions};
449 use tempfile::TempDir;
450
451 fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
452 let options = TuiOptions {
453 model: "deepseek-v4-pro".to_string(),
454 workspace: tmpdir.path().to_path_buf(),
455 config_path: None,
456 config_profile: None,
457 allow_shell: false,
458 use_alt_screen: true,
459 use_mouse_capture: false,
460 use_bracketed_paste: true,
461 max_subagents: 1,
462 skills_dir: tmpdir.path().join("skills"),
463 memory_path: tmpdir.path().join("memory.md"),
464 notes_path: tmpdir.path().join("notes.txt"),
465 mcp_config_path: tmpdir.path().join("mcp.json"),
466 use_memory: false,
467 start_in_agent_mode: false,
468 skip_onboarding: true,
469 yolo: false,
470 resume_session_id: None,
471 initial_input: None,
472 };
473 let mut app = App::new(options, &Config::default());
474 app.skills_dir = tmpdir.path().join("skills");
475 app
476 }
477
478 fn create_skill_dir(tmpdir: &TempDir, skill_name: &str, skill_content: &str) {
479 let skill_dir = tmpdir.path().join("skills").join(skill_name);
480 std::fs::create_dir_all(&skill_dir).unwrap();
481 std::fs::write(skill_dir.join("SKILL.md"), skill_content).unwrap();
482 }
483
484 #[test]
485 fn test_list_skills_empty_directory() {
486 let tmpdir = TempDir::new().unwrap();
487 let mut app = create_test_app_with_tmpdir(&tmpdir);
488 let result = list_skills(&mut app, None);
489 assert!(result.message.is_some());
490 let msg = result.message.unwrap();
491 assert!(msg.contains("No skills found"));
492 assert!(msg.contains("Skills location:"));
493 }
494
495 #[test]
496 fn test_list_skills_with_skills() {
497 let tmpdir = TempDir::new().unwrap();
498 create_skill_dir(
499 &tmpdir,
500 "test-skill",
501 "---\nname: test-skill\ndescription: A test skill\n---\nDo something",
502 );
503 let mut app = create_test_app_with_tmpdir(&tmpdir);
504 let result = list_skills(&mut app, None);
505 assert!(result.message.is_some());
506 let msg = result.message.unwrap();
507 assert!(msg.contains("Available skills"));
508 assert!(msg.contains("/test-skill"));
509 }
510
511 #[test]
512 fn test_skill_subcommand_dispatch_install_usage() {
513 let tmpdir = TempDir::new().unwrap();
514 let mut app = create_test_app_with_tmpdir(&tmpdir);
515 // Empty install spec → usage hint, not invalid-source error.
516 let result = run_skill(&mut app, Some("install"));
517 let msg = result.message.unwrap();
518 assert!(msg.contains("/skill install"), "got: {msg}");
519 }
520
521 #[test]
522 fn test_skill_subcommand_dispatch_uninstall_missing() {
523 let tmpdir = TempDir::new().unwrap();
524 let mut app = create_test_app_with_tmpdir(&tmpdir);
525 let result = run_skill(&mut app, Some("uninstall absent-skill"));
526 let msg = result.message.unwrap();
527 assert!(msg.contains("not installed"), "got: {msg}");
528 }
529
530 #[test]
531 fn test_run_skill_without_name() {
532 let tmpdir = TempDir::new().unwrap();
533 let mut app = create_test_app_with_tmpdir(&tmpdir);
534 let result = run_skill(&mut app, None);
535 assert!(result.message.is_some());
536 assert!(result.message.unwrap().contains("Usage: /skill"));
537 }
538
539 #[test]
540 fn test_run_skill_not_found() {
541 let tmpdir = TempDir::new().unwrap();
542 let mut app = create_test_app_with_tmpdir(&tmpdir);
543 let result = run_skill(&mut app, Some("nonexistent"));
544 assert!(result.message.is_some());
545 let msg = result.message.unwrap();
546 assert!(msg.contains("not found"));
547 }
548
549 #[test]
550 fn test_run_skill_activates() {
551 let tmpdir = TempDir::new().unwrap();
552 create_skill_dir(
553 &tmpdir,
554 "test-skill",
555 "---\nname: test-skill\ndescription: A test skill\n---\nDo something special",
556 );
557 let mut app = create_test_app_with_tmpdir(&tmpdir);
558 let result = run_skill(&mut app, Some("test-skill"));
559 assert!(result.message.is_some());
560 let msg = result.message.unwrap();
561 assert!(msg.contains("Skill 'test-skill' activated"));
562 assert!(msg.contains("A test skill"));
563 assert!(app.active_skill.is_some());
564 assert!(!app.history.is_empty());
565 }
566 }
567
567 lines RUST