1
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?

【Claude Code】 全部まとめて殴るサイバーパンクCLI「BARAX」をTypeScriptで自作した——セッション履歴1.4MB、検索手段ゼロ。テンプレは手動コピペ。

1
Posted at

スクリーンショット 2026-03-21 午前0.26.57.png

この記事の登場人物
🧑‍💻 …Claude Code のテンプレートシステムを52 Skills + 18 Agents まで育てた先輩
🔰 …Claude Code を使い始めて「CLAUDE.md って何ですか?」レベルの後輩

🔰「先輩、新規プロジェクト作るたびにテンプレートを手動コピーしてるんですけど、毎回 {{PROJECT_NAME}} を置換するの面倒すぎません?」
🧑‍💻「だよね。あと "3日前に認証の話したセッションどれだっけ" って ~/.claude/history.jsonl を grep してない?」
🔰「してます…」
🧑‍💻「CLAUDE.md のプレースホルダー置換忘れとかも気づけないでしょ」
🔰「気づけないです」
🧑‍💻「全部まとめて解決するCLIを作ろう。名前は BARAX
🔰「なんですかそれ」
🧑‍💻「日本語で動くサイバーパンク風CLI。barax 初期化 とか barax 履歴 検索 "認証" って打てる」
🔰「…日本語でコマンド打てるんですか?」

結論から言うと、約400行のTypeScriptで3つの実用コマンドが完成しました。

この記事でわかること

  • Claude Code の ~/.claude/ 内部データ構造の全貌
  • サイバーパンク風UIデザインシステムの作り方
  • テンプレート自動展開 / セッション2層検索 / ヘルスチェックの実装
  • 全コマンド日本語エイリアス対応barax 診断 = barax doctor

完成品:BRAXの3コマンド

🧑‍💻「まず完成品を見せる」

コマンド 日本語 機能
barax init barax 初期化 テンプレートから対話的にプロジェクト生成
barax history search <query> barax 履歴 検索 <query> セッション履歴を2層検索
barax history list barax 履歴 一覧 最近のセッション一覧
barax history stats barax 履歴 統計 使用統計ダッシュボード
barax doctor [path] barax 診断 [path] 10項目ヘルスチェック + スコアリング
██████╗  █████╗ ██████╗  █████╗ ██╗  ██╗
██╔══██╗██╔══██╗██╔══██╗██╔══██╗╚██╗██╔╝
██████╔╝███████║██████╔╝███████║ ╚███╔╝
██╔══██╗██╔══██║██╔══██╗██╔══██║ ██╔██╗
██████╔╝██║  ██║██║  ██║██║  ██║██╔╝ ██╗
╚═════╝ ╚═╝  ╚═╝╚═╝  ╚═╝╚═╝  ╚═╝╚═╝  ╚═╝

  BARAX — Claude Code 日本語対応CLI v1.0.0

Commands:
  init|初期化                    新規プロジェクトをテンプレートから初期化
  history|履歴                  Claude Code セッション履歴の検索・統計
  doctor|診断 [options] [path]  プロジェクトのヘルスチェック

🔰「init|初期化 って表示されてる…本当に日本語で動くんですね」
🧑‍💻「Commander.js の .alias() で実現してる。じゃあ作っていこう」


まず、Claude Code のデータ構造を知る

🔰「そもそも ~/.claude/ の中身って何が入ってるんですか?」
🧑‍💻「こうなってる」

🧑‍💻「BRAXが読むのは3つだけ」

ファイル サイズ感 BRAXの使い方
sessions-index.json 数KB × プロジェクト数 Tier 1検索(高速)
history.jsonl 数MB Tier 2検索(ディープ)
stats-cache.json 数十KB 統計ダッシュボード

BRAXはこれらのファイルを読み取り専用でアクセスします。書き込みは一切行わないので安全です。


history.jsonl — 入力履歴

🧑‍💻「1行1JSONのフラットなログ」

{"display":"認証フローを実装して","timestamp":1768802444268,"project":"/Users/you/my-app","sessionId":"b798f1ff-..."}

🔰「全プロンプトが残ってるんですか」
🧑‍💻「そう。でもプロジェクト横断で1ファイルだから、特定のプロジェクトの話を探すのが大変なんだよね」

sessions-index.json — セッション索引

🧑‍💻「こっちはプロジェクトごとに存在する。セッションのメタ情報が集約されてる」

{
  "version": 1,
  "entries": [{
    "sessionId": "5b628d2c-...",
    "firstPrompt": "認証フローを実装して",
    "summary": "Google OAuth認証フロー実装完了",
    "messageCount": 72,
    "created": "2026-02-05T10:58:45.753Z",
    "gitBranch": "main",
    "projectPath": "/Users/you/my-app"
  }]
}

🔰「firstPromptsummary があるなら、これだけで大抵の検索はできそうですね」
🧑‍💻「そう。だからBRAXは まずこっちを検索して、見つからなければ history.jsonl をフルスキャン する2層構造にしてる」

🔰「なるほど、普段は高速で、必要なときだけディープ検索」
🧑‍💻「sessions-index は数KBだからミリ秒で終わる。history.jsonl は数MBあっても Node.js のインメモリ処理なら1秒以内」


プロジェクトセットアップ

🧑‍💻「じゃあ作っていく」

mkdir -p ~/ccx/src/{ui,commands,lib,types}
cd ~/ccx

技術スタック

パッケージ 役割 なぜこれか
Commander.js CLIフレームワーク 0依存, 起動18ms, .alias() で日本語対応
chalk v5 ターミナルカラー Pure ESM, hex() でネオンカラー
gradient-string テキストグラデーション バナーの cyan→magenta→pink
figlet ASCIIアートフォント "BARAX" のデカ文字
ora スピナー テンプレート展開中の演出
boxen 装飾ボックス スコア表示、エラー表示
chalk-animation グリッチエフェクト 起動演出
@inquirer/prompts v7 対話プロンプト ESMネイティブ版
dayjs 日付処理 2KB(moment 232KB, date-fns 72KB は過剰)

🔰「全部合わせてどのくらいですか?」
🧑‍💻「135パッケージ、脆弱性ゼロ。12秒でインストール完了」

package.json

{
  "name": "barax",
  "version": "1.0.0",
  "description": "BARAX — Claude Code 日本語対応サイバーパンク風CLIツール",
  "type": "module",
  "bin": {
    "barax": "./dist/index.js"
  },
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch"
  },
  "dependencies": {
    "commander": "^13.1.0",
    "chalk": "^5.4.1",
    "gradient-string": "^3.0.0",
    "figlet": "^1.8.0",
    "ora": "^8.2.0",
    "boxen": "^8.0.1",
    "chalk-animation": "^2.0.3",
    "@inquirer/prompts": "^7.3.2",
    "dayjs": "^1.11.13"
  },
  "devDependencies": {
    "typescript": "^5.7.3",
    "@types/node": "^22.12.0",
    "@types/figlet": "^1.7.0",
    "@types/gradient-string": "^1.1.6",
    "@types/chalk-animation": "^1.6.3"
  }
}

"type": "module" が必須。 これがないと import/export 構文が使えません。さらに ESM モードでは import パスに .js 拡張子が必要です(import './foo.js' — TypeScriptソースでも!)

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}
npm install

プロジェクト全体像

🔰「ファイル構成ってどうなるんですか?」
🧑‍💻「こう」

🧑‍💻「依存の流れは commands → lib + ui → types の一方向。循環参照なし」


型定義を先に決める

🧑‍💻「まず型から。これで全モジュールの契約が決まる」

