blob: f16cef3fd07a517e68578905ea6348fa0ec05b23 [file]
#!/usr/bin/env bash
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
# Set up AI coding agent integration with TinkerPop's Agent Skills.
#
# TinkerPop maintains Agent Skills in .skills/:
# tinker-dev - Development guidance (coding conventions, build recipes, etc.)
# tinker-doc - Documentation authoring and review (voice, per-book style, etc.)
# tinker-review - Graph-based PR review (knowledge graph analysis, playbooks, etc.)
#
# Different AI coding tools discover skills in different directories. This script
# creates the necessary symlinks or shims so your tool can find the skills.
#
# Usage:
# bin/agent-setup.sh <agent>
# bin/agent-setup.sh --list
# bin/agent-setup.sh --all
#
# Examples:
# bin/agent-setup.sh claude # Set up for Claude Code
# bin/agent-setup.sh kiro # Set up for Kiro
# bin/agent-setup.sh --all # Set up for all supported agents
#
# Supported agents:
# claude - Claude Code (.claude/skills/)
# copilot - GitHub Copilot (.github/skills/ and .agents/skills/)
# cursor - Cursor (.cursor/skills/)
# codex - OpenAI Codex (.codex/skills/)
# junie - JetBrains Junie (.junie/skills/)
# kiro - Kiro (.kiro/skills/)
set -uo pipefail
SKILLS=("tinker-dev" "tinker-doc" "tinker-review")
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
NC='\033[0m'
ok() { echo -e " ${GREEN}✓${NC} $1"; }
skip() { echo -e " ${YELLOW}○${NC} $1"; }
bad() { echo -e " ${RED}✗${NC} $1"; }
usage() {
echo "Usage: bin/agent-setup.sh <agent|--list|--all|--contributor [agent]>"
echo ""
echo "Agents: claude, copilot, cursor, codex, junie, kiro"
echo ""
echo "Options:"
echo " --list List supported agents and their skill discovery paths"
echo " --all Set up shims for all supported agents"
echo " --contributor Also install beads workflow hooks (committers; claude, kiro)"
echo " --help Show this message"
}
# Verify we're in the repo root
if [[ ! -d ".skills/tinker-dev" ]]; then
bad "Cannot find .skills/tinker-dev — run this script from the TinkerPop repository root."
exit 1
fi
# Remove TinkerPop skill entries (symlinks or copies) from a tool's skill
# directory before the current ones are (re)created. Matching only "tinker*"
# keeps any custom skills the user maintains intact. Entries for skills that no
# longer exist (e.g. a renamed "tinkerpop-dev") are reported as stale removals;
# current skills are cleared silently so the setup step can recreate them
# cleanly — this also fixes symlinks whose relative target path has changed.
purge_tinker_skills() {
local target_dir="$1"
[[ -d "$target_dir" ]] || return 0
local entry name skill is_current
for entry in "$target_dir"/tinker*; do
# If the glob matched nothing it stays literal — skip non-existent paths.
[[ -e "$entry" || -L "$entry" ]] || continue
name=$(basename "$entry")
is_current=0
for skill in "${SKILLS[@]}"; do
[[ "$name" == "$skill" ]] && is_current=1 && break
done
rm -rf "$entry"
[[ "$is_current" -eq 0 ]] && skip "removed stale skill $target_dir/$name"
done
}
# Create a symlink from a tool's skill directory to our canonical skill
setup_symlink() {
local tool_name="$1"
local target_dir="$2"
local skill_name="$3"
local skill_dir=".skills/$skill_name"
mkdir -p "$target_dir"
local link_path="$target_dir/$skill_name"
if [[ -L "$link_path" ]]; then
skip "$tool_name: symlink already exists at $link_path"
return 0
fi
if [[ -e "$link_path" ]]; then
bad "$tool_name: $link_path already exists and is not a symlink — skipping"
return 1
fi
# Compute relative path from target_dir to skill_dir
local rel_path
rel_path=$(python3 -c "import os.path; print(os.path.relpath('$skill_dir', '$target_dir'))" 2>/dev/null)
if [[ -z "$rel_path" ]]; then
rel_path=$(perl -e 'use File::Spec; print File::Spec->abs2rel("'"$skill_dir"'", "'"$target_dir"'")' 2>/dev/null)
fi
if [[ -z "$rel_path" ]]; then
bad "$tool_name: could not compute relative path (need python3 or perl)"
return 1
fi
ln -s "$rel_path" "$link_path"
ok "$tool_name: created symlink $link_path -> $rel_path"
}
# Kiro doesn't follow symlinks in .kiro/skills/, so we copy the skill directory
# instead. See: https://github.com/kirodotdev/Kiro/issues (symlink support).
setup_kiro() {
mkdir -p ".kiro/skills"
purge_tinker_skills ".kiro/skills"
for skill_name in "${SKILLS[@]}"; do
local target_dir=".kiro/skills/$skill_name"
cp -r ".skills/$skill_name" "$target_dir"
ok "kiro: copied $skill_name to $target_dir"
done
echo ""
echo -e " ${YELLOW}NOTE:${NC} Kiro uses copies, not symlinks. If you update skills in"
echo -e " .skills/, re-run this script to sync the changes."
}
setup_agent() {
local agent="$1"
case "$agent" in
claude)
purge_tinker_skills ".claude/skills"
for skill in "${SKILLS[@]}"; do
setup_symlink "claude" ".claude/skills" "$skill"
done
;;
copilot)
purge_tinker_skills ".github/skills"
purge_tinker_skills ".agents/skills"
for skill in "${SKILLS[@]}"; do
setup_symlink "copilot (.github)" ".github/skills" "$skill"
setup_symlink "copilot (.agents)" ".agents/skills" "$skill"
done
;;
cursor)
purge_tinker_skills ".cursor/skills"
for skill in "${SKILLS[@]}"; do
setup_symlink "cursor" ".cursor/skills" "$skill"
done
;;
codex)
purge_tinker_skills ".codex/skills"
for skill in "${SKILLS[@]}"; do
setup_symlink "codex" ".codex/skills" "$skill"
done
;;
junie)
purge_tinker_skills ".junie/skills"
for skill in "${SKILLS[@]}"; do
setup_symlink "junie" ".junie/skills" "$skill"
done
;;
kiro)
setup_kiro
;;
*)
bad "Unknown agent: $agent"
echo ""
usage
return 1
;;
esac
}
# --- Contributor hooks (opt-in) ---------------------------------------------
#
# Beads is a committer tool, so hook wiring is opt-in via --contributor. The
# logic lives in bin/beads-agent-hook.sh; the JSON under bin/agent-hooks/ only
# names events and invokes it. Installs are idempotent: our entries are found
# by their beads-agent-hook.sh reference (claude) or tinkerpop-beads- name
# prefix (kiro), removed, then rewritten.
HOOK_AGENTS=("claude" "kiro")
setup_claude_hooks() {
local settings=".claude/settings.local.json"
mkdir -p ".claude"
[[ -f "$settings" ]] || echo '{}' > "$settings"
if ! python3 - "$settings" "bin/agent-hooks/claude.json" <<'PY'
import json, sys
settings_path, hooks_path = sys.argv[1], sys.argv[2]
with open(settings_path) as fh:
settings = json.load(fh)
with open(hooks_path) as fh:
ours = {k: v for k, v in json.load(fh)["hooks"].items()}
MARKER = "beads-agent-hook.sh"
def is_ours(entry):
return any(MARKER in h.get("command", "") for h in entry.get("hooks", []))
# Sweep every event, not just the ones we are about to write: an event we no
# longer wire (PreCompact, once) must not be orphaned in the user's settings.
existing = settings.setdefault("hooks", {})
for event in list(existing):
kept = [e for e in existing[event] if not is_ours(e)]
if kept:
existing[event] = kept
else:
del existing[event]
for event, entries in ours.items():
existing[event] = existing.get(event, []) + entries
with open(settings_path, "w") as fh:
json.dump(settings, fh, indent=2)
fh.write("\n")
PY
then
bad "claude: could not merge hooks into $settings"
return 1
fi
ok "claude: merged beads hooks into $settings (SessionStart, Stop, UserPromptSubmit)"
}
setup_kiro_hooks() {
mkdir -p ".kiro/hooks"
cp "bin/agent-hooks/kiro.json" ".kiro/hooks/tinkerpop-beads.json"
ok "kiro: wrote .kiro/hooks/tinkerpop-beads.json (SessionStart, Stop, UserPromptSubmit)"
}
setup_hooks() {
local agent="$1"
if ! command -v bd >/dev/null 2>&1; then
skip "$agent: 'bd' not found — hooks installed anyway, they stay silent without it"
fi
case "$agent" in
claude) setup_claude_hooks ;;
kiro) setup_kiro_hooks ;;
*)
bad "No hook support for: $agent"
echo " Hooks are available for: ${HOOK_AGENTS[*]}"
return 1
;;
esac
}
list_agents() {
echo "Supported agents and their skill discovery paths:"
echo ""
echo " Skills: ${SKILLS[*]}"
echo ""
echo " claude .claude/skills/<skill>/ -> symlink to .skills/<skill>"
echo " copilot .github/skills/<skill>/ -> symlink to .skills/<skill>"
echo " .agents/skills/<skill>/ -> symlink to .skills/<skill>"
echo " cursor .cursor/skills/<skill>/ -> symlink to .skills/<skill>"
echo " codex .codex/skills/<skill>/ -> symlink to .skills/<skill>"
echo " junie .junie/skills/<skill>/ -> symlink to .skills/<skill>"
echo " kiro .kiro/skills/<skill>/ -> copy of .skills/<skill> (re-run to sync)"
}
# --- Main ---
if [[ $# -eq 0 ]]; then
usage
exit 1
fi
case "$1" in
--help|-h)
usage
;;
--list)
list_agents
;;
--all)
echo "Setting up all agent integrations..."
echo ""
for agent in claude copilot cursor codex junie kiro; do
setup_agent "$agent"
done
echo ""
echo "Done. Symlinked directories and generated files are gitignored."
echo "Add them to .gitignore if they aren't already."
;;
--contributor)
shift
if [[ $# -eq 0 ]]; then
echo "Setting up beads hooks for all supported agents..."
echo ""
for agent in "${HOOK_AGENTS[@]}"; do
setup_hooks "$agent"
done
else
echo "Setting up beads hooks for $1..."
echo ""
setup_hooks "$1"
fi
echo ""
echo "Hooks are advisory — they remind, they never block. Logic lives in"
echo "bin/beads-agent-hook.sh; run it directly to see what an agent is shown:"
echo ""
echo " bin/beads-agent-hook.sh stop"
;;
*)
echo "Setting up $1..."
echo ""
setup_agent "$1"
;;
esac