Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions iii-directory/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -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
/// `<name>.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 {
Expand All @@ -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(),
}
}
}
Expand Down Expand Up @@ -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 {
Expand Down
183 changes: 118 additions & 65 deletions iii-directory/src/fs_source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
pub abs_path: PathBuf,
}

Expand All @@ -74,12 +76,17 @@ pub struct PromptFrontmatter {
pub name: Option<String>,
#[serde(default)]
pub description: Option<String>,
/// Free-form classifier; the library uses `command` (slash-style
/// message injection, the default) vs `system` (a full system prompt).
#[serde(default)]
pub kind: Option<String>,
}

/// 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<PromptFrontmatter, String> {
let (fm_text, _) = split_frontmatter(content);
let Some(fm_text) = fm_text else {
Expand Down Expand Up @@ -292,6 +299,63 @@ pub fn scan_skills(skills_folder: &Path) -> (Vec<FsSkill>, Vec<SkipReason>) {
/// 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<FsPrompt, SkipReason> {
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<FsPrompt>, Vec<SkipReason>) {
let mut prompts: Vec<FsPrompt> = Vec::new();
let mut skipped: Vec<SkipReason> = Vec::new();
Expand All @@ -312,83 +376,72 @@ pub fn scan_prompts(skills_folder: &Path) -> (Vec<FsPrompt>, Vec<SkipReason>) {
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 `<ns>/prompts/` path requirement — the
/// library is not namespaced by worker.
pub fn scan_user_prompts(root: &Path) -> (Vec<FsPrompt>, Vec<SkipReason>) {
let mut prompts: Vec<FsPrompt> = Vec::new();
let mut skipped: Vec<SkipReason> = 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));
Expand Down
8 changes: 4 additions & 4 deletions iii-directory/src/functions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
Expand All @@ -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"
);
Expand Down
Loading
Loading