SLOPSHOPPER

statusline

入力欄の上にgit状態、ディレクトリ、モデル、使用率、言語のバージョン、reviewボタン等を常時表示するmod

newbandprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · statusline
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM reload unpushed working-directory opus5.5 ctx:49% 5h:31%(09:53) 7d:-- review simplify security node:dev ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
reload unpushed working-directory opus5.5 ctx:49% 5h:31%(09:53) 7d:-- review simplify security node:
README

Claude Code Plugin

概要

metalmental (ユーザ名) の claude code の plugins 等をまとめたリポジトリ

plugin一覧

plugin名説明
ask-user-question-enforceSkillのユーザー確認をAskUserQuestionに統一させるSKILL
aws-best-practiceAWSリソースの推奨設定SKILL
career-transition-supportITエンジニア転職SKILL
code-review-by-book読んだ技術書をもとにしたコードレビューWorkflow
git-githubcommit、worktree/branch、pr作成Skill
local-doc-searchローカルPCのファイルを全文検索やRAG検索するMCP
sandbox-enforce全claude設定ファイルでsandbox.enabledにするSkill
session-daily-report全プロジェクトのセッション履歴から日報を作成するWorkflow
statuslineModelやBranch名、Python等のversionを表示するstatusline Mod
tool-failure-logToolの実行エラーを調査可能にするSkill
ui-plusUIに表のコピーや会話の折りたたみボタン追加するMod
verified-web-search検索したURLの完全なページを取得し、正確な1件のみ返すWorkflow

Add Marketplace

# Launch Claude
claude

# Add this marketplace
/plugin marketplace add https://github.com/Flupinochan/metalmental-claude

# Reload plugins
/reload-plugins

Install Plugins Example

/plugin install git-github@metalmental-plugins-official
/git-github:commit-enforce

Local Develop Example

claude --plugin-dir "$(pwd)/plugins/session-daily-report"

共通ルール (plugin作成/修正後にすること)

  • plugin.json の version を上げること
  • plugin-dev:plugin-validator や claude plugin validate, test, eval 実行して動作確認するかユーザに確認すること
  • docs/help-skill-sample.md を参考にして /help skillを作成すること
