コマンドラインからAIと対話し、Vim×AIで開発効率を爆上げする

# はじめに

AIアシスタントと対話するためのコマンドラインツール「gpt-cli」の紹介です。これはOpenAIやAnthropicのAIモデルとターミナル上で対話することができるAPIクライアントです。

お使いのエディタがVimであれば、後述するVimプラグインの設定をすることでコーディングの補助やドキュメント生成、テストコード作成など、様々な用途に活用できます。

Vimエディタをお使いでない方も、ターミナル上でAIと対話できることの便利さを実感していただけると思います。

https://github.com/u1and0/gpt-cli


# 使い方

## gptコマンド

[インストール](#インストール)とAPIキーの[セットアップ](#セットアップ)が済んでいることが前提です。

```shell-session
$ gpt -m "モデル名" --max-tokens 100 -s 'システムプロンプト' 'ユーザープロンプト'
```

その他オプションは[オプション](#オプション)参照

<details><summary>少しだけ実装の話。</summary>

メイン関数はコマンドラインをパースして`LLM`クラスのインスタンスを作成し、`llm.ask()関数へ渡します。

```typescript:main()
async function main() {
  // Parse command argument
  const params = parseArgs();
  // console.debug(params);
  if (params.version) {
    console.error(`gpt ${VERSION}`);
    Deno.exit(0);
  }
  if (params.help) {
    console.error(helpMessage);
    Deno.exit(0);
  }
  // create LLM モデルの作成
  const llm = createLLM(params);
  const messages: Message[] = params.content !== undefined
    ? [{ role: Role.User, content: params.content }]
    : [];
  // query LLM 一回限りの応答
  if (params.no_conversation) {
    const content = await llm.query(messages);
    console.log(content);
    return;
  }
  // ask LLM 対話的応答
  console.log("Ctrl-D to confirm input, q or exit to end conversation");
  llm.ask(messages);
}
```

モデル名の始まりの文字でモデルごとのクラスを使い分けます。

```typescript:createLLM()
function createLLM(params: Params): LLM {
  if (params.model.startsWith("gpt")) {
    return new GPT(
      params.model,
      params.temperature,
      params.max_tokens,
      params.system_prompt,
    );
  } else if (params.model.startsWith("claude")) {
    return new Claude(
      params.model,
      params.temperature,
      params.max_tokens,
      params.system_prompt,
    );
  } else {
    throw new Error(`invalid model: ${params.model}`);
  }
}
```

例えば`GPT`クラスの`ask()`メソッドは次の役割を非同期に行います。`Claude`クラスも同様です。

- 入力の受付
- スピナーの開始、終了
- GPT APIの入出力
- 1文字ずつの出力

```typescript:GPT.ask()
class GPT implements LLM {
// ...snip
  /** ChatGPT へ対話形式に質問し、回答を得る */
  public async ask(messages: Message[]) {
    messages = await setUserInputInMessage(messages);
    if (this.system_prompt) {
      messages = this.pushSustemPrompt(messages);
    }
    const spinnerID = spinner.start();
    // POST data to OpenAI API
    const resp = await this.agent(messages);
    spinner.stop(spinnerID);
    messages = await this.print(resp, messages);
    await this.ask(messages);
  }
```

LLMインターフェイスで複数のAPIを規格化します。

- `agent`はAPIのインスタンスです。
- `ask()`は入出力プロンプトをやり取りします。
- `query()`は一度限りの回答をしてコマンドを終了します。
- `getContent()`はAPIから返ってきたJSONの出力プロンプトのみを取得します。

```typescript:interface LLM
interface LLM {
  agent: (messages: Message[]) => Promise<void>;
  ask(messages: Message[]): Promise<void>;
  query(messages: Message[]): Promise<string>;
  getContent(data: Response): string;
}
```

APIの出力を待っている間のロードスピナーの実装です。
`id = spinner.start()`でスタートして、`spinner.stop(id)`で止めます。
コンストラクタの第1引数にスピナー表示の文字列アレイ、第2に引数にスピナーが切り替わる間隔をmsecで指定します。

```typescript:Spinner()
/** 戻り値のIDがclearInterval()によって削除されるまで
 * ., .., ...を繰り返しターミナルに表示するロードスピナー
 * usage:
 *  const spinner = new Spinner([".", "..", "..."], 100);
 *  const spinnerID = spinner.start();
 *  // processing...
 *  spinner.stop(spinnerID);
 */
class Spinner {
  constructor(
    private readonly texts: string[],
    private readonly interval: number,
  ) {}

  start(): number {
    let i = 0;
    return setInterval(() => {
      i = ++i % this.texts.length;
      Deno.stderr.writeSync(new TextEncoder().encode("\r" + this.texts[i]));
    }, this.interval);
  }

  /** Load spinner stop */
  stop(id: number) {
    clearInterval(id);
  }
}

const spinner = new Spinner([".", "..", "..."], 100);
```

```typescript:1文字ずつの出力
class GPT implements LLM {
// ...snip
  // print1by1() の完了を待つために
  // async (data)として、print1by1()をawaitする
  public async print(response: Response, messages: Message[]) {
    if (response.error) {
      throw new Error(`Fetch request failed: ${response.error}`);
    }
    const content = this.getContent(response);
    // assistantの回答をmessagesに追加
    messages.push({ role: Role.Assistant, content: content });
    // console.debug(messages);
    await print1by1(`\n${this.model}: ${content}`);
    return messages;
  }
}

// 渡された文字列を1文字ずつ20msecごとにターミナルに表示する
export async function print1by1(str: string): Promise<void> {
  str += "\n";
  return await new Promise((resolve) => {
    let i = 0;
    const intervalId = setInterval(() => {
      Deno.stdout.writeSync(new TextEncoder().encode(str[i]));
      i++;
      if (i === str.length) {
        clearInterval(intervalId);
        resolve();
      }
    }, 20);
  });
}
```

v0.3.4時点での実装です。
詳細はリポジトリを覗いてください。
</details>


### ご挨拶

![Peek 2024-03-30 20-03.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113494/a15731ab-c7a7-c242-28ed-8abc6252c565.gif)

`gpt`コマンドを実行するとLLMモデルとの会話が始まります。

### Claudeとも会話できます

![Peek 2024-03-30 09-24 (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113494/faf5270c-4c4a-e906-888a-46e443a366ef.gif)

デフォルトではOpenAI社のLLM GPT-3.5-turbo と会話します。`--model`または`-m`オプションでモデルを指定することで、モデルを指定できます。GPT4やAnthropic社のLLM Claudeとも会話できます。


### 引数を渡すと即座にそのプロンプトに基づいて会話が始まります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113494/ac38f572-a2df-1f7f-aae2-21df91cd59da.png)

例えば、`gpt-cli.ts`の内容の一部を`tail`コマンドを使ってプロンプトに含めると、コードの解説をしてくれました。


### 役割を与える
system prompt(-s) オプションに役割を与えることができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113494/38f59992-b192-4955-0107-7c11cb87bfd9.png)

同じhello worldを渡しても、システムプロンプトを与えない場合では"Hello there! I'm ..."と挨拶を返すのに対して、システムプロンプトに'generate python code'を指定すると、`print("hello world")`を返してきました。


## Vim plugin[^2]
Github Copilot的なことができます。
Vimmerの方以外は残念ながら[まとめ](#まとめ)まで読み飛ばしてもらって結構です。

[^2]: [vimからchatGPTを呼んでスニペットみたいに使う](https://zenn.dev/yaasita/articles/8e1ee2f7a7603e)

### コメントからコードを生成してもらう
![Peek 2024-03-30 11-05.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113494/5c19c1f2-cecf-59df-c6ff-6dc2c364feca.gif)

`:GPTGenerateCode`で現在行の`fizz buzz`をユーザープロンプトに追加してコードを生成してもらいます。

<details><summary>:GPTGenerateCodeには次を設定しています。</summary>

```vim:~/.vimrc
command! -nargs=0 -range GPTGenerateCode 
\ <line1>,<line2>call gptcli#GPT('You are best of code generator.
\ Generate a prompt to continue coding based on the given input code.
\ Generate only code effectively, DO NOT generate descriptions nor code blocks.
\ If you need describe code, please comment out it.',
\ { "max_tokens": 400, "temperature": 0.5, "model": "claude-3-haiku-20240307" })
```

つまり、

```shell-session
$ gpt --max-tokens 400  \
    --temperature 0.5 \
    --model claude-3-haiku-20240307 \
    --system-prompt 'You are best of code generator. {...snip} \
    '{選択範囲}'
```

を実行して、その結果を選択範囲の最後に書き込みます。ここでは選択範囲に`fizz buzz`を渡しましたので、Claude3 HaikuがPythonでfizz buzzのコードを生成してくれました。


</details>




<details><summary>autoloadのGPT()関数</summary>

`gpt-cli/autoload/gptcli.vim`で次の`GPT()`関数を読み込みます。

```vim:autoload/gptcli.vim
" Usage
" call gptcli#GPT("your system prompt")
" call gptcli#GPT("your system prompt", {model="gpt-3.5-turbo", max_tokens=1000, temperature=1.0})
" 上に挙げたkwargsはgptのデフォルト値なので指定しなければこの値でgptが実行される。
function! gptcli#GPT(system_prompt, kwargs={}) range
            " \ max_tokens=1000,
            " \ temperature=1.0,
            " \ model="gpt-3.5-turbo") range
    " filetype &ft はGPTの実行先ファイルに応じて取得する
    " シングルクォートで囲まないと特殊文字をshellコマンドとして渡すときにエラー
    let l:args = ["gpt", "-n"]
    if a:system_prompt != ""
        if &ft != ""
            let syntax = " Use syntax of " . &ft
            let l:system_prompt =  "'" . a:system_prompt . l:syntax .  ".'"
        else
            let l:system_prompt = "'" . a:system_prompt .  ".'"
        endif
        call add(l:args, "-s")
        call add(l:args, l:system_prompt)
    endif

    " オプションの追加

    " gptのmodelのデフォルトはgpt-3.5-turbo
    if has_key(a:kwargs, "model")
        call add(l:args, "-m")
        call add(l:args, a:kwargs["model"])
    endif

    " gptのmax_tokensのデフォルトは1000
    if has_key(a:kwargs, "max_tokens")
        call add(l:args, "-x")
        call add(l:args, a:kwargs["max_tokens"])
    endif

    " gptのmax_tokensのデフォルトは1000
    if has_key(a:kwargs, "temperature")
        call add(l:args, "-t")
        call add(l:args, a:kwargs["temperature"])
    endif

    " 範囲指定をuser_promptとして使う。
    " 範囲指定がない場合は、現在のカーソル位置の行を使用する。
    let lines = getline(a:firstline, a:lastline)
    let l:user_prompt =  "'" . join(lines, "\n") . "'"
    " ユーザープロンプトを追加
    call add(l:args, l:user_prompt)
    let l:cmd = join(l:args)
    echo l:cmd

    " コマンドを実行して選択範囲の最終行以降に追加する。
    let l:result = systemlist(l:cmd)
    call append(a:lastline, l:result)
endfunction
```

</details>


### 好きな言語でコードを生成してもらう
現在のfiletypeを認識してその言語でコードを返します。

![Peek 2024-03-30 11-10.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113494/cfa8f3c6-a303-0a8c-396e-7a7a30d11844.gif)


先程のGifでは`no ft`ですのでAIが得意な言語のPythonを返してきましたが、こちらのGifでは`:set ft=javascript`としてJavaScriptに設定することで、`:GPTGenerateCode`に言語を明示しなくても、JavaScriptでfizz buzzのコードを生成してくれます。

<details><summary>GPT()のこの部分でftを読み込んでsystem promptに渡します。</summary>

```vim:autoload/gptcli.vim
{...snip}
if a:system_prompt != ""
        if &ft != ""
            let syntax = " Use syntax of " . &ft
            let l:system_prompt =  "'" . a:system_prompt . l:syntax .  ".'"
        else
            let l:system_prompt = "'" . a:system_prompt .  ".'"
        endif
        call add(l:args, "-s")
        call add(l:args, l:system_prompt)
    endif
{...snip}
```

つまり、system promptに`Use syntax of javascript.`が追加されます。

</details>



### テストコードを生成してもらう

![Peek 2024-04-01 03-31.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113494/fd167cdd-020d-4fbd-028d-76d9d73685ce.gif)

こちらのGifでは選択範囲のコードを`:GPTGenerateTest`でテストコードを生成して解説を加えてくれています。

<details><summary>:GPTGenerateTestには次を設定しています。</summary>

```vim:.vimrc
command! -nargs=0 -range GPTGenerateTest
    \ <line1>,<line2>call gptcli#GPT('You are the best code tester.
    \ Please write test code that covers all cases to try the given code.',
    \ { "temperature": 0.5, "model": "claude-3-haiku-20240307" })
```

</details>


しかし、解説が英語で返されているので、分かりづらいですね。

### 日本語で解説してもらう

![Peek 2024-04-01 03-35.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113494/f243f19b-ee47-9821-5899-7ed2acc17320.gif)

選択範囲を`:GPTGenerateDocs`に与えて、日本語でテストコードの解説をしてくれています。

<details><summary>:GPTGenerateDocsには次を設定しています。</summary>

```vim:.vimrc
command! -nargs=0 -range GPTGenerateDocs
    \ <line1>,<line2>call gptcli#GPT('あなたは最高のコードライターです。
    \ 与えられたコードに基づいてわかりやすい日本語のドキュメントを
    \ コメントアウトして生成してください。',
    \ {"max_tokens": 2000, "model": "claude-3-haiku-20240307"})
