Packages

fnord

0.9.40

AI code archaeology

Current section

Files

Jump to
fnord lib external_configs loader.ex
Raw

lib/external_configs/loader.ex

defmodule ExternalConfigs.Loader do
@moduledoc """
Discovers and loads Cursor rules, Cursor skills, and Claude Code skills
from the user's home directory and the current project's source root.
Project-scoped entries override user/global entries of the same name
(CSS-style layering).
"""
require Logger
# Single Services.Globals slot holding a map: %{{scope, project_name} => result}.
# Cache lives for the invocation only; see clear_cache/0 for the test escape hatch.
@cache_key :external_configs_loader_cache
@type loaded :: %{
cursor_rules: [ExternalConfigs.CursorRule.t()],
cursor_skills: [ExternalConfigs.Skill.t()],
claude_skills: [ExternalConfigs.Skill.t()],
claude_agents: [ExternalConfigs.Agent.t()]
}
@doc """
Load all enabled external configs for the given project, honoring the
per-project `external_configs` settings toggles.
"""
@spec load(Store.Project.t()) :: loaded
def load(%Store.Project{} = project) do
flags = Settings.ExternalConfigs.flags(project.name)
cursor_skills = maybe_load(flags.cursor_skills, fn -> load_cursor_skills(project) end)
claude_skills = maybe_load(flags.claude_skills, fn -> load_claude_skills(project) end)
%{
cursor_rules: maybe_load(flags.cursor_rules, fn -> load_cursor_rules(project) end),
cursor_skills: dedup_cross_flavor(cursor_skills, claude_skills),
claude_skills: claude_skills,
claude_agents: maybe_load(flags.claude_agents, fn -> load_claude_agents(project) end)
}
end
@doc """
Load only cursor rules (global + project) regardless of settings.
Cached per session and per project. The hot path is the Injector,
which reruns this on every file read/write while the rule set is
stable for the life of the invocation.
"""
@spec load_cursor_rules(Store.Project.t()) :: [ExternalConfigs.CursorRule.t()]
def load_cursor_rules(%Store.Project{} = project) do
cached(:cursor_rules, project, fn ->
source_root = project.source_root
global = discover_cursor_rules_dir(home_dir(".cursor/rules"), :global)
project_rules =
discover_cursor_rules_dir(project_dir(source_root, ".cursor/rules"), :project)
legacy = discover_legacy_cursorrules(source_root)
# Project overrides global; legacy is kept alongside (distinct name).
merge_by_name(global, project_rules) ++ legacy
end)
end
@doc """
Load all cursor skills (global + project), project overriding global.
Cached per session and per project.
Skills flagged for self-delegation (see `ExternalConfigs.Skill` -
`fnord_skip`) are dropped here, before any consumer sees them. The drop
is logged once per cache miss with the offending path.
"""
@spec load_cursor_skills(Store.Project.t()) :: [ExternalConfigs.Skill.t()]
def load_cursor_skills(%Store.Project{} = project) do
cached(:cursor_skills, project, fn ->
source_root = project.source_root
global = discover_skills_dir(home_dir(".cursor/skills"), :cursor, :global)
project_skills =
discover_skills_dir(project_dir(source_root, ".cursor/skills"), :cursor, :project)
global
|> merge_by_name(project_skills)
|> reject_self_delegating()
end)
end
@doc """
Load all Claude Code skills (global + project), project overriding global.
Cached per session and per project.
Skills flagged for self-delegation (see `ExternalConfigs.Skill` -
`fnord_skip`) are dropped here, before any consumer sees them. The drop
is logged once per cache miss with the offending path.
"""
@spec load_claude_skills(Store.Project.t()) :: [ExternalConfigs.Skill.t()]
def load_claude_skills(%Store.Project{} = project) do
cached(:claude_skills, project, fn ->
source_root = project.source_root
global = discover_skills_dir(home_dir(".claude/skills"), :claude, :global)
project_skills =
discover_skills_dir(project_dir(source_root, ".claude/skills"), :claude, :project)
global
|> merge_by_name(project_skills)
|> reject_self_delegating()
end)
end
# Drops skills that are shims back to fnord. Filters at the loader so all
# consumers (run_skill, the catalog, listing commands) see the same view.
# Logging happens here rather than at parse time so it fires once per
# cache miss, not on every `from_dir` call.
defp reject_self_delegating(skills) do
Enum.reject(skills, fn skill ->
case skill do
%ExternalConfigs.Skill{fnord_skip: true, fnord_skip_reason: reason, path: path} ->
Logger.info(
"Skipping self-delegating #{skill.flavor} skill " <>
"#{inspect(skill.name)} (#{reason}): #{path}"
)
true
_ ->
false
end
end)
end
@doc """
Load all Claude Code subagents (global + project), project overriding
global by name. Cached per session and per project.
"""
@spec load_claude_agents(Store.Project.t()) :: [ExternalConfigs.Agent.t()]
def load_claude_agents(%Store.Project{} = project) do
cached(:claude_agents, project, fn ->
source_root = project.source_root
global = discover_agents_dir(home_dir(".claude/agents"), :global)
project_agents = discover_agents_dir(project_dir(source_root, ".claude/agents"), :project)
merge_by_name(global, project_agents)
end)
end
@doc """
Remove cursor skills that duplicate a claude skill, preferring claude.
Two dedup passes run in order:
1. **Inode identity** — cursor skills whose on-disk directory resolves to
the same inode as a claude skill are dropped. Handles individual
directory symlinks and whole-tree symlinks at any depth. `File.stat/1`
follows symlinks; cursor entries that cannot be stat'd are kept
(conservative: don't silently drop on filesystem errors).
2. **Name identity** — cursor skills whose name matches a claude skill are
dropped even when stored at a distinct path. A same-name clash across
flavors almost always means one is a copy or re-export of the other.
"""
@spec dedup_cross_flavor([ExternalConfigs.Skill.t()], [ExternalConfigs.Skill.t()]) ::
[ExternalConfigs.Skill.t()]
def dedup_cross_flavor(cursor_skills, claude_skills) do
# Phase 1: inode identity — same real directory regardless of symlink depth.
# File.stat/1 follows symlinks, so both sides resolve to the real inode.
claude_dir_ids =
claude_skills
|> Enum.flat_map(fn skill ->
case File.stat(Path.dirname(skill.path)) do
{:ok, %{inode: inode, major_device: dev}} -> [{dev, inode}]
_ -> []
end
end)
|> MapSet.new()
inode_deduped =
Enum.reject(cursor_skills, fn skill ->
case File.stat(Path.dirname(skill.path)) do
{:ok, %{inode: inode, major_device: dev}} ->
MapSet.member?(claude_dir_ids, {dev, inode})
_ ->
false
end
end)
# Phase 2: name identity — cursor skill with same name as a claude skill
# is a conceptual duplicate even when stored at a distinct path.
claude_names = MapSet.new(claude_skills, & &1.name)
Enum.reject(inode_deduped, fn skill -> MapSet.member?(claude_names, skill.name) end)
end
@doc """
Clears all external-configs loader caches. Call from tests that mutate
the on-disk rules/skills mid-test; production code should never need
this since the escript VM is short-lived.
"""
@spec clear_cache() :: :ok
def clear_cache() do
Services.Globals.put_env(:fnord, @cache_key, %{})
:ok
end
# Where a detected file lives. `:global` is the user's home directory
# (~/.cursor, ~/.claude) and applies to every project; `:project` is the
# repo's source root; `:legacy` is the project-root `.cursorrules` file.
# The startup nudge uses this to tell the user whether they're about to
# surface their personal global configs or repo-local ones.
@type on_disk_scope :: :global | :project | :legacy
@doc """
Cheap on-disk presence check, used by the coordinator's startup nudge
to detect "feature is off, but the files are sitting right there."
Skips parsing; just asks whether any candidate file exists under the
global or project search paths for the given source.
"""
@spec has_any_on_disk?(Store.Project.t(), Settings.ExternalConfigs.source()) :: boolean()
def has_any_on_disk?(%Store.Project{} = project, source) do
on_disk_scopes(project, source) != []
end
@doc """
Reports which scopes (`:global`, `:project`, `:legacy`) actually have
candidate files on disk for the given source. Empty list means nothing
is present. Like `has_any_on_disk?/2` this skips parsing and only probes
for file existence, but it preserves *where* the hits are so the startup
nudge can tell the user whether the detected files are global (in their
home directory, shared across all projects) or local to this repo.
"""
@spec on_disk_scopes(Store.Project.t(), Settings.ExternalConfigs.source()) :: [on_disk_scope()]
def on_disk_scopes(%Store.Project{source_root: source_root}, :cursor_rules) do
scopes_present([
{:global, any_mdc?(home_dir(".cursor/rules"))},
{:project, any_mdc?(project_dir(source_root, ".cursor/rules"))},
{:legacy, legacy_cursorrules_present?(source_root)}
])
end
def on_disk_scopes(%Store.Project{source_root: source_root}, :cursor_skills) do
scopes_present([
{:global, any_skill_md?(home_dir(".cursor/skills"))},
{:project, any_skill_md?(project_dir(source_root, ".cursor/skills"))}
])
end
def on_disk_scopes(%Store.Project{source_root: source_root}, :claude_skills) do
scopes_present([
{:global, any_skill_md?(home_dir(".claude/skills"))},
{:project, any_skill_md?(project_dir(source_root, ".claude/skills"))}
])
end
def on_disk_scopes(%Store.Project{source_root: source_root}, :claude_agents) do
scopes_present([
{:global, any_agent_md?(home_dir(".claude/agents"))},
{:project, any_agent_md?(project_dir(source_root, ".claude/agents"))}
])
end
# Keeps only the scopes whose probe returned true, preserving listed order
# (global before project before legacy) for stable output.
defp scopes_present(pairs), do: for({scope, true} <- pairs, do: scope)
defp any_mdc?(nil), do: false
defp any_mdc?(dir) do
File.dir?(dir) and
dir
|> Path.join("**/*.mdc")
|> Path.wildcard()
|> Enum.any?()
end
defp legacy_cursorrules_present?(nil), do: false
defp legacy_cursorrules_present?(root), do: File.regular?(Path.join(root, ".cursorrules"))
defp any_skill_md?(nil), do: false
defp any_skill_md?(dir) do
case File.ls(dir) do
{:ok, entries} ->
entries
|> Enum.map(&Path.join(dir, &1))
|> Enum.any?(fn entry ->
File.dir?(entry) and File.regular?(Path.join(entry, "SKILL.md"))
end)
_ ->
false
end
end
defp any_agent_md?(nil), do: false
defp any_agent_md?(dir) do
File.dir?(dir) and
dir
|> Path.join("*.md")
|> Path.wildcard()
|> Enum.any?()
end
defp discover_agents_dir(nil, _source), do: []
defp discover_agents_dir(dir, source) do
case File.dir?(dir) do
false ->
[]
true ->
dir
|> Path.join("*.md")
|> Path.wildcard()
|> Enum.sort()
|> Enum.map(&load_agent_file(&1, source))
|> Enum.reject(&is_nil/1)
end
end
defp load_agent_file(path, source) do
case ExternalConfigs.Agent.from_file(path, source) do
{:ok, agent} ->
agent
{:error, reason} ->
Logger.warning("Failed to load Claude agent #{path}: #{inspect(reason)}")
nil
end
end
defp maybe_load(true, fun), do: fun.()
defp maybe_load(_disabled, _fun), do: []
# Memoize the result of `fun` under {scope, project_name, source_root,
# home_dir} in Services.Globals. The scope lets three different loaders
# share one map without colliding. source_root and home_dir are in the
# key so tests that rebind HOME or rotate a project's source directory
# don't see stale cache hits from a prior run inside the same VM.
defp cached(scope, %Store.Project{name: project_name, source_root: source_root}, fun) do
cache = Services.Globals.get_env(:fnord, @cache_key, %{})
key = {scope, project_name, source_root, Settings.get_user_home()}
case Map.fetch(cache, key) do
{:ok, cached_result} ->
cached_result
:error ->
result = fun.()
Services.Globals.put_env(:fnord, @cache_key, Map.put(cache, key, result))
result
end
end
defp project_dir(nil, _sub), do: nil
defp project_dir(root, sub), do: Path.join(root, sub)
# Routes through Settings.get_user_home/0 rather than Path.expand("~").
# Path.expand resolves via init:get_argument(home), frozen at VM init
# and unaware of runtime $HOME overrides. The test harness relies on
# the runtime override.
defp home_dir(sub), do: Path.join(Settings.get_user_home(), sub)
defp discover_cursor_rules_dir(nil, _source), do: []
defp discover_cursor_rules_dir(dir, source) do
case File.dir?(dir) do
false ->
[]
true ->
dir
|> Path.join("**/*.mdc")
|> Path.wildcard()
|> Enum.sort()
|> Enum.map(&load_rule_file(&1, source))
|> Enum.reject(&is_nil/1)
end
end
defp load_rule_file(path, source) do
case ExternalConfigs.CursorRule.from_file(path, source) do
{:ok, rule} ->
rule
{:error, reason} ->
Logger.warning("Failed to load cursor rule #{path}: #{inspect(reason)}")
nil
end
end
defp discover_legacy_cursorrules(nil), do: []
defp discover_legacy_cursorrules(source_root) do
path = Path.join(source_root, ".cursorrules")
case File.regular?(path) do
true ->
case ExternalConfigs.CursorRule.from_legacy_file(path) do
{:ok, rule} ->
[rule]
{:error, reason} ->
Logger.warning("Failed to load legacy .cursorrules #{path}: #{inspect(reason)}")
[]
end
false ->
[]
end
end
defp discover_skills_dir(nil, _flavor, _source), do: []
defp discover_skills_dir(dir, flavor, source) do
case File.dir?(dir) do
false ->
[]
true ->
dir
|> File.ls!()
|> Enum.sort()
|> Enum.map(&Path.join(dir, &1))
|> Enum.filter(&File.dir?/1)
|> Enum.map(&load_skill_dir(&1, flavor, source))
|> Enum.reject(&is_nil/1)
end
end
defp load_skill_dir(dir, flavor, source) do
case ExternalConfigs.Skill.from_dir(dir, flavor, source) do
{:ok, skill} ->
skill
{:error, :enoent} ->
# No SKILL.md in this directory; ignore silently.
nil
{:error, reason} ->
Logger.warning("Failed to load #{flavor} skill from #{dir}: #{inspect(reason)}")
nil
end
end
# Project entries override global entries with the same name. The list is
# returned sorted by name for stable output.
defp merge_by_name(global, project) do
by_name = Map.new(global, &{&1.name, &1})
project
|> Enum.reduce(by_name, fn item, acc -> Map.put(acc, item.name, item) end)
|> Map.values()
|> Enum.sort_by(& &1.name)
end
end