3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

自分でAIエージェントを作ってみたいと思ったことはありませんか?
コーディングエージェントを使っていると、「内部はどう設計されていて、どう実装されているのか」が気になることがあります。
そこで触ってみたいのが Pi SDK です。

Pi SDK は、TypeScript製のAIエージェント開発ライブラリです。
軽量なターミナル用AIエージェントとして使えるだけでなく、SDK として自分の AI エージェントを実装したり、アプリケーションに組み込んだりできます。
公式ドキュメントでも、Pi SDK は Pi のエージェント機能をプログラムから利用するためのものとして紹介されています。カスタム UI の作成、既存アプリへの統合、自動化ワークフロー、カスタムツールの実装などに利用できます。
Pi SDK は、主に次の3つのパッケージで構成されています。

  • @earendil-works/pi-coding-agent
    コーディングエージェントのランタイムを実装するためのモジュールが含まれています
  • @earendil-works/pi-agent-core
    エージェントの基本機能が抽象化されています
  • @earendil-works/pi-ai
    さまざまなLLMプロバイダーを同じAPIで扱えるように抽象化されています

今回はこのうち、コーディングエージェント向けのパッケージである @earendil-works/pi-coding-agent を使って開発していきます。

余談ですが、 Pi は 自律型のAIエージェントとして有名なあの OpenClaw といった実プロダクトでも使われてきたエージェント基盤です。

ただし、OpenClaw はもともと Pi のruntime を使っていましたが、最近では OpenClaw 側で agent runtime の内部化が進んでおり、リポジトリ内の packages/agent-core にコア部分が実装されています。
そのため、現在の OpenClaw を「Pi SDK をそのまま呼び出している実装」とまでは言い切れないのですが、Pi SDK を使ううえでの設計例としては、今でも参考になる部分が多いです。

この記事では、Pi の公式ドキュメントと公式 examples を参照しながら、最小構成の CLI エージェントを段階的に作っていきます。

本記事は 2026年6月初頭の @earendil-works/pi-coding-agent 0.79.x 系を前提にしています。Pi SDK は更新が速いため、最新バージョンではAPIや挙動が変わっている可能性があります。

0. はじめに:この記事で作るもの

この記事では、最終的に次のような CLI を作ります。

# 現在のディレクトリを対象にワンショット実行
pnpm run dev -- "このディレクトリの構成を説明して"
# 別ディレクトリを対象にワンショット実行
pnpm run dev -- -C ../my-app "主要ファイルを要約して"
# 別ディレクトリを対象に対話モードで起動
pnpm run dev -- --cwd ../my-app
# この記事では pnpm で実行していますが、npm でも同じ考え方で実行できます。
# npm を使う場合は、適宜 pnpm を npm に読み替えてください。
  • ユーザーの入力を Pi のエージェントに送る
  • エージェントの返答をストリーミング表示する
  • read / grep / find / ls などの組み込みツールを有効にする
  • -C / --cwd で対象プロジェクトのカレントディレクトリを指定する
  • 自作のカスタムツールを追加する
  • .pi/skills にスキルを追加して、プロジェクト向けの振る舞いを定義する

1. 最小のチャットループを作る

まずは、Pi SDK でエージェントに1回だけ質問する最小コードを書きます。
最初に、AI とチャットできるだけの実装を作ってみましょう。

プロジェクトを作る

mkdir pi-light-agent
cd pi-light-agent
pnpm init -y

pnpm 11 では、一部の依存パッケージの build script 承認でインストールが止まることがあります。
その対策として、pnpm-workspace.yaml を作っておきます。

allowBuilds:
  '@google/genai': true
  esbuild: true
  protobufjs: true

依存関係を追加します。

pnpm add @earendil-works/pi-coding-agent typebox
pnpm add -D typescript tsx @types/node

package.json を編集します。主な変更点は scripts です。

