Packages
fnord
0.9.23
0.9.40
0.9.39
0.9.38
0.9.37
0.9.36
0.9.35
0.9.34
0.9.33
0.9.32
0.9.31
0.9.30
0.9.29
0.9.28
0.9.27
0.9.26
0.9.25
0.9.24
0.9.23
0.9.22
0.9.21
0.9.20
0.9.19
0.9.18
0.9.17
0.9.16
0.9.15
0.9.14
0.9.13
0.9.12
0.9.11
0.9.10
0.9.9
0.9.8
0.9.7
0.9.6
0.9.5
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.99
0.8.98
0.8.97
0.8.96
0.8.95
0.8.94
0.8.93
0.8.92
0.8.91
0.8.90
0.8.89
0.8.88
0.8.87
0.8.86
0.8.85
0.8.84
0.8.83
0.8.82
0.8.81
0.8.80
0.8.79
0.8.78
0.8.77
0.8.76
0.8.75
0.8.74
0.8.73
0.8.72
0.8.71
0.8.70
0.8.69
0.8.68
0.8.67
0.8.66
0.8.65
0.8.64
0.8.63
0.8.62
0.8.61
0.8.60
0.8.59
0.8.58
0.8.57
0.8.56
0.8.55
0.8.54
0.8.53
0.8.52
0.8.51
0.8.50
0.8.49
0.8.48
0.8.47
0.8.46
0.8.45
0.8.44
0.8.43
0.8.42
0.8.41
0.8.40
0.8.39
0.8.38
0.8.37
0.8.36
0.8.35
0.8.34
0.8.33
0.8.32
0.8.31
0.8.30
0.8.29
0.8.27
0.8.26
0.8.25
0.8.24
0.8.23
0.8.22
0.8.21
0.8.20
0.8.19
0.8.18
0.8.17
0.8.16
0.8.15
0.8.14
0.8.13
0.8.12
0.8.11
0.8.1
0.8.0
0.7.24
0.7.23
0.7.22
0.7.21
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.3
0.7.2
0.7.1
0.7.0
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.1
0.6.0
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.44
0.4.43
0.4.42
0.4.41
0.4.40
0.4.39
0.4.38
0.4.37
0.4.36
0.4.35
0.4.34
0.4.33
0.4.32
0.4.30
0.4.29
0.4.28
0.4.27
0.4.26
0.4.25
0.4.24
0.4.23
0.4.22
0.4.21
0.4.20
0.4.19
0.4.18
0.4.17
0.4.16
0.4.15
0.4.14
0.4.13
0.4.12
0.4.11
0.4.10
0.4.9
0.4.8
0.4.7
0.4.6
0.4.5
0.4.4
0.4.3
0.4.2
0.4.1
0.4.0
0.3.0
0.2.0
0.1.0
AI code archaeology
Current section
Files
Jump to
Current section
Files
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_rules: maybe_load(flags.cursor_rules, fn -> load_cursor_rules(project) end),
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),
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.
"""
@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)
merge_by_name(global, project_skills)
end)
end
@doc """
Load all Claude Code skills (global + project), project overriding global.
Cached per session and per project.
"""
@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)
merge_by_name(global, project_skills)
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 """
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
@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{source_root: source_root}, :cursor_rules) do
any_mdc?(home_dir(".cursor/rules")) or
any_mdc?(project_dir(source_root, ".cursor/rules")) or
legacy_cursorrules_present?(source_root)
end
def has_any_on_disk?(%Store.Project{source_root: source_root}, :cursor_skills) do
any_skill_md?(home_dir(".cursor/skills")) or
any_skill_md?(project_dir(source_root, ".cursor/skills"))
end
def has_any_on_disk?(%Store.Project{source_root: source_root}, :claude_skills) do
any_skill_md?(home_dir(".claude/skills")) or
any_skill_md?(project_dir(source_root, ".claude/skills"))
end
def has_any_on_disk?(%Store.Project{source_root: source_root}, :claude_agents) do
any_agent_md?(home_dir(".claude/agents")) or
any_agent_md?(project_dir(source_root, ".claude/agents"))
end
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