```

</details>

ご覧のように、お好きなプロンプトをプリセットしておくことで、簡単に生成AIをコーディングに取り込めます。

### 入力補完

![Peek 2024-04-01 04-30.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113494/8df7c32b-0493-03f2-d02f-d3a77094077f.gif)

キーバインド<kbd>C-X,C-*</kbd> で様々な補完が使えることはVimmer諸兄ならご存知のことかと思います。
例えば下記のようにしてマッピングを割り当てると、「コメントを書いてコード実装」したり、「最初のコードを自身が書いて残りをAIが実装」することができます。

```vim:.vimrc
" C-X, C-Gで:GPTGenerateCode
inoremap <C-x><C-g> <Esc>:GPTGenerateCode<CR>
```

コマンド入力を省略した、いわゆる「略式詠唱」みたいなことでしょうか。
`GPT()`関数は選択範囲を渡さないと現在行を基に補完を行います。
コード生成に限らず、好きなコマンド、好きなプロンプトを好きなマッピング[^1]に割り当てられます。

[^1]: 補完といえば<kbd>C-X</kbd>`で始まるものが一般的です。`h:compl`でデフォルトのマッピングを見ることができます。衝突しないように気をつけましょう。


### 任意のプロンプト

![Peek 2024-04-01 04-22.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113494/e6735744-8424-7ae5-1f35-8d89d85ad236.gif)