// src/types/index.ts

// ─── テンプレート関連 ───
export interface TemplatePlaceholders {
  PROJECT_NAME: string;
  PROJECT_DESCRIPTION: string;
  ORGANIZATION: string;
  TECH_STACK: string;
  DATABASE: string;
  WORKTREE_BASE: string;
  [key: string]: string;  // 拡張可能
}

export interface ScaffoldResult {
  outputDir: string;
  filesCreated: string[];
  placeholdersReplaced: number;
}

// ─── 設定関連 ───
export interface BaraxConfig {
  templateDir: string;
  defaultOrg: string;
  claudeDir: string;
  projects: ProjectEntry[];
}

export interface ProjectEntry {
  name: string;
  path: string;
  createdAt: string;
  techStack: string;
  database: string;
}

// ─── 履歴関連 ───
export interface SessionIndexEntry {
  sessionId: string;
  projectPath: string;
  firstPrompt: string;
  summary?: string;
  createdAt: string;
  updatedAt: string;
  branch?: string;
  messageCount: number;
}

export interface SearchResult {
  sessionId: string;
  projectPath: string;
  matchedText: string;
  timestamp: string;
  branch?: string;
  summary?: string;
  source: 'index' | 'deep';
}

export interface HistoryStats {
  totalSessions: number;
  totalMessages: number;
  tokensByModel: Record<string, number>;
  activityByHour: number[];
  topActiveDays: Array<{ date: string; count: number }>;
}

// ─── Doctor関連 ───
export type CheckStatus = 'OK' | 'WARN' | 'FAIL';

export interface HealthCheck {
  name: string;
  status: CheckStatus;
  message: string;
  details?: string;
}

export interface DoctorReport {
  projectPath: string;
  checks: HealthCheck[];
  score: number;
  timestamp: string;
}

🔰「[key: string]: string ってなんですか?」
🧑‍💻「インデックスシグネチャ。固定の6キー以外にも自由にプレースホルダーを追加できるようにしてる。テンプレート側で {{AUTHOR}} とか独自のプレースホルダーを使ってても対応可能」


サイバーパンクUIシステム

🧑‍💻「ここがBRAXの 見た目のキモ
🔰「なんでサイバーパンクなんですか」
🧑‍💻「かっこいいから」

カラーパレット

theme.ts

// src/ui/theme.ts
import chalk from 'chalk';

export const COLORS = {
  neonCyan:    '#00FFFF',
  neonMagenta: '#FF00FF',
  neonGreen:   '#39FF14',
  neonPink:    '#FF2D7B',
  neonYellow:  '#FFE100',
  neonBlue:    '#00D4FF',
  dimGray:     '#555577',
} as const;

export const GRADIENTS = {
  banner:  [COLORS.neonCyan, COLORS.neonMagenta, COLORS.neonPink],
  success: [COLORS.neonGreen, COLORS.neonCyan],
  error:   [COLORS.neonYellow, COLORS.neonPink],
  info:    [COLORS.neonBlue, COLORS.neonCyan],
} as const;

export const BOX_STYLES = {
  default: { padding: 1, margin: { top: 1, bottom: 1, left: 0, right: 0 }, borderStyle: 'round' as const, borderColor: COLORS.neonCyan },
  success: { padding: 1, margin: { top: 1, bottom: 1, left: 0, right: 0 }, borderStyle: 'round' as const, borderColor: COLORS.neonGreen },
  error:   { padding: 1, margin: { top: 1, bottom: 1, left: 0, right: 0 }, borderStyle: 'round' as const, borderColor: COLORS.neonPink },
  warning: { padding: 1, margin: { top: 1, bottom: 1, left: 0, right: 0 }, borderStyle: 'round' as const, borderColor: COLORS.neonYellow },
} as const;

export const styled = {
  cyan:    (text: string) => chalk.hex(COLORS.neonCyan)(text),
  magenta: (text: string) => chalk.hex(COLORS.neonMagenta)(text),
  green:   (text: string) => chalk.hex(COLORS.neonGreen)(text),
  pink:    (text: string) => chalk.hex(COLORS.neonPink)(text),
  yellow:  (text: string) => chalk.hex(COLORS.neonYellow)(text),
  blue:    (text: string) => chalk.hex(COLORS.neonBlue)(text),
  dim:     (text: string) => chalk.hex(COLORS.dimGray)(text),
  bold:    (text: string) => chalk.bold(text),
  label:   (text: string) => chalk.hex(COLORS.neonCyan).bold(text),
  value:   (text: string) => chalk.hex(COLORS.neonMagenta)(text),
  ok:      (text: string) => chalk.hex(COLORS.neonGreen).bold(text),
  warn:    (text: string) => chalk.hex(COLORS.neonYellow).bold(text),
  fail:    (text: string) => chalk.hex(COLORS.neonPink).bold(text),
};

export function hr(char = '', length = 50): string {
  return styled.dim(char.repeat(length));
}

export const ICONS = {
  check:   styled.green(''),
  cross:   styled.pink(''),
  warning: styled.yellow(''),
  arrow:   styled.cyan(''),
  dot:     styled.magenta(''),
  star:    styled.yellow(''),
  bolt:    styled.cyan(''),
  gear:    styled.blue(''),
  folder:  styled.cyan('📁'),
  search:  styled.magenta('🔍'),
  chart:   styled.green('📊'),
} as const;

🔰「chalk.hex() って何ですか?」
🧑‍💻「16進カラーコードでターミナルテキストに色をつける。chalk.red() とかの既定色じゃなくて、好きなネオンカラーが使える。ターミナルが256色モードでも自動で最近似色にフォールバックしてくれる」

banner.ts — ASCIIアートバナー

// src/ui/banner.ts
import figlet from 'figlet';
import gradient from 'gradient-string';
import chalkAnimation from 'chalk-animation';
import { GRADIENTS, styled, hr } from './theme.js';

const BARAX_GRADIENT = gradient([...GRADIENTS.banner]);

export function showBanner(): void {
  const asciiArt = figlet.textSync('BARAX', {
    font: 'ANSI Shadow',
    horizontalLayout: 'default',
  });
  console.log('');
  console.log(BARAX_GRADIENT(asciiArt));
  console.log(hr('', 55));
  console.log(styled.dim('  BARAX') + styled.cyan('') + styled.dim('Claude Code 日本語対応CLI v1.0.0'));
  console.log(hr('', 55));
  console.log('');
}

export function showSubBanner(title: string): void {
  const subGradient = gradient([...GRADIENTS.info]);
  console.log('');
  console.log(subGradient(`  ▸ ${title}`));
  console.log(hr('', 50));
}

🔰「[...GRADIENTS.banner] のスプレッドって必要なんですか?」
🧑‍💻「必要。as const で readonly タプルになってるから、gradient-string が要求する mutable 配列に変換してる。これないとTypeScriptの型エラーになる」

よくあるハマりポイント: as const で定義した配列をライブラリに渡すと readonly エラー。[...array] でスプレッドすれば解決。

components.ts — 再利用UI部品

// src/ui/components.ts
import boxen from 'boxen';
import chalk from 'chalk';
import { COLORS, BOX_STYLES, styled, ICONS } from './theme.js';
import type { CheckStatus } from '../types/index.js';

export function styledBox(content: string, style: keyof typeof BOX_STYLES = 'default', title?: string): string {
  return boxen(content, {
    ...BOX_STYLES[style],
    title: title ? styled.bold(title) : undefined,
    titleAlignment: 'left',
  });
}

