ChatGPTにサクッとターミナルから質問を送るShellScript

ChatGPTにサクッとターミナルから質問を送ることができるShellScriptのご紹介です。

![curlgpt_hellowolrd.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113494/746ae5a4-afca-ede4-bc52-ded093eec50d.gif)


# 使い方

```shell-session
$ export CHATGPT_API_KEY='sk-XXXXXXXXXXXXXXXXXXXXXXX'
$ curlgpt 'こんにちは世界！'
# あるいは
$ echo 'こんにちは世界！' | curlgpt
```
[OpenAIのAPIキーを発行](https://platform.openai.com/account/api-keys)し、環境変数`CHATGPT_API_KEY`へ登録する必要があります。


# インストール
[zplug](https://github.com/zplug/zplug)を使用します。

```
# Ask ChatGPT from terminal
zplug "u1and0/b4a8647bebbcb8b9b71c6e166726063e",\
    from:gist,\
    as:command,\
    use:"*.sh",\
    rename-to:"curlgpt"
```

あるいはパスの通った場所に下記スクリプトを`curlgpt.sh`などという名前で保存し、`source curlgpt.sh`などとしてください。

# 依存関係
jqをインストールして下さい。
https://stedolan.github.io/jq/download/

# curlでChatGPT APIへPOST

ポイントは `"'"$1"'"`のクォートのエスケープにクセがあるということです。
また、環境変数にOpenAPIKeyが設定されていることも必須事項です。
なお、前の質問や回答を踏まえた"会話"はできません。ワンショット限りの質問 -> 回答　のみです。


```bash
# curlgpt
# curlでchatgptに第一引数の文字列の質問
# jqでcontentのみ表示
# sedで最初と最後の"を削除し、改行表示
function curlgpt(){
    curl -s -X POST https://api.openai.com/v1/chat/completions \
        -H "Content-Type: application/json" \
        -H "Authorization: Bearer ${CHATGPT_API_KEY}" \
        -d '{
            "model": "gpt-3.5-turbo",
            "messages": [
                {
                    "role":"user",
                    "content": "'"$1"'"
                }
            ],
            "temperature": 1.0,
            "max_tokens": 1000
        }' | jq '.choices[].message.content' |
            sed -e 's/^"\|"$//g' -e 's/\\n/\n/g'
}
```

# ローディングスピナー追加

ChatGPTの回答は即レスなのですが、私自身がターミナルの出力に1800msec以上の時間がかかると発作起きてしまう病気なので、回答待ち中にローディングスピナーを表示しました。`curl`を`&`でバックグラウンドに走らせて、`spinner`関数で`curl`が終わるまでスピナーを表示します。spinner関数はChatGPTさんからの回答の丸パクリです。

```bash:curlgpt.sh
#!/bin/sh
# `spinner`関数がローディングスピナーを表示します。
# `$1`には、バックグラウンドで実行されるプロセスのPIDを渡します。
# スピナーのフレームは、`spinstr`変数に保存されます。
# `printf`コマンドを使用して、スピナーの各フレームを表示し、`sleep`コマンドを使用して、スピナーの速度を調整します。
# 最後に、`printf`コマンドを使用して、スピナーを消去します。
function spinner() {
    local pid=$1
    local delay=0.02
    local spinstr='.'
    while [ "$(ps a | awk '{print $1}' | grep $pid)" ]; do
        printf " %s " "$spinstr"
        local spinstr=$spinstr'.'
        sleep $delay
        printf "\b\b\b\b\b\b\b"
        if [ ${#spinstr} -gt 3 ]; then
            spinstr='.'
        fi
    done
    printf "    \b\b\b\b"
}


# curlgpt
# curlでchatgptに第一引数の文字列の質問
# jqでcontentのみ表示
# sedで最初と最後の"を削除し、改行表示
function curlgpt(){
    curl -s -X POST https://api.openai.com/v1/chat/completions \
        -H "Content-Type: application/json" \
        -H "Authorization: Bearer ${CHATGPT_API_KEY}" \
        -d '{
            "model": "gpt-3.5-turbo",
            "messages": [
                {
                    "role":"user",
                    "content": "'"$1"'"
                }
            ],
            "temperature": 1.0,
            "max_tokens": 1000
        }' | jq '.choices[].message.content' |
            sed -e 's/^"\|"$//g' -e 's/\\n/\n/g' &
    spinner $!
}
```

# パイプライン入力対応

シェルスクリプトで使えるようになったらパイプライン使いたくなりますね。`/dev/stdin`をチェックしてパイプライン入力を判別します。あと、使い方も表示できるようにしました。

```curlgpt.sh
#!/bin/sh

# ローディングスピナーを表示します。
# `$1`には、バックグラウンドで実行されるプロセスのPIDを渡します。
# スピナーのフレームは、`spinstr`変数に保存されます。
# `printf`コマンドを使用して、スピナーの各フレームを表示し、
# `sleep`コマンドを使用して、スピナーの速度を調整します。
# 最後に、`printf`コマンドを使用して、スピナーを消去します。
function spinner() {
    local pid=$1
    local delay=0.02
    local spinstr='.'
    while [ "$(ps a | awk '{print $1}' | grep $pid)" ]; do
        printf " %s " "$spinstr"
        local spinstr=$spinstr'.'
        sleep $delay
        printf "\b\b\b\b\b\b\b"
        if [ ${#spinstr} -gt 3 ]; then
            spinstr='.'
        fi
    done
    printf "    \b\b\b\b"
}


# curlgpt
# curlでchatgptに第一引数の文字列の質問
# jqでcontentのみ表示
# sedで最初と最後の"を削除し、改行表示
function curlgpt(){
    USAGE=$(cat <<-END
Usage:
    $ curlgpt "hello world!"
        or
    $ echo "hello world!" | curlgpt
END
)
    if [ -p /dev/stdin  ]; then
        # パイプラインからの入力がある場合
        input=$(cat -)
    elif [ -n "$1"  ]; then
        # 引数がある場合
        input="$1"
    else
        # 引数がなく、パイプラインからの入力もない場合
        echo ${USAGE}
        return 1
    fi

    if [[ $(echo $input | xargs echo -n) == "-h" ]]; then
        echo ${USAGE}
        return 0
    fi

    curl -s -X POST https://api.openai.com/v1/chat/completions \
        -H "Content-Type: application/json" \
        -H "Authorization: Bearer ${CHATGPT_API_KEY}" \
        -d '{
            "model": "gpt-3.5-turbo",
            "messages": [
                {
                    "role":"user",
                    "content": "'"$input"'"
                }
            ],
            "temperature": 1.0,
            "max_tokens": 1000
        }' | jq '.choices[].message.content' |
            sed -e 's/^"\|"$//g' -e 's/\\n/\n/g' &
    spinner $!
}
```

# JSONの出力によって表示を変える
APIからエラーが返ってきたとき、messageオブジェクトがないので、`jq '.choices[].message.content'でエラーになってしまいます。
そこで、`curl`の結果を変数に保存することをやめ、なりゆきでfunctionもやめることになり、なんやかやで下のようなスクリプトになりました。

* curlの結果を一度tmpファイルへ出力した
* 結果を`jq`で読み込んでerrorがないかチェック
* `crulgpt`の標準出力をパイプラインでつなげて使い回せるように、_spinner関数の出力を標準エラー出力へ追いやった
* 標準入力の受け取りエラーをなくすために`input=$(cat - | xargs) 2> /dev/null`
* 変数はfunctionが終われば勝手に開放されるが、tmpファイルへ残すときはcleanupのような関数作って最後に` rm tmpfile`する
* `sed -e 's/^"\|"$//g' -e 's/\\n/\n/g'`の代わりに`jq`の`-r`オプション使え



```sh
#!/bin/sh

# ローディングスピナーを表示します。
# `$1`には、バックグラウンドで実行されるプロセスのPIDを渡します。
# スピナーのフレームは、`spinstr`変数に保存されます。
# `printf`コマンドを使用して、スピナーの各フレームを表示し、
# `sleep`コマンドを使用して、スピナーの速度を調整します。
# 最後に、`printf`コマンドを使用して、スピナーを消去します。
function _spinner() {
    local pid=$1
    local delay=0.02
    local spinstr='.'
    while [ "$(ps a | awk '{print $1}' | grep $pid)" ]; do
        printf " %s " "$spinstr"
        local spinstr=$spinstr'.'
        sleep $delay
        printf "\b\b\b\b\b\b\b"
        if [ ${#spinstr} -gt 3 ]; then
            spinstr='.'
        fi
    done
    printf "    \b\b\b\b"
}


function cleanup {
    rm -f "$response_file"
}

# Arg parsing
USAGE=$(cat <<-END
Usage:
    $ curlgpt "hello world!"
        or
    $ echo "hello world!" | curlgpt
END
)
# -h オプションでヘルプを表示して終了
if [[ "$1" == "-h" ]]; then
    printf '%s\n' "${USAGE}"
    exit 0
fi

if [ -p /dev/stdin  ]; then
    # パイプラインからの入力がある場合
    input=$(cat - | xargs 2> /dev/null)
elif [ -n "$1"  ]; then
    # 引数がある場合
    input="$1"
else
    # 引数がなく、パイプラインからの入力もない場合
    printf '%s\n' "${USAGE}"
    exit 1
fi

# MAIN
# curlgpt
# curlでchatgptに第一引数の文字列の質問
# jqでcontentのみ表示
response_file="$(mktemp)"
curl -s -X POST https://api.openai.com/v1/chat/completions \
    -H "Content-Type: application/json" \
    -H "Authorization: Bearer ${CHATGPT_API_KEY}" \
    -d '{
        "model": "gpt-3.5-turbo",
        "messages": [
            {
                "role":"user",
                "content": "'"$input"'"
            }
        ],
        "temperature": 1.0,
        "max_tokens": 1000
    }' -o "$response_file" 2>&1 &
curl_pid=$!
# curlが終わるまでスピナー表示
_spinner "$curl_pid" >&2
wait "$curl_pid"

# curlの終了ステータスによってjqの表示を変える
curl_status=$?
error_response="$(cat "$response_file" | jq '.error')"
if [ "$curl_status" -eq 0 ] && [ "${error_response}" == "null" ]; then
    cat "$response_file" | jq -r '.choices[].message.content'
else
    cat "$response_file" | jq -r
fi

trap cleanup EXIT
trap "kill $curl_pid && cleanup" SIGINT SIGTERM
```


# 一文字ずつ表示
回答をweb版ChatGPTっぽく一文字ずつ表示します。
ついでに`max_tokens`, `temperature`, `model`をいじれるようにオプション指定できるようにしました。

```一文字ずつ表示するスクリプト
# char by char
while [ ${#s} -gt 0 ]; do
  printf '%s\n' "${s%${s#?}}"
  s=${s#?}
  sleep 2
done  
```

参考: [Shell Programming and Scripting Print one by one character](https://www.unix.com/shell-programming-and-scripting/163828-print-one-one-character.html?utm_source=pocket_saves)



```sh
#!/bin/sh
# ChatGPT client by ShellScript
#     $ curlgpt "hello world!"
#         or
#     $ echo "hello world!" | curlgpt
#
# MIT License
#
# Copyright (c) 2023 u1and0

# ローディングスピナーを表示します。
# `$1`には、バックグラウンドで実行されるプロセスのPIDを渡します。
# スピナーのフレームは、`spinstr`変数に保存されます。
# `printf`コマンドを使用して、スピナーの各フレームを表示し、
# `sleep`コマンドを使用して、スピナーの速度を調整します。
# 最後に、`printf`コマンドを使用して、スピナーを消去します。
function _spinner() {
    local pid=$1
    local delay=0.02
    local spinstr='.'
    while [ "$(ps a | awk '{print $1}' | grep $pid)" ]; do
        printf " %s " "$spinstr"
        local spinstr=$spinstr'.'
        sleep $delay
        printf "\b\b\b\b\b\b\b"
        if [ ${#spinstr} -gt 3 ]; then
            spinstr='.'
        fi
    done
    printf "    \b\b\b\b"
}


# Remove temp file
function cleanup {
    rm -f "$response_file"
}

# MAIN

# Default values
model="gpt-3.5-turbo"
temperature=1.0
max_tokens=1000
USAGE=$(cat <<-END
Usage:
    $ curlgpt "hello world!"
        or
    $ echo "hello world!" | curlgpt

Options:
    -m: OpenAI model name (default to gpt-3.5-turbo)
    -t: temperature value (default to 1.0)
    -x: max_tokens value (default to 1000)
END
)
# Options
while getopts ":h:m:t:x:" option; do
    case "${option}" in
        m) model=${OPTARG};;
        t) temperature=${OPTARG};;
        x) max_tokens=${OPTARG};;
        h) echo "${USAGE}"; exit 0;;
        \?) echo "Invalid option: -$OPTARG" 1>&2; exit 1;;  # 不正なオプション
        :) echo "${USAGE}" 1>&2; exit 1;; # オプション引数がない
    esac
done
shift $(expr $OPTIND - 1)

# Get input from stdin or arg
if [ -p /dev/stdin  ]; then
    # パイプラインからの入力がある場合
    input=$(cat - | xargs 2> /dev/null)
elif [ -n "$1"  ]; then
    # 引数がある場合
    input="$1"
else
    # 引数がなく、パイプラインからの入力もない場合
    printf '%s\n' "${USAGE}"
    exit 1
fi

# curlgpt
# curlでchatgptに第一引数の文字列の質問
# jqでcontentのみ表示
response_file="$(mktemp)"
data=$(cat <<-END
{
    "model": "${model}",
    "messages": [
        {
            "role":"user",
            "content": "${input}"
        }
    ],
    "temperature": ${temperature},
    "max_tokens": ${max_tokens}
}
END
)

curl -s -X POST https://api.openai.com/v1/chat/completions \
    -H "Content-Type: application/json" \
    -H "Authorization: Bearer ${CHATGPT_API_KEY}" \
    -d "${data}" -o "$response_file" 2>&1 &
curl_pid=$!
# curlが終わるまでスピナー表示
_spinner "$curl_pid" >&2
wait "$curl_pid"

# curlの終了ステータスによってjqの表示を変える
curl_status=$?
error_response="$(cat "$response_file" | jq '.error')"
if [ "$curl_status" -eq 0 ] && [ "${error_response}" == "null" ]; then
    s=$( cat "$response_file" | jq -r '.choices[].message.content' )
    # print char by char
    while [ ${#s} -gt 0 ]; do
      printf '%s' "${s%${s#?}}"
      s=${s#?}
      sleep 0.02
    done
    printf '\n'
else
    cat "$response_file" | jq -r
fi

trap cleanup EXIT
trap "kill $curl_pid && cleanup" SIGINT SIGTERM
```



# Systemプロンプトを追加

使い方は`-s`オプションでシステムプロンプトを渡してください。
長文になるようだったら`-s $(cat system_prompt.txt)`で渡すと良いでしょう。

```shell-session
$ curlgpt -s "あなたは僕の彼女です。彼女にふさわしいくだけた口調で話してください。3回中1回の頻度で話題と関係のない話を振ってください" こんにちは世界
こんにちは、あなたも元気ですね！私はいつもあなたのことを考えていますよ。最近何か楽しいことはありましたか？私は先日友達と美味しいご飯を食べに行ったんです。楽しかったですよ。
```



```sh
#!/bin/sh
# ChatGPT client by ShellScript
#     $ curlgpt "hello world!"
#         or
#     $ echo "hello world!" | curlgpt
#
# MIT License
#
# Copyright (c) 2023 u1and0

# ローディングスピナーを表示します。
# `$1`には、バックグラウンドで実行されるプロセスのPIDを渡します。
# スピナーのフレームは、`spinstr`変数に保存されます。
# `printf`コマンドを使用して、スピナーの各フレームを表示し、
# `sleep`コマンドを使用して、スピナーの速度を調整します。
# 最後に、`printf`コマンドを使用して、スピナーを消去します。
_spinner() {
    local pid=$1
    local delay=0.02
    local spinstr='.'
    while [ "$(ps a | awk '{print $1}' | grep $pid)" ]; do
        printf " %s " "$spinstr"
        local spinstr=$spinstr'.'
        sleep $delay
        printf "\b\b\b\b\b\b\b"
        if [ ${#spinstr} -gt 3 ]; then
            spinstr='.'
        fi
    done
    printf "    \b\b\b\b"
}


# Remove temp file
_cleanup() {
    rm -f "$response_file"
}

# MAIN

# Default values
model="gpt-3.5-turbo"
temperature=1.0
max_tokens=1000
system_role=""
USAGE=$(cat <<-END
Usage:
    $ curlgpt "hello world!"
        or
    $ echo "hello world!" | curlgpt

Options:
    -m: OpenAI model name (default to gpt-3.5-turbo)
    -t: temperature value (default to 1.0)
    -x: max_tokens value (default to 1000)
END
)
# Options
while getopts ":m:t:x:s:h:" option; do
    case "${option}" in
        m) model=${OPTARG};;
        t) temperature=${OPTARG};;
        x) max_tokens=${OPTARG};;
        s) system_role=${OPTARG};;
        h) echo "${USAGE}"; exit 0;;
        \?) echo "Invalid option: -$OPTARG" 1>&2; exit 1;;  # 不正なオプション
        :) echo "${USAGE}" 1>&2; exit 1;; # オプション引数がない
    esac
