Current section

Files

Jump to
claude lib mix tasks claude.install.ex
Raw

lib/mix/tasks/claude.install.ex

defmodule Mix.Tasks.Claude.Install do
@shortdoc "Setup Claude for your Elixir project"
@moduledoc """
Setup Claude for your Elixir project.
This task is automatically run when you execute:
mix igniter.install claude
It sets up Claude Code integration for your Elixir project by:
1. Creating a .claude.exs configuration file for project-specific settings
2. Installing all Claude hooks for auto-formatting and compilation checking
3. Adding usage_rules dependency for better LLM integration
4. Syncing usage rules to CLAUDE.md for enhanced code assistance
5. Generating any configured subagents for specialized assistance
6. Configuring MCP servers (like Tidewave for Phoenix projects)
7. Ensuring your project is properly configured for Claude Code integration
## Example
```sh
mix igniter.install claude
```
"""
use Igniter.Mix.Task
@meta_agent_config %{
name: "Meta Agent",
description:
"Generates new, complete Claude Code subagent from user descriptions. Use PROACTIVELY when users ask to create new subagents. Expert agent architect.",
prompt: """
# Purpose
Your sole purpose is to act as an expert agent architect. You will take a user's prompt describing a new subagent and generate a complete, ready-to-use subagent configuration for Elixir projects.
## Important Documentation
You MUST reference these official Claude Code documentation pages to ensure accurate subagent generation:
- **Subagents Guide**: https://docs.anthropic.com/en/docs/claude-code/sub-agents
- **Settings Reference**: https://docs.anthropic.com/en/docs/claude-code/settings
- **Hooks System**: https://docs.anthropic.com/en/docs/claude-code/hooks
Use the WebSearch tool to look up specific details from these docs when needed, especially for:
- Tool naming conventions and available tools
- Subagent YAML frontmatter format
- Best practices for descriptions and delegation
- Settings.json structure and configuration options
## Instructions
When invoked, you must follow these steps:
1. **Analyze Input:** Carefully analyze the user's request to understand the new agent's purpose, primary tasks, and domain
- Use WebSearch to consult the subagents documentation if you need clarification on best practices
2. **Devise a Name:** Create a descriptive name (e.g., "Database Migration Agent", "API Integration Agent")
3. **Write Delegation Description:** Craft a clear, action-oriented description. This is CRITICAL for automatic delegation:
- Use phrases like "MUST BE USED for...", "Use PROACTIVELY when...", "Expert in..."
- Be specific about WHEN to invoke
- Avoid overlap with existing agents
4. **Infer Necessary Tools:** Based on tasks, determine MINIMAL tools required:
- Code reviewer: `[:read, :grep, :glob]`
- Refactorer: `[:read, :edit, :multi_edit, :grep]`
- Test runner: `[:read, :edit, :bash, :grep]`
- Remember: No `:task` prevents delegation loops
5. **Construct System Prompt:** Design the prompt considering:
- **Clean Slate**: Agent has NO memory between invocations
- **Context Discovery**: Specify exact files/patterns to check first
- **Performance**: Avoid reading entire directories
- **Self-Contained**: Never assume main chat context
6. **Check for Issues:**
- Read current `.claude.exs` to avoid description conflicts
- Ensure tools match actual needs (no extras)
7. **Generate Configuration:** Add the new subagent to `.claude.exs`:
%{
name: "Generated Name",
description: "Generated action-oriented description",
prompt: \"""
# Purpose
You are [role definition].
## Instructions
When invoked, follow these steps:
1. [Specific startup sequence]
2. [Core task execution]
3. [Validation/verification]
## Context Discovery
Since you start fresh each time:
- Check: [specific files first]
- Pattern: [efficient search patterns]
- Limit: [what NOT to read]
## Best Practices
- [Domain-specific guidelines]
- [Performance considerations]
- [Common pitfalls to avoid]
\""",
tools: [inferred tools]
}
8. **Final Actions:**
- Update `.claude.exs` with the new configuration
- Instruct user to run `mix claude.install`
## Key Principles
**Avoid Common Pitfalls:**
- Context overflow: "Read all files in lib/" → "Read only specific module"
- Ambiguous delegation: "Database expert" → "MUST BE USED for Ecto migrations"
- Hidden dependencies: "Continue refactoring" → "Refactor to [explicit patterns]"
- Tool bloat: Only include tools actually needed
**Performance Patterns:**
- Targeted reads over directory scans
- Specific grep patterns over broad searches
- Limited context gathering on startup
## Output Format
Your response should:
1. Show the complete subagent configuration to add
2. Explain key design decisions
3. Warn about any potential conflicts
4. Remind to run `mix claude.install`
""",
tools: [:write, :read, :edit, :multi_edit, :bash, :web_search]
}
@default_tidewave_port 4000
@tidewave_setup_instructions """
Tidewave integrates with your Phoenix application:
1. Add to your deps in mix.exs: {:tidewave, "~> 0.2.0"}
2. Run: mix deps.get
3. Configure in config/dev.exs (see Tidewave docs)
4. Start your Phoenix server: mix phx.server
5. MCP endpoint will be at: http://localhost:PORT/tidewave/mcp
"""
@tool_atom_to_string %{
bash: "Bash",
edit: "Edit",
glob: "Glob",
grep: "Grep",
ls: "LS",
multi_edit: "MultiEdit",
notebook_edit: "NotebookEdit",
notebook_read: "NotebookRead",
read: "Read",
task: "Task",
todo_write: "TodoWrite",
web_fetch: "WebFetch",
web_search: "WebSearch",
write: "Write"
}
@impl Igniter.Mix.Task
def info(_argv, _composing_task) do
%Igniter.Mix.Task.Info{
group: :claude,
example: "mix igniter.install claude",
only: [:dev],
dep_opts: [runtime: false]
}
end
@impl Igniter.Mix.Task
def igniter(igniter) do
igniter
|> Igniter.assign(claude_exs_path: ".claude.exs", claude_dir_path: ".claude")
|> create_claude_exs()
|> add_usage_rules_dependency()
|> install_hooks()
|> setup_phoenix_mcp()
|> sync_usage_rules()
|> generate_subagents()
|> setup_tidewave_if_configured()
|> add_claude_exs_to_formatter()
end
defp create_claude_exs(igniter) do
path = igniter.assigns[:claude_exs_path]
if Igniter.exists?(igniter, path) do
igniter
|> ensure_default_hooks(path)
|> check_meta_agent_and_notify(path)
else
Igniter.create_new_file(
igniter,
path,
claude_exs_template()
)
end
end
defp add_usage_rules_dependency(igniter) do
Igniter.Project.Deps.add_dep(
igniter,
{:usage_rules, "~> 0.1", only: [:dev]},
on_exists: :skip
)
end
defp add_claude_exs_to_formatter(igniter) do
# Check if .formatter.exs exists
if Igniter.exists?(igniter, ".formatter.exs") do
# Read the current formatter file to check if .claude.exs is already included
igniter
|> Igniter.add_notice("""
To format .claude.exs files, add \".claude.exs\" to your formatter inputs:
# .formatter.exs
[
inputs: [".claude.exs", "{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]
Then run `mix format` to apply the formatting.
""")
else
# Create a new .formatter.exs with .claude.exs included
default_formatter = """
# Used by "mix format"
[
inputs: [".claude.exs", "{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]
"""
igniter
|> Igniter.create_new_file(".formatter.exs", default_formatter)
end
end
defp ensure_default_hooks(igniter, path) do
default_hooks = [
Claude.Hooks.PostToolUse.ElixirFormatter,
Claude.Hooks.PostToolUse.CompilationChecker,
Claude.Hooks.PreToolUse.PreCommitCheck
]
case read_and_eval_claude_exs(igniter, path) do
{:ok, config} when is_map(config) ->
existing_hooks = Map.get(config, :hooks, [])
missing_hooks = default_hooks -- existing_hooks
if missing_hooks != [] do
igniter
|> Igniter.add_notice("""
Your .claude.exs is missing some default hooks. Add these to enable core functionality:
hooks: [
#{Enum.map_join(missing_hooks, ",\n ", &" #{inspect(&1)}")}
],
""")
else
igniter
end
_ ->
igniter
end
end
defp read_hooks_from_claude_exs(igniter) do
claude_exs_path = igniter.assigns[:claude_exs_path]
if Igniter.exists?(igniter, claude_exs_path) do
case read_and_eval_claude_exs(igniter, claude_exs_path) do
{:ok, config} when is_map(config) ->
hooks = Map.get(config, :hooks, [])
hooks
|> List.wrap()
|> Enum.map(&normalize_hook_module/1)
|> Enum.reject(&is_nil/1)
_ ->
[]
end
else
[]
end
end
defp normalize_hook_module(hook_module) when is_atom(hook_module) do
if Code.ensure_loaded?(hook_module) and function_exported?(hook_module, :config, 0) do
script_name = hook_module_to_script_name(hook_module)
script_path = ".claude/hooks/#{script_name}"
description =
if function_exported?(hook_module, :description, 0) do
hook_module.description()
else
"Custom hook"
end
event_type =
if function_exported?(hook_module, :__hook_event__, 0) do
hook_module.__hook_event__()
else
:post_tool_use
end
matchers =
if function_exported?(hook_module, :__hook_matcher__, 0) do
matcher = hook_module.__hook_matcher__()
String.split(matcher, "|")
else
[]
end
{hook_module, script_path, event_type, matchers, description}
else
nil
end
end
defp normalize_hook_module(_), do: nil
defp hook_module_to_script_name(module) do
module
|> Module.split()
|> List.last()
|> Macro.underscore()
|> Kernel.<>(".exs")
end
defp format_meta_agent_for_template do
# Format the Meta Agent config for inclusion in the template
inspect(@meta_agent_config, pretty: true, limit: :infinity, printable_limit: :infinity)
end
defp format_meta_agent_for_notice do
# Format the Meta Agent config for display in notices
# Build it manually to show proper formatting
name = inspect(@meta_agent_config.name)
description = inspect(@meta_agent_config.description)
tools = inspect(@meta_agent_config.tools)
prompt = @meta_agent_config.prompt
# Manually build the string to preserve formatting
" %{\n" <>
" name: #{name},\n" <>
" description: #{description},\n" <>
" prompt: \"\"\"\n#{prompt}\"\"\",\n" <>
" tools: #{tools}\n" <>
" }"
end
defp claude_exs_template do
meta_agent_str = format_meta_agent_for_template()
"""
# .claude.exs - Claude configuration for this project
# This file is evaluated when Claude reads your project settings
# and merged with .claude/settings.json (this file takes precedence)
# You can configure various aspects of Claude's behavior here:
# - Project metadata and context
# - Custom behaviors and preferences
# - Development workflow settings
# - Code generation patterns
# - And more as Claude evolves
%{
# Hooks that run during Claude Code operations
hooks: [
# Default hooks - these provide core functionality
Claude.Hooks.PostToolUse.ElixirFormatter, # Automatically formats Elixir files after editing
Claude.Hooks.PostToolUse.CompilationChecker, # Checks for compilation errors after editing
Claude.Hooks.PreToolUse.PreCommitCheck, # Validates code before git commits
# Optional hooks - uncomment to enable
# Claude.Hooks.PostToolUse.RelatedFiles, # Suggests updating test files when implementation changes
],
# MCP servers (Tidewave is automatically configured for Phoenix projects)
# mcp_servers: [:tidewave],
#
# You can also specify custom configuration like port:
# mcp_servers: [
# {:tidewave, [port: 5000]}
# ],
#
# To disable a server without removing it:
# mcp_servers: [
# {:tidewave, [port: 4000, enabled?: false]}
# ],
# Subagents provide specialized expertise with their own context
subagents: [
#{meta_agent_str}
]
}
"""
end
defp install_hooks(igniter) do
settings_path = Path.join(igniter.assigns.claude_dir_path, "settings.json")
relative_settings_path = Path.relative_to_cwd(settings_path)
claude_exs_hooks = read_hooks_from_claude_exs(igniter)
igniter
|> Igniter.assign(claude_exs_hooks: claude_exs_hooks)
|> install_hooks_claude_code_hooks_dir()
|> install_hooks_to_claude_code_settings(relative_settings_path)
|> add_hooks_notice(relative_settings_path, claude_exs_hooks)
end
defp add_hooks_notice(igniter, relative_settings_path, hooks) do
hooks_message =
if hooks == [] do
"No hooks configured in .claude.exs"
else
format_hooks_list(hooks)
end
igniter
|> Igniter.add_notice("""
Claude hooks have been installed to #{relative_settings_path}
Hook scripts generated in .claude/hooks/
Enabled hooks:
#{hooks_message}
""")
end
defp install_hooks_to_claude_code_settings(igniter, relative_settings_path) do
initial_settings = build_hooks_settings(%{}, igniter)
initial_content = Jason.encode!(initial_settings, pretty: true) <> "\n"
igniter
|> Igniter.create_or_update_file(relative_settings_path, initial_content, fn source ->
content = Rewrite.Source.get(source, :content)
new_content =
case Jason.decode(content) do
{:ok, existing_settings} ->
updated_settings = build_hooks_settings(existing_settings, igniter)
Jason.encode!(updated_settings, pretty: true) <> "\n"
{:error, _} ->
initial_content
end
Rewrite.Source.update(source, :content, new_content)
end)
end
defp build_hooks_settings(settings_map, igniter) when is_map(settings_map) do
cleaned_settings = remove_all_hooks(settings_map)
cleaned_hooks = Map.get(cleaned_settings, "hooks", %{})
all_hooks = igniter.assigns[:claude_exs_hooks] || []
hooks_by_event_and_matcher =
all_hooks
|> Enum.group_by(fn {_module, _script, event, matchers, _desc} ->
event_type = to_event_type_string(event)
matcher = format_matcher(matchers)
{event_type, matcher}
end)
new_hooks =
Enum.reduce(hooks_by_event_and_matcher, cleaned_hooks, fn {{event_type, matcher}, hooks},
acc ->
existing_matchers = Map.get(acc, event_type, [])
matcher_index =
Enum.find_index(existing_matchers, fn m ->
Map.get(m, "matcher") == matcher
end)
hook_configs =
Enum.map(hooks, fn {_module, script_path, _event, _matchers, _desc} ->
%{
"type" => "command",
"command" => "cd $CLAUDE_PROJECT_DIR && elixir #{script_path}"
}
end)
if matcher_index do
updated_matchers =
List.update_at(existing_matchers, matcher_index, fn matcher_obj ->
existing_hooks = Map.get(matcher_obj, "hooks", [])
Map.put(matcher_obj, "hooks", existing_hooks ++ hook_configs)
end)
Map.put(acc, event_type, updated_matchers)
else
new_matcher_obj = %{
"matcher" => matcher,
"hooks" => hook_configs
}
Map.put(acc, event_type, existing_matchers ++ [new_matcher_obj])
end
end)
Map.put(settings_map, "hooks", new_hooks)
end
defp remove_all_hooks(settings) when is_map(settings) do
hooks = Map.get(settings, "hooks", %{})
updated_hooks = remove_old_claude_hooks(hooks)
if updated_hooks == %{} do
Map.delete(settings, "hooks")
else
Map.put(settings, "hooks", updated_hooks)
end
end
defp remove_old_claude_hooks(hooks_config) do
claude_patterns = [
"mix claude hooks run",
".claude/hooks/elixir_formatter.exs",
".claude/hooks/compilation_checker.exs",
".claude/hooks/pre_commit_check.exs",
".claude/hooks/related_files.exs",
~r{\.claude/hooks/.*\.exs$}
]
hooks_config
|> Enum.map(fn
{event_type, matchers} when is_list(matchers) ->
updated_matchers =
matchers
|> Enum.map(fn matcher_obj ->
hooks_list = Map.get(matcher_obj, "hooks", [])
filtered_hooks =
hooks_list
|> Enum.reject(fn hook ->
command = Map.get(hook, "command", "")
Enum.any?(claude_patterns, fn pattern ->
case pattern do
%Regex{} -> Regex.match?(pattern, command)
string -> String.contains?(command, string)
end
end)
end)
if filtered_hooks == [] do
:remove
else
Map.put(matcher_obj, "hooks", filtered_hooks)
end
end)
|> Enum.reject(&(&1 == :remove))
if updated_matchers == [] do
{event_type, :remove}
else
{event_type, updated_matchers}
end
{event_type, other} ->
{event_type, other}
end)
|> Enum.reject(fn {_, value} -> value == :remove end)
|> Map.new()
end
defp to_event_type_string(event_atom) do
case event_atom do
:pre_tool_use -> "PreToolUse"
:post_tool_use -> "PostToolUse"
:user_prompt_submit -> "UserPromptSubmit"
:notification -> "Notification"
:stop -> "Stop"
:subagent_stop -> "SubagentStop"
:pre_compact -> "PreCompact"
_ -> Atom.to_string(event_atom)
end
end
defp format_matcher(nil), do: "*"
defp format_matcher(".*"), do: "*"
defp format_matcher(matcher) when is_binary(matcher), do: matcher
defp format_matcher(matchers) when is_list(matchers) do
matchers
|> Enum.map(&to_string/1)
|> Enum.join("|")
end
defp format_matcher(matcher), do: to_string(matcher)
defp format_hooks_list(custom_hooks) do
custom_hooks
|> Enum.map(fn {_module, _script, _event, _matchers, desc} ->
" • #{desc}"
end)
|> Enum.join("\n")
|> case do
"" -> " No hooks installed"
hooks -> hooks
end
end
defp install_hooks_claude_code_hooks_dir(igniter) do
claude_dep = get_claude_dependency()
all_hooks = igniter.assigns[:claude_exs_hooks] || []
Enum.reduce(all_hooks, igniter, fn {module, script_path, _event, _matchers, _desc}, acc ->
content = generate_hook_script(module, claude_dep)
Igniter.create_or_update_file(acc, script_path, content, fn source ->
Rewrite.Source.update(source, :content, content)
end)
end)
end
defp generate_hook_script(hook_module, claude_dep) do
module_name = Module.split(hook_module) |> Enum.join(".")
deps = "[#{claude_dep}, {:jason, \"~> 1.4\"}, {:igniter, \"~> 0.6\"}]"
description =
if function_exported?(hook_module, :description, 0) do
hook_module.description()
else
"Claude Code hook"
end
"""
#!/usr/bin/env elixir
# Hook script for #{description}
# This script is called with JSON input via stdin from Claude Code
# Install dependencies
Mix.install(#{deps})
# Read JSON from stdin
input = IO.read(:stdio, :eof)
# Run the hook module
# The hook now handles JSON output internally using JsonOutput.write_and_exit/1
# which will output JSON and exit with code 0
#{module_name}.run(input)
# If we reach here, the hook didn't exit properly, so we exit with success
System.halt(0)
"""
end
defp get_claude_dependency do
if Mix.Project.get() == Claude.MixProject do
"{:claude, path: \".\"}"
else
case get_claude_version_from_deps() do
{:ok, version} -> "{:claude, \"#{version}\"}"
:error -> "{:claude, \"~> 0.1\"}"
end
end
end
defp get_claude_version_from_deps do
case get_installed_claude_version() do
{:ok, version} ->
{:ok, "~> #{version}"}
:error ->
deps = Mix.Project.config()[:deps] || []
case List.keyfind(deps, :claude, 0) do
{:claude, version} when is_binary(version) ->
{:ok, version}
{:claude, version, _opts} when is_binary(version) ->
{:ok, version}
_ ->
:error
end
end
end
defp get_installed_claude_version do
case Mix.Project.deps_paths() do
%{claude: claude_path} when is_binary(claude_path) ->
mix_file = Path.join(claude_path, "mix.exs")
with true <- File.exists?(mix_file),
{:ok, content} <- File.read(mix_file),
[_, version] <- Regex.run(~r/@version\s+"([^"]+)"/, content) do
{:ok, version}
else
_ -> :error
end
_ ->
:error
end
end
defp setup_phoenix_mcp(igniter) do
if Igniter.Project.Deps.has_dep?(igniter, :phoenix) do
add_tidewave_to_project(igniter)
else
igniter
end
end
defp add_tidewave_to_project(igniter) do
igniter
|> Igniter.add_notice("""
Phoenix detected! To enable Tidewave MCP server, add the following to your .claude.exs file:
%{
mcp_servers: [:tidewave]
}
Or with a custom port:
%{
mcp_servers: [{:tidewave, [port: 5000]}]
}
Tidewave provides Phoenix-specific tools for working with your application.
""")
end
defp sync_usage_rules(igniter) do
igniter
|> Igniter.add_notice("""
Syncing usage rules to CLAUDE.md...
This will help Claude Code understand how to use your project's dependencies.
""")
|> Igniter.add_task("usage_rules.sync", [
"CLAUDE.md",
"--all",
"--inline",
"usage_rules:all",
"--link-to-folder",
"deps"
])
end
defp generate_subagents(igniter) do
claude_exs_path = ".claude.exs"
if Igniter.exists?(igniter, claude_exs_path) do
case read_and_eval_claude_exs(igniter, claude_exs_path) do
{:ok, config} when is_map(config) ->
subagents = Map.get(config, :subagents, [])
if is_list(subagents) and subagents != [] do
process_subagents_directly(igniter, subagents)
else
igniter
end
{:ok, _} ->
igniter
{:error, reason} ->
igniter
|> Igniter.add_warning("Failed to load .claude.exs: #{reason}")
end
else
igniter
end
end
defp process_subagents_directly(igniter, subagent_configs) do
results =
Enum.map(subagent_configs, fn config ->
case validate_and_generate_subagent(config) do
{:ok, {name, path, content}} ->
{:ok, {name, path, content}}
{:error, reason} ->
{:error, {config[:name] || "Unknown", reason}}
end
end)
errors = Enum.filter(results, &match?({:error, _}, &1))
if errors == [] do
successful = Enum.map(results, fn {:ok, result} -> result end)
igniter
|> add_generated_files(successful)
|> Igniter.add_notice(format_subagents_success_message(successful))
else
igniter
|> Igniter.add_warning(format_subagents_error_message(errors))
end
end
defp validate_and_generate_subagent(config) do
with :ok <- validate_subagent_config(config),
{:ok, enhanced_prompt} <- maybe_add_usage_rules(config) do
name = config.name
filename = subagent_filename(name)
relative_path = Path.join([".claude", "agents", filename])
content =
generate_subagent_markdown(%{
name: name,
description: config.description,
prompt: enhanced_prompt,
tools: config[:tools] || []
})
{:ok, {name, relative_path, content}}
end
end
defp validate_subagent_config(config) do
required_keys = [:name, :description, :prompt]
missing_keys = required_keys -- Map.keys(config)
if missing_keys == [] do
# Validate tools if present
case config[:tools] do
nil ->
:ok
tools when is_list(tools) ->
if Enum.all?(tools, &is_atom/1) do
:ok
else
{:error, "Tools must be a list of atoms"}
end
_ ->
{:error, "Tools must be a list"}
end
else
{:error, "Missing required keys: #{inspect(missing_keys)}"}
end
end
defp maybe_add_usage_rules(config) do
case config[:usage_rules] do
nil ->
{:ok, config.prompt}
rules when is_list(rules) ->
usage_rules_content = load_usage_rules(rules)
enhanced_prompt = config.prompt <> "\n\n## Usage Rules\n\n" <> usage_rules_content
{:ok, enhanced_prompt}
_ ->
{:error, "usage_rules must be a list"}
end
end
defp load_usage_rules(rules) do
rules
|> Enum.map(&load_single_usage_rule/1)
|> Enum.reject(&is_nil/1)
|> Enum.join("\n\n")
end
defp load_single_usage_rule(rule) when is_atom(rule) do
# Look for deps/package_name/usage-rules.md
path = Path.join(["deps", Atom.to_string(rule), "usage-rules.md"])
case File.read(path) do
{:ok, content} -> "### #{rule}\n\n#{content}"
_ -> nil
end
end
defp load_single_usage_rule(rule) when is_binary(rule) do
case String.split(rule, ":", parts: 2) do
[package] ->
# Same as atom version
load_single_usage_rule(String.to_atom(package))
[package, "all"] ->
# Load all usage-rules files in the package
deps_path = Path.join("deps", package)
usage_rules_path = Path.join(deps_path, "usage-rules")
if File.dir?(usage_rules_path) do
usage_rules_path
|> File.ls!()
|> Enum.filter(&String.ends_with?(&1, ".md"))
|> Enum.map(fn file ->
path = Path.join(usage_rules_path, file)
case File.read(path) do
{:ok, content} -> "### #{package}:#{Path.rootname(file)}\n\n#{content}"
_ -> nil
end
end)
|> Enum.reject(&is_nil/1)
|> Enum.join("\n\n")
else
nil
end
[package, sub_rule] ->
# Load specific sub-rule
path = Path.join(["deps", package, "usage-rules", "#{sub_rule}.md"])
case File.read(path) do
{:ok, content} -> "### #{package}:#{sub_rule}\n\n#{content}"
_ -> nil
end
end
end
defp load_single_usage_rule(_), do: nil
defp subagent_filename(name) do
name
|> String.downcase()
|> String.replace(~r/[^a-z0-9]+/, "-")
|> String.trim("-")
|> Kernel.<>(".md")
end
defp generate_subagent_markdown(subagent) do
frontmatter = generate_frontmatter(subagent)
"""
#{frontmatter}
#{subagent.prompt}
"""
|> String.trim()
end
defp generate_frontmatter(subagent) do
# Convert name to lowercase with hyphens as per Claude Code conventions
name =
subagent.name
|> String.downcase()
|> String.replace(~r/[^a-z0-9]+/, "-")
|> String.trim("-")
lines = [
"---",
"name: #{name}",
"description: #{subagent.description}"
]
# Add tools line only if tools are specified
lines =
if subagent.tools != [] do
tools_line =
subagent.tools
|> Enum.map(&tool_to_string/1)
|> Enum.join(", ")
lines ++ ["tools: #{tools_line}"]
else
lines
end
(lines ++ ["---"])
|> Enum.join("\n")
end
defp add_generated_files(igniter, results) do
Enum.reduce(results, igniter, fn {_name, relative_path, content}, acc ->
Igniter.create_or_update_file(acc, relative_path, content, fn source ->
Rewrite.Source.update(source, :content, content)
end)
end)
end
defp format_subagents_success_message(results) do
lines = [
"Generated #{length(results)} subagent(s):",
""
]
result_lines =
Enum.map(results, fn {name, relative_path, _content} ->
"• #{name}#{relative_path}"
end)
(lines ++ result_lines ++ ["", "Subagents are now available in Claude Code."])
|> Enum.join("\n")
end
defp format_subagents_error_message(errors) do
error_lines =
Enum.map(errors, fn {:error, {name, reason}} ->
"• #{name}: #{inspect(reason)}"
end)
(["Failed to generate some subagents:"] ++ error_lines)
|> Enum.join("\n")
end
defp setup_tidewave_if_configured(igniter) do
relative_settings_path = Path.join(igniter.assigns.claude_dir_path, "settings.json")
case get_tidewave_config(igniter) do
{:ok, port} ->
igniter
|> update_settings_with_tidewave(relative_settings_path, port)
|> Igniter.add_notice("""
Tidewave MCP server has been configured in #{relative_settings_path}
Port: #{port}
Endpoint: http://localhost:#{port}/tidewave/mcp
#{@tidewave_setup_instructions}
""")
:not_configured ->
igniter
:disabled ->
igniter
|> remove_tidewave_from_settings(relative_settings_path)
end
end
defp get_tidewave_config(igniter) do
claude_exs_path = ".claude.exs"
if Igniter.exists?(igniter, claude_exs_path) do
case read_and_eval_claude_exs(igniter, claude_exs_path) do
{:ok, config} when is_map(config) ->
case Map.get(config, :mcp_servers, []) do
[] ->
:not_configured
servers when is_list(servers) ->
case find_tidewave_in_servers(servers) do
{:ok, port} -> {:ok, port}
:disabled -> :disabled
:not_found -> :not_configured
end
_ ->
:not_configured
end
_ ->
:not_configured
end
else
:not_configured
end
end
defp read_and_eval_claude_exs(igniter, path) do
try do
igniter = Igniter.include_existing_file(igniter, path)
source = Rewrite.source!(igniter.rewrite, path)
content = Rewrite.Source.get(source, :content)
{result, _binding} = Code.eval_string(content, [], file: path)
{:ok, result}
rescue
_e in [Rewrite.Error] ->
{:error, "File not found: #{path}"}
e in [CompileError, SyntaxError] ->
{:error, inspect(e)}
end
end
defp find_tidewave_in_servers([]), do: :not_found
defp find_tidewave_in_servers([:tidewave | _]), do: {:ok, @default_tidewave_port}
defp find_tidewave_in_servers([{:tidewave, opts} | _]) when is_list(opts) do
if Keyword.get(opts, :enabled?, true) do
port = Keyword.get(opts, :port, @default_tidewave_port)
{:ok, port}
else
:disabled
end
end
defp find_tidewave_in_servers([_ | rest]), do: find_tidewave_in_servers(rest)
defp update_settings_with_tidewave(igniter, relative_settings_path, port) do
initial_settings = %{}
initial_content = Jason.encode!(initial_settings, pretty: true) <> "\n"
igniter
|> Igniter.create_or_update_file(relative_settings_path, initial_content, fn source ->
content = Rewrite.Source.get(source, :content)
new_content =
case Jason.decode(content) do
{:ok, settings} ->
tidewave_config = %{
"tidewave" => %{
"type" => "sse",
"url" => "http://localhost:#{port}/tidewave/mcp"
}
}
updated_settings = Map.put(settings, "mcpServers", tidewave_config)
Jason.encode!(updated_settings, pretty: true) <> "\n"
{:error, _} ->
settings = %{
"mcpServers" => %{
"tidewave" => %{
"type" => "sse",
"url" => "http://localhost:#{port}/tidewave/mcp"
}
}
}
Jason.encode!(settings, pretty: true) <> "\n"
end
Rewrite.Source.update(source, :content, new_content)
end)
end
defp remove_tidewave_from_settings(igniter, relative_settings_path) do
if Igniter.exists?(igniter, relative_settings_path) do
igniter
|> Igniter.update_file(relative_settings_path, fn source ->
content = Rewrite.Source.get(source, :content)
case Jason.decode(content) do
{:ok, settings} ->
case Map.get(settings, "mcpServers") do
%{"tidewave" => _} = mcp_servers ->
updated_mcp = Map.delete(mcp_servers, "tidewave")
updated_settings =
if map_size(updated_mcp) == 0 do
Map.delete(settings, "mcpServers")
else
Map.put(settings, "mcpServers", updated_mcp)
end
new_content = Jason.encode!(updated_settings, pretty: true) <> "\n"
Rewrite.Source.update(source, :content, new_content)
_ ->
source
end
{:error, _} ->
source
end
end)
else
igniter
end
end
defp check_meta_agent_and_notify(igniter, path) do
# Skip in test environment
if igniter.assigns[:test_mode] || Mix.env() == :test do
igniter
else
case read_and_eval_claude_exs(igniter, path) do
{:ok, config} when is_map(config) ->
subagents = Map.get(config, :subagents, [])
has_meta_agent =
Enum.any?(subagents, fn agent ->
Map.get(agent, :name) == "Meta Agent"
end)
if has_meta_agent do
igniter
else
# Show notice about how to add Meta Agent
igniter
|> Igniter.add_notice("""
Your project doesn't have a Meta Agent configured.
The Meta Agent helps you create new subagents.
To add it, copy the following to your .claude.exs subagents list:
#{format_meta_agent_for_notice()}
Then run `mix claude.install` again to generate the agent file.
""")
end
_ ->
# If we can't read the file, just return the igniter unchanged
igniter
end
end
end
defp tool_to_string(tool) when is_atom(tool) do
Map.get(@tool_atom_to_string, tool, Atom.to_string(tool))
end
@impl Igniter.Mix.Task
def supports_umbrella?, do: false
end