diff --git a/.github/workflows/test-distros.yaml b/.github/workflows/test-distros.yaml index 728f9fe..8c6b74f 100644 --- a/.github/workflows/test-distros.yaml +++ b/.github/workflows/test-distros.yaml @@ -42,6 +42,7 @@ jobs: - name: chezmoi init dotfiles run: | sudo -u dev chezmoi init collieiscute --branch "$CHEZMOI_BRANCH" --apply -v + sudo -u dev bash /home/dev/.local/share/chezmoi/tests/sync-ai-extensions.sh macos: name: macos:latest @@ -53,3 +54,4 @@ jobs: - name: chezmoi init dotfiles run: | chezmoi init collieiscute --branch "$CHEZMOI_BRANCH" --apply -v + bash "$HOME/.local/share/chezmoi/tests/sync-ai-extensions.sh" diff --git a/README.md b/README.md index 0eb6a18..70e61d8 100644 --- a/README.md +++ b/README.md @@ -201,9 +201,9 @@ Then `chezmoi apply`. ### AI extensions -- [`home/.chezmoidata/ai.yaml`](home/.chezmoidata/ai.yaml) is the single inventory for user skills, marketplaces, and plugins across Claude Code, Codex, and OpenCode. Entries intentionally track upstream latest releases. -- `run_after_5-sync-ai-extensions.sh.tmpl` synchronizes Claude Code and Codex plugins and copies listed skills to each CLI's user skill directory on every `chezmoi apply`. Removing an entry from the inventory does not uninstall existing local content. -- OpenCode plugins are rendered into `~/.config/opencode/opencode.json`; OpenCode installs them at startup. Review and trust new Codex hooks manually with `/hooks`. +- `run_after_5-sync-ai-extensions.sh` checks npm versions and Git revisions in parallel during every full `chezmoi apply`, then runs plugin managers only when an installed version is stale or missing. +- Shared skills are copied to each CLI's user skill directory from one checkout under `~/.local/share/chezmoi-ai`. Review and trust new Codex hooks manually with `/hooks`. +- OpenCode plugins are declared in `~/.config/opencode/opencode.json`; the sync script refreshes a missing or stale generated package cache. ### Fish plugins (managed by [`fisher`](https://github.com/jorgebucaran/fisher)) diff --git a/home/.chezmoidata/ai.yaml b/home/.chezmoidata/ai.yaml deleted file mode 100644 index d4168a2..0000000 --- a/home/.chezmoidata/ai.yaml +++ /dev/null @@ -1,30 +0,0 @@ -ai: - skills: - - id: addyosmani-agent-skills - type: git - source: https://github.com/addyosmani/agent-skills.git - path: skills - hosts: [claude, codex, opencode] - - id: respond-structured - type: local - path: .local/share/ai-skills - hosts: [claude, codex, opencode] - - claude: - marketplaces: - - name: ponytail - source: DietrichGebert/ponytail - plugins: - - ponytail@ponytail - - codex: - marketplaces: - - name: ponytail - source: DietrichGebert/ponytail - plugins: - - ponytail@ponytail - - opencode: - plugins: - - "@khalilgharbaoui/opencode-claude-code-plugin" - - "@dietrichgebert/ponytail" diff --git a/home/.chezmoiscripts/run_after_5-sync-ai-extensions.sh b/home/.chezmoiscripts/run_after_5-sync-ai-extensions.sh new file mode 100644 index 0000000..1bee288 --- /dev/null +++ b/home/.chezmoiscripts/run_after_5-sync-ai-extensions.sh @@ -0,0 +1,191 @@ +#!/usr/bin/env bash +set -u + +PONYTAIL_PACKAGE='@dietrichgebert/ponytail' +PONYTAIL_PLUGIN='ponytail@ponytail' +PONYTAIL_REPO='DietrichGebert/ponytail' +CLAUDE_PROVIDER_PACKAGE='@khalilgharbaoui/opencode-claude-code-plugin' +SKILLS_REPO='https://github.com/addyosmani/agent-skills.git' + +info() { + printf 'AI extensions: %s\n' "$*" +} + +json_version() { + [[ "$1" =~ \"version\"[[:space:]]*:[[:space:]]*\"([^\"]+)\" ]] && printf '%s\n' "${BASH_REMATCH[1]}" +} + +npm_latest() { + local json + json="$(curl --connect-timeout 3 --max-time 10 -fsSL "$1")" || return + json_version "$json" +} + +with_timeout() { + local seconds="$1" + shift + if command -v timeout >/dev/null 2>&1; then + timeout "$seconds" "$@" + elif command -v gtimeout >/dev/null 2>&1; then + gtimeout "$seconds" "$@" + elif command -v perl >/dev/null 2>&1; then + perl -e 'alarm shift; exec @ARGV' "$seconds" "$@" + else + "$@" + fi +} + +move_exact() { + if command -v perl >/dev/null 2>&1; then + perl -e 'rename $ARGV[0], $ARGV[1] or exit 1' "$1" "$2" + else + mv -T -- "$1" "$2" + fi +} + +update_claude() { + local version="$1" marketplaces plugins + info "Updating Claude Code $PONYTAIL_PLUGIN to $version" + marketplaces="$(with_timeout 30 claude plugin marketplace list --json)" || return + if [[ "${marketplaces//[[:space:]]/}" != *"\"name\":\"ponytail\""* ]]; then + with_timeout 120 claude plugin marketplace add "$PONYTAIL_REPO" --scope user || return + else + with_timeout 120 claude plugin marketplace update ponytail || return + fi + plugins="$(with_timeout 30 claude plugin list --json)" || return + if [[ "${plugins//[[:space:]]/}" == *"\"id\":\"$PONYTAIL_PLUGIN\""* ]]; then + with_timeout 120 claude plugin update "$PONYTAIL_PLUGIN" --scope user + else + with_timeout 120 claude plugin install "$PONYTAIL_PLUGIN" --scope user + fi +} + +update_codex() { + local version="$1" marketplaces + info "Updating Codex $PONYTAIL_PLUGIN to $version" + marketplaces="$(with_timeout 30 codex plugin marketplace list --json)" || return + if [[ "${marketplaces//[[:space:]]/}" != *"\"name\":\"ponytail\""* ]]; then + with_timeout 120 codex plugin marketplace add "$PONYTAIL_REPO" --json || return + else + with_timeout 120 codex plugin marketplace upgrade ponytail --json || return + fi + with_timeout 120 codex plugin add "$PONYTAIL_PLUGIN" --json +} + +update_opencode() ( + local package="$1" version="$2" + local cache_root="${XDG_CACHE_HOME:-$HOME/.cache}/opencode/packages" + local cache="$cache_root/${package}@latest" + local manifest="$cache/node_modules/$package/package.json" + local backup="${cache}.previous" + local discard="${cache}.discard.$$" + local lock="${cache}.sync-lock.$$" marker=".sync-ai-$$" contender contender_pid staging='' staged_cache staged_manifest + [[ -r "$manifest" && "$(json_version "$(<"$manifest")")" == "$version" ]] && return + info "Updating OpenCode $package to $version" + + mkdir -p "$(dirname "$cache")" || return + mkdir "$lock" 2>/dev/null || return + trap 'rm -rf -- "$lock"' EXIT + for contender in "${cache}.sync-lock."*; do + [[ "$contender" == "$lock" || ! -d "$contender" ]] && continue + contender_pid="${contender##*.}" + if [[ "$contender_pid" =~ ^[0-9]+$ ]] && kill -0 "$contender_pid" 2>/dev/null; then + return + fi + rm -rf -- "$contender" || return + done + trap '[[ -e "$cache" || ! -e "$backup" ]] || move_exact "$backup" "$cache" || :; [[ -z "$staging" ]] || rm -rf -- "$staging"; rm -rf -- "$discard" "$lock"' EXIT + + [[ -e "$cache" || ! -e "$backup" ]] || move_exact "$backup" "$cache" || return + staging="$(mktemp -d "$cache_root/.sync-ai.XXXXXX")" || return + + XDG_CACHE_HOME="$staging" XDG_CONFIG_HOME="$staging/config" \ + with_timeout 120 opencode plugin "$package" --global --pure || return + staged_cache="$staging/opencode/packages/${package}@latest" + staged_manifest="$staged_cache/node_modules/$package/package.json" + [[ -r "$staged_manifest" && "$(json_version "$(<"$staged_manifest")")" == "$version" ]] || return + : >"$staged_cache/$marker" || return + + rm -rf -- "$discard" || return + if [[ -e "$backup" ]]; then + [[ ! -e "$cache" ]] || move_exact "$cache" "$discard" || return + else + [[ ! -e "$cache" ]] || move_exact "$cache" "$backup" || return + fi + if move_exact "$staged_cache" "$cache" && [[ -e "$cache/$marker" ]] && + [[ -r "$manifest" && "$(json_version "$(<"$manifest")")" == "$version" ]]; then + rm -f -- "$cache/$marker" + rm -rf -- "$backup" "$discard" + return + fi + return 1 +) + +sync_skills() { + local remote="$1" checkout="$HOME/.local/share/chezmoi-ai/sources/addyosmani-agent-skills" + mkdir -p "$(dirname "$checkout")" || return + if [[ ! -d "$checkout/.git" ]]; then + info 'Cloning addyosmani-agent-skills' + with_timeout 30 git clone --depth=1 "$SKILLS_REPO" "$checkout" || return + elif [[ -n "$remote" && "$(git -C "$checkout" rev-parse HEAD)" != "$remote" ]]; then + if [[ -n "$(git -C "$checkout" status --porcelain)" ]]; then + info 'Keeping locally modified addyosmani-agent-skills' + else + info 'Updating addyosmani-agent-skills' + with_timeout 30 git -C "$checkout" pull --ff-only "$SKILLS_REPO" main || return + [[ "$(git -C "$checkout" rev-parse HEAD)" == "$remote" ]] || return + fi + fi + + local destination skill source + for source in "$checkout/skills" "$HOME/.local/share/ai-skills"; do + [[ -d "$source" ]] || continue + for destination in "$HOME/.claude/skills" "$HOME/.agents/skills" "$HOME/.config/opencode/skills"; do + mkdir -p "$destination" || return + for skill in "$source"/*; do + [[ ! -d "$skill" ]] || cp -a "$skill" "$destination/" || return + done + done + done +} + +main() { + local tmp ponytail_version provider_version skills_revision + tmp="$(mktemp -d)" || return + trap "rm -rf -- '$tmp'" EXIT + npm_latest 'https://registry.npmjs.org/@dietrichgebert%2fponytail/latest' >"$tmp/ponytail" & + local ponytail_pid=$! + npm_latest 'https://registry.npmjs.org/@khalilgharbaoui%2fopencode-claude-code-plugin/latest' >"$tmp/provider" & + local provider_pid=$! + with_timeout 10 git ls-remote --exit-code "$SKILLS_REPO" refs/heads/main 2>/dev/null | cut -f1 >"$tmp/skills" & + local skills_pid=$! + wait "$ponytail_pid" || : + wait "$provider_pid" || : + wait "$skills_pid" || : + + ponytail_version="$(<"$tmp/ponytail")" + provider_version="$(<"$tmp/provider")" + skills_revision="$(<"$tmp/skills")" + + if [[ -n "$ponytail_version" ]]; then + local claude_cache="$HOME/.claude/plugins/cache/ponytail/ponytail/$ponytail_version" + if command -v claude >/dev/null 2>&1 && { [[ ! -d "$claude_cache" ]] || ! grep -Fq "\"installPath\": \"$claude_cache\"" "$HOME/.claude/plugins/installed_plugins.json" 2>/dev/null; }; then + update_claude "$ponytail_version" || info 'Claude Code update failed; keeping the installed version' + fi + if command -v codex >/dev/null 2>&1 && { [[ ! -d "$HOME/.codex/plugins/cache/ponytail/ponytail/$ponytail_version" ]] || ! grep -Fq "[plugins.\"$PONYTAIL_PLUGIN\"]" "$HOME/.codex/config.toml" 2>/dev/null; }; then + update_codex "$ponytail_version" || info 'Codex update failed; keeping the installed version' + fi + if command -v opencode >/dev/null 2>&1; then + update_opencode "$PONYTAIL_PACKAGE" "$ponytail_version" || info 'OpenCode Ponytail update failed' + fi + else + info 'Could not check the Ponytail version; keeping the installed version' + fi + + if command -v opencode >/dev/null 2>&1 && [[ -n "$provider_version" ]]; then + update_opencode "$CLAUDE_PROVIDER_PACKAGE" "$provider_version" || info 'OpenCode plugin update failed; keeping the installed version' + fi + sync_skills "$skills_revision" || info 'Skill synchronization failed; keeping the existing skills' +} + +[[ "${BASH_SOURCE[0]}" != "$0" ]] || main "$@" diff --git a/home/.chezmoiscripts/run_after_5-sync-ai-extensions.sh.tmpl b/home/.chezmoiscripts/run_after_5-sync-ai-extensions.sh.tmpl deleted file mode 100644 index b5d80ad..0000000 --- a/home/.chezmoiscripts/run_after_5-sync-ai-extensions.sh.tmpl +++ /dev/null @@ -1,102 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -info() { - printf 'AI extensions: %s\n' "$*" -} - -skill_destination() { - case "$1" in - claude) printf '%s\n' "$HOME/.claude/skills" ;; - codex) printf '%s\n' "$HOME/.agents/skills" ;; - opencode) printf '%s\n' "$HOME/.config/opencode/skills" ;; - *) - printf 'Unknown AI skill host: %s\n' "$1" >&2 - return 1 - ;; - esac -} - -sync_skill_collection() { - local source="$1" - local path="$2" - shift 2 - - if [[ ! -d "$source/$path" ]]; then - printf 'AI skill source is missing: %s\n' "$source/$path" >&2 - return 1 - fi - - local host destination skill - for host in "$@"; do - destination="$(skill_destination "$host")" - mkdir -p "$destination" - for skill in "$source/$path"/*; do - [[ -d "$skill" ]] || continue - cp -a "$skill" "$destination/" - done - done -} - -sync_git_skill_collection() { - local id="$1" - local source="$2" - local path="$3" - shift 3 - local checkout="$HOME/.local/share/chezmoi-ai/sources/$id" - - mkdir -p "$(dirname "$checkout")" - if [[ ! -d "$checkout/.git" ]]; then - info "Cloning skill collection $id" - git clone --depth=1 "$source" "$checkout" - elif git -C "$checkout" diff --quiet && git -C "$checkout" diff --cached --quiet; then - info "Updating skill collection $id" - git -C "$checkout" pull --ff-only - else - info "Keeping locally modified skill collection $id" - fi - - sync_skill_collection "$checkout" "$path" "$@" -} - -if command -v claude >/dev/null 2>&1; then - info "Synchronizing Claude Code plugins" - {{- range .ai.claude.marketplaces }} - if ! claude plugin marketplace list --json | grep -Fq '"name": "{{ .name }}"'; then - claude plugin marketplace add "{{ .source }}" --scope user - fi - claude plugin marketplace update "{{ .name }}" - {{- end }} - {{- range .ai.claude.plugins }} - if claude plugin list --json | grep -Fq '"id": "{{ . }}"'; then - claude plugin update "{{ . }}" --scope user - else - claude plugin install "{{ . }}" --scope user - fi - {{- end }} -else - info "Claude Code is not installed; skipping" -fi - -if command -v codex >/dev/null 2>&1; then - info "Synchronizing Codex plugins" - {{- range .ai.codex.marketplaces }} - if ! codex plugin marketplace list --json | grep -Fq '"name": "{{ .name }}"'; then - codex plugin marketplace add "{{ .source }}" --json - fi - codex plugin marketplace upgrade "{{ .name }}" --json - {{- end }} - {{- range .ai.codex.plugins }} - codex plugin add "{{ . }}" --json - {{- end }} -else - info "Codex is not installed; skipping" -fi - -{{- range .ai.skills }} -{{- if eq .type "git" }} -sync_git_skill_collection "{{ .id }}" "{{ .source }}" "{{ .path }}"{{ range .hosts }} "{{ . }}"{{ end }} -{{- else if eq .type "local" }} -sync_skill_collection "$HOME/{{ .path }}" "."{{ range .hosts }} "{{ . }}"{{ end }} -{{- end }} -{{- end }} diff --git a/home/dot_claude/private_settings.json.tmpl b/home/dot_claude/private_settings.json similarity index 63% rename from home/dot_claude/private_settings.json.tmpl rename to home/dot_claude/private_settings.json index 3eb603b..7d722f9 100644 --- a/home/dot_claude/private_settings.json.tmpl +++ b/home/dot_claude/private_settings.json @@ -14,21 +14,15 @@ "model": "opus", "effortLevel": "max", "enabledPlugins": { - {{- range $index, $plugin := .ai.claude.plugins }} - {{- if $index }},{{ end }} - "{{ $plugin }}": true - {{- end }} + "ponytail@ponytail": true }, "extraKnownMarketplaces": { - {{- range $index, $marketplace := .ai.claude.marketplaces }} - {{- if $index }},{{ end }} - "{{ $marketplace.name }}": { + "ponytail": { "source": { "source": "github", - "repo": "{{ $marketplace.source }}" + "repo": "DietrichGebert/ponytail" } } - {{- end }} }, "theme": "dark", "verbose": true, diff --git a/home/dot_config/opencode/opencode.json.tmpl b/home/dot_config/opencode/opencode.json.tmpl index ece2991..a556027 100644 --- a/home/dot_config/opencode/opencode.json.tmpl +++ b/home/dot_config/opencode/opencode.json.tmpl @@ -2,10 +2,8 @@ "$schema": "https://opencode.ai/config.json", "shell": "fish", "plugin": [ - {{- range $index, $plugin := .ai.opencode.plugins }} - {{- if $index }},{{ end }} - "{{ $plugin }}" - {{- end }} + "@khalilgharbaoui/opencode-claude-code-plugin", + "@dietrichgebert/ponytail" ], "permission": { "bash": "allow", diff --git a/tests/sync-ai-extensions.sh b/tests/sync-ai-extensions.sh new file mode 100644 index 0000000..6f3ab04 --- /dev/null +++ b/tests/sync-ai-extensions.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "$repo/home/.chezmoiscripts/run_after_5-sync-ai-extensions.sh" + +[[ "$(json_version '{"version":"0.9.2"}')" == "0.9.2" ]] + +( + npm_latest() { return 1; } + with_timeout() { return 1; } + sync_skills() { return 1; } + main >/dev/null +) + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +export XDG_CACHE_HOME="$tmp/cache" +manifest="$XDG_CACHE_HOME/opencode/packages/example/plugin@latest/node_modules/example/plugin/package.json" +mkdir -p "$(dirname "$manifest")" +printf '%s\n' '{"version":"1.0.0"}' >"$manifest" +mkdir -p "$tmp/bin" +printf '%s\n' '#!/bin/sh' 'exit 1' >"$tmp/bin/opencode" +chmod +x "$tmp/bin/opencode" +PATH="$tmp/bin:$PATH" +! update_opencode example/plugin 2.0.0 >/dev/null +[[ "$(json_version "$(<"$manifest")")" == "1.0.0" ]] + +printf '%s\n' \ + '#!/bin/sh' \ + 'package="$2"' \ + 'manifest="$XDG_CACHE_HOME/opencode/packages/${package}@latest/node_modules/$package/package.json"' \ + 'mkdir -p "$(dirname "$manifest")"' \ + 'printf '\''%s\n'\'' '\''{"version":"2.0.0"}'\'' >"$manifest"' \ + >"$tmp/bin/opencode" +update_opencode example/plugin 2.0.0 >/dev/null +[[ "$(json_version "$(<"$manifest")")" == "2.0.0" ]] + +cache="$XDG_CACHE_HOME/opencode/packages/example/plugin@latest" +mv "$cache" "${cache}.previous" +printf '%s\n' '#!/bin/sh' 'exit 1' >"$tmp/bin/opencode" +! update_opencode example/plugin 3.0.0 >/dev/null +[[ "$(json_version "$(<"$manifest")")" == "2.0.0" ]]