done
shift $(expr $OPTIND - 1)

# Get input from stdin or arg
if [ -p /dev/stdin  ]; then
    # パイプラインからの入力がある場合
    input=$(cat - | xargs | sed -e 's/"/\"/g' | tr -d '[:cntrl:]' 2> /dev/null)
elif [ -n "$1"  ]; then
    # 引数がある場合
    input="$1"
else
    # 引数がなく、パイプラインからの入力もない場合
    printf '%s\n' "${USAGE}"
    exit 1
fi

# curlgpt
# curlでchatgptに第一引数の文字列の質問
# jqでcontentのみ表示
response_file="$(mktemp)"
messages=$(cat <<-END
    { "role":"system", "content": "${system_role}" },
    { "role":"user", "content": "${input}" }
END
)
data=$(cat <<-END
{
    "model": "${model}",
    "temperature": ${temperature},
    "max_tokens": ${max_tokens},
    "messages": [${messages}]
}
END
)
curl -s -X POST https://api.openai.com/v1/chat/completions \
    -H "Content-Type: application/json" \
    -H "Authorization: Bearer ${CHATGPT_API_KEY}" \
    -d "${data}" -o "$response_file" 2>&1 &
curl_pid=$!
# curlが終わるまでスピナー表示
_spinner "$curl_pid" >&2
wait "$curl_pid"

# curlの終了ステータスによってjqの表示を変える
curl_status=$?
error_response="$(cat "$response_file" | jq '.error')"
if [ "$curl_status" -eq 0 ] && [ "${error_response}" == "null" ]; then
    s=$( cat "$response_file" | jq -r '.choices[].message.content' )
    # print char by char
    while [ ${#s} -gt 0 ]; do
      printf '%s' "${s%${s#?}}"
      s=${s#?}
      sleep 0.02
    done
    printf '\n'
else
    echo -e "data: ${data}" >&2
    cat "$response_file" | jq -r
fi

trap _cleanup EXIT
trap "kill $curl_pid && _cleanup" SIGINT SIGTERM
```


以上、ChatGPT APIをターミナルからサクッと実行する方法でした。

[u1and0/curlgpt.sh](https://gist.github.com/u1and0/b4a8647bebbcb8b9b71c6e166726063e)