defmodule Mix.Tasks.JobyKit.Install do
@shortdoc "Install JobyKit into an existing Phoenix application"
@moduledoc """
Installs JobyKit into an existing Phoenix project.
Generates four files under `lib/<your_app>_web/`:
* `design_manifest.ex` — `use JobyKit.Manifest` declaration with one
example component registration and a `daisy_overrides/0` callback.
* `design_previews.ex` — preview functions for the registered
components (one per component, suffixed `_preview`).
* `live/design_system_live.ex` — the kit-curated `/design` page.
* `live/custom_designs_live.ex` — the host-owned `/custom-designs`
page for composites and domain components.
Existing files are not overwritten unless you pass `--force`. Routes
are not auto-injected; the task prints the lines you need to add to
`router.ex` at the end.
## Usage
mix joby_kit.install
mix joby_kit.install --force # overwrite existing files
mix joby_kit.install --web MyAppWeb # specify web module name
## Next steps
After running this task, follow the printed instructions to:
1. Add the two `live` routes and the JSON `get` route to your
`router.ex`.
2. Restart the dev server.
3. Visit `/design` and `/custom-designs`.
See the `mix joby_kit.bootstrap` task for a more aggressive variant
aimed at fresh `mix phx.new` projects (replaces the default Phoenix
landing page). For a from-scratch app, see `mix joby_kit.new`.
"""
use Mix.Task
@switches [force: :boolean, web: :string]
@impl Mix.Task
def run(args) do
{opts, _argv, _invalid} = OptionParser.parse(args, switches: @switches)
# Tolerate missing project context (e.g. when invoked from a Mix
# archive after `mix archive.install`). When there's no `:app`, the
# caller must pass `--web` so we can derive the web module name.
app = Keyword.get(Mix.Project.config(), :app)
web_module = web_module_name(opts[:web], app)
web_path = Macro.underscore(web_module)
web_module_anchor = String.replace(web_path, "/", "")
assigns = [
app: app,
web_module: web_module,
web_path: web_path,
web_module_anchor: web_module_anchor
]
targets = [
{"design_manifest.ex", "lib/#{web_path}/design_manifest.ex"},
{"design_previews.ex", "lib/#{web_path}/design_previews.ex"},
{"composite_components.ex", "lib/#{web_path}/components/composite_components.ex"},
{"design_system_live.ex", "lib/#{web_path}/live/design_system_live.ex"},
{"custom_designs_live.ex", "lib/#{web_path}/live/custom_designs_live.ex"}
]
File.mkdir_p!("lib/#{web_path}/live")
force? = opts[:force] == true
Enum.each(targets, fn {template, dest} ->
template_path = template_path(template)
copy_or_skip(template_path, dest, assigns, force?)
end)
patch_agents_md()
patch_claude_md()
patch_app_css()
patch_layout_nav(web_path)
print_next_steps(web_module)
:ok
end
defp patch_layout_nav(web_path) do
# Phoenix 1.8 puts the layout markup either in app.html.heex (the
# standard) or in the Layouts module's function component. Try both.
candidates = [
"lib/#{web_path}/components/layouts/app.html.heex",
"lib/#{web_path}/components/layouts.ex"
]
Enum.reduce_while(candidates, :no_match, fn path, _acc ->
case JobyKit.NavPatcher.patch(path) do
:patched ->
Mix.shell().info(
"* update #{path} (added /design and /custom-designs links to the nav)"
)
{:halt, :patched}
:unchanged ->
{:halt, :unchanged}
:no_nav_found ->
{:cont, :no_nav}
:missing ->
{:cont, :missing}
end
end)
|> case do
:patched -> :ok
:unchanged -> :ok
_other -> print_nav_manual_fallback(web_path)
end
end
defp print_nav_manual_fallback(web_path) do
Mix.shell().info("""
* skipped layout nav patch (no <nav>/<header> + <ul> found in
lib/#{web_path}/components/layouts/app.html.heex or layouts.ex).
Add these manually to your nav so /design and /custom-designs are
reachable:
<li><.link navigate={~p"/design"}>Design</.link></li>
<li><.link navigate={~p"/custom-designs"}>Custom Designs</.link></li>
""")
end
defp patch_agents_md(path \\ "AGENTS.md") do
case JobyKit.AgentsMd.patch(path) do
:created -> Mix.shell().info("* create #{path}")
:patched -> Mix.shell().info("* update #{path} (added JobyKit guidelines; replaced daisyUI conflict line if present)")
:unchanged -> :ok
end
end
defp patch_claude_md(path \\ "CLAUDE.md") do
case JobyKit.ClaudeMd.patch(path) do
:created -> Mix.shell().info("* create #{path} (auto-loaded by Claude Code; inlines the wrapper-contract diagnostics)")
:patched -> Mix.shell().info("* update #{path} (added JobyKit wrapper-contract block)")
:unchanged -> :ok
end
end
defp patch_app_css(path \\ "assets/css/app.css") do
case JobyKit.AppCss.patch(path) do
:patched -> Mix.shell().info("* update #{path} (added @source for deps/joby_kit/lib so Tailwind scans kit components)")
:unchanged -> :ok
:missing -> :ok
end
end
defp web_module_name(nil, nil) do
Mix.raise("""
Could not determine the web module name.
`mix joby_kit.install` was invoked outside a Mix project (or in a
project without an `:app` configured). Re-run with --web:
mix joby_kit.install --web MyAppWeb
""")
end
defp web_module_name(nil, app) do
"#{Macro.camelize(to_string(app))}Web"
end
defp web_module_name(override, _app) when is_binary(override), do: override
defp template_path(name) do
Application.app_dir(:joby_kit, ["priv", "templates", "joby_kit.install", name])
end
defp copy_or_skip(source, dest, assigns, force?) do
if File.exists?(dest) and not force? do
Mix.shell().info("* skip #{dest} (already exists; use --force to overwrite)")
else
Mix.Generator.copy_template(source, dest, assigns, force: true)
end
end
defp print_next_steps(web_module) do
Mix.shell().info("""
JobyKit files generated.
Add these routes to your router (lib/#{Macro.underscore(web_module)}/router.ex):
scope "/", #{web_module} do
pipe_through :browser
live "/design", DesignSystemLive, :index
live "/custom-designs", CustomDesignsLive, :index
end
scope "/" do
# The JSON endpoint needs a JSON-accepting pipeline. Use the
# default `:api` pipeline that ships with `mix phx.new`, or
# roll your own that fetches sessions/auth if you want to
# gate the manifest behind a logged-in user.
pipe_through :api
get "/design.json", JobyKit.ManifestController, :show,
private: %{joby_kit_manifest: #{web_module}.DesignManifest}
end
Then restart `mix phx.server`, visit /design and /custom-designs, and
extend lib/#{Macro.underscore(web_module)}/design_manifest.ex with
your own component registrations.
Note on core components: the manifest registers `JobyKit.CoreComponents`
(button, card, icon, input, flash). Your host's `<.button>` etc. still
resolve through `#{web_module}.CoreComponents` until you wire the kit
module in. To make `<.button>` etc. resolve to the kit version, edit
lib/#{Macro.underscore(web_module)}.ex's `html_helpers` to:
import JobyKit.CoreComponents
# remove or thin out: import #{web_module}.CoreComponents
Run `mix joby_kit.lint` to verify the contract holds end-to-end.
Tip: run `curl http://localhost:PORT/design.json | jq` to see the
machine-readable manifest your AI agents can consume.
── For the AI agent picking up this app ──────────────────────────
CLAUDE.md (project root, auto-loaded by Claude Code) inlines the
wrapper-contract diagnostics. AGENTS.md has the full build order.
Symptoms you skipped step 1:
• `<button class="…">` / `<input>` / `<textarea>` in `.heex` when
`<.button>` / `<.input>` already exists.
• A private function component styled as if it were a primitive.
• A new component without `data-component`, `attr :rest, :global`,
or a `DesignManifest` entry.
Run `mix joby_kit.lint` to catch all three.
""")
end
end