{
  "name": "pi-light-agent",
  "version": "0.1.0",
  "packageManager": "pnpm@11.0.7",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "tsx src/cli.ts",
    "minimal": "tsx src/minimal.ts"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "type": "module",
  "dependencies": {
    "@earendil-works/pi-coding-agent": "^0.79.0",
    "typebox": "^1.2.4"
  },
  "devDependencies": {
    "@types/node": "^25.9.2",
    "tsx": "^4.22.4",
    "typescript": "^6.0.3"
  }
}

tsconfig.json も作ります。

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*.ts"]
}

API キーは環境変数で渡します。まずはどちらか一方で大丈夫です。

export ANTHROPIC_API_KEY="your_api_key"
# または
export OPENAI_API_KEY="your_api_key"

最小コードを書く

src/minimal.ts を作ります。

import {
  AuthStorage,
  createAgentSession,
  ModelRegistry,
  SessionManager
} from "@earendil-works/pi-coding-agent";
const authStorage = AuthStorage.create();
const modelRegistry = ModelRegistry.create(authStorage);
const { session } = await createAgentSession({
  authStorage,
  modelRegistry,
  sessionManager: SessionManager.inMemory()
});
try {
  session.subscribe((event) => {
    if (
      event.type === "message_update" &&
      event.assistantMessageEvent.type === "text_delta"
    ) {
      process.stdout.write(event.assistantMessageEvent.delta);
    }
  });
  await session.prompt("このディレクトリにはどんなファイルがありますか?");
} finally {
  session.dispose();
}

動かします。

pnpm run minimal

このコードの流れはシンプルです。

  1. createAgentSession() でエージェントのセッションを作る
  2. session.subscribe() でイベントを購読する
  3. session.prompt() でユーザー入力を送る
  4. text_delta イベントを標準出力に流す

Pi SDK のドキュメントでも、Quick Start として AuthStorageModelRegistrySessionManager.inMemory()session.subscribe()session.prompt() を使う例が掲載されています。
ただ、この時点ではまだ CLI らしい入口がありません。次に、コマンドライン引数からプロンプトを受け取れるようにします。

1.5. CLIの入口を作って試す

src/cli.ts を作ります。
まずは、引数で渡した文章をそのまま session.prompt() に渡すだけのワンショット CLI にします。

import {
  AuthStorage,
  createAgentSession,
  ModelRegistry,
  SessionManager
} from "@earendil-works/pi-coding-agent";
const prompt = process.argv.slice(2).join(" ").trim();
if (!prompt) {
  console.error('Usage: pnpm run dev -- "質問文"');
  process.exit(1);
}
const authStorage = AuthStorage.create();
const modelRegistry = ModelRegistry.create(authStorage);
const { session } = await createAgentSession({
  authStorage,
  modelRegistry,
  sessionManager: SessionManager.inMemory()
});
try {
  session.subscribe((event) => {
    if (
      event.type === "message_update" &&
      event.assistantMessageEvent.type === "text_delta"
    ) {
      process.stdout.write(event.assistantMessageEvent.delta);
    }
    if (event.type === "agent_end") {
      process.stdout.write("\n");
    }
  });
  await session.prompt(prompt);
} finally {
  session.dispose();
}

実行します。

pnpm run dev -- "こんにちは。短く自己紹介して"

pnpm run dev -- "..."-- は、pnpm に対して「ここから先の引数をスクリプトへ渡す」という意味です。
これで、最低限のワンショット CLI ができました。
次に、引数がないときは対話モードで起動するようにします。

