SLOPSHOPPER

dotfiles

Skills (drawio-diagram, jp-deslop, approval-gate, cmd-history, harness), the mods (auto-session-title, ado-link-bar, toast-notify, cmdHistory), and…

newpanebandguardcommandtoast
★ 1v0.8.2no licenseupdated 2026-10-25ntaksh42/dotfiles/claude/mods/dotfiles
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dotfiles
│ ┃ cmd-history ✕ › 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 │ │ › /cmdHistory │ ⎿ dotfiles: PowerShell の履歴が見つかりませんでした。 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · cmd-history
> : 絞り込む語(空白区切り) ⏎ 先頭を選ぶ 一致する履歴がありません
README

env - Claude Code 環境設定

Windows 環境の Claude Code dotfiles(hooks・skills・settings)を一元管理するリポジトリ。

リポジトリ構成

claude/
  install.ps1              インストーラ(共通ルールを両エージェントへ、settings.json を Claude へ配置)
  CLAUDE.global.md          Claude Code / Codex の共通ルールの管理元
  settings.template.json   settings.json のテンプレート
  mods/                    Claude Code プラグイン(dotfiles-mods マーケットプレイス)
    dotfiles/              自作の skills・hooks をまとめた plugin
app-settings/              アプリ設定ファイルのバックアップ
tools/                     汎用 PowerShell ユーティリティ

PowerShell コマンド

app-settings/pwsh/Microsoft.PowerShell_profile.ps1 は基本ヘルパー・エイリアスと Update-Profile を持つローダーで、機能本体は app-settings/pwsh/profile.d/ に 分割しています(navigation / git / vs-build / integrations / ai-cli / devtools-catalog / devtools / extra-tools / windows-settings / ssh / psreadline / help)。 読み込み順は profile.d/manifest.txt が決めます。新しいファイルを足すときは profile.d/ に置いて manifest.txt に 1 行追加します。配置時はローダーと profile.d/ の両方が必要です。

profile は現在読み込んでいるプロファイルを編集し、reload は再読み込みします。 Update-Profile は GitHub の main からローダーと profile.d/ 一式を取得し、構文検証・ 上書き確認・バックアップ後に更新するため、未公開のローカル変更は反映しません。

コマンド一覧は phelp(例: phelp git)、検索は Ctrl+G で開けます。 gl と cat は標準エイリアスを置き換え、それぞれ Git log、bat 連携として動作します。

Git の短縮名は app-settings/git/.gitconfig の [alias] に寄せてあり、g s、g co、 g cm <msg> のように打ちます。PowerShell 側(profile.d/git.ps1)には cd する・fzf で選ぶ・ 実行前に確認を取る・複数リポジトリをまとめる、といった git alias では書けないものだけを置いています。 git-help は両方をまとめて一覧表示します。

検証は pwsh -NoProfile -File app-settings/pwsh/tests/Invoke-AllTests.ps1 で全テストファイルを一括実行します(個別実行は各 Test-*.ps1)。

一部の端末だけに入れるアプリ

DevDeck(ntaksh42/DevDeck)と RdpManager(ntaksh42/rdp-manager)は全端末には不要なため、 Install-DevTools のカタログには入れず、専用の Install-ExtraTools で導入します。 GitHub Releases の最新インストーラを取得して実行し、導入済みで最新なら何もしません。 古い場合は更新し、実行中のアプリは終了します。

Install-ExtraTools                       # DevDeck と RdpManager の両方
Install-ExtraTools -Name DevDeck         # 指定したものだけ
Install-ExtraTools -Name RdpManager -Yes # 確認を省略

DevDeck は NSIS 版(ユーザー単位・無人)、RdpManager は MSI 版(マシン単位のため UAC が出ます)を使います。 対象を増やすときは profile.d/extra-tools.ps1 の $script:ExtraTools に 1 行足します。

Git 設定

PowerShell 7 / Git for Windows 向けの共有設定は app-settings/git/.gitconfig にあります。個人情報や署名鍵、認証 helper は 公開用の設定から分離し、~/.gitconfig.local で管理します。既存の設定が ある場合は、それをローカル設定として残してから共有設定を配置します。

# 既存の ~/.gitconfig がある場合(認証・ユーザー情報もそのまま保持)
Move-Item ~/.gitconfig ~/.gitconfig.local
Copy-Item app-settings/git/.gitconfig ~/.gitconfig

# Git を初めて設定する場合は、上の Move-Item の代わりにこちらを実行
Copy-Item app-settings/git/.gitconfig.local.example ~/.gitconfig.local
notepad ~/.gitconfig.local

delta が未導入の場合は Install-DevTools で導入できます。設定後は git config --global --list で読み込み結果を確認してください。

ステータスライン設定

claude/settings.template.json の statusLine は npx -y ccstatusline@2.2.32 を呼び出します(npm 上の新版を無検証で実行しないよう版を固定しています。更新するときはこの版を書き換えます)。 ccstatusline はレイアウト設定を ~/.config/ccstatusline/settings.json から読み込みます。この設定は app-settings/ccstatusline/settings.json を管理元として Install-DevTools (remote-config バックエンド)が配置・更新します。

Install-DevTools

ccstatusline はTUI上での編集で設定ファイル自身を書き換えるため、 Install-DevTools は配置済みの設定と管理元の内容が異なる場合、差分を表示した 上で上書き可否を確認します(手元での編集を誤って消さないよう、この確認は -Force を付けても省略されません)。上書き確認も含めてすべての確認に「はい」と 答える場合は Install-DevTools -Yes を使います(差分表示とバックアップは行われます)。

