From 08ab1a725efffb185089c3a2ca0fa34264ebb46c Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Mon, 27 Jul 2026 14:41:05 +0100 Subject: [PATCH] feat(iii-directory): injectable prompt library (backend + directory UI) The directory worker's prompt surface becomes a real library on the injectable-view base: - backend (folded from the prompt-library branch): directory::prompts::save and ::delete, kind (command|system) + source (worker|user) on list/get, prompts_folder config. - injectable UI: the prompts tab lists worker-shipped and user entries with kind + source badges; create, edit, fork, and delete via save/delete; worker-shipped templates render read-only. The skills tab is unchanged. Console-side prompt selection (composer picker, slash commands) ships as a separate console PR; this change is directory-only. --- iii-directory/src/config.rs | 40 ++ iii-directory/src/fs_source.rs | 183 ++++--- iii-directory/src/functions/mod.rs | 8 +- iii-directory/src/functions/prompts.rs | 488 +++++++++++++++++- iii-directory/ui/src/page/browser.tsx | 6 +- iii-directory/ui/src/page/index.tsx | 44 +- iii-directory/ui/src/page/prompts-library.tsx | 325 ++++++++++++ iii-directory/ui/styles.css | 41 ++ 8 files changed, 999 insertions(+), 136 deletions(-) create mode 100644 iii-directory/ui/src/page/prompts-library.tsx diff --git a/iii-directory/src/config.rs b/iii-directory/src/config.rs index 42abedd4d..919286ce6 100644 --- a/iii-directory/src/config.rs +++ b/iii-directory/src/config.rs @@ -61,6 +61,20 @@ fn default_auto_download() -> bool { true } +/// Default root for user-authored prompts (the prompt library). +pub const DEFAULT_PROMPTS_FOLDER: &str = "~/.iii/prompts"; + +/// Default root for project-local prompt overrides. +pub const DEFAULT_LOCAL_PROMPTS_FOLDER: &str = "./.iii/prompts"; + +fn default_prompts_folder() -> String { + DEFAULT_PROMPTS_FOLDER.to_string() +} + +fn default_local_prompts_folder() -> String { + DEFAULT_LOCAL_PROMPTS_FOLDER.to_string() +} + #[derive(Deserialize, Serialize, Debug, Clone, JsonSchema)] pub struct SkillsConfig { /// Folder that backs every read (`directory::skills::list`, @@ -113,6 +127,20 @@ pub struct SkillsConfig { /// folder. #[serde(default = "default_auto_download")] pub auto_download: bool, + + /// Folder for user-authored prompts (the prompt library): flat + /// `.md` files with `description` (and optional `kind`) + /// frontmatter. `directory::prompts::save` writes here; entries + /// appear in `directory::prompts::list` with `source: user`. + /// Supports the same three resolution forms as `skills_folder`. + #[serde(default = "default_prompts_folder")] + pub prompts_folder: String, + + /// Folder for project-local prompt overrides. An entry here shadows + /// a same-named entry in `prompts_folder`. Supports the same three + /// resolution forms as `skills_folder`. + #[serde(default = "default_local_prompts_folder")] + pub local_prompts_folder: String, } impl Default for SkillsConfig { @@ -125,6 +153,8 @@ impl Default for SkillsConfig { registry_cache_ttl_ms: default_registry_cache_ttl_ms(), filter_unregistered: default_filter_unregistered(), auto_download: default_auto_download(), + prompts_folder: default_prompts_folder(), + local_prompts_folder: default_local_prompts_folder(), } } } @@ -178,6 +208,16 @@ impl SkillsConfig { resolve_path(&self.local_skills_folder) } + /// Absolute path to the configured user prompt library folder. + pub fn resolved_prompts_folder(&self) -> PathBuf { + resolve_path(&self.prompts_folder) + } + + /// Absolute path to the configured project-local prompt folder. + pub fn resolved_local_prompts_folder(&self) -> PathBuf { + resolve_path(&self.local_prompts_folder) + } + /// Registry base URL with any trailing slash trimmed so callers can /// build URLs as `format!("{base}/w/{worker}/skills")`. pub fn registry_base(&self) -> &str { diff --git a/iii-directory/src/fs_source.rs b/iii-directory/src/fs_source.rs index 7947c8d7f..ef26971f4 100644 --- a/iii-directory/src/fs_source.rs +++ b/iii-directory/src/fs_source.rs @@ -43,13 +43,15 @@ pub struct FsSkill { pub abs_path: PathBuf, } -/// One filesystem-backed prompt entry. `description` is parsed from -/// frontmatter at scan time so [`crate::functions::prompts::mcp_list`] -/// can render the slash-command picker without re-reading every file. +/// One filesystem-backed prompt entry. `description` and `kind` are +/// parsed from frontmatter at scan time so `directory::prompts::list` +/// can render its rows without re-reading every file. #[derive(Debug, Clone, PartialEq, Eq)] pub struct FsPrompt { pub name: String, pub description: String, + /// Frontmatter `kind:`; `None` means the default `command`. + pub kind: Option, pub abs_path: PathBuf, } @@ -74,12 +76,17 @@ pub struct PromptFrontmatter { pub name: Option, #[serde(default)] pub description: Option, + /// Free-form classifier; the library uses `command` (slash-style + /// message injection, the default) vs `system` (a full system prompt). + #[serde(default)] + pub kind: Option, } /// Parse the REQUIRED prompt frontmatter block out of raw file content. -/// Shared by [`scan_prompts`] (scan-time) and `directory::prompts::update` -/// (write-time) so the two validations can't drift: a write that this -/// function rejects is exactly a file the next scan would skip. +/// Shared by [`scan_prompts`] / [`scan_user_prompts`] (scan-time) and +/// `directory::prompts::update` (write-time) so the two validations can't +/// drift: a write that this function rejects is exactly a file the next +/// scan would skip. pub fn parse_prompt_frontmatter(content: &str) -> Result { let (fm_text, _) = split_frontmatter(content); let Some(fm_text) = fm_text else { @@ -292,6 +299,63 @@ pub fn scan_skills(skills_folder: &Path) -> (Vec, Vec) { /// Rejection reasons mirror [`scan_skills`]: missing frontmatter, /// invalid YAML, missing `description`, invalid prompt name, or a name /// collision with another prompt. +/// Parse one on-disk prompt file: read, split frontmatter, YAML-parse, +/// derive the name (frontmatter `name:` falling back to the file stem), +/// validate it, and require a non-empty `description`. Shared by +/// [`scan_prompts`] and [`scan_user_prompts`] so the frontmatter contract +/// lives in exactly one place. +fn parse_prompt_file(abs: PathBuf) -> Result { + fn skip(path: PathBuf, reason: String) -> SkipReason { + SkipReason { + kind: SourceKind::Prompt, + path, + reason, + } + } + let content = match std::fs::read_to_string(&abs) { + Ok(c) => c, + Err(e) => return Err(skip(abs, format!("read: {e}"))), + }; + let fm = match parse_prompt_frontmatter(&content) { + Ok(f) => f, + Err(reason) => return Err(skip(abs, reason)), + }; + // Prompt names are flat — fall back to the file stem when frontmatter + // doesn't declare one. + let derived = abs + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + let name = fm + .name + .as_deref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or(derived); + if let Err(e) = validate_name(&name) { + return Err(skip(abs, format!("invalid prompt name {name:?}: {e}"))); + } + let description = match fm.description { + Some(d) if !d.trim().is_empty() => d.trim().to_string(), + _ => { + return Err(skip( + abs, + "frontmatter missing non-empty `description`".into(), + )) + } + }; + Ok(FsPrompt { + name, + description, + kind: fm + .kind + .map(|k| k.trim().to_string()) + .filter(|k| !k.is_empty()), + abs_path: abs, + }) +} + pub fn scan_prompts(skills_folder: &Path) -> (Vec, Vec) { let mut prompts: Vec = Vec::new(); let mut skipped: Vec = Vec::new(); @@ -312,83 +376,72 @@ pub fn scan_prompts(skills_folder: &Path) -> (Vec, Vec) { if !has_prompts_segment(&rel) { continue; } - let content = match std::fs::read_to_string(&abs) { - Ok(c) => c, - Err(e) => { - skipped.push(SkipReason { - kind: SourceKind::Prompt, - path: abs, - reason: format!("read: {e}"), - }); + let p = match parse_prompt_file(abs) { + Ok(p) => p, + Err(s) => { + skipped.push(s); continue; } }; - let fm = match parse_prompt_frontmatter(&content) { - Ok(f) => f, - Err(reason) => { + if let Some(existing) = prompts.iter().find(|q| q.name == p.name) { + if existing.abs_path != p.abs_path { skipped.push(SkipReason { kind: SourceKind::Prompt, - path: abs, - reason, + path: p.abs_path, + reason: format!( + "duplicate name {:?} also produced by {}", + p.name, + existing.abs_path.display() + ), }); - continue; } - }; + continue; + } + prompts.push(p); + } - // Prompt names are flat — fall back to the file stem when - // frontmatter doesn't declare one. - let derived = abs - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("") - .to_string(); - let name = fm - .name - .as_deref() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .unwrap_or(derived); - - if let Err(e) = validate_name(&name) { + prompts.sort_by(|a, b| a.name.cmp(&b.name)); + (prompts, skipped) +} + +/// Scan a user prompt-library root: every `*.md` under it (flat by +/// convention, nested tolerated), same frontmatter rules as worker-shipped +/// prompts (`description` required, `name` falls back to the file stem, +/// optional `kind`), but with NO `/prompts/` path requirement — the +/// library is not namespaced by worker. +pub fn scan_user_prompts(root: &Path) -> (Vec, Vec) { + let mut prompts: Vec = Vec::new(); + let mut skipped: Vec = Vec::new(); + + let entries = match walk_markdown(root) { + Ok(v) => v, + Err(e) => { skipped.push(SkipReason { kind: SourceKind::Prompt, - path: abs, - reason: format!("invalid prompt name {name:?}: {e}"), + path: root.to_path_buf(), + reason: e, }); - continue; + return (prompts, skipped); } + }; - let description = match fm.description { - Some(d) if !d.trim().is_empty() => d.trim().to_string(), - _ => { - skipped.push(SkipReason { - kind: SourceKind::Prompt, - path: abs, - reason: "frontmatter missing non-empty `description`".into(), - }); + for (abs, _rel) in entries { + let p = match parse_prompt_file(abs) { + Ok(p) => p, + Err(s) => { + skipped.push(s); continue; } }; - - if let Some(existing) = prompts.iter().find(|p| p.name == name) { - if existing.abs_path != abs { - skipped.push(SkipReason { - kind: SourceKind::Prompt, - path: abs, - reason: format!( - "duplicate name {name:?} also produced by {}", - existing.abs_path.display() - ), - }); - } + if prompts.iter().any(|q| q.name == p.name) { + skipped.push(SkipReason { + kind: SourceKind::Prompt, + path: p.abs_path, + reason: format!("duplicate prompt name {:?} in the library root", p.name), + }); continue; } - - prompts.push(FsPrompt { - name, - description, - abs_path: abs, - }); + prompts.push(p); } prompts.sort_by(|a, b| a.name.cmp(&b.name)); diff --git a/iii-directory/src/functions/mod.rs b/iii-directory/src/functions/mod.rs index bc28a12d3..115efc82c 100644 --- a/iii-directory/src/functions/mod.rs +++ b/iii-directory/src/functions/mod.rs @@ -57,15 +57,15 @@ pub fn register_all( cfg.load().registry_cache_ttl_ms, )); skills::register_with_cache(iii, cfg, &cache); - prompts::register(iii, cfg); let subs = Subscribers::from(trigger_types); + prompts::register(iii, cfg, &subs.prompts); download::register(iii, cfg, &subs); update::register(iii, cfg, &subs, &cache); registry::register(iii, cfg); engine_fn::register(iii); tracing::info!( "iii-directory registered 3 directory::skills::* reads (list + get + index), \ - 2 directory::prompts::* reads (list + get), 2 updates (skills + prompts), \ + 4 directory::prompts::* (list + get + save + delete), 2 updates (skills + prompts), \ 3 downloads, 2 directory::registry::workers::*, \ and 1 directory::engine::functions::info" ); @@ -79,15 +79,15 @@ pub fn register_all_with_cache( registry_cache: registry::RegistryCache, ) { skills::register_with_cache(iii, cfg, cache); - prompts::register(iii, cfg); let subs = Subscribers::from(trigger_types); + prompts::register(iii, cfg, &subs.prompts); download::register(iii, cfg, &subs); update::register(iii, cfg, &subs, cache); registry::register_with_cache(iii, cfg, registry_cache); engine_fn::register(iii); tracing::info!( "iii-directory registered 3 directory::skills::* reads (list + get + index), \ - 2 directory::prompts::* reads (list + get), 2 updates (skills + prompts), \ + 4 directory::prompts::* (list + get + save + delete), 2 updates (skills + prompts), \ 3 downloads, 2 directory::registry::workers::*, \ and 1 directory::engine::functions::info" ); diff --git a/iii-directory/src/functions/prompts.rs b/iii-directory/src/functions/prompts.rs index 7d7110fa2..a30532b46 100644 --- a/iii-directory/src/functions/prompts.rs +++ b/iii-directory/src/functions/prompts.rs @@ -1,18 +1,26 @@ -//! Filesystem-backed prompts reader. +//! Filesystem-backed prompts reader + the user prompt library. //! //! Public API (reachable by any worker over `iii.trigger`): //! -//! * `directory::prompts::list` — metadata-only listing of every prompt -//! in `//prompts/*.md`, sorted by name. +//! * `directory::prompts::list` — metadata-only listing of every prompt: +//! worker-shipped templates from `//prompts/*.md` +//! (`source: worker`) merged with user-library entries from +//! `prompts_folder` / `local_prompts_folder` (`source: user`), sorted +//! by name. Filterable by `kind` and `source`. //! * `directory::prompts::get` — fetch one prompt's body + metadata. +//! * `directory::prompts::save` — write (or overwrite / fork) a user +//! library entry; agent-callable so an orchestrator can author a +//! small system prompt for a sub-agent and spawn with it. +//! * `directory::prompts::delete` — remove a user library entry. //! -//! Both responses are plain JSON shapes — no MCP envelope, no role/ -//! messages wrapper — so this worker stays agnostic to MCP and any -//! other adapter. Adapters can shape the response on their own side. +//! Two kinds flow through the same store: `command` (slash-style +//! templates injected into the MESSAGE context, the default) and +//! `system` (full system prompts applied via the router override or the +//! send/spawn `system_prompt` options). The `kind` frontmatter field +//! separates them; pickers filter on it. //! -//! There is no `prompts::register` / `prompts::unregister`. Prompts -//! arrive on disk via `directory::skills::download` (or by direct -//! editing) and are re-read on every list/get call. +//! Precedence on name collision: `local_prompts_folder` shadows +//! `prompts_folder`, which shadows worker-shipped templates. use std::sync::Arc; @@ -20,13 +28,23 @@ use iii_sdk::errors::Error; use iii_sdk::{IIIClient, RegisterFunction}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use serde_json::json; use crate::config::{SharedConfig, SkillsConfig}; -use crate::fs_source; +use crate::fs_source::{self, FsPrompt}; use crate::functions::error::{not_found_message, NextAction}; +use crate::sources::write_file_atomic; +use crate::trigger_types::{self, SubscriberSet}; const NAME_MAX_LEN: usize = 64; +/// The library kinds `save` accepts. Worker-shipped files may carry any +/// `kind`, but entries written through the API stay within the two the +/// pickers understand. +const SAVE_KINDS: &[&str] = &["command", "system"]; + +const DEFAULT_KIND: &str = "command"; + /// Recovery pointer attached to a `directory::prompts::get` miss. const PROMPT_NOT_FOUND_NEXT: &[NextAction] = &[NextAction::new( "directory::prompts::list", @@ -34,12 +52,23 @@ const PROMPT_NOT_FOUND_NEXT: &[NextAction] = &[NextAction::new( )]; #[derive(Debug, Default, Deserialize, JsonSchema)] -struct ListPromptsInput {} +struct ListPromptsInput { + /// Only entries of this `kind` (e.g. `system` or `command`). + #[serde(default)] + kind: Option, + /// Only entries from this source: `worker` or `user`. + #[serde(default)] + source: Option, +} #[derive(Debug, Serialize, JsonSchema)] struct PromptEntry { name: String, description: String, + /// `command` (default) or `system`, from frontmatter. + kind: String, + /// `worker` (shipped template) or `user` (library entry). + source: String, /// File mtime as RFC 3339. modified_at: String, } @@ -65,6 +94,10 @@ pub struct PromptGetInput { pub struct PromptGetOutput { pub name: String, pub description: String, + /// `command` (default) or `system`, from frontmatter. + pub kind: String, + /// `worker` (shipped template) or `user` (library entry). + pub source: String, /// Raw markdown body (post-frontmatter) from disk. pub body: String, /// FULL on-disk file content (frontmatter included). Present only @@ -76,27 +109,75 @@ pub struct PromptGetOutput { pub modified_at: String, } -pub fn register(iii: &Arc, cfg: &SharedConfig) { +#[derive(Debug, Deserialize, JsonSchema)] +pub struct PromptSaveInput { + /// Library entry name (lowercase ASCII, digits, `-`, `_`). + pub name: String, + /// Non-empty teaser shown by `list`. + pub description: String, + /// The prompt text. Omit it and set `from` to fork an existing + /// prompt's body under the new name. + #[serde(default)] + pub body: Option, + /// `command` (default) or `system`. + #[serde(default)] + pub kind: Option, + /// Name of an existing prompt whose body seeds this one when `body` + /// is omitted (fork). + #[serde(default)] + pub from: Option, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct PromptSaveOutput { + pub name: String, + pub kind: String, + /// Absolute path written. + pub path: String, + /// `true` when an existing library file of the same name was replaced. + pub overwrote: bool, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct PromptDeleteInput { + pub name: String, + /// Must be exactly `true` — same confirmation convention as the + /// worker lifecycle ops. + #[serde(default)] + pub yes: bool, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct PromptDeleteOutput { + pub name: String, + pub deleted: bool, +} + +pub fn register(iii: &Arc, cfg: &SharedConfig, prompts_subs: &SubscriberSet) { register_list_prompts(iii, cfg); register_get_prompt(iii, cfg); + register_save_prompt(iii, cfg, prompts_subs); + register_delete_prompt(iii, cfg, prompts_subs); } fn register_list_prompts(iii: &Arc, cfg: &SharedConfig) { let cfg_inner = cfg.clone(); iii.register_function( "directory::prompts::list", - RegisterFunction::new_async(move |_input: ListPromptsInput| { + RegisterFunction::new_async(move |input: ListPromptsInput| { let cfg = cfg_inner.load_full(); async move { - let (prompts, _skipped) = fs_source::scan_prompts_merged( - &cfg.resolved_skills_folder(), - &cfg.local_skills_folder(), - ); - let out: Vec = prompts + let out: Vec = collect_all(&cfg) .into_iter() - .map(|p| { + .filter(|(p, source)| { + input.kind.as_deref().is_none_or(|k| k == effective_kind(p)) + && input.source.as_deref().is_none_or(|s| s == *source) + }) + .map(|(p, source)| { let modified_at = fs_modified_at(&p.abs_path); PromptEntry { + kind: effective_kind(&p).to_string(), + source: source.to_string(), name: p.name, description: p.description, modified_at, @@ -107,7 +188,9 @@ fn register_list_prompts(iii: &Arc, cfg: &SharedConfig) { } }) .description( - "List filesystem-backed prompts (name, description, modified_at) from skills_folder.", + "List prompts: worker-shipped templates (source `worker`) merged with user \ + library entries (source `user`), each `command` or `system` kind. Filter with \ + `kind` and/or `source`.", ), ); } @@ -121,24 +204,111 @@ fn register_get_prompt(iii: &Arc, cfg: &SharedConfig) { async move { get_prompt(&cfg, req).await.map_err(Error::Handler) } }) .description( - "Fetch one filesystem-backed prompt by name. Returns the raw markdown body plus name, \ - description, and modified_at — no envelope, no templating.", + "Fetch one prompt by name (user library entries shadow worker templates). \ + Returns the raw markdown body plus name, description, kind, source, and \ + modified_at — no envelope, no templating.", + ), + ); +} + +fn register_save_prompt(iii: &Arc, cfg: &SharedConfig, subs: &SubscriberSet) { + let cfg_inner = cfg.clone(); + let subs = subs.clone(); + let engine = iii.clone(); + iii.register_function( + "directory::prompts::save", + RegisterFunction::new_async(move |req: PromptSaveInput| { + let cfg = cfg_inner.load_full(); + let subs = subs.clone(); + let engine = engine.clone(); + async move { + let out = save_prompt(&cfg, req).map_err(Error::Handler)?; + notify_change(&engine, &subs, "save", &out.name).await; + Ok::<_, Error>(out) + } + }) + .description( + "Save a prompt to the user library (`prompts_folder`): name, description, \ + body, kind `command` or `system` (default `command`). Omit `body` and set \ + `from` to fork an existing prompt under the new name. Overwrites a \ + same-named library entry; fires directory::prompts::on-change.", + ), + ); +} + +fn register_delete_prompt(iii: &Arc, cfg: &SharedConfig, subs: &SubscriberSet) { + let cfg_inner = cfg.clone(); + let subs = subs.clone(); + let engine = iii.clone(); + iii.register_function( + "directory::prompts::delete", + RegisterFunction::new_async(move |req: PromptDeleteInput| { + let cfg = cfg_inner.load_full(); + let subs = subs.clone(); + let engine = engine.clone(); + async move { + let out = delete_prompt(&cfg, req).map_err(Error::Handler)?; + notify_change(&engine, &subs, "delete", &out.name).await; + Ok::<_, Error>(out) + } + }) + .description( + "Delete a user library prompt from `prompts_folder`. Requires exactly \ + `yes: true`. Worker-shipped templates and project-local files are refused. \ + Fires directory::prompts::on-change.", ), ); } // ---------- core helpers (reusable in tests) ---------- +/// Fire `directory::prompts::on-change` for a library write, matching the +/// download fan-out's payload shape. +async fn notify_change(engine: &IIIClient, subs: &SubscriberSet, op: &str, name: &str) { + trigger_types::dispatch( + engine, + subs, + json!({ "op": op, "name": name, "source": "user" }), + ) + .await; +} + +/// `command` unless the frontmatter says otherwise. +fn effective_kind(p: &FsPrompt) -> &str { + p.kind.as_deref().unwrap_or(DEFAULT_KIND) +} + +/// Every visible prompt with its source, name-shadowed in precedence +/// order: local library, then global library, then worker-shipped. +pub fn collect_all(cfg: &SkillsConfig) -> Vec<(FsPrompt, &'static str)> { + let mut seen: Vec<(FsPrompt, &'static str)> = Vec::new(); + let mut push_new = |batch: Vec, source: &'static str| { + for p in batch { + if !seen.iter().any(|(existing, _)| existing.name == p.name) { + seen.push((p, source)); + } + } + }; + let (local_user, _) = fs_source::scan_user_prompts(&cfg.resolved_local_prompts_folder()); + push_new(local_user, "user"); + let (global_user, _) = fs_source::scan_user_prompts(&cfg.resolved_prompts_folder()); + push_new(global_user, "user"); + let (shipped, _) = + fs_source::scan_prompts_merged(&cfg.resolved_skills_folder(), &cfg.local_skills_folder()); + push_new(shipped, "worker"); + seen.sort_by(|a, b| a.0.name.cmp(&b.0.name)); + seen +} + pub async fn get_prompt( cfg: &SkillsConfig, req: PromptGetInput, ) -> Result { let name = req.name; validate_name(&name)?; - let (prompts, _skipped) = - fs_source::scan_prompts_merged(&cfg.resolved_skills_folder(), &cfg.local_skills_folder()); - let Some(fs) = prompts.iter().find(|p| p.name == name).cloned() else { - let names: Vec = prompts.into_iter().map(|p| p.name).collect(); + let all = collect_all(cfg); + let Some((fs, source)) = all.iter().find(|(p, _)| p.name == name).cloned() else { + let names: Vec = all.into_iter().map(|(p, _)| p.name).collect(); let candidates = rank_prompt_names(&names, &name, 3); return Err(not_found_message( "D210", @@ -156,6 +326,8 @@ pub async fn get_prompt( }; let modified_at = fs_modified_at(&fs.abs_path); Ok(PromptGetOutput { + kind: effective_kind(&fs).to_string(), + source: source.to_string(), name: fs.name, description: fs.description, body, @@ -164,6 +336,86 @@ pub async fn get_prompt( }) } +pub fn save_prompt(cfg: &SkillsConfig, req: PromptSaveInput) -> Result { + validate_name(&req.name)?; + let description = req.description.trim(); + if description.is_empty() { + return Err("description must be non-empty".into()); + } + let kind = req.kind.as_deref().unwrap_or(DEFAULT_KIND); + if !SAVE_KINDS.contains(&kind) { + return Err(format!("kind must be one of {SAVE_KINDS:?}, got {kind:?}")); + } + let body = match (req.body, req.from.as_deref()) { + (Some(b), _) if !b.trim().is_empty() => b, + (_, Some(from)) => { + validate_name(from)?; + let all = collect_all(cfg); + let Some((src, _)) = all.iter().find(|(p, _)| p.name == from) else { + return Err(format!("fork source prompt {from:?} not found")); + }; + fs_source::read_body(&src.abs_path)? + } + _ => return Err("provide a non-empty `body`, or `from` to fork an existing prompt".into()), + }; + + let dest = cfg + .resolved_prompts_folder() + .join(format!("{}.md", req.name)); + let overwrote = dest.exists(); + // Serialized with the same YAML machinery the scanner parses it back + // with, so writer and reader can never drift on escaping. The name is + // deliberately NOT written: the scanner derives it from the file stem, + // and a stored copy would go stale on a hand-rename. + let fm_yaml = serde_yaml::to_string(&WrittenFrontmatter { description, kind }) + .map_err(|e| format!("serialize frontmatter: {e}"))?; + let contents = format!("---\n{fm_yaml}---\n\n{}", body.trim_start_matches('\n')); + write_file_atomic(&dest, contents.as_bytes())?; + Ok(PromptSaveOutput { + name: req.name, + kind: kind.to_string(), + path: dest.display().to_string(), + overwrote, + }) +} + +pub fn delete_prompt( + cfg: &SkillsConfig, + req: PromptDeleteInput, +) -> Result { + if !req.yes { + return Err("pass exactly `yes: true` to confirm the delete".into()); + } + validate_name(&req.name)?; + let dest = cfg + .resolved_prompts_folder() + .join(format!("{}.md", req.name)); + if !dest.exists() { + let all = collect_all(cfg); + if all.iter().any(|(p, _)| p.name == req.name) { + return Err(format!( + "{:?} is not a user library entry in prompts_folder — worker-shipped \ + templates and project-local files are not deletable through this function", + req.name + )); + } + return Err(format!("no user library prompt named {:?}", req.name)); + } + std::fs::remove_file(&dest).map_err(|e| format!("remove {}: {e}", dest.display()))?; + Ok(PromptDeleteOutput { + name: req.name, + deleted: true, + }) +} + +/// The frontmatter `save` writes; parsed back by `PromptFrontmatter` in +/// `fs_source`. +#[derive(Serialize)] +struct WrittenFrontmatter<'a> { + description: &'a str, + kind: &'a str, +} + /// Rank prompt names by closeness to a missed name (lowercased Levenshtein, /// reusing the skills ranker's distance fn), returning the closest `limit`. /// Empty when there are no prompts on disk. @@ -223,6 +475,18 @@ fn fs_modified_at(path: &std::path::Path) -> String { mod tests { use super::*; + fn temp_cfg() -> (SkillsConfig, tempfile::TempDir) { + let root = tempfile::tempdir().expect("tempdir"); + let cfg = SkillsConfig { + skills_folder: root.path().join("skills").display().to_string(), + local_skills_folder: root.path().join("local-skills").display().to_string(), + prompts_folder: root.path().join("prompts").display().to_string(), + local_prompts_folder: root.path().join("local-prompts").display().to_string(), + ..SkillsConfig::default() + }; + (cfg, root) + } + #[test] fn name_validation_accepts_kebab_and_underscore() { assert!(validate_name("send-email").is_ok()); @@ -240,4 +504,176 @@ mod tests { assert!(validate_name("mcp::send").is_err()); assert!(validate_name(&"x".repeat(NAME_MAX_LEN + 1)).is_err()); } + + #[test] + fn save_list_get_roundtrip_with_kind_and_source() { + let (cfg, root) = temp_cfg(); + let out = save_prompt( + &cfg, + PromptSaveInput { + name: "blog-writer".into(), + description: "Writes blog posts: direct, punchy".into(), + body: Some("You write blog posts. Use the web worker only.".into()), + kind: Some("system".into()), + from: None, + }, + ) + .expect("save"); + assert!(!out.overwrote); + assert_eq!(out.kind, "system"); + + let all = collect_all(&cfg); + let (entry, source) = all.iter().find(|(p, _)| p.name == "blog-writer").unwrap(); + assert_eq!(*source, "user"); + assert_eq!(entry.kind.as_deref(), Some("system")); + + let got = futures::executor::block_on(get_prompt( + &cfg, + PromptGetInput { + name: "blog-writer".into(), + raw: None, + }, + )) + .expect("get"); + assert_eq!(got.kind, "system"); + assert_eq!(got.source, "user"); + assert!(got.body.contains("web worker")); + + drop(root); + } + + #[test] + fn fork_seeds_body_from_an_existing_prompt() { + let (cfg, root) = temp_cfg(); + save_prompt( + &cfg, + PromptSaveInput { + name: "base".into(), + description: "base prompt".into(), + body: Some("ORIGINAL BODY".into()), + kind: Some("system".into()), + from: None, + }, + ) + .unwrap(); + let forked = save_prompt( + &cfg, + PromptSaveInput { + name: "base-fork".into(), + description: "forked".into(), + body: None, + kind: Some("system".into()), + from: Some("base".into()), + }, + ) + .expect("fork"); + let got = futures::executor::block_on(get_prompt( + &cfg, + PromptGetInput { + name: "base-fork".into(), + raw: None, + }, + )) + .unwrap(); + assert!(got.body.contains("ORIGINAL BODY")); + assert!(!forked.overwrote); + drop(root); + } + + #[test] + fn save_rejects_unknown_kind_and_empty_body() { + let (cfg, root) = temp_cfg(); + assert!(save_prompt( + &cfg, + PromptSaveInput { + name: "x".into(), + description: "d".into(), + body: Some("b".into()), + kind: Some("vibe".into()), + from: None, + }, + ) + .is_err()); + assert!(save_prompt( + &cfg, + PromptSaveInput { + name: "x".into(), + description: "d".into(), + body: None, + kind: None, + from: None, + }, + ) + .is_err()); + drop(root); + } + + #[test] + fn delete_requires_confirmation_and_only_touches_the_library() { + let (cfg, root) = temp_cfg(); + save_prompt( + &cfg, + PromptSaveInput { + name: "victim".into(), + description: "d".into(), + body: Some("b".into()), + kind: None, + from: None, + }, + ) + .unwrap(); + assert!(delete_prompt( + &cfg, + PromptDeleteInput { + name: "victim".into(), + yes: false, + }, + ) + .is_err()); + let out = delete_prompt( + &cfg, + PromptDeleteInput { + name: "victim".into(), + yes: true, + }, + ) + .expect("delete"); + assert!(out.deleted); + assert!(delete_prompt( + &cfg, + PromptDeleteInput { + name: "victim".into(), + yes: true, + }, + ) + .is_err()); + drop(root); + } + + #[test] + fn local_library_shadows_global_shadows_worker() { + let (cfg, root) = temp_cfg(); + std::fs::create_dir_all(cfg.resolved_local_prompts_folder()).unwrap(); + std::fs::write( + cfg.resolved_local_prompts_folder().join("dup.md"), + "---\ndescription: local\n---\nLOCAL", + ) + .unwrap(); + save_prompt( + &cfg, + PromptSaveInput { + name: "dup".into(), + description: "global".into(), + body: Some("GLOBAL".into()), + kind: None, + from: None, + }, + ) + .unwrap(); + let all = collect_all(&cfg); + let hits: Vec<_> = all.iter().filter(|(p, _)| p.name == "dup").collect(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].0.description, "local"); + drop(root); + } } diff --git a/iii-directory/ui/src/page/browser.tsx b/iii-directory/ui/src/page/browser.tsx index 0e50845be..41484c192 100644 --- a/iii-directory/ui/src/page/browser.tsx +++ b/iii-directory/ui/src/page/browser.tsx @@ -92,7 +92,11 @@ function useContainerNarrow( * types. The handler id carries the `iii::` prefix so per-event * invocations stay span-suppressed and out of the trace feed; the * binding is GC'd with the tab and unregistered on unmount. */ -function useOnChange(host: Host, triggerType: string, onEvent: () => void) { +export function useOnChange( + host: Host, + triggerType: string, + onEvent: () => void, +) { const onEventRef = useRef(onEvent) onEventRef.current = onEvent useEffect(() => { diff --git a/iii-directory/ui/src/page/index.tsx b/iii-directory/ui/src/page/index.tsx index 0a42d9677..b298a708f 100644 --- a/iii-directory/ui/src/page/index.tsx +++ b/iii-directory/ui/src/page/index.tsx @@ -13,6 +13,7 @@ import { } from '@iii-dev/console-ui' import { formatBytes, formatRelativeTime } from '../lib/format' import { type BrowserAdapter, CollectionBrowser } from './browser' +import { PromptsLibrary } from './prompts-library' interface SkillRow { id: string @@ -22,12 +23,6 @@ interface SkillRow { modified_at: string } -interface PromptRow { - name: string - description: string - modified_at: string -} - const skillsAdapter: BrowserAdapter = { noun: 'skill', onChangeType: 'directory::skills::on-change', @@ -61,45 +56,14 @@ const skillsAdapter: BrowserAdapter = { }, } -const promptsAdapter: BrowserAdapter = { - noun: 'prompt', - onChangeType: 'directory::prompts::on-change', - async list(host) { - const out = await host.iii.trigger<{ prompts: PromptRow[] }>( - 'directory::prompts::list', - ) - return (out.prompts ?? []).map((p) => ({ - key: p.name, - title: '', - description: p.description, - fine: formatRelativeTime(p.modified_at), - })) - }, - async load(host, name) { - const out = await host.iii.trigger<{ body: string; raw?: string | null }>( - 'directory::prompts::get', - { name, raw: true }, - ) - return out.raw ?? out.body - }, - async save(host, name, content) { - // The effective name after the write follows a frontmatter rename. - const out = await host.iii.trigger<{ name: string }>( - 'directory::prompts::update', - { name, content }, - ) - return out.name ?? name - }, -} - export function DirectoryPage({ host }: { host: Host }) { return (
directory - filesystem-backed skills & prompts — edit the markdown, save - writes through directory::*::update + filesystem-backed skills & prompts — edit skill markdown in place; + the prompt library adds create, fork, and delete
@@ -111,7 +75,7 @@ export function DirectoryPage({ host }: { host: Host }) { - +
diff --git a/iii-directory/ui/src/page/prompts-library.tsx b/iii-directory/ui/src/page/prompts-library.tsx new file mode 100644 index 000000000..7fdb7ceaf --- /dev/null +++ b/iii-directory/ui/src/page/prompts-library.tsx @@ -0,0 +1,325 @@ +/** + * The prompts tab's library surface: every prompt the directory worker + * serves — worker-shipped slash templates and user library entries, in + * `command` and `system` kinds — in a left rail, the selected prompt's + * fields + body in an editor on the right. User library entries save, + * fork (save under a new name), and delete in place through + * `directory::prompts::save` / `delete`; worker-shipped templates render + * read-only (they ship with their worker's bundle). + * + * Unlike the skills tab's raw-markdown editor, prompts edit as structured + * fields (name, kind, description, body) because the worker reconstructs + * the frontmatter on save — the same shape the composer's slash picker and + * the send/spawn system-prompt options consume. + */ + +import { Badge, Button, CodeEditor, type Host, Input } from '@iii-dev/console-ui' +import { useCallback, useEffect, useState } from 'react' +import { formatRelativeTime } from '../lib/format' +import { useOnChange } from './browser' + +interface PromptRow { + name: string + description: string + kind: string + source: string + modified_at: string +} + +interface PromptDetail extends PromptRow { + body: string +} + +interface Draft { + name: string + description: string + kind: string + body: string + /** Existing user-library entry (save overwrites) vs a new/forked one. */ + existing: boolean + /** Worker-shipped rows render read-only. */ + readonly: boolean +} + +const EMPTY_DRAFT: Draft = { + name: '', + description: '', + kind: 'system', + body: '', + existing: false, + readonly: false, +} + +export function PromptsLibrary({ host }: { host: Host }) { + const [rows, setRows] = useState(null) + const [listError, setListError] = useState(null) + const [search, setSearch] = useState('') + const [draft, setDraft] = useState(null) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const [savedFlash, setSavedFlash] = useState(false) + + const refresh = useCallback(() => { + host.iii + .trigger<{ prompts: PromptRow[] }>('directory::prompts::list', {}) + .then((out) => { + setRows(out.prompts ?? []) + setListError(null) + }) + .catch((e) => setListError(String(e))) + }, [host]) + + useEffect(() => { + refresh() + }, [refresh]) + + // A save/delete/download landing anywhere re-reads the store. + useOnChange(host, 'directory::prompts::on-change', refresh) + + const open = useCallback( + (row: PromptRow) => { + setError(null) + host.iii + .trigger('directory::prompts::get', { name: row.name }) + .then((detail) => { + setDraft({ + name: detail.name, + description: detail.description ?? '', + kind: detail.kind ?? 'command', + body: detail.body ?? '', + existing: detail.source === 'user', + readonly: detail.source !== 'user', + }) + }) + .catch((e) => setError(String(e))) + }, + [host], + ) + + const act = useCallback( + async (fn: () => Promise) => { + setBusy(true) + setError(null) + try { + await fn() + refresh() + return true + } catch (e) { + setError(String(e)) + return false + } finally { + setBusy(false) + } + }, + [refresh], + ) + + const onSave = useCallback(async () => { + if (!draft) return + const ok = await act(() => + host.iii.trigger('directory::prompts::save', { + name: draft.name.trim(), + description: draft.description.trim(), + body: draft.body, + kind: draft.kind, + }), + ) + if (ok) { + setDraft({ ...draft, existing: true }) + setSavedFlash(true) + window.setTimeout(() => setSavedFlash(false), 1600) + } + }, [act, draft, host]) + + const onFork = useCallback(() => { + if (!draft) return + setDraft({ + ...draft, + name: `${draft.name}-fork`, + existing: false, + readonly: false, + }) + }, [draft]) + + const onDelete = useCallback(async () => { + if (!draft) return + if ( + !window.confirm(`Delete prompt "${draft.name}"? This cannot be undone.`) + ) + return + const ok = await act(() => + host.iii.trigger('directory::prompts::delete', { + name: draft.name, + yes: true, + }), + ) + if (ok) setDraft(null) + }, [act, draft, host]) + + const needle = search.trim().toLowerCase() + const visible = (rows ?? []).filter( + (r) => + !needle || + r.name.toLowerCase().includes(needle) || + r.description.toLowerCase().includes(needle), + ) + + const canSave = + draft !== null && + !draft.readonly && + !busy && + draft.name.trim().length > 0 && + draft.description.trim().length > 0 + + return ( +
+
+
+ + +
+ {listError ? ( +
{listError}
+ ) : rows === null ? ( +
· loading prompts…
+ ) : visible.length === 0 ? ( +
+ · no prompts{needle ? ' match' : ' yet'} +
+ ) : ( +
    + {visible.map((r) => ( +
  • + +
  • + ))} +
+ )} +
+ +
+ {!draft ? ( +
+ · select a prompt to view or edit it, or create a new one +
+ ) : ( + <> +
+ setDraft({ ...draft, name: v })} + placeholder="prompt-name" + aria-label="prompt name" + disabled={draft.existing || draft.readonly} + /> + + {draft.readonly ? ( + worker-shipped · read-only + ) : null} + + {!draft.readonly ? ( + + ) : null} + + {draft.existing && !draft.readonly ? ( + + ) : null} + + {savedFlash ? 'saved' : ''} + +
+
+ setDraft({ ...draft, description: v })} + placeholder="one-line description (shown in the list and pickers)" + aria-label="prompt description" + disabled={draft.readonly} + /> +
+ {error ?
{error}
: null} +
+
+ setDraft({ ...draft, body: v })} + language="markdown" + className="dir-ui-code" + aria-label="prompt body" + placeholder="the prompt text…" + readOnly={draft.readonly} + /> +
+
+ + )} +
+
+ ) +} diff --git a/iii-directory/ui/styles.css b/iii-directory/ui/styles.css index 761133ff4..b73087e6f 100644 --- a/iii-directory/ui/styles.css +++ b/iii-directory/ui/styles.css @@ -488,3 +488,44 @@ flex-direction: column; gap: 4px; } + +/* ── the prompts library (prompts tab) ──────────────────────────────── */ + +[data-iii-ui="iii-directory"] .dir-ui-prompt-side-head { + display: flex; + align-items: center; + gap: 6px; +} +[data-iii-ui="iii-directory"] .dir-ui-prompt-side-head > :first-child { + flex: 1; + min-width: 0; +} +[data-iii-ui="iii-directory"] .dir-ui-prompt-row-head { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} +[data-iii-ui="iii-directory"] .dir-ui-prompt-spacer { + flex: 1; +} +[data-iii-ui="iii-directory"] .dir-ui-prompt-kind { + min-width: 96px; + padding: 4px 6px; + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 12px; + color: var(--color-ink); + background: var(--color-bg); + border: 1px solid var(--color-rule); + border-radius: 3px; +} +[data-iii-ui="iii-directory"] .dir-ui-prompt-kind:disabled { + opacity: 0.6; +} +[data-iii-ui="iii-directory"] .dir-ui-prompt-desc { + padding: 8px 12px 0; +} +[data-iii-ui="iii-directory"] .dir-ui-prompt-body { + min-height: 320px; + padding: 8px 12px 12px; +}