diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index 2e09e74..a85b57f 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -382,15 +382,119 @@ pub fn list_sessions() -> Result, String> { Ok(out) } -/// Path to the bundled deepcode CLI (alongside the .app) so the GUI can -/// drop users into the CLI for advanced workflows. +/// Path to the `deepcode` CLI so the GUI can drop users into it for advanced +/// workflows. Resolves a globally-installed `deepcode` on PATH (npm i -g +/// deepcode-cli). Bundling the CLI inside the .app is separate future work. #[tauri::command] pub fn cli_path() -> Option { - // Bundled at `/Contents/Resources/deepcode` (we copy it in the - // electron-builder ... I mean tauri.conf.json bundle step in v1.1). + find_on_path("deepcode") +} + +fn find_on_path(exe: &str) -> Option { + let path = std::env::var_os("PATH")?; + for dir in std::env::split_paths(&path) { + let candidate = dir.join(exe); + if candidate.is_file() { + return Some(candidate); + } + } None } +// ── Skills listing ───────────────────────────────────────────────────── +// The Skills screen lists built-in (bundled .app resource) + user + project +// skills. Built-in skills resolve via the Tauri resource dir; user/project from +// fixed ~/.deepcode/skills + /.deepcode/skills. Each skill is a directory +// with a SKILL.md (`---` frontmatter + body). Mirrors core's skills/loader.ts. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillInfo { + pub name: String, + pub description: String, + pub source: String, + pub path: String, + pub body: String, +} + +fn unquote(s: &str) -> String { + s.trim().trim_matches(|c| c == '"' || c == '\'').to_string() +} + +/// Extract `name` + `description` from a SKILL.md `---`-fenced frontmatter block. +pub fn parse_skill_frontmatter(content: &str) -> (Option, Option) { + let trimmed = content.trim_start(); + let Some(rest) = trimmed.strip_prefix("---") else { + return (None, None); + }; + let Some(end) = rest.find("\n---") else { + return (None, None); + }; + let mut name = None; + let mut description = None; + for line in rest[..end].lines() { + if let Some(v) = line.strip_prefix("name:") { + name = Some(unquote(v)); + } else if let Some(v) = line.strip_prefix("description:") { + description = Some(unquote(v)); + } + } + (name, description) +} + +fn collect_skills_from(dir: &std::path::Path, source: &str, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let p = entry.path(); + if !p.is_dir() { + continue; + } + let skill_md = p.join("SKILL.md"); + let Ok(content) = std::fs::read_to_string(&skill_md) else { + continue; + }; + let (name, description) = parse_skill_frontmatter(&content); + out.push(SkillInfo { + name: name.unwrap_or_else(|| entry.file_name().to_string_lossy().to_string()), + description: description.unwrap_or_default(), + source: source.to_string(), + path: skill_md.to_string_lossy().to_string(), + body: content, + }); + } +} + +/// Collect skills from the built-in (optional), user, and project (optional) +/// directories. Pure (takes dirs) so it's unit-testable. +pub fn collect_skills( + builtin: Option<&std::path::Path>, + user: &std::path::Path, + project: Option<&std::path::Path>, +) -> Vec { + let mut out: Vec = Vec::new(); + if let Some(b) = builtin { + collect_skills_from(b, "builtin", &mut out); + } + collect_skills_from(user, "user", &mut out); + if let Some(pr) = project { + collect_skills_from(pr, "project", &mut out); + } + out.sort_by(|a, b| a.name.cmp(&b.name)); + out +} + +#[tauri::command] +pub fn list_skills(app: tauri::AppHandle, cwd: Option) -> Vec { + use tauri::Manager; + let builtin = app.path().resource_dir().ok().map(|r| r.join("skills")); + let user = dirs::home_dir() + .map(|h| h.join(".deepcode").join("skills")) + .unwrap_or_default(); + let project = cwd.map(|c| std::path::PathBuf::from(c).join(".deepcode").join("skills")); + collect_skills(builtin.as_deref(), &user, project.as_deref()) +} + /// Open a URL in the user's default browser via the plugin-opener bridge. /// (Wrapped here so the renderer has a single typed surface.) #[tauri::command] @@ -606,6 +710,63 @@ mod contract_tests { assert!(collect_plugins(&dir).is_empty()); } + #[test] + fn parse_skill_frontmatter_extracts_name_and_description() { + let md = "---\nname: greet\ndescription: \"Say hello\"\n---\nBody here\n"; + let (name, desc) = parse_skill_frontmatter(md); + assert_eq!(name.as_deref(), Some("greet")); + assert_eq!(desc.as_deref(), Some("Say hello")); + } + + #[test] + fn parse_skill_frontmatter_none_without_fence() { + let (name, desc) = parse_skill_frontmatter("no frontmatter here"); + assert!(name.is_none() && desc.is_none()); + } + + #[test] + fn skill_info_serializes_camel_case() { + let v = serde_json::to_value(SkillInfo { + name: "s".into(), + description: "d".into(), + source: "builtin".into(), + path: "/x/SKILL.md".into(), + body: "b".into(), + }) + .unwrap(); + let k = keys(&v); + assert!(k.contains(&"name".to_string()) && k.contains(&"source".to_string()), "got {k:?}"); + } + + #[test] + fn collect_skills_reads_builtin_user_project_and_sorts() { + let root = std::env::temp_dir().join(format!("dc-skills-{}", std::process::id())); + let mk = |dir: &std::path::Path, name: &str, desc: &str| { + let sd = dir.join(name); + std::fs::create_dir_all(&sd).unwrap(); + std::fs::write( + sd.join("SKILL.md"), + format!("---\nname: {name}\ndescription: {desc}\n---\nbody-{name}\n"), + ) + .unwrap(); + }; + let builtin = root.join("builtin"); + let user = root.join("user"); + let project = root.join("project"); + mk(&builtin, "zeta", "builtin one"); + mk(&user, "alpha", "user one"); + mk(&project, "mid", "project one"); + + let rows = collect_skills(Some(&builtin), &user, Some(&project)); + std::fs::remove_dir_all(&root).ok(); + + // sorted by name → alpha(user), mid(project), zeta(builtin) + assert_eq!(rows.iter().map(|s| s.name.as_str()).collect::>(), vec!["alpha", "mid", "zeta"]); + assert_eq!(rows[0].source, "user"); + assert_eq!(rows[2].source, "builtin"); + assert!(rows[2].body.contains("body-zeta")); + } + #[test] fn session_meta_serializes_snake_case() { let v = serde_json::to_value(SessionMeta { diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 4caa787..75d7863 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -16,9 +16,9 @@ mod tools; use commands::{ append_allow_matcher, cli_path, get_app_info, get_settings_path, list_plugins, list_sessions, - load_keybindings, load_settings_file, open_url, read_credentials, save_credentials, - save_keybindings, save_settings_file, session_append, session_create, session_read, - session_set_title, + list_skills, load_keybindings, load_settings_file, open_url, read_credentials, + save_credentials, save_keybindings, save_settings_file, session_append, session_create, + session_read, session_set_title, }; use tools::{tool_bash, tool_edit, tool_glob, tool_grep, tool_read, tool_write}; use tauri::Manager; @@ -48,6 +48,7 @@ pub fn run() { session_set_title, list_sessions, list_plugins, + list_skills, cli_path, open_url, tool_read, diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 8aca01e..fc38f3a 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -31,6 +31,9 @@ "bundle": { "active": true, "targets": ["app"], + "resources": { + "../../../packages/core/skills": "skills" + }, "category": "public.app-category.developer-tools", "shortDescription": "DeepSeek-powered coding agent", "longDescription": "DeepCode is a Claude-Code-parity coding agent powered by DeepSeek — chat, plan mode, tool use, sandboxed bash, MCP, plugins.", diff --git a/apps/desktop/src/lib/tauri-api.test.ts b/apps/desktop/src/lib/tauri-api.test.ts index 12ee286..62dc029 100644 --- a/apps/desktop/src/lib/tauri-api.test.ts +++ b/apps/desktop/src/lib/tauri-api.test.ts @@ -13,6 +13,7 @@ import { appendAllowMatcher, getAppInfo, listPlugins, + listSkills, loadSettingsFile, readCredentials, saveCredentials, @@ -131,3 +132,15 @@ describe('listPlugins', () => { expect(result).toEqual(rows); }); }); + +describe('listSkills', () => { + it('invokes list_skills with the cwd and returns the rows', async () => { + const rows = [ + { name: 'greet', description: 'd', source: 'builtin', path: '/x/SKILL.md', body: 'b' }, + ]; + invokeMock.mockResolvedValue(rows); + const result = await listSkills('/proj'); + expect(invokeMock).toHaveBeenCalledWith('list_skills', { cwd: '/proj' }); + expect(result).toEqual(rows); + }); +}); diff --git a/apps/desktop/src/lib/tauri-api.ts b/apps/desktop/src/lib/tauri-api.ts index 2700d6c..4fdd5b7 100644 --- a/apps/desktop/src/lib/tauri-api.ts +++ b/apps/desktop/src/lib/tauri-api.ts @@ -143,6 +143,20 @@ export async function listPlugins(): Promise { return (await invoke('list_plugins')) as PluginInfo[]; } +/** A skill row as returned by the `list_skills` Rust command (camelCase). */ +export interface SkillInfo { + name: string; + description: string; + source: 'builtin' | 'user' | 'project' | 'plugin'; + path: string; + body: string; +} + +/** Built-in (bundled) + user + project skills. `cwd` enables project skills. */ +export async function listSkills(cwd?: string): Promise { + return (await invoke('list_skills', { cwd })) as SkillInfo[]; +} + /** Create a new session JSONL file. Returns the generated id. */ export async function sessionCreate(cwd: string): Promise { return (await invoke('session_create', { cwd })) as string; diff --git a/apps/desktop/src/lib/window-shim.ts b/apps/desktop/src/lib/window-shim.ts index 040cf69..0d4e206 100644 --- a/apps/desktop/src/lib/window-shim.ts +++ b/apps/desktop/src/lib/window-shim.ts @@ -5,11 +5,13 @@ import type { AgentEvent, Mode } from '@deepcode/core/dist/types.js'; import type { DeepCodeAPI } from '../types/global.js'; import { abortAgentTurn, clearHistory, resumeSession, startAgentTurn } from './mac-agent.js'; +import { loadProjectPath } from './project.js'; import { appendAllowMatcher, getAppInfo, listPlugins, listSessions, + listSkills, loadSettingsFile, openUrl, readCredentials, @@ -124,10 +126,24 @@ export function installTauriShim(): void { }, skills: { async list() { - return []; + // Built-in (bundled .app resource) + user + project skills via the + // list_skills Rust command. Project skills need the picked project dir. + try { + const cwd = await loadProjectPath(); + return await listSkills(cwd); + } catch { + return []; + } }, - async body() { - return ''; + async body({ path }: { path: string }) { + // list_skills already returns each skill's body; find by SKILL.md path. + try { + const cwd = await loadProjectPath(); + const found = (await listSkills(cwd)).find((s) => s.path === path); + return found?.body ?? ''; + } catch { + return ''; + } }, }, agent: {