PythonコードをGo言語に書き直してもらっています。
先程Pythonで書いてもらったfizz buzzコードを選択して、コマンドには`:GPTComplete Go言語で書き直して`と入力します。

<details><summary>:GPTCompleteには次を指定しています。</summary>

```vim:.vimrc
command! -nargs=? -range GPTComplete
    \ <line1>,<line2>call gptcli#GPT(<q-args>,
    \ { "model": "claude-3-haiku-20240307" })
```

</details>

### 対話する
![Peek 2024-04-01 04-43.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113494/23286593-1418-d2cc-451a-e22efe7f3fa8.gif)

別ウィンドウにターミナルを開いて対話することができます。ウィンドウ間でコードをコピペできるので便利です。
コーディングして、相談して、またしばらくコーディングして、詰まったら自分のコードをコピペして、相談して...ができるので快適です。


<details><summary>:GPTConversateには次を設定しています。</summary>

q-argsで引数を渡せばシステムプロンプトを設定できます。
例えばGifでは関西弁で話してもらうように命じました。

```vim:.vimrc
command! -nargs=? GPTConversate
    \ call gptcli#GPTWindow(<q-args>,
    \ {"model": "claude-3-sonnet-20240229" })
```

</details>

<details><summary>GPTWindow()関数は次のようになっています。</summary>

