#!/usr/bin/env bash set -euo pipefail usage() { cat <<'USAGE' 使い方: bash scripts/setup-opencode.sh [--model ID ...] [--help] API キーなしで公開モデル一覧を取得し、OpenCode に aichat を登録します。 登録後、opencode auth login で API キーを登録してください。 --model ID 登録するモデルを指定します(省略時は全モデル) --help, -h このヘルプを表示します USAGE } fail() { printf 'エラー: %s\n' "$1" >&2 exit "${2:-1}" } check_dependencies() { local command_name for command_name in bash curl jq; do if ! command -v "$command_name" >/dev/null 2>&1; then fail "$command_name が見つかりません。Ubuntu/Debian では sudo apt install bash curl jq、macOS では brew install bash curl jq でインストールしてください。" fi done } parse_args() { model_ids=() while (($#)); do case "$1" in --help|-h) usage; exit 0 ;; --model) if (($# < 2)) || [[ ! $2 =~ ^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$ ]]; then usage >&2 fail '--model には有効なモデル ID を指定してください。' 2 fi model_ids+=("$2") shift 2 ;; *) usage >&2; fail "不明なオプションです: $1" 2 ;; esac done } normalize_base_url() { local raw=${METACEPTRONS2_API_BASE_URL:-https://api.karudan.info/v1} local scheme authority path host [[ $raw =~ ^(https?)://([^/?#]+)([^?#]*)$ ]] || fail 'API の接続先 URL が不正です。' scheme=${BASH_REMATCH[1]} authority=${BASH_REMATCH[2]} path=${BASH_REMATCH[3]} [[ $authority != *'@'* && $authority != *'\\'* && $authority != *' '* ]] || fail 'API の接続先 URL に認証情報を含めることはできません。' [[ $path == '' || $path == '/' || $path == '/v1' || $path == '/v1/' ]] || fail 'API の接続先 URL のパスは /v1 にしてください。' if [[ $authority =~ ^(\[[^]]+\])(:[0-9]+)?$ ]]; then host=${BASH_REMATCH[1]} elif [[ $authority =~ ^([^:]+)(:[0-9]+)?$ ]]; then host=${BASH_REMATCH[1]} else fail 'API の接続先 URL のホストまたはポートが不正です。' fi if [[ $scheme == http && $host != localhost && $host != 127.0.0.1 && $host != '[::1]' ]]; then fail 'API の接続先は HTTPS にしてください(localhost のみ HTTP 可)。' fi base_url="$scheme://$authority/v1" models_url="$scheme://$authority/public/models" curl_protocol=https if [[ $scheme == http ]]; then curl_protocol=http; fi } check_config() { config_path="${XDG_CONFIG_HOME:-$HOME/.config}/opencode/opencode.json" if [[ -L $config_path ]]; then fail "シンボリックリンクの設定ファイルは変更できません: $config_path" fi if [[ -e $config_path ]]; then [[ -f $config_path ]] || fail "通常ファイル以外の設定は変更できません: $config_path" jq -e 'type == "object"' "$config_path" >/dev/null 2>&1 || fail "既存の設定は厳密な JSON オブジェクトではありません: $config_path" else [[ ! -e ${config_path}c && ! -L ${config_path}c ]] || fail "JSONC 設定が存在します。手動で確認してください: ${config_path}c" fi } cleanup() { [[ -z ${response_file:-} ]] || rm -f -- "$response_file" [[ -z ${output_file:-} ]] || rm -f -- "$output_file" } fetch_models() { local status response_file=$(mktemp) if status=$(curl -q --silent --show-error --output "$response_file" --write-out '%{http_code}' \ --max-time 10 --proto "=$curl_protocol" --max-filesize 1048576 "$models_url" 2>/dev/null); then : else fail 'モデル一覧を取得できませんでした。接続先・ネットワーク・応答サイズを確認してください。' fi case "$status" in 200) ;; 404) fail '接続先のサーバーが /public/models に未対応です(404)。gateway を更新するか管理者に問い合わせてください。' ;; 429) fail 'リクエストが制限されました(429)。しばらく待って再実行してください。' ;; *) fail "モデル一覧を取得できませんでした(HTTP $status)。接続先またはサービスの状態を確認してください。" ;; esac jq -e '.data | type == "array"' "$response_file" >/dev/null 2>&1 || fail 'モデル一覧の応答形式が不正です。' model_count=$(jq '.data | length' "$response_file") printf '%s から %s 件のモデルを取得しました\n' "$models_url" "$model_count" check_requested_models } # --model で指定した ID がすべて一覧にあることを確認する check_requested_models() { local missing ((${#model_ids[@]})) || return 0 missing=$(jq -r '[.data[].id] as $ids | $ARGS.positional[] | select(. as $id | $ids | index($id) == null)' \ "$response_file" --args ${model_ids[@]+"${model_ids[@]}"} | paste -sd ' ' -) || fail 'モデル一覧の応答形式が不正です。' [[ -z $missing ]] || fail "指定したモデルは利用できるモデル一覧にありません: $missing" } merge_config() { local config_dir input_path merge_error selected_count existing_count added_count updated_count config_dir=${config_path%/*} mkdir -p -- "$config_dir" # mktemp は mode 600 で作る。既存ファイルがあれば cp -p で権限を引き継ぐ output_file=$(mktemp "$config_dir/.opencode.json.XXXXXXXX") if [[ -e $config_path ]]; then input_path=$config_path cp -p -- "$config_path" "$output_file" else input_path=/dev/stdin fi if ! merge_error=$(jq --slurpfile response "$response_file" --arg base "$base_url" ' def obj($v; $label): if ($v | type) == "object" then $v else error($label + " は JSON オブジェクトである必要があります") end; def valid_id: type == "string" and test("^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$"); ($response[0].data) as $all | if any($all[]; (.id | valid_id) | not) then error("モデル一覧に不正な ID があります") else . end | ($ARGS.positional) as $requested | if any($requested[]; . as $id | all($all[]; .id != $id)) then error("指定したモデルが一覧にありません") else . end | obj((if has("provider") then .provider else {} end); "provider") as $providers | obj((if $providers | has("aichat") then $providers.aichat else {} end); "provider.aichat") as $current | obj((if $current | has("options") then $current.options else {} end); "provider.aichat.options") as $options | obj((if $current | has("models") then $current.models else {} end); "provider.aichat.models") as $models | (reduce $all[] as $item ($models; if ($requested | length) > 0 and (($requested | index($item.id)) == null) then . else ($item.id) as $id | obj((.[$id] // {}); "モデル") as $old | ($old + {name: (if ($item.name | type) == "string" then $item.name else $id end)}) as $named | (if ($item.context_length != null and $item.max_output_tokens != null) then $named + {limit: {context: $item.context_length, output: $item.max_output_tokens}} else $named end) as $limited | (if ($item | has("input_modalities")) and ($item.input_modalities | type) == "array" then $limited + (if $limited | has("attachment") then {} else {attachment: ($item.input_modalities | index("image") != null)} end) + (if $limited | has("modalities") then {} else {modalities: {input: (if ($item.input_modalities | index("image")) != null then ["text", "image"] else ["text"] end), output: ["text"]}} end) else $limited end) as $capable | .[$id] = (if ($item | has("supports_reasoning")) and ($item.supports_reasoning | type) == "boolean" and ($capable | has("reasoning") | not) then $capable + {reasoning: $item.supports_reasoning} else $capable end) end )) as $merged | . + {"$schema": (if has("$schema") then ."$schema" else "https://opencode.ai/config.json" end), provider: ($providers + {aichat: ($current + { npm: "@ai-sdk/openai-compatible", name: (if $current | has("name") then $current.name else "metaceptrons2" end), options: ($options + {baseURL: $base}), models: $merged })})} ' "$input_path" --args ${model_ids[@]+"${model_ids[@]}"} 2>&1 > "$output_file" <<< '{}'); then merge_error=$(printf '%s' "$merge_error" | sed -e 's/^jq: error ([^)]*): //' | head -n 1) fail "設定のマージに失敗したため、設定ファイルは変更していません: ${merge_error:-不明なエラー}" fi if [[ -e $config_path ]] && cmp -s -- "$config_path" "$output_file"; then printf 'すでに最新です: %s\n' "$config_path" return fi selected_count=$(jq '$ARGS.positional as $ids | [.data[] | select(($ids | length) == 0 or (.id as $id | $ids | index($id) != null))] | length' "$response_file" --args ${model_ids[@]+"${model_ids[@]}"}) existing_count=0 if [[ -e $config_path ]]; then existing_count=$(jq --slurpfile response "$response_file" '$ARGS.positional as $ids | (.provider.aichat.models // {}) as $old | [$response[0].data[] | select(($ids | length) == 0 or (.id as $id | $ids | index($id) != null)) | .id as $id | select($old | has($id))] | length' "$config_path" --args ${model_ids[@]+"${model_ids[@]}"}) fi added_count=$((selected_count - existing_count)) updated_count=$existing_count mv -f -- "$output_file" "$config_path" output_file='' printf 'aichat を %s に登録しました(追加 %s 件、更新 %s 件)\n' "$config_path" "$added_count" "$updated_count" } main() { check_dependencies parse_args "$@" normalize_base_url check_config trap cleanup EXIT trap 'exit 130' INT TERM fetch_models merge_config printf '次に API キーを登録してください: opencode auth login を実行し、「Other」を選んで provider ID に aichat、続けて API キーを入力します。\n登録後、opencode models aichat で確認できます。\n' } main "$@"