Obsidian の vault(Markdown ノートの集まり)に対して、ノートの起票・一覧・絞り込み・完了処理をコマンドラインからやる CLI を作って、10 か月ほど使っています。Rust 製で 12 ファイル・3,158 行、コミット 44 回。名前は snail(カタツムリ=社名)です。

リポジトリは公開しています: ymzkryo/snail-cli

うまく動いている話より、設計のほころびのほうが役に立つと思うので、そこを中心に書きます。結論を先に言うと、**いちばんたちが悪かったのは「成功したように見える失敗」**でした。

なぜ作ったか

Obsidian にはテンプレート機能があります。ただ、面倒が残るのはテンプレートの中身ではなくその前後でした。

  • ノートをどのディレクトリに置くか
  • ファイル名にどう日付を入れるか
  • プロジェクトノートの採番

毎回同じ frontmatter を書いて、同じ場所に置く。この作業が残ります。GUI を開いて、テンプレートを選んで、移動して、という手数が、思いついたことを書き留める障害になっていました。

GTD の語彙をそのままサブコマンドにした

$ snail --help   # Commands: の部分だけ抜粋
Commands:
  memo     Manage general memos
  todo     Manage todo tasks
  project  Manage projects
  gtd      GTD review and daily management

GTD(Getting Things Done)で運用しているので、memo / todo / project / gtd をそのまま並べています。自分の頭の中の分類と、コマンドの分類を一致させるのが狙いでした。この 4 つは 10 か月変えていないので、当たりだったと思います(fix というのを一度足して、4 日で消したことはあります)。

設計の基本:frontmatter を契約にする

CLI が読み書きするのは、基本的に frontmatter だけです。本文には踏み込みません。

---
title: タスクのタイトル
date: 2026-09-29
status: next
project: blog
estimate: 1h
---

status が next なら「今日やる」、someday なら「いつかやる」。本文は人間のもの、frontmatter は機械のものという線引きです。

たとえば todo list がやるのは、設定された4つのディレクトリ(INBOX / NEXTACTION / いつかやる / プロジェクト)を走査して、frontmatter の値で絞り込むことです。タスクの起票(todo new)は INBOX 固定(status: inbox)で、完了時にアーカイブへ移すところだけディレクトリを動かします。なおこのうち「いつかやる」を実際に拾えるようになったのはつい最近で、それも後で書きます。status に応じた振り分けそのものは、別の仕組み(GitHub Actions)に任せています。

この線引きのおかげで、CLI が想定する frontmatter の書式を保てば、本文は GUI から自由に編集できます。逆に言うと書式のほうは自由ではありません。frontmatter のパーサーは簡易実装で、たとえば複数行形式のタグ一覧は読めません。