スキルの実体は ~/.agents/skills/ に置き、Claude Code と Codex で共有します。 Install-DevTools は既存の Claude スキルをそこへ移し、~/.claude/skills から 共通ディレクトリへのジャンクションを作ります(管理者権限・開発者モードは不要)。 同名のスキルは Claude 版を採用し、元の Codex 版は ~/.agents/skills-backup-<ID>/ に退避します。従来の Codex 側から Claude 側への個別リンクも移行できます。 無関係な場所へのリンクがある場合は、変更前にエラーにして手動解決を求めます。

別環境でスキルの共通化だけを行う場合は、このリポジトリで次を実行します。 再実行しても移行済みの構成は変更しません。

pwsh -NoProfile -File tools/Sync-DevToolSkills.ps1

配置後は Claude Code を再起動すると、モデル・コンテキスト使用率・git ブランチ・ セッション使用量などの構成が反映されます。

Claude Code Mods

Install-DevTools は claude/mods/dotfiles plugin を導入します。自作の skills・hooks・Mod(auto-session-title、ado-link-bar、toast-notify、cmdHistory)はすべてこの 1 つの plugin にまとめてあります(スキル名は dotfiles:<name>)。 dotfiles-mods マーケットプレイスが未登録なら GitHub の ntaksh42/dotfiles から登録し、 その後 claude plugin install を実行します。claude/install.ps1 がクローン先のパスで 登録済みの場合は、そのマーケットプレイスをそのまま使います。GitHub から登録する場合は、 Mod が main に取り込まれている必要があります。claude が PATH に無いとスキップされます。

組織の管理設定(allowManagedModsOnly など)で Mod が拒否される環境では、同じ plugin の代替が働きます。 トースト通知とセッション名は settings hook(scripts/lite-hook.mjs)、PR 表示は ccstatusline の statusline/pr-line.mjs、/cmdHistory は /dotfiles:cmd-history スキルです。代替では停滞検知、 モデルによるセッション名の要約、リンクバー(ado-link-bar)は使えません。Node 22.18 以上が必要です。

Codex ステータスライン

現在は一時的に無効化中です。 Install-DevTools は Codex ステータスラインを インストールせず、導入済みの環境では %LOCALAPPDATA%\CodexStatusline\codex-wt.ps1 を codex-wt.ps1.disabled に退避します(codex は通常の Codex CLI を起動します)。 再び有効にするには、.disabled を元の名前に戻し、Install-DevTools のカタログと Disable-CodexStatusline の呼び出しを元に戻します。

有効だった頃は、下側 18% のペインにモデル、推論強度、コンテキスト、git 状態、 セッション/週次の使用量を表示していました。tools/Install-CodexStatusline.ps1 と app-settings/codex-statusline/ は再開用に残してあります。

Crit

Install-DevTools は Crit 本体と Codex 連携もインストールします。取得した実行ファイルは リリースの checksums.txt と SHA256 を照合し、一致しなければ入れません。インストール時に ~/.crit.config.json の既存項目を維持しつつ、社内利用向けの安全設定として ローカルホスト限定、更新確認の無効化、Share の無効化、agent_cmd の無効化を設定します。

{
  "host": "127.0.0.1",
  "no_update_check": true,
  "share_url": "",
  "share_targets": [],
  "agent_cmd": ""
}

セットアップ手順

1. リポジトリをクローン

git clone https://github.com/ntaksh42/env.git
cd env

2. インストーラを実行

powershell.exe -ExecutionPolicy Bypass -File claude\install.ps1

インストーラが行うこと:

  • claude/CLAUDE.global.md を ~/.claude/CLAUDE.md と ~/.codex/AGENTS.md にコピー
  • settings.template.json からパスを解決して ~/.claude/settings.json を生成

共通ルールだけを配置・更新する場合は次を実行します。内容が異なる既存ファイルは .backup.<ID> に退避し、同じ内容なら変更しません。Codex の配置先は CODEX_HOME が設定されていればそれに従います。

pwsh -NoProfile -File tools/Install-AgentInstructions.ps1

ルールの変更は配置先ではなく claude/CLAUDE.global.md に行い、再配置してください。 AGENTS.override.md があると Codex はそちらを優先するため、インストーラは警告を表示します。 検証は pwsh -NoProfile -File tools/tests/Test-InstallAgentInstructions.ps1 で実行できます。

3. plugin を導入

Install-DevTools

skills・hooks・Mod を claude plugin install で導入します。

4. Claude Code / Codex の新しいセッションを開始

共通ルールを読み直すため、Claude Code / Codex の新しいセッションを開始してください。


AI 向け CLI ツール

%LOCALAPPDATA%\Microsoft\WinGet\Links\ に以下のツールをインストール済みです。

  • jq v1.8.1: JSON プロセッサ。API レスポンスや設定ファイルの前処理でトークン消費を大きく抑えられます。
  • rg (ripgrep) v15.1.0: .gitignore を自動除外する高速 grep。--json 出力に対応し、多くの AI エージェントと相性が良いです。
  • yq v4.53.2: YAML / TOML / XML プロセッサ。jq 風構文で K8s や CI 設定を処理できます。

ユーティリティ

app-settings/ の設定ファイルは Install-DevTools が GitHub の main から配置します (プロファイルは Update-Profile)。実環境で変えた設定は、手でリポジトリへコピーして取り込みます。

# Windows 設定(レジストリ)を適用 (pwsh プロファイル経由)。HKLM 項目は管理者権限が必要
Set-WindowsSettings -Check    # 差分のみ表示
Set-WindowsSettings           # 適用

Orca 設定

Install-DevTools は Orca 本体も winget(StablyAI.Orca)でインストールします。 導入済みの場合はスキップします。

app-settings/orca/keybindings.json は Install-DevTools が ~/.orca/keybindings.json へ配置します。 外観・エディタ・ターミナル設定の控えは app-settings/orca/settings.json にあります。 JSON の自動読み込みには対応していないため、app-settings/orca/README.md を参照してください。

Dockyard