export function statusLine(status: CheckStatus, label: string, message: string): string {
  const icon = status === 'OK' ? ICONS.check : status === 'WARN' ? ICONS.warning : ICONS.cross;
  const statusText = status === 'OK' ? styled.ok(status.padEnd(4))
    : status === 'WARN' ? styled.warn(status.padEnd(4))
    : styled.fail(status.padEnd(4));
  return `  ${icon} ${statusText} ${styled.label(label.padEnd(28))} ${styled.dim(message)}`;
}

export function sectionHeader(title: string): void {
  console.log('');
  console.log(`  ${styled.cyan('┌─')} ${styled.bold(title)}`);
  console.log(`  ${styled.cyan('')}`);
}

export function sectionEnd(): void {
  console.log(`  ${styled.cyan('' + ''.repeat(48))}`);
}

export function dataTable(headers: string[], rows: string[][]): void {
  const colWidths = headers.map((h, i) => {
    const maxRow = rows.reduce((max, row) => {
      const cellLen = stripAnsi(row[i] || '').length;
      return cellLen > max ? cellLen : max;
    }, 0);
    return Math.max(stripAnsi(h).length, maxRow) + 2;
  });
  const headerLine = headers.map((h, i) => styled.label(h.padEnd(colWidths[i]))).join(styled.dim(''));
  console.log(`  ${headerLine}`);
  console.log(`  ${colWidths.map(w => styled.dim(''.repeat(w))).join(styled.dim('─┼─'))}`);
  for (const row of rows) {
    const line = row.map((cell, i) => {
      const padding = colWidths[i] - stripAnsi(cell).length;
      return cell + ' '.repeat(Math.max(0, padding));
    }).join(styled.dim(''));
    console.log(`  ${line}`);
  }
}

export function progressBar(current: number, total: number, width = 30): string {
  const ratio = Math.min(current / total, 1);
  const filled = Math.round(width * ratio);
  const empty = width - filled;
  const bar = chalk.hex(COLORS.neonCyan)(''.repeat(filled)) + chalk.hex(COLORS.dimGray)(''.repeat(empty));
  return `${bar} ${styled.cyan(`${Math.round(ratio * 100)}%`)}`;
}

export function keyValue(key: string, value: string): string {
  return `  ${styled.label(key.padEnd(20))} ${styled.value(value)}`;
}

export function scoreDisplay(score: number): string {
  const color = score >= 80 ? COLORS.neonGreen : score >= 50 ? COLORS.neonYellow : COLORS.neonPink;
  return chalk.hex(color).bold(`${score}/100`);
}

function stripAnsi(str: string): string {
  return str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');
}

🔰「stripAnsi って何に使うんですか?」
🧑‍💻「テーブルのカラム幅計算。ANSIエスケープコード(色の制御文字)が入ると str.length が狂うから、それを除去して純粋な文字幅を出してる」

animations.ts

// src/ui/animations.ts
import { styled } from './theme.js';

export function typewriter(text: string, delayMs = 30): Promise<void> {
  return new Promise((resolve) => {
    let i = 0;
    const chars = [...text];
    const interval = setInterval(() => {
      if (i < chars.length) { process.stdout.write(chars[i]); i++; }
      else { clearInterval(interval); process.stdout.write('\n'); resolve(); }
    }, delayMs);
  });
}

export function glitchFlash(text: string, iterations = 5, delayMs = 80): Promise<void> {
  return new Promise((resolve) => {
    const glitchChars = '!@#$%^&*░▒▓█';
    let count = 0;
    const interval = setInterval(() => {
      if (count < iterations) {
        const glitched = [...text].map(ch => ch === ' ' || ch === '\n' ? ch
          : Math.random() > 0.7 ? glitchChars[Math.floor(Math.random() * glitchChars.length)] : ch
        ).join('');
        process.stdout.write(`\r${styled.magenta(glitched)}`);
        count++;
      } else {
        process.stdout.write(`\r${styled.cyan(text)}\n`);
        clearInterval(interval);
        resolve();
      }
    }, delayMs);
  });
}

export function sleep(ms: number): Promise<void> {
  return new Promise(resolve => setTimeout(resolve, ms));
}

コアライブラリ

config.ts — ~/.barax/config.json 管理

🧑‍💻「BRAXの設定は ~/.barax/config.json に保存する」

// src/lib/config.ts
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import type { BaraxConfig, ProjectEntry } from '../types/index.js';

const BARAX_DIR = join(homedir(), '.barax');
const CONFIG_PATH = join(BARAX_DIR, 'config.json');

const DEFAULT_CONFIG: BaraxConfig = {
  templateDir: join(homedir(), 'claude-code-templates'),
  defaultOrg: '',
  claudeDir: join(homedir(), '.claude'),
  projects: [],
};

export function ensureBaraxDir(): void {
  if (!existsSync(BARAX_DIR)) mkdirSync(BARAX_DIR, { recursive: true });
}

export function loadConfig(): BaraxConfig {
  ensureBaraxDir();
  if (!existsSync(CONFIG_PATH)) { saveConfig(DEFAULT_CONFIG); return { ...DEFAULT_CONFIG }; }
  try { return { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(CONFIG_PATH, 'utf-8')) }; }
  catch { return { ...DEFAULT_CONFIG }; }
}

export function saveConfig(config: BaraxConfig): void {
  ensureBaraxDir();
  writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8');
}

export function addProject(entry: ProjectEntry): void {
  const config = loadConfig();
  config.projects = config.projects.filter(p => p.name !== entry.name);
  config.projects.push(entry);
  saveConfig(config);
}

export function getClaudeDir(): string { return loadConfig().claudeDir; }
export { BARAX_DIR, CONFIG_PATH };

template-engine.ts — {{PLACEHOLDER}} 置換エンジン

🧑‍💻「テンプレートエンジンの核はこの正規表現1行」

/\{\{(\w+)\}\}/g

🔰「{{}} で囲まれた英数字をキャプチャする?」
🧑‍💻「そう。あとはマップから値を引いて置換するだけ」

// src/lib/template-engine.ts
import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, existsSync } from 'node:fs';
import { join, relative, dirname } from 'node:path';
import type { TemplatePlaceholders, ScaffoldResult } from '../types/index.js';

const PLACEHOLDER_RE = /\{\{(\w+)\}\}/g;

export function extractPlaceholders(content: string): string[] {
  const matches = new Set<string>();
  let match: RegExpExecArray | null;
  while ((match = PLACEHOLDER_RE.exec(content)) !== null) matches.add(match[1]);
  return [...matches];
}

export function replacePlaceholders(content: string, values: TemplatePlaceholders): string {
  return content.replace(PLACEHOLDER_RE, (_, key: string) => values[key] ?? `{{${key}}}`);
}

function replaceFileName(fileName: string, values: TemplatePlaceholders): string {
  return replacePlaceholders(fileName.replace(/\.template\./, '.'), values);
}

function walkDir(dir: string): string[] {
  const files: string[] = [];
  for (const entry of readdirSync(dir)) {
    if (['node_modules', '.git', 'dist', '.DS_Store'].includes(entry)) continue;
    const fullPath = join(dir, entry);
    if (statSync(fullPath).isDirectory()) files.push(...walkDir(fullPath));
    else files.push(fullPath);
  }
  return files;
}

const BINARY_EXTENSIONS = new Set(['.png','.jpg','.jpeg','.gif','.ico','.webp','.woff','.woff2','.ttf','.zip','.tar','.gz','.pdf']);

