use std::path::{Path, PathBuf};
pub const EXTENSIONS: &[&str] = &[
"rs", "go", "py", "pyi", "ts", "tsx", "js", "jsx", "mts", "cts", "mjs", "cjs",
];
const LANGUAGES: &[&str] = &[
"rust",
"go",
"py",
"python",
"ts",
"typescript",
"js",
"javascript",
];
const BUILTIN_WITH_LSP: &[&str] = &["Explore", "Plan", "general-purpose", "fork", "claude"];
const BUILTIN_WITHOUT_LSP: &[&str] = &["statusline-setup", "claude-code-guide"];
pub fn is_code(path: &str) -> bool {
let name = path.rsplit('/').next().unwrap_or(path);
name.rsplit_once('.')
.is_some_and(|(stem, ext)| !stem.is_empty() && EXTENSIONS.contains(&ext))
}
pub fn names_code(selector: &str) -> bool {
if LANGUAGES.contains(&selector) {
return true;
}
let Some((_, tail)) = selector.rsplit_once('.') else {
return false;
};
let tail = tail.trim_start_matches('{').trim_end_matches('}');
tail.split(',').any(|ext| EXTENSIONS.contains(&ext))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Agent {
Main,
Named(String),
Unnamed,
}
impl Agent {
pub fn from_payload(agent_id: Option<&str>, agent_type: Option<&str>) -> Agent {
fn present(s: Option<&str>) -> Option<&str> {
s.filter(|s| !s.trim().is_empty())
}
match (present(agent_id), present(agent_type)) {
(_, Some(t)) => Agent::Named(t.to_string()),
(Some(_), None) => Agent::Unnamed,
(None, None) => Agent::Main,
}
}
}
pub fn serves(target: &Path) -> Result<(), &'static str> {
if on_path("fleet-lsp").is_none() {
return Err("fleet-lsp is not on PATH");
}
let Some(repo) = git_root(target) else {
return Err("outside a git working tree");
};
if !plugin_enabled(&repo) {
return Err("the fleet-lsp plugin is not enabled");
}
Ok(())
}
pub fn agent_has_tool(agent: &Agent, cwd: &Path) -> Result<(), &'static str> {
let name = match agent {
Agent::Main => return Ok(()),
Agent::Unnamed => return Err("a subagent of unknown type"),
Agent::Named(name) => name.as_str(),
};
if BUILTIN_WITH_LSP.contains(&name) {
return Ok(());
}
if BUILTIN_WITHOUT_LSP.contains(&name) {
return Err("this agent has no LSP tool");
}
let Some(text) = definition(name, cwd) else {
return Err("the agent's definition was not found");
};
match tools_of(&text) {
None => Ok(()),
Some(tools) if tools.iter().any(|t| t == "LSP") => Ok(()),
Some(_) => Err("this agent has no LSP tool"),
}
}
fn definition(name: &str, cwd: &Path) -> Option<String> {
let read = |p: PathBuf| std::fs::read_to_string(p).ok();
if let Some((plugin, agent)) = name.split_once(':') {
let root = plugin_install_path(plugin)?;
return read(root.join("agents").join(format!("{agent}.md")));
}
if name.contains('/') || name.contains("..") {
return None;
}
let file = format!("{name}.md");
let project = git_root(cwd).unwrap_or_else(|| cwd.to_path_buf());
read(project.join(".claude/agents").join(&file))
.or_else(|| read(crate::settings::config_dir()?.join("agents").join(&file)))
}
pub fn tools_of(definition: &str) -> Option<Vec<String>> {
let mut lines = definition.lines();
if lines.next()?.trim() != "---" {
return None;
}
let front: Vec<&str> = lines.take_while(|l| l.trim() != "---").collect();
let at = front.iter().position(|l| l.starts_with("tools:"))?;
let clean = |s: &str| s.trim().trim_matches(|c| c == '"' || c == '\'').to_string();
let inline = front[at]["tools:".len()..].trim();
if !inline.is_empty() {
let inline = inline.trim_start_matches('[').trim_end_matches(']');
return Some(
inline
.split(',')
.map(clean)
.filter(|t| !t.is_empty())
.collect(),
);
}
Some(
front[at + 1..]
.iter()
.map_while(|l| l.trim_start().strip_prefix("- "))
.map(clean)
.collect(),
)
}
fn plugin_enabled(repo: &Path) -> bool {
let files = [
crate::settings::config_dir().map(|d| d.join("settings.json")),
Some(repo.join(".claude/settings.json")),
Some(repo.join(".claude/settings.local.json")),
];
let said: Vec<Option<bool>> = files
.into_iter()
.map(|f| f.and_then(|f| std::fs::read_to_string(f).ok()))
.map(|text| text.as_deref().and_then(fleet_lsp_enabled_in))
.collect();
most_specific(&said)
}
fn most_specific(said: &[Option<bool>]) -> bool {
said.iter().rev().find_map(|s| *s).unwrap_or(false)
}
fn fleet_lsp_enabled_in(text: &str) -> Option<bool> {
let v: serde_json::Value = serde_json::from_str(text).ok()?;
v.get("enabledPlugins")?
.as_object()?
.iter()
.find(|(k, _)| k.starts_with("fleet-lsp@"))
.and_then(|(_, on)| on.as_bool())
}
fn plugin_install_path(plugin: &str) -> Option<PathBuf> {
let file = crate::settings::config_dir()?.join("plugins/installed_plugins.json");
install_path_in(&std::fs::read_to_string(file).ok()?, plugin)
}
fn install_path_in(installed: &str, plugin: &str) -> Option<PathBuf> {
let v: serde_json::Value = serde_json::from_str(installed).ok()?;
let prefix = format!("{plugin}@");
let (_, entries) = v
.get("plugins")?
.as_object()?
.iter()
.find(|(k, _)| k.starts_with(&prefix))?;
let path = entries.as_array()?.last()?.get("installPath")?.as_str()?;
Some(PathBuf::from(path))
}
pub fn git_root(target: &Path) -> Option<PathBuf> {
let start = if target.is_dir() {
target
} else {
target.parent()?
};
start
.ancestors()
.find(|d| d.join(".git").exists())
.map(Path::to_path_buf)
}
pub fn on_path(program: &str) -> Option<PathBuf> {
let suffixed = format!("{program}{}", std::env::consts::EXE_SUFFIX);
std::env::split_paths(&std::env::var_os("PATH")?)
.flat_map(|d| [d.join(program), d.join(&suffixed)])
.find(|p| p.is_file())
}
pub enum Told {
First,
Before,
Unrecorded(&'static str),
}
pub fn told(session: &str, key: &str) -> Told {
use std::io::Write;
if !crate::session_state::usable_id(session) || key.contains('\n') {
return Told::Unrecorded("no usable session id or key");
}
let Some(dir) = crate::journal::dir().map(|d| d.join("sessions")) else {
return Told::Unrecorded("no journal directory");
};
let file = dir.join(format!("{session}.lsp"));
if let Ok(seen) = std::fs::read_to_string(&file) {
if seen.lines().any(|l| l == key) {
return Told::Before;
}
}
if std::fs::create_dir_all(&dir).is_err() {
return Told::Unrecorded("the sessions directory could not be created");
}
let written = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&file)
.and_then(|mut f| writeln!(f, "{key}"));
match written {
Ok(()) => Told::First,
Err(_) => Told::Unrecorded("the session record could not be written"),
}
}
pub fn normal(path: &Path) -> PathBuf {
let lexical = lexical(path);
std::fs::canonicalize(&lexical).unwrap_or(lexical)
}
fn lexical(path: &Path) -> PathBuf {
use std::path::Component;
let mut lexical = PathBuf::new();
for part in path.components() {
match part {
Component::CurDir => {}
Component::ParentDir => {
if !lexical.pop() {
lexical.push("..");
}
}
other => lexical.push(other.as_os_str()),
}
}
lexical
}
fn heading_path(line: &str) -> Option<&str> {
if line.is_empty() || line.starts_with(char::is_whitespace) {
return None;
}
let mut rest = line.strip_suffix(':').unwrap_or(line);
for _ in 0..2 {
match rest.rsplit_once(':') {
Some((head, tail)) if !tail.is_empty() && tail.bytes().all(|b| b.is_ascii_digit()) => {
rest = head
}
_ => break,
}
}
(rest.len() < line.len() && is_code(rest)).then_some(rest)
}
pub fn asked_files(call: &crate::payload::LspCall) -> Vec<PathBuf> {
let mut files: Vec<PathBuf> = Vec::new();
if call.operation != "workspaceSymbol" && !call.file_path.as_os_str().is_empty() {
files.push(normal(&call.file_path));
}
let repo = normal(&git_root(&call.cwd).unwrap_or_else(|| call.cwd.clone()));
let base = normal(&call.cwd);
let mut spelled = std::collections::HashSet::new();
for line in call.response.as_deref().unwrap_or_default().lines() {
let Some(named) = heading_path(line) else {
continue;
};
if !spelled.insert(named) {
continue;
}
let named = named.strip_prefix("file://").unwrap_or(named);
let named = Path::new(named);
let abs = if named.is_absolute() || cfg!(windows) {
normal(&call.cwd.join(named))
} else {
lexical(&base.join(named))
};
if abs.starts_with(&repo) && !files.contains(&abs) {
files.push(abs);
}
}
files
}
fn agent_key(agent_id: Option<&str>) -> Option<String> {
match agent_id {
None => Some("main".to_string()),
Some(id) if !id.is_empty() && !id.contains(char::is_whitespace) => Some(id.to_string()),
Some(_) => None,
}
}
fn record_file(session: &str) -> Option<PathBuf> {
if !crate::session_state::usable_id(session) {
return None;
}
Some(
crate::journal::dir()?
.join("sessions")
.join(format!("{session}.lsp")),
)
}
pub fn record_asked(
session: &str,
agent_id: Option<&str>,
files: &[PathBuf],
) -> Result<(), &'static str> {
use std::io::Write;
let Some(agent) = agent_key(agent_id) else {
return Err("no usable agent id");
};
let mut skipped = false;
let lines: Vec<String> = files
.iter()
.filter_map(|f| {
let text = f.to_string_lossy();
if text.contains('\n') {
skipped = true;
return None;
}
Some(format!("asked {agent} {text}"))
})
.collect();
if lines.is_empty() {
return if skipped {
Err("a path with a newline")
} else {
Ok(())
};
}
let Some(file) = record_file(session) else {
return Err("no usable session id or journal directory");
};
if let Some(dir) = file.parent() {
if std::fs::create_dir_all(dir).is_err() {
return Err("the sessions directory could not be created");
}
}
let have = std::fs::read_to_string(&file).unwrap_or_default();
let mut out = String::new();
for line in &lines {
if !have.lines().any(|l| l == line) && !out.lines().any(|l| l == line) {
out.push_str(line);
out.push('\n');
}
}
if !out.is_empty() {
let written = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&file)
.and_then(|mut f| f.write_all(out.as_bytes()));
if written.is_err() {
return Err("the session record could not be written");
}
}
if skipped {
return Err("a path with a newline");
}
Ok(())
}
pub fn asked(session: &str, agent_id: Option<&str>, path: &Path) -> bool {
let Some(agent) = agent_key(agent_id) else {
return false;
};
let Some(file) = record_file(session) else {
return false;
};
let Ok(seen) = std::fs::read_to_string(file) else {
return false;
};
let want = format!("asked {agent} {}", normal(path).to_string_lossy());
seen.lines().any(|l| l == want)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::payload::LspCall;
#[cfg(unix)]
fn fixture(name: &str) -> LspCall {
let raw = std::fs::read_to_string(format!(
"{}/tests/fixtures/lsp-posttooluse-{name}.json",
env!("CARGO_MANIFEST_DIR")
))
.expect("fixture");
match crate::payload::parse(&raw) {
crate::payload::Event::PostLsp(c) => *c,
_ => panic!("not an LSP call"),
}
}
fn call(op: &str, file: &str, response: &str) -> LspCall {
LspCall {
operation: op.into(),
file_path: PathBuf::from("/nonexistent/repo").join(file),
response: Some(response.into()),
cwd: PathBuf::from("/nonexistent/repo"),
session: "s".into(),
permission_mode: "default".into(),
agent_id: None,
}
}
#[cfg(unix)]
#[test]
fn the_fixtures_are_parsed_as_real_payloads() {
let ds = fixture("documentsymbol");
assert_eq!(ds.operation, "documentSymbol");
assert!(ds
.response
.as_deref()
.unwrap()
.starts_with("Document symbols:"));
let files = asked_files(&ds);
assert_eq!(
files,
[PathBuf::from("/home/user/Developer/amont-agent/src/lsp.rs")]
);
let fr = fixture("findreferences");
let names: Vec<String> = asked_files(&fr)
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect();
let root = "/home/user/Developer/amont-agent";
assert_eq!(
names,
[
format!("{root}/src/journal.rs"),
format!("{root}/src/main.rs"),
format!("{root}/src/doctor.rs")
]
);
let err = fixture("error");
assert!(err
.response
.as_deref()
.unwrap()
.starts_with("Error performing"));
assert_eq!(asked_files(&err).len(), 1, "filePath alone");
}
#[test]
fn a_payload_without_a_file_path_still_counts_its_headings() {
let raw = r#"{"hook_event_name":"PostToolUse","tool_name":"LSP","session_id":"s","cwd":"/nonexistent/repo",
"tool_input":{"operation":"workspaceSymbol","query":"Foo"},
"tool_response":{"result":"src/a.rs:\n Foo (Struct) - Line 3"}}"#;
let crate::payload::Event::PostLsp(c) = crate::payload::parse(raw) else {
panic!("a workspaceSymbol call without filePath is still ours");
};
assert_eq!(
asked_files(&c),
[PathBuf::from("/nonexistent/repo/src/a.rs")]
);
let mut hover = *c;
hover.operation = "hover".into();
assert!(asked_files(&hover)
.iter()
.all(|p| !p.as_os_str().is_empty()));
}
#[test]
fn workspace_symbol_ignores_its_file_path_and_reads_headings() {
let c = call(
"workspaceSymbol",
"src/x.rs",
"Found 2 symbols:\n\nrelais/crates/a/src/mod.rs:\n Foo (Struct) - Line 3\n./src/y.rs:12:4\n../outside/z.rs:\n/etc/passwd.rs:\nNOTES.md:",
);
let got = asked_files(&c);
assert_eq!(
got,
[
PathBuf::from("/nonexistent/repo/relais/crates/a/src/mod.rs"),
PathBuf::from("/nonexistent/repo/src/y.rs"),
]
);
}
#[test]
fn hover_counts_its_file_path() {
let c = call("hover", "src/x.rs", "```rust\nfn x()\n```");
assert_eq!(
asked_files(&c),
[PathBuf::from("/nonexistent/repo/src/x.rs")]
);
}
#[test]
fn one_normal_form_for_every_spelling() {
let dir = std::env::temp_dir().join(format!("amont-agent-lsp-nf-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("real/src")).expect("dirs");
std::fs::write(dir.join("real/src/x.rs"), "x").expect("file");
#[cfg(unix)]
std::os::unix::fs::symlink(dir.join("real"), dir.join("link")).expect("symlink");
let direct = normal(&dir.join("real/src/x.rs"));
assert_eq!(normal(&dir.join("real/./src/../src/x.rs")), direct);
#[cfg(unix)]
assert_eq!(normal(&dir.join("link/src/x.rs")), direct);
let mut c = call("findReferences", "src/x.rs", "./src/x.rs:\n Line 1:1");
let via = if cfg!(unix) { "link" } else { "real" };
c.cwd = dir.join(via);
c.file_path = dir.join(via).join("src/x.rs");
assert_eq!(asked_files(&c), [direct]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_path_with_a_newline_is_unrecorded() {
let bad = PathBuf::from("/a/b\nc.rs");
assert!(record_asked("s-1", None, &[bad]).is_err());
assert!(record_asked("s-1", Some("a 1"), &[PathBuf::from("/a.rs")]).is_err());
}
#[test]
fn code_is_known_by_its_extension() {
for p in ["a.rs", "src/x.go", "/a/b.pyi", "c.tsx", "d.mjs", "e.cts"] {
assert!(is_code(p), "{p}");
}
for p in [
"a.md",
"Cargo.toml",
"x.json",
".rs",
"rs",
"a.rs/b",
"notes.RS",
] {
assert!(!is_code(p), "{p}");
}
}
#[test]
fn selectors_name_code() {
for s in ["*.rs", "**/*.{ts,tsx}", "rust", "python", "*.py"] {
assert!(names_code(s), "{s}");
}
for s in ["*.md", "**/*macro*", "yaml", "*.{json,yaml}"] {
assert!(!names_code(s), "{s}");
}
}
#[test]
fn the_payload_names_the_agent() {
assert_eq!(Agent::from_payload(None, None), Agent::Main);
assert_eq!(Agent::from_payload(Some("a1"), None), Agent::Unnamed);
assert_eq!(
Agent::from_payload(Some("a1"), Some("Explore")),
Agent::Named("Explore".into())
);
assert_eq!(Agent::from_payload(Some(""), Some(" ")), Agent::Main);
}
#[test]
fn tools_are_read_inline_and_as_a_list() {
let inline = "---\nname: x\ntools: Read, Grep, LSP\n---\nbody";
assert_eq!(tools_of(inline).unwrap(), ["Read", "Grep", "LSP"]);
let bracket = "---\ntools: [Read, \"Bash\"]\n---\n";
assert_eq!(tools_of(bracket).unwrap(), ["Read", "Bash"]);
let list = "---\nname: x\ntools:\n - Read\n - 'LSP'\nmodel: haiku\n---\n";
assert_eq!(tools_of(list).unwrap(), ["Read", "LSP"]);
assert_eq!(tools_of("---\nname: x\n---\ntools: Read"), None);
assert_eq!(tools_of("no front matter"), None);
}
#[test]
fn the_most_specific_settings_file_decides() {
assert!(!most_specific(&[None, None, None]), "off when nobody says");
assert!(most_specific(&[Some(true), None, None]));
assert!(
!most_specific(&[Some(true), Some(false), None]),
"project wins"
);
assert!(
most_specific(&[Some(false), None, Some(true)]),
"local wins"
);
let on = r#"{"enabledPlugins":{"fleet-lsp@fleet-lsp":true,"x@y":false}}"#;
assert_eq!(fleet_lsp_enabled_in(on), Some(true));
assert_eq!(
fleet_lsp_enabled_in(r#"{"enabledPlugins":{"x@y":true}}"#),
None
);
assert_eq!(
fleet_lsp_enabled_in(r#"{"enabledPlugins":{"fleet-lsp@a":"yes"}}"#),
None
);
assert_eq!(
fleet_lsp_enabled_in(r#"{"enabledPlugins":{"fleet-"#),
None,
"truncated"
);
assert_eq!(fleet_lsp_enabled_in(""), None);
}
#[test]
fn a_plugin_is_found_where_it_was_installed() {
let installed = r#"{"version":2,"plugins":{
"relais@relais-local":[{"installPath":"/old"},{"installPath":"/p/relais/0.12.0"}],
"relaisx@other":[{"installPath":"/wrong"}]}}"#;
assert_eq!(
install_path_in(installed, "relais"),
Some(PathBuf::from("/p/relais/0.12.0")),
"the last entry"
);
assert_eq!(install_path_in(installed, "fleet-lsp"), None);
assert_eq!(
install_path_in(r#"{"plugins":{"relais@x":[]}}"#, "relais"),
None
);
assert_eq!(
install_path_in(r#"{"plugins":"#, "relais"),
None,
"malformed"
);
}
#[test]
fn builtin_agents_are_known_without_a_file() {
let cwd = Path::new("/nonexistent");
assert!(agent_has_tool(&Agent::Main, cwd).is_ok());
assert!(agent_has_tool(&Agent::Named("Explore".into()), cwd).is_ok());
assert!(agent_has_tool(&Agent::Named("statusline-setup".into()), cwd).is_err());
assert!(agent_has_tool(&Agent::Unnamed, cwd).is_err());
assert!(agent_has_tool(&Agent::Named("../x".into()), cwd).is_err());
}
}