--- name: contribute-skill description: ローカルで改修した `skills//` を upstream リポジトリ (Fandhe-AI/agent-cli-skills 等) へ PR として投稿する。`skills-lock.json` の `source` を読み、`Fandhe-AI/` 以外への push は安全弁で中止。clone → 反映 → セキュリティチェック → ブランチ作成 → push → `gh pr create` を実行。マージ後は sync-skills-lock で hash 更新。「スキルを upstream に貢献」「外部リポジトリに PR」などで使用。 argument-hint: " (例: contribute-skill create-pr)" user-invocable: true model: sonnet --- # contribute-skill ローカルで改修した `skills//` を、`skills-lock.json` に記録された upstream リポジトリへ PR として投稿します。 ## 前提条件 - `gh` CLI がインストールされ認証済みであること(対象 org への push / PR 権限が必要) - 対象スキルが `skills-lock.json` に登録され `source` を持つこと(未登録・`source` 欠落・lock 不在・解析失敗はスクリプトが clone 前に中止する。新規スキルの初回貢献は対象外) - 対象スキルのディレクトリ配下が最新コミットと一致していること(未コミットの変更・未追跡・ignore 対象ファイルがあるとスクリプトが clone 前に中止する。対象スキル以外の場所の変更は検査しない) ## 責務の分離 - **create-pr**: 現在のリポジトリ内でカレントブランチから base へ PR を作成する - **contribute-skill**: 別リポジトリ(upstream)へ clone → 変更反映 → push → PR 作成を行う 外部リポジトリ貢献は clone / path 変換 / 異なる認証境界が関わるため、別スキルとして分離しています。 ## フロー ### Step 1: 引数を検証する ```bash SKILL_NAME="$ARGUMENTS" # 空判定ガード: パス解決の前に SKILL_NAME を確定させる if [[ -z "${SKILL_NAME}" ]]; then echo "対象スキルを指定してください。候補:" ls -1 skills/ 2>/dev/null ls -1 .agents/skills/ 2>/dev/null # .claude/skills/ は skills/ への symlink 慣習と実体配置(例: create-skill / create-agent)が # 併存するため、実ディレクトリのみ列挙する(symlink は実体が上の 2 候補に現れる)。 # 親の .claude / .claude/skills 自体が symlink の場合も配下の実体は symlink 先で列挙されるため、 # 経路の全親が symlink でないときのみ find する(-type d は親 symlink 経由でも列挙してしまう) [[ ! -L .claude && ! -L .claude/skills ]] && find .claude/skills -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sed 's|.*/||' echo "(lockfile 由来のスキルは .agents/skills/ のみ、リポジトリ管理スキルは .claude/skills/ のみに存在する場合がある)" exit 1 # ユーザーが選んだスキル名を引数に付けて再実行する fi # kebab-case 検証(パストラバーサル防止): パス解決より前に実施する if [[ ! "${SKILL_NAME}" =~ ^[a-z][a-z0-9-]+$ ]]; then echo "エラー: SKILL_NAME は小文字 kebab-case のみ許可されています: ${SKILL_NAME}" exit 1 fi # symlink 境界の共通判定(fail-closed): 相対経路 rel の全要素を先頭から累積検査し、 # いずれかが symlink なら該当パスを SYMLINK_COMPONENT に設定して 1 を返す。 # 第2引数 base を与えると base 配下の相対経路として検査する(省略時はカレント基準)。 # 末尾要素だけの -L 検査では親(例: .claude 自体がリポジトリ外を指す symlink)を # すり抜けるため、候補判定・override 検証・upstream 配置判定・rm 前検証の # すべての経路判定でこの関数を使い「どの経路要素が symlink でも候補と数えない」を一貫させる。 assert_no_symlink_components() { local rel="$1" prefix="${2:+${2}/}" acc="" part local -a parts SYMLINK_COMPONENT="" IFS='/' read -r -a parts <<< "${rel}" for part in "${parts[@]}"; do acc="${acc:+${acc}/}${part}" if [[ -L "${prefix}${acc}" ]]; then SYMLINK_COMPONENT="${prefix}${acc}" return 1 fi done return 0 } # override: 環境変数 LOCAL_SKILL_DIR が設定済みならそれを検証して使う if [[ -n "${LOCAL_SKILL_DIR:-}" ]]; then case "${LOCAL_SKILL_DIR}" in "skills/${SKILL_NAME}"|".agents/skills/${SKILL_NAME}"|".claude/skills/${SKILL_NAME}") ;; *) echo "エラー: LOCAL_SKILL_DIR は skills/${SKILL_NAME} / .agents/skills/${SKILL_NAME} / .claude/skills/${SKILL_NAME} のいずれかを指定してください: ${LOCAL_SKILL_DIR}" exit 1 ;; esac # symlink(.claude/skills/ → skills/ 慣習)は差分表示が空になるため実体側を要求する。 # 末尾要素だけでなく全中間要素(例: .claude → .claude/skills)も累積検査し、 # 親ディレクトリが symlink のパス指定も fail-closed で拒否する(自動解決側と同一関数で対称) if ! assert_no_symlink_components "${LOCAL_SKILL_DIR}"; then echo "エラー: LOCAL_SKILL_DIR の経路に symlink が含まれています。実体側のパスを指定してください: ${SYMLINK_COMPONENT} -> $(readlink "${SYMLINK_COMPONENT}")" exit 1 fi if [[ ! -d "${LOCAL_SKILL_DIR}" ]]; then echo "エラー: 指定された LOCAL_SKILL_DIR が存在しません: ${LOCAL_SKILL_DIR}" exit 1 fi else # 自動解決(複数存在する場合は中止して override を促す) have_skills=0; have_agents=0; have_claude=0 # 各候補は経路の全要素(.claude 等の最上位親を含む)が実体の場合のみ数える。 # symlink 経由の候補は実体側候補で検出されるため数えず(重複カウント防止)、 # .claude 自体がリポジトリ外を指す symlink の場合も候補と数えない(fail-closed) [[ -d "skills/${SKILL_NAME}" ]] && assert_no_symlink_components "skills/${SKILL_NAME}" && have_skills=1 [[ -d ".agents/skills/${SKILL_NAME}" ]] && assert_no_symlink_components ".agents/skills/${SKILL_NAME}" && have_agents=1 [[ -d ".claude/skills/${SKILL_NAME}" ]] && assert_no_symlink_components ".claude/skills/${SKILL_NAME}" && have_claude=1 if (( have_skills + have_agents + have_claude > 1 )); then echo "エラー: ${SKILL_NAME} の実体が skills/ / .agents/skills/ / .claude/skills/ の複数に存在します。" echo "環境変数 LOCAL_SKILL_DIR にどれかを指定して再実行してください(例: LOCAL_SKILL_DIR=.agents/skills/${SKILL_NAME})。" exit 1 elif [[ "${have_skills}" -eq 1 ]]; then LOCAL_SKILL_DIR="skills/${SKILL_NAME}" elif [[ "${have_agents}" -eq 1 ]]; then LOCAL_SKILL_DIR=".agents/skills/${SKILL_NAME}" elif [[ "${have_claude}" -eq 1 ]]; then LOCAL_SKILL_DIR=".claude/skills/${SKILL_NAME}" else echo "エラー: ローカルスキルが見つかりません: skills/${SKILL_NAME} / .agents/skills/${SKILL_NAME} / .claude/skills/${SKILL_NAME}" exit 1 fi fi ``` 引数が空の場合はパス解決に進まず、`skills/`・`.agents/skills/`・`.claude/skills/`(実ディレクトリのみ。symlink は実体側の候補に現れるため除外)の候補一覧を表示して終了します。Claude はその一覧をユーザーに提示し、スキル名を選んでもらってから再実行を促してください。後続の Step では `${LOCAL_SKILL_DIR}/` を使ってローカルパスを参照します。 `skills/`・`.agents/skills/`・`.claude/skills/`(実ディレクトリのみ。symlink は実体側でカウント)の**複数にディレクトリが存在する場合は中止**し、環境変数 `LOCAL_SKILL_DIR` に改修対象のパス(`skills/`・`.agents/skills/`・`.claude/skills/` のいずれか)を指定して再実行するよう案内します(silently に `skills/` を優先しません)。環境変数 `LOCAL_SKILL_DIR` が設定済みの場合は、許可された3パスのいずれかであること・経路の全要素(中間の親ディレクトリ含む)が symlink でないこと・実在することを検証してから採用し、自動解決をスキップします。いずれにも存在しなければエラーで中止します。 ### Step 2: upstream を特定する ルートの `skills-lock.json` を読み、`skills..source` を取り出します。 ```bash # jq が使えるなら jq で取得する SOURCE=$(jq -r --arg n "${SKILL_NAME}" '.skills[$n].source // empty' skills-lock.json) || { echo "エラー: skills-lock.json を解析できません。中止します。"; exit 1; } SOURCE_TYPE=$(jq -r --arg n "${SKILL_NAME}" '.skills[$n].sourceType // empty' skills-lock.json) || SOURCE_TYPE="" # 未登録・source 欠落は fail-closed(スクリプトと同じ判定) if [[ -z "${SOURCE}" ]]; then echo "エラー: スキル '${SKILL_NAME}' が skills-lock.json に未登録、または source がありません。中止します。" exit 1 fi # 安全弁: Fandhe-AI org 以外への push を拒否する # 1) まず正規化する(URL 形式は OWNER/REPO へ変換、短縮形はそのまま採用) case "${SOURCE}" in https://github.com/*) REPO_SLUG="${SOURCE#https://github.com/}" ;; *) REPO_SLUG="${SOURCE}" ;; esac # 末尾 .git の除去は両形式共通で行う # (URL 分岐内のみで除去すると短縮形 'Fandhe-AI/.git' が .git 付きのまま # 後段の正規表現を通過してしまうため、検証の前に必ずここで正規化する) REPO_SLUG="${REPO_SLUG%.git}" # 2) 正規化後の値を厳密検証する: owner は Fandhe-AI 固定、repo は単一セグメントのみ許可する # [A-Za-z0-9._-]+ は '/'・'?'・'#'・空文字を含められないため、 # パストラバーサル(../)・余剰パスセグメント・クエリ・フラグメントをすべて拒否できる # (前方一致では `Fandhe-AI/../../attacker/repo` のような値が通過するため) if [[ ! "${REPO_SLUG}" =~ ^Fandhe-AI/[A-Za-z0-9._-]+$ ]]; then echo "エラー: source '${SOURCE}' は Fandhe-AI/ 形式ではありません。中止します。" exit 1 fi # 3) '.'・'..' は上記正規表現を通過してしまうため repo 名として明示拒否する # (例: 'Fandhe-AI/..git' は .git 除去後に 'Fandhe-AI/.' へ化ける) case "${REPO_SLUG#Fandhe-AI/}" in .|..) echo "エラー: source '${SOURCE}' の repository 名が不正です。中止します。" exit 1 ;; esac # sourceType が github 以外なら中止(GitHub 以外の source は本スキルの想定外) if [[ "${SOURCE_TYPE}" != "github" ]]; then echo "エラー: sourceType '${SOURCE_TYPE}' は github ではありません。中止します。" exit 1 fi ``` - `source` は正規化後の `OWNER/REPO` が `^Fandhe-AI/[A-Za-z0-9._-]+$` に完全一致する場合のみ許可します(安全弁:見知らぬリポジトリへ意図せず push しないため)。`../` を含むパストラバーサル・クエリ(`?x=1`)・フラグメント(`#frag`)・余剰パスセグメント(`/extra`)を含む値は正規表現に一致せずエラーで中止します。検証は必ず `.git` 除去などの正規化の**後**に行います(正規化前に検証すると `Fandhe-AI/..git` のような値が正規化後に別の値へ化けてすり抜けるため)。 - `sourceType` が `github` 以外の場合も **エラーで中止** します(GitHub 以外の source は本スキルの想定外であり、`gh repo clone` / `gh pr create` が正常動作しないため)。 - 正規化後の `REPO_SLUG` は以降の Step で `gh repo clone`・`gh pr create --repo` に利用します。 ### Step 3: 変更内容を確認する ```bash # コピー元が HEAD と一致することを先に確認する(強制点は skills-contribute.sh 側。 # --untracked-files=all を省くと status.showUntrackedFiles=no で未追跡が隠れ、--ignored を # 省くと .env 等の ignore 対象が見逃される。非ゼロ終了も clean と扱わず中止する) rc=0; DIRTY=$(git status --porcelain=v1 --untracked-files=all --ignored -- "${LOCAL_SKILL_DIR}") || rc=$? if [[ "${rc}" -ne 0 || -n "${DIRTY}" ]]; then echo "中止: ${LOCAL_SKILL_DIR} が HEAD と一致しないか、状態を確認できません" >&2 printf '%s\n' "${DIRTY}" >&2 exit 1 fi git log --oneline -- "${LOCAL_SKILL_DIR}/" # HEAD~1 の有無を明示的に判定する(git diff は差分ありでも終了コード 0 のため || で分岐しない) if git rev-parse --verify -q HEAD~1 >/dev/null; then git diff HEAD~1 HEAD -- "${LOCAL_SKILL_DIR}/" else # 親コミットがない場合(初回コミット・shallow clone 等)は空ツリーと HEAD を比較する # (作業ツリー差分ではコミット済み・クリーンな状態で空になるため使わない) git diff "$(git hash-object -t tree /dev/null)" HEAD -- "${LOCAL_SKILL_DIR}/" fi ``` 検査で中止した場合、Claude は自動で stash・削除・コミットをしません。列挙されたパスをユーザーへ提示し、対応(コミットする/取り除く)を選んでもらってから再実行します。 ユーザーに「この改修内容で upstream に PR を作ってよいか」を確認します。 ### Step 4: セキュリティチェック(必須) `create-pr` と同様に以下をレビューします。 - 認証・認可の実装漏れ - API キー・シークレットのハードコーディング - XSS の可能性(ドキュメントでも外部埋め込みが含まれる場合) - 入力バリデーションの欠如 - OWASP Top 10 問題があれば upstream 貢献を中止し、ユーザーに警告します。 ### Step 5: 変更を反映する `skills-contribute.sh` は upstream の `gh repo clone` から作業ディレクトリ(`WORKDIR`)の作成・反映までを自己完結で行うため、手動での事前 clone は不要です。Step 6 以降は同スクリプトの機械可読な出力(`CONTRIBUTE_SKILL_WORKDIR=` 等)だけを唯一の正として使います。 **このステップは手順を個別に打鍵せず、必ず本スキル自身のスクリプト(`skills-contribute.sh`)を実行してください。** 同スクリプトには rm -rf 前の symlink 境界検証(TOCTOU 対策込み)が実装されており、以下の断片だけを個別に実行すると検証が欠落します。 `skills-contribute.sh` は upstream 側でスキルがどのパス構造に置かれているか(`UPSTREAM_SKILL_PATH`。`skills-lock.json` の `skillPath` はローカル install パスであり upstream 内の配置ではないため使用しません)の判定と、`cp -R` の delete-then-copy 反映を内部で行う自己完結型スクリプトです。判定・反映のロジックは後述の参考コードのとおりです。 `LOCAL_SKILL_DIR` は Step 1 で解決した**貢献対象スキル**(`$ARGUMENTS`)のパスであり、本スキル(contribute-skill)自身の配置とは無関係です。スクリプトの実行パスに `LOCAL_SKILL_DIR` を流用すると、貢献対象が contribute-skill 以外の場合に存在しないパスを参照してしまいます。実行するスクリプト自身の配置は別変数 `CONTRIBUTE_SKILL_DIR` として、本スキル(contribute-skill)自身のインストール場所から解決してください。 `skills-contribute.sh` は呼び出し時のカレントディレクトリを貢献元リポジトリのルートとして `LOCAL_SKILL_DIR`・`skills-lock.json` を探索し、内部で自分自身の `gh repo clone` と `WORKDIR`(clone 先)を新規作成します。実行直前にこの Step 内で `ORIG_DIR`(貢献元ローカルリポジトリのルート)を捕捉しておいてください。スクリプトの標準出力最終行群が返す `CONTRIBUTE_SKILL_WORKDIR=` と `CONTRIBUTE_SKILL_UPSTREAM_PATH=` を捕捉し、`WORKDIR` および(後述の参考コードで示す判定ロジックの)`UPSTREAM_SKILL_PATH` はこれらの値のみを唯一の正として採用します(参考コードを個別実行して得た値は使用しません)。これにより Step 6 以降が参照する `${WORKDIR}/upstream` と `${UPSTREAM_SKILL_PATH}` は、スクリプトが実際に使った clone・実際に反映したパスと一致します。 ```bash # cd する前にローカルリポジトリのルートを捕捉する(cd - は stdout を汚染するため使用しない) ORIG_DIR="$(pwd)" # 本スキル自身(contribute-skill)の配置を ORIG_DIR 基準の絶対パスで解決する。 # LOCAL_SKILL_DIR(貢献対象)とは別物。 # override: 環境変数 CONTRIBUTE_SKILL_DIR が設定済みならそれを検証して使う if [[ -n "${CONTRIBUTE_SKILL_DIR:-}" ]]; then # 完全一致判定で3候補それぞれの固定相対パス文字列リテラルを選ぶ。 # ${VAR#pattern} 等のパラメータ展開によるプレフィックス除去は使わない # (ORIG_DIR に [ * ? 等の glob 文字が含まれると pattern として解釈され、 # 接頭辞を除去し切れず絶対パスのまま検査関数へ渡ってしまうため)。 if [[ "${CONTRIBUTE_SKILL_DIR}" == "${ORIG_DIR}/skills/contribute-skill" ]]; then CONTRIBUTE_SKILL_REL="skills/contribute-skill" elif [[ "${CONTRIBUTE_SKILL_DIR}" == "${ORIG_DIR}/.agents/skills/contribute-skill" ]]; then CONTRIBUTE_SKILL_REL=".agents/skills/contribute-skill" elif [[ "${CONTRIBUTE_SKILL_DIR}" == "${ORIG_DIR}/.claude/skills/contribute-skill" ]]; then CONTRIBUTE_SKILL_REL=".claude/skills/contribute-skill" else echo "エラー: CONTRIBUTE_SKILL_DIR は ${ORIG_DIR}/skills/contribute-skill / ${ORIG_DIR}/.agents/skills/contribute-skill / ${ORIG_DIR}/.claude/skills/contribute-skill のいずれかを指定してください: ${CONTRIBUTE_SKILL_DIR}" exit 1 fi # LOCAL_SKILL_DIR と同じ fail-closed 方針: 末尾要素・中間の親ディレクトリのいずれかが # symlink の場合は実体側パスの指定を要求する(assert_no_symlink_components は Step 1 で定義済み)。 # 上記で完全一致判定した固定相対パスリテラル(CONTRIBUTE_SKILL_REL)を検査関数へ渡す。 if ! assert_no_symlink_components "${CONTRIBUTE_SKILL_REL}" "${ORIG_DIR}"; then echo "エラー: CONTRIBUTE_SKILL_DIR の経路に symlink が含まれています。実体側のパスを指定してください: ${SYMLINK_COMPONENT} -> $(readlink "${SYMLINK_COMPONENT}")" exit 1 fi if [[ ! -d "${CONTRIBUTE_SKILL_DIR}" ]]; then echo "エラー: 指定された CONTRIBUTE_SKILL_DIR が存在しません: ${CONTRIBUTE_SKILL_DIR}" exit 1 fi else # 自動解決。Step 1 の LOCAL_SKILL_DIR 解決と同じ fail-closed 方針: 複数存在する場合は # どれを使うべきか判断できないため中止する(silently に skills/ を優先しない)。 # 各候補は経路の全要素(.claude 等の最上位親を含む)が実体の場合のみ数える # (symlink 経由の候補は実体側候補で検出されるため重複カウントしない)。 have_contribute_skills=0; have_contribute_agents=0; have_contribute_claude=0 [[ -d "${ORIG_DIR}/skills/contribute-skill" ]] && assert_no_symlink_components "skills/contribute-skill" "${ORIG_DIR}" && have_contribute_skills=1 [[ -d "${ORIG_DIR}/.agents/skills/contribute-skill" ]] && assert_no_symlink_components ".agents/skills/contribute-skill" "${ORIG_DIR}" && have_contribute_agents=1 [[ -d "${ORIG_DIR}/.claude/skills/contribute-skill" ]] && assert_no_symlink_components ".claude/skills/contribute-skill" "${ORIG_DIR}" && have_contribute_claude=1 if (( have_contribute_skills + have_contribute_agents + have_contribute_claude > 1 )); then echo "エラー: contribute-skill の実体が ${ORIG_DIR}/skills/contribute-skill / ${ORIG_DIR}/.agents/skills/contribute-skill / ${ORIG_DIR}/.claude/skills/contribute-skill の複数に存在します。" echo "環境変数 CONTRIBUTE_SKILL_DIR にどれかを指定して再実行してください(例: CONTRIBUTE_SKILL_DIR=${ORIG_DIR}/.agents/skills/contribute-skill)。" exit 1 elif [[ "${have_contribute_skills}" -eq 1 ]]; then CONTRIBUTE_SKILL_DIR="${ORIG_DIR}/skills/contribute-skill" elif [[ "${have_contribute_agents}" -eq 1 ]]; then CONTRIBUTE_SKILL_DIR="${ORIG_DIR}/.agents/skills/contribute-skill" elif [[ "${have_contribute_claude}" -eq 1 ]]; then CONTRIBUTE_SKILL_DIR="${ORIG_DIR}/.claude/skills/contribute-skill" else echo "エラー: contribute-skill 自身の配置が見つかりません(${ORIG_DIR}/skills/contribute-skill / ${ORIG_DIR}/.agents/skills/contribute-skill / ${ORIG_DIR}/.claude/skills/contribute-skill)。" exit 1 fi fi # skills-contribute.sh は自分自身で clone するため、呼び出し前に必ずローカルリポジトリ # ルートへ cd し直す。LOCAL_SKILL_DIR は通常の変数代入では子プロセスへ継承されない # (export されていない)ため明示的に渡す。標準出力はそのまま表示しつつ変数へも捕捉する。 cd "${ORIG_DIR}" SCRIPT_OUTPUT=$(LOCAL_SKILL_DIR="${LOCAL_SKILL_DIR}" "${CONTRIBUTE_SKILL_DIR}/scripts/skills-contribute.sh" "${SKILL_NAME}" "${REPO_SLUG}" | tee /dev/stderr) # スクリプトが実際に使った作業 clone・upstream スキルパスを Step 6 以降の唯一の正として採用する。 # 以下の参考コードで計算され得る UPSTREAM_SKILL_PATH はこの値で上書きする。 # 以降 "${WORKDIR}/upstream" は常にスクリプトが cp -R でコピーした clone を、 # "${UPSTREAM_SKILL_PATH}" は常にスクリプトが実際に反映したパスを指す。 SCRIPT_UPSTREAM_DIR=$(echo "${SCRIPT_OUTPUT}" | grep '^CONTRIBUTE_SKILL_WORKDIR=' | tail -1 | cut -d= -f2-) UPSTREAM_SKILL_PATH=$(echo "${SCRIPT_OUTPUT}" | grep '^CONTRIBUTE_SKILL_UPSTREAM_PATH=' | tail -1 | cut -d= -f2-) if [[ -z "${SCRIPT_UPSTREAM_DIR}" || ! -d "${SCRIPT_UPSTREAM_DIR}" ]]; then echo "エラー: skills-contribute.sh の出力から作業ディレクトリ(CONTRIBUTE_SKILL_WORKDIR)を取得できませんでした。" exit 1 fi if [[ -z "${UPSTREAM_SKILL_PATH}" ]]; then echo "エラー: skills-contribute.sh の出力から upstream スキルパス(CONTRIBUTE_SKILL_UPSTREAM_PATH)を取得できませんでした。" exit 1 fi WORKDIR="$(dirname "${SCRIPT_UPSTREAM_DIR}")" cd "${SCRIPT_UPSTREAM_DIR}" # デフォルトブランチを取得する(Step 8 の gh pr create --base で使用)。 # origin/HEAD 未設定時に symbolic-ref が非ゼロ終了する。set -e 下(本コマンドを # skills-contribute.sh 同様の厳格モードで実行する場合)では ${DEFAULT_BRANCH:-main} # フォールバックへ到達できなくなるため `|| true` で吸収する # (skills-contribute.sh の同じ処理と同じ理由)。 DEFAULT_BRANCH=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/origin/||' || true) echo "デフォルトブランチ: ${DEFAULT_BRANCH:-main}" ``` 以下は `skills-contribute.sh` が内部で実行する処理(`UPSTREAM_SKILL_PATH` の判定・delete-then-copy)の参考コードです。上記のスクリプト実行によって既に完了しているため、個別に実行する必要はありません。 ```bash # upstream のスキル配置はクローンしたリポジトリのレイアウトで判定する # (skills-lock.json の skillPath はローカル install パスであり upstream の配置ではないため使わない) # 各候補は assert_no_symlink_components(Step 1 で定義した共通判定)で経路の全要素が # 実体の場合のみ採用する。clone 内に symlink(例: .claude がリポジトリ外を指す)が # 含まれていても、その経路をコピー先と数えず fail-closed で次候補へフォールバックする if [[ -d "skills/${SKILL_NAME}" ]] && assert_no_symlink_components "skills/${SKILL_NAME}"; then UPSTREAM_SKILL_PATH="skills/${SKILL_NAME}" elif [[ -d ".agents/skills/${SKILL_NAME}" ]] && assert_no_symlink_components ".agents/skills/${SKILL_NAME}"; then UPSTREAM_SKILL_PATH=".agents/skills/${SKILL_NAME}" elif [[ -d ".claude/skills/${SKILL_NAME}" ]] && assert_no_symlink_components ".claude/skills/${SKILL_NAME}"; then # upstream が .claude/skills/ 配下に実体を置いている慣習(例: create-skill / create-agent)。 # .claude/skills/ は skills/ への symlink 慣習も併存するため、 # 経路のどこかが symlink の場合は採用しない # (symlink の場合は解決先の実体が上の 2 分岐で検出される) UPSTREAM_SKILL_PATH=".claude/skills/${SKILL_NAME}" elif [[ -d "skills" ]] && assert_no_symlink_components "skills"; then # upstream が skills/ 配下で公開している慣習 UPSTREAM_SKILL_PATH="skills/${SKILL_NAME}" elif [[ -d ".agents/skills" ]] && assert_no_symlink_components ".agents/skills"; then # upstream が .agents/skills/ 配下で公開している慣習 UPSTREAM_SKILL_PATH=".agents/skills/${SKILL_NAME}" elif [[ -d ".claude/skills" ]] && assert_no_symlink_components ".claude/skills"; then # upstream が .claude/skills/ を実体スキルルートとして公開している慣習。 # 新規スキル(upstream にまだ存在しない)は上の個別パス判定に掛からないため、 # 親ディレクトリの存在で判定する。.claude / .claude/skills が symlink の場合は # 実体側ルートが上の skills/ / .agents/skills/ 分岐で検出されるため採用しない UPSTREAM_SKILL_PATH=".claude/skills/${SKILL_NAME}" else echo "警告: upstream にスキルルートが見つかりません。skills/ を既定として新規追加します。" UPSTREAM_SKILL_PATH="skills/${SKILL_NAME}" fi ``` ```bash # 削除伝搬のための同期: cp -R は削除を反映しないため、宛先を消してからコピーする # 安全弁1: 削除対象が clone 内の想定スキルパス(3 形態のみ)であることを検証してから rm する case "${UPSTREAM_SKILL_PATH}" in "skills/${SKILL_NAME}"|".agents/skills/${SKILL_NAME}"|".claude/skills/${SKILL_NAME}") ;; *) echo "エラー: 想定外の UPSTREAM_SKILL_PATH です: ${UPSTREAM_SKILL_PATH}" exit 1 ;; esac # 安全弁2: 実体パスでの symlink 境界検証(fail-closed)。安全弁1 の case allowlist は # 文字列形式のみの検証であり、clone 内の中間ディレクトリ(skills / .agents 等)が # 実行中に symlink へ差し替えられた場合には対応できないため、rm -rf の直前に # 以下を実体パスで再確認する(詳細な実装は scripts/skills-contribute.sh を参照)。 # (a) clone ルートから削除対象までの各中間パス要素が symlink でないこと # (b) 削除対象の親ディレクトリの正規パス(pwd -P)が clone ルート配下であること # (c) 検証と削除の間の TOCTOU を閉じるため、検証済みの親ディレクトリへ cd -P した # cwd に対して相対パスで rm する(パス文字列を再解決しない) # いずれかに違反する場合は削除を実行せずエラー終了する。 CLONE_ROOT="${WORKDIR}/upstream" DELETE_TARGET="${CLONE_ROOT}/${UPSTREAM_SKILL_PATH}" CLONE_ROOT_REAL="$(cd "${CLONE_ROOT}" && pwd -P)" if ! assert_no_symlink_components "${UPSTREAM_SKILL_PATH}" "${CLONE_ROOT}"; then echo "エラー: 削除対象の経路に symlink が含まれています: ${SYMLINK_COMPONENT}" exit 1 fi DELETE_PARENT="$(dirname "${DELETE_TARGET}")" DELETE_LEAF="$(basename "${DELETE_TARGET}")" if [[ -d "${DELETE_PARENT}" ]]; then ( cd -P -- "${DELETE_PARENT}" || exit 1 PARENT_REAL="$(pwd -P)" case "${PARENT_REAL}/" in "${CLONE_ROOT_REAL}/"*) ;; *) echo "エラー: 削除対象の親ディレクトリが clone ルート配下ではありません: ${PARENT_REAL}" exit 1 ;; esac rm -rf -- "${DELETE_LEAF}" ) fi # スクリプトはここで貢献元の対象スキルを再検査する(clone を挟む間の変更を拾うため。 # 未コミット・未追跡・ignore 対象があれば中止し、コピーしない) mkdir -p "${WORKDIR}/upstream/${UPSTREAM_SKILL_PATH}" # LOCAL_SKILL_DIR は Step 1 で解決済み(skills// または .agents/skills//) # ORIG_DIR は Step 5 で cd する前に捕捉済み(cd - は stdout 汚染のため使用しない) cp -R "${ORIG_DIR}/${LOCAL_SKILL_DIR}/." "${WORKDIR}/upstream/${UPSTREAM_SKILL_PATH}/" ``` 削除対象は必ず `${WORKDIR}/upstream/` 配下(clone 用の一時ディレクトリ)に閉じ、`UPSTREAM_SKILL_PATH` が `skills/`・`.agents/skills/`・`.claude/skills/` の 3 形態以外なら `rm -rf` の前に中止します。加えて中間パスの symlink 化・TOCTOU に対する実体パス検証を rm 直前に行います。新規スキル追加(宛先未存在)の場合も削除処理は無害にスキップされ、直後の `mkdir -p` で作成されます。 ### Step 6: 差分を確認する ```bash cd "$WORKDIR/upstream" git status git diff ``` ユーザーに差分を見せ、内容が意図通りか確認します。 ### Step 7: ブランチ作成・コミット ```bash SLUG=$(date +%Y%m%d-%H%M%S) git switch -c "contribute/${SKILL_NAME}-${SLUG}" git add "${UPSTREAM_SKILL_PATH}/" git commit -m "$(cat <<'EOF' (): ローカルの skills// からの貢献。 EOF )" ``` - `git add "${UPSTREAM_SKILL_PATH}/"` はパス指定 add のため、Step 5 の delete-then-copy で消えたファイルの削除(`D`)も含めて stage されます - Conventional Commits 形式 - `--no-verify` は使用しない(pre-commit フックを通す) - co-author は付けない(ローカル規約に合わせる) ### Step 8: push と PR 作成 ```bash git push -u origin "contribute/${SKILL_NAME}-${SLUG}" # 貢献元リポジトリの OWNER/REPO を取得する(PR body の Source 節に使用。 # 本スキルは任意のリポジトリから実行されるため、特定リポジトリ名を固定しない) SRC_REPO=$(git -C "${ORIG_DIR}" remote get-url origin 2>/dev/null \ | sed -E 's#^(git@github\.com:|https://github\.com/)##; s#\.git$##') [[ -n "${SRC_REPO}" ]] || SRC_REPO=$(basename "${ORIG_DIR}") gh pr create \ --repo "${REPO_SLUG}" \ --base "${DEFAULT_BRANCH:-main}" \ --title "(): " \ --body "$(cat <<'EOF' ## Summary - の改修内容(箇条書き) ## Source ローカルの 側で改修後、`/contribute-skill ` により投稿。 ## Test plan - [ ] SKILL.md を実際に Claude Code で実行 - [ ] Conventional Commits に沿ったメッセージ生成を確認 - [ ] エッジケース確認 EOF )" ``` `--repo` には Step 2 で正規化した `${REPO_SLUG}`(`OWNER/REPO` 形式)を渡します。URL 形式から `OWNER/REPO` への変換は Step 2 の case 文で完了しています。 body の `` は上で取得した貢献元リポジトリの `OWNER/REPO`(origin 未設定時はディレクトリ名)に置き換えます(heredoc はクォート済みのため Claude が実値で埋める。特定リポジトリ名のハードコード禁止)。 Draft PR を作成する場合は `--draft` を付けます(デフォルトはユーザー確認の上で決定)。 ### Step 9: PR URL を返す & 後処理案内 - PR URL をユーザーに返す - 「マージされたら `/sync-skills-lock` を実行して `skills-lock.json` の `computedHash` を更新してください」と案内 - 作業用ディレクトリ `$WORKDIR` は残したまま(成否が確定するまで) ## 注意事項 - **SKILL_NAME は kebab-case のみ許可**:`..` のような値によるパストラバーサルを防ぐため、空判定の直後・パス解決の前に `^[a-z][a-z0-9-]+$` で検証する(OWASP A03 / A01) - **`skills/`・`.agents/skills/`・`.claude/skills/` の複数に実体が存在する場合は中止**:silently に `skills/` を優先せず、環境変数 `LOCAL_SKILL_DIR` に改修対象パスを指定して再実行を求める。`LOCAL_SKILL_DIR` は `skills/`・`.agents/skills/`・`.claude/skills/` の3パスのみ受理し(末尾要素・中間の親ディレクトリのいずれかが symlink なら実体側パスの指定を要求)、任意パス指定によるパストラバーサルを防ぐ。Step 5 で本スキル自身(contribute-skill)の配置を解決する `CONTRIBUTE_SKILL_DIR` も同じ fail-closed 方針を取り、`${ORIG_DIR}/skills/contribute-skill`・`${ORIG_DIR}/.agents/skills/contribute-skill`・`${ORIG_DIR}/.claude/skills/contribute-skill` の3候補のみ受理する(末尾要素・中間の親ディレクトリのいずれかが symlink なら実体側パスの指定を要求)。3候補のうち複数が存在する場合は silently にどれかを優先せず中止して環境変数 `CONTRIBUTE_SKILL_DIR` での指定を求める(LOCAL_SKILL_DIR とは非対称にしない) - **source が Fandhe-AI org 以外の場合は中止**:前方一致(`Fandhe-AI/*` 等)ではなく、正規化(`.git` 除去等)後の `OWNER/REPO` が `^Fandhe-AI/[A-Za-z0-9._-]+$` に完全一致するかで判定する。`../` によるパストラバーサル・クエリ・フラグメント・余剰パスセグメントを含む値、および repo 名が `.`/`..` になる値は中止し、意図しない外部リポジトリへの push を防ぐ - **lock 不在・未登録・`source` 欠落・lock 解析失敗は fail-closed**:clone 前に中止する(照合を省略して続行しない)。新規スキルの初回貢献は本スキルの対象外 - **セキュリティ問題が見つかった場合は中止**:修正後に再実行 - **upstream の配置はクローンしたリポジトリのレイアウトで判定する**:`skills-lock.json` の `skillPath` はローカル install パス(例: `.agents/skills/github-docs/SKILL.md`)であり、upstream リポジトリ内の配置ではない。`skillPath` の dirname を `UPSTREAM_SKILL_PATH` に採用してはならない。判定順は `skills/` の存在 → `.agents/skills/` の存在 → `.claude/skills/` の存在 → スキルルート親ディレクトリの慣習(`skills/` → `.agents/skills/` → `.claude/skills/` の順。新規スキルは個別パスが存在しないためこの親ディレクトリ判定で配置先が決まる)→ 最終デフォルト `skills/`(より一般的な公開レイアウト)。全候補で `assert_no_symlink_components` により経路の全要素(`.claude` 等の最上位親を含む)が symlink でない場合のみ採用し、`.claude` 自体がリポジトリ外を指す symlink でも外部内容が upstream へコピーされない(fail-closed。symlink 経由の実体は前段の実体側候補で検出される) - **宛先は消してからコピーする(削除伝搬)**:`cp -R` は追加・上書きのみで削除を反映しないため、ローカルで削除したファイルが upstream 側に残存してしまう。`rm -rf` 前に `UPSTREAM_SKILL_PATH` が `skills/`・`.agents/skills/`・`.claude/skills/` のいずれかであることを case 文で検証し、それ以外の値なら中止する。加えて rm -rf 直前に実体パス(symlink 境界・clone ルート配下チェック、cd -P + 相対 rm による TOCTOU 対策)を再検証する。削除対象は必ず clone 用の一時ディレクトリ(`${WORKDIR}/upstream/`)配下のみに閉じ、それ以外のファイルには一切触れない。**Step 5 は必ず `${CONTRIBUTE_SKILL_DIR}/scripts/skills-contribute.sh`(本スキル自身の配置から別途解決したパス。貢献対象のパスである `LOCAL_SKILL_DIR` とは別物)経由で実行し、断片コマンドの個別打鍵で検証を省略しない** - **コピー元は HEAD と一致している場合のみ反映する**:事前レビューはコミット済み差分しか見せない一方、`cp -R` は作業ツリーの現物をコピーするため、未コミット・未追跡・ignore 対象のファイルが無レビューで upstream PR へ混入し得る。スクリプトは `${LOCAL_SKILL_DIR}` 配下を `git status --porcelain=v1 --untracked-files=all --ignored` で検査し、出力が非空なら中止する。`--untracked-files=all` を外すとユーザー設定 `status.showUntrackedFiles=no` で未追跡が隠れ、`--ignored` を外すと `.env` 等がコピーされる。非ゼロ終了(git リポジトリ外など)も clean と扱わない。`git update-index --assume-unchanged` / `--skip-worktree` を付けたファイルの変更は `git status` でも検出できず対象外 - **既に同名の branch がある場合**:秒単位スラッグで通常は衝突しないが、万一の場合はユーザーに確認 ## sandbox 環境での実行 このスキルはネットワーク越しの GitHub 操作(`gh repo clone`・`git push`・PR 作成)を必須とする。該当コマンドはコマンド単位で sandbox 無効にして実行する。ネットワーク遮断を解除できない環境では実行できない。 ## 検証 PR 作成後、以下で完了を確認する。 ```bash # PR が作成されたことを確認 gh pr view --repo "${REPO_SLUG}" --web # または URL を直接確認(Step 9 で出力済み) ``` - PR URL が返されること - PR のタイトル・差分が意図した内容であること - `sync-skills-lock` 実行案内が出力されていること ## 既存スキルとの関係 - Step 4 のセキュリティチェック、Step 7 の Conventional Commits、Step 8 の PR body は `create-pr/SKILL.md` の流儀を踏襲 - マージ後は `sync-skills-lock` で `skills-lock.json` の `computedHash` を更新