Links in the band above the prompt to the Azure DevOps pull requests and work items mentioned most recently in the conversation.

Windows 環境の Claude Code dotfiles(hooks・skills・settings)を一元管理するリポジトリ。
claude/
install.ps1 インストーラ(hooks・skills・settings を ~/.claude に展開)
settings.template.json settings.json のテンプレート
hooks/ Claude Code フック用スクリプト
skills/ Claude Code スキル
app-settings/ アプリ設定ファイルのバックアップ
tools/ 汎用 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 / psreadline / help)。 読み込み順は profile.d/manifest.txt が決めます。新しいファイルを足すときは profile.d/ に置いて manifest.txt に 1 行追加します。配置時はローダーと profile.d/ の両方が必要です。
ローカルの変更を反映する場合は、リポジトリから同期します。
pwsh -NoProfile -File tools/Sync-AppSettings.ps1 -WhatIf
pwsh -NoProfile -File tools/Sync-AppSettings.ps1
同期先は Windows の Documents フォルダ配下の PowerShell/ です。 実環境で編集した場合は -Direction Pull でローダーと profile.d/ を管理元へ取り込みます。 profile は現在読み込んでいるプロファイルを編集し、reload は再読み込みします。 Update-Profile は GitHub の main からローダーと profile.d/ 一式を取得し、構文検証・ 上書き確認・バックアップ後に更新するため、未公開のローカル変更は反映しません。
コマンド一覧は phelp(例: phelp git)、検索は Ctrl+G で開けます。 gl、gp、gcm、cat は標準エイリアスを置き換え、それぞれ Git log、Git push、Git commit、bat 連携として動作します。 gco の引数なし実行は git-switch と同じブランチ選択を使います。 その他の既存の短縮名も維持しています。
検証は 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 行足します。
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@latest を呼び出します。ccstatusline はレイアウト設定を ~/.config/ccstatusline/settings.json から読み込みます。この設定は app-settings/ccstatusline/settings.json を管理元として Install-DevTools (remote-config バックエンド)が配置・更新します。
Install-DevTools
ccstatusline はTUI上での編集で設定ファイル自身を書き換えるため、 Install-DevTools は配置済みの設定と管理元の内容が異なる場合、差分を表示した 上で上書き可否を確認します(手元での編集を誤って消さないよう、この確認は -Force を付けても省略されません)。上書き確認も含めてすべての確認に「はい」と 答える場合は Install-DevTools -Yes を使います(差分表示とバックアップは行われます)。
Install-DevTools は ~/.agents/skills/ にだけあるスキルを ~/.claude/skills/ へ移し、 元の場所にシンボリックリンクを作ります。同名の既存スキルと SKILL.md のないディレクトリは変更しません。
配置後は Claude Code を再起動すると、モデル・コンテキスト使用率・git ブランチ・ セッション使用量などの構成が反映されます。
Install-DevTools は claude/mods/ の Mod(auto-session-title、toast-notify など)も導入します。 dotfiles-mods マーケットプレイスが未登録なら GitHub の ntaksh42/dotfiles から登録し、 その後 claude plugin install を実行します。claude/install.ps1 がクローン先のパスで 登録済みの場合は、そのマーケットプレイスをそのまま使います。GitHub から登録する場合は、 Mod が main に取り込まれている必要があります。claude が PATH に無いとスキップされます。
現在は一時的に無効化中です。 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/ は再開用に残してあります。
Install-DevTools は Crit 本体と Codex 連携もインストールします。インストール時に ~/.crit.config.json の既存項目を維持しつつ、社内利用向けの安全設定として ローカルホスト限定、更新確認の無効化、Share の無効化、agent_cmd の無効化を設定します。
{
"host": "127.0.0.1",
"no_update_check": true,
"share_url": "",
"share_targets": [],
"agent_cmd": ""
}
git clone https://github.com/ntaksh42/env.git
cd env
powershell.exe -ExecutionPolicy Bypass -File claude\install.ps1
インストーラが行うこと:
claude/hooks/*.ps1 を ~/.claude/hooks/ にコピーclaude/skills/ を ~/.claude/skills/ にコピーclaude/agents/*.md を ~/.claude/agents/ にコピーsettings.template.json からパスを解決して ~/.claude/settings.json を生成.HOOK メタデータを読み取り、settings.json に自動登録設定を反映するために Claude Code を再起動してください。
%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 設定を処理できます。各フックスクリプトは先頭に .HOOK メタデータブロックを持ちます:
<#
.HOOK
{
"event": "PostToolUse",
"matcher": "Task",
"async": true
}
#>
install.ps1 がこのブロックを解析して settings.json の hooks セクションに自動登録します。 新しいフックを追加する場合は、スクリプト先頭にこのブロックを含めるだけで自動的に反映されます。
# 複数 git リポジトリを一括 pull
powershell.exe -File tools\Update-GitRepositories.ps1 -Path "C:\Projects"
# app-settings/ の設定ファイルと実環境の配置先を同期
pwsh -File tools\Sync-AppSettings.ps1 # repo -> 実環境 (既定)
pwsh -File tools\Sync-AppSettings.ps1 -Direction Pull # 実環境 -> repo
# Windows 設定(レジストリ)を適用 (pwsh プロファイル経由)。HKLM 項目は管理者権限が必要
Set-WindowsSettings -Check # 差分のみ表示
Set-WindowsSettings # 適用
Install-DevTools は Orca 本体も winget(StablyAI.Orca)でインストールします。 導入済みの場合はスキップします。
Orca の外観・エディタ・ターミナル設定は app-settings/orca/settings.json に保存します。 python tools/Export-OrcaSettings.py で現在の設定を取り込めます。認証情報や作業履歴は 対象外です。JSON の自動読み込みには対応していないため、復元方法と対象範囲は app-settings/orca/README.md を参照してください。
PowerToys の設定(FancyZones のレイアウト・ホットキーを含む)は app-settings/powertoys/ に保存します。対象は PowerToys 公式の Back up & restore と 同じ設定ファイルです。ユーザー名入りのパスを含む Workspaces\workspaces.json と NewPlus\settings.json は公開リポジトリに載せないため対象外です。
pwsh -File tools\Sync-PowerToysSettings.ps1 -Direction Pull # 実環境 -> repo(バックアップ)
pwsh -File tools\Sync-PowerToysSettings.ps1 -WhatIf # 復元の対象だけ確認
pwsh -File tools\Sync-PowerToysSettings.ps1 # repo -> 実環境(復元)
バックアップ(Pull)は app-settings/powertoys/manifest.txt も更新します。 新しい PC では Install-DevTools が PowerToys 本体(winget)に続いて設定を GitHub の main から配置します(PowerToys settings)。FancyZones\custom-layouts.json が未作成の環境にだけ 一度実行し、既存ファイルが異なる場合は .backup.<日時> を残します。設定を取り直すときは 上の Sync-PowerToysSettings.ps1 を使います。
復元は PowerToys を停止してコピーし、起動していた場合は再起動します。 FancyZones のモニターごとの適用レイアウト(applied-layouts.json)は対象外なので、 復元後に各モニターへレイアウトを選び直してください。
hooks/register.tsx 140 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, 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: 'ado-link-bar', key: 'links' } as const, { prs: [], workItems: [] })
8const details = atom({ plugin: 'ado-link-bar', 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 = { 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// az.cmd only starts through a shell, so Windows goes through cmd. Piped, az writes JSON in the
22// console code page (cp932 on Japanese Windows), so PYTHONUTF8=1 pins it to UTF-8 for the titles.
23async function az($: EngineInterface, args: string[]) {
24 const isWindows = (await $.env.get('OS')) === 'Windows_NT'
25 const argv = isWindows ? ['cmd', '/d', '/c', 'set', 'PYTHONUTF8=1&&', 'az', ...args] : ['az', ...args]
26 return $.process.run(argv, { timeoutMs: 30_000 })
27}
28
29// Looks up a work item's title and state; on failure the link keeps its bare #id.
30async function fetchDetails($: EngineInterface, url: string) {
31 const ref = workItemRef(url)
32 if (!ref) return
33 const ran = await az($, ['boards', 'work-item', 'show', '--id', ref.id, '--org', ref.orgUrl, '--fields', 'System.Title,System.State', '--expand', 'none', '-o', 'json'])
34 const info = ran.exitCode === 0 ? parseWorkItem(ran.stdout) : null
35 if (!info) {
36 await $.ui.log(`ado-link-bar: no title for ${url}: ${ran.stderr.trim().split('\n')[0] ?? `az exited ${ran.exitCode}`}`, { to: 'debug' })
37 return
38 }
39 await update($, details, prev => ({ ...(prev ?? {}), [url]: info }))
40}
41
42// Asks for the titles not asked for yet. They arrive later; the row redraws when they land.
43function requestDetails($: EngineInterface, urls: readonly string[]) {
44 for (const url of urls) {
45 if (requested.has(url)) continue
46 requested.add(url)
47 fetchDetails($, url).catch(error => $.ui.log(`ado-link-bar: ${String(error)}`, { to: 'debug' }))
48 }
49}
50
51// Adds what one message mentions to the front of the lists.
52async function note($: EngineInterface, text: string) {
53 const found = findMentions(text, cfg.organization)
54 if (found.length === 0) return
55 await update($, links, prev => {
56 const cur = prev ?? EMPTY
57 return {
58 prs: remember(cur.prs, found.filter(f => f.kind === 'pr'), cfg.prs),
59 workItems: remember(cur.workItems, found.filter(f => f.kind === 'workItem'), cfg.workItems),
60 }
61 })
62 requestDetails($, found.filter(f => f.kind === 'workItem').map(f => f.url))
63}
64
65export const register: Register = (on, options) => {
66 if (options.enabled === false) return
67
68 cfg.organization = String(options.organization ?? '').trim()
69 cfg.prs = Math.max(0, Math.floor(Number(options.pullRequests ?? 5)))
70 cfg.workItems = Math.max(0, Math.floor(Number(options.workItems ?? 5)))
71
72 // The lists outlive a reload; their titles are asked for again.
73 on('session.start', async ($, e, next) => {
74 const started = await next(e)
75 const l = (await read($, links)) ?? EMPTY
76 const known = (await read($, details)) ?? {}
77 requestDetails($, l.workItems.map(m => m.url).filter(url => !(url in known)))
78 return started
79 })
80
81 // The person's prompts and the model's replies count as mentions; tool
82 // output, reminders and subagents' rows do not.
83 on('session.append', async ($, e, next) => {
84 const isMention =
85 e.agentId === undefined && !e.message.isMeta && (e.door === 'prompt' || e.door === 'response')
86 if (isMention) await note($, textOf(e.message.content))
87 return next(e)
88 })
89
90 // A resumed session's rows are loaded, not appended: read them back from
91 // the transcript. /clear starts the lists over.
92 on('classic.SessionStart', async ($, e, next) => {
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
106 // The band above the prompt: one row of links. The hint line under the
107 // prompt keeps a single row on the terminal, so a second row there never shows.
108 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
109 if (e.props.hasSurvey) return next(e)
110 const l = (await read($, links)) ?? EMPTY
111 if (l.prs.length === 0 && l.workItems.length === 0) return next(e)
112 const info = (await read($, details)) ?? {}
113
114 const { Box, Text, Link } = $.ui.resolve(e)
115 const group = (title: string, items: Links['prs']) => [
116 <Text dimColor>{`${title} `}</Text>,
117 ...items.flatMap((m, i) => {
118 const wi = info[m.url]
119 return [
120 ...(i > 0 ? [<Text dimColor>{' · '}</Text>] : []),
121 <Link href={m.url}>{m.label}</Link>,
122 ...(wi ? [<Text>{` ${cut(wi.title)}`}</Text>] : []),
123 ...(wi?.state ? [<Text dimColor>{` ${wi.state}`}</Text>] : []),
124 ]
125 }),
126 ]
127 const groups = [
128 ...(l.prs.length ? [group('PRs', l.prs)] : []),
129 ...(l.workItems.length ? [group('WIs', l.workItems)] : []),
130 ]
131 const row = groups.flatMap((g, i) => [...(i > 0 ? [<Text dimColor>{' '}</Text>] : []), ...g])
132
133 return (
134 <Box flexDirection="row" paddingLeft={2}>
135 {row}
136 </Box>
137 )
138 })
139}
140hooks/links.ts 107 lines1import 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}
107types/index.d.ts 16 lines1// One link in the bar: where it goes and the short text drawn for it.
2export type Mention = { url: string; label: string }
3
4// Newest first, each list already cut to its setting.
5export type Links = { prs: Mention[]; workItems: Mention[] }
6
7// A work item's title and state as Azure Boards reports them, keyed by its URL.
8export type WorkItemInfo = { title: string; state: string }
9export type WorkItemDetails = Record<string, WorkItemInfo>
10
11declare module 'claude-code' {
12 interface PluginState {
13 'ado-link-bar': { links: Links; details: WorkItemDetails }
14 }
15}
16