本文へ移動
hanzochang

herdrアプリのmodとは|入れ方、作り方、入れる前に確かめること

公開

herdrのデスクトップアプリのmodは、窓のパネルやメニューを描くJavaScriptで、使う人と同じ権限でコマンドを動かし、ネットにつなぎます。herdr.dev/modsに並ぶmodをエージェントに入れてもらう手順、入れる前に中身のどこを読むか、自分で小さなmodを作って確かめるまでを、Herdrアプリ0.1.14で試した内容でまとめます。

はじめに

herdrのデスクトップアプリを使い始めて、herdr.dev/mods をのぞいたとき、次のようなところで手が止まるはずです。

  • 一覧には数百のmodが並んでいるのに、入れるボタンもコマンドも見当たらない
  • 知らない人が書いたmodを、そのまま自分のアプリに入れてよいのかわからない
  • 自分でも作ってみたいが、何で書くのか、どこに置くのかがわからない
  • 作ったmodのフォルダを置いたのに、窓に出てこない

手が止まる原因は、modが、アプリの窓を描くプログラムで、しかも使う人と同じ権限で動くことにあります。入れるのも作るのも、Claude CodeやCodexのようなエージェントに頼む前提で作られていて、そのぶん中身を確かめる役目も人とエージェントに任されています。

この記事では、2026年11月9日にHerdrアプリ0.1.14を手元に入れ、アプリが書き出す説明ファイルと公式リポジトリの説明を読み、自分で小さなmodを作って表示させた内容をまとめます。アプリそのものの紹介はherdrのデスクトップアプリの記事に書いています。

herdrアプリのmod

modは、Herdrアプリの窓にパネル、メニュー、ダイアログを描く小さなJavaScriptのプログラムです。~/.config/herdr-app/mods/ の下にフォルダを1つ作り、その中に main.js と mod.json を置きます。

画面の左にあるスペースの一覧や、エージェントの一覧、タブバー、通知も、初めから入っているmodです。手元の0.1.14では、mods フォルダに spaces、agents、tabs、files、changes など21個のフォルダが入っていました。どれも自分のmodと同じ部品で作られているので、作り方の見本として読めます。

レイアウトとの分担は、レイアウトが窓のどこに何を置くかを決め、modがその枠の中を描く、という形です。

書くのはJavaScriptですが、HTMLとCSSは使えません。アプリの説明ファイルによると、modはgpui-kitという仕組みで、div() や v_flex() のような関数をつないで画面の形を書き、それをアプリがネイティブの部品として描きます。Webページの中身をそのまま持ち込むことはできず、setTimeout も使えません。

公開されているmodを入れる手順

GitHubで herdr-mod のタグが付いたリポジトリは、11月9日の時点で484件ありました。レイアウトの herdr-layout は273件です。タグを付けて公開すると、1時間以内にherdr.dev/modsに載る仕組みです。

入れるためのボタンやコマンドは、アプリにありません。Herdrの中で起動したエージェントに、modのURLを渡して頼みます。

text
Add herdr.dev/mods/<作者>/<名前>
text
Add herdr.dev/mods/<作者>/<名前>

GitHubのフォルダのURLを渡しても構いません。頼まれたエージェントが従う手順は、アプリが ~/.config/herdr-app/AGENTS.md に書き出しています。要点を日本語にすると次のとおりです。

  1. RECIPE.mdとすべてのファイルを読む。入れる前に、modがネットにつなぐか、プログラムを動かすか、自分のフォルダの外のファイルを読むかを人に伝える
  2. modのフォルダを mods/ にコピーする。名前がすでに使われていたら、上書きせずに新しい方の名前を変える
  3. 手元の環境に合わない部分(入っていないコマンド、名前の重なり)だけを直す
  4. modに keys.json が付いていれば、自分の keys.json に足す。すでに使っているキーを置き換えるときは人に聞く
  5. herdr-app check で読み込めるかを確かめる

1番目で、エージェントから「このmodはネットにつなぎます」のような報告があるはずです。報告がないまま入れ終わったら、次の章の確かめ方で自分でも見てください。