import { stdin as input, stdout as output } from "node:process";
import { createInterface } from "node:readline/promises";
import {
  AuthStorage,
  createAgentSession,
  ModelRegistry,
  SessionManager
} from "@earendil-works/pi-coding-agent";
const initialPrompt = process.argv.slice(2).join(" ").trim();
const authStorage = AuthStorage.create();
const modelRegistry = ModelRegistry.create(authStorage);
const { session } = await createAgentSession({
  authStorage,
  modelRegistry,
  sessionManager: SessionManager.inMemory()
});
let busy = false;
session.subscribe((event) => {
  if (
    event.type === "message_update" &&
    event.assistantMessageEvent.type === "text_delta"
  ) {
    process.stdout.write(event.assistantMessageEvent.delta);
  }
  if (event.type === "agent_end") {
    process.stdout.write("\n");
  }
});
async function ask(text: string): Promise<boolean> {
  const prompt = text.trim();
  if (!prompt) return true;
  busy = true;
  try {
    await session.prompt(prompt);
    return true;
  } catch (error) {
    console.error(
      `\n[error] ${error instanceof Error ? error.message : String(error)}`
    );
    return false;
  } finally {
    busy = false;
  }
}
process.on("SIGINT", async () => {
  if (busy) {
    await session.abort();
    process.stdout.write("\n[aborted]\n");
    return;
  }
  session.dispose();
  process.exit(0);
});
if (initialPrompt) {
  const ok = await ask(initialPrompt);
  session.dispose();
  process.exit(ok ? 0 : 1);
}
const rl = createInterface({ input, output });
console.log("Pi light agent");
console.log("commands: /exit");
console.log("");
while (true) {
  const text = (await rl.question("> ")).trim();
  if (!text) continue;
  if (text === "/exit") {
    rl.close();
    session.dispose();
    process.exit(0);
  }
  await ask(text);
}

ワンショット実行は次のように行います。

pnpm run dev -- "このディレクトリの構成を説明して"

対話モードは次のように起動します。

pnpm run dev
> このプロジェクトは何をするものですか?

これで、自作の最小 CLI ループができました。

2. 組み込みツールを追加してみる

AIエージェントにローカルファイルやコマンド実行権限を与える場合、意図しないファイル参照、秘密情報の読み取り、コマンド実行などのリスクがあります。この記事ではまず読み取り系ツールのみを有効にし、APIキーや社内情報を含むディレクトリでは試さない前提で説明します。bash / edit / write を有効にする場合は、サンドボックス環境や検証用リポジトリで試すことをおすすめします。

ここまでは、エージェントに質問を送るだけでした。
次は、Piが用意している組み込みツールを有効にしてみましょう。
Pi SDK のドキュメントでは、組み込みツールとして readbasheditwritegrepfindls が挙げられています。また、読み取り専用の例として tools: ["read", "grep", "find", "ls"] が紹介されています。
この記事では、まず安全寄りに読み取り系だけを有効にします。

const { session } = await createAgentSession({
  authStorage,
  modelRegistry,
  tools: ["read", "grep", "find", "ls"],
  sessionManager: SessionManager.inMemory()
});

src/cli.ts の該当部分を書き換えます。

const enabledTools = ["read", "grep", "find", "ls"] as const;
const { session } = await createAgentSession({
  authStorage,
  modelRegistry,
  tools: [...enabledTools],
  sessionManager: SessionManager.inMemory()
});

起動時にツール一覧も表示しておきます。

console.log("Pi light agent");
console.log(`tools: ${enabledTools.join(", ")}`);
console.log("commands: /tools, /exit");
console.log("");

対話モードのローカルコマンドとして /tools も追加します。

if (text === "/tools") {
  console.log(enabledTools.join(", "));
  continue;
}

これで、エージェントは対象ディレクトリのファイルを読んだり、検索したりできるようになります。
それでは、試してみましょう。

pnpm run dev -- "このプロジェクトの主要ファイルを調べて、役割を説明して"

このチュートリアルでは basheditwrite は有効にしませんが、安全なサンドボックス環境などを用意して試してみるのもよさそうです。

2.5. 指定したディレクトリを認識する

いまのCLIは、起動したディレクトリを対象に動きます。
そのため、pnpm run dev -- ... のように実行すると、このチュートリアル用プロジェクトのディレクトリしか認識できません。
別のプロジェクトを対象にしたい場合は、次のようにディレクトリを引数で渡せると便利です。

pnpm run dev -- -C ../my-app "このプロジェクトを説明して"

Pi SDK のドキュメントでは、custom cwd を渡すと、createAgentSession() がその cwd を基準に選択済みの組み込みツールを作ると説明されています。また、cwdDefaultResourceLoader.pi/extensions/.pi/skills/.pi/prompts/AGENTS.md などを探す基準にもなります。
そこで、-C / --cwd / --cwd=... を受け取れるようにします。

