playbooks/roles/agent_skills/tasks/main.yml
2026-05-26 12:58:56 +08:00

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.