Merge pull request 'agents: caller-provided skills hook (move node1-ssh to the owning repo)' (#65) from feat/caller-provided-skills into main
This commit was merged in pull request #65.
This commit is contained in:
+11
-13
@@ -112,21 +112,19 @@ jobs:
|
|||||||
SKILLS: ${{ steps.prep.outputs.skills }} # JSON array of skills this agent may load
|
SKILLS: ${{ steps.prep.outputs.skills }} # JSON array of skills this agent may load
|
||||||
run: bash "$SCRIPTS/install-opencode.sh"
|
run: bash "$SCRIPTS/install-opencode.sh"
|
||||||
|
|
||||||
- name: Set up read-only SSH alias `node1` (+ opencode skill so the agent actually knows about it)
|
- name: Install caller-provided skills (from the caller repo's .gitea/agent-skills/)
|
||||||
# 1) Writes the deploy key + an SSH config alias so the agent can run
|
# Framework skill-plugin hook. A consuming repo can ship its OWN opencode skills under
|
||||||
# `ssh node1 <read-only cmd>` (matches the homelab opencode.json allowlist).
|
# `.gitea/agent-skills/<name>/` (SKILL.md + skill.json + optional setup.sh) — e.g. homelab's
|
||||||
# 2) Emits a `node1-ssh` opencode Skill file under ~/.config/opencode/skills/ so any
|
# "ssh into the deploy host" skill. This installs the ones allowed for the running agent, so
|
||||||
# downstream repo's dev agent discovers this capability via OpenCode's skill registry
|
# deploy-target / infra specifics live in the repo they belong to, not in this framework.
|
||||||
# rather than having to trial against the permission allowlist. Only emitted when the
|
# SECRETS_JSON = toJSON(secrets): a caller's setup.sh reads the repo-specific secrets it needs
|
||||||
# swarm plumbing is actually wired for that caller (SWARM_HOST/SWARM_USER/SSH_PRIV_KEY).
|
# (whose names this framework can't know) via jq; it never touches disk here in the clear.
|
||||||
# All three secrets are passed via env and never inlined into shell — this shared workflow
|
|
||||||
# runs in repos that don't have them and must not fail there.
|
|
||||||
env:
|
env:
|
||||||
SCRIPTS: ${{ runner.temp }}/agents-scripts
|
SCRIPTS: ${{ runner.temp }}/agents-scripts
|
||||||
SWARM_HOST: ${{ secrets.SWARM_HOST }}
|
NAME: ${{ steps.prep.outputs.name }}
|
||||||
SWARM_USER: ${{ secrets.SWARM_USER }}
|
WORKSPACE: ${{ github.workspace }}
|
||||||
SSH_PRIV_KEY: ${{ secrets.SSH_PRIV_KEY }}
|
SECRETS_JSON: ${{ toJSON(secrets) }}
|
||||||
run: bash "$SCRIPTS/skill-node1-ssh.sh"
|
run: bash "$SCRIPTS/install-caller-skills.sh"
|
||||||
|
|
||||||
- name: Set up `gitea-api` skill (let agents read/write issues, PRs, Actions across repos)
|
- name: Set up `gitea-api` skill (let agents read/write issues, PRs, Actions across repos)
|
||||||
# Emits an opencode Skill file. The skill uses SELF_TOKEN — the running agent's OWN token
|
# Emits an opencode Skill file. The skill uses SELF_TOKEN — the running agent's OWN token
|
||||||
|
|||||||
@@ -1,8 +1,54 @@
|
|||||||
{
|
{
|
||||||
"pm": {"model":"ollama-cloud/gemma4:cloud","vision":true, "mode":"comment","skills":["gitea-api"],"desc":"Product manager — research, plan, ask clarifying questions, and decide which dev should do the work. Comments only; never edits files."},
|
"pm": {
|
||||||
"junior": {"model":"ollama-cloud/kimi-k2.7-code:cloud","vision":false,"mode":"pr", "skills":[],"desc":"Junior dev — small, low-risk changes (mostly YAML/compose/config). Text-only, cannot read images. Defers complex or image tasks to @senior or @lead."},
|
"model": "ollama-cloud/gemma4:cloud",
|
||||||
"senior": {"model":"ollama-cloud/glm-5.2:cloud","vision":false,"mode":"pr", "skills":["gitea-api","node1-ssh"],"desc":"Senior dev — complex, multi-file implementation (GLM-5.2 via Ollama Cloud, text-only)."},
|
"vision": true,
|
||||||
"lead": {"model":"anthropic/claude-opus-4-8","vision":true, "mode":"pr", "skills":["gitea-api","node1-ssh"],"desc":"Tech lead — the hardest problems, architecture, and final calls."},
|
"mode": "comment",
|
||||||
"qa": {"model":"ollama-cloud/minimax-m3:cloud","vision":true, "mode":"comment","skills":["gitea-api"],"desc":"QA — verifies things work. Drives a headless browser (Playwright) to open a URL/web app, click through it, screenshot, and report bugs or confirm behavior. Comments findings; opens no PRs."},
|
"skills": [
|
||||||
"ops": {"model":"anthropic/claude-opus-4-8","vision":false,"mode":"comment","skills":["gitea-admin"],"desc":"Gitea operator — administers the Gitea instance itself: create orgs/users/repos, manage labels and secrets, mint scoped per-user tokens, bootstrap new repos with the agent caller. Comments only; never edits code. ALWAYS confirms before any destructive action (delete user/repo/org)."}
|
"gitea-api"
|
||||||
|
],
|
||||||
|
"desc": "Product manager — research, plan, ask clarifying questions, and decide which dev should do the work. Comments only; never edits files."
|
||||||
|
},
|
||||||
|
"junior": {
|
||||||
|
"model": "ollama-cloud/kimi-k2.7-code:cloud",
|
||||||
|
"vision": false,
|
||||||
|
"mode": "pr",
|
||||||
|
"skills": [],
|
||||||
|
"desc": "Junior dev — small, low-risk changes (mostly YAML/compose/config). Text-only, cannot read images. Defers complex or image tasks to @senior or @lead."
|
||||||
|
},
|
||||||
|
"senior": {
|
||||||
|
"model": "ollama-cloud/glm-5.2:cloud",
|
||||||
|
"vision": false,
|
||||||
|
"mode": "pr",
|
||||||
|
"skills": [
|
||||||
|
"gitea-api"
|
||||||
|
],
|
||||||
|
"desc": "Senior dev — complex, multi-file implementation (GLM-5.2 via Ollama Cloud, text-only)."
|
||||||
|
},
|
||||||
|
"lead": {
|
||||||
|
"model": "anthropic/claude-opus-4-8",
|
||||||
|
"vision": true,
|
||||||
|
"mode": "pr",
|
||||||
|
"skills": [
|
||||||
|
"gitea-api"
|
||||||
|
],
|
||||||
|
"desc": "Tech lead — the hardest problems, architecture, and final calls."
|
||||||
|
},
|
||||||
|
"qa": {
|
||||||
|
"model": "ollama-cloud/minimax-m3:cloud",
|
||||||
|
"vision": true,
|
||||||
|
"mode": "comment",
|
||||||
|
"skills": [
|
||||||
|
"gitea-api"
|
||||||
|
],
|
||||||
|
"desc": "QA — verifies things work. Drives a headless browser (Playwright) to open a URL/web app, click through it, screenshot, and report bugs or confirm behavior. Comments findings; opens no PRs."
|
||||||
|
},
|
||||||
|
"ops": {
|
||||||
|
"model": "anthropic/claude-opus-4-8",
|
||||||
|
"vision": false,
|
||||||
|
"mode": "comment",
|
||||||
|
"skills": [
|
||||||
|
"gitea-admin"
|
||||||
|
],
|
||||||
|
"desc": "Gitea operator — administers the Gitea instance itself: create orgs/users/repos, manage labels and secrets, mint scoped per-user tokens, bootstrap new repos with the agent caller. Comments only; never edits code. ALWAYS confirms before any destructive action (delete user/repo/org)."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install CALLER-PROVIDED opencode skills — the framework's skill-plugin hook.
|
||||||
|
#
|
||||||
|
# The reusable workflow ships a few built-in skills (gitea-api, gitea-admin). A consuming repo can
|
||||||
|
# add its OWN, repo-specific skills (e.g. a homelab "ssh into the deploy host" skill) without any
|
||||||
|
# change to this framework: it commits them under `.gitea/agent-skills/<name>/` in its own repo.
|
||||||
|
# This step discovers them in the checked-out caller workspace and installs the ones allowed for the
|
||||||
|
# running agent. That keeps deploy-target / infra specifics in the repo they belong to, not here.
|
||||||
|
#
|
||||||
|
# Layout the framework expects, per skill, in the CALLER repo:
|
||||||
|
# .gitea/agent-skills/<name>/
|
||||||
|
# SKILL.md (required) — the opencode Skill doc; copied verbatim into the skill registry.
|
||||||
|
# skill.json (required) — {"agents":["senior","lead"]} — which agents may load this skill.
|
||||||
|
# setup.sh (optional) — runtime setup (e.g. write an SSH alias). Runs ONLY when this agent is
|
||||||
|
# allowed the skill. Receives $SECRETS_JSON (all inherited secrets, as JSON) and must
|
||||||
|
# extract what it needs via jq; it must no-op cleanly if its secrets aren't set.
|
||||||
|
#
|
||||||
|
# Required env (provided by the workflow step): NAME WORKSPACE SECRETS_JSON
|
||||||
|
# (SECRETS_JSON = toJSON(secrets); passed so a caller's setup.sh can read repo-specific secrets
|
||||||
|
# whose names this framework cannot know in advance.)
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
DIR="${WORKSPACE:-$GITHUB_WORKSPACE}/.gitea/agent-skills"
|
||||||
|
CFG="$HOME/.config/opencode/opencode.json"
|
||||||
|
[ -d "$DIR" ] || { echo "no caller skills (.gitea/agent-skills/ absent) — nothing to install"; exit 0; }
|
||||||
|
|
||||||
|
allow='{}' # skills to flip to "allow" in permission.skill for THIS agent
|
||||||
|
for skill_dir in "$DIR"/*/; do
|
||||||
|
[ -d "$skill_dir" ] || continue
|
||||||
|
name=$(basename "$skill_dir")
|
||||||
|
md="$skill_dir/SKILL.md"; meta="$skill_dir/skill.json"
|
||||||
|
if [ ! -f "$md" ] || [ ! -f "$meta" ]; then
|
||||||
|
echo "caller skill '$name': missing SKILL.md or skill.json — skipping"; continue
|
||||||
|
fi
|
||||||
|
# Is this agent allowed the skill?
|
||||||
|
if ! jq -e --arg n "$NAME" '(.agents // []) | index($n)' "$meta" >/dev/null 2>&1; then
|
||||||
|
echo "caller skill '$name': not allowed for @$NAME — skipping"; continue
|
||||||
|
fi
|
||||||
|
# Install the doc.
|
||||||
|
dest="$HOME/.config/opencode/skills/$name"
|
||||||
|
mkdir -p "$dest" && chmod 700 "$dest"
|
||||||
|
cp "$md" "$dest/SKILL.md"
|
||||||
|
chmod -R o=rX "$dest"
|
||||||
|
# Optional runtime setup, with all inherited secrets available as JSON (never printed here).
|
||||||
|
if [ -f "$skill_dir/setup.sh" ]; then
|
||||||
|
echo "caller skill '$name': running setup.sh for @$NAME"
|
||||||
|
SECRETS_JSON="${SECRETS_JSON:-{}}" NAME="$NAME" WORKSPACE="${WORKSPACE:-$GITHUB_WORKSPACE}" \
|
||||||
|
bash "$skill_dir/setup.sh" || { echo "caller skill '$name': setup.sh failed — skipping this skill"; continue; }
|
||||||
|
fi
|
||||||
|
allow=$(jq -nc --argjson a "$allow" --arg n "$name" '$a + {($n):"allow"}')
|
||||||
|
echo "caller skill '$name': installed + allowed for @$NAME"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Merge the allowed caller skills into the permission allow-list opencode already wrote.
|
||||||
|
if [ "$allow" != '{}' ] && [ -f "$CFG" ]; then
|
||||||
|
tmp=$(mktemp)
|
||||||
|
jq --argjson add "$allow" '.permission.skill = ((.permission.skill // {}) + $add)' "$CFG" > "$tmp" && mv "$tmp" "$CFG"
|
||||||
|
echo "permission.skill updated with caller skills: $(jq -c '.permission.skill' "$CFG")"
|
||||||
|
fi
|
||||||
@@ -12,9 +12,10 @@ set -u
|
|||||||
[ -z "$CMT" ] && CMT="(a new issue was just opened — assess it)"
|
[ -z "$CMT" ] && CMT="(a new issue was just opened — assess it)"
|
||||||
THREAD=$(cat /tmp/thread.md 2>/dev/null); [ -z "$THREAD" ] && THREAD="(no prior comments)"
|
THREAD=$(cat /tmp/thread.md 2>/dev/null); [ -z "$THREAD" ] && THREAD="(no prior comments)"
|
||||||
DESC=$(jq -r --arg a "$NAME" '.[$a].desc' /tmp/agents.json)
|
DESC=$(jq -r --arg a "$NAME" '.[$a].desc' /tmp/agents.json)
|
||||||
# Include each teammate's skills so an agent (esp. @pm) can route by capability — e.g. only
|
# Include each teammate's registry skills so an agent (esp. @pm) can route by capability — e.g. only
|
||||||
# @senior/@lead hold node1-ssh, so a node1 task must not go to @junior. Skill *names* only; the
|
# skill-holders should get a task that needs that skill. Skill *names* only; the scoped how-to detail
|
||||||
# scoped how-to detail stays hidden per the permission.skill allow-list.
|
# stays hidden per the permission.skill allow-list. (Caller-provided skills from a repo's
|
||||||
|
# .gitea/agent-skills/ are not in this roster — document that routing in the caller's AGENTS.md.)
|
||||||
ROSTER=$(jq -r 'to_entries | map("- @\(.key): \(.value.desc) (vision: \(.value.vision); skills: \(.value.skills | if length>0 then join(", ") else "none" end))") | join("\n")' /tmp/agents.json)
|
ROSTER=$(jq -r 'to_entries | map("- @\(.key): \(.value.desc) (vision: \(.value.vision); skills: \(.value.skills | if length>0 then join(", ") else "none" end))") | join("\n")' /tmp/agents.json)
|
||||||
if [ "$VISION" = "true" ]; then CAP="You CAN read images attached to the issue."; else CAP="You CANNOT read images — you are a text-only model."; fi
|
if [ "$VISION" = "true" ]; then CAP="You CAN read images attached to the issue."; else CAP="You CANNOT read images — you are a text-only model."; fi
|
||||||
NOTE=""
|
NOTE=""
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Set up read-only SSH alias `node1` (+ opencode skill so the agent actually knows about it).
|
|
||||||
# 1) Writes the deploy key + an SSH config alias so the agent can run
|
|
||||||
# `ssh node1 <read-only cmd>` (matches the homelab opencode.json allowlist).
|
|
||||||
# 2) Emits a `node1-ssh` opencode Skill file under ~/.config/opencode/skills/ so any
|
|
||||||
# downstream repo's dev agent discovers this capability via OpenCode's skill registry
|
|
||||||
# rather than having to trial against the permission allowlist. Only emitted when the
|
|
||||||
# swarm plumbing is actually wired for that caller (SWARM_HOST/SWARM_USER/SSH_PRIV_KEY).
|
|
||||||
# All three secrets are passed via env and never inlined into shell — this shared workflow
|
|
||||||
# runs in repos that don't have them and must not fail there.
|
|
||||||
#
|
|
||||||
# Required env (provided by the workflow step): SWARM_HOST SWARM_USER SSH_PRIV_KEY
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
if [ -z "$SWARM_HOST" ] || [ -z "$SWARM_USER" ] || [ -z "$SSH_PRIV_KEY" ]; then
|
|
||||||
echo "swarm secrets not set in this repo — skipping node1 SSH alias + skill"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
mkdir -p ~/.ssh ~/.config/opencode/skills/node1-ssh && chmod 700 ~/.ssh ~/.config/opencode/skills/node1-ssh
|
|
||||||
# Write the private key with 600 perms; never echo its contents.
|
|
||||||
printf '%s\n' "$SSH_PRIV_KEY" > ~/.ssh/agent_node1
|
|
||||||
chmod 600 ~/.ssh/agent_node1
|
|
||||||
# SSH config alias `node1` — last-match-wins in the homelab opencode allowlist
|
|
||||||
# (`deny ssh *` + specific `allow ssh node1 …`), so the alias name is fixed.
|
|
||||||
cat > ~/.ssh/config <<EOF
|
|
||||||
Host node1
|
|
||||||
HostName $SWARM_HOST
|
|
||||||
User $SWARM_USER
|
|
||||||
IdentityFile ~/.ssh/agent_node1
|
|
||||||
IdentitiesOnly yes
|
|
||||||
StrictHostKeyChecking accept-new
|
|
||||||
ConnectTimeout 10
|
|
||||||
EOF
|
|
||||||
chmod 600 ~/.ssh/config
|
|
||||||
echo "node1 SSH alias configured (host=$SWARM_HOST user=$SWARM_USER)"
|
|
||||||
|
|
||||||
# Emit a reusable opencode Skill that surfaces the capability to downstream agents.
|
|
||||||
# OpenCode's skill tool registers it via the <available_skills> block, so any dev agent
|
|
||||||
# can discover "I am allowed to ssh node1" without trial-and-error against the allowlist.
|
|
||||||
cat > ~/.config/opencode/skills/node1-ssh/SKILL.md <<'SKILLET'
|
|
||||||
---
|
|
||||||
name: node1-ssh
|
|
||||||
description: Read-only diagnostics on the swarm host via `ssh node1 …` — use when debugging a deploy or checking a running service.
|
|
||||||
domains: [swarm]
|
|
||||||
tags: [ssh, swarm, diagnostics, docker]
|
|
||||||
---
|
|
||||||
|
|
||||||
# `node1-ssh` Skill
|
|
||||||
|
|
||||||
Use this skill to run **read-only** commands against **node1** (the Docker Swarm host) when:
|
|
||||||
- A deploy failed and you need to inspect running services.
|
|
||||||
- You need to see a service's logs for debugging.
|
|
||||||
- You want to check the state of the stack on the swarm.
|
|
||||||
|
|
||||||
## How it works
|
|
||||||
|
|
||||||
Commands run via `ssh node1 <cmd>`. The SSH alias is configured in `${HOME}/.ssh/config`
|
|
||||||
during this workflow (only when swarm secrets are configured for the caller repo).
|
|
||||||
|
|
||||||
## What you're actually allowed to run — the allowlist is the source of truth
|
|
||||||
|
|
||||||
This skill does **not** define which commands are permitted, and you must not assume a fixed
|
|
||||||
list here. The single source of truth for exactly which `ssh node1 …` commands are allowed is
|
|
||||||
the **caller repo's own OpenCode permission config** (e.g. `opencode.json` in the homelab repo:
|
|
||||||
a `deny "ssh *"` with specific `allow "ssh node1 …"` entries, last-match-wins).
|
|
||||||
|
|
||||||
- Only read-only diagnostics are permitted; any write/mutating command on node1 is denied.
|
|
||||||
- The permission layer enforces this — if a command is not on the caller's allowlist it will be
|
|
||||||
blocked, regardless of what this skill or any other allowlist says.
|
|
||||||
- So: reach for `ssh node1 …` for read-only diagnostics, and treat the caller's `opencode.json`
|
|
||||||
`ssh node1` allow-entries as the authoritative list of what will actually run.
|
|
||||||
|
|
||||||
## Example
|
|
||||||
|
|
||||||
> The frontend returned a 5xx after a deploy.
|
|
||||||
>
|
|
||||||
> Action (a read-only log inspection, subject to the caller's allowlist):
|
|
||||||
> ```
|
|
||||||
> ssh node1 "docker service logs --tail 100 --timestamps homelab_frontend"
|
|
||||||
> ```
|
|
||||||
SKILLET
|
|
||||||
chmod -R o=rX ~/.config/opencode/skills/node1-ssh
|
|
||||||
echo "opencode skill node1-ssh installed ($(wc -l < ~/.config/opencode/skills/node1-ssh/SKILL.md) lines)"
|
|
||||||
Reference in New Issue
Block a user