引数パーサーを作る

src/args.ts を作ります。

import { stat } from "node:fs/promises";
import path from "node:path";
export interface ParsedAgentArgs {
  cwd: string;
  prompt: string;
  help: boolean;
}
export async function parseAgentArgs(
  argv: string[],
  baseCwd = process.cwd()
): Promise<ParsedAgentArgs> {
  let cwd = baseCwd;
  const promptParts: string[] = [];
  for (let i = 0; i < argv.length; i += 1) {
    const arg = argv[i];
    if (i === 0 && arg === "--") {
      continue;
    }
    if (arg === "-h" || arg === "--help") {
      return {
        cwd: path.resolve(cwd),
        prompt: "",
        help: true
      };
    }
    if (arg === "-C" || arg === "--cwd") {
      const value = argv[i + 1];
      if (!value) {
        throw new Error(`${arg} requires a directory path.`);
      }
      cwd = path.resolve(baseCwd, value);
      i += 1;
      continue;
    }
    if (arg.startsWith("--cwd=")) {
      const value = arg.slice("--cwd=".length);
      if (!value) {
        throw new Error("--cwd requires a directory path.");
      }
      cwd = path.resolve(baseCwd, value);
      continue;
    }
    if (arg === "--") {
      promptParts.push(...argv.slice(i + 1));
      break;
    }
    promptParts.push(arg);
  }
  const resolvedCwd = path.resolve(cwd);
  const stats = await stat(resolvedCwd).catch(() => undefined);
  if (!stats) {
    throw new Error(`Directory does not exist: ${resolvedCwd}`);
  }
  if (!stats.isDirectory()) {
    throw new Error(`Not a directory: ${resolvedCwd}`);
  }
  return {
    cwd: resolvedCwd,
    prompt: promptParts.join(" ").trim(),
    help: false
  };
}
export function usage(): string {
  return `Usage:
pnpm run dev -- [options] [prompt]
Options:
  -C, --cwd <dir>   Run the agent against a specific directory
  --cwd=<dir>       Same as above
  -h, --help        Show this help
Examples:
pnpm run dev -- "このディレクトリの構成を説明して"
pnpm run dev -- -C ../my-app "主要ファイルを要約して"
pnpm run dev -- --cwd=/path/to/project`;
}

cli.tscwd を渡す

src/cli.ts で、この引数パーサーを使います。

import { parseAgentArgs, usage } from "./args.js";
let parsed;
try {
  parsed = await parseAgentArgs(process.argv.slice(2));
} catch (error) {
  console.error(
    `[error] ${error instanceof Error ? error.message : String(error)}`
  );
  console.error(usage());
  process.exit(1);
}
if (parsed.help) {
  console.log(usage());
  process.exit(0);
}
const cwd = parsed.cwd;
const initialPrompt = parsed.prompt;

続いて、createAgentSession()SessionManager.inMemory()cwd を渡します。

const { session } = await createAgentSession({
  cwd,
  authStorage,
  modelRegistry,
  tools: [...enabledTools],
  sessionManager: SessionManager.inMemory(cwd)
});

ここで重要なのは、同じ cwd を Pi SDK 側に渡すことです。

createAgentSession({ cwd, ... })
SessionManager.inMemory(cwd)

さらに、プロジェクト固有の .pi/skillsAGENTS.md などの読み込みを明示したい場合は、DefaultResourceLoader にも cwd を渡します。

import {
  DefaultResourceLoader,
  getAgentDir
} from "@earendil-works/pi-coding-agent";
const agentDir = getAgentDir();
const loader = new DefaultResourceLoader({
  cwd,
  agentDir,
  systemPromptOverride: () =>
    `あなたは軽量CLIで動く開発支援エージェントです。
ルール:
- 日本語で簡潔に答える
- ファイル操作はまず読み取り中心で行う
- 不明点があれば、推測と事実を分けて説明する
- 対象プロジェクトのルートは ${cwd} です`.trim()
});
await loader.reload();
const { session } = await createAgentSession({
  cwd,
  agentDir,
  authStorage,
  modelRegistry,
  resourceLoader: loader,
  tools: [...enabledTools],
  sessionManager: SessionManager.inMemory(cwd)
});

