Lecture 1プロジェクト初期化 — Node.js CLIの骨格を作る
12:00
プロジェクト初期化 — Node.js CLIの骨格を作る
このコースでは、Anthropic が提供する AI コーディングツール「Claude Code」を使って、Node.js で実用的なコマンドラインツール(CLI)をゼロから構築します。作るものは「ファイルオーガナイザー」です。散らかったディレクトリの中のファイルを拡張子ごとに自動でフォルダに振り分けてくれるツールを10回の講義で完成させます。第1回では、プロジェクトの初期化から、グローバルインストール可能な CLI の骨格を作るところまでを学びます。
Node.js と Claude Code の準備
CLI ツールを作るには Node.js が必要です。公式サイト(https://nodejs.org/)から LTS 版をダウンロードしてインストールしてください。インストール後、ターミナルでバージョンを確認します。
node --version
# v20.x.x 以上を推奨
npm --version
# 10.x.x 以上
次に Claude Code をインストールします。Claude Code は Anthropic の公式 CLI ツールで、ターミナル上で自然言語の指示を出すだけでコードの生成・編集・実行をすべて行ってくれます。
npm install -g @anthropic-ai/claude-code
作業ディレクトリを作成し、Claude Code を起動しましょう。
mkdir file-organizer
cd file-organizer
claude
Claude Code が起動すると対話型プロンプトが表示されます。ここに自然言語で指示を入力していきます。このコース全体を通じて、Claude Code に「お願い」する形でコードを書いていく流れになります。
プロジェクトを初期化する
Claude Code に次のプロンプトを入力して、プロジェクトを初期化しましょう。
> Node.jsのCLIツールプロジェクトを初期化して。
package.jsonのbinフィールドを設定して、
グローバルインストールできるようにして。
ツール名は "file-organizer"、コマンド名は "forg" にして
Claude Code は以下のような package.json を生成してくれます。
{
"name": "file-organizer",
"version": "1.0.0",
"description": "ファイルを拡張子ごとにフォルダへ自動整理するCLIツール",
"type": "module",
"main": "src/index.js",
"bin": {
"forg": "./src/index.js"
},
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"keywords": ["cli", "file", "organizer", "directory"],
"author": "",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
}
ここで重要なのが bin フィールドです。"forg": "./src/index.js" という設定は、「npm install -g でグローバルインストールしたとき、forg というコマンドで src/index.js が実行される」という意味になります。type: "module" を指定することで、ES Modules(import/export)構文が使えるようになります。
Shebang とエントリポイント
CLI ツールのエントリポイントには「Shebang(シバン)」と呼ばれる特別な1行目が必要です。Claude Code に続けてお願いしましょう。
> src/index.js を作成して。
先頭にshebangを書いて、基本的なCLIの骨格を作って。
コマンド名、バージョン、説明を表示するだけでOK
Claude Code が生成するエントリポイントは以下のようになります。
#!/usr/bin/env node
// file-organizer - ファイルを拡張子ごとに整理するCLIツール
const APP_NAME = 'file-organizer';
const VERSION = '1.0.0';
function main() {
const args = process.argv.slice(2);
if (args.includes('--version') || args.includes('-v')) {
console.log(`${APP_NAME} v${VERSION}`);
process.exit(0);
}
if (args.includes('--help') || args.includes('-h')) {
console.log(`
${APP_NAME} v${VERSION}
使い方:
forg [オプション] [ディレクトリ]
オプション:
-v, --version バージョンを表示
-h, --help ヘルプを表示
`);
process.exit(0);
}
console.log(`${APP_NAME} v${VERSION} - ファイルオーガナイザー`);
console.log('ファイルの整理を開始します...');
}
main();
1行目の #!/usr/bin/env node がシバンです。Unix 系の OS(macOS, Linux)では、この行があることで「このファイルを Node.js で実行する」とシステムに伝えます。Windows では直接的な効果はありませんが、npm がグローバルインストール時に適切なラッパーを生成してくれるため、クロスプラットフォームで動作します。
process.argv は Node.js がコマンドライン引数を格納する配列です。process.argv[0] が Node.js の実行パス、process.argv[1] がスクリプトのパスなので、.slice(2) でユーザーが実際に渡した引数だけを取得しています。
ローカルで動作確認する
作成した CLI をローカルでテストしましょう。Claude Code に聞いてみます。
> このCLIツールをローカルでテストする方法を教えて。
npm link を使って
npm link を使うと、開発中のパッケージをグローバルにシンボリックリンクとして登録できます。
# プロジェクトルートで実行
npm link
# これで forg コマンドが使えるようになる
forg --version
# file-organizer v1.0.0
forg --help
# ヘルプメッセージが表示される
forg
# file-organizer v1.0.0 - ファイルオーガナイザー
# ファイルの整理を開始します...
npm link はグローバルの node_modules にシンボリックリンクを作成するため、ソースコードを編集するとリアルタイムに反映されます。開発中は npm link を使い、完成したら npm publish で公開するという流れが一般的です。
開発中は npm link の代わりに直接実行することもできます。
node src/index.js --version
# file-organizer v1.0.0
テストが終わったら、リンクを解除するには npm unlink -g file-organizer を実行します。
プロジェクト構成を整える
今後の講義で機能を追加していくために、ディレクトリ構成を整えておきましょう。
> プロジェクトのディレクトリ構成を整理して。
src/ 以下に commands/, utils/, config/ フォルダを作って、
それぞれ空の index.js を置いて
Claude Code が作成する構成は以下のとおりです。
file-organizer/
├── src/
│ ├── index.js # エントリポイント(shebang付き)
│ ├── commands/ # サブコマンド(organize, undo, stats)
│ │ └── index.js
│ ├── utils/ # ユーティリティ関数
│ │ └── index.js
│ └── config/ # 設定管理
│ └── index.js
├── package.json
├── .gitignore
└── README.md
Claude Code に .gitignore も作ってもらいましょう。
> Node.js用の.gitignoreを作成して
node_modules/
dist/
.env
*.log
.DS_Store
この構成が、コース全体を通じて開発する「ファイルオーガナイザー」のベースになります。次回以降、commands/ にサブコマンドを、utils/ にファイル操作のヘルパーを、config/ に設定管理のコードを追加していきます。
演習問題
- 環境確認:
node --versionとnpm --versionを実行して、Node.js 18 以上がインストールされていることを確認してください。 - プロジェクト作成: Claude Code を使って
file-organizerプロジェクトを初期化し、npm linkでグローバルコマンドとして登録してください。 - カスタマイズ:
forgコマンドに--verboseフラグを追加して、詳細モードの ON/OFF を表示するように Claude Code にお願いしてみましょう。 - process.argv の理解:
console.log(process.argv)をmain()関数の先頭に追加して、forg hello world --flagを実行したときの配列の中身を確認してください。
参考リンク
Lecture 2引数パーサー — コマンドライン引数を処理する
12:00
引数パーサー — コマンドライン引数を処理する
前回はプロジェクトの初期化と CLI の骨格を作りました。process.argv を直接パースする方法も学びましたが、実際のツールではオプションの数が増えるにつれて手作業でのパースが困難になります。今回は、Node.js の CLI ツール開発で定番のライブラリ「Commander.js」を使って、引数とオプションを体系的に処理する方法を学びます。Claude Code にお願いしながら、ファイルオーガナイザーの引数処理を本格的に実装していきましょう。
Commander.js をインストールする
Commander.js は、Node.js で最も広く使われている引数パーサーライブラリです。GitHub スター数は 25,000 以上で、npm の週間ダウンロード数も非常に多い定番パッケージです。Claude Code にインストールをお願いしましょう。
> commander.js をインストールして。
ファイルオーガナイザーのCLI引数処理に使いたい
Claude Code が実行するコマンドは以下です。
npm install commander
Commander.js を使うと、POSIX 準拠のオプション解析、ヘルプの自動生成、サブコマンドの管理など、CLI ツールに必要な機能がすべて揃います。process.argv を手動でパースする必要がなくなります。
基本的なプログラム定義
Claude Code に、エントリポイントを Commander.js ベースに書き換えてもらいましょう。
> src/index.js を Commander.js を使って書き換えて。
以下のオプションを定義して:
- 対象ディレクトリ(引数、省略時はカレントディレクトリ)
- --dry-run: 実際に移動せず、何をするか表示するだけ
- --verbose: 詳細ログを表示
- --ext <extensions>: 対象の拡張子をカンマ区切りで指定
- --output <dir>: 整理先のディレクトリを指定
Claude Code が生成するコードは以下のようになります。
#!/usr/bin/env node
import { Command } from 'commander';
const program = new Command();
program
.name('forg')
.description('ファイルを拡張子ごとにフォルダへ自動整理するCLIツール')
.version('1.0.0', '-v, --version', 'バージョンを表示')
.argument('[directory]', '整理するディレクトリ', '.')
.option('-d, --dry-run', '実際に移動せず、実行計画を表示する', false)
.option('--verbose', '詳細なログを表示する', false)
.option('-e, --ext <extensions>', '対象の拡張子をカンマ区切りで指定', '')
.option('-o, --output <dir>', '整理先のディレクトリを指定')
.action((directory, options) => {
console.log('対象ディレクトリ:', directory);
console.log('オプション:', options);
if (options.dryRun) {
console.log('[DRY RUN] 実際のファイル移動は行いません');
}
if (options.ext) {
const extensions = options.ext.split(',').map(e => e.trim());
console.log('対象拡張子:', extensions);
}
console.log('ファイルの整理を開始します...');
});
program.parse();
Commander.js の基本的な API を確認しましょう。.name() でコマンド名を設定し、.description() で説明文を追加します。.version() は -v と --version フラグを自動で設定してくれます。.argument() は位置引数(コマンドの後に直接書く値)を定義します。第3引数がデフォルト値です。.option() はオプションフラグを定義します。ハイフン1つの短縮形と2つの完全形を同時に指定できます。
オプションの値変換と検証
Commander.js ではオプションの値をカスタム関数で変換・検証できます。Claude Code に拡張してもらいましょう。
> --ext オプションの値をカンマ区切りで配列に変換する
カスタムパーサーを追加して。
さらに --max-size オプションを追加して、
ファイルサイズの上限をMB単位で指定できるようにして。
数値のバリデーションも入れて
// カスタムパーサー関数
function parseExtensions(value) {
return value.split(',').map(ext => {
ext = ext.trim().toLowerCase();
return ext.startsWith('.') ? ext : `.${ext}`;
});
}
function parseSize(value) {
const size = parseFloat(value);
if (isNaN(size) || size <= 0) {
throw new Error('サイズは正の数値で指定してください');
}
return size;
}
program
.option('-e, --ext <extensions>', '対象の拡張子をカンマ区切りで指定', parseExtensions)
.option('--max-size <mb>', 'ファイルサイズの上限(MB)', parseSize);
parseExtensions 関数は、ユーザーが jpg,png,gif と入力しても .jpg,.png,.gif と入力しても、統一的に ['.jpg', '.png', '.gif'] という配列に変換します。parseSize 関数は不正な値が渡された場合にエラーを投げます。Commander.js はこのエラーを自動でキャッチし、ユーザーに親切なエラーメッセージを表示してくれます。
自動生成されるヘルプ
Commander.js の大きな利点の一つが、ヘルプメッセージの自動生成です。上記の設定をするだけで、forg --help を実行したときに以下のような出力が自動で生成されます。
Usage: forg [options] [directory]
ファイルを拡張子ごとにフォルダへ自動整理するCLIツール
Arguments:
directory 整理するディレクトリ (default: ".")
Options:
-v, --version バージョンを表示
-d, --dry-run 実際に移動せず、実行計画を表示する (default: false)
--verbose 詳細なログを表示する (default: false)
-e, --ext <extensions> 対象の拡張子をカンマ区切りで指定
--max-size <mb> ファイルサイズの上限(MB)
-o, --output <dir> 整理先のディレクトリを指定
-h, --help ヘルプを表示
.description() や .argument() に渡した説明文がそのままヘルプに反映されます。手動でヘルプ文字列を管理する必要がなく、オプションを追加するだけで常に正確なヘルプが表示されます。
ヘルプをカスタマイズしたい場合は、Claude Code にお願いしましょう。
> ヘルプの末尾に使用例を追加して。
forg ~/Downloads や forg --dry-run ~/Desktop のような例を表示したい
program
.addHelpText('after', `
使用例:
$ forg ~/Downloads # ダウンロードフォルダを整理
$ forg --dry-run ~/Desktop # デスクトップの整理計画を表示
$ forg -e jpg,png ~/Pictures # 画像ファイルのみ整理
$ forg -o ~/Sorted ~/Documents # 整理先を指定して実行
`);
.addHelpText('after', ...) で、自動生成されたヘルプの後に追加テキストを表示できます。'before' を指定すれば前に追加することもできます。
動作確認とデバッグ
実装した引数処理が正しく動作するか確認しましょう。
# バージョン表示
forg -v
# 1.0.0
# ヘルプ表示
forg --help
# ドライランモード
forg --dry-run ~/Downloads
# 対象ディレクトリ: /Users/you/Downloads
# オプション: { dryRun: true, verbose: false, ext: undefined, output: undefined }
# [DRY RUN] 実際のファイル移動は行いません
# 拡張子フィルター
forg -e jpg,png,gif ~/Pictures
# 対象拡張子: [ '.jpg', '.png', '.gif' ]
# 不正な値のバリデーション
forg --max-size abc
# error: option '--max-size <mb>' argument 'abc' is invalid.
# サイズは正の数値で指定してください
Commander.js はケバブケース(--dry-run)のオプションをキャメルケース(dryRun)のプロパティに自動変換してくれます。これにより、コマンドラインの慣習(ハイフン区切り)と JavaScript の慣習(キャメルケース)の両方を自然に満たせます。
演習問題
- オプション追加:
--ignore <patterns>オプションを追加して、無視するファイルパターンをカンマ区切りで指定できるようにしてください。例:forg --ignore .DS_Store,Thumbs.db - 必須オプション: Commander.js で必須オプション(
.requiredOption())を使って、--confirmフラグなしでは実行できないようにしてみましょう。 - 環境変数:
FORG_DEFAULT_DIR環境変数が設定されている場合、デフォルトのディレクトリとして使うようにaction内を修正してみましょう。 - ヘルプのカスタマイズ:
.addHelpText()を使って、ヘルプの前にアスキーアートのロゴを表示するようにしてみましょう。
参考リンク
Lecture 3ファイル操作 — ファイルの読み書きを実装する
12:00
ファイル操作 — ファイルの読み書きを実装する
前回は Commander.js を使ってコマンドライン引数を処理できるようになりました。今回は、ファイルオーガナイザーの核心部分であるファイル操作を実装します。Node.js の fs/promises モジュールと path モジュールを使って、ディレクトリ内のファイルを読み取り、拡張子ごとにフォルダへ移動する機能を作っていきます。Claude Code に指示を出しながら、安全で堅牢なファイル操作コードを書いていきましょう。
fs/promises と path モジュールの基礎
Node.js にはファイルシステムを操作する fs モジュールが組み込まれています。従来のコールバック形式ではなく、fs/promises を使うことで async/await によるモダンな非同期処理が書けます。Claude Code にまず基礎的なユーティリティを作ってもらいましょう。
> src/utils/file-utils.js を作成して。
fs/promises と path を使って、以下のヘルパー関数を実装して:
- listFiles(dir): ディレクトリ内のファイル一覧を取得
- getFileInfo(filePath): ファイルの情報(サイズ、拡張子、更新日)を取得
- ensureDir(dir): ディレクトリが存在しなければ作成
- moveFile(src, dest): ファイルを移動(同名ファイルがある場合はリネーム)
Claude Code が生成するコードを見ていきましょう。
import { readdir, stat, mkdir, rename, access } from 'fs/promises';
import { join, extname, basename, dirname } from 'path';
/**
* ディレクトリ内のファイル一覧を取得する(サブディレクトリは除外)
*/
export async function listFiles(dir) {
const entries = await readdir(dir, { withFileTypes: true });
return entries
.filter(entry => entry.isFile())
.map(entry => join(dir, entry.name));
}
/**
* ファイルの詳細情報を取得する
*/
export async function getFileInfo(filePath) {
const stats = await stat(filePath);
return {
path: filePath,
name: basename(filePath),
ext: extname(filePath).toLowerCase(),
size: stats.size,
modifiedAt: stats.mtime,
};
}
/**
* ディレクトリが存在しなければ再帰的に作成する
*/
export async function ensureDir(dir) {
try {
await access(dir);
} catch {
await mkdir(dir, { recursive: true });
}
}
/**
* ファイルを移動する(同名ファイルが存在する場合はリネーム)
*/
export async function moveFile(src, dest) {
const destDir = dirname(dest);
await ensureDir(destDir);
let finalDest = dest;
let counter = 1;
while (true) {
try {
await access(finalDest);
// ファイルが存在する場合、リネーム
const ext = extname(dest);
const nameWithoutExt = basename(dest, ext);
finalDest = join(destDir, `${nameWithoutExt} (${counter})${ext}`);
counter++;
} catch {
// ファイルが存在しない = この名前で移動OK
break;
}
}
await rename(src, finalDest);
return finalDest;
}
このコードのポイントを解説します。readdir の { withFileTypes: true } オプションを使うと、各エントリが Dirent オブジェクトとして返されます。.isFile() でファイルかどうか、.isDirectory() でディレクトリかどうかを判定できます。mkdir の { recursive: true } は、親ディレクトリが存在しない場合も含めて再帰的にディレクトリを作成します。access はファイルの存在確認に使い、存在しない場合はエラーが投げられます。
拡張子マッピングの定義
ファイルオーガナイザーの要は、拡張子からカテゴリを判定するマッピングです。Claude Code に定義してもらいましょう。
> src/utils/categories.js を作成して。
拡張子からファイルカテゴリへのマッピングを定義して。
カテゴリは: 画像, 動画, 音声, ドキュメント, アーカイブ, コード, データ, その他
export const CATEGORY_MAP = {
// 画像
'.jpg': '画像',
'.jpeg': '画像',
'.png': '画像',
'.gif': '画像',
'.svg': '画像',
'.webp': '画像',
'.bmp': '画像',
'.ico': '画像',
// 動画
'.mp4': '動画',
'.avi': '動画',
'.mov': '動画',
'.mkv': '動画',
'.webm': '動画',
'.flv': '動画',
// 音声
'.mp3': '音声',
'.wav': '音声',
'.flac': '音声',
'.aac': '音声',
'.ogg': '音声',
'.m4a': '音声',
// ドキュメント
'.pdf': 'ドキュメント',
'.doc': 'ドキュメント',
'.docx': 'ドキュメント',
'.xls': 'ドキュメント',
'.xlsx': 'ドキュメント',
'.ppt': 'ドキュメント',
'.pptx': 'ドキュメント',
'.txt': 'ドキュメント',
'.md': 'ドキュメント',
'.csv': 'ドキュメント',
// アーカイブ
'.zip': 'アーカイブ',
'.tar': 'アーカイブ',
'.gz': 'アーカイブ',
'.rar': 'アーカイブ',
'.7z': 'アーカイブ',
// コード
'.js': 'コード',
'.ts': 'コード',
'.py': 'コード',
'.html': 'コード',
'.css': 'コード',
'.json': 'コード',
'.xml': 'コード',
'.yaml': 'コード',
'.yml': 'コード',
// データ
'.sql': 'データ',
'.db': 'データ',
'.sqlite': 'データ',
};
/**
* 拡張子からカテゴリを判定する
*/
export function getCategory(ext) {
return CATEGORY_MAP[ext.toLowerCase()] || 'その他';
}
/**
* 全カテゴリ名の一覧を取得する
*/
export function getAllCategories() {
const categories = new Set(Object.values(CATEGORY_MAP));
categories.add('その他');
return [...categories];
}
organize コマンドの実装
ヘルパー関数とカテゴリマッピングが揃ったので、メインの整理機能を実装しましょう。
> src/commands/organize.js を作成して。
listFiles, getFileInfo, moveFile, ensureDir と getCategory を使って、
指定ディレクトリ内のファイルを拡張子のカテゴリ別フォルダに移動する
organize コマンドを実装して。
--dry-run オプションに対応して、
--verbose で詳細ログを出すようにして
import { resolve } from 'path';
import { join } from 'path';
import { listFiles, getFileInfo, moveFile, ensureDir } from '../utils/file-utils.js';
import { getCategory } from '../utils/categories.js';
export async function organize(directory, options) {
const targetDir = resolve(directory);
const outputDir = options.output ? resolve(options.output) : targetDir;
console.log(`\n対象ディレクトリ: ${targetDir}`);
if (options.dryRun) {
console.log('[DRY RUN] 実際のファイル移動は行いません\n');
}
// ファイル一覧を取得
const files = await listFiles(targetDir);
if (files.length === 0) {
console.log('整理するファイルが見つかりませんでした。');
return;
}
console.log(`${files.length} 個のファイルが見つかりました。\n`);
// 拡張子フィルター
const extFilter = options.ext
? new Set(options.ext)
: null;
const results = { moved: 0, skipped: 0, errors: 0 };
for (const filePath of files) {
const info = await getFileInfo(filePath);
const category = getCategory(info.ext);
// 拡張子フィルターが指定されている場合
if (extFilter && !extFilter.has(info.ext)) {
if (options.verbose) {
console.log(` スキップ: ${info.name} (フィルター外)`);
}
results.skipped++;
continue;
}
const destDir = join(outputDir, category);
const destPath = join(destDir, info.name);
if (options.dryRun) {
console.log(` ${info.name} → ${category}/`);
results.moved++;
continue;
}
try {
await ensureDir(destDir);
const finalPath = await moveFile(filePath, destPath);
results.moved++;
if (options.verbose) {
console.log(` 移動: ${info.name} → ${category}/`);
}
} catch (err) {
console.error(` エラー: ${info.name} - ${err.message}`);
results.errors++;
}
}
console.log(`\n完了: ${results.moved} 個移動, ${results.skipped} 個スキップ, ${results.errors} 個エラー`);
}
この organize 関数が、ファイルオーガナイザーの中核ロジックです。ファイル一覧を取得し、各ファイルのカテゴリを判定して、対応するフォルダに移動します。--dry-run が指定されている場合は、実際のファイル移動をスキップし、何が起こるかを表示するだけにしています。
エントリポイントとの結合
最後に、src/index.js から organize 関数を呼び出すように結合しましょう。
> src/index.js の action 内で organize 関数を呼び出すように修正して
#!/usr/bin/env node
import { Command } from 'commander';
import { organize } from './commands/organize.js';
const program = new Command();
program
.name('forg')
.description('ファイルを拡張子ごとにフォルダへ自動整理するCLIツール')
.version('1.0.0')
.argument('[directory]', '整理するディレクトリ', '.')
.option('-d, --dry-run', '実際に移動せず、実行計画を表示する', false)
.option('--verbose', '詳細なログを表示する', false)
.option('-e, --ext <extensions>', '対象の拡張子をカンマ区切りで指定')
.option('-o, --output <dir>', '整理先のディレクトリを指定')
.action(async (directory, options) => {
try {
await organize(directory, options);
} catch (err) {
console.error(`エラーが発生しました: ${err.message}`);
process.exit(1);
}
});
program.parse();
これで forg ~/Downloads --dry-run を実行すると、ダウンロードフォルダ内のファイルがどのカテゴリに振り分けられるかを確認できます。--dry-run を外せば、実際にファイルが移動されます。
演習問題
- 動作テスト: テスト用のディレクトリを作成し、様々な拡張子のファイルを配置して
forg --dry-runを実行してみましょう。期待通りのカテゴリ分けがされるか確認してください。 - カテゴリ追加:
categories.jsに「フォント」カテゴリ(.ttf,.otf,.woff,.woff2)を追加してみましょう。Claude Code にお願いしてもOKです。 - 再帰オプション:
--recursiveオプションを追加して、サブディレクトリ内のファイルも対象にするように拡張してみましょう。readdirの{ recursive: true }オプションが使えます。 - サイズ表示:
--verboseモード時に、各ファイルのサイズも表示するようにorganize関数を修正してみましょう。
参考リンク
Lecture 4対話型プロンプト — ユーザーに質問する
12:00
対話型プロンプト — ユーザーに質問する
前回まででファイルの読み取りと移動機能を実装しました。しかし、ユーザーが forg ~/Downloads を実行した瞬間にファイルが移動されてしまうのは少し危険です。「本当に実行しますか?」と確認したり、「どのカテゴリを対象にしますか?」と選択肢を提示したりする対話的な機能があれば、ユーザー体験が大きく向上します。今回は @inquirer/prompts ライブラリを使って、対話型のインタラクションを実装します。
@inquirer/prompts をインストールする
Inquirer は Node.js の対話型プロンプトライブラリの定番です。最新版の @inquirer/prompts はモジュラー設計で、必要なプロンプトタイプだけをインポートできるようになっています。Claude Code にインストールをお願いしましょう。
> @inquirer/prompts をインストールして。
ユーザーへの確認や選択肢の表示に使いたい
npm install @inquirer/prompts
@inquirer/prompts には以下のようなプロンプトタイプがあります。
confirm- はい/いいえの確認input- テキスト入力select- 単一選択checkbox- 複数選択password- パスワード入力(非表示)number- 数値入力
これらを組み合わせることで、直感的なユーザーインタラクションを構築できます。
実行前の確認プロンプト
まず、ファイル整理を実行する前にユーザーに確認を取る機能を追加しましょう。
> src/commands/organize.js を修正して、
ファイル一覧を表示した後に「実行しますか?」と
confirmプロンプトを表示するようにして。
--yes オプションで確認をスキップできるようにして。
--dry-run の場合は確認不要にして
Claude Code が修正するコードの該当部分を見てみましょう。
import { confirm } from '@inquirer/prompts';
import { resolve, join } from 'path';
import { listFiles, getFileInfo, moveFile, ensureDir } from '../utils/file-utils.js';
import { getCategory } from '../utils/categories.js';
export async function organize(directory, options) {
const targetDir = resolve(directory);
const outputDir = options.output ? resolve(options.output) : targetDir;
// ファイル一覧を取得
const files = await listFiles(targetDir);
if (files.length === 0) {
console.log('整理するファイルが見つかりませんでした。');
return;
}
// ファイルをカテゴリ別に集計して表示
const plan = await buildOrganizePlan(files, options);
displayPlan(plan);
// dry-run の場合はここで終了
if (options.dryRun) {
console.log('\n[DRY RUN] 以上が実行計画です。');
return;
}
// --yes が指定されていない場合は確認を取る
if (!options.yes) {
const proceed = await confirm({
message: `${plan.totalFiles} 個のファイルを移動しますか?`,
default: false,
});
if (!proceed) {
console.log('キャンセルしました。');
return;
}
}
// 実際のファイル移動を実行
await executeOrganize(plan, options);
}
async function buildOrganizePlan(files, options) {
const categories = {};
let totalFiles = 0;
for (const filePath of files) {
const info = await getFileInfo(filePath);
if (options.ext && !options.ext.includes(info.ext)) {
continue;
}
const category = getCategory(info.ext);
if (!categories[category]) {
categories[category] = [];
}
categories[category].push(info);
totalFiles++;
}
return { categories, totalFiles };
}
function displayPlan(plan) {
console.log(`\n整理計画: ${plan.totalFiles} 個のファイル\n`);
for (const [category, files] of Object.entries(plan.categories)) {
console.log(` ${category}/ (${files.length} 個)`);
for (const file of files.slice(0, 3)) {
console.log(` - ${file.name}`);
}
if (files.length > 3) {
console.log(` ... 他 ${files.length - 3} 個`);
}
}
}
confirm プロンプトは、ユーザーに (y/N) の選択を求めます。default: false を指定しているため、Enter キーだけ押した場合はキャンセルされます。これは安全側に倒す設計で、「うっかり実行してしまった」という事故を防ぎます。
カテゴリ選択プロンプト
次に、どのカテゴリを整理対象にするかをユーザーに選択してもらう機能を追加しましょう。
> 対話モード (--interactive) を追加して。
checkboxプロンプトでカテゴリを選択できるようにして。
選択したカテゴリのファイルだけを整理するようにして
import { confirm, checkbox } from '@inquirer/prompts';
async function selectCategories(plan) {
const choices = Object.entries(plan.categories).map(([category, files]) => ({
name: `${category} (${files.length} 個)`,
value: category,
checked: true, // デフォルトで全選択
}));
const selected = await checkbox({
message: '整理するカテゴリを選択してください',
choices,
required: true,
});
return selected;
}
checkbox プロンプトは矢印キーで移動し、スペースキーで選択/解除できます。required: true を指定すると、少なくとも1つの選択が必要になります。ユーザーはカテゴリ一覧を見ながら、画像だけ整理する、ドキュメントだけ整理するといった柔軟な操作が可能になります。
出力先ディレクトリの入力
出力先ディレクトリが指定されていない場合に、ユーザーに入力してもらうプロンプトも追加しましょう。
> --interactive モード時に、出力先ディレクトリを
inputプロンプトで入力できるようにして。
デフォルト値は対象ディレクトリと同じにして。
バリデーションで存在するパスかどうかチェックして
import { confirm, checkbox, input } from '@inquirer/prompts';
import { access } from 'fs/promises';
async function askOutputDir(defaultDir) {
const outputDir = await input({
message: '整理先のディレクトリを入力してください',
default: defaultDir,
validate: async (value) => {
try {
await access(resolve(value));
return true;
} catch {
return 'ディレクトリが存在しません。作成しますか? (パスを確認してください)';
}
},
});
return resolve(outputDir);
}
input プロンプトの validate 関数は、true を返すとバリデーション成功、文字列を返すとその文字列がエラーメッセージとして表示されます。非同期のバリデーション(async 関数)もサポートされているため、ファイルシステムへのアクセスチェックなども行えます。
対話モードの統合
すべてのプロンプトを統合して、対話モードの完全なフローを作りましょう。
> organize コマンドに --interactive (-i) フラグを追加して、
対話モードの完全なフローを実装して。
カテゴリ選択 → 出力先入力 → 実行確認 の順で進むようにして
export async function organize(directory, options) {
const targetDir = resolve(directory);
const files = await listFiles(targetDir);
if (files.length === 0) {
console.log('整理するファイルが見つかりませんでした。');
return;
}
const plan = await buildOrganizePlan(files, options);
displayPlan(plan);
if (options.dryRun) {
console.log('\n[DRY RUN] 以上が実行計画です。');
return;
}
let outputDir = options.output ? resolve(options.output) : targetDir;
let selectedCategories = Object.keys(plan.categories);
// 対話モード
if (options.interactive) {
selectedCategories = await selectCategories(plan);
outputDir = await askOutputDir(outputDir);
// 選択結果を表示
console.log(`\n選択されたカテゴリ: ${selectedCategories.join(', ')}`);
console.log(`出力先: ${outputDir}`);
}
// 最終確認
if (!options.yes) {
const fileCount = selectedCategories.reduce(
(sum, cat) => sum + (plan.categories[cat]?.length || 0), 0
);
const proceed = await confirm({
message: `${fileCount} 個のファイルを移動しますか?`,
default: false,
});
if (!proceed) {
console.log('キャンセルしました。');
return;
}
}
await executeOrganize(plan, options, selectedCategories, outputDir);
}
対話モードでは、ユーザーが能動的に操作を選択できるため、安全性と使いやすさが両立します。--interactive と --yes を組み合わせれば、カテゴリは選択しつつ最終確認はスキップするといった柔軟な運用も可能です。
演習問題
- 確認プロンプト:
forg ~/Downloadsを実行して、確認プロンプトが表示されることを確認してください。--yesフラグを付けると確認がスキップされることも確認しましょう。 - select プロンプト:
@inquirer/promptsのselectを使って、整理モード(「カテゴリ別」「日付別」「サイズ別」)を選択できるプロンプトを実装してみましょう。 - パスワード入力:
passwordプロンプトを使って、削除操作の前にパスワード確認を求める機能を試作してみましょう。入力内容が画面に表示されないことを確認してください。 - 入力バリデーション:
inputプロンプトでディレクトリパスの入力を求め、絶対パスかどうかをバリデーションする関数を実装してみましょう。
参考リンク
Lecture 5出力の装飾 — カラフルなターミナル表示
12:00
出力の装飾 — カラフルなターミナル表示
ここまでで、ファイルの整理機能と対話型プロンプトを実装しました。機能としては十分ですが、console.log のプレーンテキストだけでは味気ない出力になってしまいます。今回は、ターミナル出力を視覚的にリッチにするライブラリ群を導入します。色付きテキスト、スピナーアニメーション、テーブル表示、プログレスバーなどを組み合わせて、プロフェッショナルな見た目の CLI ツールに仕上げていきましょう。
chalk でテキストに色を付ける
chalk は Node.js でターミナルの文字色を操作する定番ライブラリです。ANSI エスケープコードをラップし、直感的な API でテキストに色やスタイルを適用できます。
> chalk をインストールして。
src/utils/logger.js を作成して、
成功・警告・エラー・情報の各メッセージを
色付きで表示するヘルパー関数を実装して
npm install chalk
Claude Code が生成する logger.js は以下のようになります。
import chalk from 'chalk';
export const logger = {
success(message) {
console.log(chalk.green('✓ ') + message);
},
warn(message) {
console.log(chalk.yellow('⚠ ') + message);
},
error(message) {
console.error(chalk.red('✗ ') + message);
},
info(message) {
console.log(chalk.blue('ℹ ') + message);
},
dim(message) {
console.log(chalk.dim(message));
},
highlight(message) {
console.log(chalk.bold.cyan(message));
},
// ファイル移動のログ
move(fileName, category) {
const file = chalk.white(fileName);
const arrow = chalk.dim('→');
const dest = chalk.green.bold(category + '/');
console.log(` ${file} ${arrow} ${dest}`);
},
// セクションヘッダー
header(title) {
console.log();
console.log(chalk.bold.underline(title));
console.log();
},
};
chalk はメソッドチェーンでスタイルを組み合わせられます。chalk.bold.cyan() は太字でシアン色、chalk.green.bold() は緑色の太字になります。主なスタイルメソッドには以下があります。
- 色:
red,green,blue,yellow,cyan,magenta,white,gray - 背景色:
bgRed,bgGreen,bgBlueなど - スタイル:
bold,dim,italic,underline,strikethrough
chalk v5 以降は ESM のみをサポートしています。package.json に "type": "module" を設定しているため、import chalk from 'chalk' で正しくインポートできます。
ora でスピナーを表示する
ファイルの移動処理中に、処理が進んでいることをユーザーに伝えるスピナー(ローディングアニメーション)を追加しましょう。
> ora をインストールして。
ファイル整理の実行中にスピナーを表示するようにして。
処理完了時に成功メッセージを表示して
npm install ora
import ora from 'ora';
async function executeOrganize(plan, options, selectedCategories, outputDir) {
const spinner = ora({
text: 'ファイルを整理しています...',
color: 'cyan',
}).start();
const results = { moved: 0, skipped: 0, errors: 0 };
for (const category of selectedCategories) {
const files = plan.categories[category] || [];
spinner.text = `${category} のファイルを移動中... (${results.moved}/${plan.totalFiles})`;
for (const file of files) {
try {
const destDir = join(outputDir, category);
const destPath = join(destDir, file.name);
await ensureDir(destDir);
await moveFile(file.path, destPath);
results.moved++;
} catch (err) {
results.errors++;
if (options.verbose) {
spinner.stop();
logger.error(`${file.name}: ${err.message}`);
spinner.start();
}
}
}
}
if (results.errors > 0) {
spinner.warn(`完了(一部エラーあり): ${results.moved} 個移動, ${results.errors} 個エラー`);
} else {
spinner.succeed(`完了: ${results.moved} 個のファイルを整理しました`);
}
return results;
}
ora のスピナーは .start() で開始し、.succeed(), .fail(), .warn(), .info() で終了メッセージとともに停止します。処理中に .text を更新することで、現在の進捗状況をリアルタイムに表示できます。スピナーが回っている間は同じ行が更新され続けるため、ターミナルが大量のログで埋まることもありません。
cli-table3 でテーブル表示
整理結果をテーブル形式で見やすく表示しましょう。
> cli-table3 をインストールして。
ファイル整理の結果をテーブル形式で表示する
関数を作って。カテゴリ、ファイル数、合計サイズを列にして
npm install cli-table3
import Table from 'cli-table3';
import chalk from 'chalk';
export function displayResultsTable(plan) {
const table = new Table({
head: [
chalk.cyan('カテゴリ'),
chalk.cyan('ファイル数'),
chalk.cyan('合計サイズ'),
],
colWidths: [20, 15, 15],
style: {
head: [],
border: ['dim'],
},
});
let totalFiles = 0;
let totalSize = 0;
for (const [category, files] of Object.entries(plan.categories)) {
const count = files.length;
const size = files.reduce((sum, f) => sum + f.size, 0);
totalFiles += count;
totalSize += size;
table.push([
chalk.white(category),
String(count),
formatSize(size),
]);
}
// 合計行
table.push([
chalk.bold('合計'),
chalk.bold(String(totalFiles)),
chalk.bold(formatSize(totalSize)),
]);
console.log(table.toString());
}
function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
cli-table3 は Unicode のボーダー文字を使ってきれいなテーブルを描画します。colWidths で列幅を固定し、style オブジェクトで見た目を細かくカスタマイズできます。テーブルの各行は配列として .push() するだけで追加できます。
出力イメージは以下のようになります。
┌──────────────────┬─────────────┬─────────────┐
│ カテゴリ │ ファイル数 │ 合計サイズ │
├──────────────────┼─────────────┼─────────────┤
│ 画像 │ 15 │ 24.3 MB │
│ ドキュメント │ 8 │ 5.1 MB │
│ コード │ 12 │ 156.2 KB │
│ アーカイブ │ 3 │ 120.5 MB │
├──────────────────┼─────────────┼─────────────┤
│ 合計 │ 38 │ 150.1 MB │
└──────────────────┴─────────────┴─────────────┘
プログレスバーの実装
大量のファイルを処理する場合、スピナーだけでは進捗が分かりにくいです。プログレスバーも追加しましょう。
> cli-progress をインストールして。
ファイル数が20個以上の場合はプログレスバーを表示するようにして。
20個未満の場合はスピナーのまま
npm install cli-progress
import cliProgress from 'cli-progress';
import chalk from 'chalk';
function createProgressBar(total) {
return new cliProgress.SingleBar({
format: chalk.cyan('{bar}') + ' | {percentage}% | {value}/{total} ファイル | {filename}',
barCompleteChar: '█',
barIncompleteChar: '░',
hideCursor: true,
});
}
// 使用例(executeOrganize 内)
if (plan.totalFiles >= 20) {
const bar = createProgressBar(plan.totalFiles);
bar.start(plan.totalFiles, 0, { filename: '' });
for (const file of allFiles) {
bar.update(results.moved + results.errors, { filename: file.name });
// ... ファイル移動処理 ...
}
bar.stop();
}
cli-progress のフォーマット文字列では、{bar}, {percentage}, {value}, {total} といったプレースホルダーが使えます。カスタムフィールド(ここでは {filename})を定義して、処理中のファイル名を表示することもできます。出力イメージは以下の通りです。
████████████░░░░░░░░ | 60% | 24/40 ファイル | report.pdf
演習問題
- 色のカスタマイズ:
logger.jsにカテゴリごとに色を変える関数を追加してみましょう。画像は緑、動画は赤、ドキュメントは青など。 - アスキーアート: chalk を使って、ツール起動時にカラフルなアスキーアートのロゴを表示する関数を作ってみましょう。
- テーブル拡張:
cli-table3のテーブルに「最大ファイル」列を追加して、各カテゴリで最も大きいファイルの名前を表示してみましょう。 - スピナーバリエーション: ora のドキュメントを参考に、スピナーの種類を変更してみましょう(
dots,line,star,hamburgerなど)。
参考リンク
Lecture 6サブコマンド — 複数機能を整理する
12:00
サブコマンド — 複数機能を整理する
ここまでの講義で、ファイルの整理機能、対話型プロンプト、そしてリッチな出力表示を実装してきました。しかし現在の forg コマンドは整理機能しかありません。実用的な CLI ツールには「元に戻す」「統計を見る」「設定を変える」といった複数の機能が必要です。今回は Commander.js のサブコマンド機能を使って、forg organize、forg undo、forg stats の3つのサブコマンドを実装します。Git の git commit、git push のような構造をイメージしてください。
サブコマンドの設計
まず、ファイルオーガナイザーに必要なサブコマンドを整理しましょう。Claude Code に設計を相談します。
> forg CLIツールのサブコマンドを設計して。
以下の3つのサブコマンドが必要:
- organize: ファイルを整理する(既存機能)
- undo: 直前の整理を元に戻す
- stats: ディレクトリの統計情報を表示する
サブコマンドなしで実行した場合はヘルプを表示して
CLI ツールのサブコマンド設計では、以下の原則を意識します。各サブコマンドは独立した1つの責務を持つこと。共通オプション(--verbose など)はトップレベルに、サブコマンド固有のオプションはサブコマンドに配置すること。サブコマンドなしで実行された場合は使い方を表示すること。これらは Git や Docker などの広く使われている CLI ツールが採用しているパターンです。
エントリポイントの再構成
src/index.js をサブコマンド対応に書き換えましょう。
> src/index.js を Commander.js のサブコマンドパターンで書き換えて。
organize, undo, stats の3つのサブコマンドを定義して。
各サブコマンドは別ファイル(src/commands/ 内)からインポートして
#!/usr/bin/env node
import { Command } from 'commander';
import { registerOrganizeCommand } from './commands/organize.js';
import { registerUndoCommand } from './commands/undo.js';
import { registerStatsCommand } from './commands/stats.js';
const program = new Command();
program
.name('forg')
.description('ファイルを拡張子ごとにフォルダへ自動整理するCLIツール')
.version('1.0.0', '-v, --version');
// サブコマンドを登録
registerOrganizeCommand(program);
registerUndoCommand(program);
registerStatsCommand(program);
// サブコマンドなしで実行された場合はヘルプを表示
program.action(() => {
program.help();
});
program.parse();
Commander.js では program.command() を使ってサブコマンドを追加します。各サブコマンドの定義を別ファイルに分離し、register 関数としてエクスポートするパターンを採用しています。こうすることで、サブコマンドが増えてもエントリポイントはシンプルなままです。
organize サブコマンドの登録
既存の organize 機能をサブコマンドとして登録する形に修正します。
> src/commands/organize.js を修正して、
registerOrganizeCommand 関数をエクスポートするようにして。
program.command('organize') でサブコマンドを定義して。
エイリアスとして 'org' も設定して
import { resolve, join } from 'path';
import { confirm, checkbox } from '@inquirer/prompts';
import ora from 'ora';
import { listFiles, getFileInfo, moveFile, ensureDir } from '../utils/file-utils.js';
import { getCategory } from '../utils/categories.js';
import { logger } from '../utils/logger.js';
import { saveHistory } from '../utils/history.js';
export function registerOrganizeCommand(program) {
program
.command('organize')
.alias('org')
.description('ファイルを拡張子のカテゴリ別フォルダに整理する')
.argument('[directory]', '整理するディレクトリ', '.')
.option('-d, --dry-run', '実行計画のみ表示', false)
.option('--verbose', '詳細ログを表示', false)
.option('-e, --ext <extensions>', '対象拡張子(カンマ区切り)')
.option('-o, --output <dir>', '整理先ディレクトリ')
.option('-y, --yes', '確認をスキップ', false)
.option('-i, --interactive', '対話モード', false)
.action(async (directory, options) => {
try {
await organize(directory, options);
} catch (err) {
logger.error(err.message);
process.exit(1);
}
});
}
async function organize(directory, options) {
// ... 既存の organize ロジック(前回までに実装済み)
}
.alias('org') を設定することで、forg organize ~/Downloads の代わりに forg org ~/Downloads と短縮して実行できるようになります。サブコマンドに対しても .argument() や .option() を個別に定義できます。
undo サブコマンドの実装
整理を元に戻す機能を実装するには、まず整理操作の履歴を記録する必要があります。
> src/utils/history.js を作成して、
ファイル移動の履歴を保存・読み込みする機能を実装して。
履歴は .forg-history.json としてプロジェクトルートに保存して。
その後 src/commands/undo.js を作成して、
履歴を元にファイルを元の位置に戻す undo サブコマンドを実装して
// src/utils/history.js
import { readFile, writeFile } from 'fs/promises';
import { join } from 'path';
const HISTORY_FILE = '.forg-history.json';
export async function saveHistory(targetDir, moves) {
const historyPath = join(targetDir, HISTORY_FILE);
const entry = {
timestamp: new Date().toISOString(),
targetDir,
moves: moves.map(m => ({
from: m.originalPath,
to: m.newPath,
})),
};
let history = [];
try {
const data = await readFile(historyPath, 'utf-8');
history = JSON.parse(data);
} catch {
// ファイルが存在しない場合は空配列
}
history.push(entry);
await writeFile(historyPath, JSON.stringify(history, null, 2));
}
export async function loadHistory(targetDir) {
const historyPath = join(targetDir, HISTORY_FILE);
try {
const data = await readFile(historyPath, 'utf-8');
return JSON.parse(data);
} catch {
return [];
}
}
export async function removeLastHistory(targetDir) {
const historyPath = join(targetDir, HISTORY_FILE);
const history = await loadHistory(targetDir);
const last = history.pop();
await writeFile(historyPath, JSON.stringify(history, null, 2));
return last;
}
// src/commands/undo.js
import { resolve } from 'path';
import { confirm } from '@inquirer/prompts';
import ora from 'ora';
import { moveFile } from '../utils/file-utils.js';
import { loadHistory, removeLastHistory } from '../utils/history.js';
import { logger } from '../utils/logger.js';
export function registerUndoCommand(program) {
program
.command('undo')
.description('直前のファイル整理を元に戻す')
.argument('[directory]', '対象ディレクトリ', '.')
.option('-y, --yes', '確認をスキップ', false)
.action(async (directory, options) => {
try {
await undo(directory, options);
} catch (err) {
logger.error(err.message);
process.exit(1);
}
});
}
async function undo(directory, options) {
const targetDir = resolve(directory);
const history = await loadHistory(targetDir);
if (history.length === 0) {
logger.warn('元に戻せる操作がありません。');
return;
}
const lastEntry = history[history.length - 1];
const date = new Date(lastEntry.timestamp).toLocaleString('ja-JP');
logger.header('直前の操作');
logger.info(`日時: ${date}`);
logger.info(`ファイル数: ${lastEntry.moves.length}`);
if (!options.yes) {
const proceed = await confirm({
message: `${lastEntry.moves.length} 個のファイルを元に戻しますか?`,
default: false,
});
if (!proceed) {
console.log('キャンセルしました。');
return;
}
}
const spinner = ora('ファイルを元に戻しています...').start();
let restored = 0;
for (const move of lastEntry.moves) {
try {
await moveFile(move.to, move.from);
restored++;
} catch (err) {
logger.error(`復元失敗: ${move.to} - ${err.message}`);
}
}
await removeLastHistory(targetDir);
spinner.succeed(`${restored} 個のファイルを元に戻しました`);
}
stats サブコマンドの実装
ディレクトリの統計情報を表示するサブコマンドを実装します。
> src/commands/stats.js を作成して。
指定ディレクトリのファイル統計を表示するサブコマンドを実装して。
カテゴリ別のファイル数、合計サイズをテーブルで表示して。
上位5つの大きいファイルも表示して
// src/commands/stats.js
import { resolve } from 'path';
import { listFiles, getFileInfo } from '../utils/file-utils.js';
import { getCategory } from '../utils/categories.js';
import { displayResultsTable } from '../utils/display.js';
import { logger } from '../utils/logger.js';
import chalk from 'chalk';
export function registerStatsCommand(program) {
program
.command('stats')
.description('ディレクトリのファイル統計情報を表示する')
.argument('[directory]', '対象ディレクトリ', '.')
.option('--top <n>', '大きいファイルの表示数', parseInt, 5)
.action(async (directory, options) => {
try {
await stats(directory, options);
} catch (err) {
logger.error(err.message);
process.exit(1);
}
});
}
async function stats(directory, options) {
const targetDir = resolve(directory);
const files = await listFiles(targetDir);
if (files.length === 0) {
logger.warn('ファイルが見つかりませんでした。');
return;
}
const fileInfos = await Promise.all(files.map(getFileInfo));
// カテゴリ別に集計
const categories = {};
for (const info of fileInfos) {
const category = getCategory(info.ext);
if (!categories[category]) {
categories[category] = [];
}
categories[category].push(info);
}
logger.header(`ディレクトリ統計: ${targetDir}`);
logger.info(`合計ファイル数: ${fileInfos.length}`);
// テーブル表示
displayResultsTable({ categories, totalFiles: fileInfos.length });
// 上位N個の大きいファイル
const topN = options.top;
const sorted = fileInfos.sort((a, b) => b.size - a.size).slice(0, topN);
console.log();
logger.header(`サイズ上位 ${topN} ファイル`);
for (const file of sorted) {
const size = chalk.yellow(formatSize(file.size).padStart(10));
console.log(` ${size} ${file.name}`);
}
}
これで forg stats ~/Downloads --top 10 と実行すると、ダウンロードフォルダのファイル統計と、サイズの大きいファイル上位10個が表示されます。
演習問題
- サブコマンド追加:
forg listサブコマンドを追加して、指定ディレクトリ内のファイルをカテゴリ別にツリー表示する機能を実装してみましょう。 - エイリアス: 各サブコマンドに短縮エイリアスを設定してみましょう(
stats->st、undo->uなど)。 - デフォルトコマンド: サブコマンドなしで
forg ~/Downloadsを実行した場合、自動的にorganizeが実行されるように.isDefault(true)を設定してみましょう。 - undo の複数回対応: 現在の undo は直前の1回だけですが、
forg undo --allで全履歴を元に戻す機能を追加してみましょう。
参考リンク
Lecture 7設定ファイル — ユーザー設定を管理する
12:00
設定ファイル — ユーザー設定を管理する
前回はサブコマンドを使ってツールの機能を整理しました。今回は、ユーザーがツールの動作をカスタマイズできる設定ファイル機能を実装します。毎回 --verbose や --output ~/Sorted と入力するのは面倒です。設定ファイルにデフォルト値を保存しておけば、コマンドを短く保てます。XDG Base Directory 仕様に準拠した設定ファイルの配置場所、デフォルト設定との統合、forg config サブコマンドの実装まで、Claude Code と一緒に作っていきましょう。
設定ファイルの場所を決める
CLI ツールの設定ファイルは、OS ごとの標準的なディレクトリに配置するのが慣習です。Linux では XDG Base Directory 仕様に従い ~/.config/ 以下に配置します。macOS では ~/Library/Preferences/ も使われますが、多くの CLI ツールは Linux と同じ ~/.config/ を使います。Windows では %APPDATA% が一般的です。
> src/config/config-manager.js を作成して。
OSに応じた設定ファイルの場所を自動判定して、
~/.config/forg/config.json に設定を保存・読み込みする
ConfigManager クラスを実装して。
デフォルト設定との統合も行って
import { readFile, writeFile, mkdir } from 'fs/promises';
import { join } from 'path';
import { homedir, platform } from 'os';
const APP_NAME = 'forg';
// デフォルト設定
const DEFAULT_CONFIG = {
defaultDir: '.',
outputDir: null,
verbose: false,
dryRun: false,
interactive: false,
confirmBeforeMove: true,
categories: {
// カスタムカテゴリマッピング(ユーザーが拡張可能)
},
ignore: [
'.DS_Store',
'Thumbs.db',
'.gitkeep',
],
maxFileSizeMB: null,
};
export class ConfigManager {
constructor() {
this.configDir = this._getConfigDir();
this.configPath = join(this.configDir, 'config.json');
this.config = null;
}
/**
* OSに応じた設定ディレクトリのパスを返す
*/
_getConfigDir() {
const os = platform();
if (os === 'win32') {
const appData = process.env.APPDATA || join(homedir(), 'AppData', 'Roaming');
return join(appData, APP_NAME);
}
// macOS / Linux: XDG Base Directory 仕様
const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
return join(xdgConfig, APP_NAME);
}
/**
* 設定を読み込む(存在しなければデフォルト値を使用)
*/
async load() {
try {
const data = await readFile(this.configPath, 'utf-8');
const userConfig = JSON.parse(data);
// デフォルト設定とユーザー設定をマージ
this.config = this._deepMerge(DEFAULT_CONFIG, userConfig);
} catch {
this.config = { ...DEFAULT_CONFIG };
}
return this.config;
}
/**
* 設定を保存する
*/
async save(config) {
await mkdir(this.configDir, { recursive: true });
const data = JSON.stringify(config || this.config, null, 2);
await writeFile(this.configPath, data, 'utf-8');
}
/**
* 特定のキーの値を取得する
*/
get(key) {
if (!this.config) {
throw new Error('設定が読み込まれていません。load() を先に呼んでください。');
}
return key.split('.').reduce((obj, k) => obj?.[k], this.config);
}
/**
* 特定のキーに値を設定する
*/
set(key, value) {
if (!this.config) {
this.config = { ...DEFAULT_CONFIG };
}
const keys = key.split('.');
let obj = this.config;
for (let i = 0; i < keys.length - 1; i++) {
if (!obj[keys[i]] || typeof obj[keys[i]] !== 'object') {
obj[keys[i]] = {};
}
obj = obj[keys[i]];
}
obj[keys[keys.length - 1]] = value;
}
/**
* 設定をデフォルトにリセットする
*/
async reset() {
this.config = { ...DEFAULT_CONFIG };
await this.save();
}
/**
* オブジェクトのディープマージ
*/
_deepMerge(target, source) {
const result = { ...target };
for (const key of Object.keys(source)) {
if (
source[key] &&
typeof source[key] === 'object' &&
!Array.isArray(source[key]) &&
target[key] &&
typeof target[key] === 'object'
) {
result[key] = this._deepMerge(target[key], source[key]);
} else {
result[key] = source[key];
}
}
return result;
}
}
_deepMerge メソッドは、ネストされたオブジェクトのマージを行います。ユーザーが categories の一部だけを変更しても、デフォルトのカテゴリ設定が失われることはありません。設定の優先順位は「コマンドラインオプション > 設定ファイル > デフォルト値」です。
config サブコマンドの実装
設定の閲覧・変更を行う forg config サブコマンドを実装しましょう。
> src/commands/config.js を作成して。
以下のサブコマンドを実装して:
- forg config list: 全設定を表示
- forg config get <key>: 特定の設定値を表示
- forg config set <key> <value>: 設定値を変更
- forg config reset: デフォルトに戻す
- forg config path: 設定ファイルのパスを表示
import { ConfigManager } from '../config/config-manager.js';
import { logger } from '../utils/logger.js';
import { confirm } from '@inquirer/prompts';
import chalk from 'chalk';
export function registerConfigCommand(program) {
const configCmd = program
.command('config')
.description('ツールの設定を管理する');
// forg config list
configCmd
.command('list')
.alias('ls')
.description('現在の設定を一覧表示する')
.action(async () => {
const cm = new ConfigManager();
const config = await cm.load();
logger.header('現在の設定');
printConfig(config);
console.log();
logger.dim(`設定ファイル: ${cm.configPath}`);
});
// forg config get <key>
configCmd
.command('get <key>')
.description('指定したキーの設定値を表示する')
.action(async (key) => {
const cm = new ConfigManager();
await cm.load();
const value = cm.get(key);
if (value === undefined) {
logger.warn(`キー "${key}" は存在しません`);
process.exit(1);
}
if (typeof value === 'object') {
console.log(JSON.stringify(value, null, 2));
} else {
console.log(value);
}
});
// forg config set <key> <value>
configCmd
.command('set <key> <value>')
.description('指定したキーに値を設定する')
.action(async (key, value) => {
const cm = new ConfigManager();
await cm.load();
// 値の型変換
let parsedValue = value;
if (value === 'true') parsedValue = true;
else if (value === 'false') parsedValue = false;
else if (value === 'null') parsedValue = null;
else if (!isNaN(Number(value))) parsedValue = Number(value);
cm.set(key, parsedValue);
await cm.save();
logger.success(`${key} = ${JSON.stringify(parsedValue)}`);
});
// forg config reset
configCmd
.command('reset')
.description('設定をデフォルトに戻す')
.action(async () => {
const proceed = await confirm({
message: '設定をデフォルトに戻しますか?',
default: false,
});
if (!proceed) {
console.log('キャンセルしました。');
return;
}
const cm = new ConfigManager();
await cm.reset();
logger.success('設定をデフォルトに戻しました');
});
// forg config path
configCmd
.command('path')
.description('設定ファイルのパスを表示する')
.action(() => {
const cm = new ConfigManager();
console.log(cm.configPath);
});
}
function printConfig(config, prefix = '') {
for (const [key, value] of Object.entries(config)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === 'object' && !Array.isArray(value)) {
printConfig(value, fullKey);
} else {
const keyStr = chalk.cyan(fullKey.padEnd(25));
const valueStr = chalk.white(JSON.stringify(value));
console.log(` ${keyStr} ${valueStr}`);
}
}
}
これで以下のようにコマンドが使えるようになります。
# 全設定を表示
forg config list
# 特定の設定値を確認
forg config get verbose
# false
# 設定を変更
forg config set verbose true
# ✓ verbose = true
forg config set ignore '["*.tmp",".DS_Store"]'
# 設定ファイルの場所を確認
forg config path
# /home/user/.config/forg/config.json
# デフォルトに戻す
forg config reset
設定とコマンドラインオプションの統合
設定ファイルの値をコマンドラインオプションのデフォルト値として使うように統合しましょう。
> organize コマンドで、設定ファイルの値を
コマンドラインオプションのデフォルト値として使うようにして。
コマンドラインで明示的に指定された場合はそちらを優先して
// src/commands/organize.js(修正部分)
import { ConfigManager } from '../config/config-manager.js';
export function registerOrganizeCommand(program) {
program
.command('organize')
.alias('org')
.description('ファイルを拡張子のカテゴリ別フォルダに整理する')
.argument('[directory]', '整理するディレクトリ')
.option('-d, --dry-run', '実行計画のみ表示')
.option('--verbose', '詳細ログを表示')
.option('-o, --output <dir>', '整理先ディレクトリ')
.option('-y, --yes', '確認をスキップ')
.action(async (directory, cmdOptions) => {
try {
// 設定ファイルを読み込み
const cm = new ConfigManager();
const config = await cm.load();
// 優先順位: コマンドラインオプション > 設定ファイル > デフォルト値
const options = {
dryRun: cmdOptions.dryRun ?? config.dryRun ?? false,
verbose: cmdOptions.verbose ?? config.verbose ?? false,
output: cmdOptions.output ?? config.outputDir ?? null,
yes: cmdOptions.yes ?? !config.confirmBeforeMove,
interactive: cmdOptions.interactive ?? config.interactive ?? false,
};
const targetDir = directory ?? config.defaultDir ?? '.';
await organize(targetDir, options);
} catch (err) {
logger.error(err.message);
process.exit(1);
}
});
}
??(Nullish Coalescing 演算子)を使うことで、「コマンドラインで明示的に指定されていない場合のみ設定ファイルの値を使う」という挙動を実現しています。|| と異なり、false や 0 といった falsy な値も正しく扱えます。
カスタムカテゴリの設定
ユーザーが独自のカテゴリマッピングを追加できるようにしましょう。
> 設定ファイルの categories フィールドに
ユーザー独自の拡張子マッピングを追加できるようにして。
デフォルトのカテゴリマッピングとマージして使うようにして
// src/utils/categories.js(修正版)
import { CATEGORY_MAP } from './default-categories.js';
export function getCategory(ext, customCategories = {}) {
// カスタムカテゴリを優先
if (customCategories[ext]) {
return customCategories[ext];
}
return CATEGORY_MAP[ext.toLowerCase()] || 'その他';
}
設定ファイルでカスタムカテゴリを定義する例を示します。
{
"categories": {
".psd": "デザイン",
".ai": "デザイン",
".sketch": "デザイン",
".fig": "デザイン",
".blend": "3Dモデル",
".obj": "3Dモデル",
".fbx": "3Dモデル"
}
}
これにより、デザイナーや3Dアーティストなど、職種に応じたカスタマイズが可能になります。デフォルトのカテゴリマッピングは変更せず、カスタム設定が優先される設計にしています。
演習問題
- 設定ファイル作成:
forg config set verbose trueとforg config set defaultDir ~/Downloadsを実行して、設定ファイルが正しく作成されることを確認してください。 - プロファイル機能: 「仕事用」「プライベート用」など、複数の設定プロファイルを切り替える
--profile <name>オプションを実装してみましょう。設定ファイルをconfig-work.json、config-personal.jsonのように分けます。 - 設定のエクスポート/インポート:
forg config export > my-config.jsonとforg config import my-config.jsonで設定のバックアップ・復元ができる機能を追加してみましょう。 - 環境変数の統合:
FORG_VERBOSE=trueのように、環境変数でも設定を上書きできるようにしてみましょう。優先順位は「コマンドライン > 環境変数 > 設定ファイル > デフォルト」です。
参考リンク
Lecture 8エラー処理 — 堅牢なCLIにする
12:00
エラー処理 — 堅牢なCLIにする
前回は設定ファイルによるユーザーカスタマイズを実装しました。今回は、CLI ツールを「本番運用」に耐えうる品質にするための重要なテーマ、エラー処理を扱います。ファイルが見つからない、権限がない、ディスク容量が足りない、ネットワーク接続がない。ユーザーの環境では様々なエラーが発生し得ます。Claude Code と一緒に、ユーザーフレンドリーなエラーメッセージ、適切な終了コード、グレースフルなエラーリカバリを実装していきましょう。
終了コード(Exit Code)の設計
Unix の慣習として、CLI ツールは処理結果を「終了コード」で返します。0 が成功、それ以外がエラーを意味します。ファイルオーガナイザーの終了コードを設計しましょう。
> src/utils/exit-codes.js を作成して。
CLIツールの終了コードを定数として定義して。
一般的な慣習に従って設計して
/**
* 終了コードの定義
* 0: 成功
* 1: 一般的なエラー
* 2: 不正な引数・オプション
* 3: ファイル/ディレクトリが見つからない
* 4: 権限エラー
* 5: ディスク容量不足
* 126: コマンド実行不能
* 130: Ctrl+C による中断
*/
export const ExitCode = {
SUCCESS: 0,
GENERAL_ERROR: 1,
INVALID_ARGUMENT: 2,
NOT_FOUND: 3,
PERMISSION_DENIED: 4,
DISK_FULL: 5,
CANNOT_EXECUTE: 126,
INTERRUPTED: 130,
};
/**
* 終了コードに応じたメッセージを返す
*/
export function getExitMessage(code) {
const messages = {
[ExitCode.SUCCESS]: '正常終了',
[ExitCode.GENERAL_ERROR]: 'エラーが発生しました',
[ExitCode.INVALID_ARGUMENT]: '引数またはオプションが不正です',
[ExitCode.NOT_FOUND]: 'ファイルまたはディレクトリが見つかりません',
[ExitCode.PERMISSION_DENIED]: '権限がありません',
[ExitCode.DISK_FULL]: 'ディスク容量が不足しています',
[ExitCode.INTERRUPTED]: 'ユーザーにより中断されました',
};
return messages[code] || '不明なエラー';
}
シェルスクリプトから forg を呼び出す場合、終了コードを $? で確認して次の処理を分岐できます。適切な終了コードを返すことで、他のツールとの連携が可能になります。
カスタムエラークラスの作成
Node.js の標準 Error クラスを拡張して、CLI ツール専用のエラークラスを作りましょう。
> src/utils/errors.js を作成して。
CLIツール用のカスタムエラークラスを実装して。
エラーの種類ごとにクラスを分けて、
終了コードとユーザー向けメッセージを持つようにして
import { ExitCode } from './exit-codes.js';
/**
* CLI ツールの基底エラークラス
*/
export class CLIError extends Error {
constructor(message, exitCode = ExitCode.GENERAL_ERROR, suggestion = '') {
super(message);
this.name = 'CLIError';
this.exitCode = exitCode;
this.suggestion = suggestion;
}
}
/**
* ファイル/ディレクトリが見つからないエラー
*/
export class NotFoundError extends CLIError {
constructor(path) {
super(
`パスが見つかりません: ${path}`,
ExitCode.NOT_FOUND,
'パスが正しいか確認してください。絶対パスで指定することもできます。'
);
this.name = 'NotFoundError';
}
}
/**
* 権限エラー
*/
export class PermissionError extends CLIError {
constructor(path, operation = 'アクセス') {
super(
`${operation}権限がありません: ${path}`,
ExitCode.PERMISSION_DENIED,
'sudo を付けて実行するか、ファイルの権限を確認してください。'
);
this.name = 'PermissionError';
}
}
/**
* バリデーションエラー
*/
export class ValidationError extends CLIError {
constructor(message, suggestion = '') {
super(message, ExitCode.INVALID_ARGUMENT, suggestion);
this.name = 'ValidationError';
}
}
/**
* ディスク容量不足エラー
*/
export class DiskFullError extends CLIError {
constructor() {
super(
'ディスク容量が不足しています',
ExitCode.DISK_FULL,
'不要なファイルを削除してディスク容量を確保してください。'
);
this.name = 'DiskFullError';
}
}
各エラークラスは exitCode と suggestion プロパティを持ちます。exitCode は process.exit() に渡す値、suggestion はユーザーに対処法を伝えるメッセージです。これにより、エラーが発生した際に「何が起きたか」だけでなく「どうすればいいか」まで案内できます。
グローバルエラーハンドラーの実装
アプリケーション全体で発生するエラーを一元的に処理するグローバルハンドラーを実装しましょう。
> src/utils/error-handler.js を作成して。
グローバルなエラーハンドラーを実装して。
CLIError の場合はユーザーフレンドリーな表示、
想定外のエラーの場合はスタックトレースを表示して。
Ctrl+C のハンドリングも追加して
import chalk from 'chalk';
import { CLIError } from './errors.js';
import { ExitCode } from './exit-codes.js';
/**
* グローバルエラーハンドラーを登録する
*/
export function setupErrorHandlers() {
// 未処理の例外
process.on('uncaughtException', (err) => {
handleError(err);
});
// 未処理の Promise rejection
process.on('unhandledRejection', (reason) => {
handleError(reason instanceof Error ? reason : new Error(String(reason)));
});
// Ctrl+C(SIGINT)
process.on('SIGINT', () => {
console.log();
console.log(chalk.yellow('\n中断されました'));
process.exit(ExitCode.INTERRUPTED);
});
// SIGTERM
process.on('SIGTERM', () => {
console.log(chalk.yellow('\n終了シグナルを受信しました'));
process.exit(ExitCode.INTERRUPTED);
});
}
/**
* エラーを処理してユーザーフレンドリーに表示する
*/
export function handleError(err) {
if (err instanceof CLIError) {
// CLIError: ユーザーフレンドリーなメッセージを表示
console.error();
console.error(chalk.red(`エラー: ${err.message}`));
if (err.suggestion) {
console.error(chalk.yellow(`ヒント: ${err.suggestion}`));
}
process.exit(err.exitCode);
}
// Node.js のシステムエラーを変換
if (err.code) {
const friendlyError = convertSystemError(err);
if (friendlyError) {
handleError(friendlyError);
return;
}
}
// 想定外のエラー: デバッグ情報を表示
console.error();
console.error(chalk.red('予期しないエラーが発生しました:'));
console.error(chalk.red(err.message));
console.error();
console.error(chalk.dim('デバッグ情報:'));
console.error(chalk.dim(err.stack));
console.error();
console.error(chalk.dim('このエラーが繰り返し発生する場合は、'));
console.error(chalk.dim('GitHub Issues に報告してください。'));
process.exit(ExitCode.GENERAL_ERROR);
}
/**
* Node.js のシステムエラーコードをCLIErrorに変換する
*/
function convertSystemError(err) {
switch (err.code) {
case 'ENOENT':
return new CLIError(
`ファイルまたはディレクトリが見つかりません: ${err.path || ''}`,
ExitCode.NOT_FOUND,
'パスが正しいか確認してください。'
);
case 'EACCES':
case 'EPERM':
return new CLIError(
`権限がありません: ${err.path || ''}`,
ExitCode.PERMISSION_DENIED,
'ファイルの権限を確認するか、管理者権限で実行してください。'
);
case 'ENOSPC':
return new CLIError(
'ディスク容量が不足しています',
ExitCode.DISK_FULL,
'不要なファイルを削除してディスク容量を確保してください。'
);
case 'EEXIST':
return new CLIError(
`既に存在します: ${err.path || ''}`,
ExitCode.GENERAL_ERROR,
'--force オプションで上書きするか、別のパスを指定してください。'
);
case 'EISDIR':
return new CLIError(
`ディレクトリが指定されています(ファイルが必要です): ${err.path || ''}`,
ExitCode.INVALID_ARGUMENT,
'ファイルのパスを指定してください。'
);
default:
return null;
}
}
convertSystemError 関数は、Node.js のシステムエラーコード(ENOENT, EACCES など)を、ユーザーが理解できるメッセージに変換します。これは非常に重要な処理です。ENOENT というエラーコードは開発者にしか分かりませんが、「ファイルまたはディレクトリが見つかりません」と日本語で表示すれば、誰でも原因が分かります。
エントリポイントへの統合
グローバルエラーハンドラーをエントリポイントに統合しましょう。
> src/index.js にグローバルエラーハンドラーを統合して。
アプリケーション開始時に setupErrorHandlers() を呼んで
#!/usr/bin/env node
import { Command } from 'commander';
import { setupErrorHandlers, handleError } from './utils/error-handler.js';
import { registerOrganizeCommand } from './commands/organize.js';
import { registerUndoCommand } from './commands/undo.js';
import { registerStatsCommand } from './commands/stats.js';
import { registerConfigCommand } from './commands/config.js';
// グローバルエラーハンドラーを登録
setupErrorHandlers();
const program = new Command();
program
.name('forg')
.description('ファイルを拡張子ごとにフォルダへ自動整理するCLIツール')
.version('1.0.0', '-v, --version');
registerOrganizeCommand(program);
registerUndoCommand(program);
registerStatsCommand(program);
registerConfigCommand(program);
program.action(() => {
program.help();
});
program.parse();
入力バリデーションの実装
エラーが発生する前に不正な入力を検出するバリデーション層を追加しましょう。
> src/utils/validator.js を作成して。
organize コマンドの入力をバリデーションする関数を実装して。
ディレクトリの存在確認、書き込み権限チェック、
拡張子フォーマットの検証を行って
import { access, stat } from 'fs/promises';
import { constants } from 'fs';
import { resolve } from 'path';
import { NotFoundError, PermissionError, ValidationError } from './errors.js';
/**
* ディレクトリの存在と権限をチェックする
*/
export async function validateDirectory(dir) {
const fullPath = resolve(dir);
// 存在チェック
try {
await access(fullPath);
} catch {
throw new NotFoundError(fullPath);
}
// ディレクトリかどうかチェック
const stats = await stat(fullPath);
if (!stats.isDirectory()) {
throw new ValidationError(
`${fullPath} はディレクトリではありません`,
'ディレクトリのパスを指定してください。'
);
}
// 読み取り権限チェック
try {
await access(fullPath, constants.R_OK);
} catch {
throw new PermissionError(fullPath, '読み取り');
}
return fullPath;
}
/**
* 出力先ディレクトリの書き込み権限をチェックする
*/
export async function validateOutputDir(dir) {
const fullPath = resolve(dir);
try {
await access(fullPath, constants.W_OK);
} catch (err) {
if (err.code === 'ENOENT') {
// 存在しない場合は親ディレクトリの書き込み権限をチェック
return fullPath; // 後で mkdir で作成される
}
throw new PermissionError(fullPath, '書き込み');
}
return fullPath;
}
/**
* 拡張子リストのフォーマットを検証する
*/
export function validateExtensions(extensions) {
if (!extensions || extensions.length === 0) return;
for (const ext of extensions) {
if (!ext.startsWith('.')) {
throw new ValidationError(
`不正な拡張子フォーマット: "${ext}"`,
'拡張子は "." で始めてください(例: .jpg, .png)'
);
}
if (ext.length < 2) {
throw new ValidationError(
`不正な拡張子: "${ext}"`,
'拡張子は少なくとも1文字必要です(例: .js)'
);
}
}
}
バリデーションを organize コマンドの先頭で呼び出すことで、エラーが深い処理ロジックに到達する前にキャッチできます。これにより、エラーメッセージがより的確で、ユーザーにとって分かりやすくなります。
演習問題
- エラーテスト: 存在しないディレクトリに対して
forg organize /nonexistentを実行し、適切なエラーメッセージが表示されることを確認してください。 - 終了コード確認: エラー発生時の終了コードをシェルで
echo $?(bash)またはecho $LASTEXITCODE(PowerShell)で確認してください。 - リトライ機能: 一時的なエラー(ファイルロックなど)が発生した場合に3回までリトライする
withRetry(fn, maxRetries)ヘルパー関数を実装してみましょう。 - ログファイル: エラー情報を
~/.config/forg/error.logにも書き出すようにhandleError関数を拡張してみましょう。--debugフラグが有効な場合のみスタックトレースも記録します。
参考リンク
Lecture 9テスト — CLIツールをテストする
12:00
テスト — CLIツールをテストする
前回はエラー処理を実装して、ツールの堅牢性を高めました。しかし、手動でエラーケースを毎回確認するのは非効率です。今回は自動テストを導入して、コードの品質を継続的に保証できるようにします。テストフレームワーク「Vitest」を使って、ユーティリティ関数のユニットテスト、コマンドの統合テスト、ファイルシステムのモックまで、Claude Code に手伝ってもらいながら実装していきましょう。
Vitest のセットアップ
Vitest は Vite ベースの高速テストフレームワークです。Jest 互換の API を持ちながら、ESM をネイティブサポートしているため、"type": "module" のプロジェクトでもそのまま動作します。
> Vitest をインストールして、テスト環境をセットアップして。
package.json の test スクリプトも設定して
npm install -D vitest
package.json にテストスクリプトを追加します。
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
}
テストファイルの配置規約を決めましょう。Vitest はデフォルトで **/*.test.js または **/*.spec.js パターンのファイルを検出します。ソースコードの隣にテストを置く「コロケーション」パターンを採用します。
src/
├── utils/
│ ├── categories.js
│ ├── categories.test.js # テストファイル
│ ├── file-utils.js
│ ├── file-utils.test.js # テストファイル
│ └── ...
├── commands/
│ ├── organize.js
│ ├── organize.test.js # テストファイル
│ └── ...
└── ...
ユーティリティ関数のテスト
まず、純粋な関数であるカテゴリマッピングのテストから始めましょう。外部依存がないため、テストが書きやすいです。
> src/utils/categories.test.js を作成して。
getCategory 関数と getAllCategories 関数のテストを書いて。
正常系・境界値・エッジケースを網羅して
import { describe, it, expect } from 'vitest';
import { getCategory, getAllCategories } from './categories.js';
describe('getCategory', () => {
it('画像ファイルの拡張子を正しく判定する', () => {
expect(getCategory('.jpg')).toBe('画像');
expect(getCategory('.png')).toBe('画像');
expect(getCategory('.gif')).toBe('画像');
expect(getCategory('.svg')).toBe('画像');
expect(getCategory('.webp')).toBe('画像');
});
it('動画ファイルの拡張子を正しく判定する', () => {
expect(getCategory('.mp4')).toBe('動画');
expect(getCategory('.avi')).toBe('動画');
expect(getCategory('.mov')).toBe('動画');
});
it('ドキュメントファイルの拡張子を正しく判定する', () => {
expect(getCategory('.pdf')).toBe('ドキュメント');
expect(getCategory('.docx')).toBe('ドキュメント');
expect(getCategory('.xlsx')).toBe('ドキュメント');
expect(getCategory('.txt')).toBe('ドキュメント');
expect(getCategory('.md')).toBe('ドキュメント');
});
it('大文字の拡張子でも正しく判定する', () => {
expect(getCategory('.JPG')).toBe('画像');
expect(getCategory('.PDF')).toBe('ドキュメント');
expect(getCategory('.MP4')).toBe('動画');
});
it('未知の拡張子は「その他」を返す', () => {
expect(getCategory('.xyz')).toBe('その他');
expect(getCategory('.unknown')).toBe('その他');
expect(getCategory('.abc123')).toBe('その他');
});
it('空文字列はその他を返す', () => {
expect(getCategory('')).toBe('その他');
});
it('カスタムカテゴリが指定された場合はそちらを優先する', () => {
const custom = { '.psd': 'デザイン' };
expect(getCategory('.psd', custom)).toBe('デザイン');
// デフォルトカテゴリはそのまま動作する
expect(getCategory('.jpg', custom)).toBe('画像');
});
});
describe('getAllCategories', () => {
it('「その他」を含むカテゴリ一覧を返す', () => {
const categories = getAllCategories();
expect(categories).toContain('その他');
});
it('主要なカテゴリがすべて含まれている', () => {
const categories = getAllCategories();
expect(categories).toContain('画像');
expect(categories).toContain('動画');
expect(categories).toContain('音声');
expect(categories).toContain('ドキュメント');
expect(categories).toContain('アーカイブ');
expect(categories).toContain('コード');
});
it('重複がない', () => {
const categories = getAllCategories();
const unique = new Set(categories);
expect(categories.length).toBe(unique.size);
});
});
テストを実行してみましょう。
npm test
# 出力例:
# ✓ src/utils/categories.test.js (10)
# ✓ getCategory (7)
# ✓ getAllCategories (3)
# Test Files 1 passed (1)
# Tests 10 passed (10)
describe でテストグループを作り、it で個々のテストケースを定義します。expect と toBe/toContain でアサーションを記述します。Vitest は Jest とほぼ同じ API なので、Jest の経験があればすぐに使えます。
ファイルシステムのモック
ファイル操作のテストでは、実際のファイルシステムを操作するのは避けたいです。Vitest の vi.mock を使って fs/promises をモックしましょう。
> src/utils/file-utils.test.js を作成して。
fs/promises をモックして、listFiles と getFileInfo のテストを書いて。
実際のファイルシステムには触れないようにして
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { listFiles, getFileInfo, ensureDir } from './file-utils.js';
// fs/promises をモック
vi.mock('fs/promises', () => ({
readdir: vi.fn(),
stat: vi.fn(),
mkdir: vi.fn(),
access: vi.fn(),
rename: vi.fn(),
}));
import { readdir, stat, mkdir, access } from 'fs/promises';
beforeEach(() => {
vi.clearAllMocks();
});
describe('listFiles', () => {
it('ファイルのみを返す(ディレクトリを除外する)', async () => {
readdir.mockResolvedValue([
{ name: 'photo.jpg', isFile: () => true },
{ name: 'docs', isFile: () => false },
{ name: 'report.pdf', isFile: () => true },
{ name: '.hidden', isFile: () => true },
]);
const files = await listFiles('/test/dir');
expect(files).toHaveLength(3);
expect(files).toContain('/test/dir/photo.jpg');
expect(files).toContain('/test/dir/report.pdf');
expect(files).toContain('/test/dir/.hidden');
expect(files).not.toContain('/test/dir/docs');
});
it('空のディレクトリでは空配列を返す', async () => {
readdir.mockResolvedValue([]);
const files = await listFiles('/empty/dir');
expect(files).toHaveLength(0);
});
it('readdir がエラーを投げた場合、例外が伝播する', async () => {
readdir.mockRejectedValue(new Error('ENOENT'));
await expect(listFiles('/nonexistent')).rejects.toThrow('ENOENT');
});
});
describe('getFileInfo', () => {
it('ファイルの情報を正しく返す', async () => {
const mockDate = new Date('2025-01-15T10:30:00Z');
stat.mockResolvedValue({
size: 1024 * 1024, // 1MB
mtime: mockDate,
});
const info = await getFileInfo('/test/photo.jpg');
expect(info.name).toBe('photo.jpg');
expect(info.ext).toBe('.jpg');
expect(info.size).toBe(1024 * 1024);
expect(info.modifiedAt).toEqual(mockDate);
});
it('拡張子がないファイルは空文字列を返す', async () => {
stat.mockResolvedValue({
size: 100,
mtime: new Date(),
});
const info = await getFileInfo('/test/Makefile');
expect(info.ext).toBe('');
});
});
describe('ensureDir', () => {
it('ディレクトリが存在しない場合、作成する', async () => {
access.mockRejectedValue(new Error('ENOENT'));
mkdir.mockResolvedValue(undefined);
await ensureDir('/new/dir');
expect(mkdir).toHaveBeenCalledWith('/new/dir', { recursive: true });
});
it('ディレクトリが既に存在する場合、何もしない', async () => {
access.mockResolvedValue(undefined);
await ensureDir('/existing/dir');
expect(mkdir).not.toHaveBeenCalled();
});
});
vi.mock('fs/promises') でモジュール全体をモック化します。各テストで mockResolvedValue や mockRejectedValue を使って、関数の戻り値を制御します。beforeEach の vi.clearAllMocks() で、テスト間でモックの状態がリークしないようにしています。
コマンドの統合テスト
個々の関数だけでなく、コマンド全体の挙動もテストしましょう。テンポラリディレクトリを使った統合テストを書きます。
> src/commands/organize.test.js を作成して。
一時ディレクトリにテストファイルを作成し、
organize コマンドの結果を検証する統合テストを書いて。
テスト終了後にクリーンアップして
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, writeFile, readdir, rm } from 'fs/promises';
import { join } from 'path';
import { tmpdir } from 'os';
import { organize } from './organize.js';
describe('organize コマンド(統合テスト)', () => {
let testDir;
beforeEach(async () => {
// テンポラリディレクトリを作成
testDir = await mkdtemp(join(tmpdir(), 'forg-test-'));
// テストファイルを作成
await writeFile(join(testDir, 'photo.jpg'), 'image data');
await writeFile(join(testDir, 'document.pdf'), 'pdf data');
await writeFile(join(testDir, 'script.js'), 'console.log("hello")');
await writeFile(join(testDir, 'music.mp3'), 'audio data');
await writeFile(join(testDir, 'unknown.xyz'), 'unknown');
});
afterEach(async () => {
// テンポラリディレクトリを削除
await rm(testDir, { recursive: true, force: true });
});
it('ファイルがカテゴリ別フォルダに移動される', async () => {
await organize(testDir, { yes: true, verbose: false });
const entries = await readdir(testDir);
expect(entries).toContain('画像');
expect(entries).toContain('ドキュメント');
expect(entries).toContain('コード');
expect(entries).toContain('音声');
expect(entries).toContain('その他');
// 各カテゴリフォルダの中身を確認
const images = await readdir(join(testDir, '画像'));
expect(images).toContain('photo.jpg');
});
it('dry-run モードではファイルが移動されない', async () => {
await organize(testDir, { dryRun: true, yes: true });
const entries = await readdir(testDir);
expect(entries).toContain('photo.jpg');
expect(entries).toContain('document.pdf');
expect(entries).not.toContain('画像');
});
it('拡張子フィルターが正しく動作する', async () => {
await organize(testDir, {
ext: ['.jpg', '.png'],
yes: true,
});
const entries = await readdir(testDir);
expect(entries).toContain('画像');
// フィルター外のファイルはそのまま
expect(entries).toContain('document.pdf');
expect(entries).toContain('script.js');
});
});
統合テストでは実際のファイルシステムを使います。mkdtemp で一時ディレクトリを作成し、テスト後に rm で削除します。これにより、テスト環境が他のテストや既存のファイルに影響を与えることがありません。
テストカバレッジの確認
テストがコードのどの部分をカバーしているか確認しましょう。
> テストカバレッジを表示するように設定して。
vitest.config.js を作成して、カバレッジの設定を追加して
npm install -D @vitest/coverage-v8
// vitest.config.js
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
include: ['src/**/*.js'],
exclude: ['src/**/*.test.js'],
thresholds: {
branches: 70,
functions: 70,
lines: 70,
statements: 70,
},
},
},
});
npm run test:coverage
# 出力例:
# -------------|---------|----------|---------|---------|
# File | % Stmts | % Branch | % Funcs | % Lines |
# -------------|---------|----------|---------|---------|
# categories | 100 | 100 | 100 | 100 |
# file-utils | 85 | 75 | 80 | 85 |
# errors | 100 | 100 | 100 | 100 |
# -------------|---------|----------|---------|---------|
thresholds を設定することで、カバレッジが一定の基準を下回った場合にテストを失敗させることができます。CI/CD パイプラインと組み合わせると、品質の低下を自動で検出できます。
演習問題
- エラークラスのテスト:
src/utils/errors.test.jsを作成して、NotFoundError,PermissionError,ValidationErrorの各エラークラスが正しいプロパティ(exitCode,suggestion,message)を持つことをテストしてください。 - スナップショットテスト:
displayResultsTable関数の出力をスナップショットテスト(expect(...).toMatchSnapshot())で検証するテストを書いてみましょう。 - ウォッチモード:
npm run test:watchを実行してウォッチモードを起動し、テストファイルを変更したときに自動でテストが再実行されることを確認してください。 - 設定ファイルのテスト:
ConfigManagerクラスのテストを書いてください。テンポラリディレクトリを使って、設定の保存・読み込み・マージが正しく動作することを検証しましょう。
参考リンク
Lecture 10npm公開 — ツールを世界に配布する
12:00
npm公開 — ツールを世界に配布する
全9回の講義を通じて、ファイルオーガナイザー CLI ツール「forg」を完成させました。引数パーサー、ファイル操作、対話型プロンプト、カラフルな出力、サブコマンド、設定ファイル、エラー処理、テスト。これだけの機能が揃ったツールを、最後に npm で公開して世界中の開発者が使えるようにしましょう。Claude Code を使って、パッケージの準備から公開、さらに GitHub Actions による CI/CD パイプラインの構築まで行います。
npm アカウントの準備
npm にパッケージを公開するには、npm のアカウントが必要です。まだ持っていない場合は、https://www.npmjs.com/signup でアカウントを作成してください。
> npmにログインする方法を教えて。
2FAの設定方法も含めて
# npm にログイン
npm login
# ユーザー名、パスワード、メールアドレスを入力
# ブラウザが開いて認証を求められる場合もある
# ログイン状態を確認
npm whoami
# your-username
セキュリティのため、npm アカウントには2要素認証(2FA)を設定することを強く推奨します。npm のウェブサイトの Account Settings から設定できます。認証アプリ(Google Authenticator や Authy)を使う方法が一般的です。
package.json の最終調整
公開前に package.json を最終調整しましょう。Claude Code にお願いします。
> package.json を npm 公開用に最終調整して。
以下を追加・修正して:
- description を英語で追加
- keywords を充実させる
- repository, bugs, homepage フィールド
- files フィールドで公開ファイルを制限
- engines で最低 Node.js バージョンを指定
- license を MIT に
{
"name": "file-organizer-cli",
"version": "1.0.0",
"description": "A CLI tool to automatically organize files into categorized folders by extension",
"type": "module",
"main": "src/index.js",
"bin": {
"forg": "./src/index.js"
},
"files": [
"src/",
"README.md",
"LICENSE"
],
"scripts": {
"start": "node src/index.js",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"prepublishOnly": "npm test"
},
"keywords": [
"cli",
"file",
"organizer",
"directory",
"sort",
"cleanup",
"extension",
"command-line"
],
"author": "Your Name <your-email@example.com>",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/your-username/file-organizer-cli.git"
},
"bugs": {
"url": "https://github.com/your-username/file-organizer-cli/issues"
},
"homepage": "https://github.com/your-username/file-organizer-cli#readme",
"engines": {
"node": ">=18.0.0"
},
"dependencies": {
"@inquirer/prompts": "^7.0.0",
"chalk": "^5.0.0",
"cli-progress": "^3.0.0",
"cli-table3": "^0.6.0",
"commander": "^12.0.0",
"ora": "^8.0.0"
},
"devDependencies": {
"@vitest/coverage-v8": "^2.0.0",
"vitest": "^2.0.0"
}
}
重要なフィールドを解説します。files フィールドは npm パッケージに含めるファイル・ディレクトリを明示的に指定します。node_modules やテストファイルなど不要なものを除外できます。指定しない場合は .gitignore の逆(無視されないファイル全て)が含まれますが、明示的に指定する方が安全です。prepublishOnly スクリプトは npm publish の実行前に自動で走ります。テストが失敗すると公開がブロックされるため、壊れたパッケージの公開を防げます。engines フィールドは Node.js の最低バージョンを指定します。>=18.0.0 を指定しているのは、fs/promises や ES Modules のサポートが安定しているバージョンだからです。
README.md の作成
npm パッケージの第一印象は README で決まります。Claude Code に作ってもらいましょう。
> README.md を作成して。
以下の内容を含めて:
- ツールの概要とスクリーンショット(テキストで代替)
- インストール方法(npm install -g と npx)
- 使用例(各サブコマンド)
- 設定ファイルの説明
- ライセンス
# file-organizer-cli (forg)
ファイルを拡張子ごとにカテゴリ別フォルダへ自動整理する CLI ツール。
## インストール
```bash
# グローバルインストール
npm install -g file-organizer-cli
# または npx で一時実行
npx file-organizer-cli organize ~/Downloads
使い方
# ファイルを整理する
forg organize ~/Downloads
# 整理計画を確認する(ドライラン)
forg organize --dry-run ~/Downloads
# 対話モードで整理する
forg organize -i ~/Downloads
# 直前の整理を元に戻す
forg undo ~/Downloads
# ファイル統計を表示する
forg stats ~/Downloads
# 設定を変更する
forg config set verbose true
ライセンス
MIT
README はそのまま npm パッケージのページにも表示されます。使用例を豊富に載せることで、パッケージを見つけた人がすぐに使い方を理解できます。
## 公開前の最終チェック
公開する前に、パッケージの内容を確認しましょう。
npm publish する前に確認すべきことをリストアップして。 npm pack でドライランも実行して
```bash
# パッケージに含まれるファイルを確認
npm pack --dry-run
# 出力例:
# npm notice package: file-organizer-cli@1.0.0
# npm notice === Tarball Contents ===
# npm notice 1.2kB src/index.js
# npm notice 3.4kB src/commands/organize.js
# npm notice 2.1kB src/commands/undo.js
# npm notice 1.8kB src/commands/stats.js
# npm notice ...
# npm notice 2.5kB README.md
# npm notice 1.1kB LICENSE
# npm notice === Tarball Details ===
# npm notice name: file-organizer-cli
# npm notice version: 1.0.0
# npm notice package size: 8.2 kB
# npm notice total files: 15
npm pack --dry-run で、実際に公開されるファイルの一覧とパッケージサイズを確認できます。テストファイルや設定ファイルが含まれていないこと、必要なソースファイルがすべて含まれていることを確認しましょう。
パッケージ名の重複もチェックします。
# パッケージ名が空いているか確認
npm search file-organizer-cli
# または npm のウェブサイトで検索
# https://www.npmjs.com/search?q=file-organizer-cli
名前が既に使われている場合は、スコープ付きパッケージ名(@your-username/file-organizer-cli)を使うか、別の名前を考えましょう。
npm publish の実行
すべての準備が整ったら、いよいよ公開です。
# テストを実行
npm test
# 公開(prepublishOnly で自動テスト実行)
npm publish
# 公開が成功した場合の出力:
# npm notice
# npm notice 📦 file-organizer-cli@1.0.0
# npm notice === Tarball Details ===
# npm notice name: file-organizer-cli
# npm notice version: 1.0.0
# + file-organizer-cli@1.0.0
公開後、他のマシンからインストールできることを確認しましょう。
# グローバルインストール
npm install -g file-organizer-cli
# 動作確認
forg --version
# 1.0.0
# npx で一時実行
npx file-organizer-cli organize --dry-run ~/Downloads
npx を使うと、パッケージをインストールせずに一時的に実行できます。「試しに使ってみたい」というユーザーには npx を案内するのが親切です。
GitHub Actions で CI/CD を構築する
コードを変更するたびに手動でテストを実行するのは面倒です。GitHub Actions を使って、プッシュ時に自動でテストとリリースが行われるように設定しましょう。
> .github/workflows/ci.yml を作成して。
以下のジョブを定義して:
- push と PR でテストを自動実行
- Node.js 18, 20, 22 のマトリクスビルド
- main ブランチのタグ push で自動 npm publish
name: CI/CD
on:
push:
branches: [main]
tags: ['v*']
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm test
- run: npm run test:coverage
if: matrix.node-version == 22
publish:
needs: test
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
このワークフローは2つのジョブから構成されます。test ジョブは、プッシュと PR のたびに Node.js の3バージョンでテストを実行します。publish ジョブは、v1.0.0 のようなタグがプッシュされたときにのみ実行され、npm にパッケージを自動公開します。
NPM_TOKEN は GitHub リポジトリの Settings > Secrets に登録する必要があります。npm のウェブサイトで Access Token を発行し、それを GitHub に登録します。
新しいバージョンをリリースする流れは以下の通りです。
# バージョンを上げる
npm version patch # 1.0.0 → 1.0.1
# または
npm version minor # 1.0.0 → 1.1.0
# または
npm version major # 1.0.0 → 2.0.0
# タグをプッシュすると自動で npm publish される
git push origin main --tags
npm version コマンドは package.json のバージョンを更新し、Git のコミットとタグを自動で作成してくれます。
コース全体のまとめ
このコースでは、Claude Code を活用しながら、10回の講義で実用的な CLI ツールを完成させました。学んだ技術を振り返りましょう。
- プロジェクト初期化:
package.jsonのbinフィールド、shebang、npm link - 引数パーサー: Commander.js によるオプション定義とヘルプ自動生成
- ファイル操作:
fs/promisesによる非同期ファイル処理 - 対話型プロンプト:
@inquirer/promptsによる確認・選択・入力 - 出力の装飾: chalk、ora、cli-table3 によるリッチな表示
- サブコマンド: Commander.js のサブコマンドによる機能整理
- 設定ファイル: XDG 準拠の設定管理
- エラー処理: カスタムエラー、終了コード、グレースフルハンドリング
- テスト: Vitest によるユニットテスト・統合テスト
- npm 公開: パッケージ準備、公開、GitHub Actions CI/CD
これらの技術は、ファイルオーガナイザーに限らず、あらゆる CLI ツールの開発に応用できます。ぜひ自分だけのツールを作って、npm で公開してみてください。
演習問題
- npm 公開: 実際に npm アカウントを作成し、このコースで作ったツールを(名前を変えて)公開してみましょう。別のマシンからインストールして動作することを確認してください。
- バージョンアップ: 新機能(例:
forg renameサブコマンド)を追加して、npm version minorでバージョンを上げ、再公開してみましょう。 - npx 対応確認:
npx your-package-name --helpで、インストールせずにツールが実行できることを確認しましょう。 - GitHub Actions: CI/CD ワークフローを設定して、PR を出したときにテストが自動で実行されることを確認しましょう。