475 lines
16 KiB
YAML
475 lines
16 KiB
YAML
---
|
|
- 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: "'<<CHANGED>>' 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 "<<CHANGED>>linked nested skills"
|
|
fi
|
|
args:
|
|
executable: /bin/bash
|
|
register: agent_skills_flatten_result
|
|
changed_when: "'<<CHANGED>>' 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.
|