export function scaffoldProject(templateDir: string, outputDir: string, values: TemplatePlaceholders): ScaffoldResult {
  if (!existsSync(templateDir)) throw new Error(`テンプレートディレクトリが見つかりません: ${templateDir}`);
  const files = walkDir(templateDir);
  const filesCreated: string[] = [];
  let placeholdersReplaced = 0;

  for (const srcPath of files) {
    const relPath = relative(templateDir, srcPath);
    const newRelPath = replaceFileName(relPath, values);
    const destPath = join(outputDir, newRelPath);
    mkdirSync(dirname(destPath), { recursive: true });

    const ext = srcPath.slice(srcPath.lastIndexOf('.'));
    if (BINARY_EXTENSIONS.has(ext.toLowerCase())) {
      writeFileSync(destPath, readFileSync(srcPath));
    } else {
      const content = readFileSync(srcPath, 'utf-8');
      placeholdersReplaced += extractPlaceholders(content).filter(k => values[k] !== undefined).length;
      writeFileSync(destPath, replacePlaceholders(content, values), 'utf-8');
    }
    filesCreated.push(newRelPath);
  }
  return { outputDir, filesCreated, placeholdersReplaced };
}

🔰「*.template.* のリネームって何ですか?」
🧑‍💻「.env.template.local.env.local みたいに、テンプレート用のマーカーを除去する。git にコミットしたいけど .env として認識されたくないファイルに便利」


history-scanner.ts — 2層セッション検索

🧑‍💻「ここがBRAXの一番の売り」

// src/lib/history-scanner.ts
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import dayjs from 'dayjs';
import { getClaudeDir } from './config.js';
import type { SearchResult, HistoryStats, SessionIndexEntry } from '../types/index.js';

interface RawSessionIndex {
  version: number;
  entries: Array<{
    sessionId: string; firstPrompt: string; summary?: string;
    messageCount: number; created: string; modified: string;
    gitBranch?: string; projectPath: string;
  }>;
}

interface RawHistoryLine {
  display: string; timestamp: number; project: string; sessionId: string;
}

// ─── Tier 1: sessions-index 検索 (高速) ───
export function searchSessionIndex(
  query: string,
  options: { project?: string; after?: string; before?: string } = {},
): SearchResult[] {
  const projectsDir = join(getClaudeDir(), 'projects');
  if (!existsSync(projectsDir)) return [];
  const results: SearchResult[] = [];
  const q = query.toLowerCase();

  for (const dir of readdirSync(projectsDir)) {
    const indexPath = join(projectsDir, dir, 'sessions-index.json');
    if (!existsSync(indexPath)) continue;
    try {
      const raw: RawSessionIndex = JSON.parse(readFileSync(indexPath, 'utf-8'));
      for (const e of raw.entries) {
        if (options.project && !e.projectPath.toLowerCase().includes(options.project.toLowerCase())) continue;
        if (options.after && e.created < options.after) continue;
        if (options.before && e.created > options.before) continue;
        const fp = e.firstPrompt.toLowerCase().includes(q);
        const sm = e.summary?.toLowerCase().includes(q) ?? false;
        if (fp || sm) results.push({
          sessionId: e.sessionId, projectPath: e.projectPath,
          matchedText: (fp ? e.firstPrompt : e.summary ?? '').slice(0, 100),
          timestamp: e.created, branch: e.gitBranch || undefined,
          summary: e.summary, source: 'index',
        });
      }
    } catch { /* skip */ }
  }
  return results.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
}

// ─── Tier 2: history.jsonl 全文検索 (ディープ) ───
export function searchHistoryDeep(
  query: string,
  options: { project?: string; after?: string; before?: string } = {},
): SearchResult[] {
  const historyPath = join(getClaudeDir(), 'history.jsonl');
  if (!existsSync(historyPath)) return [];
  const results: SearchResult[] = [];
  const q = query.toLowerCase();
  const seen = new Set<string>();

  for (const line of readFileSync(historyPath, 'utf-8').split('\n')) {
    if (!line.trim()) continue;
    try {
      const e: RawHistoryLine = JSON.parse(line);
      if (!e.display) continue;
      if (options.project && !e.project.toLowerCase().includes(options.project.toLowerCase())) continue;
      const ts = dayjs(e.timestamp).toISOString();
      if (options.after && ts < options.after) continue;
      if (options.before && ts > options.before) continue;
      if (e.display.toLowerCase().includes(q)) {
        const key = `${e.sessionId}:${e.display.slice(0, 50)}`;
        if (seen.has(key)) continue;
        seen.add(key);
        results.push({ sessionId: e.sessionId, projectPath: e.project,
          matchedText: e.display.slice(0, 100), timestamp: ts, source: 'deep' });
      }
    } catch { /* skip */ }
  }
  return results.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
}

// ─── セッション一覧 ───
export function listRecentSessions(limit = 20): SessionIndexEntry[] {
  const projectsDir = join(getClaudeDir(), 'projects');
  if (!existsSync(projectsDir)) return [];
  const all: SessionIndexEntry[] = [];
  for (const dir of readdirSync(projectsDir)) {
    const indexPath = join(projectsDir, dir, 'sessions-index.json');
    if (!existsSync(indexPath)) continue;
    try {
      const raw: RawSessionIndex = JSON.parse(readFileSync(indexPath, 'utf-8'));
      for (const e of raw.entries) all.push({
        sessionId: e.sessionId, projectPath: e.projectPath,
        firstPrompt: e.firstPrompt.slice(0, 80), summary: e.summary,
        createdAt: e.created, updatedAt: e.modified,
        branch: e.gitBranch || undefined, messageCount: e.messageCount,
      });
    } catch { /* skip */ }
  }
  return all.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, limit);
}

// ─── 統計情報 ───
export function getStats(): HistoryStats {
  const claudeDir = getClaudeDir();
  let totalSessions = 0, totalMessages = 0;
  const activityByHour = new Array(24).fill(0);
  const dayMap = new Map<string, number>();

  const statsPath = join(claudeDir, 'stats-cache.json');
  if (existsSync(statsPath)) {
    try {
      const raw = JSON.parse(readFileSync(statsPath, 'utf-8'));
      for (const d of raw.dailyActivity) {
        totalSessions += d.sessionCount; totalMessages += d.messageCount;
        dayMap.set(d.date, d.messageCount);
      }
    } catch { /* skip */ }
  }

  const historyPath = join(claudeDir, 'history.jsonl');
  if (existsSync(historyPath)) {
    for (const line of readFileSync(historyPath, 'utf-8').split('\n')) {
      if (!line.trim()) continue;
      try { activityByHour[dayjs(JSON.parse(line).timestamp).hour()]++; }
      catch { /* skip */ }
    }
  }

  return {
    totalSessions, totalMessages, tokensByModel: {}, activityByHour,
    topActiveDays: [...dayMap.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5)
      .map(([date, count]) => ({ date, count })),
  };
}

🔰「Tier 2 で seen を使って重複排除してるのはなぜ?」
🧑‍💻「同じセッションで似たプロンプトが複数回記録されてることがある。セッション×テキスト先頭50文字をキーにして、同じものは1回だけ表示する」


project-scanner.ts — ヘルスチェッカー

// src/lib/project-scanner.ts
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
import type { HealthCheck, DoctorReport } from '../types/index.js';

const PLACEHOLDER_RE = /\{\{(\w+)\}\}/g;

