diff --git a/.icons/plandex.svg b/.icons/plandex.svg new file mode 100644 index 000000000..5e4a5ffa3 --- /dev/null +++ b/.icons/plandex.svg @@ -0,0 +1 @@ +Plandex diff --git a/registry/fran-mora/.images/avatar.svg b/registry/fran-mora/.images/avatar.svg new file mode 100644 index 000000000..cc2acd09e --- /dev/null +++ b/registry/fran-mora/.images/avatar.svg @@ -0,0 +1,4 @@ + + + FM + diff --git a/registry/fran-mora/README.md b/registry/fran-mora/README.md new file mode 100644 index 000000000..6da294324 --- /dev/null +++ b/registry/fran-mora/README.md @@ -0,0 +1,19 @@ +--- +display_name: Francesco Moramarco +bio: AI/ML engineer shipping modules for AI coding agents and developer tooling. +github: fran-mora +avatar: ./.images/avatar.svg +status: community +--- + +# Francesco Moramarco + +AI/ML engineer focused on AI coding agents, developer tooling, and dev-environment ergonomics. + +## Modules + +- **plandex**: Install and run the [Plandex](https://plandex.ai) CLI AI coding agent in your Coder workspace. + +## Contributing + +If you'd like to contribute to this namespace, please [open an issue](https://github.com/coder/registry/issues) or submit a pull request. diff --git a/registry/fran-mora/modules/plandex/README.md b/registry/fran-mora/modules/plandex/README.md new file mode 100644 index 000000000..5899914ce --- /dev/null +++ b/registry/fran-mora/modules/plandex/README.md @@ -0,0 +1,157 @@ +--- +display_name: Plandex +description: Install and configure the Plandex CLI AI coding agent in your workspace. +icon: ../../../../.icons/plandex.svg +verified: false +tags: [agent, plandex, ai, cli] +--- + +# Plandex + +Install and configure the [Plandex](https://plandex.ai) CLI AI coding agent in your workspace. Starting Plandex is left to the caller (template command, IDE launcher, or a custom `coder_script`) — the same pattern used by the official `claude-code` module. + +```tf +module "plandex" { + source = "registry.coder.com/fran-mora/plandex/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + openai_api_key = "sk-..." +} +``` + +## Prerequisites + +Plandex needs at least one upstream LLM provider key. Set whichever the user prefers: + +- `openai_api_key` — passed to Plandex via `OPENAI_API_KEY`. Default provider. +- `anthropic_api_key` — passed via `ANTHROPIC_API_KEY`. +- `google_api_key` — passed via `GOOGLE_API_KEY`. +- `openrouter_api_key` — passed via `OPENROUTER_API_KEY`. + +For a self-hosted Plandex server, also set `plandex_api_host` to the server URL. + +## Examples + +### Standalone mode with a launcher app + +Install Plandex against the user's OpenAI key and add a `coder_app` that opens a Plandex REPL in the workspace from the dashboard. + +```tf +locals { + plandex_workdir = "/home/coder/project" +} + +module "plandex" { + source = "registry.coder.com/fran-mora/plandex/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + workdir = local.plandex_workdir + openai_api_key = "sk-..." +} + +resource "coder_app" "plandex" { + agent_id = coder_agent.main.id + slug = "plandex" + display_name = "Plandex" + icon = "/icon/plandex.svg" + open_in = "slim-window" + command = <<-EOT + #!/bin/bash + set -e + cd ${local.plandex_workdir} + plandex + EOT +} +``` + +> [!NOTE] +> `coder_app.command` runs when the user clicks the app tile. The module sets the relevant API-key env vars on the agent so the CLI starts pre-authenticated. + +### Pin a specific Plandex version + +```tf +module "plandex" { + source = "registry.coder.com/fran-mora/plandex/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + workdir = "/home/coder/project" + plandex_version = "2.2.1" + openai_api_key = "sk-..." +} +``` + +### Self-hosted Plandex server + +Point the CLI at a self-hosted Plandex server instead of Plandex Cloud. + +```tf +module "plandex" { + source = "registry.coder.com/fran-mora/plandex/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + workdir = "/home/coder/project" + plandex_api_host = "https://plandex.example.com" + anthropic_api_key = "sk-ant-..." +} +``` + +### Skip the installer (Plandex pre-installed in the image) + +If Plandex is already baked into the workspace image, set `install_plandex = false` so the module only configures env vars. + +```tf +module "plandex" { + source = "registry.coder.com/fran-mora/plandex/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + install_plandex = false + openai_api_key = "sk-..." +} +``` + +### Serialize a downstream `coder_script` after the install pipeline + +The module exposes the `coder exp sync` name of each script it creates via the `scripts` output: an ordered list (`pre_install`, `install`, `post_install`) of names for scripts this module actually creates. Scripts that were not configured are absent from the list. + +Downstream `coder_script` resources can wait for this module's install pipeline to finish using `coder exp sync want `: + +```tf +module "plandex" { + source = "registry.coder.com/fran-mora/plandex/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + workdir = "/home/coder/project" + openai_api_key = "sk-..." +} + +resource "coder_script" "post_plandex" { + agent_id = coder_agent.main.id + display_name = "Run after Plandex install" + run_on_start = true + script = <<-EOT + #!/bin/bash + set -euo pipefail + trap 'coder exp sync complete post-plandex' EXIT + coder exp sync want post-plandex ${join(" ", module.plandex.scripts)} + coder exp sync start post-plandex + + # Your work here runs after plandex finishes installing. + plandex version + EOT +} +``` + +## Troubleshooting + +If Plandex doesn't appear on the workspace `PATH` after install, check the install log: + +```bash +cat ~/.coder-modules/fran-mora/plandex/logs/install.log +``` + +The Plandex installer writes the binary to `/usr/local/bin/plandex` if `sudo` is available, otherwise to `$HOME/.local/bin/plandex`. The module ensures the latter is on `PATH` by appending it to the user's shell profiles. + +## References + +- [Plandex documentation](https://docs.plandex.ai) +- [Plandex GitHub](https://github.com/plandex-ai/plandex) diff --git a/registry/fran-mora/modules/plandex/main.tf b/registry/fran-mora/modules/plandex/main.tf new file mode 100644 index 000000000..207d84e7b --- /dev/null +++ b/registry/fran-mora/modules/plandex/main.tf @@ -0,0 +1,155 @@ +terraform { + required_version = ">= 1.9" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.12" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +data "coder_workspace" "me" {} + +data "coder_workspace_owner" "me" {} + +variable "icon" { + type = string + description = "The icon to use for the app." + default = "/icon/plandex.svg" +} + +variable "workdir" { + type = string + description = "Optional project directory. When set, the module pre-creates it if missing so Plandex can be opened in it directly." + default = null +} + +variable "pre_install_script" { + type = string + description = "Custom script to run before installing Plandex. Useful for dependency ordering between modules." + default = null +} + +variable "post_install_script" { + type = string + description = "Custom script to run after installing Plandex." + default = null +} + +variable "install_plandex" { + type = bool + description = "Whether to install Plandex." + default = true +} + +variable "plandex_version" { + type = string + description = "The version of Plandex to install. Use 'latest' for the most recent release, or pin a specific version like '2.2.1'." + default = "latest" +} + +variable "openai_api_key" { + type = string + description = "OpenAI API key passed to Plandex via the OPENAI_API_KEY env var." + sensitive = true + default = "" +} + +variable "anthropic_api_key" { + type = string + description = "Anthropic API key passed to Plandex via the ANTHROPIC_API_KEY env var." + sensitive = true + default = "" +} + +variable "google_api_key" { + type = string + description = "Google API key passed to Plandex via the GOOGLE_API_KEY env var." + sensitive = true + default = "" +} + +variable "openrouter_api_key" { + type = string + description = "OpenRouter API key passed to Plandex via the OPENROUTER_API_KEY env var." + sensitive = true + default = "" +} + +variable "plandex_api_host" { + type = string + description = "Optional Plandex server host. Set this to your self-hosted Plandex server URL (e.g. https://plandex.example.com). Leave empty to use the public Plandex Cloud or BYO-key local mode." + default = "" +} + +resource "coder_env" "openai_api_key" { + count = var.openai_api_key != "" ? 1 : 0 + agent_id = var.agent_id + name = "OPENAI_API_KEY" + value = var.openai_api_key +} + +resource "coder_env" "anthropic_api_key" { + count = var.anthropic_api_key != "" ? 1 : 0 + agent_id = var.agent_id + name = "ANTHROPIC_API_KEY" + value = var.anthropic_api_key +} + +resource "coder_env" "google_api_key" { + count = var.google_api_key != "" ? 1 : 0 + agent_id = var.agent_id + name = "GOOGLE_API_KEY" + value = var.google_api_key +} + +resource "coder_env" "openrouter_api_key" { + count = var.openrouter_api_key != "" ? 1 : 0 + agent_id = var.agent_id + name = "OPENROUTER_API_KEY" + value = var.openrouter_api_key +} + +resource "coder_env" "plandex_api_host" { + count = var.plandex_api_host != "" ? 1 : 0 + agent_id = var.agent_id + name = "PLANDEX_API_HOST" + value = var.plandex_api_host +} + +locals { + workdir = var.workdir != null ? trimsuffix(var.workdir, "/") : "" + install_script = templatefile("${path.module}/scripts/install.sh.tftpl", { + ARG_PLANDEX_VERSION = var.plandex_version + ARG_INSTALL_PLANDEX = tostring(var.install_plandex) + ARG_WORKDIR = local.workdir + }) + module_dir_name = ".coder-modules/fran-mora/plandex" +} + +module "coder_utils" { + source = "registry.coder.com/coder/coder-utils/coder" + version = "0.0.1" + + agent_id = var.agent_id + module_directory = "$HOME/${local.module_dir_name}" + display_name_prefix = "Plandex" + icon = var.icon + pre_install_script = var.pre_install_script + post_install_script = var.post_install_script + install_script = local.install_script +} + +# Pass-through of coder-utils script outputs so upstream modules can serialize +# their coder_script resources behind this module's install pipeline using +# `coder exp sync want `. +output "scripts" { + description = "Ordered list of coder exp sync names for the coder_script resources this module actually creates, in run order (pre_install, install, post_install). Scripts that were not configured are absent from the list." + value = module.coder_utils.scripts +} diff --git a/registry/fran-mora/modules/plandex/main.tftest.hcl b/registry/fran-mora/modules/plandex/main.tftest.hcl new file mode 100644 index 000000000..ad4500de6 --- /dev/null +++ b/registry/fran-mora/modules/plandex/main.tftest.hcl @@ -0,0 +1,255 @@ +run "defaults_are_correct" { + command = plan + + variables { + agent_id = "test-agent" + } + + assert { + condition = var.install_plandex == true + error_message = "Plandex installation should be enabled by default" + } + + assert { + condition = var.plandex_version == "latest" + error_message = "Default Plandex version should be 'latest'" + } + + assert { + condition = var.icon == "/icon/plandex.svg" + error_message = "Default icon should be '/icon/plandex.svg'" + } + + assert { + condition = var.workdir == null + error_message = "Workdir should be null by default" + } + + assert { + condition = var.openai_api_key == "" + error_message = "OpenAI API key should default to empty" + } + + assert { + condition = var.anthropic_api_key == "" + error_message = "Anthropic API key should default to empty" + } + + assert { + condition = var.google_api_key == "" + error_message = "Google API key should default to empty" + } + + assert { + condition = var.openrouter_api_key == "" + error_message = "OpenRouter API key should default to empty" + } + + assert { + condition = var.plandex_api_host == "" + error_message = "Plandex API host should default to empty" + } + + assert { + condition = local.module_dir_name == ".coder-modules/fran-mora/plandex" + error_message = "Module dir name should follow the .coder-modules// convention" + } + + assert { + condition = local.workdir == "" + error_message = "Workdir local should be empty string when var.workdir is null" + } +} + +run "workdir_trailing_slash_trimmed" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder/project/" + } + + assert { + condition = local.workdir == "/home/coder/project" + error_message = "Trailing slash on workdir should be trimmed" + } +} + +run "workdir_without_trailing_slash" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder/project" + } + + assert { + condition = local.workdir == "/home/coder/project" + error_message = "Workdir without trailing slash should pass through unchanged" + } +} + +run "plandex_version_pinning" { + command = plan + + variables { + agent_id = "test-agent" + plandex_version = "2.2.1" + } + + assert { + condition = var.plandex_version == "2.2.1" + error_message = "Plandex version should be settable" + } +} + +run "install_disabled" { + command = plan + + variables { + agent_id = "test-agent" + install_plandex = false + } + + assert { + condition = var.install_plandex == false + error_message = "Installation should be skippable" + } +} + +run "openai_api_key_creates_env_resource" { + command = plan + + variables { + agent_id = "test-agent" + openai_api_key = "sk-test-key" + } + + assert { + condition = length(coder_env.openai_api_key) == 1 + error_message = "Setting openai_api_key should create exactly one coder_env resource" + } + + assert { + condition = length(coder_env.anthropic_api_key) == 0 + error_message = "Anthropic env should not be created when only OpenAI key is set" + } +} + +run "multiple_provider_keys_create_multiple_env_resources" { + command = plan + + variables { + agent_id = "test-agent" + openai_api_key = "sk-openai" + anthropic_api_key = "sk-ant" + google_api_key = "google-key" + openrouter_api_key = "or-key" + } + + assert { + condition = length(coder_env.openai_api_key) == 1 + error_message = "OpenAI env should be created when key is set" + } + + assert { + condition = length(coder_env.anthropic_api_key) == 1 + error_message = "Anthropic env should be created when key is set" + } + + assert { + condition = length(coder_env.google_api_key) == 1 + error_message = "Google env should be created when key is set" + } + + assert { + condition = length(coder_env.openrouter_api_key) == 1 + error_message = "OpenRouter env should be created when key is set" + } +} + +run "no_api_keys_creates_no_env_resources" { + command = plan + + variables { + agent_id = "test-agent" + } + + assert { + condition = length(coder_env.openai_api_key) == 0 + error_message = "OpenAI env should not be created without a key" + } + + assert { + condition = length(coder_env.anthropic_api_key) == 0 + error_message = "Anthropic env should not be created without a key" + } + + assert { + condition = length(coder_env.google_api_key) == 0 + error_message = "Google env should not be created without a key" + } + + assert { + condition = length(coder_env.openrouter_api_key) == 0 + error_message = "OpenRouter env should not be created without a key" + } + + assert { + condition = length(coder_env.plandex_api_host) == 0 + error_message = "Plandex API host env should not be created without a value" + } +} + +run "self_hosted_api_host" { + command = plan + + variables { + agent_id = "test-agent" + plandex_api_host = "https://plandex.example.com" + } + + assert { + condition = length(coder_env.plandex_api_host) == 1 + error_message = "Setting plandex_api_host should create the PLANDEX_API_HOST env resource" + } + + assert { + condition = coder_env.plandex_api_host[0].value == "https://plandex.example.com" + error_message = "PLANDEX_API_HOST should pass through the configured URL" + } +} + +run "custom_scripts_configuration" { + command = plan + + variables { + agent_id = "test-agent" + pre_install_script = "#!/bin/bash\necho 'pre-install'" + post_install_script = "#!/bin/bash\necho 'post-install'" + } + + assert { + condition = var.pre_install_script != null + error_message = "Pre-install script should be settable" + } + + assert { + condition = var.post_install_script != null + error_message = "Post-install script should be settable" + } +} + +run "custom_icon" { + command = plan + + variables { + agent_id = "test-agent" + icon = "/custom/icon.svg" + } + + assert { + condition = var.icon == "/custom/icon.svg" + error_message = "Custom icon should be settable" + } +} diff --git a/registry/fran-mora/modules/plandex/scripts/install.sh.tftpl b/registry/fran-mora/modules/plandex/scripts/install.sh.tftpl new file mode 100644 index 000000000..6a79bfa5f --- /dev/null +++ b/registry/fran-mora/modules/plandex/scripts/install.sh.tftpl @@ -0,0 +1,117 @@ +#!/bin/bash + +set -euo pipefail + +BOLD='\033[0;1m' + +command_exists() { + command -v "$1" > /dev/null 2>&1 +} + +ARG_PLANDEX_VERSION='${ARG_PLANDEX_VERSION}' +ARG_INSTALL_PLANDEX='${ARG_INSTALL_PLANDEX}' +ARG_WORKDIR='${ARG_WORKDIR}' + +# Plandex's official installer drops the binary in /usr/local/bin (with sudo) +# or $HOME/.local/bin (without). Prepend the latter so the binary is on PATH +# regardless of which path the installer chose. +export PATH="$HOME/.local/bin:$PATH" + +echo "--------------------------------" +printf "ARG_PLANDEX_VERSION: %s\n" "$ARG_PLANDEX_VERSION" +printf "ARG_INSTALL_PLANDEX: %s\n" "$ARG_INSTALL_PLANDEX" +printf "ARG_WORKDIR: %s\n" "$ARG_WORKDIR" +echo "--------------------------------" + +add_path_to_shell_profiles() { + local path_dir="$1" + + for profile in "$HOME/.profile" "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.zprofile" "$HOME/.zshrc"; do + if [ -f "$profile" ]; then + if ! grep -q "$path_dir" "$profile" 2> /dev/null; then + echo "export PATH=\"\$PATH:$path_dir\"" >> "$profile" + echo "Added $path_dir to $profile" + fi + fi + done + + local fish_config="$HOME/.config/fish/config.fish" + if [ -f "$fish_config" ]; then + if ! grep -q "$path_dir" "$fish_config" 2> /dev/null; then + echo "fish_add_path $path_dir" >> "$fish_config" + echo "Added $path_dir to $fish_config" + fi + fi +} + +ensure_plandex_in_path() { + local PLANDEX_BIN="" + if command -v plandex > /dev/null 2>&1; then + PLANDEX_BIN=$(command -v plandex) + elif [ -x "$HOME/.local/bin/plandex" ]; then + PLANDEX_BIN="$HOME/.local/bin/plandex" + elif [ -x "/usr/local/bin/plandex" ]; then + PLANDEX_BIN="/usr/local/bin/plandex" + fi + + if [ -z "$PLANDEX_BIN" ] || [ ! -x "$PLANDEX_BIN" ]; then + echo "Warning: Could not find plandex binary on PATH after install." + return + fi + + local PLANDEX_DIR + PLANDEX_DIR=$(dirname "$PLANDEX_BIN") + + if [ -n "$${CODER_SCRIPT_BIN_DIR:-}" ] && [ ! -e "$CODER_SCRIPT_BIN_DIR/plandex" ]; then + ln -s "$PLANDEX_BIN" "$CODER_SCRIPT_BIN_DIR/plandex" + echo "Created symlink: $CODER_SCRIPT_BIN_DIR/plandex -> $PLANDEX_BIN" + fi + + add_path_to_shell_profiles "$PLANDEX_DIR" +} + +install_plandex_cli() { + if [ "$ARG_INSTALL_PLANDEX" != "true" ]; then + echo "Skipping Plandex installation as per configuration." + ensure_plandex_in_path + return + fi + + if command_exists plandex; then + echo "Plandex already installed: $(plandex version 2> /dev/null || echo 'unknown version')" + ensure_plandex_in_path + return + fi + + printf "$${BOLD}Installing Plandex CLI (version: %s)...\n" "$ARG_PLANDEX_VERSION" + + if [ "$ARG_PLANDEX_VERSION" = "latest" ]; then + curl -sL https://plandex.ai/install.sh | bash + else + # The Plandex installer reads the PLANDEX_VERSION env var to pin a specific + # version. Strip a leading 'v' so users can pass either '2.2.1' or 'v2.2.1'. + local pinned_version="$${ARG_PLANDEX_VERSION#v}" + curl -sL https://plandex.ai/install.sh | PLANDEX_VERSION="$pinned_version" bash + fi + + ensure_plandex_in_path + + if ! command_exists plandex; then + echo "ERROR: Plandex installation appears to have failed." + exit 1 + fi + + echo "Installed Plandex successfully: $(plandex version 2> /dev/null || echo 'unknown version')" +} + +setup_workdir() { + if [ -n "$ARG_WORKDIR" ] && [ ! -d "$ARG_WORKDIR" ]; then + echo "Workdir '$ARG_WORKDIR' does not exist; creating it." + mkdir -p "$ARG_WORKDIR" + fi +} + +install_plandex_cli +setup_workdir + +echo "Plandex module setup completed."