外すときは、mods/ の下のフォルダを消します。キーを足していれば、keys.json からも消します。

入れる前に確かめること

公式リポジトリのsecurity.mdには、modは囲いの中で動かず、使っている人と同じことができると書かれています。コマンドを動かす、URLにつなぐ、ファイルを読む、つないだ別のマシンでコマンドを動かす、のどれも、実行のたびに確認を求められることはありません。説明には、シェルスクリプトと同じつもりで扱うよう書かれています。

ふだんエージェントにコマンドを動かす許可を細かく出している人ほど、この違いに気をつけてください。modはエージェントの許可の仕組みの外で、アプリの一部として動きます。

中身のどこを読むか

modが使うアプリの機能は、herdr という名前から読み込まれます。main.js の先頭の import の行と、次の名前を探すと、そのmodが何をするかの見当がつきます。

表
探す名前すること
execコマンドを動かす。on が付いていれば、つないだ別のマシンで動かす
fetchURLにつなぐ
hcherdr-coreに命令する。ターミナルの文字を読む、文字を送る
readTextFile、fs/promisesファイルを読む
writeTextFileファイルを書く
表
探す名前すること
execコマンドを動かす。on が付いていれば、つないだ別のマシンで動かす
fetchURLにつなぐ
hcherdr-coreに命令する。ターミナルの文字を読む、文字を送る
readTextFile、fs/promisesファイルを読む
writeTextFileファイルを書く

exec が見つかったら、そのすぐ後ろの1つ目の値が、動かすコマンドの名前です。git なのか sh なのかで、読む深さを変えます。sh -c に文字列を渡していれば、その文字列の中身まで読みます。

公開されているmodを読んだ例

星の多いmodを2つ、手元にリポジトリを取ってきて読みました。アプリには入れていません。

VS CodeのGit Graphに似た画面を出すherdr-git-graph(v0.3.2)は、exec で git を動かし、コミットの一覧と差分を読みます。右クリックのメニューからは、reset、rebase、push、ブランチの削除もできます。どれも実行の前にダイアログで確かめる作りで、確かめた後にHEADが変わっていたら止まるようになっていました。

このmodは、1日に1回、GitHubのAPIに新しい版が出ていないかを問い合わせます。コードの説明によると、送るのは公開されているリリースの情報を取る1回の問い合わせだけで、使う人やリポジトリの情報は送りません。更新の画面にある「Stop checking」で止められます。

スペースの一覧をフォルダで分けるherdr-space-foldersは、sh -c で、フォルダの分け方を ~/.config/herdr-space-folders.json に書き込みます。~/.config/herdr-app の外のファイルです。初めから入っている spaces を複製して作られていて、mod.json の cloned_from にその記録が残っていました。

どちらも、読めば何をするかがわかる書き方でした。それでも、500件近くある全部が同じとは限らないので、入れるたびに読む手間は省かない方がよいと思います。

自分でmodを作る

試しに、各スペースのフォルダで今のブランチと、まだコミットしていない変更の数を出すmodを作りました。公式の説明に載っている見本を元にしています。

フォルダは ~/.config/herdr-app/mods/branch-list/ です。mod.json には、出す場所と幅を書きます。

json
{ "place": "right", "size": 260 }
json
{ "place": "right", "size": 260 }

main.js は次のとおりです。View を継いだクラスを1つ、default exportします。

js
import { View, div } from "gpui-kit";
import { v_flex, h_flex } from "gpui-base";
import { exec, spaces, theme, run } from "herdr";

export default class BranchList extends View {
  init(_props, cx) {
    this.info = {};
    cx.timer.every(5000, (cx) => this.refresh(cx));
    this.refresh(cx);
  }

  refresh(cx) {
    cx.spawn(async (cx) => {
      for (const p of spaces()) {
        if (!p.folder) continue;
        try {
          const b = await exec("git", ["branch", "--show-current"], { cwd: p.folder, on: p.machine });
          const s = await exec("git", ["status", "--porcelain"], { cwd: p.folder, on: p.machine });
          const dirty = s.stdout.split("\n").filter((l) => l.trim()).length;
          this.info[p.id] = b.code === 0 ? { branch: b.stdout.trim(), dirty } : null;
        } catch { this.info[p.id] = null; }
      }
      cx.notify();
    });
  }