export function checkProject(projectPath: string): DoctorReport {
  const checks: HealthCheck[] = [
    checkFile(projectPath, 'CLAUDE.md'),
    checkPlaceholders(projectPath, 'CLAUDE.md'),
    checkDir(projectPath, '.claude', '.claude/ ディレクトリ'),
    checkDir(projectPath, '.claude/skills', '.claude/skills/'),
    checkFile(projectPath, 'package.json'),
    checkPlaceholders(projectPath, 'package.json'),
    checkDir(projectPath, '.git', 'Git リポジトリ'),
    checkAllPlaceholders(projectPath),
    checkEnv(projectPath),
    checkDocker(projectPath),
  ];
  const score = checks.reduce((s, c) => s - (c.status === 'WARN' ? 5 : c.status === 'FAIL' ? 15 : 0), 100);
  return { projectPath, checks, score: Math.max(0, score), timestamp: new Date().toISOString() };
}

function checkFile(dir: string, f: string): HealthCheck {
  return existsSync(join(dir, f))
    ? { name: f, status: 'OK', message: '存在します' }
    : { name: f, status: 'FAIL', message: '見つかりません' };
}
function checkDir(dir: string, d: string, label: string): HealthCheck {
  const p = join(dir, d);
  return existsSync(p) && statSync(p).isDirectory()
    ? { name: label, status: 'OK', message: '存在します' }
    : { name: label, status: 'WARN', message: '見つかりません' };
}
function checkPlaceholders(dir: string, f: string): HealthCheck {
  const p = join(dir, f), label = `${f} プレースホルダー`;
  if (!existsSync(p)) return { name: label, status: 'WARN', message: 'ファイルなし' };
  const m = readFileSync(p, 'utf-8').match(PLACEHOLDER_RE);
  return m?.length
    ? { name: label, status: 'FAIL', message: `未置換: ${[...new Set(m)].join(', ')}` }
    : { name: label, status: 'OK', message: '未置換なし' };
}
function checkAllPlaceholders(dir: string): HealthCheck {
  const found: string[] = [];
  walk(dir, (fp, c) => { const m = c.match(PLACEHOLDER_RE); if (m) found.push(`${relative(dir, fp)}: ${[...new Set(m)].join(', ')}`); });
  return found.length > 0
    ? { name: '全ファイル {{PLACEHOLDER}}', status: 'WARN', message: `${found.length}ファイルに未置換あり`, details: found.slice(0, 5).join('\n') }
    : { name: '全ファイル {{PLACEHOLDER}}', status: 'OK', message: '未置換なし' };
}
function checkEnv(dir: string): HealthCheck {
  if (existsSync(join(dir, '.env'))) return { name: '.env ファイル', status: 'OK', message: '存在します' };
  if (existsSync(join(dir, '.env.example'))) return { name: '.env ファイル', status: 'WARN', message: '.env.example あり, .env 未作成' };
  return { name: '.env ファイル', status: 'WARN', message: '見つかりません (不要な場合は問題なし)' };
}
function checkDocker(dir: string): HealthCheck {
  return existsSync(join(dir, 'docker-compose.yml'))
    ? { name: 'docker-compose.yml', status: 'OK', message: '存在します' }
    : { name: 'docker-compose.yml', status: 'WARN', message: '見つかりません (不要な場合は問題なし)' };
}

const SKIP = new Set(['node_modules', '.git', 'dist', '.next', '__pycache__']);
const EXTS = new Set(['.ts','.tsx','.js','.json','.md','.yml','.yaml','.html','.css','.py','.go']);
function walk(dir: string, cb: (p: string, c: string) => void): void {
  if (!existsSync(dir)) return;
  for (const e of readdirSync(dir)) {
    if (SKIP.has(e) || e.startsWith('.')) continue;
    const fp = join(dir, e);
    try {
      const s = statSync(fp);
      if (s.isDirectory()) walk(fp, cb);
      else if (EXTS.has(e.slice(e.lastIndexOf('.')))) cb(fp, readFileSync(fp, 'utf-8'));
    } catch { /* skip */ }
  }
}

🧑‍💻「チェック項目とスコアリングはこう」

ステータス 減点 意味
✔ OK 0 問題なし
⚠ WARN -5 推奨だが必須ではない
✖ FAIL -15 要対応

コマンド実装

init.ts — barax 初期化

🧑‍💻「対話的プロンプトでプロジェクト情報を収集して、テンプレートを一発展開する」

