agents: caller-provided skills hook (move node1-ssh to the owning repo) #65
+11
-13
@@ -112,21 +112,19 @@ jobs:
|
||||
SKILLS: ${{ steps.prep.outputs.skills }} # JSON array of skills this agent may load
|
||||
run: bash "$SCRIPTS/install-opencode.sh"
|
||||
|
||||
- name: 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.
|
||||
- name: Install caller-provided skills (from the caller repo's .gitea/agent-skills/)
|
||||
# Framework skill-plugin hook. A consuming repo can ship its OWN opencode skills under
|
||||
# `.gitea/agent-skills/<name>/` (SKILL.md + skill.json + optional setup.sh) — e.g. homelab's
|
||||
# "ssh into the deploy host" skill. This installs the ones allowed for the running agent, so
|
||||
# deploy-target / infra specifics live in the repo they belong to, not in this framework.
|
||||
# SECRETS_JSON = toJSON(secrets): a caller's setup.sh reads the repo-specific secrets it needs
|
||||
# (whose names this framework can't know) via jq; it never touches disk here in the clear.
|
||||
env:
|
||||
SCRIPTS: ${{ runner.temp }}/agents-scripts
|
||||
SWARM_HOST: ${{ secrets.SWARM_HOST }}
|
||||
SWARM_USER: ${{ secrets.SWARM_USER }}
|
||||
SSH_PRIV_KEY: ${{ secrets.SSH_PRIV_KEY }}
|
||||
run: bash "$SCRIPTS/skill-node1-ssh.sh"
|
||||
NAME: ${{ steps.prep.outputs.name }}
|
||||
WORKSPACE: ${{ github.workspace }}
|
||||
SECRETS_JSON: ${{ toJSON(secrets) }}
|
||||
run: bash "$SCRIPTS/install-caller-skills.sh"
|
||||
|
||||
- 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
|
||||
|
||||
@@ -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."},
|
||||
"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","node1-ssh"],"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","node1-ssh"],"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)."}
|
||||
"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."
|
||||
},
|
||||
"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)"
|
||||
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)
|
||||
# Include each teammate's 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
|
||||
# scoped how-to detail stays hidden per the permission.skill allow-list.
|
||||
# Include each teammate's registry skills so an agent (esp. @pm) can route by capability — e.g. only
|
||||
# skill-holders should get a task that needs that skill. Skill *names* only; the scoped how-to detail
|
||||
# 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)
|
||||
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=""
|
||||
|
||||
@@ -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