Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 26 additions & 21 deletions .tmp/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,45 +193,50 @@
- 検証: ID 指定でノートが返る
- [x] T10.10 `status()` ツール登録(インデックス状態・キュー残数・最新エラー)
- 検証: 戻り値スキーマのテスト
- [ ] T10.11 Claude Desktop 実機接続テスト(設定例ドキュメント込み)
- [x] T10.11 Claude Desktop 実機接続テスト(設定例ドキュメント込み)
- 検証: Claude Desktop の MCP 設定に登録して `save_memory` が呼べる

---

## T11. CLI (F-8)

- [ ] T11.1 CLI フレームワーク選定と導入(候補: Citty / Commander / 自前
- 検証: `dennoh --help` が表示される
- [ ] T11.2 `dennoh init` 統合(T1.4 を CLI から呼ぶ)
- [ ] T11.3 `dennoh serve` 統合(T10.2)
- [ ] T11.4 `dennoh add "<text>"` 実装(stdin パイプ対応)
- [x] T11.1 CLI フレームワーク選定と導入(自前ディスパッチャを `src/cli/main.ts` に実装
- 検証: `dennoh --help` が表示される(バイリンガル対応)
- [x] T11.2 `dennoh init` 統合(T1.4 を CLI から呼ぶ)
- [x] T11.3 `dennoh serve` 統合(T10.2)
- [x] T11.4 `dennoh add "<text>"` 実装(stdin パイプ対応)
- 検証: `echo hello | dennoh add` でメモが保存される
- [ ] T11.5 `dennoh update <id> "<text>"` 実装(stdin パイプ対応)
- 補足: パイプ判定は `process.stdin.isTTY !== true`(実パイプでは `isTTY` が `undefined` で `=== false` では拾えないため)
- [x] T11.5 `dennoh update <id> "<text>"` 実装(stdin パイプ対応)
- 検証: 既存ノートが更新
- [ ] T11.6 `dennoh delete <id>` 実装
- [ ] T11.7 `dennoh search "<query>" [--project X] [--tag Y] [--limit N] [--json]`
- 検証: 表形式と JSON 両方で結果が出る
- [ ] T11.8 `dennoh get <id> [--json]`
- [ ] T11.9 `dennoh recent [--limit N] [--json]`
- [ ] T11.10 `dennoh status` 実装
- [ ] T11.11 `dennoh reindex` 実装(T4.5 を CLI から)
- [ ] T11.12 `dennoh history <id>` / `dennoh restore <id> <commit>` 統合
- [ ] T11.13 `dennoh config get/set/list` 統合
- [ ] T11.14 終了コード規約(成功 0、ユーザーエラー 1、内部エラー 2)
- 検証: 不正引数で 1、未捕捉例外で 2
- [ ] T11.15 `--help` / コマンド別ヘルプの i18n 対応
- [x] T11.6 `dennoh delete <id>` 実装
- [x] T11.7 `dennoh search "<query>" [--project X] [--tag Y] [--limit N] [--json]`
- 検証: 1行1件の一覧表示と JSON 両方で結果が出る
- [x] T11.8 `dennoh get <id> [--json]`
- [x] T11.9 `dennoh recent [--limit N] [--json]`
- [x] T11.10 `dennoh status` 実装
- [x] T11.11 `dennoh reindex` 実装(T4.5 を CLI から)
- [x] T11.12 `dennoh history <id>` / `dennoh restore <id> <commit>` 統合
- [x] T11.13 `dennoh config get/set/list` 統合
- [x] T11.14 終了コード規約(成功 0、ユーザーエラー 1、内部エラー 2)
- 検証: 不正引数・ID不存在・バリデーションで 1、DB接続失敗・予期しない例外で 2(`src/cli/types.ts` に `EXIT_SUCCESS`/`EXIT_USER_ERROR`/`EXIT_INTERNAL_ERROR` を定義し全コマンドに適用)
- [x] T11.15 `--help` / コマンド別ヘルプの i18n 対応
- 検証: `DENNOH_LANG=en dennoh --help` で英語表示

---

## T12. i18n (4.7)

> **現状メモ**: CLI のメッセージは `status.ts` 由来の「各コマンドファイル内 `MESSAGES: Record<Lang, {...}>`」方式で日英対応済み(add/update/delete/get/search/recent/reindex + main の usage/不明コマンド)。集中辞書(`src/i18n/ja.ts`/`en.ts`)方式は未導入のため T12.1/T12.3 は別途リファクタが必要。history/restore/serve/config は英語のまま(未 localize)。

- [ ] T12.1 メッセージ辞書(`src/i18n/ja.ts`, `src/i18n/en.ts`、フラットキー構造)
- 検証: ja/en 両方が同じキー集合を持つ単体テスト
- [ ] T12.2 言語解決ロジック(環境変数 > 設定ファイル > 既定 `ja`)
- 検証: 優先度のテスト
- 注: 現状は集中辞書ではなくコマンド単位の `MESSAGES` 定数で実装(要件の辞書方式は未着手)
- [x] T12.2 言語解決ロジック(環境変数 > 設定ファイル > 既定 `ja`)
- 検証: 優先度のテスト(`src/config` の `resolveLang()`: `DENNOH_LANG` > config.lang > 既定 `ja`)
- [ ] T12.3 CLI 全コマンドが辞書経由でメッセージ出力
- 検証: ハードコード文字列が無いことを grep で検出するテスト
- 注: 新規 7 コマンド + main は日英対応済みだが、集中辞書経由ではなく、また history/restore/serve/config は未 localize
- [ ] T12.4 MCP ツール description が辞書経由
- 検証: T10.11 と同じ

Expand Down
85 changes: 85 additions & 0 deletions src/cli/commands/add.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import type { Database } from "bun:sqlite";

import { readStdin } from "@/cli/stdin";
import {
type CliIO,
EXIT_INTERNAL_ERROR,
EXIT_SUCCESS,
EXIT_USER_ERROR,
readError,
} from "@/cli/types";
import { type Lang, readConfig, resolveLang } from "@/config";
import { saveMemory } from "@/core/memory";
import { ContentValidationError } from "@/core/validate";
import { closeDatabase, openDatabase, runMigrations } from "@/db";

// Bilingual strings this command emits directly. Errors thrown by the core
// (config/validation/git) carry their own message and are rendered via
// readError; only the command-level help text is localized here, mirroring the
// status.ts pattern.
const MESSAGES: Record<Lang, { usage: string }> = {
ja: { usage: '使い方: dennoh add "<本文>"\n' },
en: { usage: 'Usage: dennoh add "<text>"\n' },
};

// `dennoh add "<text>"` — create a new note from its sole argument, or from
// stdin when no argument is given and the input is piped. This is a thin
// wrapper over `saveMemory`; all the file → DB → git work lives in core.
export async function addCommand(args: string[], io: CliIO): Promise<number> {
const messages = MESSAGES[resolveLang()];

// Prefer the positional argument. Only fall back to stdin when content was
// not passed AND stdin is not an interactive terminal (i.e. piped or
// redirected). `isTTY` is `true` only for a real TTY and `undefined` for a
// pipe — never `false` — so the gate is `!== true`, not `=== false`. Reading
// stdin on a TTY would block forever waiting for an EOF the user has no
// reason to send, so that case is a usage error instead.
let content: string;
const arg = args[0];
if (arg !== undefined) {
content = arg;
} else if (process.stdin.isTTY !== true) {
content = await readStdin();
} else {
io.stderr(messages.usage);
return EXIT_USER_ERROR;
}

let vaultPath: string;
try {
vaultPath = readConfig().vaultPath;
} catch (e) {
// Missing/invalid config ("run init first") is user-actionable, not an
// internal malfunction.
io.stderr(`${readError(e)}\n`);
return EXIT_USER_ERROR;
}

// openDatabase creates the .dennoh dir and opens SQLite, closing itself on
// internal failure; handle that before the try/finally so closeDatabase
// never runs on an unopened handle. A failed open is an environmental
// problem, so it exits with the internal-error code.
let db: Database;
try {
db = openDatabase(vaultPath);
} catch (e) {
io.stderr(`${readError(e)}\n`);
return EXIT_INTERNAL_ERROR;
}

try {
// runMigrations is idempotent — it covers the brand-new-vault case where
// `add` is the first command to touch the database.
runMigrations(db);
const id = await saveMemory(db, vaultPath, content);
io.stdout(`${id}\n`);
return EXIT_SUCCESS;
} catch (e) {
io.stderr(`${readError(e)}\n`);
// A rejected content (empty/oversize/binary) is the caller's mistake;
// anything else escaping here is unexpected and counts as internal.
return e instanceof ContentValidationError ? EXIT_USER_ERROR : EXIT_INTERNAL_ERROR;
} finally {
closeDatabase(db);
}
}
85 changes: 85 additions & 0 deletions src/cli/commands/delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import type { Database } from "bun:sqlite";

import {
type CliIO,
EXIT_INTERNAL_ERROR,
EXIT_SUCCESS,
EXIT_USER_ERROR,
isNotFoundError,
readError,
} from "@/cli/types";
import { type Lang, readConfig, resolveLang } from "@/config";
import { deleteMemory } from "@/core/memory";
import { closeDatabase, getNoteById, openDatabase, runMigrations } from "@/db";

const MESSAGES: Record<
Lang,
{ usage: string; notFound: (id: string) => string; success: (id: string) => string }
> = {
ja: {
usage: "使い方: dennoh delete <id>\n",
notFound: (id) => `メモが見つからないか、既に削除されています (id=${id})\n`,
success: (id) => `削除しました ${id}\n`,
},
en: {
usage: "Usage: dennoh delete <id>\n",
notFound: (id) => `note not found or already deleted (id=${id})\n`,
success: (id) => `deleted ${id}\n`,
},
};

// `dennoh delete <id>` — soft-delete a note (remove the file, stamp deleted_at,
// record a git commit). A thin wrapper over `deleteMemory`.
export async function deleteCommand(args: string[], io: CliIO): Promise<number> {
const messages = MESSAGES[resolveLang()];

const id = args[0];
if (!id) {
io.stderr(messages.usage);
return EXIT_USER_ERROR;
}

let vaultPath: string;
try {
vaultPath = readConfig().vaultPath;
} catch (e) {
io.stderr(`${readError(e)}\n`);
return EXIT_USER_ERROR;
}

let db: Database;
try {
db = openDatabase(vaultPath);
} catch (e) {
io.stderr(`${readError(e)}\n`);
return EXIT_INTERNAL_ERROR;
}

try {
runMigrations(db);

// Pre-check existence so an unknown or already-deleted id is a user error,
// not a generic throw out of deleteMemory that would read as internal.
if (getNoteById(db, id) === null) {
io.stderr(messages.notFound(id));
return EXIT_USER_ERROR;
}

await deleteMemory(db, vaultPath, id);
io.stdout(messages.success(id));
return EXIT_SUCCESS;
} catch (e) {
// The pre-check covers the common missing-id case; this guards the TOCTOU
// race where the note is deleted between that check and deleteMemory. Only
// a genuine not-found maps to a user error — a mid-delete failure (e.g. a
// git error after the soft delete) does not match and stays internal.
if (isNotFoundError(e)) {
io.stderr(messages.notFound(id));
return EXIT_USER_ERROR;
}
io.stderr(`${readError(e)}\n`);
return EXIT_INTERNAL_ERROR;
} finally {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
closeDatabase(db);
}
}
95 changes: 95 additions & 0 deletions src/cli/commands/get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type { Database } from "bun:sqlite";

import { takeBooleanFlag } from "@/cli/flags";
import {
type CliIO,
EXIT_INTERNAL_ERROR,
EXIT_SUCCESS,
EXIT_USER_ERROR,
readError,
} from "@/cli/types";
import { type Lang, readConfig, resolveLang } from "@/config";
import { getNote } from "@/core/memory";
import { closeDatabase, openDatabase, runMigrations } from "@/db";

// Only the help and not-found prose is localized. The frontmatter field labels
// in the human-readable view (`id:`, `created:`, …) are language-neutral data
// keys — like a YAML dump — so they stay constant in both languages.
const MESSAGES: Record<Lang, { usage: string; notFound: (id: string) => string }> = {
ja: {
usage: "使い方: dennoh get <id> [--json]\n",
notFound: (id) => `メモが見つかりません (id=${id})\n`,
},
en: {
usage: "Usage: dennoh get <id> [--json]\n",
notFound: (id) => `note not found (id=${id})\n`,
},
};

// `dennoh get <id> [--json]` — print a single note. The default rendering is a
// human-readable header (id + frontmatter fields) followed by the body; with
// `--json` the whole NoteRead object is emitted verbatim for tooling. A thin
// wrapper over `getNote`, which resolves the on-disk path through the DB.
export async function getCommand(args: string[], io: CliIO): Promise<number> {
const messages = MESSAGES[resolveLang()];

const { present: json, rest } = takeBooleanFlag(args, "--json");
const id = rest[0];
if (!id) {
io.stderr(messages.usage);
return EXIT_USER_ERROR;
}

let vaultPath: string;
try {
vaultPath = readConfig().vaultPath;
} catch (e) {
io.stderr(`${readError(e)}\n`);
return EXIT_USER_ERROR;
}

let db: Database;
try {
db = openDatabase(vaultPath);
} catch (e) {
io.stderr(`${readError(e)}\n`);
return EXIT_INTERNAL_ERROR;
}

try {
runMigrations(db);
// getNote returns null for an unknown or soft-deleted id (the DB lookup
// filters `deleted_at IS NULL`); both collapse into one "not found".
const result = await getNote(db, vaultPath, id);
if (result === null) {
io.stderr(messages.notFound(id));
return EXIT_USER_ERROR;
}

if (json) {
io.stdout(`${JSON.stringify(result, null, 2)}\n`);
return EXIT_SUCCESS;
}

// Human-readable form: id + frontmatter fields, a blank line, then the
// body exactly as stored. projects/tags are joined with ", " so an empty
// list renders as an empty value rather than "[]".
const { frontmatter, body } = result;
io.stdout(`id: ${result.id}\n`);
io.stdout(`created: ${frontmatter.createdAt}\n`);
io.stdout(`updated: ${frontmatter.updatedAt}\n`);
io.stdout(`source: ${frontmatter.source}\n`);
io.stdout(`projects: ${frontmatter.projects.join(", ")}\n`);
io.stdout(`tags: ${frontmatter.tags.join(", ")}\n`);
io.stdout(`\n${body}`);
if (!body.endsWith("\n")) {
io.stdout("\n");
}
return EXIT_SUCCESS;
} catch (e) {
io.stderr(`${readError(e)}\n`);
return EXIT_INTERNAL_ERROR;
} finally {
closeDatabase(db);
}
}
Loading