--- - name: Validate agent skills sync input ansible.builtin.assert: that: - agent_skills_user | length > 0 - agent_skills_group | length > 0 - agent_skills_home | length > 0 - agent_skills_local_source_dir | length > 0 - (not agent_skills_xworkspace_core_enabled | bool) or agent_skills_xworkspace_core_source_dir | length > 0 - agent_skills_remote_dir | length > 0 - agent_skills_targets | length > 0 fail_msg: "agent_skills_user, home, source dirs, remote dir, and targets must be set." - name: Build required agent skills list ansible.builtin.set_fact: agent_skills_required_entries: "{{ agent_skills_typical_scenario_skills + agent_skills_extra_required_skills }}" - name: Ensure local agent skills source directory exists for auto install ansible.builtin.file: path: "{{ agent_skills_local_source_dir }}" state: directory mode: "0755" delegate_to: localhost become: false check_mode: false when: - agent_skills_local_source_create | bool - agent_skills_auto_install_enabled | bool - name: Inspect local agent skills source directory ansible.builtin.stat: path: "{{ agent_skills_local_source_dir }}" register: agent_skills_local_source delegate_to: localhost become: false - name: Assert local agent skills source directory exists ansible.builtin.assert: that: - agent_skills_local_source.stat.isdir | default(false) fail_msg: "Local skills source directory does not exist: {{ agent_skills_local_source_dir }}" - name: Inspect xworkspace core skills source directory ansible.builtin.stat: path: "{{ agent_skills_xworkspace_core_source_dir }}" register: agent_skills_xworkspace_core_source delegate_to: localhost become: false when: agent_skills_xworkspace_core_enabled | bool - name: Assert xworkspace core skills source directory exists ansible.builtin.assert: that: - agent_skills_xworkspace_core_source.stat.isdir | default(false) fail_msg: "xworkspace core skills source directory does not exist: {{ agent_skills_xworkspace_core_source_dir }}" when: - agent_skills_xworkspace_core_enabled | bool - agent_skills_xworkspace_core_required | bool - name: Build effective agent skills source directories ansible.builtin.set_fact: agent_skills_effective_source_dirs: >- {{ [agent_skills_local_source_dir] + ( ( agent_skills_xworkspace_core_enabled | bool and agent_skills_xworkspace_core_source.stat.isdir | default(false) ) | ternary([agent_skills_xworkspace_core_source_dir], []) ) }} - name: Inspect required local scenario skills ansible.builtin.shell: | set -eu for source_dir in {{ agent_skills_effective_source_dirs | map('quote') | join(' ') }}; do for candidate in {{ ([item.name] + (item.aliases | default([]))) | unique | map('quote') | join(' ') }}; do if [ -f "$source_dir/$candidate/SKILL.md" ]; then printf '%s\n' "$source_dir/$candidate" exit 0 fi match="$(find "$source_dir" -type f -path "*/$candidate/SKILL.md" -print -quit)" if [ -n "$match" ]; then dirname "$match" exit 0 fi done done exit 1 args: executable: /bin/bash register: agent_skills_local_skill_presence changed_when: false failed_when: false loop: "{{ agent_skills_required_entries }}" loop_control: label: "{{ item.name }}" delegate_to: localhost become: false check_mode: false - name: Build missing scenario skills list ansible.builtin.set_fact: agent_skills_missing_entries: >- {{ agent_skills_local_skill_presence.results | selectattr('rc', 'ne', 0) | map(attribute='item') | list }} - name: Install missing scenario skills from local installer adapters ansible.builtin.shell: | set -eu skill_name={{ item.name | quote }} target_dir={{ agent_skills_local_source_dir | quote }} target_parent="$(dirname "$target_dir")" target_base="$(basename "$target_dir")" install_rc=1 if command -v clawhub >/dev/null 2>&1; then for install_name in {{ ([item.install_name | default(item.name)] + (item.aliases | default([]))) | unique | map('quote') | join(' ') }}; do if clawhub --workdir "$target_parent" --dir "$target_base" --no-input install \ {{ (item.install_force | default(false) | bool) | ternary('--force', '') }} "$install_name"; then install_rc=0 break fi done exit "$install_rc" elif command -v find-skills >/dev/null 2>&1; then for install_name in {{ ([item.install_name | default(item.name)] + (item.aliases | default([]))) | unique | map('quote') | join(' ') }}; do if find-skills install "$install_name" --target "$target_dir"; then install_rc=0 break fi done exit "$install_rc" elif [ "{{ agent_skills_auto_install_fail_on_missing_installer | bool | ternary('true', 'false') }}" = "true" ]; then echo "Missing installer for $skill_name. Install clawhub or find-skills, or preseed $target_dir/$skill_name." >&2 exit 127 else echo "Skipped missing skill $skill_name because no installer adapter is available." >&2 fi args: executable: /bin/bash loop: "{{ agent_skills_missing_entries }}" loop_control: label: "{{ item.name }}" register: agent_skills_install_result changed_when: agent_skills_install_result.rc == 0 delegate_to: localhost become: false when: - agent_skills_auto_install_enabled | bool - agent_skills_missing_entries | length > 0 - name: Reinspect required local scenario skills after auto install ansible.builtin.shell: | set -eu for source_dir in {{ agent_skills_effective_source_dirs | map('quote') | join(' ') }}; do for candidate in {{ ([item.name] + (item.aliases | default([]))) | unique | map('quote') | join(' ') }}; do if [ -f "$source_dir/$candidate/SKILL.md" ]; then printf '%s\n' "$source_dir/$candidate" exit 0 fi match="$(find "$source_dir" -type f -path "*/$candidate/SKILL.md" -print -quit)" if [ -n "$match" ]; then dirname "$match" exit 0 fi done done exit 1 args: executable: /bin/bash register: agent_skills_local_skill_presence_after_install changed_when: false failed_when: false loop: "{{ agent_skills_required_entries }}" loop_control: label: "{{ item.name }}" delegate_to: localhost become: false when: agent_skills_auto_install_enabled | bool check_mode: false - name: Build unresolved scenario skills list ansible.builtin.set_fact: agent_skills_unresolved_entries: >- {{ ( agent_skills_auto_install_enabled | bool ) | ternary( agent_skills_local_skill_presence_after_install.results | default([]), agent_skills_local_skill_presence.results | default([]) ) | selectattr('rc', 'ne', 0) | map(attribute='item.name') | list }} - name: Assert required scenario skills are available locally ansible.builtin.assert: that: - agent_skills_unresolved_entries | length == 0 fail_msg: >- Required scenario skills are still missing from {{ agent_skills_local_source_dir }}: {{ agent_skills_unresolved_entries | join(', ') }}. - name: Build resolved local scenario skill paths ansible.builtin.set_fact: agent_skills_resolved_local_paths: >- {{ ( (agent_skills_auto_install_enabled | bool) | ternary( agent_skills_local_skill_presence_after_install.results | default([]), agent_skills_local_skill_presence.results | default([]) ) ) | selectattr('rc', 'eq', 0) | map(attribute='stdout') | list | unique }} - name: Run optional scenario skill quality gates ansible.builtin.shell: | set -eu skill_path={{ item.0 | quote }} gate_name={{ item.1.name | quote }} if command -v "$gate_name" >/dev/null 2>&1; then {{ item.1.argv_prefix | map('quote') | join(' ') }} "$skill_path" else echo "Skipped missing quality gate: $gate_name" fi args: executable: /bin/bash register: agent_skills_quality_gate_results changed_when: false failed_when: agent_skills_quality_gate_fail_on_error | bool and agent_skills_quality_gate_results.rc != 0 loop: "{{ agent_skills_resolved_local_paths | product(agent_skills_quality_gate_commands) | list }}" loop_control: label: "{{ item.1.name }} {{ item.0 | basename }}" delegate_to: localhost become: false when: - agent_skills_quality_gate_enabled | bool - agent_skills_resolved_local_paths | length > 0 check_mode: false - name: Detect local top-level symlink skills ansible.builtin.find: paths: "{{ agent_skills_local_source_dir }}" file_type: link recurse: false register: agent_skills_local_symlinks delegate_to: localhost become: false - name: Build rsync excludes for local symlink skills ansible.builtin.set_fact: agent_skills_local_symlink_excludes: >- {{ agent_skills_local_symlinks.files | map(attribute='path') | map('basename') | list }} - name: Install rsync for agent skills sync ansible.builtin.apt: name: rsync state: present update_cache: true environment: DEBIAN_FRONTEND: noninteractive APT_LISTCHANGES_FRONTEND: none when: agent_skills_install_rsync | bool - name: Ensure agent skills owner home exists ansible.builtin.file: path: "{{ agent_skills_home }}" state: directory owner: "{{ agent_skills_user }}" group: "{{ agent_skills_group }}" mode: "0755" - name: Ensure canonical agent skills directory exists ansible.builtin.file: path: "{{ agent_skills_remote_dir }}" state: directory owner: "{{ agent_skills_user }}" group: "{{ agent_skills_group }}" mode: "0755" - name: Sync local agent skills into canonical directory ansible.builtin.command: argv: >- {{ [ 'rsync', '-a', '--partial', '--timeout=' ~ (agent_skills_rsync_timeout | string) ] + (['--dry-run'] if ansible_check_mode else []) + (['-z'] if (agent_skills_rsync_compress | bool) else []) + (['--delete'] if (agent_skills_delete_removed | bool and agent_skills_source_index == 0) else []) + (['--delete-excluded'] if (agent_skills_delete_removed | bool and agent_skills_source_index == 0) else []) + ( ( agent_skills_rsync_excludes + ((agent_skills_source_index == 0) | ternary(agent_skills_local_symlink_excludes, [])) ) | map('regex_replace', '^(.*)$', '--exclude=\1') | list ) + agent_skills_rsync_extra_opts + [ '-e', 'ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null', item ~ '/', ( ansible_user | default(ansible_ssh_user) | default('root') ) ~ '@' ~ ( ansible_host | default(inventory_hostname) ) ~ ':' ~ agent_skills_remote_dir ~ '/' ] }} register: agent_skills_rsync_result changed_when: "'<>' in agent_skills_rsync_result.stdout" check_mode: false loop: "{{ agent_skills_effective_source_dirs }}" loop_control: index_var: agent_skills_source_index label: "{{ item }}" delegate_to: localhost become: false - name: Set canonical agent skills ownership ansible.builtin.file: path: "{{ agent_skills_remote_dir }}" state: directory owner: "{{ agent_skills_user }}" group: "{{ agent_skills_group }}" recurse: true - name: Link nested categorized skills at canonical root ansible.builtin.shell: | set -eu changed=0 while IFS= read -r skill_manifest; do skill_dir="$(dirname "$skill_manifest")" skill_name="$(basename "$skill_dir")" link_path={{ agent_skills_remote_dir | quote }}/"$skill_name" if [ -e "$link_path" ] && [ ! -L "$link_path" ]; then continue fi current_target="" if [ -L "$link_path" ]; then current_target="$(readlink "$link_path")" fi if [ "$current_target" != "$skill_dir" ]; then if [ "{{ ansible_check_mode | ternary('true', 'false') }}" != "true" ]; then ln -sfn "$skill_dir" "$link_path" fi changed=1 fi done < <(find {{ agent_skills_remote_dir | quote }} -mindepth 3 -name SKILL.md -type f -print) if [ "$changed" = "1" ]; then echo "<>linked nested skills" fi args: executable: /bin/bash register: agent_skills_flatten_result changed_when: "'<>' in agent_skills_flatten_result.stdout" check_mode: false when: agent_skills_remote_flatten_nested_skills | bool - name: Set canonical agent skills ownership after nested links ansible.builtin.file: path: "{{ agent_skills_remote_dir }}" state: directory owner: "{{ agent_skills_user }}" group: "{{ agent_skills_group }}" recurse: true when: agent_skills_remote_flatten_nested_skills | bool - name: Flatten agent skills target paths ansible.builtin.set_fact: agent_skills_target_paths: "{{ agent_skills_targets | subelements('paths') | map('last') | list }}" - name: Inspect agent skills target paths ansible.builtin.stat: path: "{{ item }}" register: agent_skills_target_path_stats loop: "{{ agent_skills_target_paths }}" - name: Reject existing non-link target directories unless replacement is enabled ansible.builtin.fail: msg: >- Agent skills target already exists and is not a symlink: {{ item.item }}. Set agent_skills_replace_existing_target_dirs=true if this path should be replaced with a link to {{ agent_skills_remote_dir }}. loop: "{{ agent_skills_target_path_stats.results }}" when: - item.stat.exists | default(false) - not item.stat.islnk | default(false) - item.item not in agent_skills_preserve_existing_target_dirs - not agent_skills_replace_existing_target_dirs | bool - name: Replace existing non-link target directories when enabled ansible.builtin.file: path: "{{ item.item }}" state: absent loop: "{{ agent_skills_target_path_stats.results }}" when: - item.stat.exists | default(false) - not item.stat.islnk | default(false) - item.item not in agent_skills_preserve_existing_target_dirs - agent_skills_replace_existing_target_dirs | bool - name: Build agent skills target parent paths ansible.builtin.set_fact: agent_skills_target_parent_paths: "{{ agent_skills_target_paths | map('dirname') | list | unique }}" - name: Inspect agent skills target parent directories ansible.builtin.stat: path: "{{ item }}" register: agent_skills_target_parent_stats loop: "{{ agent_skills_target_parent_paths }}" - name: Ensure agent skills target parent directories exist ansible.builtin.file: path: "{{ item.item }}" state: directory owner: "{{ agent_skills_user }}" group: "{{ agent_skills_group }}" mode: "0755" loop: "{{ agent_skills_target_parent_stats.results }}" when: - not item.stat.exists | default(false) - name: Link agent skills targets to canonical directory ansible.builtin.file: src: "{{ agent_skills_remote_dir }}" dest: "{{ item }}" state: link force: true loop: "{{ agent_skills_target_paths }}" when: item not in agent_skills_preserve_existing_target_dirs - name: Verify canonical skill manifests are present ansible.builtin.find: paths: "{{ agent_skills_remote_dir }}" patterns: SKILL.md recurse: true file_type: file register: agent_skills_manifest_files - name: Assert synced agent skills contain manifests ansible.builtin.assert: that: - agent_skills_manifest_files.matched | int > 0 fail_msg: "No SKILL.md files found under {{ agent_skills_remote_dir }} after sync." - name: Report synced agent skills ansible.builtin.debug: msg: >- Synced {{ agent_skills_manifest_files.matched }} skill manifests into {{ agent_skills_remote_dir }} and linked {{ agent_skills_target_paths | length }} agent target directories.