cwd を確認できるように、対話モードにも /cwd コマンドを追加します。

if (text === "/cwd") {
  console.log(cwd);
  continue;
}

起動時の表示も変えます。

console.log("Pi light agent");
console.log(`cwd: ${cwd}`);
console.log(`tools: ${enabledTools.join(", ")}`);
console.log("commands: /cwd, /tools, /exit");
console.log("");

試してみます。

pnpm run dev -- -C ../my-app "このプロジェクトの構成を説明して"

または、対話モードで起動します。

pnpm run dev -- --cwd ../my-app

これで、CLI を置いている場所と、エージェントに見せたい対象プロジェクトを分けられるようになりました。

3. カスタムツールを追加する

ここまでは、Piが用意している組み込みツールを使ってきました。
次は、自分でツールを1つ追加します。
Pi SDK のドキュメントでは、defineTool() でカスタムツールを定義し、customTools: [myTool] のように渡せることが説明されています。また、tools allowlist を使う場合は、カスタムツール名も tools に含める必要があります。
今回は例として、現在の日本時間を返す now ツールを作ります。
src/tools.ts を作ります。

import { Type } from "typebox";
import { defineTool } from "@earendil-works/pi-coding-agent";
export const nowTool = defineTool({
  name: "now",
  label: "Now",
  description: "Get the current local time in Japan.",
  parameters: Type.Object({}),
  execute: async () => {
    const now = new Date().toLocaleString("ja-JP", {
      timeZone: "Asia/Tokyo",
      hour12: false
    });
    return {
      content: [
        {
          type: "text",
          text: `現在の日本時間は ${now} です。`
        }
      ],
      details: {
        timezone: "Asia/Tokyo"
      }
    };
  }
});

src/cli.ts で読み込みます。

import { nowTool } from "./tools.js";

有効なツール一覧に now を足します。

const enabledTools = ["read", "grep", "find", "ls", "now"] as const;

createAgentSession()customTools を渡します。

const { session } = await createAgentSession({
  cwd,
  authStorage,
  modelRegistry,
  resourceLoader: loader,
  tools: [...enabledTools],
  customTools: [nowTool],
  sessionManager: SessionManager.inMemory(cwd)
});

試してみます。

pnpm run dev -- "今の日本時間を教えて"

now ツールを使ってくれれば成功です。
このように、Pi SDKでは組み込みツールに加えて、自分のアプリケーション専用のツールを追加できます。
たとえば、社内 API を呼び出すツール、プロジェクト固有の検索ツール、CI の結果を取得するツールなども、同じ考え方で追加できます。

4. スキルを追加してみる

最後に、スキルを追加してみます。
公式ドキュメントでは、Skills は必要に応じて読み込まれる capability packages であり、特定タスク向けの workflow、setup instructions、helper scripts、reference documentation を提供するものとして説明されています。

Pi の Skills も、一般的なコーディングエージェントと同じように実装されており、特定タスク向けの手順や補助情報をまとめた自己完結型の能力パッケージとして利用されます。
Claude Code や Codex で Skills を使うときのような感覚で.pi/skills/ や、cwd とその親ディレクトリにある .agents/skills/ などからスキルを読み込むことができます。
そのため、 スキルのフォーマットは他のエージェントと同じく SKILL.md を持つディレクトリとして作ってください。
今回は、プロジェクトレビュー用のスキルを作ります。

mkdir -p .pi/skills/project-review

.pi/skills/project-review/SKILL.md を作ります。

---
name: project-review
description: Use when reviewing this project structure and suggesting small improvements.
---
# Project Review
このスキルを使うときは、次の方針でレビューしてください。
- まず `package.json``src` ディレクトリを確認する
- いきなり修正せず、読み取り系ツールで状況を把握する
- 改善提案は最大3つに絞る
- 日本語で簡潔にまとめる