  render(cx) {
    const t = theme();
    return v_flex().size_full().bg(t.chrome).p(10).gap(6).text_size(13).children([
      div().text_color(t.dim).text_size(11.5).child("Branches"),
      ...spaces().map((p) => {
        const i = this.info[p.id];
        return v_flex().id(`bl-${p.id}`).px(8).py(6).rounded(6).hover((s) => s.bg(t.hover))
          .on_click(() => run("space.select", { id: p.id }))
          .child(div().truncate().text_color(p.active ? t.foreground : t.muted).child(p.name))
          .child(h_flex().gap(8).text_size(12)
            .child(div().truncate().text_color(t.accent).child(i ? i.branch || "(detached)" : "git なし"))
            .child(div().text_color(i && i.dirty ? t.blocked : t.dim).child(i ? `${i.dirty} changes` : "")));
      }),
    ]);
  }
}
js
import { View, div } from "gpui-kit";
import { v_flex, h_flex } from "gpui-base";
import { exec, spaces, theme, run } from "herdr";

export default class BranchList extends View {
  init(_props, cx) {
    this.info = {};
    cx.timer.every(5000, (cx) => this.refresh(cx));
    this.refresh(cx);
  }

  refresh(cx) {
    cx.spawn(async (cx) => {
      for (const p of spaces()) {
        if (!p.folder) continue;
        try {
          const b = await exec("git", ["branch", "--show-current"], { cwd: p.folder, on: p.machine });
          const s = await exec("git", ["status", "--porcelain"], { cwd: p.folder, on: p.machine });
          const dirty = s.stdout.split("\n").filter((l) => l.trim()).length;
          this.info[p.id] = b.code === 0 ? { branch: b.stdout.trim(), dirty } : null;
        } catch { this.info[p.id] = null; }
      }
      cx.notify();
    });
  }

  render(cx) {
    const t = theme();
    return v_flex().size_full().bg(t.chrome).p(10).gap(6).text_size(13).children([
      div().text_color(t.dim).text_size(11.5).child("Branches"),
      ...spaces().map((p) => {
        const i = this.info[p.id];
        return v_flex().id(`bl-${p.id}`).px(8).py(6).rounded(6).hover((s) => s.bg(t.hover))
          .on_click(() => run("space.select", { id: p.id }))
          .child(div().truncate().text_color(p.active ? t.foreground : t.muted).child(p.name))
          .child(h_flex().gap(8).text_size(12)
            .child(div().truncate().text_color(t.accent).child(i ? i.branch || "(detached)" : "git なし"))
            .child(div().text_color(i && i.dirty ? t.blocked : t.dim).child(i ? `${i.dirty} changes` : "")));
      }),
    ]);
  }
}

setTimeout の代わりに cx.timer.every で5秒ごとに読み直し、時間のかかる git は cx.spawn の中で動かします。色は theme() から取るので、アプリの明るい表示と暗い表示の両方に合います。このmodも exec で git を動かすので、前の章の表では「コマンドを動かす」に当たります。

checkで確かめる

置いたら、herdr-app check で読み込めるかを確かめます。

bash
herdr-app check ~/.config/herdr-app/mods/branch-list
bash
herdr-app check ~/.config/herdr-app/mods/branch-list

通れば check passed と出ます。わざと括弧を1つ消して試すと、unexpected token in expression と、誤りのある行と列が出て、終了コードは1になりました。

ただし、check が通っても、実際の画面で動くとは限りません。存在しない値を読む誤りをわざと入れても、check は通りました。説明ファイルにも、check は一度描くところまでしか確かめないと書かれています。最後はアプリの窓で見て確かめます。

窓に出す

手元では、アプリを開いたままフォルダを作ったところ、mod.json に "place": "right" と書いても窓の右には出てきませんでした。今のタブの右に開くには、次のコマンドを使います。