// src/commands/init.ts
import { input, select, confirm } from '@inquirer/prompts';
import ora from 'ora';
import { existsSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { showBanner, showSubBanner } from '../ui/banner.js';
import { styledBox, keyValue, sectionHeader, sectionEnd } from '../ui/components.js';
import { styled, ICONS } from '../ui/theme.js';
import { loadConfig, addProject } from '../lib/config.js';
import { scaffoldProject } from '../lib/template-engine.js';
import type { TemplatePlaceholders } from '../types/index.js';

export async function initCommand(): Promise<void> {
  showBanner();
  showSubBanner('PROJECT INITIALIZER');
  const config = loadConfig();

  const projectName = await input({
    message: styled.cyan('プロジェクト名:'),
    validate: (v) => !v.trim() ? 'プロジェクト名は必須です'
      : !/^[a-z0-9-]+$/.test(v) ? '小文字英数字とハイフンのみ' : true,
  });
  const description = await input({ message: styled.cyan('プロジェクト概要:'), default: `${projectName} - Claude Code powered project` });
  const organization = await input({ message: styled.cyan('組織名:'), default: config.defaultOrg || 'my-org' });
  const techStack = await select({
    message: styled.cyan('技術スタック:'),
    choices: [
      { name: 'Next.js + Hono + Drizzle (フルスタック)', value: 'nextjs-hono-drizzle' },
      { name: 'Next.js のみ', value: 'nextjs' },
      { name: 'Hono のみ (API)', value: 'hono' },
      { name: 'カスタム', value: 'custom' },
    ],
  });
  const database = await select({
    message: styled.cyan('データベース:'),
    choices: [
      { name: 'PostgreSQL', value: 'postgresql' }, { name: 'MySQL', value: 'mysql' },
      { name: 'SQLite', value: 'sqlite' }, { name: 'なし', value: 'none' },
    ],
  });
  const outputDir = await input({ message: styled.cyan('出力先ディレクトリ:'), default: join(process.cwd(), projectName) });
  const worktreeBase = await input({ message: styled.cyan('Worktreeベースパス:'), default: resolve(outputDir, '..') });

  // 確認サマリー
  sectionHeader('確認');
  console.log(keyValue('プロジェクト名', projectName));
  console.log(keyValue('概要', description));
  console.log(keyValue('技術スタック', techStack));
  console.log(keyValue('データベース', database));
  console.log(keyValue('出力先', resolve(outputDir)));
  sectionEnd();

  if (!await confirm({ message: styled.cyan('この内容でプロジェクトを作成しますか?'), default: true })) {
    console.log(styled.yellow('\n  キャンセルしました。')); return;
  }

  if (!existsSync(config.templateDir)) {
    console.log(styledBox(`テンプレートディレクトリが見つかりません:\n${config.templateDir}`, 'error', 'ERROR')); return;
  }

  const spinner = ora({ text: styled.cyan('テンプレートを展開中...'), color: 'cyan' }).start();
  const values: TemplatePlaceholders = { PROJECT_NAME: projectName, PROJECT_DESCRIPTION: description, ORGANIZATION: organization, TECH_STACK: techStack, DATABASE: database, WORKTREE_BASE: worktreeBase };
  const result = scaffoldProject(config.templateDir, resolve(outputDir), values);
  spinner.succeed(styled.green('テンプレート展開完了!'));

  console.log(styledBox(
    `${ICONS.check} プロジェクト ${styled.cyan(projectName)} を作成しました!\n\n` +
    `${styled.label('作成ファイル数:')} ${styled.value(String(result.filesCreated.length))}\n` +
    `${styled.label('置換数:')} ${styled.value(String(result.placeholdersReplaced))}`,
    'success', 'COMPLETE'));

  sectionHeader('Next Steps');
  console.log(`  ${ICONS.arrow} cd ${styled.cyan(resolve(outputDir))}`);
  console.log(`  ${ICONS.arrow} npm install`);
  console.log(`  ${ICONS.arrow} git init && git add -A && git commit -m "init"`);
  console.log(`  ${ICONS.arrow} barax doctor`);
  sectionEnd();

  addProject({ name: projectName, path: resolve(outputDir), createdAt: new Date().toISOString(), techStack, database });
}
$ barax 初期化

  ▸ PROJECT INITIALIZER
──────────────────────────────────────────────────
? プロジェクト名: my-saas-app
? プロジェクト概要: SaaS管理ダッシュボード
? 組織名: my-org
? 技術スタック:
  ❯ Next.js + Hono + Drizzle (フルスタック)
    Next.js のみ (フロントエンド)
    Hono のみ (API)
    カスタム(手動入力)
? DB: PostgreSQL
? 出力先ディレクトリ: ~/projects/my-saas-app
? Worktreeベースパス: ~/worktrees

  ┌─ 確認
  │  名前:        my-saas-app
  │  概要:        SaaS管理ダッシュボード
  │  技術スタック: Next.js + Hono + Drizzle
  │  DB:          PostgreSQL
  │  出力先:      ~/projects/my-saas-app
  └──────────────────────────

? この内容でプロジェクトを作成しますか? Yes

  ⠋ テンプレートを展開中...

╭ COMPLETE ───────────────────────────────╮
│                                         │
│   ✔ プロジェクト my-saas-app を作成!    │
│                                         │
│   作成ファイル数: 42                    │
│   置換数:         18                    │
│                                         │
╰─────────────────────────────────────────╯

  ┌─ Next Steps
  │  ▸ cd ~/projects/my-saas-app
  │  ▸ npm install
  │  ▸ barax doctor
  └──────────────────────────

history.ts — barax 履歴

🧑‍💻「サブコマンドが3つ: 検索, 一覧, 統計

// src/commands/history.ts
import { Command } from 'commander';
import dayjs from 'dayjs';
import { showBanner, showSubBanner } from '../ui/banner.js';
import { styledBox, dataTable, sectionHeader, sectionEnd, progressBar } from '../ui/components.js';
import { styled, ICONS } from '../ui/theme.js';
import { searchSessionIndex, searchHistoryDeep, listRecentSessions, getStats } from '../lib/history-scanner.js';
import type { SearchResult } from '../types/index.js';

export function registerHistoryCommand(program: Command): void {
  const history = program.command('history').alias('履歴')
    .description('Claude Code セッション履歴の検索・統計');

  // ─── search | 検索 ───
  history.command('search <query>').alias('検索')
    .description('セッション履歴を検索')
    .option('-d, --deep', 'history.jsonl 全文検索 (Tier 2)')
    .option('-p, --project <name>', 'プロジェクト名フィルタ')
    .option('--after <date>', '指定日以降 (YYYY-MM-DD)')
    .option('--before <date>', '指定日以前 (YYYY-MM-DD)')
    .action(async (query: string, opts: { deep?: boolean; project?: string; after?: string; before?: string }) => {
      showBanner(); showSubBanner('HISTORY SEARCH');
      const filterOpts = { project: opts.project,
        after: opts.after ? dayjs(opts.after).toISOString() : undefined,
        before: opts.before ? dayjs(opts.before).toISOString() : undefined };

      console.log(`  ${ICONS.search} 検索キーワード: ${styled.cyan(query)}`);
      console.log(styled.dim('  ▸ Tier 1: sessions-index を検索中...'));
      const indexResults = searchSessionIndex(query, filterOpts);
      console.log(`  ${ICONS.check} ${styled.green(`${indexResults.length}件`)} ヒット`);

      let deepResults: SearchResult[] = [];
      if (opts.deep) {
        console.log(styled.dim('  ▸ Tier 2: history.jsonl を全文検索中...'));
        deepResults = searchHistoryDeep(query, filterOpts);
        console.log(`  ${ICONS.check} ${styled.green(`${deepResults.length}件`)} ヒット`);
      }

      const all = [...indexResults, ...deepResults];
      if (all.length === 0) {
        console.log(styledBox(`"${query}" に一致する結果なし。\n` + (opts.deep ? '' : `${ICONS.arrow} --deep で全文検索可。`), 'warning', 'NO RESULTS')); return;
      }

      sectionHeader(`検索結果 (${all.length}件)`);
      const shorten = (p: string) => { const s = p.replace(process.env.HOME || '', '~').split('/'); return s.length > 3 ? '…/' + s.slice(-2).join('/') : s.join('/'); };
      dataTable(['日時', 'プロジェクト', 'ブランチ', 'マッチ', 'ソース'],
        all.slice(0, 30).map(r => [
          styled.dim(dayjs(r.timestamp).format('MM/DD HH:mm')), styled.cyan(shorten(r.projectPath)),
          styled.magenta(r.branch || '-'), r.matchedText.slice(0, 50),
          r.source === 'index' ? styled.green('IDX') : styled.yellow('DEEP')]));
      sectionEnd();
      if (all.length > 30) console.log(styled.dim(`  ... 他 ${all.length - 30} 件`));
    });

  // ─── list | 一覧 ───
  history.command('list').alias('一覧')
    .description('最近のセッション一覧').option('-n, --limit <number>', '表示件数', '20')
    .action(async (opts: { limit: string }) => {
      showBanner(); showSubBanner('RECENT SESSIONS');
      const sessions = listRecentSessions(parseInt(opts.limit, 10));
      if (!sessions.length) { console.log(styledBox('セッション情報なし', 'warning', 'NO DATA')); return; }
      const shorten = (p: string) => { const s = p.replace(process.env.HOME || '', '~').split('/'); return s.length > 3 ? '…/' + s.slice(-2).join('/') : s.join('/'); };
      sectionHeader(`最近のセッション (${sessions.length}件)`);
      dataTable(['日時', 'プロジェクト', 'MSG', 'サマリー'],
        sessions.map(s => [styled.dim(dayjs(s.updatedAt).format('MM/DD HH:mm')), styled.cyan(shorten(s.projectPath)),
          styled.value(String(s.messageCount)), (s.summary || s.firstPrompt).slice(0, 45)]));
      sectionEnd();
    });

  // ─── stats | 統計 ───
  history.command('stats').alias('統計')
    .description('使用統計ダッシュボード')
    .action(async () => {
      showBanner(); showSubBanner('USAGE STATISTICS');
      const stats = getStats();
      console.log(styledBox(
        `${styled.label('総セッション数:')}  ${styled.value(String(stats.totalSessions))}\n` +
        `${styled.label('総メッセージ数:')}  ${styled.value(String(stats.totalMessages))}`, 'default', 'OVERVIEW'));

      sectionHeader('時間帯別アクティビティ');
      const max = Math.max(...stats.activityByHour, 1);
      for (let h = 0; h < 24; h++) {
        const v = stats.activityByHour[h];
        console.log(`  ${styled.dim(String(h).padStart(2,'0')+':00')} ${progressBar(v, max, 20)} ${styled.dim(String(v))}`);
      }
      sectionEnd();

      if (stats.topActiveDays.length > 0) {
        sectionHeader('最もアクティブな日 Top 5');
        dataTable(['日付', 'メッセージ数', 'アクティビティ'],
          stats.topActiveDays.map(d => [styled.cyan(d.date), styled.value(String(d.count)),
            progressBar(d.count, stats.topActiveDays[0].count, 25)]));
        sectionEnd();
      }
    });
}
$ barax 履歴 検索 "認証"

  🔍 検索キーワード: 認証
  ▸ Tier 1: sessions-index を検索中...
  ✔ 7件 ヒット (sessions-index)

  ┌─ 検索結果 (7件)
  │
  日時          │ プロジェクト      │ ブランチ │ マッチ                              │ ソース
  ────────────┼───────────────┼──────┼──────────────────────────────────┼──────
  03/15 17:02 │ …/my-saas-app │ main │ Next.jsフロントエンド実装計画...        │ IDX
  03/15 16:53 │ …/my-saas-app │ main │ フロントエンド実装計画:認証...           │ IDX
  03/15 14:20 │ …/my-saas-app │ main │ 認証フロー実装計画...                  │ IDX
  03/15 14:13 │ …/my-saas-app │ main │ 認証・認可設計のシーケンス...             │ IDX
  03/15 14:04 │ …/my-saas-app │ main │ バックエンド基盤実装プラン...             │ IDX
  03/14 13:48 │ …/my-saas-app │ main │ エラーハンドリング・認証・リポジトリ...      │ IDX
  03/12 20:44 │ …/apps/api    │ -    │ 認証APIが仕様書通り実装され正常稼働中...    │ IDX
  └────────────────────────────────────────────────
$ barax 履歴 統計

╭ OVERVIEW ───────────────────╮
│   総セッション数:  128      │
│   総メッセージ数:  4520     │
╰─────────────────────────────╯

  ┌─ 時間帯別アクティビティ
  │
  00:00 ██████████░░░░░░░░░░ 48%   52
  01:00 ██░░░░░░░░░░░░░░░░░░ 12%   13
  ...
  09:00 █████░░░░░░░░░░░░░░░ 24%   26
  10:00 ███████░░░░░░░░░░░░░ 33%   36
  11:00 ███████████████████░ 93%  101
  12:00 █████████████░░░░░░░ 66%   72
  ...
  16:00 ████████████████████ 100% 109
  17:00 ████████████████░░░░ 82%   89
  18:00 ████████████████████ 100% 108
  ...
  └────────────────────────────────────────────────

  ┌─ 最もアクティブな日 Top 5
  │
  日付         │ メッセージ数 │ アクティビティ
  ───────────┼──────────┼─────────────────────────────
  2026-03-15 │  523     │ █████████████████████████ 100%
  2026-03-12 │  498     │ █████████████████████████  95%
  2026-03-08 │  481     │ ████████████████████████░  92%
  2026-03-05 │  430     │ ████████████████████░░░░░  82%
  2026-03-01 │  398     │ ███████████████████░░░░░░  76%
  └────────────────────────────────────────────────

doctor.ts — barax 診断

// src/commands/doctor.ts
import { resolve } from 'node:path';
import { showBanner, showSubBanner } from '../ui/banner.js';
import { styledBox, statusLine, sectionHeader, sectionEnd, scoreDisplay } from '../ui/components.js';
import { styled, ICONS } from '../ui/theme.js';
import { checkProject } from '../lib/project-scanner.js';
import { loadConfig } from '../lib/config.js';

export async function doctorCommand(targetPath?: string, options?: { all?: boolean }): Promise<void> {
  showBanner(); showSubBanner('PROJECT DOCTOR');

  if (options?.all) {
    const config = loadConfig();
    if (!config.projects.length) {
      console.log(styledBox('登録済みプロジェクトなし。\nbarax init で作成してください。', 'warning', 'NO PROJECTS')); return;
    }
    for (const p of config.projects) printReport(checkProject(p.path), p.name);
    return;
  }

  const path = resolve(targetPath || process.cwd());
  console.log(`  ${ICONS.folder} 対象: ${styled.cyan(path)}\n`);
  printReport(checkProject(path));
}

function printReport(report: ReturnType<typeof checkProject>, name?: string): void {
  const title = name || report.projectPath.split('/').pop() || 'project';
  sectionHeader(`${title} ヘルスチェック`);
  for (const c of report.checks) console.log(statusLine(c.status, c.name, c.message));

  const ok = report.checks.filter(c => c.status === 'OK').length;
  const warn = report.checks.filter(c => c.status === 'WARN').length;
  const fail = report.checks.filter(c => c.status === 'FAIL').length;

  console.log(styledBox(
    `${styled.label('スコア:')} ${scoreDisplay(report.score)}\n\n` +
    `  ${ICONS.check} OK: ${styled.green(String(ok))}  ` +
    `${ICONS.warning} WARN: ${styled.yellow(String(warn))}  ` +
    `${ICONS.cross} FAIL: ${styled.pink(String(fail))}`,
    report.score >= 80 ? 'success' : report.score >= 50 ? 'warning' : 'error', 'SCORE'));
}
$ barax 診断 ~/projects/my-saas-app

  📁 対象: ~/projects/my-saas-app

  ┌─ my-saas-app ヘルスチェック
  │
  ✔ OK   CLAUDE.md                    存在します
  ✔ OK   CLAUDE.md プレースホルダー           未置換なし
  ✔ OK   .claude/ ディレクトリ              存在します
  ✔ OK   .claude/skills/              存在します
  ✔ OK   package.json                 存在します
  ✔ OK   package.json プレースホルダー        未置換なし
  ✔ OK   Git リポジトリ                    存在します
  ✔ OK   全ファイル {{PLACEHOLDER}}        未置換なし
  ✔ OK   .env ファイル                    存在します
  ⚠ WARN docker-compose.yml           見つかりません (不要な場合は問題なし)

╭ SCORE ─────────────────────────────────╮
│   スコア: 95/100                       │
│   ✔ OK: 9  ⚠ WARN: 1  ✖ FAIL: 0     │
╰────────────────────────────────────────╯

CLIエントリーポイント — 日本語エイリアスの秘密

🧑‍💻「最後にエントリーポイント。ここで .alias() を使って日本語コマンドを登録してる」

// src/index.ts
#!/usr/bin/env node
import { Command } from 'commander';
import { showBanner } from './ui/banner.js';
import { styled } from './ui/theme.js';
import { initCommand } from './commands/init.js';
import { registerHistoryCommand } from './commands/history.js';
import { doctorCommand } from './commands/doctor.js';

const program = new Command();
program
  .name('barax')
  .description(styled.cyan('BARAX') + styled.dim(' — Claude Code 日本語対応サイバーパンクCLI'))
  .version('1.0.0')
  .action(() => { showBanner(); program.help(); });

program.command('init').alias('初期化')
  .description('新規プロジェクトをテンプレートから初期化')
  .action(async () => {
    try { await initCommand(); }
    catch (err) {
      if ((err as Error).name === 'ExitPromptError') { console.log(styled.dim('\n  中断しました。')); process.exit(0); }
      throw err;
    }
  });

registerHistoryCommand(program);

program.command('doctor [path]').alias('診断')
  .description('プロジェクトのヘルスチェック')
  .option('-a, --all', '登録済み全プロジェクトをチェック')
  .action(async (path?: string, opts?: { all?: boolean }) => { await doctorCommand(path, opts); });

program.parseAsync(process.argv).catch((err) => {
  console.error(styled.pink(`\n  エラー: ${err.message}`)); process.exit(1);
});

🔰「.alias('初期化') って1行書くだけで日本語コマンドになるんですか?」
🧑‍💻「そう。Commander.js がヘルプ表示も init|初期化 って自動で出してくれる」


ビルド & 動作確認

cd ~/ccx
npm run build
chmod +x dist/index.js
npm link

🧑‍💻「これで barax コマンドがグローバルで使えるようになった。試してみて」

barax                              # バナー + ヘルプ
barax 初期化                       # プロジェクト初期化(対話式)
barax 履歴 検索 "認証"             # セッション検索
barax 履歴 検索 "API" --deep      # ディープ検索
barax 履歴 一覧                    # 最近のセッション
barax 履歴 統計                    # 使用統計ダッシュボード
barax 診断                         # カレントディレクトリ
barax 診断 ~/projects/my-app      # 指定パス

実行結果

barax 診断 の結果

$ barax 診断

  ┌─ my-saas-app ヘルスチェック
  │
  ✔ OK   CLAUDE.md                    存在します
  ✔ OK   CLAUDE.md プレースホルダー           未置換なし
  ✔ OK   .claude/ ディレクトリ              存在します
  ✔ OK   .claude/skills/              存在します
  ✔ OK   package.json                 存在します
  ✔ OK   package.json プレースホルダー        未置換なし
  ✔ OK   Git リポジトリ                    存在します
  ✔ OK   全ファイル {{PLACEHOLDER}}        未置換なし
  ✔ OK   .env ファイル                    存在します
  ⚠ WARN docker-compose.yml           見つかりません (不要な場合は問題なし)

  ╭ SCORE ─────────────────────────────────╮
  │   スコア: 95/100                       │
  │   ✔ OK: 9  ⚠ WARN: 1  ✖ FAIL: 0     │
  ╰────────────────────────────────────────╯

🔰「おー、95点」
🧑‍💻「docker-compose がないだけだから WARN 1個。実質パーフェクト」

barax 履歴 検索 "認証" の結果

$ barax 履歴 検索 "認証"

  🔍 検索キーワード: 認証
  ▸ Tier 1: sessions-index を検索中...
  ✔ 7件 ヒット

  ┌─ 検索結果 (7件)
  │
  日時          │ プロジェクト           │ ブランチ │ マッチ                          │ ソース
  ────────────┼──────────────────┼──────┼──────────────────────────────┼──────
  03/15 17:02 │ …/my-saas-app     │ main │ Next.jsフロントエンド実装計画...    │ IDX
  03/15 16:53 │ …/my-saas-app     │ main │ フロントエンド実装計画:認証...       │ IDX
  03/15 14:20 │ …/my-saas-app     │ main │ 認証フロー実装計画...              │ IDX
  ...

🔰「ミリ秒で7件出ましたね」
🧑‍💻「sessions-index は数KBだからね。--deep 付けたら history.jsonl も全文検索する」

barax 履歴 統計 の結果

$ barax 履歴 統計

  ╭ OVERVIEW ──────────────╮
  │  総セッション数:  128  │
  │  総メッセージ数:  4520  │
  ╰────────────────────────╯

  時間帯別アクティビティ
  00:00 ██████████░░░░░░░░░░ 48%   52
  11:00 ███████████████████░ 93%  101
  16:00 ████████████████████ 100% 109
  18:00 ████████████████████ 100% 108

  最もアクティブな日 Top 5
  2026-03-15 │  523 │ █████████████████████████ 100%
  2026-03-12 │  498 │ █████████████████████████  95%
  2026-03-08 │  481 │ ████████████████████████░  92%

🔰「16〜18時がピークなの草」
🧑‍💻「午後の集中タイムだね」


ハマりどころ

🔰「実装中にハマったところありました?」
🧑‍💻「いくつかある」

ESM で import パスに .js が必要
// NG — Module not found
import { styled } from './theme';

// OK
import { styled } from './theme.js';

🧑‍💻「TypeScriptソースでも .js 拡張子が必須。"module": "NodeNext" のルール」
🔰「…直感に反しますね」
🧑‍💻「慣れ」

as const の readonly 問題
const COLORS = { neonCyan: '#00FFFF' } as const;
const GRADIENTS = { banner: [COLORS.neonCyan, '#FF00FF'] } as const;

// NG — readonly 配列を mutable 引数に渡せない
gradient(GRADIENTS.banner);

// OK — スプレッドで mutable に変換
gradient([...GRADIENTS.banner]);
@inquirer/prompts v7 は ESM ネイティブ
// NG — レガシー inquirer(CommonJS)
import inquirer from 'inquirer';

// OK — ESM ネイティブ版
import { input, select, confirm } from '@inquirer/prompts';

🧑‍💻「レガシーの inquirer は CommonJS 前提で ESM プロジェクトだとエラーになることがある。@inquirer/prompts v7 を使え」

Ctrl+C で inquirer がエラーを投げる
try {
  await initCommand();
} catch (err) {
  // ユーザーが Ctrl+C で中断すると ExitPromptError が飛ぶ
  if ((err as Error).name === 'ExitPromptError') {
    console.log('中断しました。');
    process.exit(0);
  }
  throw err;
}

🧑‍💻「@inquirer/prompts は Ctrl+C を ExitPromptError 例外として投げる。キャッチしないとスタックトレースが表示されてしまう」


まとめ — BRAXの全体像

🔰「結局、BRAXで何が解決したんですか?」
🧑‍💻「3つ」

課題 Before After (BARAX)
プロジェクト初期化 テンプレートを手動コピー → {{PLACEHOLDER}} を1つずつ置換 barax 初期化 → 対話プロンプトで自動展開
セッション検索 grephistory.jsonl を漁る barax 履歴 検索 "認証" → 2層検索 + テーブル表示
設定の健全性 目視チェック(見落とし多発) barax 診断 → 10項目自動チェック + スコアリング

🔰「発展アイデアってあります?」
🧑‍💻「いくらでも」

コマンド案 機能
barax sync テンプレートリポジトリから最新を同期
barax skills Skills/Agents のカタログ管理
barax cost トークン使用量から月額コスト推定
barax export セッションを Markdown にエクスポート

🔰「じゃあまず barax 初期化 から使ってみます」
🧑‍💻「barax 診断 も忘れずに。プレースホルダー置換忘れは95%これで気づける」


おわりに

🔰「結局、これ何がよかったんですか?」
🧑‍💻「自分のワークフローの "面倒くさい" を、自分で殴りにいったこと」

Claude Code は強力だけど、その周辺——テンプレート管理、履歴検索、設定の健全性チェック——は自分で整備する領域です。

BARAX は770行の TypeScript で、3つの不満を3つのコマンドに変えました。

不満 コマンド かかった行数
テンプレ手動コピペ barax 初期化 ~210行
履歴が検索できない barax 履歴 検索 ~280行
設定ミスに気づけない barax 診断 ~180行

🧑‍💻「大事なのは BARAX そのものじゃない。Claude Code のデータ構造を理解して、自分の手で道具を作れるということ」
🔰「たしかに、sessions-index.json とか history.jsonl とか、今回初めて知りました」
🧑‍💻「それが一番の収穫。構造がわかれば、この記事のコードをベースに自分だけのツールをいくらでも作れる」

この記事のコードはすべてコピペで動きます。mkdir -p ~/ccx/src から始めて、あなただけの Claude Code 拡張 CLI を作ってみてください。

🔰「サイバーパンクUIじゃなきゃダメですか?」
🧑‍💻「好みでいい。でもターミナルがネオンに光ると、開発テンションは確実に上がる

1
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
1
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?