「基本的に」と書いたのは、本文に書き込むのは gtd 系だけだからです(読むほうは一覧もやっています。タイトルを出すために本文の最初の # 見出しを見ます)。日報の ToDo セクションを読む・書く、週報のレビュー節を読んで書き戻す、といった処理です。そしてそのうち、日報に書き足すほうが壊れました(後述)。

普段使うテンプレートは外部ファイルにする

テンプレートは設定ファイルでパスを指すだけにしました。

# ~/.config/snail-cli/config.toml(抜粋・パスは簡略化しています)
[templates]
todo         = "~/dotfiles/.../snip-todo.md"
daily_report = "~/dotfiles/.../snip-daily-report.md"

[directories]
next    = "00100_NEXTACTION"
someday = "00500_いつかやる"

書式を変えたいときにCLI を再ビルドしなくて済むのが効きます。テンプレートは dotfiles の側に置いてあるので、そちらだけ直せば反映されます。

ただしテンプレートを外に出すと、CLI は「そこに何が書かれているか」を知らないまま動きます。これが後で効いてきます。

--format json が効いた

一覧にはフィルタと並び替えを付けました。

$ snail todo list -f "status:next" --sort due --format json

このうち --format json は、最初から機械に叩かせるために入れたものでした。とくに AI エージェントです。

正直に言うと、記事を書きながら履歴を確認して思い出しました。足したコミットのタイトルは feat: filter todo list for GTD Engage, and tag new notes で、GTD Engage は Claude Code のスキルの名前です。同じコミットの doc コメントにも for scripts and skills と書いてあり、そのスキル自体は同じ日の 4 時間後に入っています。

--format <FORMAT>
    Output format; json skips the interactive prompt

対話プロンプトを飛ばすのも、スキルから叩くために要った性質です。text 出力は最後に「開くファイルを選べ」の入力待ちに入るので、機械から呼ぶと止まります。いまはスキルが snail todo list -f "status:next" --sort due --format json を叩いて「今日やるべきタスク」を組み立てています。

doc コメントに for scripts and skills とあるとおり、スクリプトとスキルを同じ口で賄うつもりで足していました。実際、分けて設計する必要はありませんでした。機械から呼べるようにする、という一段の抽象では同じものだったからです。

いちばん使うのは todo done

$ snail todo done "path/to/note.md"
Marked as done: .../00100_NEXTACTION/2026-09-09-....md
Archived to: .../99999_アーカイブ/99991_task/2026-09-09-....md

status を done にして、そのままアーカイブへ移動します。「完了にする」と「片付ける」を 1 コマンドにまとめました。

地味ですが、これが一番効いています。2 手に分かれていると、status だけ変えて移動を忘れる日が必ず出るからです。

Rust を選んだ理由

起動が速いことだけです。1 日に何十回も叩くので、ここが体感に直結します。凝ったことはしていないので、言語の機能を使い倒しているわけではありません。

残っている 3 つのほころび

10 か月使って出てきた問題のうち、次の 3 つは v0.3.2 の今も残っています(直す予定です)。別に、直し終えたものが 1 つあるので、そちらは後ろに分けました。

1. フィルタの値が検証しきれていない

フィルタの検証は、実はちゃんと書いてあります。

$ snail todo list -f "zzzkey:whatever"
Error: unknown filter key: "zzzkey"
  known keys: status, project, context, due, review
$ echo $?
1

キー名が未知なら、既知のキーを並べて exit 1 で落ちます。due: に notadate のような形式からして日付でない値を渡したときも同じです。ここは気持ちよく動きます。

受理されるキーワードは today / overdue / reached / missing、それに missing の別名として none です。ただしエラーメッセージは none を案内しません(expected … one of: の一覧に入っていない)。これも小さな「ドキュメントと実装のずれ」で、記事を書くために叩いて初めて気づきました。

抜けているのは値のほうです。 しかも 2 か所あります。

まず日付。形式のチェックは通るのに、存在しない日付が素通りします。

$ snail todo list -f "due:2026-99-99" --format json
[]
$ snail todo list -f "due:2026-02-30" --format json
[]

判定はこれだけでした。

fn is_date(s: &str) -> bool {
    let parts: Vec<&str> = s.split('-').collect();
    parts.len() == 3
        && [4, 2, 2] == [parts[0].len(), parts[1].len(), parts[2].len()]
        && parts.iter().all(|p| p.chars().all(|c| c.is_ascii_digit()))
}

桁数と数字かどうかだけです。99 月も 2 月 30 日 も通ります。これも記事を書きながら叩いて気づきました。

そしてもう 1 か所、自由文字列を取るキーの値です。

$ snail todo list -f "project:ZZZ_NOT_EXIST"
No active todos found.
$ echo $?
0

project のように自由文字列を取るキーは、存在確認をしません(status と context も同じで、status:nxt も黙って 0 件になります)。つまり タイポと「本当に 0 件」が区別できません。-f "project:blg" と打ち間違えたときに「そのプロジェクトのタスクは無い」と読んでしまいます。

しかもヘルプにはこう書いてあります。

Unknown keys and values are errors, not empty results.

「未知のキーと値はエラーになる。空の結果にはならない」。 キーについては本当です。でも values と書いてあるのに、値のほうは素通りします。ヘルプのほうが実装より広く約束していました。

ソースのコメントのほうは、もっとはっきり約束していました。

/// Unknown keys and unsupported values are errors rather than silently
/// ignored, so a filter that cannot work never looks like "0 results".

「動かないフィルタが『0 件』に見えることは決してない」。 存在しないプロジェクト名はどのノートにも絶対にマッチしません。つまり project:blg は動かないフィルタで、それが 0 件に見えています。自分がコメントで約束したことを、自分で破っていました。

実は、これは 2 回目です。 この検証を足したコミット(2026-09-06)のメッセージに、こう書いてありました。

project: was documented but never implemented, context: was ignored, and both silently returned every todo. Unknown keys and unsupported values were dropped without a word, so a broken filter looked like a working one.

「壊れたフィルタが、動いているフィルタに見えた」。 当時すでに同じ失敗を踏んで、同じ言葉で書いていたわけです。project: にいたっては、ヘルプに載っているのに実装すらありませんでした。

そこでキーの検証と、日付の形式チェックを入れました。ところが日付の中身も自由文字列も手つかずのまま、ヘルプには「値もエラーになる」と書きました。

症状は逆です。1 回目は全件が返り、2 回目は 0 件が返ります。それでも ヘルプが実装より先に約束していた点は同じでした。1 回目は実装していないものを書き、2 回目は半分しか直していないのに全部直したと書いた。

直したぶんだけドキュメントを進める、それだけの話でした。ところがヘルプを読んだ人ほど「空なら本当に 0 件だ」と信じます。

自分で叩いているときは気づきます。困るのは --format json で機械から呼ぶときです。人間なら「あれ、あるはずだけど」と思う場面が、黙って空配列で通ります。

キー名の検証を書いたときに、値のほうは「形式が合っていればいい」「自由文字列だから」で済ませていました。検証を書いた気になっていたわけです。

2. 成功したように見える失敗

冒頭で予告したのがこれです。snail gtd today add "タスク" は日報の ToDo セクションに 1 行足すコマンドなのですが、タスクを足さないまま「成功」と出力します。

原因は if の 1 行です。

if line.trim() == "## TODO" {

## TODO を完全一致で探しています。 ところが実際の日報の見出しはこうです。

## ✅ 今日のToDo

ここで話がつながります。 CLI に内蔵してある既定テンプレートの見出しは、ちゃんと ## TODO です。

"---\ndate: {}\n---\n\n# {} Daily Report\n\n## TODO\n\n## Done\n\n## Memo\n",

(ソースに埋め込んである文字列そのままです。)

ところがこれは、実運用では一度も使われません。 このテンプレートが発火するのは「設定したテンプレートファイルが見つからないとき」だけです。私の環境ではファイルがあるので、こちらには来ません。

そして設定で指しているテンプレートを開いたら、ToDo セクションがありませんでした。## 💼 work や ## 🍚 meal は並んでいるのに、ToDo の見出しが 1 つもない。履歴を全部当たっても、このファイルに ## TODO が存在したことは一度もありません。

では実際の日報にある ## ✅ 今日のToDo は誰が書いているのか。第三のファイルでした。別のテンプレートに書かれていて、それを vault 側の GitHub Actions が日報テンプレートと合成しています。CLI はその存在を知りません。

整理すると、こうです。

誰が何を決めているか
CLI のコード## TODO を決め打ちで探す
CLI が指すテンプレートToDo セクションを持たない
別のテンプレート+vault 側の仕組み実際の見出し ## ✅ 今日のToDo を決める

契約の当事者が 3 つに分かれていて、誰も全体を見ていませんでした。

そして、同じファイルの読む側はこうなっています。

// Match various TODO section headers
let line_lower = line.to_lowercase();
if line.starts_with("## ") && (line_lower.contains("todo") || line.contains("ToDo")) {

## で始まって todo を含めば通す、というゆるい判定です。コメントに「various TODO section headers」とあるとおり、見出しが揺れることを見越しています。だから gtd today list は ## ✅ 今日のToDo をちゃんと読めます。

履歴を見ると、順番はこうでした。

2025-11-21別のテンプレートに ## ✅ 今日のToDo が入る
2025-12-01snail-cli の初期コミット。書く側が == "## TODO" の完全一致で入る
2025-12-31gtd today list を実装。読む側を最初からゆるく書く

1 行目に気づいたときは、さすがに笑ってしまいました。書く側を書いた 10 日前から、実環境の見出しはすでに ## ✅ 今日のToDo だったのです。

つまり私の環境では、初日から一度も通っていません。手元の日報 510 件を調べたら、## TODO という見出しを持つものは 1 件もありませんでした。「壊れた」のではなく「最初から通っていなかった」わけです(内蔵テンプレートで日報が作られた環境なら動くはずなので、あくまで私の環境の話です)。そして 1 か月後に読む側を書いたときは、見出しが揺れることを見越してゆるく実装しました。あとから書いた側だけが実環境に合っていて、先にあった書く側は誰も見直さなかったわけです。

一覧では ToDo が見えているのに、追加だけが黙って失敗する。片方を書くときに、対になるもう片方を見ていませんでした。

話を仕組みに戻します。見出しが一致しないとセクションが見つからず、挿入位置が決まりません。するとタスクを足さないまま書き戻します。lines() で分解して join("\n") で組み直すだけなので、元のファイルとの差は末尾の改行が落ちるくらいです。

そして書き込む側はこうなっています。

    fs::write(&file_path, updated_content)
        .with_context(|| format!("Failed to update daily report: {:?}", file_path))?;

    println!("Added task to daily report: {}", task);

書き込みのエラー処理はちゃんと書いてあります。 with_context でメッセージまで付けている。それでも、足せたかどうかは見ていません。println! が無条件です。

本質は戻り値の型でした。挿入する関数のシグネチャはこうです。

fn add_to_todo_section(content: &str, task: &str) -> String {

String を返すだけで、「見つからなかった」を伝える手段がありません。 関数の中には task_added というフラグがあります(1 回だけ挿入するための制御です)が、関数の外へは返していません。呼び出し元の today_add は Result を返す形にしてあるのに、そこへ伝わる経路が無い。エラーハンドリングを書いたつもりで、判定の結果を運ぶところを書き忘れている形でした。

静かに失敗するより、成功したと言って失敗するほうが悪いと思い知りました。

ただし人間が叩いたときは、実は気づける経路があります。このコマンドは成功メッセージのあとエディタを開くので、日報がその場で目の前に出るからです。開いてみれば足されていないことが分かります。

一方スクリプトやエージェントから呼ぶと、成功表示を出したあとエディタ(vim)で止まります。open_editor が終了を待つ実装なので、そこから先に進みません。仮に抜けられたとしても、手元に残るのは成功表示だけです。穴が 2 つ重なっているわけです。重なっているもう一方が、次のほころびそのものです。

しかも、3 週間前に気づいていました

書いていて一番きまりが悪かったのはここです。この不一致は 2026-09-06 に発見して、メモに残してありました。

日報の見出しは ## ✅ 今日のToDo だが snail 側は ## TODO を決め打ちで探しているので、何も追記されない。しかも Added task to daily report: ... と成功したように出力する

自分の言葉で、症状まで正確に書いてあります。そのうえで 3 週間直していません。 記事を書くために調べ直して、初めて「まだ直っていないのか」と気づいた格好です。

「成功したように見える失敗」がこわいのは、気づいた後ですら手を動かす動機が湧きにくいからかもしれません。落ちていれば直します。動いているように見えるものは、後回しになります。

3. エディタを止める手段が揃っていない

いま触れた「エディタを開く」挙動そのものが、3 つめです。作った当初は、ノートを起票したら続けて書くのが当然だと思っていたので、生成後に必ずエディタを開く実装にしました。

スクリプトから呼ぶとそこで止まります。あとから -n, --no-edit を足して回避しました。

$ snail todo new --help
    -n, --no-edit            Do not open editor after creating

ただし足したのは memo new / todo new / project new の 3 つ、つまり「起票する」コマンドだけです。gtd today add には今も付いていません(snail gtd today --help にオプションが出てきません)。

自分がスクリプトから呼んで困ったところだけ塞いだことが、そのまま残っています。

直し終えたもの:00500_いつかやる を見ていなかった

これは 5 日前(2026-09-24)に見つけて直したものです。snail todo list が 00500_いつかやる/ ディレクトリを走査していませんでした。

面白いのは、設定には最初から入っていたことです。someday_dir() という関数が config にあるのに、一覧を組み立てる側がそれを呼んでいませんでした。使われないまま 10 か月経っていたわけです。

直したのは 60 行ほどの変更でした(PR #11)。

これは上の 3 つとは種類が違います。自分の運用が後から変わったことが原因です。作った当初は「いつかやる」を CLI で見る発想がなく、あとから GTD の回し方を変えて someday を定期的に見直すようになりました。道具の側がついてきていなかった。

運用を変えたあとも、CLI の走査対象を見直していませんでした。

ほかに残っているもの

crates.io には公開していません。 というより、snail という名前は他の方が先に取っています(ネットワーク管理ツールだそうです)。公開するなら別名を考えるところから始まります。

一方で GitHub Releases はちゃんと出ていました。v0.1.0 から v0.3.2 まで 9 本あり、どれにも Linux 向けバイナリが付いています。ワークフローを入れたのは最初のタグの直前(2026-03-02)でした。

それなのに README の Installation はこうです。

# Clone the repository
cd ~/PROJECTS/snail/snail-cli

# Build the project
cargo build --release

git clone のコマンドがありません。 コメントだけ書いて、その下は自分のローカルパスに cd しています。リリースを出しているのに案内していないうえ、書いてある手順もそのままでは動かない。ここも「ドキュメントが実装に追いついていない」側の話でした。

空実装が 3 つ残っています

memo search / project show / gtd monthly は、not yet implemented と表示するだけです。todo list のフィルタで足りてしまったので、埋めないまま 10 か月経ちました。

それでも、起動が速くて自分の運用にぴたりと合う道具が手元にあるのは快適です。費用対効果で言えば、作ってよかったと思っています。ほころびを見つけるたびに直せるのも、自分で書いたものの利点ではあります。

まとめ

  • GTD の語彙をそのままサブコマンドにしたのは 10 か月変えていません。自分の頭の中の分類とコマンドの分類が一致していると、迷いません
  • 既存ノートの更新は、原則 frontmatter に限る。 CLI が想定する書式を保つかぎり、本文は GUI から自由に編集できます。逆に、本文に書き込んでいた gtd 系で壊れました
  • 契約の当事者が 3 つに分かれていました。 コードは ## TODO を決め打ち、CLI が指すテンプレートには ToDo セクションが無く、実際の見出しは第三のファイルが決めている。片方を書くときに、対になるもう片方を見ていませんでした
  • --format json は、スクリプトとスキルを同じ口で賄うつもりで足しました。 「エージェント用」と「スクリプト用」を分けて設計する必要はありませんでした
  • ヘルプに書いた保証は、実装より強く信じられます。 「未知の値はエラーになる」と書いておいて、存在しない日付も自由文字列も素通りさせていました。しかも私はこれを 2 回やりました。直したぶんだけドキュメントを進める、それだけの話でした
  • 「成功したように見える失敗」が一番こわい。 呼び出し元が Result を返す形にしていても、判定した結果を運ぶ経路が無ければ意味がないです。そして気づいた後ですら直していませんでした(3 週間)。落ちるものは直すのに、動いて見えるものは後回しになります
  • 自分で困ったところだけ塞ぐと、同じ穴が別のコマンドに残ります(--no-edit を new 系 3 つにだけ足して、gtd today add を忘れた)
  • 運用を変えたら、一覧が拾う対象も確認する。 設定に関数だけあって呼ばれていない、という形で 10 か月眠っていました

持ち帰りを 1 つに絞るなら、成功を表示する前に、目的の変更ができたかを判定する。これだけで今回のいちばん痛い穴は塞がります。