Pi の Skills ドキュメントでは、起動時にスキルの名前と description を読み込み、タスクが一致したときに agent が read を使って SKILL.md 全体を読み込む、という流れが説明されています。
試してみます。

pnpm run dev -- "project-review スキルを使って、このプロジェクトを軽くレビューして"

または対話モードで起動してから入力します。

pnpm run dev
> project-review スキルを使って、このプロジェクトを軽くレビューして

一般的なエージェントの設計論として、スキルとツールは役割が少し異なります。
ツールは「エージェントが実行できる関数」として捉えるとよいです。
スキルは「エージェントに渡す作業手順や専門知識」です。
プロジェクト固有のレビュー観点、リリース手順、設計方針、ドキュメント生成ルールなどは、カスタムツールよりもスキルとして定義したほうが扱いやすいことがあります。

5. ここまでのコード(全部)

ここまでの最終構成は次のとおりです。

pi-light-agent/
├─ package.json
├─ pnpm-workspace.yaml
├─ tsconfig.json
├─ src/
│  ├─ args.ts
│  ├─ cli.ts
│  └─ tools.ts
└─ .pi/
   └─ skills/
      └─ project-review/
         └─ SKILL.md

package.json

{
  "name": "pi-light-agent",
  "version": "0.1.0",
  "packageManager": "pnpm@11.0.7",
  "type": "module",
  "scripts": {
    "dev": "tsx src/cli.ts",
    "minimal": "tsx src/minimal.ts"
  },
  "dependencies": {
    "@earendil-works/pi-coding-agent": "^0.79.0",
    "typebox": "^1.2.4"
  },
  "devDependencies": {
    "@types/node": "^25.9.2",
    "tsx": "^4.22.4",
    "typescript": "^6.0.3"
  }
}

pnpm-workspace.yaml

allowBuilds:
  '@google/genai': true
  esbuild: true
  protobufjs: true

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*.ts"]
}

src/args.ts

import { stat } from "node:fs/promises";
import path from "node:path";
export interface ParsedAgentArgs {
  cwd: string;
  prompt: string;
  help: boolean;
}
export async function parseAgentArgs(
  argv: string[],
  baseCwd = process.cwd()
): Promise<ParsedAgentArgs> {
  let cwd = baseCwd;
  const promptParts: string[] = [];
  for (let i = 0; i < argv.length; i += 1) {
    const arg = argv[i];
    if (i === 0 && arg === "--") {
      continue;
    }
    if (arg === "-h" || arg === "--help") {
      return {
        cwd: path.resolve(cwd),
        prompt: "",
        help: true
      };
    }
    if (arg === "-C" || arg === "--cwd") {
      const value = argv[i + 1];
      if (!value) {
        throw new Error(`${arg} requires a directory path.`);
      }
      cwd = path.resolve(baseCwd, value);
      i += 1;
      continue;
    }
    if (arg.startsWith("--cwd=")) {
      const value = arg.slice("--cwd=".length);
      if (!value) {
        throw new Error("--cwd requires a directory path.");
      }
      cwd = path.resolve(baseCwd, value);
      continue;
    }
    if (arg === "--") {
      promptParts.push(...argv.slice(i + 1));
      break;
    }
    promptParts.push(arg);
  }
  const resolvedCwd = path.resolve(cwd);
  const stats = await stat(resolvedCwd).catch(() => undefined);
  if (!stats) {
    throw new Error(`Directory does not exist: ${resolvedCwd}`);
  }
  if (!stats.isDirectory()) {
    throw new Error(`Not a directory: ${resolvedCwd}`);
  }
  return {
    cwd: resolvedCwd,
    prompt: promptParts.join(" ").trim(),
    help: false
  };
}
export function usage(): string {
  return `Usage:
pnpm run dev -- [options] [prompt]
Options:
  -C, --cwd <dir>   Run the agent against a specific directory
  --cwd=<dir>       Same as above
  -h, --help        Show this help
Examples:
pnpm run dev -- "このディレクトリの構成を説明して"
pnpm run dev -- -C ../my-app "主要ファイルを要約して"
pnpm run dev -- --cwd=/path/to/project`;
}

