Skip to main content

yapi_core/
resources.rs

1//! Context files, skills, prompt templates and system prompt files.
2//!
3//! Ports of the discovery parts of `resource-loader.ts`, `skills.ts` and
4//! `prompt-templates.ts` in pi `v1.0.0`. Project `.yapi/` resources load only for a
5//! trusted project. `AGENTS.md` and `CLAUDE.md` files always load.
6
7use std::collections::HashSet;
8use std::path::{Path, PathBuf};
9use std::sync::LazyLock;
10
11use regex_lite::Regex;
12use serde_json::Value;
13pub use yapi_types::rpc::SourceInfo;
14pub use yapi_types::system_prompt::{ContextFile, Skill};
15
16use crate::config::PROJECT_DIR;
17
18/// A problem found while loading skills, prompt templates or themes: pi's
19/// `ResourceDiagnostic`, listed under the startup `[... conflicts]` sections.
20#[derive(Clone, Debug, PartialEq, Eq)]
21pub enum Diagnostic {
22    /// A resource that loads with a problem, or a path that does not load.
23    Warning {
24        /// What is wrong.
25        message: String,
26        /// The path.
27        path: PathBuf,
28    },
29    /// A command-line path that does not exist.
30    Error {
31        /// What is wrong.
32        message: String,
33        /// The path.
34        path: PathBuf,
35    },
36    /// A name an earlier resource took. The later one is skipped.
37    Collision {
38        /// The name.
39        name: String,
40        /// The resource that keeps the name, with its source.
41        winner: SourceInfo,
42        /// The skipped file.
43        loser: PathBuf,
44    },
45}
46
47/// A prompt template, expanded by `/name args`.
48#[derive(Clone, Debug, PartialEq, Eq)]
49pub struct PromptTemplate {
50    /// The command name: the file name without `.md`.
51    pub name: String,
52    /// From frontmatter, else the first non-empty line, cut at 60 characters.
53    pub description: String,
54    /// `argument-hint` from frontmatter.
55    pub argument_hint: Option<String>,
56    /// The body.
57    pub content: String,
58    /// The file.
59    pub file_path: PathBuf,
60    /// Where it was found.
61    pub source: SourceInfo,
62}
63
64/// Frontmatter as pi's `yaml` parser gives it to JavaScript: the top-level
65/// mapping, in document order.
66pub type Frontmatter = serde_json::Map<String, Value>;
67
68/// pi's `extractFrontmatter`: the YAML between `---` lines, when there is
69/// any, and the trimmed markdown body after it.
70pub fn split_frontmatter(content: &str) -> (Option<String>, String) {
71    let normalized = content
72        .strip_prefix('\u{feff}')
73        .unwrap_or(content)
74        .replace("\r\n", "\n")
75        .replace('\r', "\n");
76    match normalized
77        .starts_with("---")
78        .then(|| normalized[3..].find("\n---"))
79        .flatten()
80    {
81        Some(end) => (
82            Some(normalized.get(4..end + 3).unwrap_or_default().to_owned()),
83            normalized[end + 7..].trim().to_owned(),
84        ),
85        None => (None, normalized),
86    }
87}
88
89/// pi's `parseFrontmatter`: the frontmatter and the body, or the YAML error.
90pub fn parse_frontmatter(content: &str) -> Result<(Frontmatter, String), String> {
91    let (yaml, body) = split_frontmatter(content);
92    let Some(yaml) = yaml.filter(|yaml| !yaml.is_empty()) else {
93        return Ok((Frontmatter::new(), body));
94    };
95    let documents = load_yaml(yaml).map_err(|error| error.to_string())?;
96    let frontmatter = match documents.into_iter().next().map(yaml_to_json) {
97        Some(Value::Object(map)) => map,
98        _ => Frontmatter::new(),
99    };
100    Ok((frontmatter, body))
101}
102
103/// The documents in `yaml`, read as pi's `yaml` package reads them: unlike
104/// yaml_rust2, it takes a tab alone after a key's colon.
105fn load_yaml(mut yaml: String) -> Result<Vec<yaml_rust2::Yaml>, yaml_rust2::ScanError> {
106    loop {
107        match yaml_rust2::YamlLoader::load_from_str(&yaml) {
108            Err(error) if error.info() == "':' must be followed by a valid YAML whitespace" => {
109                // The error points past the tabs. The first of them becomes a space.
110                let at = yaml
111                    .char_indices()
112                    .nth(error.marker().index())
113                    .map_or(yaml.len(), |(at, _)| at);
114                let start = yaml[..at].trim_end_matches('\t').len();
115                if start == at {
116                    return Err(error);
117                }
118                yaml.replace_range(start..=start, " ");
119            }
120            result => return result,
121        }
122    }
123}
124
125/// A YAML value as JavaScript sees it, with keys as strings.
126fn yaml_to_json(yaml: yaml_rust2::Yaml) -> Value {
127    use yaml_rust2::Yaml;
128    match yaml {
129        Yaml::Real(_) => yaml
130            .as_f64()
131            .and_then(serde_json::Number::from_f64)
132            .map_or(Value::Null, Value::Number),
133        Yaml::Integer(number) => Value::from(number),
134        Yaml::String(text) => Value::String(text),
135        Yaml::Boolean(flag) => Value::Bool(flag),
136        Yaml::Array(items) => Value::Array(items.into_iter().map(yaml_to_json).collect()),
137        Yaml::Hash(entries) => Value::Object(
138            entries
139                .into_iter()
140                .map(|(key, value)| {
141                    let key = match yaml_to_json(key) {
142                        Value::String(text) => text,
143                        Value::Null => String::new(),
144                        other => other.to_string(),
145                    };
146                    (key, yaml_to_json(value))
147                })
148                .collect(),
149        ),
150        Yaml::Null | Yaml::BadValue | Yaml::Alias(_) => Value::Null,
151    }
152}
153
154fn read_text(path: &Path) -> Option<String> {
155    std::fs::read_to_string(path).ok().map(|text| {
156        text.strip_prefix('\u{feff}')
157            .map(str::to_owned)
158            .unwrap_or(text)
159    })
160}
161
162fn context_file_in(dir: &Path) -> Option<ContextFile> {
163    for name in [
164        "AGENTS.override.md",
165        "AGENTS.md",
166        "AGENTS.MD",
167        "CLAUDE.md",
168        "CLAUDE.MD",
169    ] {
170        let path = dir.join(name);
171        if path.is_file()
172            && let Some(content) = read_text(&path)
173        {
174            return Some(ContextFile { path, content });
175        }
176    }
177    None
178}
179
180/// pi's `findGitPaths`: the folder holding the `.git` of `cwd` or its
181/// nearest ancestor, and the repository's common git directory.
182fn git_paths(cwd: &Path) -> Option<(PathBuf, PathBuf)> {
183    for dir in cwd.ancestors() {
184        let git = dir.join(".git");
185        if git.is_dir() {
186            return git.join("HEAD").exists().then(|| (dir.to_path_buf(), git));
187        }
188        if !git.is_file() {
189            continue;
190        }
191        let text = std::fs::read_to_string(&git).ok()?;
192        let Some(target) = text.trim().strip_prefix("gitdir: ") else {
193            continue;
194        };
195        let git_dir = crate::tools::path::resolve_lexically(dir, Path::new(target.trim()));
196        if !git_dir.join("HEAD").exists() {
197            return None;
198        }
199        let common = git_dir.join("commondir");
200        let common = if common.exists() {
201            let text = std::fs::read_to_string(common).ok()?;
202            crate::tools::path::resolve_lexically(&git_dir, Path::new(text.trim()))
203        } else {
204            git_dir
205        };
206        return Some((dir.to_path_buf(), common));
207    }
208    None
209}
210
211/// pi's `findShadowedContextFile`: when `cwd` is in a linked worktree nested
212/// in its main checkout, the main checkout's context file, canonical, that
213/// the worktree's own context file replaces.
214fn shadowed_context_file(cwd: &Path) -> Option<PathBuf> {
215    let canonical =
216        |path: &Path| std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
217    let (repo_dir, common_git_dir) = git_paths(cwd)?;
218    let common_git_dir = canonical(&common_git_dir);
219    let worktree = canonical(&repo_dir);
220    let main = common_git_dir.parent()?;
221    // An ordinary checkout, a sibling worktree, a bare layout or a submodule
222    // shadows nothing.
223    if worktree == main
224        || !worktree.starts_with(main)
225        || canonical(&main.join(".git")) != common_git_dir
226    {
227        return None;
228    }
229    Some(main.join(context_file_in(&worktree)?.path.file_name()?))
230}
231
232/// The global context file from the agent directory, then those of
233/// `project_dir` and its ancestors, outermost first. In a linked worktree
234/// nested in its main checkout, the main checkout's file is left out when the
235/// worktree has its own.
236pub fn context_files(project_dir: &Path, agent_dir: &Path) -> Vec<ContextFile> {
237    let mut files = Vec::new();
238    if let Some(global) = context_file_in(agent_dir) {
239        files.push(global);
240    }
241    let shadowed = shadowed_context_file(project_dir);
242    let mut ancestors = Vec::new();
243    for dir in project_dir.ancestors() {
244        if let Some(file) = context_file_in(dir)
245            && shadowed.as_ref().is_none_or(|shadowed| {
246                std::fs::canonicalize(&file.path).ok().as_ref() != Some(shadowed)
247            })
248            && !files
249                .iter()
250                .any(|seen: &ContextFile| seen.path == file.path)
251            && !ancestors
252                .iter()
253                .any(|seen: &ContextFile| seen.path == file.path)
254        {
255            ancestors.insert(0, file);
256        }
257    }
258    files.extend(ancestors);
259    files
260}
261
262/// The source of a resource at `path` found under `origin`, whose path it
263/// replaces.
264fn found_at(origin: &SourceInfo, path: &Path) -> SourceInfo {
265    SourceInfo {
266        path: path.display().to_string(),
267        ..origin.clone()
268    }
269}
270
271/// pi's collision rule for resources in precedence order: the first of each
272/// name is kept and later ones are reported as collisions. `meta` gives a
273/// resource's name, source and file. A resource whose `real_path` a kept one
274/// has is the same file reached again and is skipped silently.
275fn first_by_name<T>(
276    found: Vec<T>,
277    meta: impl Fn(&T) -> (&str, &SourceInfo, &Path),
278    real_path: impl Fn(&T) -> Option<PathBuf>,
279) -> (Vec<T>, Vec<Diagnostic>) {
280    let mut kept: Vec<T> = Vec::new();
281    let mut files = HashSet::new();
282    let mut collisions = Vec::new();
283    for item in found {
284        let real = real_path(&item);
285        if real.as_ref().is_some_and(|real| files.contains(real)) {
286            continue;
287        }
288        let (name, _, file) = meta(&item);
289        match kept.iter().find(|existing| meta(existing).0 == name) {
290            Some(existing) => collisions.push(Diagnostic::Collision {
291                name: name.to_owned(),
292                winner: meta(existing).1.clone(),
293                loser: file.to_path_buf(),
294            }),
295            None => {
296                files.extend(real);
297                kept.push(item);
298            }
299        }
300    }
301    (kept, collisions)
302}
303
304/// A path given on the command line, as pi records it: source `cli`, scope
305/// `temporary`.
306pub fn cli_source(path: &Path) -> SourceInfo {
307    SourceInfo {
308        path: path.display().to_string(),
309        source: "cli".into(),
310        scope: "temporary".into(),
311        origin: "top-level".into(),
312        base_dir: None,
313    }
314}
315
316/// The Agent Skills limits pi checks names and descriptions against, in
317/// UTF-16 code units as JavaScript counts them.
318const MAX_NAME_LENGTH: usize = 64;
319const MAX_DESCRIPTION_LENGTH: usize = 1024;
320
321/// pi's `validateName`.
322fn name_problems(name: &str) -> Vec<String> {
323    let mut problems = Vec::new();
324    let length = yapi_types::js::len(name);
325    if length > MAX_NAME_LENGTH {
326        problems.push(format!(
327            "name exceeds {MAX_NAME_LENGTH} characters ({length})"
328        ));
329    }
330    if name.is_empty()
331        || !name
332            .chars()
333            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
334    {
335        problems.push(
336            "name contains invalid characters (must be lowercase a-z, 0-9, hyphens only)"
337                .to_owned(),
338        );
339    }
340    if name.starts_with('-') || name.ends_with('-') {
341        problems.push("name must not start or end with a hyphen".to_owned());
342    }
343    if name.contains("--") {
344        problems.push("name must not contain consecutive hyphens".to_owned());
345    }
346    problems
347}
348
349/// pi's `loadSkillFromFile`: a `SKILL.md` file, or another markdown file with
350/// a description. Problems with the name or description are reported. A
351/// skill without a description does not load.
352fn load_skill(
353    path: &Path,
354    origin: &SourceInfo,
355    diagnostics: &mut Vec<Diagnostic>,
356) -> Option<Skill> {
357    let warn = |message: String| Diagnostic::Warning {
358        message,
359        path: path.to_path_buf(),
360    };
361    let declared = path.file_name().is_some_and(|name| name == "SKILL.md");
362    // The frontmatter parser skips a byte order mark.
363    let text = match std::fs::read_to_string(path) {
364        Ok(text) => text,
365        Err(error) => {
366            diagnostics.push(warn(crate::tools::node_error(&error, "open", path)));
367            return None;
368        }
369    };
370    let frontmatter = match parse_frontmatter(&text) {
371        Ok((frontmatter, _)) => frontmatter,
372        Err(error) => {
373            if declared {
374                diagnostics.push(warn(error));
375            }
376            return None;
377        }
378    };
379    let description = frontmatter
380        .get("description")
381        .and_then(Value::as_str)
382        .filter(|text| !text.trim().is_empty());
383    if !declared && description.is_none() {
384        return None;
385    }
386    match description {
387        None => diagnostics.push(warn("description is required".to_owned())),
388        Some(text) => {
389            let length = yapi_types::js::len(text);
390            if length > MAX_DESCRIPTION_LENGTH {
391                diagnostics.push(warn(format!(
392                    "description exceeds {MAX_DESCRIPTION_LENGTH} characters ({length})"
393                )));
394            }
395        }
396    }
397    let base_dir = path.parent()?.to_path_buf();
398    let name = frontmatter
399        .get("name")
400        .and_then(Value::as_str)
401        .filter(|name| !name.is_empty())
402        .map(str::to_owned)
403        .or_else(|| {
404            base_dir
405                .file_name()
406                .map(|name| name.to_string_lossy().into_owned())
407        })?;
408    diagnostics.extend(name_problems(&name).into_iter().map(warn));
409    Some(Skill {
410        name,
411        description: description?.to_owned(),
412        file_path: path.to_path_buf(),
413        base_dir,
414        disable_model_invocation: frontmatter.get("disable-model-invocation")
415            == Some(&Value::Bool(true)),
416        source: found_at(origin, path),
417    })
418}
419
420/// Skills under `dir`: a directory with `SKILL.md` is one skill. Otherwise
421/// subdirectories are searched, and at the top level other `.md` files with a
422/// description are skills too. Hidden entries and `node_modules` are skipped.
423fn skills_in(
424    dir: &Path,
425    top: bool,
426    origin: &SourceInfo,
427    skills: &mut Vec<Skill>,
428    diagnostics: &mut Vec<Diagnostic>,
429) {
430    let skill_file = dir.join("SKILL.md");
431    if skill_file.is_file() {
432        skills.extend(load_skill(&skill_file, origin, diagnostics));
433        return;
434    }
435    let Ok(read) = std::fs::read_dir(dir) else {
436        return;
437    };
438    let mut entries: Vec<PathBuf> = read.flatten().map(|entry| entry.path()).collect();
439    entries.sort();
440    for path in entries {
441        let name = path
442            .file_name()
443            .map(|name| name.to_string_lossy().into_owned())
444            .unwrap_or_default();
445        if name.starts_with('.') || name == "node_modules" {
446            continue;
447        }
448        if path.is_dir() {
449            skills_in(&path, false, origin, skills, diagnostics);
450        } else if top && path.is_file() && name.ends_with(".md") {
451            skills.extend(load_skill(&path, origin, diagnostics));
452        }
453    }
454}
455
456/// pi's `loadSkills` over `sources`, in order, each with its source: skill
457/// files, or directories searched for skills. The first skill of a name
458/// wins, and later ones are reported as collisions.
459pub fn skills_from(sources: &[SourceInfo]) -> (Vec<Skill>, Vec<Diagnostic>) {
460    let mut found = Vec::new();
461    let mut diagnostics = Vec::new();
462    for source in sources {
463        let path = Path::new(&source.path);
464        let warn = |message: &str| Diagnostic::Warning {
465            message: message.to_owned(),
466            path: path.to_path_buf(),
467        };
468        if !path.exists() {
469            diagnostics.push(warn("skill path does not exist"));
470        } else if path.is_dir() {
471            skills_in(path, true, source, &mut found, &mut diagnostics);
472        } else if path.is_file() && path.extension().is_some_and(|ext| ext == "md") {
473            found.extend(load_skill(path, source, &mut diagnostics));
474        } else {
475            diagnostics.push(warn("skill path is not a markdown file"));
476        }
477    }
478    // The same file reached twice, through a symlink, loads once.
479    let (skills, collisions) = first_by_name(
480        found,
481        |skill| (&skill.name, &skill.source, &skill.file_path),
482        |skill| Some(std::fs::canonicalize(&skill.file_path).unwrap_or(skill.file_path.clone())),
483    );
484    diagnostics.extend(collisions);
485    (skills, diagnostics)
486}
487
488/// pi's `mergePaths`: `sources` in order, each path once, compared after
489/// resolving symlinks.
490pub fn merge_sources(sources: impl IntoIterator<Item = SourceInfo>) -> Vec<SourceInfo> {
491    let mut seen = HashSet::new();
492    sources
493        .into_iter()
494        .filter(|info| {
495            seen.insert(
496                std::fs::canonicalize(&info.path).unwrap_or_else(|_| PathBuf::from(&info.path)),
497            )
498        })
499        .collect()
500}
501
502fn escape_xml(text: &str) -> String {
503    text.replace('&', "&amp;")
504        .replace('<', "&lt;")
505        .replace('>', "&gt;")
506        .replace('"', "&quot;")
507        .replace('\'', "&apos;")
508}
509
510/// The skills section of the prompt, read with `read` or `bash`.
511pub fn format_skills(skills: &[Skill], read_tool: &str) -> String {
512    let visible: Vec<&Skill> = skills
513        .iter()
514        .filter(|skill| !skill.disable_model_invocation)
515        .collect();
516    if visible.is_empty() {
517        return String::new();
518    }
519    let mut lines = vec![
520        "\n\nThe following skills provide specialized instructions for specific tasks.".to_owned(),
521        if read_tool == "read" {
522            "Use the read tool to load a skill's file when the task matches its description."
523        } else {
524            "Use bash to load a skill's file when the task matches its description."
525        }
526        .to_owned(),
527        "When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.".to_owned(),
528        String::new(),
529        "<available_skills>".to_owned(),
530    ];
531    for skill in visible {
532        lines.push("  <skill>".into());
533        lines.push(format!("    <name>{}</name>", escape_xml(&skill.name)));
534        lines.push(format!(
535            "    <description>{}</description>",
536            escape_xml(&skill.description)
537        ));
538        lines.push(format!(
539            "    <location>{}</location>",
540            escape_xml(&skill.file_path.to_string_lossy())
541        ));
542        lines.push("  </skill>".into());
543    }
544    lines.push("</available_skills>".into());
545    lines.join("\n")
546}
547
548fn templates_in(
549    dir: &Path,
550    origin: &SourceInfo,
551    templates: &mut Vec<PromptTemplate>,
552    diagnostics: &mut Vec<Diagnostic>,
553) {
554    let Ok(read) = std::fs::read_dir(dir) else {
555        return;
556    };
557    let mut paths: Vec<PathBuf> = read.flatten().map(|entry| entry.path()).collect();
558    paths.sort();
559    for path in paths {
560        if path.is_file() && path.extension().is_some_and(|ext| ext == "md") {
561            templates.extend(template_at(path, origin, diagnostics));
562        }
563    }
564}
565
566/// pi's `loadTemplateFromFile`: the template in a markdown file. A file that
567/// cannot be read or whose frontmatter does not parse is reported.
568fn template_at(
569    path: PathBuf,
570    origin: &SourceInfo,
571    diagnostics: &mut Vec<Diagnostic>,
572) -> Option<PromptTemplate> {
573    let parsed = std::fs::read_to_string(&path)
574        .map_err(|error| crate::tools::node_error(&error, "open", &path))
575        .and_then(|text| parse_frontmatter(&text));
576    let (frontmatter, body) = match parsed {
577        Ok(parsed) => parsed,
578        Err(message) => {
579            diagnostics.push(Diagnostic::Warning { message, path });
580            return None;
581        }
582    };
583    let name = path
584        .file_stem()
585        .map(|stem| stem.to_string_lossy().into_owned())
586        .unwrap_or_default();
587    let mut description = frontmatter
588        .get("description")
589        .and_then(Value::as_str)
590        .unwrap_or_default()
591        .to_owned();
592    if description.is_empty()
593        && let Some(first) = body.lines().find(|line| !line.trim().is_empty())
594    {
595        description = yapi_types::js::slice(first, 0, 60);
596        if yapi_types::js::len(first) > 60 {
597            description += "...";
598        }
599    }
600    Some(PromptTemplate {
601        name,
602        description,
603        argument_hint: frontmatter
604            .get("argument-hint")
605            .and_then(Value::as_str)
606            .map(str::to_owned),
607        content: body,
608        source: found_at(origin, &path),
609        file_path: path,
610    })
611}
612
613/// pi's prompt template loading over `sources`, in order, each with its
614/// source: markdown files, or directories of them. The first template of a
615/// name wins, and later ones are reported as collisions. Missing paths are
616/// skipped, and the caller reports the command line's.
617pub fn templates_from(sources: &[SourceInfo]) -> (Vec<PromptTemplate>, Vec<Diagnostic>) {
618    let mut templates: Vec<PromptTemplate> = Vec::new();
619    let mut diagnostics = Vec::new();
620    for source in sources {
621        let path = PathBuf::from(&source.path);
622        if path.is_dir() {
623            templates_in(&path, source, &mut templates, &mut diagnostics);
624        } else if path.is_file() && path.extension().is_some_and(|ext| ext == "md") {
625            templates.extend(template_at(path, source, &mut diagnostics));
626        }
627    }
628    let (templates, collisions) = first_by_name(
629        templates,
630        |template| (&template.name, &template.source, &template.file_path),
631        |_| None,
632    );
633    diagnostics.extend(collisions);
634    (templates, diagnostics)
635}
636
637/// Splits arguments on whitespace, honoring single and double quotes.
638pub fn parse_command_args(text: &str) -> Vec<String> {
639    let mut args = Vec::new();
640    let mut current = String::new();
641    let mut quote: Option<char> = None;
642    for c in text.chars() {
643        match quote {
644            Some(q) if c == q => quote = None,
645            Some(_) => current.push(c),
646            None if c == '"' || c == '\'' => quote = Some(c),
647            None if c.is_whitespace() => {
648                if !current.is_empty() {
649                    args.push(std::mem::take(&mut current));
650                }
651            }
652            None => current.push(c),
653        }
654    }
655    if !current.is_empty() {
656        args.push(current);
657    }
658    args
659}
660
661/// pi's `substituteArgs`: replaces `$1`, `$@`, `$ARGUMENTS`, `${N:-default}`
662/// and `${@:start:length}`.
663pub fn substitute_args(content: &str, args: &[String]) -> String {
664    static PLACEHOLDER: LazyLock<Option<Regex>> = LazyLock::new(|| {
665        Regex::new(r"\$\{(\d+|ARGUMENTS|@):-([^}]*)\}|\$\{@:(\d+)(?::(\d+))?\}|\$(ARGUMENTS|@|\d+)")
666            .ok()
667    });
668    let Some(placeholder) = PLACEHOLDER.as_ref() else {
669        return content.to_owned();
670    };
671    let all = args.join(" ");
672    // An index with too many digits names no argument, as in JavaScript.
673    let number = |digits: &str| digits.parse::<usize>().unwrap_or(usize::MAX);
674    let arg = |digits: &str| {
675        number(digits)
676            .checked_sub(1)
677            .and_then(|index| args.get(index))
678            .map_or("", String::as_str)
679    };
680    let all_or_arg = |target: &str| match target {
681        "@" | "ARGUMENTS" => all.as_str(),
682        digits => arg(digits),
683    };
684    placeholder
685        .replace_all(content, |caps: &regex_lite::Captures<'_>| {
686            if let Some(target) = caps.get(1) {
687                let value = all_or_arg(target.as_str());
688                let default = caps.get(2).map_or("", |default| default.as_str());
689                return if value.is_empty() { default } else { value }.to_owned();
690            }
691            if let Some(start) = caps.get(3) {
692                let start = number(start.as_str()).saturating_sub(1).min(args.len());
693                let end = caps.get(4).map_or(args.len(), |length| {
694                    start
695                        .saturating_add(number(length.as_str()))
696                        .min(args.len())
697                });
698                return args[start..end].join(" ");
699            }
700            caps.get(5)
701                .map_or("", |simple| all_or_arg(simple.as_str()))
702                .to_owned()
703        })
704        .into_owned()
705}
706
707/// Expands `/name args` when `name` is a template. Other text is unchanged.
708pub fn expand_prompt_template(text: &str, templates: &[PromptTemplate]) -> String {
709    let Some(rest) = text.strip_prefix('/') else {
710        return text.to_owned();
711    };
712    let (name, args) = match rest.find(char::is_whitespace) {
713        Some(index) => (&rest[..index], rest[index..].trim_start()),
714        None => (rest, ""),
715    };
716    if name.is_empty() {
717        return text.to_owned();
718    }
719    match templates.iter().find(|template| template.name == name) {
720        Some(template) => substitute_args(&template.content, &parse_command_args(args)),
721        None => text.to_owned(),
722    }
723}
724
725/// `SYSTEM.md` replacing the default prompt: the trusted project's in
726/// `project_dir`, else the global one.
727pub fn system_prompt_file(
728    project_dir: &Path,
729    agent_dir: &Path,
730    project_trusted: bool,
731    name: &str,
732) -> Option<String> {
733    let project = project_dir.join(PROJECT_DIR).join(name);
734    if project_trusted && project.exists() {
735        return read_text(&project);
736    }
737    read_text(&agent_dir.join(name))
738}
739
740#[cfg(test)]
741mod tests {
742    use super::*;
743
744    #[test]
745    fn parses_frontmatter() {
746        let (meta, body) = parse_frontmatter(
747            "---\nname: demo\ndescription: \"Does: things\"\ndisable-model-invocation: true\nnotes: |\n  a\n    b\nwhen: >-\n  folded\n  text\ntags: [a, b]\nlimit: 3\ntabbed:\tTabbed value\noffset:\t\t-1\n---\n\nBody\n",
748        )
749        .unwrap();
750        assert_eq!(
751            serde_json::Value::Object(meta),
752            serde_json::json!({
753                "name": "demo",
754                "description": "Does: things",
755                "disable-model-invocation": true,
756                "notes": "a\n  b\n",
757                "when": "folded text",
758                "tags": ["a", "b"],
759                "limit": 3,
760                "tabbed": "Tabbed value",
761                "offset": -1,
762            })
763        );
764        assert_eq!(body, "Body");
765        // pi's `yaml` throws on malformed YAML, which skips the skill.
766        assert!(parse_frontmatter("---\nname: a: b\n---\n").is_err());
767        assert!(parse_frontmatter("---\na: x\n\tb: y\n---\n").is_err());
768        assert!(parse_frontmatter("no frontmatter").unwrap().0.is_empty());
769    }
770
771    #[test]
772    fn reports_malformed_frontmatter() {
773        let dir = std::env::temp_dir().join(format!("yapi-frontmatter-{}", std::process::id()));
774        let _ = std::fs::remove_dir_all(&dir);
775        let bad = "---\ndescription: a: b\n---\nBody\n";
776        std::fs::create_dir_all(dir.join("prompts")).unwrap();
777        std::fs::create_dir_all(dir.join("skills/broken")).unwrap();
778        std::fs::write(dir.join("prompts/bad.md"), bad).unwrap();
779        std::fs::write(dir.join("prompts/good.md"), "Hello\n").unwrap();
780        std::fs::write(dir.join("skills/broken/SKILL.md"), bad).unwrap();
781        std::fs::write(dir.join("skills/loose.md"), bad).unwrap();
782
783        // pi's `loadTemplateFromFile` warns and skips the template.
784        let (templates, diagnostics) = templates_from(&[cli_source(&dir.join("prompts"))]);
785        assert_eq!(templates.len(), 1);
786        assert!(matches!(
787            diagnostics.as_slice(),
788            [Diagnostic::Warning { path, .. }] if path.ends_with("prompts/bad.md")
789        ));
790
791        // pi's `loadSkillFromFile` warns only for a declared `SKILL.md`.
792        let (skills, diagnostics) = skills_from(&[cli_source(&dir.join("skills"))]);
793        assert!(skills.is_empty());
794        assert!(matches!(
795            diagnostics.as_slice(),
796            [Diagnostic::Warning { path, .. }] if path.ends_with("broken/SKILL.md")
797        ));
798        std::fs::remove_dir_all(&dir).unwrap();
799    }
800
801    #[test]
802    fn worktree_shadows_main() {
803        let dir = std::fs::canonicalize(std::env::temp_dir())
804            .unwrap()
805            .join(format!("yapi-worktree-{}", std::process::id()));
806        let _ = std::fs::remove_dir_all(&dir);
807        let main = dir.join("main");
808        let git_dir = main.join(".git/worktrees/feat");
809        let feat = main.join(".worktrees/feat");
810        std::fs::create_dir_all(&git_dir).unwrap();
811        std::fs::create_dir_all(&feat).unwrap();
812        std::fs::write(main.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
813        std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/feat\n").unwrap();
814        std::fs::write(git_dir.join("commondir"), "../..\n").unwrap();
815        std::fs::write(
816            feat.join(".git"),
817            format!("gitdir: {}\n", git_dir.display()),
818        )
819        .unwrap();
820        std::fs::write(main.join("AGENTS.md"), "main").unwrap();
821        let paths = || -> Vec<PathBuf> {
822            context_files(&feat, &dir.join("agent"))
823                .into_iter()
824                .map(|file| file.path)
825                .collect()
826        };
827        // The main checkout's file applies until the worktree has its own.
828        assert_eq!(paths(), [main.join("AGENTS.md")]);
829        std::fs::write(feat.join("AGENTS.md"), "feat").unwrap();
830        assert_eq!(paths(), [feat.join("AGENTS.md")]);
831        std::fs::remove_dir_all(&dir).unwrap();
832    }
833
834    #[test]
835    fn substitutes_arguments_like_pi() {
836        let args: Vec<String> = parse_command_args("one \"two three\" four");
837        assert_eq!(args, ["one", "two three", "four"]);
838        assert_eq!(
839            substitute_args("$1|$2|$9|$@", &args),
840            "one|two three||one two three four"
841        );
842        assert_eq!(substitute_args("${4:-none} ${1:-x}", &args), "none one");
843        assert_eq!(
844            substitute_args("${@:2} ${@:1:1} $ARGUMENTS", &args),
845            "two three four one one two three four"
846        );
847        assert_eq!(substitute_args("cost $ and $x", &args), "cost $ and $x");
848    }
849
850    /// Expectations from pi's `substituteArgs` in Node.
851    #[test]
852    fn substitution_edge_cases() {
853        let args = |list: &[&str]| -> Vec<String> { list.iter().map(|&arg| arg.into()).collect() };
854        let ten = args(&["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"]);
855        assert_eq!(substitute_args("$10|$1|$0|$11", &ten), "j|a||");
856        assert_eq!(
857            substitute_args(
858                "${0:-zero} ${1:-} ${2:-two} ${@:-none} ${ARGUMENTS:-none}",
859                &args(&["x"])
860            ),
861            "zero x two x x"
862        );
863        assert_eq!(
864            substitute_args("${@:-none}|${ARGUMENTS:-none}", &[]),
865            "none|none"
866        );
867        assert_eq!(
868            substitute_args(
869                "${@:0}|${@:2:1}|${@:5}|${@:2:0}|${@:1:99}",
870                &args(&["a", "b", "c"])
871            ),
872            "a b c|b|||a b c"
873        );
874        assert_eq!(
875            substitute_args(
876                "$ARGUMENTSX $@@ $$1 ${x} ${@:2:} ${1:-a}b}",
877                &args(&["one", "two"])
878            ),
879            "one twoX one two@ $one ${x} ${@:2:} oneb}"
880        );
881        assert_eq!(
882            substitute_args(
883                "$99999999999999999999|${@:99999999999999999999}|${99999999999999999999:-d}",
884                &args(&["a"])
885            ),
886            "||d"
887        );
888        assert_eq!(
889            substitute_args("${1:-multi\nline}|${2:-{nested}", &[]),
890            "multi\nline|{nested"
891        );
892        assert_eq!(
893            substitute_args("cost $ and $x and ${", &args(&["a"])),
894            "cost $ and $x and ${"
895        );
896    }
897}