```vim:gpt-cli/autoload/gptcli.vim
function! gptcli#GPTWindow(system_prompt="", kwargs={})
            " \ max_tokens=1000,
            " \ temperature=1.0,
            " \ model="gpt-3.5-turbo")
    " gptを起動するコマンドを構築する
    let l:args = ["gpt"]
    " system_promptがあれば追加
    if a:system_prompt != ""
        call extend(l:args, [ "-s", a:system_prompt ])
    endif
    " gptのmodelのデフォルトはgpt-3.5-turbo
    if has_key(a:kwargs, "model")
        call add(l:args, "-m")
        call add(l:args, a:kwargs["model"])
    endif

    " gptのmax_tokensのデフォルトは1000
    if has_key(a:kwargs, "max_tokens")
        call add(l:args, "-x")
        call add(l:args, a:kwargs["max_tokens"])
    endif

    " gptのtemperatureのデフォルトは1.0
    if has_key(a:kwargs, "temperature")
        call add(l:args, "-t")
        call add(l:args, a:kwargs["temperature"])
    endif

    echo join(l:args)
    " 新しいWindowでterminalでgptコマンドを実行する
    let l:cmd = ["new", "|", "term"]
    call extend(l:cmd, l:args)
    execute join(l:cmd)
    " call setline(1, l:user_prompt) " システムプロンプトを最初の行に設定
endfunction
```