src/tools.ts

import { Type } from "typebox";
import { defineTool } from "@earendil-works/pi-coding-agent";
export const nowTool = defineTool({
  name: "now",
  label: "Now",
  description: "Get the current local time in Japan.",
  parameters: Type.Object({}),
  execute: async () => {
    const now = new Date().toLocaleString("ja-JP", {
      timeZone: "Asia/Tokyo",
      hour12: false
    });
    return {
      content: [
        {
          type: "text",
          text: `現在の日本時間は ${now} です。`
        }
      ],
      details: {
        timezone: "Asia/Tokyo"
      }
    };
  }
});

src/cli.ts

import { stdin as input, stdout as output } from "node:process";
import { createInterface } from "node:readline/promises";
import {
  AuthStorage,
  createAgentSession,
  DefaultResourceLoader,
  getAgentDir,
  ModelRegistry,
  SessionManager
} from "@earendil-works/pi-coding-agent";
import { parseAgentArgs, usage } from "./args.js";
import { nowTool } from "./tools.js";
let parsed;
try {
  parsed = await parseAgentArgs(process.argv.slice(2));
} catch (error) {
  console.error(
    `[error] ${error instanceof Error ? error.message : String(error)}`
  );
  console.error(usage());
  process.exit(1);
}
if (parsed.help) {
  console.log(usage());
  process.exit(0);
}
const cwd = parsed.cwd;
const initialPrompt = parsed.prompt;
const enabledTools = ["read", "grep", "find", "ls", "now"] as const;
const authStorage = AuthStorage.create();
const modelRegistry = ModelRegistry.create(authStorage);
const agentDir = getAgentDir();
const loader = new DefaultResourceLoader({
  cwd,
  agentDir,
  systemPromptOverride: () =>
    `あなたは軽量CLIで動く開発支援エージェントです。
ルール:
- 日本語で簡潔に答える
- ファイル操作はまず読み取り中心で行う
- 不明点があれば、推測と事実を分けて説明する
- 現在時刻が必要なときは now ツールを使う
- 対象プロジェクトのルートは ${cwd} です`.trim()
});
await loader.reload();
const { session, modelFallbackMessage } = await createAgentSession({
  cwd,
  agentDir,
  authStorage,
  modelRegistry,
  resourceLoader: loader,
  tools: [...enabledTools],
  customTools: [nowTool],
  sessionManager: SessionManager.inMemory(cwd),
  thinkingLevel: "off"
});
if (modelFallbackMessage) {
  console.error(`Note: ${modelFallbackMessage}`);
}
let busy = false;
session.subscribe((event) => {
  if (
    event.type === "message_update" &&
    event.assistantMessageEvent.type === "text_delta"
  ) {
    process.stdout.write(event.assistantMessageEvent.delta);
  }
  if (event.type === "tool_execution_start") {
    process.stdout.write(`\n[tool: ${event.toolName}]\n`);
  }
  if (event.type === "agent_end") {
    process.stdout.write("\n");
  }
});
async function ask(text: string): Promise<boolean> {
  const prompt = text.trim();
  if (!prompt) return true;
  busy = true;
  try {
    await session.prompt(prompt);
    return true;
  } catch (error) {
    console.error(
      `\n[error] ${error instanceof Error ? error.message : String(error)}`
    );
    return false;
  } finally {
    busy = false;
  }
}
process.on("SIGINT", async () => {
  if (busy) {
    await session.abort();
    process.stdout.write("\n[aborted]\n");
    return;
  }
  session.dispose();
  process.exit(0);
});
if (initialPrompt) {
  const ok = await ask(initialPrompt);
  session.dispose();
  process.exit(ok ? 0 : 1);
}
const rl = createInterface({ input, output });
console.log("Pi light agent");
console.log(`cwd: ${cwd}`);
console.log(`tools: ${enabledTools.join(", ")}`);
console.log("commands: /help, /cwd, /tools, /exit");
console.log("");
while (true) {
  const text = (await rl.question("> ")).trim();
  if (!text) continue;
  if (text === "/exit") {
    rl.close();
    session.dispose();
    process.exit(0);
  }
  if (text === "/help") {
    console.log("入力した文章をそのままエージェントに送ります。");
    console.log("/cwd   対象ディレクトリを表示");
    console.log("/tools 有効なツールを表示");
    console.log("/exit  終了");
    continue;
  }
  if (text === "/cwd") {
    console.log(cwd);
    continue;
  }
  if (text === "/tools") {
    console.log(enabledTools.join(", "));
    continue;
  }
  await ask(text);
}