Install-DevTools は Dockyard の最新リリース(MSIX)も インストール・更新します(tools/Install-Dockyard.ps1)。ARM64 の PC でも x64 版を入れます。 署名証明書が未信頼の場合は Cert:\LocalMachine\TrustedPeople へ登録するため UAC の確認が出ます。 登録するのはスクリプトに書いた拇印と一致する証明書だけです(証明書を作り直したらスクリプトの拇印も更新します)。 x64 版の .NET 10 Desktop Runtime が無ければ winget で入れます(ARM64 の PC では dotnet\x64 に入ります)。

Source 7 files
hooks/register.ts 29 lines
1import type { Register } from 'claude-code'
2
3import { classicSessionStart as linkBarClassicSessionStart, register as adoLinkBar, sessionStart as linkBarSessionStart } from './ado-link-bar/register'
4import { commandRunRename as titleCommandRunRename, register as autoSessionTitle, sessionStart as titleSessionStart } from './auto-session-title/register'
5import { register as cmdHistory, sessionStart as cmdHistorySessionStart } from './cmdHistory/register'
6import {
7  classicSessionStart as toastClassicSessionStart,
8  commandRunRename as toastCommandRunRename,
9  register as toastNotify,
10  sessionStart as toastSessionStart,
11  turnComplete as toastTurnComplete,
12} from './toast-notify/register'
13
14// The engine accepts one unmatched hook per event per plugin, so mods that share an event are nested here.
15
16export const register: Register = (on, options) => {
17  on('session.start', ($, e, next) =>
18    linkBarSessionStart($, e, (e1 => titleSessionStart($, e1, (e2 => cmdHistorySessionStart($, e2, (e3 => toastSessionStart($, e3, next)) as never)) as never)) as never))
19  on('turn.complete', toastTurnComplete)
20  on('classic.SessionStart', ($, e, next) =>
21    linkBarClassicSessionStart($, e, (e1 => toastClassicSessionStart($, e1, next)) as never))
22  on('command.run', { command: 'rename' }, ($, e, next) =>
23    titleCommandRunRename($, e, (e1 => toastCommandRunRename($, e1, next)) as never))
24  adoLinkBar(on, options)
25  autoSessionTitle(on, options)
26  cmdHistory(on, options)
27  toastNotify(on, options)
28}
29
hooks/ado-link-bar/register.tsx 162 lines
1import { atom, read, update } from 'claude-code'
2import type { Args, EngineInterface, Next, Register } from 'claude-code'
3
4import type { Links, WorkItemDetails } from '../../types'
5import { cut, findMentions, parseWorkItem, remember, textOf, transcriptTexts, workItemRef } from './links'
6
7const links = atom({ plugin: 'dotfiles', key: 'links' } as const, { prs: [], workItems: [] })
8const details = atom({ plugin: 'dotfiles', key: 'details' } as const, {} as WorkItemDetails)
9
10const EMPTY: Links = { prs: [], workItems: [] }
11// How much of a resumed transcript to read back: the recent end only.
12const TAIL_CHARS = 4 * 1024 * 1024
13// A transcript larger than this is not read back at all.
14const MAX_READ_BYTES = 64 * 1024 * 1024
15
16// Module variables start over on a reload; register sets them again from the settings.
17const cfg = { enabled: true, organization: '', prs: 5, workItems: 5 }
18// Work items whose title was already asked for, so each is fetched once.
19const requested = new Set<string>()
20
21// How az is started, found once.
22let azArgv: string[] | undefined
23
24// Piped, az writes JSON in the ANSI code page (cp932 on Japanese Windows), and the MSI's az.cmd
25// starts its python with -I, which ignores PYTHONUTF8. So Windows runs that python (az.cmd's
26// ..\python.exe) itself with -X utf8, and falls back to az.cmd through cmd where it is not there.
27async function azBase($: EngineInterface): Promise<string[]> {
28  if (azArgv) return azArgv
29  if ((await $.env.get('OS')) !== 'Windows_NT') return (azArgv = ['az'])
30  const where = await $.process.run(['where', 'az.cmd'])
31  const cmd = where.exitCode === 0 ? (where.stdout.split(/\r?\n/)[0] ?? '').trim() : ''
32  const python = cmd.replace(/[\\/][^\\/]+[\\/][^\\/]+$/, '\\python.exe')
33  const isFound = cmd !== '' && (await $.fs.stat(python).catch(() => null))?.kind === 'file'
34  return (azArgv = isFound ? [python, '-X', 'utf8', '-IBm', 'azure.cli'] : ['cmd', '/d', '/c', 'az'])
35}
36
37// cmd reads & | < > ^ % " ( ) itself, so an org taken from a link must not carry them through cmd.
38const CMD_UNSAFE = /[&|<>^%"!()\r\n]/
39
40async function az($: EngineInterface, args: string[]) {
41  const base = await azBase($)
42  if (base[0] === 'cmd' && args.some(a => CMD_UNSAFE.test(a))) throw new Error(`not passing ${args.join(' ')} through cmd`)
43  return $.process.run([...base, ...args], { timeoutMs: 30_000 })
44}
45
46// Looks up a work item's title and state; on failure the link keeps its bare #id.
47async function fetchDetails($: EngineInterface, url: string) {
48  const ref = workItemRef(url)
49  if (!ref) return
50  const ran = await az($, ['boards', 'work-item', 'show', '--id', ref.id, '--org', ref.orgUrl, '--fields', 'System.Title,System.State', '--expand', 'none', '-o', 'json'])
51  const info = ran.exitCode === 0 ? parseWorkItem(ran.stdout) : null
52  if (!info) {
53    await $.ui.log(`ado-link-bar: no title for ${url}: ${ran.stderr.trim().split('\n')[0] ?? `az exited ${ran.exitCode}`}`, { to: 'debug' })
54    return
55  }
56  await update($, details, prev => ({ ...(prev ?? {}), [url]: info }))
57}
58
59// Asks for the titles not asked for yet. They arrive later; the row redraws when they land.
60function requestDetails($: EngineInterface, urls: readonly string[]) {
61  for (const url of urls) {
62    if (requested.has(url)) continue
63    requested.add(url)
64    fetchDetails($, url).catch(error => $.ui.log(`ado-link-bar: ${String(error)}`, { to: 'debug' }))
65  }
66}
67
68// Adds what one message mentions to the front of the lists.
69async function note($: EngineInterface, text: string) {
70  const found = findMentions(text, cfg.organization)
71  if (found.length === 0) return
72  await update($, links, prev => {
73    const cur = prev ?? EMPTY
74    return {
75      prs: remember(cur.prs, found.filter(f => f.kind === 'pr'), cfg.prs),
76      workItems: remember(cur.workItems, found.filter(f => f.kind === 'workItem'), cfg.workItems),
77    }
78  })
79  requestDetails($, found.filter(f => f.kind === 'workItem').map(f => f.url))
80}
81
82export async function sessionStart($: EngineInterface, e: Args<'session.start'>, next: Next<'session.start'>) {
83  if (!cfg.enabled) return next(e)
84  const started = await next(e)
85  const l = (await read($, links)) ?? EMPTY
86  const known = (await read($, details)) ?? {}
87  requestDetails($, l.workItems.map(m => m.url).filter(url => !(url in known)))
88  return started
89}
90
91export async function classicSessionStart($: EngineInterface, e: Args<'classic.SessionStart'>, next: Next<'classic.SessionStart'>) {
92  if (!cfg.enabled) return next(e)
93  const result = await next(e)
94  if (e.source === 'clear') {
95    await update($, links, () => EMPTY)
96  } else if (e.source === 'resume' || e.source === 'fork') {
97    const stat = await $.fs.stat(e.transcript_path).catch(() => null)
98    if (stat?.kind === 'file' && stat.size <= MAX_READ_BYTES) {
99      const text = await $.fs.read(e.transcript_path).catch(() => '')
100      if (typeof text === 'string') await note($, transcriptTexts(text.slice(-TAIL_CHARS)).join('\n'))
101    }
102  }
103  return result
104}
105
106export const register: Register = (on, options) => {
107  cfg.enabled = options.linkBarEnabled !== false
108  if (!cfg.enabled) return
109
110  cfg.organization = String(options.linkBarOrganization ?? '').trim()
111  cfg.prs = Math.max(0, Math.floor(Number(options.linkBarPullRequests ?? 5)))
112  cfg.workItems = Math.max(0, Math.floor(Number(options.linkBarWorkItems ?? 5)))
113
114  // The lists outlive a reload; their titles are asked for again.
115
116  // The person's prompts and the model's replies count as mentions; tool
117  // output, reminders and subagents' rows do not.
118  on('session.append', async ($, e, next) => {
119    const isMention =
120      e.agentId === undefined && !e.message.isMeta && (e.door === 'prompt' || e.door === 'response')
121    if (isMention) await note($, textOf(e.message.content))
122    return next(e)
123  })
124
125  // A resumed session's rows are loaded, not appended: read them back from
126  // the transcript. /clear starts the lists over.
127
128  // The band above the prompt: one row of links. The hint line under the
129  // prompt keeps a single row on the terminal, so a second row there never shows.
130  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
131    if (e.props.hasSurvey) return next(e)
132    const l = (await read($, links)) ?? EMPTY
133    if (l.prs.length === 0 && l.workItems.length === 0) return next(e)
134    const info = (await read($, details)) ?? {}
135
136    const { Box, Text, Link } = $.ui.resolve(e)
137    const group = (title: string, items: Links['prs']) => [
138      <Text dimColor>{`${title} `}</Text>,
139      ...items.flatMap((m, i) => {
140        const wi = info[m.url]
141        return [
142          ...(i > 0 ? [<Text dimColor>{' · '}</Text>] : []),
143          <Link href={m.url}>{m.label}</Link>,
144          ...(wi ? [<Text>{` ${cut(wi.title)}`}</Text>] : []),
145          ...(wi?.state ? [<Text dimColor>{` ${wi.state}`}</Text>] : []),
146        ]
147      }),
148    ]
149    const groups = [
150      ...(l.prs.length ? [group('PRs', l.prs)] : []),
151      ...(l.workItems.length ? [group('WIs', l.workItems)] : []),
152    ]
153    const row = groups.flatMap((g, i) => [...(i > 0 ? [<Text dimColor>{'   '}</Text>] : []), ...g])
154
155    return (
156      <Box flexDirection="row" paddingLeft={2}>
157        {row}
158      </Box>
159    )
160  })
161}
162
hooks/auto-session-title/register.ts 88 lines
1import { atom, read, update } from 'claude-code'
2import type { Args, EngineInterface, Next, Register } from 'claude-code'
3
4import type { TitleSession } from '../../types'
5
6const PLUGIN = 'dotfiles'
7const MAX_LEN = 40
8const MAX_PROMPT_CHARS = 500
9// 最初のプロンプトで付け、以降はこの回数ごとに会話全体から付け直す
10const RENAME_EVERY = 5
11const RECENT_PROMPTS = 5
12
13const SYSTEM = 'Reply with only a short Japanese title (at most 30 characters) summarizing the user request below. Always write the title in Japanese, even if the request is in another language. No quotes, no trailing punctuation.'
14const RENAME_SYSTEM = 'Reply with only a short Japanese title (at most 30 characters) for the whole session, judging from the requests below. Keep the current title if it still fits. Always write the title in Japanese. No quotes, no trailing punctuation.'
15
16// mod の再読み込みで消えないよう $.state に持つ。/clear で session_id が変わっても前のタイトルが引き継がれるため、セッション単位で管理する
17const sessions = atom({ plugin: 'dotfiles', key: 'sessions' } as const, {})
18// 今のタイトル。手動の /rename で付いたものなら manual。/clear 後も引き継がれるのでセッションをまたいで持つ
19const current = atom({ plugin: 'dotfiles', key: 'current' } as const, { title: '', manual: false })
20
21// 組織管理下では classic.UserPromptSubmit が個人インストールの mod に回ってこないため、
22// prompt.submit で数えて /rename で付ける。/rename はセッションが空いてから走るのでフックでは待たない
23async function retitle($: EngineInterface, id: string, s: TitleSession, prompt: string) {
24  const isFirst = s.count === 1
25  const { title: now } = await read($, current)
26  const r = await $.model.complete({
27    model: 'haiku',
28    effort: 'low',
29    maxTokens: 60,
30    timeoutMs: 8000,
31    system: isFirst ? SYSTEM : RENAME_SYSTEM,
32    prompt: isFirst
33      ? prompt
34      : [`Current title: ${now}`, `First request: ${s.first}`, 'Recent requests:', ...s.recent.map(p => `- ${p}`)].join('\n'),
35  })
36  const summary = r.isAnswered ? (r.text.trim().split('\n')[0] ?? '') : ''
37  // 付け直しに失敗したら今のタイトルのままにする
38  if (!summary && !isFirst) return
39  const title = (summary || (prompt.split('\n')[0] ?? '')).slice(0, MAX_LEN)
40  if (!title || title === now) return
41  // 生成を待つ間の /rename・/clear や、後から始まった命名を上書きしない
42  if ((await read($, current)).manual || await $.session.id() !== id) return
43  const latest = (await read($, sessions))[id]
44  if (latest && Math.floor((latest.count - 1) / RENAME_EVERY) !== Math.floor((s.count - 1) / RENAME_EVERY)) return
45  await $.command.run({ command: 'rename', args: title })
46  await update($, current, () => ({ title, manual: false }))
47}
48
49// settings hook 版(scripts/lite-hook.mjs)は、この環境変数があればタイトルを付けない
50export async function sessionStart($: EngineInterface, e: Args<'session.start'>, next: Next<'session.start'>) {
51  await $.env.set('DOTFILES_MOD_TITLE', '1')
52  return next(e)
53}
54
55export async function commandRunRename($: EngineInterface, e: Args<'command.run'>, next: Next<'command.run'>) {
56  const ran = await next(e)
57  const title = e.args.trim()
58  if (title && !(e.origin?.kind === 'plugin' && e.origin.name === PLUGIN)) await update($, current, () => ({ title, manual: true }))
59  return ran
60}
61
62export const register: Register = on => {
63  on('prompt.submit', async ($, e, next) => {
64    const ran = await next(e)
65    if (ran.drop !== undefined || e.command || e.isMeta) return ran
66    // origin が無いものは本人の入力として扱う
67    const kind = e.origin?.kind ?? 'composer'
68    if (kind !== 'composer' && kind !== 'bridge') return ran
69    // 手動で付けた名前は上書きしない
70    if ((await read($, current)).manual) return ran
71
72    const prompt = e.text.trim().slice(0, MAX_PROMPT_CHARS)
73    if (!prompt || prompt.startsWith('/')) return ran
74    const id = await $.session.id()
75    const all = await update($, sessions, map => {
76      const cur: TitleSession = map[id] ?? { count: 0, first: prompt, recent: [] }
77      return { ...map, [id]: { ...cur, count: cur.count + 1, recent: [...cur.recent, prompt].slice(-RECENT_PROMPTS) } }
78    })
79    const s = all[id]
80    if (!s || (s.count - 1) % RENAME_EVERY !== 0) return ran
81
82    retitle($, id, s, prompt).catch(error => $.ui.log(`${PLUGIN}: ${String(error)}`, { to: 'debug' }))
83    return ran
84  })
85
86  // 自分の $.command.run はこのフックを通らないので、ここに来るのは手動の /rename
87}
88
hooks/cmdHistory/register.tsx 108 lines
1import { atom, read, update } from 'claude-code'
2import type { Args, EngineInterface, Next, Register } from 'claude-code'
3
4const PANE = 'cmd-history'
5const MAX_ENTRIES = 3000
6const entries = atom({ plugin: 'dotfiles', key: 'entries' } as const, [])
7const filter = atom({ plugin: 'dotfiles', key: 'filter' } as const, '')
8
9// PSReadLine の履歴ファイルを新しい順・重複なしの項目にする。複数行の項目は最終行以外の行末がバッククォート
10export const parseHistory = (text: string) => {
11  const items: string[] = []
12  let pending: string[] = []
13  for (const line of text.split(/\r?\n/)) {
14    if (line.endsWith('`')) {
15      pending.push(line.slice(0, -1))
16      continue
17    }
18    const item = [...pending, line].join('\n').trim()
19    pending = []
20    if (item !== '') items.push(item)
21  }
22  return [...new Set(items.reverse())].slice(0, MAX_ENTRIES)
23}
24
25// 空白区切りの語をすべて含む項目(大文字小文字は区別しない)
26export const matches = (list: readonly string[], query: string) => {
27  const words = query.toLowerCase().split(/\s+/).filter(Boolean)
28  return list.filter(item => words.every(w => item.toLowerCase().includes(w)))
29}
30
31async function loadHistory($: EngineInterface) {
32  const appData = await $.env.get('APPDATA')
33  if (appData === undefined) return []
34  const path = `${appData}/Microsoft/Windows/PowerShell/PSReadLine/ConsoleHost_history.txt`
35  if (!(await $.fs.exists(path))) return []
36  return parseHistory(await $.fs.read(path))
37}
38
39// ペインを閉じてから入力欄に入れる(ペインがキーを持っている間は入力欄が受け付けない)
40async function pick($: EngineInterface, command: string) {
41  await $.ui.close({ id: PANE })
42  const filled = await $.prompt.fill({ text: `!${command}` })
43  if (!filled.isFilled) $.ui.toast('cmdHistory: 入力欄に入れられませんでした')
44}
45
46export async function sessionStart($: EngineInterface, e: Args<'session.start'>, next: Next<'session.start'>) {
47  await $.command.register({
48    name: 'cmdHistory',
49    description: 'PowerShell の履歴から選んで ! コマンドとして入力欄に入れる',
50    argumentHint: '[絞り込む語]',
51  })
52  return next(e)
53}
54
55export const register: Register = on => {
56
57  on('command.run', { command: 'cmdHistory' }, async ($, e) => {
58    await update($, entries, () => [])
59    await update($, filter, () => e.args.trim())
60    const list = await loadHistory($)
61    if (list.length === 0) return { text: 'PowerShell の履歴が見つかりませんでした。' }
62    await update($, entries, () => list)
63    await $.ui.open({ id: PANE, title: 'PowerShell history', focus: true, closeOnEscape: true, rows: 16 })
64    return {}
65  })
66
67  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
68    // mobile には Input がない
69    if (e.surface === 'mobile') {
70      const { Text } = $.ui.resolve(e)
71      return <Text dimColor>端末で開いてください</Text>
72    }
73    const { Box, Text, Input, Button } = $.ui.resolve(e)
74    const query = await read($, filter)
75    const hits = matches(await read($, entries), query)
76    const room = Math.max(1, (e.props.scroll?.bodyRows ?? 16) - 2)
77    const width = Math.max(10, (e.props.bodyColumns ?? 80) - 2)
78    const label = (item: string) => {
79      const one = item.replace(/\n/g, ' ⏎ ')
80      return one.length > width ? `${one.slice(0, width - 1)}…` : one
81    }
82
83    return (
84      <Box flexDirection="column">
85        <Input
86          key="filter"
87          label="> "
88          placeholder="絞り込む語(空白区切り)"
89          value={query}
90          submitLabel="先頭を選ぶ"
91          autoFocus
92          onInput={(value: string) => void update($, filter, () => value)}
93          onSubmit={() => {
94            if (hits[0] !== undefined) void pick($, hits[0])
95          }}
96        />
97        {hits.length === 0 && <Text dimColor>一致する履歴がありません</Text>}
98        {hits.slice(0, room).map((item, i) => (
99          <Button key={`row:${i}`} plain onPress={() => void pick($, item)}>
100            {label(item)}
101          </Button>
102        ))}
103        {hits.length > room && <Text dimColor>ほか {hits.length - room} 件</Text>}
104      </Box>
105    )
106  })
107}
108
hooks/toast-notify/register.ts 160 lines
1import type { Args, EngineInterface, Next, Register } from 'claude-code'
2
3// Windows PowerShell 5.1 の AppUserModelID。pwsh 7 は WinRT を読めないため powershell.exe を使う
4const APP_ID = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe'
5// 送信は scripts/toast-send.ps1(タイトル・本文は環境変数で渡し、スクリプト側で XML エスケープする)。settings hook 版の scripts/lite-hook.mjs と共有する
6const MAX_BODY = 200
7// 停滞判定の間隔。活動の記録は同期的にフラグを立てるだけにし、時刻はここで読む
8const STALL_TICK_MS = 15_000
9
10// リロードで初期化されるため register で設定し直す
11const cfg = { enabled: true, minTurnMs: 30_000, minCommandMs: 60_000, stallMs: 300_000 }
12// メインのターン中に、最後に進捗(モデル出力・ツール呼び出し)があった時刻
13const stall = { active: false, last: 0, dirty: false, notified: false }
14const touch = () => { stall.dirty = true }
15// セッション名。/rename や auto-session-title の命名はプロンプト送信・セッション開始の入出力で拾う
16// 組織管理下では classic.* が個人インストールの mod に回ってこないため、/rename の実行からも拾う
17let sessionTitle = ''
18
19export const formatDuration = (ms: number) => {
20  const s = Math.round(ms / 1000)
21  return s < 60 ? `${s}秒` : `${Math.floor(s / 60)}分${s % 60 ? `${s % 60}秒` : ''}`
22}
23
24export const firstLine = (text: string) => {
25  const line = text.split('\n').map(l => l.trim()).find(l => l !== '') ?? ''
26  return line.length > MAX_BODY ? `${line.slice(0, MAX_BODY - 1)}…` : line
27}
28
29async function project($: EngineInterface) {
30  const cwd = await $.session.cwd()
31  return cwd.split(/[\\/]/).filter(Boolean).pop() ?? cwd
32}
33
34type Level = 'info' | 'warn'
35
36async function toast($: EngineInterface, title: string, body: string, level: Level) {
37  const ran = await $.process.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-File', `${$.plugin.root}/scripts/toast-send.ps1`], {
38    env: { TOAST_NOTIFY_TITLE: `${title} - ${await project($)}`, TOAST_NOTIFY_BODY: body, TOAST_NOTIFY_SESSION: sessionTitle, TOAST_NOTIFY_LEVEL: level, TOAST_NOTIFY_APP: APP_ID },
39    timeoutMs: 15_000,
40  })
41  if (ran.exitCode !== 0) await $.ui.log(`toast-notify: ${ran.stderr.trim().split('\n')[0]}`, { to: 'debug' })
42}
43
44// 通知の失敗でフックを止めない。表示を待たずに戻る
45function notify($: EngineInterface, title: string, body: string, level: Level = 'info') {
46  toast($, title, body, level).catch(error => $.ui.log(`toast-notify: ${String(error)}`, { to: 'debug' }))
47}
48
49export async function sessionStart($: EngineInterface, e: Args<'session.start'>, next: Next<'session.start'>) {
50  if (!cfg.enabled) return next(e)
51  // settings hook 版(scripts/lite-hook.mjs)は、この環境変数があれば通知しない
52  await $.env.set('DOTFILES_MOD_TOAST', '1')
53  const started = await next(e)
54  if (cfg.stallMs <= 0) return started
55  $.clock.every(STALL_TICK_MS, () => {
56    $.clock.now().then(now => {
57      if (!stall.active) return
58      if (stall.dirty) Object.assign(stall, { last: now, dirty: false, notified: false })
59      else if (!stall.notified && now - stall.last >= cfg.stallMs) {
60        stall.notified = true
61        notify($, 'Claude: 停止の可能性', `${formatDuration(cfg.stallMs)}間進捗がありません`, 'warn')
62      }
63    }, error => $.ui.log(`toast-notify: ${String(error)}`, { to: 'debug' }))
64  })
65  return started
66}
67
68export async function turnComplete($: EngineInterface, e: Args<'turn.complete'>, next: Next<'turn.complete'>) {
69  if (!cfg.enabled) return next(e)
70  const done = await next(e)
71  if (e.agentId === undefined) stall.active = false
72  if (e.agentId !== undefined || e.isAborted) return done
73  if (e.reason === 'error' || e.reason === 'refusal') {
74    const detail = e.reason === 'refusal' ? e.refusal.explanation ?? '' : firstLine(e.answer)
75    notify($, 'Claude: エラーで停止', detail || (e.reason === 'refusal' ? 'リクエストが拒否されました' : 'API エラー'), 'warn')
76  } else if (e.durationMs >= cfg.minTurnMs) {
77    notify($, `Claude: 完了 (${formatDuration(e.durationMs)})`, firstLine(e.answer) || '応答が完了しました')
78  }
79  return done
80}
81
82export async function classicSessionStart($: EngineInterface, e: Args<'classic.SessionStart'>, next: Next<'classic.SessionStart'>) {
83  if (!cfg.enabled) return next(e)
84  const ran = await next(e)
85  sessionTitle = ran.sessionTitle ?? e.session_title ?? sessionTitle
86  return ran
87}
88
89export async function commandRunRename($: EngineInterface, e: Args<'command.run'>, next: Next<'command.run'>) {
90  if (!cfg.enabled) return next(e)
91  const ran = await next(e)
92  sessionTitle = e.args.trim() || sessionTitle
93  return ran
94}
95
96export const register: Register = (on, options) => {
97  cfg.enabled = options.toastEnabled !== false
98  if (!cfg.enabled) return
99  cfg.minTurnMs = Math.max(0, Number(options.toastMinTurnSeconds ?? 30)) * 1000
100  cfg.minCommandMs = Math.max(0, Number(options.toastMinCommandSeconds ?? 60)) * 1000
101  cfg.stallMs = Math.max(0, Number(options.toastStallSeconds ?? 300)) * 1000
102
103  if (cfg.stallMs > 0) {
104
105    on('turn.start', async ($, e, next) => {
106      Object.assign(stall, { active: true, last: await $.clock.now(), dirty: false, notified: false })
107      return next(e)
108    })
109
110    // サブエージェントの出力も進捗とみなす
111    on('turn.step', async function* ($, e, next) {
112      const stream = next(e)
113      for await (const chunk of stream) {
114        touch()
115        yield chunk
116      }
117      return await stream.result
118    })
119  }
120
121
122  on('classic.UserPromptSubmit', async ($, e, next) => {
123    const ran = await next(e)
124    sessionTitle = ran.sessionTitle ?? e.session_title ?? sessionTitle
125    return ran
126  })
127
128  // 許可・質問ダイアログ。idle_prompt はターン完了通知と重なるので出さない
129  on('classic.Notification', async ($, e, next) => {
130    const done = await next(e)
131    if (e.agent_id === undefined && e.notification_type !== 'idle_prompt' && e.notification_type !== 'auth_success') {
132      notify($, 'Claude: 入力待ち', e.message, 'warn')
133    }
134    return done
135  })
136
137  // ツールの開始・終了も進捗とみなす(実行中のまま 5 分黙ったコマンドは停滞として通知する)
138  on('tool.call', async ($, e, next) => {
139    touch()
140    try {
141      return await next(e)
142    } finally {
143      touch()
144    }
145  })
146
147  for (const tool of ['Bash', 'PowerShell'] as const) {
148    on('tool.call', { tool }, async ($, e, next) => {
149      const started = await $.clock.now()
150      const ran = await next(e)
151      const elapsed = (await $.clock.now()) - started
152      if (ran.deny === undefined && elapsed >= cfg.minCommandMs) {
153        const failed = ran.isError === true
154        notify($, `${failed ? 'コマンド失敗' : 'コマンド完了'} (${formatDuration(elapsed)})`, firstLine(e.command), failed ? 'warn' : 'info')
155      }
156      return ran
157    })
158  }
159}
160
hooks/ado-link-bar/links.ts 107 lines
1import type { Mention, WorkItemInfo } from '../../types'
2
3export type Kind = 'pr' | 'workItem'
4
5export type Found = Mention & { kind: Kind }
6
7// dev.azure.com/<org> or the older <org>.visualstudio.com
8const HOST = String.raw`https:\/\/(?:dev\.azure\.com\/([^\/\s]+)|([\w-]+)\.visualstudio\.com(?:\/DefaultCollection)?)`
9const PR = new RegExp(String.raw`^${HOST}\/([^\/\s]+)\/_git\/([^\/\s?#]+)\/pullrequest\/(\d+)`, 'i')
10const WORK_ITEM = new RegExp(String.raw`^${HOST}(?:\/[^\/\s]+)?\/_workitems\/edit\/(\d+)`, 'i')
11const URL_RE = /https:\/\/[^\s<>()[\]{}"'`|\\]+/g
12// AB#1234: Azure Boards' own notation for a work item.
13const AB_RE = /\bAB#(\d+)\b/g
14
15// What a URL is, its canonical form and its label: `repo!12` for a pull
16// request (Azure DevOps' own notation), `#1234` for a work item.
17export const classify = (url: string): Found | null => {
18  const pr = PR.exec(url)
19  if (pr) return { kind: 'pr', url: pr[0], label: `${decodeURIComponent(pr[4] ?? '')}!${pr[5]}` }
20  const wi = WORK_ITEM.exec(url)
21  if (wi) {
22    // One URL per work item, whichever project path the mention carried.
23    const base = wi[1] ? `https://dev.azure.com/${wi[1]}` : `https://${wi[2]}.visualstudio.com`
24    return { kind: 'workItem', url: `${base}/_workitems/edit/${wi[3]}`, label: `#${wi[3]}` }
25  }
26  return null
27}
28
29// Every pull request and work item in a message, in the order written.
30// AB#1234 counts only when `organization` is set.
31export const findMentions = (text: string, organization = ''): Found[] => {
32  const hits: { at: number; found: Found }[] = []
33  for (const m of text.matchAll(URL_RE)) {
34    const found = classify(m[0])
35    if (found) hits.push({ at: m.index ?? 0, found })
36  }
37  if (organization) {
38    for (const m of text.matchAll(AB_RE)) {
39      const url = `https://dev.azure.com/${organization}/_workitems/edit/${m[1]}`
40      hits.push({ at: m.index ?? 0, found: { kind: 'workItem', url, label: `#${m[1]}` } })
41    }
42  }
43  return hits.sort((a, b) => a.at - b.at).map(h => h.found)
44}
45
46// The organization URL and id `az boards work-item show` needs for a work
47// item link as `classify` writes it.
48export const workItemRef = (url: string): { orgUrl: string; id: string } | null => {
49  const m = /^https:\/\/(?:dev\.azure\.com\/([^\/]+)|([\w-]+)\.visualstudio\.com)\/_workitems\/edit\/(\d+)$/.exec(url)
50  if (!m || !m[3]) return null
51  return { orgUrl: `https://dev.azure.com/${m[1] ?? m[2]}`, id: m[3] }
52}
53
54// The title and state in `az boards work-item show -o json` output.
55export const parseWorkItem = (json: string): WorkItemInfo | null => {
56  try {
57    const fields = (JSON.parse(json) as { fields?: Record<string, unknown> }).fields ?? {}
58    const title = fields['System.Title']
59    const state = fields['System.State']
60    return typeof title === 'string' ? { title, state: typeof state === 'string' ? state : '' } : null
61  } catch {
62    return null
63  }
64}
65
66// Shortens a title to fit the band.
67export const cut = (s: string, max = 20) => {
68  const t = s.replace(/\s+/g, ' ').trim()
69  return t.length <= max ? t : `${t.slice(0, max - 1).trimEnd()}…`
70}
71
72// Moves each found link to the front, the last one written ending up first,
73// and keeps `max`.
74export const remember = (list: readonly Mention[], found: readonly Found[], max: number): Mention[] => {
75  let next = [...list]
76  for (const f of found) next = [{ url: f.url, label: f.label }, ...next.filter(m => m.url !== f.url)]
77  return next.slice(0, Math.max(0, max))
78}
79
80// The text a transcript row carries: a string, or its text blocks.
81export const textOf = (content: unknown): string => {
82  if (typeof content === 'string') return content
83  if (!Array.isArray(content)) return ''
84  return content
85    .map(b => (b && typeof b === 'object' && (b as { type?: unknown }).type === 'text' ? String((b as { text?: unknown }).text ?? '') : ''))
86    .join('\n')
87}
88
89// The person's prompts and the model's replies in a transcript file's JSONL,
90// oldest first; meta rows, tool results and subagent rows are left out.
91export const transcriptTexts = (jsonl: string): string[] => {
92  const out: string[] = []
93  for (const line of jsonl.split('\n')) {
94    if (!line.startsWith('{')) continue
95    let row: { type?: string; isMeta?: boolean; isSidechain?: boolean; message?: { content?: unknown } }
96    try {
97      row = JSON.parse(line)
98    } catch {
99      continue
100    }
101    if ((row.type !== 'user' && row.type !== 'assistant') || row.isMeta || row.isSidechain) continue
102    const text = textOf(row.message?.content)
103    if (text) out.push(text)
104  }
105  return out
106}
107
types/index.d.ts 28 lines
1// ado-link-bar: バーに出すリンク(新しい順、設定の件数で切り詰め済み)
2export type Mention = { url: string; label: string }
3export type Links = { prs: Mention[]; workItems: Mention[] }
4// ado-link-bar: 作業項目のタイトルと状態(URL をキーにする)
5export type WorkItemInfo = { title: string; state: string }
6export type WorkItemDetails = Record<string, WorkItemInfo>
7
8// auto-session-title: 1 セッション分の記録(数えたプロンプト数、最初の依頼、直近の依頼)
9export type TitleSession = { count: number; first: string; recent: string[] }
10// auto-session-title: 今のタイトルと、それが手動の /rename で付いたものか
11export type TitleCurrent = { title: string; manual: boolean }
12
13// cmdHistory: 新しい順・重複なしの PowerShell 履歴
14export type CmdHistoryEntries = string[]
15
16declare module 'claude-code' {
17  interface PluginState {
18    dotfiles: {
19      links: Links
20      details: WorkItemDetails
21      sessions: Record<string, TitleSession>
22      current: TitleCurrent
23      entries: CmdHistoryEntries
24      filter: string
25    }
26  }
27}
28