</details>



# インストール
3つのオプションがあります。

## バイナリをダウンロードする
最も簡単な方法です。
ただし、[リポジトリ](https://github.com/u1and0/gpt-cli)または[Release](https://github.com/u1and0/gpt-cli/releases)を見て最新バージョンであることを確認してください。

```shell-session
$ curl -LO https://github.com/u1and0/gpt-cli/releases/download/v0.3.4/gpt-cli-linux.zip
$ unzip gpt-cli-linux.zip
$ chmod 755 gpt
$ sudo ln -s ./gpt /usr/bin
$ gpt -v
```

gpt-cli-macos.zipとgpt-cli-windows.zipもあります[^3]。
zipファイル名を入れ替えてみてください。

[^3]: [Actions](https://github.com/u1and0/gpt-cli/actions)による自動リリースのため、動作未検証です。


## ランタイムを使う
[deno](https://docs.deno.com/runtime/manual/getting_started/installation)が必要です。

deno install` を使って `~/.deno/bin`にリンクを作成します。

```shell-session
$ git clone https://github.com/u1and0/gpt-cli
$ cd gpt-cli
$ deno install -f --allow-net --allow-env --name gpt gpt-cli.ts
$ export PATH=$PATH:~/.deno/bin
$ bash -l
```


## ソースからコンパイル
[deno](https://docs.deno.com/runtime/manual/getting_started/installation)が必要です。

`deno compile`でバイナリを作成します。

```shell-session
$ git clone https://github.com/u1and0/gpt-cli
$ cd gpt-cli
$ deno compile --allow-net --allow-env --no-check --output gpt gpt-cli.ts
$ chmod 755 ./gpt
$ sudo ln -s ./gpt /usr/bin
```


# セットアップ

## API キー
### OpenAI API (GPT)

[OpenAI APIキーの取得](https://platform.openai.com/api-keys)を行い、環境変数を設定します。

```shell-session
$ export OPENAI_API_KEY='sk-*****'
```

### Anthropic API (Claude)

[Anthropic APIキーを取得](https://console.anthropic.com/login)し、環境変数を設定します。

```shell-session
$ export ANTHROPIC_API_KEY='sk-ant-*****'
```

Windowsユーザーはシステムのプロパティから環境変数に設定してください。


## Vimプラグイン
VimにGithub Copilotのような体験をもたらします。

例えば、deinでプラグインを管理したい場合、以下のようなtomlファイルを書きます。

```toml
[[plugins]]
repo = 'u1and0/gpt-cli'
if = '''executable('gpt')'''
hook_add = '''
    command! -nargs=0 -range GPTGenerateCode <line1>,<line2>call gptcli#GPT('You are best of code generator. Generate a prompt to continue coding based on the given input code. Generate only code effectively, DO NOT generate descriptions nor code blocks. If you need describe code, please comment out it.', { "max_tokens": 400, "temperature": 0.5, "model": "claude-3-haiku-20240307" })
    " Keybind C-X, C-G
    inoremap <C-x><C-g> <Esc>:GPTGenerateCode<CR>
    " Docs to code
    command! -nargs=0 -range GPTGenerateDocs <line1>,<line2>call gptcli#GPT('あなたは最高のコードライターです。 与えられたコードに基づいてわかりやすい日本語のドキュメントをコメントアウトして生成してください。', {"max_tokens": 2000, "model": "claude-3-haiku-20240307"})
    " Create test code
    command! -nargs=0 -range GPTGenerateTest <line1>,<line2>call gptcli#GPT('You are the best code tester. Please write test code that covers all cases to try the given code.', { "temperature": 0.5, "model": "claude-3-haiku-20240307" })

    " Any system prompt
    command! -nargs=? -range GPTComplete <line1>,<line2>call gptcli#GPT(<q-args>, { "model": "claude-3-haiku-20240307" })
    " Conversate with GPT
    command! -nargs=? GPTConversate call gptcli#GPTWindow(<q-args>, {"model": "claude-3-sonnet-20240229" })
'''
```


GPT()関数に独自のプロンプトを設定することで、AIは選択範囲を基に補完します。

GPTWindow()関数を使えば、コマンドラインにケースバイケースのシステムプロンプトを置くことで、ターミナルを開き、Vim上でAIと対話することができます。



## オプション

| 短いオプション | 長いオプション | 型 | 説明 |
|----|--------|--------------|-----------|
| -v | --version | boolean | バージョンを表示する。 |
| -h | --help | boolean | ヘルプメッセージを表示する。 |
| -m | --model | string | OpenAI または Anthropic モデル (gpt-4, claude-instant-1.2, claude-3-opus-20240229, claude-3-haiku-20240307, デフォルト gpt-3.5-turbo)| |
| -x | --max_tokens | number | 回答の最大トークンの数（デフォルト1000）|
| -t | --temperature | number | 数値が高いほどクリエイティブな回答、低いほど正確な回答（デフォルト1.0) |
| -s | --system-prompt | string | AIモデルの返答を導くために与えられる最初の指示|
| -n | --no-conversation | boolan | 会話モードなし。1回の質問と回答のみ。|


# まとめ

本記事では、AIアシスタントと対話できるコマンドラインツール「gpt-cli」とそのVimプラグインについて解説しました。

gpt-cliを使えば、ターミナルから離れることなくAIモデルと簡単に対話することができます。単なる質問応答だけでなく、shellの入出力を利用できる点が強みです。

また、Vimプラグインでは、エディタ上でAIとインタラクティブに作業できる点が大きな魅力です。コメントからコードを生成したり、テストコードを自動生成したり、任意のプロンプトに応じた回答を得られるため、コーディングの効率が大幅に向上します。

AIを活用したプログラミング作業を体験してみたい方は、ぜひgpt-cliを試してみてはいかがでしょうか。AIの時代に向けて、新しいプログラミングスタイルを手に入れられるはずです。