.pi/skills/project-review/SKILL.md

---
name: project-review
description: Use when reviewing this project structure and suggesting small improvements.
---
# Project Review
このスキルを使うときは、次の方針でレビューしてください。
- まず `package.json``src` ディレクトリを確認する
- いきなり修正せず、読み取り系ツールで状況を把握する
- 改善提案は最大3つに絞る
- 日本語で簡潔にまとめる

6. 発展:InteractiveMode、セッション保存、編集系ツール

ここから先は、この記事では詳しく扱いません。
必要になったら、公式ドキュメント公式 examplesを読むのがおすすめです。

6.1 InteractiveModeを使う

この記事では、自作の readline ループで小さな CLI を作りました。
ただし、Pi にはTUIも用意されています。
そのUIを使いたい場合、通常は InteractiveMode@earendil-works/pi-tui を使います。
特に InteractiveMode は、公式 SDK docs で editor、chat history、built-in commands を備えた full TUI interactive mode と説明されており、コーディングエージェントに必要なさまざまなUIがすでに実装されています。

参考になるのは、次の資料です。

6.2 セッションを保存する

この記事では、チュートリアル用に次のようにしています。

sessionManager: SessionManager.inMemory(cwd)

これは、履歴をファイルに保存せず、メモリで持つ設定です。
そのため、セッションが終了したら会話履歴は消えます。
もし、会話履歴を保存したい場合は、次のように SessionManager.create(cwd) を使います。

sessionManager: SessionManager.create(cwd)

公式 SDK docs でも、in-memory session、新しい persistent session、recent session の continue、specific file の open、session list などが SessionManager の例として紹介されています。
参考になるのは、次の資料です。

6.3 編集系ツールを有効にする

この記事では安全寄りにするため、読み取り系のツールを中心にしました。

tools: ["read", "grep", "find", "ls", "now"]

ファイル編集やコマンド実行まで任せたい場合は、bash / edit / write を有効にします。

tools: ["read", "grep", "find", "ls", "bash", "edit", "write", "now"]

ただし、これらはローカル環境に対して副作用を持ちます。
最初は読み取り専用で挙動を確認し、必要になってから編集系ツールを足すのがよいと思います。
公式 SDK docs でも、built-in tool names として readbasheditwritegrepfindls が挙げられています。
参考になるのは、次の資料です。

6.4 runPrintModerunRpcMode を使う

この記事では自分で CLI ループを作りましたが、Pi SDK には runPrintModerunRpcMode もあります。
runPrintMode は、プロンプトを送って結果を出力して終了する single-shot mode として説明されています。runRpcMode は JSON-RPC mode として説明されています。
CLI を自作するより、Pi が用意している run mode に寄せたい場合は、このあたりも読んでおくとよさそうです。

7. 参考資料

この記事では、主に Pi の公式ドキュメントと公式 examples を参照しました。

終わりに(宣伝)

朝日新聞社メディア研究開発センターでは、自然言語処理や音声処理、画像処理、はたまたAIエージェントの研究開発にも取り組んでおり、テックブログでも発信をしております。
https://note.com/asahi_ictrad

また、朝日新聞社は積極的にエンジニア採用を募集しており、興味を持っていただけたら応募してみませんか?
https://www.asahishimbun-saiyou.com/career/

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?