diff --git a/home/.chezmoidata/ai.yaml b/home/.chezmoidata/ai.yaml index 716a85b..bb53120 100644 --- a/home/.chezmoidata/ai.yaml +++ b/home/.chezmoidata/ai.yaml @@ -31,13 +31,19 @@ ai: skill: context7-cli work: [] plugins: + # copilot: how the plugin installs for the github-copilot target. + # plugin -> native `copilot plugin marketplace add/install` (Copilot CLI). + # skill -> `npx skills add -a github-copilot` (no native plugin). + # The claude-code target always installs every entry as a native plugin. shared: - name: caveman marketplace: caveman source: JuliusBrussee/caveman + copilot: skill - name: superpowers marketplace: superpowers-dev source: obra/superpowers + copilot: plugin mcp: shared: - name: grep diff --git a/home/.chezmoidata/packages.yaml b/home/.chezmoidata/packages.yaml index 6220861..cc5bca8 100644 --- a/home/.chezmoidata/packages.yaml +++ b/home/.chezmoidata/packages.yaml @@ -54,6 +54,7 @@ packages: taps: [] formulas: [] casks: + - copilot-cli - microsoft-office - microsoft-teams - slack diff --git a/home/.chezmoiscripts/darwin/run_onchange_06_install-ai-plugins.sh.tmpl b/home/.chezmoiscripts/darwin/run_onchange_06_install-ai-plugins.sh.tmpl index 8469a73..c16e62f 100644 --- a/home/.chezmoiscripts/darwin/run_onchange_06_install-ai-plugins.sh.tmpl +++ b/home/.chezmoiscripts/darwin/run_onchange_06_install-ai-plugins.sh.tmpl @@ -15,7 +15,7 @@ AI_PLUGIN_TARGETS=( ) AI_PLUGINS=( {{ range $activePlugins -}} - "{{ .name }}|{{ .marketplace }}|{{ .source }}" + "{{ .name }}|{{ .marketplace }}|{{ .source }}|{{ .copilot }}" {{ end -}} ) {{ template "lib/install/ai-plugins.sh" . }} diff --git a/home/.chezmoitemplates/lib/install/ai-plugins.sh b/home/.chezmoitemplates/lib/install/ai-plugins.sh index d40f9b1..920792a 100644 --- a/home/.chezmoitemplates/lib/install/ai-plugins.sh +++ b/home/.chezmoitemplates/lib/install/ai-plugins.sh @@ -3,10 +3,12 @@ # @brief Install cross-agent AI plugins for the active agent targets. # @description # Installs each plugin in AI_PLUGINS for every agent in AI_PLUGIN_TARGETS. -# Claude Code plugins install through the marketplace CLI; GitHub Copilot has -# no plugin runtime, so the plugin source installs as a skill instead. -# Sourceable from bats tests and injected into chezmoi run scripts via -# chezmoi template rendering. +# Each entry is name|marketplace|source|copilot. Claude Code always installs +# every entry as a native marketplace plugin. GitHub Copilot routes per entry +# on the copilot field: `plugin` uses the native Copilot CLI marketplace, +# `skill` (or anything else) falls back to `npx skills add`. Sourceable from +# bats tests and injected into chezmoi run scripts via chezmoi template +# rendering. set -Eeuo pipefail @@ -22,6 +24,18 @@ function _ai_plugins_add_to_claude() { claude plugin install "${name}@${marketplace}" } +# +# @description Add one plugin's marketplace and install it into the Copilot CLI. +# @arg $1 string Plugin name. +# @arg $2 string Marketplace name. +# @arg $3 string Marketplace source repo. +# +function _ai_plugins_add_copilot_plugin() { + local name="$1" marketplace="$2" src="$3" + copilot plugin marketplace add "${src}" && + copilot plugin install "${name}@${marketplace}" +} + # # @description Install one plugin source into GitHub Copilot as a skill. # @arg $1 string Marketplace source repo. @@ -48,7 +62,8 @@ function _ai_plugins_for_claude_code() { name="${entry%%|*}" rest="${entry#*|}" marketplace="${rest%%|*}" - src="${rest##*|}" + rest="${rest#*|}" + src="${rest%%|*}" _ai_plugins_add_to_claude "${name}" "${marketplace}" "${src}" || failed+=("${name}") done @@ -58,23 +73,39 @@ function _ai_plugins_for_claude_code() { } # -# @description Install every plugin for the GitHub Copilot target. +# @description Install every plugin for the GitHub Copilot target, routing each +# entry on its copilot field: `plugin` installs natively through the Copilot +# CLI, anything else installs the source as a skill. A missing required CLI +# fails only that entry, so one broken mechanism never blocks the other. # @exitcode 0 Plugins processed (failures are warned, not fatal). -# @exitcode 1 npx is missing. # function _ai_plugins_for_copilot() { - if ! command -v npx >/dev/null 2>&1; then - log_error "[ai-plugins] npx not found. Ensure Node.js is installed (run_onchange_03_install-mise-tools)." - return 1 - fi - local -a failed=() - local entry name src + local entry name marketplace src mech rest for entry in "${AI_PLUGINS[@]-}"; do [[ -z "${entry}" ]] && continue name="${entry%%|*}" - src="${entry##*|}" - _ai_plugins_add_to_copilot "${src}" || failed+=("${name}") + rest="${entry#*|}" + marketplace="${rest%%|*}" + rest="${rest#*|}" + src="${rest%%|*}" + mech="${rest##*|}" + + if [[ "${mech}" == "plugin" ]]; then + if ! command -v copilot >/dev/null 2>&1; then + log_error "[ai-plugins] copilot CLI not found. Install it (brew install copilot-cli)." + failed+=("${name}") + continue + fi + _ai_plugins_add_copilot_plugin "${name}" "${marketplace}" "${src}" || failed+=("${name}") + else + if ! command -v npx >/dev/null 2>&1; then + log_error "[ai-plugins] npx not found. Ensure Node.js is installed (run_onchange_03_install-mise-tools)." + failed+=("${name}") + continue + fi + _ai_plugins_add_to_copilot "${src}" || failed+=("${name}") + fi done if ((${#failed[@]} > 0)); then diff --git a/tests/template/darwin-install-scripts.bats b/tests/template/darwin-install-scripts.bats index 8ebead1..40cf826 100644 --- a/tests/template/darwin-install-scripts.bats +++ b/tests/template/darwin-install-scripts.bats @@ -56,6 +56,7 @@ AI_MCP_TEMPLATE="$DOTFILES_ROOT/home/.chezmoiscripts/darwin/run_onchange_08_inst assert_line 'brew "mise"' assert_line 'cask "discord"' assert_line 'mas "Klack", id: 6446206067' + refute_line 'cask "copilot-cli"' refute_line 'cask "microsoft-office"' refute_line 'cask "microsoft-teams"' refute_line 'cask "slack"' @@ -67,6 +68,7 @@ AI_MCP_TEMPLATE="$DOTFILES_ROOT/home/.chezmoiscripts/darwin/run_onchange_08_inst assert_success assert_line 'brew "mas"' + assert_line 'cask "copilot-cli"' assert_line 'cask "microsoft-office"' assert_line 'cask "microsoft-teams"' assert_line 'cask "slack"' @@ -170,8 +172,8 @@ AI_MCP_TEMPLATE="$DOTFILES_ROOT/home/.chezmoiscripts/darwin/run_onchange_08_inst assert_line 'AI_PLUGIN_TARGETS=(' assert_line --partial '"claude-code"' refute_line --partial '"github-copilot"' - assert_line --partial '"caveman|caveman|JuliusBrussee/caveman"' - assert_line --partial '"superpowers|superpowers-dev|obra/superpowers"' + assert_line --partial '"caveman|caveman|JuliusBrussee/caveman|skill"' + assert_line --partial '"superpowers|superpowers-dev|obra/superpowers|plugin"' assert_line --partial 'ai_plugins_install_main' } @@ -182,7 +184,7 @@ AI_MCP_TEMPLATE="$DOTFILES_ROOT/home/.chezmoiscripts/darwin/run_onchange_08_inst assert_line 'AI_PLUGIN_TARGETS=(' assert_line --partial '"github-copilot"' refute_line --partial '"claude-code"' - assert_line --partial '"caveman|caveman|JuliusBrussee/caveman"' + assert_line --partial '"caveman|caveman|JuliusBrussee/caveman|skill"' assert_line --partial 'ai_plugins_install_main' } diff --git a/tests/unit/lib/install/ai-plugins.bats b/tests/unit/lib/install/ai-plugins.bats index ef4896f..0c206d2 100644 --- a/tests/unit/lib/install/ai-plugins.bats +++ b/tests/unit/lib/install/ai-plugins.bats @@ -1,6 +1,9 @@ #!/usr/bin/env bats # @file tests/unit/lib/install/ai-plugins.bats # @brief Behavior tests for home/.chezmoitemplates/lib/install/ai-plugins.sh. +# Entries are name|marketplace|source|copilot. Claude installs every +# entry as a native plugin; Copilot routes on the copilot field +# (plugin -> copilot CLI, skill -> npx skills add). load '../../../test_helpers/load.bash' @@ -8,9 +11,15 @@ LOG_LIB="$DOTFILES_ROOT/home/.chezmoitemplates/lib/common/log.sh" PRELUDE="$DOTFILES_ROOT/home/.chezmoitemplates/lib/common/install-prelude.sh" LIB="$DOTFILES_ROOT/home/.chezmoitemplates/lib/install/ai-plugins.sh" +# Representative entries: caveman installs as a Copilot skill, superpowers as a +# native Copilot plugin. Both are native plugins for Claude Code. +CAVEMAN='caveman|caveman|JuliusBrussee/caveman|skill' +SUPERPOWERS='superpowers|superpowers-dev|obra/superpowers|plugin' + setup() { export CLAUDE_ARGS_FILE="$BATS_TEST_TMPDIR/claude-args" export NPX_ARGS_FILE="$BATS_TEST_TMPDIR/npx-args" + export COPILOT_ARGS_FILE="$BATS_TEST_TMPDIR/copilot-args" mkdir -p "$BATS_TEST_TMPDIR/bin" cat >"$BATS_TEST_TMPDIR/bin/claude" <<'CLAUDE' @@ -27,6 +36,13 @@ exit "${NPX_EXIT_CODE:-0}" NPX chmod +x "$BATS_TEST_TMPDIR/bin/npx" + cat >"$BATS_TEST_TMPDIR/bin/copilot" <<'COPILOT' +#!/usr/bin/env bash +printf '%s\n' "$*" | tee -a "$COPILOT_ARGS_FILE" +exit "${COPILOT_EXIT_CODE:-0}" +COPILOT + chmod +x "$BATS_TEST_TMPDIR/bin/copilot" + export PATH="$BATS_TEST_TMPDIR/bin:/usr/bin:/bin" } @@ -34,8 +50,8 @@ _run_main() { run bash -c "source '$LOG_LIB' && source '$PRELUDE' && source '$LIB' && $1 && main" } -@test "main: claude-code target adds marketplace and installs each plugin" { - _run_main "AI_PLUGIN_TARGETS=('claude-code') && AI_PLUGINS=('caveman|caveman|JuliusBrussee/caveman' 'superpowers|superpowers-dev|obra/superpowers')" +@test "claude-code target adds marketplace and installs each plugin natively" { + _run_main "AI_PLUGIN_TARGETS=('claude-code') && AI_PLUGINS=('$CAVEMAN' '$SUPERPOWERS')" assert_success assert_line "[ai-plugins] Installing AI plugins..." @@ -44,27 +60,52 @@ _run_main() { assert_line "plugin install caveman@caveman" assert_line "plugin marketplace add obra/superpowers" assert_line "plugin install superpowers@superpowers-dev" + # Claude uses the claude CLI only; copilot/npx are untouched. [ ! -s "$NPX_ARGS_FILE" ] + [ ! -s "$COPILOT_ARGS_FILE" ] } -@test "main: github-copilot target installs a plugin source as a skill" { - _run_main "AI_PLUGIN_TARGETS=('github-copilot') && AI_PLUGINS=('caveman|caveman|JuliusBrussee/caveman')" +@test "github-copilot: a skill-mechanism plugin installs via npx skills" { + _run_main "AI_PLUGIN_TARGETS=('github-copilot') && AI_PLUGINS=('$CAVEMAN')" assert_success assert_line "--yes skills add JuliusBrussee/caveman -a github-copilot --global --copy --yes" [ ! -s "$CLAUDE_ARGS_FILE" ] + [ ! -s "$COPILOT_ARGS_FILE" ] } -@test "main: installs every plugin to each active target" { - _run_main "AI_PLUGIN_TARGETS=('claude-code' 'github-copilot') && AI_PLUGINS=('caveman|caveman|JuliusBrussee/caveman')" +@test "github-copilot: a plugin-mechanism plugin installs via the copilot CLI" { + _run_main "AI_PLUGIN_TARGETS=('github-copilot') && AI_PLUGINS=('$SUPERPOWERS')" assert_success - assert_line "plugin marketplace add JuliusBrussee/caveman" - assert_line "plugin install caveman@caveman" - assert_line "--yes skills add JuliusBrussee/caveman -a github-copilot --global --copy --yes" + assert_line "plugin marketplace add obra/superpowers" + assert_line "plugin install superpowers@superpowers-dev" + [ ! -s "$NPX_ARGS_FILE" ] + [ ! -s "$CLAUDE_ARGS_FILE" ] +} + +@test "github-copilot: routes superpowers to the plugin CLI and caveman to skills" { + _run_main "AI_PLUGIN_TARGETS=('github-copilot') && AI_PLUGINS=('$CAVEMAN' '$SUPERPOWERS')" + + assert_success + grep -q "skills add JuliusBrussee/caveman -a github-copilot" "$NPX_ARGS_FILE" + grep -q "plugin install superpowers@superpowers-dev" "$COPILOT_ARGS_FILE" + [ ! -s "$CLAUDE_ARGS_FILE" ] +} + +@test "installs every plugin to each active target with the right mechanism" { + _run_main "AI_PLUGIN_TARGETS=('claude-code' 'github-copilot') && AI_PLUGINS=('$CAVEMAN' '$SUPERPOWERS')" + + assert_success + # Claude installs both as native plugins. + grep -q "plugin install caveman@caveman" "$CLAUDE_ARGS_FILE" + grep -q "plugin install superpowers@superpowers-dev" "$CLAUDE_ARGS_FILE" + # Copilot: caveman as a skill, superpowers as a native plugin. + grep -q "skills add JuliusBrussee/caveman -a github-copilot" "$NPX_ARGS_FILE" + grep -q "plugin install superpowers@superpowers-dev" "$COPILOT_ARGS_FILE" } -@test "main: exits cleanly with no plugins" { +@test "exits cleanly with no plugins" { _run_main "AI_PLUGIN_TARGETS=('claude-code') && AI_PLUGINS=()" assert_success @@ -72,16 +113,16 @@ _run_main() { [ ! -s "$CLAUDE_ARGS_FILE" ] } -@test "main: exits cleanly with no targets" { - _run_main "AI_PLUGIN_TARGETS=() && AI_PLUGINS=('caveman|caveman|JuliusBrussee/caveman')" +@test "exits cleanly with no targets" { + _run_main "AI_PLUGIN_TARGETS=() && AI_PLUGINS=('$CAVEMAN')" assert_success assert_line "[ai-plugins] No agent targets; nothing to install." [ ! -s "$CLAUDE_ARGS_FILE" ] } -@test "main: skips empty plugin entries" { - _run_main "AI_PLUGIN_TARGETS=('claude-code') && AI_PLUGINS=('caveman|caveman|JuliusBrussee/caveman' '' 'superpowers|superpowers-dev|obra/superpowers')" +@test "skips empty plugin entries" { + _run_main "AI_PLUGIN_TARGETS=('claude-code') && AI_PLUGINS=('$CAVEMAN' '' '$SUPERPOWERS')" assert_success assert_line "plugin install caveman@caveman" @@ -89,17 +130,17 @@ _run_main() { [ "$(wc -l <"$CLAUDE_ARGS_FILE")" -eq 4 ] } -@test "main: skips empty target entries" { - _run_main "AI_PLUGIN_TARGETS=('claude-code' '') && AI_PLUGINS=('caveman|caveman|JuliusBrussee/caveman')" +@test "skips empty target entries" { + _run_main "AI_PLUGIN_TARGETS=('claude-code' '') && AI_PLUGINS=('$CAVEMAN')" assert_success assert_line "plugin install caveman@caveman" [ "$(wc -l <"$CLAUDE_ARGS_FILE")" -eq 2 ] } -@test "main: warns but succeeds when a plugin fails" { +@test "warns but succeeds when a plugin fails" { export CLAUDE_EXIT_CODE=1 - _run_main "AI_PLUGIN_TARGETS=('claude-code') && AI_PLUGINS=('caveman|caveman|JuliusBrussee/caveman')" + _run_main "AI_PLUGIN_TARGETS=('claude-code') && AI_PLUGINS=('$CAVEMAN')" assert_success assert_line "warn: [ai-plugins] 1 plugin(s) failed to install:" @@ -107,7 +148,7 @@ _run_main() { assert_line "[ai-plugins] AI plugins installed." } -@test "main: reports only the plugins that failed" { +@test "reports only the plugins that failed" { cat >"$BATS_TEST_TMPDIR/bin/claude" <<'CLAUDE' #!/usr/bin/env bash printf '%s\n' "$*" | tee -a "$CLAUDE_ARGS_FILE" @@ -116,7 +157,7 @@ exit 0 CLAUDE chmod +x "$BATS_TEST_TMPDIR/bin/claude" - _run_main "AI_PLUGIN_TARGETS=('claude-code') && AI_PLUGINS=('caveman|caveman|JuliusBrussee/caveman' 'superpowers|superpowers-dev|obra/superpowers')" + _run_main "AI_PLUGIN_TARGETS=('claude-code') && AI_PLUGINS=('$CAVEMAN' '$SUPERPOWERS')" assert_success assert_line "warn: [ai-plugins] 1 plugin(s) failed to install:" @@ -124,31 +165,61 @@ CLAUDE refute_line " - caveman" } -@test "main: warns and skips an unknown target" { - _run_main "AI_PLUGIN_TARGETS=('grok') && AI_PLUGINS=('caveman|caveman|JuliusBrussee/caveman')" +@test "warns and skips an unknown target" { + _run_main "AI_PLUGIN_TARGETS=('grok') && AI_PLUGINS=('$CAVEMAN')" assert_success assert_line "warn: [ai-plugins] unknown target 'grok'; skipping." [ ! -s "$CLAUDE_ARGS_FILE" ] [ ! -s "$NPX_ARGS_FILE" ] + [ ! -s "$COPILOT_ARGS_FILE" ] } -@test "main: fails when claude is missing for a claude-code target" { +@test "fails when claude is missing for a claude-code target" { rm -f "$BATS_TEST_TMPDIR/bin/claude" # Drop /usr/bin so a host-installed claude cannot mask the missing path; /bin still provides bash. export PATH="$BATS_TEST_TMPDIR/bin:/bin" - _run_main "AI_PLUGIN_TARGETS=('claude-code') && AI_PLUGINS=('caveman|caveman|JuliusBrussee/caveman')" + _run_main "AI_PLUGIN_TARGETS=('claude-code') && AI_PLUGINS=('$CAVEMAN')" assert_failure 1 assert_line "error: [ai-plugins] claude CLI not found. Ensure Claude Code is installed." } -@test "main: fails when npx is missing for a github-copilot target" { +@test "github-copilot: warns but succeeds when npx is missing for a skill plugin" { rm -f "$BATS_TEST_TMPDIR/bin/npx" # Drop /usr/bin so a host-installed npx cannot mask the missing path; /bin still provides bash. export PATH="$BATS_TEST_TMPDIR/bin:/bin" - _run_main "AI_PLUGIN_TARGETS=('github-copilot') && AI_PLUGINS=('caveman|caveman|JuliusBrussee/caveman')" + _run_main "AI_PLUGIN_TARGETS=('github-copilot') && AI_PLUGINS=('$CAVEMAN')" - assert_failure 1 + assert_success assert_line "error: [ai-plugins] npx not found. Ensure Node.js is installed (run_onchange_03_install-mise-tools)." + assert_line "warn: [ai-plugins] 1 plugin(s) failed to install:" + assert_line " - caveman" +} + +@test "github-copilot: warns but succeeds when the copilot CLI is missing for a plugin" { + rm -f "$BATS_TEST_TMPDIR/bin/copilot" + # Drop /usr/bin so a host-installed copilot cannot mask the missing path; /bin still provides bash. + export PATH="$BATS_TEST_TMPDIR/bin:/bin" + _run_main "AI_PLUGIN_TARGETS=('github-copilot') && AI_PLUGINS=('$SUPERPOWERS')" + + assert_success + assert_line "error: [ai-plugins] copilot CLI not found. Install it (brew install copilot-cli)." + assert_line "warn: [ai-plugins] 1 plugin(s) failed to install:" + assert_line " - superpowers" +} + +@test "github-copilot: a missing copilot CLI does not block the skill plugin" { + # Keep the setup PATH (with /usr/bin) so the npx mock still runs; a copilot + # cask installs to /opt/homebrew/bin, off this PATH, so removing the mock + # alone makes `command -v copilot` fail. + rm -f "$BATS_TEST_TMPDIR/bin/copilot" + _run_main "AI_PLUGIN_TARGETS=('github-copilot') && AI_PLUGINS=('$CAVEMAN' '$SUPERPOWERS')" + + assert_success + # caveman still installs as a skill; only superpowers fails. + assert_line "--yes skills add JuliusBrussee/caveman -a github-copilot --global --copy --yes" + assert_line "warn: [ai-plugins] 1 plugin(s) failed to install:" + assert_line " - superpowers" + refute_line " - caveman" }