bash
herdr-app do pane.split '{"direction":"right","mod":"branch-list"}'
bash
herdr-app do pane.split '{"direction":"right","mod":"branch-list"}'

次の画像は、手元のHerdrアプリで、作ったmodをターミナルの右に開いたところです。2つのスペースそれぞれのブランチと、まだコミットしていない変更の数が出ています。

一度出してしまえば、main.js を保存するたびに描き直されます。アプリの説明ファイルによると、保存した中身に誤りがあるときは、前に動いていた版が動き続けます。

初めから入っているmodを複製する

スペースの一覧やファイルの一覧を少し変えたいときは、初めから入っているmodを直接書き換えずに、複製してから変えます。

bash
herdr-app do mod.clone '{"name":"files","as":"my-files"}'
bash
herdr-app do mod.clone '{"name":"files","as":"my-files"}'

手元では mods/my-files/ ができ、mod.json の cloned_from に元の名前と版が記録されました。初めから入っているmodは、アプリが起動のたびに新しい版に置き換えます。複製した方は置き換えられないので、自分の変更が消えません。そのかわり、元のmodの直しも入ってこなくなります。

窓が使えなくなったとき

modやレイアウトを変えて窓が操作できなくなったときは、ctrl-alt-shift-escape を押します。説明ファイルによると、今のレイアウトと keys.json を *.reset-<日時> という名前で脇に置き、初めの main レイアウトに戻します。このキーは、どの設定でも別の操作に変えられません。

herdrのデスクトップアプリの記事では、戻し方の説明が見当たらないと書きました。今の版ではこの戻し方が用意されています。それでも、自分のmodを書き始めるなら、~/.config/herdr-app をGitで管理しておくと、どの変更で壊れたかを追えます。

作ったmodを公開する手順

公開するときは、modのフォルダを公開のGitHubリポジトリに置き、RECIPE.md と画面写真の preview.png を添えて、リポジトリにタグ herdr-mod を付けます。

RECIPE.md は、空のフォルダから別のエージェントが同じmodを作り直せるように書く説明です。アプリの説明ファイルでは、何が見えるか、どう動くか、何でできているか、作るときに間違えやすいところ、できあがりを確かめる方法、の順で書くよう決められています。

入れる人は、前の章のとおり中身を読んでから入れます。exec や fetch を使うなら、何のために使うかを RECIPE.md と README.md に書いておくと、読む人の手間が減ります。herdr-git-graphが、GitHubへの問い合わせの中身と止め方をコードの先頭に書いていたのは、その良い例です。

始め方

まず公開されているmodを1つ入れてみるなら、エージェントにURLを渡すときに、次の一言を添えます。

text
Add herdr.dev/mods/<作者>/<名前>. 入れる前に、exec、fetch、hc、ファイルの読み書きを使っている箇所を全部挙げて
text
Add herdr.dev/mods/<作者>/<名前>. 入れる前に、exec、fetch、hc、ファイルの読み書きを使っている箇所を全部挙げて

自分で作るなら、初めから入っている spaces や files を mod.clone で複製し、少しずつ変えるところから始めると、部品の使い方を見本から覚えられます。どちらの場合も、check が通った後に窓で動きを見るところまでが1回の確認です。

この記事の株式会社hanzochangについて
少し紹介させてください!

この記事に関連する当社のサービス

業務改善・AI活用支援

転記・集計・資料作成にかかる時間を減らしませんか?

現在の業務と使っているシステムを整理し、AIが役立つ作業から改善します。試作で効果を確かめ、現場で使うアプリの開発・運用まで支援します。

サービスの詳細を見る
Jくんが多様な業務アプリを開発するイラスト。背景に多数のアプリが並ぶ。
  • 業務の整理

    手間のかかる作業と、改善する順番を明確に。

  • 試作・アプリ開発

    実際の手順に合わせて、使える仕組みを開発。

  • 導入・運用支援

    使い方の共有から、導入後の改善まで対応。

業務改善・AI活用について、お気軽にご相談ください。