Source 22 files
hooks/register.tsx 262 lines
1import type { Register } from "claude-code";
2
3import { formatBunVersion, readBunVersion } from "./bun";
4import { COLOR } from "./colors";
5import { ctxColor, formatCtx, readCtx, syncCtx } from "./ctx";
6import { gitStatusColor, readBranchName, readGitStatus } from "./gitStatus";
7import { formatGoVersion, readGoVersion } from "./go";
8import { formatModel, modelColor, readModelId, syncModelId } from "./model";
9import { formatNodeVersion, readNodeVersion } from "./nodejs";
10import { formatPythonVersion, readPythonVersion } from "./python";
11import { formatRustVersion, readRustVersion } from "./rust";
12import { formatTerraformVersion, readTerraformVersion } from "./terraform";
13import { readOutputStyle, syncOutputStyle } from "./outputStyle";
14import {
15  openPullRequest,
16  readPullRequests,
17  summarizePullRequests,
18} from "./pullRequest";
19import {
20  readRateLimits,
21  summarizeRateLimits,
22  syncRateLimits,
23} from "./rateLimits";
24import { readReloadStatus, reloadManually, summarizeReload } from "./reload";
25import { pressReview, readReviewedCommands, summarizeReviews } from "./review";
26import { syncStatusline } from "./syncStatusline";
27import {
28  copyWorkingDirectory,
29  directoryName,
30  readWorkingDirectory,
31  workingDirectoryColor,
32} from "./workingDirectory";
33
34const COMMANDS_TO_RESYNC = [
35  "cd",
36  "clear",
37  "resume",
38  "branch",
39  "rewind",
40] as const;
41
42/**
43 * statusline の各イベントのフックと、入力欄の上の描画を登録する
44 * @param on フックを登録する関数
45 */
46export const register: Register = (on) => {
47  on("session.start", async ($, event, next) => {
48    await syncStatusline($);
49
50    return next(event);
51  });
52
53  on("turn.complete", async ($, event, next) => {
54    if (event.agentId === undefined) {
55      await syncStatusline($);
56    }
57
58    return next(event);
59  });
60
61  on("session.measure", async ($, event, next) => {
62    if (event.changed.includes("context")) {
63      await syncCtx($);
64    }
65
66    if (event.changed.includes("rateLimits")) {
67      await syncRateLimits($);
68    }
69
70    return next(event);
71  });
72
73  on("classic.PostCompact", async ($, event, next) => {
74    await syncCtx($);
75
76    return next(event);
77  });
78
79  on("config.set", async ($, event, next) => {
80    const result = await next(event);
81    await syncOutputStyle($);
82
83    return result;
84  });
85
86  on("command.run", { command: "model" }, async ($, event, next) => {
87    const ran = await next(event);
88    await syncModelId($);
89
90    return ran;
91  });
92
93  on("command.run", { command: "output-style" }, async ($, event, next) => {
94    const ran = await next(event);
95    await syncOutputStyle($);
96
97    return ran;
98  });
99
100  for (const command of COMMANDS_TO_RESYNC) {
101    on("command.run", { command }, async ($, event, next) => {
102      const ran = await next(event);
103      $.clock.after(0, () => syncStatusline($));
104
105      return ran;
106    });
107  }
108
109  on("ui.render", { component: "AbovePrompt" }, async ($, event, next) => {
110    const [
111      reloadStatus,
112      gitStatus,
113      workingDirectory,
114      branchName,
115      nodeVersion,
116      pythonVersion,
117      rustVersion,
118      goVersion,
119      bunVersion,
120      terraformVersion,
121      pullRequests,
122      modelId,
123      outputStyle,
124      ctxPercent,
125      rateLimits,
126      reviewedCommands,
127    ] = await Promise.all([
128      readReloadStatus($),
129      readGitStatus($),
130      readWorkingDirectory($),
131      readBranchName($),
132      readNodeVersion($),
133      readPythonVersion($),
134      readRustVersion($),
135      readGoVersion($),
136      readBunVersion($),
137      readTerraformVersion($),
138      readPullRequests($),
139      readModelId($),
140      readOutputStyle($),
141      readCtx($),
142      readRateLimits($),
143      readReviewedCommands($),
144    ]);
145
146    if (event.props.hasSurvey || event.surface === "mobile") {
147      return next(event);
148    }
149
150    const { Box, Button, Text } = $.ui.resolve(event);
151    const { icon: reloadIcon, color: reloadColor } =
152      summarizeReload(reloadStatus);
153
154    return (
155      <Box
156        flexDirection="row"
157        flexWrap="wrap"
158        columnGap={1}
159        position="relative"
160      >
161        <Button key="reload" plain onPress={() => void reloadManually($)}>
162          <Text color={reloadColor}>{reloadIcon}</Text>
163        </Button>
164        {gitStatus !== undefined && (
165          <Text color={gitStatusColor(gitStatus)}>{gitStatus}</Text>
166        )}
167        {workingDirectory !== "" && (
168          <Button
169            key="working-directory"
170            plain
171            hover={{ scope: "working-directory" }}
172            onPress={(press) =>
173              void copyWorkingDirectory($, workingDirectory, press.surface)
174            }
175          >
176            <Text color={workingDirectoryColor(workingDirectory)}>
177              {directoryName(workingDirectory)}
178            </Text>
179          </Button>
180        )}
181        {branchName !== "" && <Text color={COLOR.indigo}>{branchName}</Text>}
182        {summarizePullRequests(pullRequests).map(
183          ({ number, url, text, color }) => (
184            <Button
185              key={`pull-request-${number}`}
186              plain
187              onPress={() => void openPullRequest($, url)}
188            >
189              <Text color={color}>{text}</Text>
190            </Button>
191          ),
192        )}
193        {modelId !== "" && (
194          <Text color={modelColor(modelId)}>{formatModel(modelId)}</Text>
195        )}
196        {outputStyle !== undefined && (
197          <Text color={COLOR.orange}>{outputStyle}</Text>
198        )}
199        <Text color={ctxColor(ctxPercent)}>{formatCtx(ctxPercent)}</Text>
200        {summarizeRateLimits(rateLimits).map(({ kind, text, color }) => (
201          <Text key={kind} color={color}>
202            {text}
203          </Text>
204        ))}
205        {summarizeReviews(reviewedCommands).map(({ command, label, color }) => (
206          <Button
207            key={label}
208            plain
209            onPress={() => void pressReview($, command)}
210          >
211            <Text color={color}>{label}</Text>
212          </Button>
213        ))}
214        {nodeVersion !== "" && (
215          <Text color={COLOR.green}>{formatNodeVersion(nodeVersion)}</Text>
216        )}
217        {pythonVersion !== "" && (
218          <Text color={COLOR.yellow}>{formatPythonVersion(pythonVersion)}</Text>
219        )}
220        {rustVersion !== "" && (
221          <Text color={COLOR.orange}>{formatRustVersion(rustVersion)}</Text>
222        )}
223        {goVersion !== "" && (
224          <Text color={COLOR.blue}>{formatGoVersion(goVersion)}</Text>
225        )}
226        {bunVersion !== "" && (
227          <Text color={COLOR.purple}>{formatBunVersion(bunVersion)}</Text>
228        )}
229        {terraformVersion !== "" && (
230          <Text color={COLOR.indigo}>
231            {formatTerraformVersion(terraformVersion)}
232          </Text>
233        )}
234        {workingDirectory !== "" && (
235          <Box
236            position="absolute"
237            top={0}
238            bottom={0}
239            left={0}
240            right={0}
241            display="none"
242            backgroundColor={COLOR.blue}
243            hover={{ scope: "working-directory", display: "flex" }}
244          >
245            <Button
246              key="working-directory-path"
247              plain
248              onPress={(press) =>
249                void copyWorkingDirectory($, workingDirectory, press.surface)
250              }
251            >
252              <Text color={workingDirectoryColor(workingDirectory)}>
253                {workingDirectory}
254              </Text>
255            </Button>
256          </Box>
257        )}
258      </Box>
259    );
260  });
261};
262
hooks/bun.ts 107 lines
1// starship参考
2// https://github.com/starship/starship/blob/main/src/modules/bun.rs
3import type { EngineInterface } from "claude-code";
4import { atom, read, update } from "claude-code";
5
6import { VERSION_COMMAND_TIMEOUT_MS } from "./commandTimeout";
7import type { Entry } from "./directoryEntries";
8import { hasFile } from "./directoryEntries";
9
10const bunVersionState = atom(
11  { plugin: "statusline", key: "bunVersion" } as const,
12  "",
13);
14
15const DETECT_FILES = ["bun.lock", "bun.lockb", "bunfig.toml"];
16
17// 取得の制御に使うだけで描画には使わないため、$.state ではなくモジュールの変数に持つ
18let versionCache = { cwd: "", version: "" };
19
20/**
21 * ディレクトリの直下の内容が、bun のプロジェクトかを判定する
22 * @param entries ディレクトリの直下の項目
23 * @returns bun のプロジェクトの場合は true
24 */
25export function isBunProject(entries: readonly Entry[]) {
26  return DETECT_FILES.some((name) => hasFile(entries, name));
27}
28
29/**
30 * bun --version の出力から、版を取り出す
31 * @param stdout bun --version の標準出力
32 * @returns 版
33 */
34export function parseBunVersion(stdout: string) {
35  return stdout.trim();
36}
37
38/**
39 * bun の版を表示用の文字列に整形する
40 * @param version 版
41 * @returns 整形後の文字列
42 */
43export function formatBunVersion(version: string) {
44  return `bun:${version}`;
45}
46
47/**
48 * bun の版のキャッシュを削除する
49 * 次の syncBunVersion では、キャッシュを使わず、bun を実行して取得する
50 */
51export function clearBunVersionCache() {
52  versionCache = { cwd: "", version: "" };
53}
54
55/**
56 * 作業ディレクトリで bun --version を実行し、版を取得する
57 * @param $ engineのインターフェース
58 * @param cwd 作業ディレクトリ
59 * @returns 版。終了コードが0でない場合、起動できない場合、タイムアウトの場合は空文字
60 */
61async function fetchBunVersion($: EngineInterface, cwd: string) {
62  try {
63    const ran = await $.process.run(["bun", "--version"], {
64      cwd,
65      timeoutMs: VERSION_COMMAND_TIMEOUT_MS,
66    });
67
68    return ran.exitCode === 0 ? parseBunVersion(ran.stdout) : "";
69  } catch (error) {
70    $.ui.log(`bun --version failed: ${String(error)}`, { to: "debug" });
71
72    return "";
73  }
74}
75
76/**
77 * 保存済みの bun の版を読む
78 * 描画の中で呼ぶと、syncBunVersion による更新時に再描画される
79 * @param $ engineのインターフェース
80 * @returns 保存済みの版。bun のプロジェクトでない間や、取得できない間は空文字
81 */
82export function readBunVersion($: EngineInterface) {
83  return read($, bunVersionState);
84}
85
86/**
87 * 作業ディレクトリの bun の版を取得し、保存済みの値を更新する
88 * 版は作業ディレクトリごとに1回だけ取得し、プロジェクトでない場合は bun を実行しない
89 * @param $ engineのインターフェース
90 */
91export async function syncBunVersion($: EngineInterface) {
92  const cwd = await $.session.cwd();
93  const entries = await $.fs.list(cwd);
94
95  if (!isBunProject(entries)) {
96    await update($, bunVersionState, () => "");
97
98    return;
99  }
100
101  if (versionCache.cwd !== cwd) {
102    versionCache = { cwd, version: await fetchBunVersion($, cwd) };
103  }
104
105  await update($, bunVersionState, () => versionCache.version);
106}
107
hooks/colors.ts 32 lines
1// claude codeのtheme keyに別名を付けて再利用することで既存の配色と一致させる
2// orangeを除き、基本的には新しいcolor codeは定義しない
3// mod側で利用されていないtheme keyもあるが、後でcolorを重複して定義しないために残しておく
4export const COLOR = {
5  green: "success",
6  blue: "planMode",
7  indigo: "suggestion",
8  yellow: "warning",
9  red: "error",
10  purple: "autoAccept",
11  white: "inactive",
12  grey: "text",
13  black: "inverseText",
14  orange: "#ff8700",
15} as const;
16
17const WARNING_PERCENT = 50;
18const DANGER_PERCENT = 80;
19
20/**
21 * 使用率に対応する色を返す
22 * @param percent 使用率 0から100
23 * @returns テーマキーで表した色
24 */
25export function levelColor(percent: number) {
26  if (percent >= DANGER_PERCENT) {
27    return COLOR.red;
28  }
29
30  return percent >= WARNING_PERCENT ? COLOR.yellow : COLOR.green;
31}
32
hooks/ctx.ts 48 lines
1import type { EngineInterface } from "claude-code";
2import { atom, read, update } from "claude-code";
3
4import { COLOR, levelColor } from "./colors";
5
6const ctxState = atom({ plugin: "statusline", key: "ctx" } as const, {});
7
8/**
9 * ctx使用率を表示用の文字列に整形する
10 * @param percent ctx使用率 0から100。無い場合は undefined
11 * @returns 整形後の文字列
12 */
13export function formatCtx(percent: number | undefined) {
14  return percent === undefined ? "ctx:--" : `ctx:${Math.round(percent)}%`;
15}
16
17/**
18 * ctx使用率に対応する色を返す
19 * @param percent ctx使用率 0から100。無い場合は undefined
20 * @returns テーマキーで表した色
21 */
22export function ctxColor(percent: number | undefined) {
23  return percent === undefined ? COLOR.blue : levelColor(percent);
24}
25
26/**
27 * 保存済みのctx使用率を読む
28 * 描画の中で呼ぶと、syncCtx による更新時に再描画される
29 * @param $ engineのインターフェース
30 * @returns 保存済みの使用率。最初の応答より前は undefined
31 */
32export async function readCtx($: EngineInterface) {
33  const { percent } = await read($, ctxState);
34
35  return percent;
36}
37
38/**
39 * セッションの現在のctx使用率を取得し、保存済みの値を更新する
40 * 最初の応答より前や compact 直後は使用率が無いため、使用率の無いオブジェクトを保存する
41 * undefined は直接保存できないため、オブジェクトのプロパティとして持つ
42 * @param $ engineのインターフェース
43 */
44export async function syncCtx($: EngineInterface) {
45  const usage = await $.session.usage();
46  await update($, ctxState, () => ({ percent: usage.context.percent }));
47}
48
hooks/gitStatus.ts 150 lines
1import type { EngineInterface } from "claude-code";
2import { atom, read, update } from "claude-code";
3
4import type { GitStatus } from "../types";
5import { COLOR } from "./colors";
6
7const gitStatusState = atom(
8  { plugin: "statusline", key: "gitStatus" } as const,
9  undefined,
10);
11
12const branchNameState = atom(
13  { plugin: "statusline", key: "branchName" } as const,
14  "",
15);
16
17const GIT_STATUS_COLORS = {
18  unpushed: COLOR.red,
19  uncommitted: COLOR.red,
20  clean: COLOR.green,
21  "no-git": COLOR.blue,
22} as const satisfies Record<GitStatus, string>;
23
24const AHEAD_OF_UPSTREAM = /^\+[1-9]/;
25const INITIAL_COMMIT = "(initial)";
26const DETACHED_HEAD = "(detached)";
27
28/**
29 * git status --porcelain=v2 --branch が出力する、ブランチの情報の項目名
30 */
31type BranchHeaderKey = "oid" | "head" | "upstream" | "ab";
32
33/**
34 * gitの状態に対応する色を返す
35 * @param gitStatus gitの状態
36 * @returns テーマキーで表した色
37 */
38export function gitStatusColor(gitStatus: GitStatus) {
39  return GIT_STATUS_COLORS[gitStatus];
40}
41
42/**
43 * git status --porcelain=v2 --branch の出力から、ブランチの情報の行の値を取り出す
44 * @param lines 出力の各行
45 * @param key 項目名。例: head、upstream、ab
46 * @returns 値。該当する行がない場合は undefined
47 */
48function branchHeaderValue(lines: readonly string[], key: BranchHeaderKey) {
49  const prefix = `# branch.${key} `;
50
51  return lines.find((line) => line.startsWith(prefix))?.slice(prefix.length);
52}
53
54/**
55 * 未プッシュのコミットがあるかを、ブランチの情報から判定する
56 * プッシュ対象があり、上流がないか、上流より先行している場合に該当する
57 * @param lines git status --porcelain=v2 --branch の出力の各行
58 * @returns 未プッシュのコミットがある場合は true
59 */
60function hasUnpushedCommits(lines: readonly string[]) {
61  const hasNothingToPush =
62    branchHeaderValue(lines, "oid") === INITIAL_COMMIT ||
63    branchHeaderValue(lines, "head") === DETACHED_HEAD;
64  const hasUpstream = branchHeaderValue(lines, "upstream") !== undefined;
65  const isAhead = AHEAD_OF_UPSTREAM.test(branchHeaderValue(lines, "ab") ?? "");
66
67  return !hasNothingToPush && (!hasUpstream || isAhead);
68}
69
70/**
71 * git status の結果から、gitの状態を1つに決める
72 * @param exitCode git status の終了コード
73 * @param stdout git status --porcelain=v2 --branch の出力
74 * @returns gitの状態
75 */
76function classifyGitStatus(exitCode: number, stdout: string): GitStatus {
77  if (exitCode !== 0) {
78    return "no-git";
79  }
80
81  const lines = stdout.split("\n").filter((line) => line.length > 0);
82
83  if (hasUnpushedCommits(lines)) {
84    return "unpushed";
85  }
86
87  const hasChanges = lines.some((line) => !line.startsWith("#"));
88
89  return hasChanges ? "uncommitted" : "clean";
90}
91
92/**
93 * git status の結果から、ブランチ名を取り出す
94 * @param exitCode git status の終了コード
95 * @param stdout git status --porcelain=v2 --branch の出力
96 * @returns ブランチ名。git でない場合、detached HEAD の場合は空文字
97 */
98function parseBranchName(exitCode: number, stdout: string) {
99  if (exitCode !== 0) {
100    return "";
101  }
102
103  const head = branchHeaderValue(stdout.split("\n"), "head");
104
105  return head === undefined || head === DETACHED_HEAD ? "" : head;
106}
107
108/**
109 * 保存済みのブランチ名を読む
110 * 描画の中で呼ぶと、syncGitStatus による更新時に再描画される
111 * @param $ engineのインターフェース
112 * @returns 保存済みのブランチ名。取得できない間は空文字
113 */
114export function readBranchName($: EngineInterface) {
115  return read($, branchNameState);
116}
117
118/**
119 * 保存済みのgitの状態を読む
120 * 描画の中で呼ぶと、syncGitStatus による更新時に再描画される
121 * @param $ engineのインターフェース
122 * @returns 保存済みのgitの状態。未取得の間は undefined
123 */
124export function readGitStatus($: EngineInterface) {
125  return read($, gitStatusState);
126}
127
128/**
129 * 作業ディレクトリのgitの状態とブランチ名を取得し、保存済みの値を更新する
130 * @param $ engineのインターフェース
131 */
132export async function syncGitStatus($: EngineInterface) {
133  const cwd = await $.session.cwd();
134  const ran = await $.process.run([
135    "git",
136    "--no-optional-locks",
137    "-C",
138    cwd,
139    "status",
140    "--porcelain=v2",
141    "--branch",
142  ]);
143  await update($, gitStatusState, () =>
144    classifyGitStatus(ran.exitCode, ran.stdout),
145  );
146  await update($, branchNameState, () =>
147    parseBranchName(ran.exitCode, ran.stdout),
148  );
149}
150
hooks/go.ts 123 lines
1// starship参考
2// https://github.com/starship/starship/blob/main/src/modules/golang.rs
3import type { EngineInterface } from "claude-code";
4import { atom, read, update } from "claude-code";
5
6import { VERSION_COMMAND_TIMEOUT_MS } from "./commandTimeout";
7import type { Entry } from "./directoryEntries";
8import { hasExtension, hasFile, hasFolder } from "./directoryEntries";
9
10const goVersionState = atom(
11  { plugin: "statusline", key: "goVersion" } as const,
12  "",
13);
14
15const DETECT_FILES = [
16  "go.mod",
17  "go.sum",
18  "go.work",
19  "glide.yaml",
20  "Gopkg.yml",
21  "Gopkg.lock",
22  ".go-version",
23];
24const DETECT_FOLDERS = ["Godeps"];
25const DETECT_EXTENSIONS = ["go"];
26
27// 取得の制御に使うだけで描画には使わないため、$.state ではなくモジュールの変数に持つ
28let versionCache = { cwd: "", version: "" };
29
30/**
31 * ディレクトリの直下の内容が、Go のプロジェクトかを判定する
32 * @param entries ディレクトリの直下の項目
33 * @returns Go のプロジェクトの場合は true
34 */
35export function isGoProject(entries: readonly Entry[]) {
36  return (
37    DETECT_FILES.some((name) => hasFile(entries, name)) ||
38    DETECT_FOLDERS.some((name) => hasFolder(entries, name)) ||
39    DETECT_EXTENSIONS.some((extension) => hasExtension(entries, extension))
40  );
41}
42
43/**
44 * go version の出力から、版を取り出す
45 * @param stdout go version の標準出力 例: go version go1.25.0 linux/amd64
46 * @returns 版。取り出せない場合は空文字
47 */
48export function parseGoVersion(stdout: string) {
49  return /^go version go(\S+)/.exec(stdout.trim())?.[1] ?? "";
50}
51
52/**
53 * Go の版を表示用の文字列に整形する
54 * @param version 版
55 * @returns 整形後の文字列
56 */
57export function formatGoVersion(version: string) {
58  return `go:${version}`;
59}
60
61/**
62 * Go の版のキャッシュを削除する
63 * 次の syncGoVersion では、キャッシュを使わず、go を実行して取得する
64 */
65export function clearGoVersionCache() {
66  versionCache = { cwd: "", version: "" };
67}
68
69/**
70 * 作業ディレクトリで go version を実行し、版を取得する
71 * @param $ engineのインターフェース
72 * @param cwd 作業ディレクトリ
73 * @returns 版。終了コードが0でない場合、起動できない場合、タイムアウトの場合は空文字
74 */
75async function fetchGoVersion($: EngineInterface, cwd: string) {
76  try {
77    // go.mod が指す新しい版を、版の取得のたびにダウンロードしないようにする
78    const ran = await $.process.run(["go", "version"], {
79      cwd,
80      timeoutMs: VERSION_COMMAND_TIMEOUT_MS,
81      env: { GOTOOLCHAIN: "local" },
82    });
83
84    return ran.exitCode === 0 ? parseGoVersion(ran.stdout) : "";
85  } catch (error) {
86    $.ui.log(`go version failed: ${String(error)}`, { to: "debug" });
87
88    return "";
89  }
90}
91
92/**
93 * 保存済みの Go の版を読む
94 * 描画の中で呼ぶと、syncGoVersion による更新時に再描画される
95 * @param $ engineのインターフェース
96 * @returns 保存済みの版。Go のプロジェクトでない間や、取得できない間は空文字
97 */
98export function readGoVersion($: EngineInterface) {
99  return read($, goVersionState);
100}
101
102/**
103 * 作業ディレクトリの Go の版を取得し、保存済みの値を更新する
104 * 版は作業ディレクトリごとに1回だけ取得し、プロジェクトでない場合は go を実行しない
105 * @param $ engineのインターフェース
106 */
107export async function syncGoVersion($: EngineInterface) {
108  const cwd = await $.session.cwd();
109  const entries = await $.fs.list(cwd);
110
111  if (!isGoProject(entries)) {
112    await update($, goVersionState, () => "");
113
114    return;
115  }
116
117  if (versionCache.cwd !== cwd) {
118    versionCache = { cwd, version: await fetchGoVersion($, cwd) };
119  }
120
121  await update($, goVersionState, () => versionCache.version);
122}
123
hooks/model.ts 61 lines
1import type { EngineInterface } from "claude-code";
2import { atom, read, update } from "claude-code";
3
4import { COLOR } from "./colors";
5
6const modelIdState = atom(
7  { plugin: "statusline", key: "modelId" } as const,
8  "",
9);
10
11const MODEL_COLORS: readonly (readonly [RegExp, string])[] = [
12  [/sonnet/i, COLOR.green],
13  [/opus/i, COLOR.yellow],
14  [/fable/i, COLOR.red],
15];
16
17/**
18 * モデルIDを表示用の短い文字列に整形する
19 * 表示名を返すAPIが無いため、IDから組み立てる
20 * @param modelId モデルID
21 * @returns 整形後の文字列
22 */
23export function formatModel(modelId: string) {
24  return modelId
25    .replace(/^claude-/, "")
26    .replace(/\[[^[\]]*\]$/, "")
27    .replace(/-\d{8}$/, "")
28    .replace(/-(\d+)-(\d+)$/, "$1.$2")
29    .replace(/-(\d+)$/, "$1");
30}
31
32/**
33 * モデルIDに対応する色を返す
34 * @param modelId モデルID
35 * @returns テーマキーで表した色
36 */
37export function modelColor(modelId: string) {
38  return (
39    MODEL_COLORS.find(([pattern]) => pattern.test(modelId))?.[1] ?? COLOR.blue
40  );
41}
42
43/**
44 * 保存済みのモデルIDを読む
45 * 描画の中で呼ぶと、syncModelId による更新時に再描画される
46 * @param $ engineのインターフェース
47 * @returns 保存済みのモデルID。未取得の間は空文字
48 */
49export function readModelId($: EngineInterface) {
50  return read($, modelIdState);
51}
52
53/**
54 * セッションの現在のモデルIDを取得し、保存済みの値を更新する
55 * @param $ engineのインターフェース
56 */
57export async function syncModelId($: EngineInterface) {
58  const modelId = await $.session.model();
59  await update($, modelIdState, () => modelId);
60}
61
hooks/nodejs.ts 131 lines
1// starship参考
2// https://github.com/starship/starship/blob/main/src/modules/nodejs.rs
3import type { EngineInterface } from "claude-code";
4import { atom, read, update } from "claude-code";
5
6import { VERSION_COMMAND_TIMEOUT_MS } from "./commandTimeout";
7import type { Entry } from "./directoryEntries";
8import { hasExtension, hasFile, hasFolder } from "./directoryEntries";
9
10const nodeVersionState = atom(
11  { plugin: "statusline", key: "nodeVersion" } as const,
12  "",
13);
14
15const DETECT_FILES = ["package.json", ".node-version", ".nvmrc"];
16const DETECT_FOLDERS = ["node_modules"];
17const DETECT_EXTENSIONS = ["js", "mjs", "cjs", "ts", "mts", "cts"];
18const EXCLUDE_FILES = [
19  "bunfig.toml",
20  "bun.lock",
21  "bun.lockb",
22  "deno.json",
23  "deno.jsonc",
24  "deno.lock",
25];
26const EXCLUDE_FOLDERS = ["esy.lock"];
27
28// 取得の制御に使うだけで描画には使わないため、$.state ではなくモジュールの変数に持つ
29let versionCache = { cwd: "", version: "" };
30
31/**
32 * ディレクトリの直下の内容が、Node.js のプロジェクトかを判定する
33 * 除外の対象があれば、他の条件によらずプロジェクトではない
34 * @param entries ディレクトリの直下の項目
35 * @returns Node.js のプロジェクトの場合は true
36 */
37export function isNodeProject(entries: readonly Entry[]) {
38  const file = (name: string) => hasFile(entries, name);
39  const folder = (name: string) => hasFolder(entries, name);
40  const extension = (name: string) => hasExtension(entries, name);
41
42  if (EXCLUDE_FILES.some(file) || EXCLUDE_FOLDERS.some(folder)) {
43    return false;
44  }
45
46  return (
47    DETECT_FILES.some(file) ||
48    DETECT_FOLDERS.some(folder) ||
49    DETECT_EXTENSIONS.some(extension)
50  );
51}
52
53/**
54 * node --version の出力から、先頭の v と空白を除いた版を取り出す
55 * @param stdout node --version の標準出力
56 * @returns 版
57 */
58export function parseNodeVersion(stdout: string) {
59  return stdout.trim().replace(/^v/, "");
60}
61
62/**
63 * Node.js の版を表示用の文字列に整形する
64 * @param version 版
65 * @returns 整形後の文字列
66 */
67export function formatNodeVersion(version: string) {
68  return `node:${version}`;
69}
70
71/**
72 * Node.js の版のキャッシュを削除する
73 * 次の syncNodeVersion では、キャッシュを使わず、node を実行して取得する
74 */
75export function clearNodeVersionCache() {
76  versionCache = { cwd: "", version: "" };
77}
78
79/**
80 * 作業ディレクトリで node --version を実行し、版を取得する
81 * @param $ engineのインターフェース
82 * @param cwd 作業ディレクトリ
83 * @returns 版。終了コードが0でない場合、起動できない場合、タイムアウトの場合は空文字
84 */
85async function fetchNodeVersion($: EngineInterface, cwd: string) {
86  try {
87    const ran = await $.process.run(["node", "--version"], {
88      cwd,
89      timeoutMs: VERSION_COMMAND_TIMEOUT_MS,
90    });
91
92    return ran.exitCode === 0 ? parseNodeVersion(ran.stdout) : "";
93  } catch (error) {
94    $.ui.log(`node --version failed: ${String(error)}`, { to: "debug" });
95
96    return "";
97  }
98}
99
100/**
101 * 保存済みの Node.js の版を読む
102 * 描画の中で呼ぶと、syncNodeVersion による更新時に再描画される
103 * @param $ engineのインターフェース
104 * @returns 保存済みの版。Node.js のプロジェクトでない間や、取得できない間は空文字
105 */
106export function readNodeVersion($: EngineInterface) {
107  return read($, nodeVersionState);
108}
109
110/**
111 * 作業ディレクトリの Node.js の版を取得し、保存済みの値を更新する
112 * 版は作業ディレクトリごとに1回だけ取得し、プロジェクトでない場合は node を実行しない
113 * @param $ engineのインターフェース
114 */
115export async function syncNodeVersion($: EngineInterface) {
116  const cwd = await $.session.cwd();
117  const entries = await $.fs.list(cwd);
118
119  if (!isNodeProject(entries)) {
120    await update($, nodeVersionState, () => "");
121
122    return;
123  }
124
125  if (versionCache.cwd !== cwd) {
126    versionCache = { cwd, version: await fetchNodeVersion($, cwd) };
127  }
128
129  await update($, nodeVersionState, () => versionCache.version);
130}
131
hooks/python.ts 138 lines
1// starship参考
2// https://github.com/starship/starship/blob/main/src/modules/python.rs
3import type { EngineInterface } from "claude-code";
4import { atom, read, update } from "claude-code";
5
6import { VERSION_COMMAND_TIMEOUT_MS } from "./commandTimeout";
7import type { Entry } from "./directoryEntries";
8import { hasExtension, hasFile } from "./directoryEntries";
9
10const pythonVersionState = atom(
11  { plugin: "statusline", key: "pythonVersion" } as const,
12  "",
13);
14
15const PYTHON_COMMANDS = ["python", "python3", "python2"];
16const DETECT_FILES = [
17  "requirements.txt",
18  ".python-version",
19  "pyproject.toml",
20  "Pipfile",
21  "tox.ini",
22  "setup.py",
23  "__init__.py",
24];
25const DETECT_EXTENSIONS = ["py", "ipynb"];
26
27// 取得の制御に使うだけで描画には使わないため、$.state ではなくモジュールの変数に持つ
28let versionCache = { cwd: "", version: "" };
29
30/**
31 * ディレクトリの直下の内容と環境変数が、Python のプロジェクトかを判定する
32 * 仮想環境の環境変数が設定されていれば、ファイルがなくてもプロジェクトとして扱う
33 * @param entries ディレクトリの直下の項目
34 * @param virtualEnv 環境変数 VIRTUAL_ENV の値。設定されていない場合は undefined
35 * @returns Python のプロジェクトの場合は true
36 */
37export function isPythonProject(
38  entries: readonly Entry[],
39  virtualEnv: string | undefined,
40) {
41  return (
42    virtualEnv !== undefined ||
43    DETECT_FILES.some((name) => hasFile(entries, name)) ||
44    DETECT_EXTENSIONS.some((extension) => hasExtension(entries, extension))
45  );
46}
47
48/**
49 * python --version の出力から、版を取り出す
50 * @param output python --version の出力
51 * @returns 版。取り出せない場合は空文字
52 */
53export function parsePythonVersion(output: string) {
54  return output.trim().split(/\s+/)[1] ?? "";
55}
56
57/**
58 * Python の版を表示用の文字列に整形する
59 * @param version 版
60 * @returns 整形後の文字列
61 */
62export function formatPythonVersion(version: string) {
63  return `python:${version}`;
64}
65
66/**
67 * Python の版のキャッシュを削除する
68 * 次の syncPythonVersion では、キャッシュを使わず、python を実行して取得する
69 */
70export function clearPythonVersionCache() {
71  versionCache = { cwd: "", version: "" };
72}
73
74/**
75 * 作業ディレクトリで --version を実行し、最初に成功したコマンドの版を取得する
76 * python2 は版を標準エラー出力に書くため、標準出力が空なら標準エラー出力を使う
77 * @param $ engineのインターフェース
78 * @param cwd 作業ディレクトリ
79 * @returns 版。すべての候補が失敗した場合は空文字
80 */
81async function fetchPythonVersion($: EngineInterface, cwd: string) {
82  for (const command of PYTHON_COMMANDS) {
83    try {
84      const ran = await $.process.run([command, "--version"], {
85        cwd,
86        timeoutMs: VERSION_COMMAND_TIMEOUT_MS,
87      });
88
89      if (ran.exitCode === 0) {
90        return parsePythonVersion(
91          ran.stdout.trim() === "" ? ran.stderr : ran.stdout,
92        );
93      }
94    } catch (error) {
95      $.ui.log(`${command} --version failed: ${String(error)}`, {
96        to: "debug",
97      });
98    }
99  }
100
101  return "";
102}
103
104/**
105 * 保存済みの Python の版を読む
106 * 描画の中で呼ぶと、syncPythonVersion による更新時に再描画される
107 * @param $ engineのインターフェース
108 * @returns 保存済みの版。Python のプロジェクトでない間や、取得できない間は空文字
109 */
110export function readPythonVersion($: EngineInterface) {
111  return read($, pythonVersionState);
112}
113
114/**
115 * 作業ディレクトリの Python の版を取得し、保存済みの値を更新する
116 * 版は作業ディレクトリごとに1回だけ取得し、プロジェクトでない場合は python を実行しない
117 * @param $ engineのインターフェース
118 */
119export async function syncPythonVersion($: EngineInterface) {
120  const cwd = await $.session.cwd();
121  const [entries, virtualEnv] = await Promise.all([
122    $.fs.list(cwd),
123    $.env.get("VIRTUAL_ENV"),
124  ]);
125
126  if (!isPythonProject(entries, virtualEnv)) {
127    await update($, pythonVersionState, () => "");
128
129    return;
130  }
131
132  if (versionCache.cwd !== cwd) {
133    versionCache = { cwd, version: await fetchPythonVersion($, cwd) };
134  }
135
136  await update($, pythonVersionState, () => versionCache.version);
137}
138
hooks/rust.ts 113 lines
1// starship参考
2// https://github.com/starship/starship/blob/main/src/modules/rust.rs
3import type { EngineInterface } from "claude-code";
4import { atom, read, update } from "claude-code";
5
6import { VERSION_COMMAND_TIMEOUT_MS } from "./commandTimeout";
7import type { Entry } from "./directoryEntries";
8import { hasExtension, hasFile } from "./directoryEntries";
9
10const rustVersionState = atom(
11  { plugin: "statusline", key: "rustVersion" } as const,
12  "",
13);
14
15const DETECT_FILES = ["Cargo.toml"];
16const DETECT_EXTENSIONS = ["rs"];
17
18// 取得の制御に使うだけで描画には使わないため、$.state ではなくモジュールの変数に持つ
19let versionCache = { cwd: "", version: "" };
20
21/**
22 * ディレクトリの直下の内容が、Rust のプロジェクトかを判定する
23 * @param entries ディレクトリの直下の項目
24 * @returns Rust のプロジェクトの場合は true
25 */
26export function isRustProject(entries: readonly Entry[]) {
27  return (
28    DETECT_FILES.some((name) => hasFile(entries, name)) ||
29    DETECT_EXTENSIONS.some((extension) => hasExtension(entries, extension))
30  );
31}
32
33/**
34 * rustc --version の出力から、版を取り出す
35 * @param stdout rustc --version の標準出力
36 * @returns 版。取り出せない場合は空文字
37 */
38export function parseRustVersion(stdout: string) {
39  return stdout.trim().split(/\s+/)[1] ?? "";
40}
41
42/**
43 * Rust の版を表示用の文字列に整形する
44 * @param version 版
45 * @returns 整形後の文字列
46 */
47export function formatRustVersion(version: string) {
48  return `rust:${version}`;
49}
50
51/**
52 * Rust の版のキャッシュを削除する
53 * 次の syncRustVersion では、キャッシュを使わず、rustc を実行して取得する
54 */
55export function clearRustVersionCache() {
56  versionCache = { cwd: "", version: "" };
57}
58
59/**
60 * 作業ディレクトリで rustc --version を実行し、版を取得する
61 * @param $ engineのインターフェース
62 * @param cwd 作業ディレクトリ
63 * @returns 版。終了コードが0でない場合、起動できない場合、タイムアウトの場合は空文字
64 */
65async function fetchRustVersion($: EngineInterface, cwd: string) {
66  try {
67    // 未導入のツールチェーンを、版の取得のたびにインストールしないようにする
68    const ran = await $.process.run(["rustc", "--version"], {
69      cwd,
70      timeoutMs: VERSION_COMMAND_TIMEOUT_MS,
71      env: { RUSTUP_AUTO_INSTALL: "0" },
72    });
73
74    return ran.exitCode === 0 ? parseRustVersion(ran.stdout) : "";
75  } catch (error) {
76    $.ui.log(`rustc --version failed: ${String(error)}`, { to: "debug" });
77
78    return "";
79  }
80}
81
82/**
83 * 保存済みの Rust の版を読む
84 * 描画の中で呼ぶと、syncRustVersion による更新時に再描画される
85 * @param $ engineのインターフェース
86 * @returns 保存済みの版。Rust のプロジェクトでない間や、取得できない間は空文字
87 */
88export function readRustVersion($: EngineInterface) {
89  return read($, rustVersionState);
90}
91
92/**
93 * 作業ディレクトリの Rust の版を取得し、保存済みの値を更新する
94 * 版は作業ディレクトリごとに1回だけ取得し、プロジェクトでない場合は rustc を実行しない
95 * @param $ engineのインターフェース
96 */
97export async function syncRustVersion($: EngineInterface) {
98  const cwd = await $.session.cwd();
99  const entries = await $.fs.list(cwd);
100
101  if (!isRustProject(entries)) {
102    await update($, rustVersionState, () => "");
103
104    return;
105  }
106
107  if (versionCache.cwd !== cwd) {
108    versionCache = { cwd, version: await fetchRustVersion($, cwd) };
109  }
110
111  await update($, rustVersionState, () => versionCache.version);
112}
113
hooks/terraform.ts 124 lines
1// starship参考
2// https://github.com/starship/starship/blob/main/src/modules/terraform.rs
3import type { EngineInterface } from "claude-code";
4import { atom, read, update } from "claude-code";
5
6import { VERSION_COMMAND_TIMEOUT_MS } from "./commandTimeout";
7import type { Entry } from "./directoryEntries";
8import { hasExtension, hasFolder } from "./directoryEntries";
9
10const terraformVersionState = atom(
11  { plugin: "statusline", key: "terraformVersion" } as const,
12  "",
13);
14
15const TERRAFORM_COMMANDS = ["terraform", "tofu"];
16const DETECT_FOLDERS = [".terraform"];
17const DETECT_EXTENSIONS = ["tf", "tofu", "tfplan", "tfstate"];
18
19// 取得の制御に使うだけで描画には使わないため、$.state ではなくモジュールの変数に持つ
20let versionCache = { cwd: "", version: "" };
21
22/**
23 * ディレクトリの直下の内容が、Terraform のプロジェクトかを判定する
24 * @param entries ディレクトリの直下の項目
25 * @returns Terraform のプロジェクトの場合は true
26 */
27export function isTerraformProject(entries: readonly Entry[]) {
28  return (
29    DETECT_FOLDERS.some((name) => hasFolder(entries, name)) ||
30    DETECT_EXTENSIONS.some((name) => hasExtension(entries, name))
31  );
32}
33
34/**
35 * terraform version の出力の1行目から、Terraform または OpenTofu と先頭の v を除いた版を取り出す
36 * 2行目以降には、更新の案内などが付くことがある
37 * @param output terraform version の標準出力
38 * @returns 版。取り出せない場合は空文字
39 */
40export function parseTerraformVersion(output: string) {
41  return (output.split("\n")[0] ?? "")
42    .replace(/^(Terraform|OpenTofu) /, "")
43    .trim()
44    .replace(/^v/, "");
45}
46
47/**
48 * Terraform の版を表示用の文字列に整形する
49 * @param version 版
50 * @returns 整形後の文字列
51 */
52export function formatTerraformVersion(version: string) {
53  return `terraform:${version}`;
54}
55
56/**
57 * Terraform の版のキャッシュを削除する
58 * 次の syncTerraformVersion では、キャッシュを使わず、terraform を実行して取得する
59 */
60export function clearTerraformVersionCache() {
61  versionCache = { cwd: "", version: "" };
62}
63
64/**
65 * 作業ディレクトリで version を実行し、最初に成功したコマンドの版を取得する
66 * @param $ engineのインターフェース
67 * @param cwd 作業ディレクトリ
68 * @returns 版。すべての候補が失敗した場合は空文字
69 */
70async function fetchTerraformVersion($: EngineInterface, cwd: string) {
71  for (const command of TERRAFORM_COMMANDS) {
72    try {
73      // 更新の確認で外部へ通信するため、無効にする
74      const ran = await $.process.run([command, "version"], {
75        cwd,
76        timeoutMs: VERSION_COMMAND_TIMEOUT_MS,
77        env: { CHECKPOINT_DISABLE: "1" },
78      });
79
80      if (ran.exitCode === 0) {
81        return parseTerraformVersion(ran.stdout);
82      }
83    } catch (error) {
84      $.ui.log(`${command} version failed: ${String(error)}`, {
85        to: "debug",
86      });
87    }
88  }
89
90  return "";
91}
92
93/**
94 * 保存済みの Terraform の版を読む
95 * 描画の中で呼ぶと、syncTerraformVersion による更新時に再描画される
96 * @param $ engineのインターフェース
97 * @returns 保存済みの版。Terraform のプロジェクトでない間や、取得できない間は空文字
98 */
99export function readTerraformVersion($: EngineInterface) {
100  return read($, terraformVersionState);
101}
102
103/**
104 * 作業ディレクトリの Terraform の版を取得し、保存済みの値を更新する
105 * 版は作業ディレクトリごとに1回だけ取得し、プロジェクトでない場合は terraform を実行しない
106 * @param $ engineのインターフェース
107 */
108export async function syncTerraformVersion($: EngineInterface) {
109  const cwd = await $.session.cwd();
110  const entries = await $.fs.list(cwd);
111
112  if (!isTerraformProject(entries)) {
113    await update($, terraformVersionState, () => "");
114
115    return;
116  }
117
118  if (versionCache.cwd !== cwd) {
119    versionCache = { cwd, version: await fetchTerraformVersion($, cwd) };
120  }
121
122  await update($, terraformVersionState, () => versionCache.version);
123}
124
hooks/outputStyle.ts 33 lines
1import type { EngineInterface } from "claude-code";
2import { atom, read, update } from "claude-code";
3
4const outputStyleState = atom(
5  { plugin: "statusline", key: "outputStyle" } as const,
6  {},
7);
8
9/**
10 * 保存済みのoutput style名を読む
11 * 描画の中で呼ぶと、syncOutputStyle による更新時に再描画される
12 * @param $ engineのインターフェース
13 * @returns 保存済みのoutput style名。設定が無い間は undefined
14 */
15export async function readOutputStyle($: EngineInterface) {
16  const { name } = await read($, outputStyleState);
17
18  return name;
19}
20
21/**
22 * 設定のoutputStyleを取得し、保存済みの値を更新する
23 * 設定が文字列でない場合は、名前の無いオブジェクトを保存する
24 * undefined は直接保存できないため、オブジェクトのプロパティとして持つ
25 * @param $ engineのインターフェース
26 */
27export async function syncOutputStyle($: EngineInterface) {
28  const outputStyle = await $.settings.read({ key: "outputStyle" });
29  await update($, outputStyleState, () => ({
30    name: typeof outputStyle === "string" ? outputStyle : undefined,
31  }));
32}
33