The Configuration pane of the kit's panes (a button on the band, /configuration): every mod's settings, for all computers or this one, with the value the other…

Skills, mods, hooks and a status line for Claude Code, plus a sync engine that keeps your Claude Code config the same on all your computers. Every part is tested on macOS, Linux and Windows on each change.
Every part is a plugin of this repo's marketplace. Add it once, then install the parts you want, from Claude Code:
/plugin marketplace add robinalexandre/claude-code-kit
# Every day, for anyone
/plugin install team@claude-code-kit
/plugin install statusline@claude-code-kit
/plugin install file-tools-guard@claude-code-kit
/plugin install recent-prompts@claude-code-kit
/plugin install dnd@claude-code-kit
/plugin install paste-preview@claude-code-kit
/plugin install recent-activity@claude-code-kit
/plugin install session-registry@claude-code-kit
/plugin install drift-check@claude-code-kit
/plugin install drift-check-hook@claude-code-kit
/plugin install nocturne-notify@claude-code-kit
# Development work
/plugin install git-identity-guard@claude-code-kit
/plugin install pr-watch@claude-code-kit
/plugin install pr-pane@claude-code-kit
/plugin install test-evidence@claude-code-kit
/plugin install ci-review@claude-code-kit
/plugin install capability-builder@claude-code-kit
/plugin install learn@claude-code-kit
/plugin install kb@claude-code-kit
# Several computers
/plugin install config-sync@claude-code-kit
/plugin install handoff@claude-code-kit
/plugin install daily-sync@claude-code-kit
/plugin install reminders@claude-code-kit
/plugin install reminders-handoffs@claude-code-kit
# Not dev
/plugin install video-montage@claude-code-kit
/plugin install insta360-montage@claude-code-kit
From a terminal it's the same with claude plugin marketplace add … and claude plugin install …; or /plugin alone to browse the parts. What a part needs comes with it: pr-pane brings pr-watch; reminders brings daily-sync, which brings config-sync. Start a new session afterwards.
Keep them up to date: Claude Code doesn't auto-update a marketplace other than Anthropic's own unless you turn it on, once: /plugin → Marketplaces → claude-code-kit → Enable auto-update. It then updates the parts in the background after a session starts (and asks for a restart). Or set it in ~/.claude/settings.json:
"extraKnownMarketplaces": {
"claude-code-kit": { "source": { "source": "github", "repo": "robinalexandre/claude-code-kit" }, "autoUpdate": true }
}
By hand: claude plugin marketplace update claude-code-kit, then claude plugin update <part>@claude-code-kit.
Setup: a part that needs a few answers (the status line in your settings, the emails for the email guard, where pr-watch looks, the phone for reminders, your private repo for config-sync, the zsh line for session-registry) says so at the first session after install, and Claude gives you the command to run in your terminal; it stops asking once it's done or you say no. Options: the language of the team rules and the opening text of drift-check are asked when you install from /plugin, and changed with claude plugin configure <part>@claude-code-kit.
The installer copies the parts you pick into ~/.claude instead. Use it to pick from a checklist, to install without a marketplace, or to edit your copies. A part already installed as a plugin is skipped.
Keep them up to date: the installer also adds a small session-start check (kit-update-check). Once a day, in the background, it fetches the clone you installed from. When that clone has changes for the parts you installed, or new parts you could add, Claude tells you at the next session start what changed and offers to update. ./install.sh --update pulls the clone and installs the changed parts again, without asking their setup questions again; ./install.sh --update <new part>… adds new ones too. Each new part is mentioned once. Turn the check off with bash ~/.claude/hooks/kit-update-check.sh off (on to resume). What came from where is recorded in ~/.claude/kit-install.json.
git clone https://github.com/robinalexandre/claude-code-kit.git
cd claude-code-kit
./install.sh # a checklist: arrows, Enter to pick, → for what each part does, then Install
./install.sh --plain # the same as one question per part
./install.sh --list # lists the parts
./install.sh --all # installs every part offered on this computer
./install.sh statusline file-tools-guard # installs the named parts
./install.sh --update # pulls, then installs again the parts that changed
On Windows, from Git Bash the same, or from PowerShell: powershell -ExecutionPolicy Bypass -File .\install.ps1 (it installs git and jq with winget when missing, then runs install.sh in Git Bash).
The installer shows the parts as a checklist by group (every day, development, several computers, not dev): ↑↓ to move, Enter or Space to pick a part or a whole group, → or Tab to unfold why, what, when and who for a part, a for all, then Install at the bottom. It greys out what doesn't apply to this computer, adds what a picked part needs (pr-pane brings pr-watch, reminders brings daily-sync), then sets up each part it installed: the emails for the email guard, where pr-watch looks for your PRs, the phone for reminders, the browser tool for test-evidence. Run a part's setup again any time with bash ~/.claude/<part>/setup.sh (hooks: bash ~/.claude/hooks/setup/<hook>.sh). It copies each part into ~/.claude, adds its hooks to ~/.claude/settings.json and saves whatever it replaces in ~/.claude/backups/before-kit-<date>/. Running it again is safe. Start a new Claude Code session afterwards. Needs jq.
To try it first without touching your own setup, tests/install-sandbox.sh runs the installer in a throwaway home folder, shows what it left and checks your files are unchanged; --claude <sandbox> then opens a first Claude Code session there.
The sync engine is a part too (./install.sh config-sync), added on its own when you pick handoff, daily-sync or reminders: the installer asks for the clone URL of an empty private git repo of your own (any host), clones it to ~/.claude-sync and sets it up; on your next computer, give the same URL. By hand: sync/README.md.
A new computer like another one: on a computer whose config isn't synced yet, the installer first asks whether it is synced from another computer, and if so sets up the sync before anything else. Each computer records its answers to the kit's questions in ~/.claude/kit-answers.txt (never a secret, such as the ntfy topic), and the sync shares them with the parts it installed. So the new computer can be set up:
~/.zshrc line, the status line it has.A part's setup run later (a plugin's, at its first session) uses the same choice, kept in ~/.claude/kit-reuse.txt; delete that file to be asked again.
rules/team.md is the base of everything here: Claude and you work as one team that builds, thinks things through and challenges each other. It asks before touching production or shared infrastructure (in two lines, not twenty), before each commit, push and PR, and before decisions that need context only you have, with the options and a recommendation. It proposes a skill, mod or hook when you keep asking for the same thing, and an AI review step with proof (screenshots, videos) on the CI of repos that don't have one. The team plugin hands them to Claude at each session start, in the language you pick in its options, so they update with the plugin; install.sh asks for the language and adds them to ~/.claude/CLAUDE.md once instead. A capability can use another language when you say so.
| Skill | What it does | Needs |
|---|---|---|
| test-evidence | Captures proof that a ticket works locally: browser screenshots, a short video of the flow, the network requests and console behind it (through the chrome-devtools MCP), or one app window or screen region, never the whole screen unless asked. Masks tokens and cookies, writes a short test report and, after your yes, attaches it to the Jira issue or as a resolved comment on the Azure DevOps PR; on GitHub, it gives you the comment and the files to drop in. | chrome-devtools MCP, jq, perl; video needs ffmpeg |
| ci-review | Proposes, once per repo, an AI review step on the CI and sets it up with you: an agent reads each PR/MR's diff and its proof (what test-evidence captures) and posts a verdict (works / doesn't / can't tell, with what to capture). Templates for anthropics/claude-code-action, the CLI in headless mode (claude -p) on GitHub Actions and GitLab CI, and cloud providers; the review prompt is a plain file any agent CLI can take. | an API key or a cloud provider account, set as a CI secret |
| capability-builder | Turns a request you keep making into a capability: picks the right kind (rule, skill, hook, mod or plain script), interviews you, scaffolds it with its tests and setup, checks it against a macOS / Linux / Windows checklist, and runs the publish checklist (nothing personal, tool-agnostic) before it goes to a kit. How this kit's own parts are built. | |
| handoff | Writes a handoff note so you can continue the current work on your other computer, and picks it up there. A note picked up or canceled moves to done/, kept 60 days; reminders-handoffs lists them. | the sync engine |
| daily-sync | Schedules a sync every morning (09:00 by default) with launchd, cron or the Windows Task Scheduler, so each computer gets what the others left even with no session open. Other skills can hook a morning.sh onto it. | the sync engine |
| reminders | "Remind me tomorrow to…": leaves a global or repo-specific reminder that every new session, on any of your computers, brings up first thing, without slowing the start: what the background sync brings arrives with your next message. A reminder can repeat ("every Friday": at 09:00 unless it says a time), be for one computer, and be filed under a group (an organization, a client); done closes a repeating one for the day, stop ends it, and closed ones stay listed as past for 60 days. Due reminders also go to the desktop and, through ntfy, to your phone every morning and at the first session of the day. | daily-sync; curl for the phone |
| kb | A personal knowledge base. Notes are Markdown files with a front matter (type, trust, topics, validity dates) and [[links]], in your config sync (so every computer and your other agents get them) or in ~/.claude/knowledge; the kb command indexes them in SQLite for full-text search (accents folded), semantic search in any language (a local multilingual model, no API) and the link graph (kb related, as a list or a Mermaid mind map). At session start Claude is told which notes mention the repository or the branch, beyond the repository's own memory; on each prompt, which notes hold most of its words. Every morning (through daily-sync), Claude reads the previous day's Claude Code transcripts of this computer, reduced to prompts, replies and files touched with secrets redacted, and proposes notes to create or merge; the code checks them (names, dates, secrets, no overwrite) and writes them with a report, each marked as the curator's. kb serve and its Dockerfile give the same index over HTTP, behind a token, to an agent of yours. Notes a third party wrote (trust: third-party) stay data, never instructions. | Python 3.12+, jq; the curation needs daily-sync and the Claude Code CLI signed in; the semantic search downloads a 500 MB model once |
| pr-watch | Tracks the PRs you and Claude open or push together until they are merged: new comments (bots marked apart), votes, CI results (required checks drive green or failed) and the merge itself, which drops the PR from the list. Also lists the open PRs waiting for your review (GitHub review requests, Azure DevOps reviewer with no vote) and reports each new one; approve votes or submits an approving review on your yes, then tracks the PR as reviewed until it is merged; merge merges a PR on your yes; park keeps a PR you won't merge tracked but quiet, out of what needs you, until unpark. Also finds your pipeline runs: those you launched by hand (Azure DevOps manual builds, GitHub workflows you dispatched) and the CI your own pushes start on a branch with no PR, a push to main included (in the projects and repos of your PRs, the GitHub repos you pushed to lately, plus those listed in its config), so a packaging or a deploy started with Claude shows up on its own, and reports each one that ended since the last check; details <build URL> lists every step with its job and state, the error lines of those with issues included, rerun <build URL> launches the run again on your yes. Also follows your GitHub issues: those you opened, commented on or were mentioned or assigned in, in any repo, active in the last 30 days, and those you subscribed to in your own repos; open ones and those closed in the last 7 days, reporting new comments by others, a close or reopen and label changes since you last looked. What it watches (open, parked and merged PRs, review requests, how many runs per repo, issues) follows pr-pane's settings, or its config.json: what is off is neither fetched nor reported. Claude proposes a reply or a fix for each change, never posts one without your yes. Checks at each session start in the background and silently (the session starts at once; each PR keeps what changed as unread, which pr-pane shows until you unfold that PR), hourly in a session (a durable scheduled task) and every morning through daily-sync, with a desktop or phone notification. The list lives in the sync repo, so it follows you on every computer and never touches the project repos. With the sync repo on GitHub (recommended), what one computer fetches is shared with the others on a branch of it that never grows, read with one conditional request: each host is asked by one computer only, again once its share is older than 2 minutes while a CI runs or 10 otherwise (asked at setup), the pane opens at once, and a host one computer cannot reach (a company's Azure DevOps from home) is still shown from another. Azure DevOps (az login or a PAT) and GitHub (a token, gh, or anonymous for public repos). | Python 3.9+; the sync engine to share the list across computers |
Mods change Claude Code's own interface. They install as plugins like the other parts; install.sh loads its copies through CLAUDE_CODE_PLUGIN_DIRS. Run a mod's tests with claude plugin test mods/<name>.
| Mod | What it does |
|---|---|
| paste-preview | Shows pasted text in full in the prompt box and the transcript, and previews pasted images above the prompt and under the sent message (macOS, Windows). On Windows, paste an image with Alt+V: in some terminals (Ghostty) Ctrl+V pastes text only. |
| pr-pane | A Dev activity button at the top right of the band above the prompt, whose number counts only what needs you (a PR or a run in red, not acknowledged), colored by what you have to do (red and marked !: something needs you; yellow: · k to review or · k running; green: a PR is ready to merge; dim: everything waits for others) and /prs open a pane of the PRs pr-watch tracks, by priority. At the top, a collapsible "Pipeline runs" section lists your runs (a packaging or a deploy launched by hand, the CI of your pushes outside a PR, marked push to <branch>), one block per repo: the running ones first, then those ended in the last day, each with its pipeline, branch, status, time and duration, open by default while one runs; a run whose host did not answer keeps its last known state, marked cached. A run unfolds from its line: rerun (press twice: a new run of the same pipeline, branch and parameters on Azure DevOps, a re-run on GitHub), diagnose with Claude when it did not succeed (asks the session to read the steps and logs and propose the fix), then every step of the run grouped by job in pipeline order, each job folded unless a step had a problem, each step's messages and log lines under their own toggle. Then, under a red title and one open block per repo, the PRs that need you: a failed or expired CI, a merge conflict, changes requested, or a new comment since the last check (unfolding the PR marks its changes read: the button clears, the PR stays where it is until the session ends). Then the PRs waiting for your review, per repo too, each with review with Claude (a quick review by a subagent, nothing posted) and approve (press twice; it reads approving… while it runs). Then a collapsible "Waiting for others" section with your other open PRs, one collapsible block per repo in merge order (a stacked PR under the one it targets). Then "Issues I follow", per repo with unfollow all (press twice): each issue on one line (yellow when it moved since you last looked, with · n issues updated on the band), unfolding to who opened it, its labels and comments and what changed, which marks it read, with unfollow. A PR you park (its park button, under its details) leaves the red list and the band for a folded "Parked, kept but quiet" section, with unpark. Last, before the recent list, a collapsible "Approved, waiting for the merge" section holds the PRs you approved and your own PRs that only wait for the merge, grouped per repo the same way, each with a merge button (press twice; merging… while it runs; squash on GitHub when the repo allows it, complete on Azure DevOps). Every section and repo block wears the worst color of what it holds (red, then yellow, then green), so a folded one still says where to look. A red PR or run can be acknowledged (ack): it turns blue, leaves the count and the colors above it, and comes back when it changes; ack + remind asks Claude for a reminder and acks until its date, shown beside it. Every PR is one line, a colored dot (red: you have to act, yellow: someone or something else, green: only the merge is left), its number, title and what it waits for; its details fold under it: status, CI, votes, conflicts, the PR's description and the changes since the last check. A PR whose CI runs, and the repo block and section around it, open by default so a CI you just launched is on screen (a folded block hiding one says CI running); what you fold stays folded. Each PR's CI checks fold under it, open while they run or when a required one failed, refreshed every minute until they end; a failed check has details (its failed steps and error lines) and rerun (press twice: re-queue on Azure DevOps, rerun failed jobs on GitHub). A merge conflict is shown in red with a fix with Claude button that asks the session to rebase and resolve it (it asks you before pushing). PRs merged or closed in the last 7 days stay listed at the bottom. An error shared by several PRs and lists (a host this computer cannot reach or sign in to) is shown once, with what it hit. Click a PR to open it in the browser; refresh (or r) fetches them live and lists what changed since the last check, without marking it seen. Its settings, in the Configuration pane or claude plugin configure, turn each section on or off (open PRs, parked, merged and closed, review requests, issues), set how many pipeline runs to show per repo (0 hides them), how long an issue stays followed without moving, how often each part is refreshed by itself, and follow the kit's low power mode (see configuration); pr-watch follows them too, so what is off is no longer fetched or reported. Needs pr-watch. |
| reminders-handoffs | One pane for whichever of reminders and handoff you have, a tab each when you have both. A button at the right of the band above the prompt, named after them (Reminders 1 now · Handoffs 1, yellow when a reminder is due or a note from your other computer waits), /reminders-pane and /handoffs-pane open it. Reminders come in three foldable sections, Now, Upcoming (with when each shows next, repeating ones included) and Past; handoff notes in Open and Done. Several groups in a section (a reminder's group, else its folder, else its computer; a note's repo) each get a foldable title. Each item is one line that unfolds to its dates, repo, branch and text, with pick (hands the reminder or the note to this session now), done (done today on a repeating reminder), stop, cancel and delete (press twice), and restore on a past one (back as it was, dates and repeat days included). |
| recent-prompts | Shows your last two messages above the prompt, and a searchable history pane; click one to scroll the transcript to it. With the learn mod, the history also lists each 💡 lesson between the messages it answered. |
| dnd | Do not disturb for the recent-prompts, pr-pane and reminders-handoffs panes. A profile hides a pane (its band button too), or keeps only some repos, reminder groups or handoff repos, @here being the session's own repo; counts and colors follow what is kept. Turn one on by hand with /dnd work 1h or by asking Claude, for this session, this computer or all computers (asked when you don't say; the status line shows 🔕 work · session while one is on), or let a rule turn it on: a Wi-Fi network, days and hours (days=weekend, from=19:00 to=08:00), a folder (a repo shows only its own). /dnd, or its button in the Configuration pane, opens the Do not disturb pane: a press per profile asks until when and where, then Activate turns it on; a name creates one, and each profile unfolds to what it keeps (a checklist) and the rules that turn it on; dnd.sh edit <profile> is the same checklist on a terminal, with the installer's keys. Profiles live in the sync repo when there is one, so every computer gets them. Offered only beside one of those panes. |
| configuration | A Configuration button at the right of the band and /configuration open the Configuration pane: every mod's settings, grouped per mod (a toggle, a choice, or a number or text typed and saved with Enter), changed for all computers or, with a private sync, for this computer only; a setting kept for this computer says the value the other computers use. Its own setting is the kit's low power mode: on, off, or on battery (the default: while the computer runs on its battery or in its low power mode, read on macOS, Linux and Windows), everything that fetches by itself (the dev activity pane and pr-watch, the reminders and handoffs pane, do not disturb) does it five times less often, and
hooks/register.tsx 502 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ModSetting } from '../types'
5
6// The kit's Configuration pane: every mod's settings. The pane mods depend on this one, so it comes
7// with any of them; dnd's Do not disturb pane opens from here as a tab of its own.
8const PANE = 'configuration'
9const TITLE = 'Configuration'
10const COMMAND = 'configuration'
11const DND_PANE = 'dnd'
12const DND_TITLE = 'Do not disturb'
13// Under the pointer, what folds turns to the theme's suggestion color with the text drawn on it in
14// the theme's inverse text color: readable in a light and a dark theme alike.
15const LIT_BOX = { backgroundColor: 'suggestion' }
16const LIT_TEXT = { color: 'inverseText', dimColor: false }
17
18const mods = atom({ plugin: 'configuration', key: 'mods' } as const, null)
19const scope = atom({ plugin: 'configuration', key: 'scope' } as const, 'all')
20const saves = atom({ plugin: 'configuration', key: 'saves' } as const, 0)
21const unfolded = atom({ plugin: 'configuration', key: 'unfolded' } as const, {})
22const power = atom({ plugin: 'configuration', key: 'power' } as const, null, { shape: 'low-power/1' })
23
24// Set by dnd once it has read what is on: dnd is installed. Read under dnd's shape.
25const dndActive = atom({ plugin: 'dnd', key: 'active' } as const, null, { shape: 'dnd-active/1' })
26
27// The band's buttons share one row under everything above the prompt, whatever order the mods
28// draw in: each takes that row out of what the mods beneath drew, adds its own button, and sorts
29// them by key (band-1-history, band-2-prs, band-3-reminders, band-4-configuration).
30const BAND_ROW = 'band-buttons'
31
32type Drawn = { type?: string; props?: { key?: string }; children?: unknown[] }
33
34const keyOf = (node: unknown) => (node !== null && typeof node === 'object' ? ((node as Drawn).props?.key ?? '') : '')
35
36const byBandKey = (one: unknown, other: unknown) => keyOf(one).localeCompare(keyOf(other))
37
38const takeBandRow = (tree: unknown): { rest: unknown; buttons: unknown[] } => {
39 if (Array.isArray(tree)) {
40 let buttons: unknown[] = []
41 const rest = tree.map(child => {
42 if (buttons.length > 0) {
43 return child
44 }
45
46 const taken = takeBandRow(child)
47 buttons = taken.buttons
48
49 return taken.rest
50 })
51
52 // The engine reads every child of a drawn tree: the row taken out leaves no hole.
53 return { rest: rest.filter(child => child !== null && child !== undefined), buttons }
54 }
55
56 const node = tree as Drawn | null
57
58 if (node === null || typeof node !== 'object' || !Array.isArray(node.children)) {
59 return { rest: tree, buttons: [] }
60 }
61
62 if (node.props?.key === BAND_ROW) {
63 return { rest: null, buttons: node.children.flat(Infinity) }
64 }
65
66 const taken = takeBandRow(node.children)
67
68 return taken.buttons.length > 0 ? { rest: { ...node, children: taken.rest }, buttons: taken.buttons } : { rest: tree, buttons: [] }
69}
70
71const slashed = (path: string) => path.replace(/\\/g, '/')
72
73const home = async ($: EngineInterface) => slashed((await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? '~')
74
75const truncate = (text: string, width: number) =>
76 text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text
77
78// A change for every computer is a /config change, which the private sync shares; one for this
79// computer alone is also written to the sync's machine file, whose settings win over the shared
80// ones at each sync, so the shared value stays for the other computers.
81type Json = Record<string, unknown>
82
83const settingsFile = async ($: EngineInterface) => {
84 const dir = await $.env.get('CLAUDE_CONFIG_DIR')
85
86 return `${dir ? slashed(dir) : `${await home($)}/.claude`}/settings.json`
87}
88
89const readJson = async ($: EngineInterface, path: string): Promise<Json> => {
90 try {
91 const parsed: unknown = JSON.parse(await $.fs.read(path))
92
93 return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Json) : {}
94 } catch {
95 return {}
96 }
97}
98
99const objectAt = (value: unknown): Json => (value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Json) : {})
100
101// This computer's file in the private sync, '' when there is no sync to keep a setting apart in.
102const machineFile = async ($: EngineInterface) => {
103 const sync = `${await home($)}/.claude-sync`
104
105 try {
106 const machine = (await $.fs.read(`${sync}/.machine`)).trim()
107 const file = `${sync}/machines/${machine}.json`
108
109 return machine !== '' && (await $.fs.exists(file)) ? { machine, file } : { machine: '', file: '' }
110 } catch {
111 return { machine: '', file: '' }
112 }
113}
114
115// Settings name a plugin's options by its name, with or without `@<where it comes from>`.
116const configKeyOf = (configs: Json, plugin: string) => Object.keys(configs).find(key => key === plugin || key.startsWith(`${plugin}@`))
117
118const overlayValue = (overlay: Json, plugin: string, field: string) => {
119 const configs = objectAt(objectAt(overlay.settings).pluginConfigs)
120 const key = configKeyOf(configs, plugin)
121
122 return key === undefined ? undefined : objectAt(objectAt(configs[key]).options)[field]
123}
124
125const loadMods = async ($: EngineInterface) => {
126 const { machine, file } = await machineFile($)
127 const overlay = file === '' ? {} : await readJson($, file)
128 // The private sync builds settings.json from its shared settings, then the machine file's over them.
129 const shared = file === '' ? {} : { settings: await readJson($, `${await home($)}/.claude-sync/settings.shared.json`) }
130 const settings = (await $.config.list())
131 .filter(row => row.provider.tier === 'user' && row.key.includes('.'))
132 .map((row): ModSetting => {
133 const plugin = row.key.slice(0, row.key.indexOf('.'))
134 const field = row.key.slice(plugin.length + 1)
135 const isHere = overlayValue(overlay, plugin, field) !== undefined
136 const sharedValue = isHere ? overlayValue(shared, plugin, field) : undefined
137
138 return {
139 key: row.key,
140 plugin,
141 field,
142 label: row.label,
143 ...(row.description ? { description: row.description } : {}),
144 kind: row.kind,
145 value: Array.isArray(row.value) ? [...row.value] : (row.value as ModSetting['value']),
146 ...(row.options ? { options: [...row.options] } : {}),
147 isLocked: row.isLocked,
148 isHere,
149 ...(sharedValue !== undefined ? { shared: sharedValue as ModSetting['value'] } : {}),
150 }
151 })
152
153 await update($, mods, () => ({ settings, machine }))
154}
155
156// What every other computer has for a setting this one keeps apart.
157const sharedText = (setting: ModSetting) =>
158 setting.shared === undefined ? 'the default' : setting.shared === true ? 'on' : setting.shared === false ? 'off' : String(setting.shared)
159
160// Writes the field's value into the machine file, or takes it out (undefined), leaving no empty object behind.
161const writeOverlay = async ($: EngineInterface, file: string, setting: ModSetting, value: ModSetting['value'] | undefined) => {
162 const overlay = await readJson($, file)
163 const settings = objectAt(overlay.settings)
164 const configs = objectAt(settings.pluginConfigs)
165 const local = await readJson($, await settingsFile($))
166 const key = configKeyOf(objectAt(local.pluginConfigs), setting.plugin) ?? configKeyOf(configs, setting.plugin) ?? setting.plugin
167 const entry = objectAt(configs[key])
168 const { [setting.field]: _dropped, ...others } = objectAt(entry.options)
169 const options = value === undefined ? others : { ...others, [setting.field]: value }
170 const { options: _old, ...entryRest } = entry
171 const nextEntry = Object.keys(options).length > 0 ? { ...entryRest, options } : entryRest
172 const { [key]: _was, ...otherConfigs } = configs
173 const nextConfigs = Object.keys(nextEntry).length > 0 ? { ...otherConfigs, [key]: nextEntry } : otherConfigs
174 const { pluginConfigs: _configs, ...settingsRest } = settings
175 const nextSettings = Object.keys(nextConfigs).length > 0 ? { ...settingsRest, pluginConfigs: nextConfigs } : settingsRest
176
177 await $.fs.write(file, `${JSON.stringify({ ...overlay, settings: nextSettings }, null, 2)}\n`)
178}
179
180// For this computer, /config writes first so the machine file names the plugin as settings.json
181// does; for every computer, the machine file lets go first so the sync shares the new value.
182const setMod = async ($: EngineInterface, setting: ModSetting, value: ModSetting['value']) => {
183 const { file } = await machineFile($)
184 const isHere = file !== '' && (await read($, scope)) === 'here'
185
186 try {
187 if (file !== '' && !isHere && setting.isHere) {
188 await writeOverlay($, file, setting, undefined)
189 }
190
191 const { deny } = await $.config.set({ key: setting.key, value })
192
193 if (deny !== undefined) {
194 $.ui.toast(`Could not change ${setting.label}: ${deny}`)
195 } else if (isHere) {
196 await writeOverlay($, file, setting, value)
197 }
198 } catch (error) {
199 $.ui.toast(`Could not change ${setting.label}: ${String(error).split('\n')[0] ?? ''}`)
200 } finally {
201 await loadMods($)
202 await update($, saves, count => count + 1)
203 }
204}
205
206const openPane = async ($: EngineInterface) => {
207 await $.ui.open({ id: PANE, title: TITLE, columns: 64 })
208 await loadMods($)
209}
210
211// Low power mode, for every part of the kit that fetches by itself: `on`, `off`, or `on battery`
212// (while the computer runs on its battery or in its low power mode). The mods read it under the
213// shape `low-power/1`; scripts (pr-watch, the status line) from ~/.claude/.low-power.json.
214const POWER_FILE = '.low-power.json'
215const POWER_EVERY_MS = 60_000
216// A PowerShell call costs a second: Windows is asked less often.
217const POWER_EVERY_WINDOWS_MS = 5 * 60_000
218const POWER_KEPT_MS = 10 * 60_000
219const LINUX_MAINS = ['AC', 'ACAD', 'AC0', 'ADP0', 'ADP1']
220
221type PowerWay = 'pmset' | 'linux' | 'windows' | 'none'
222let powerWay: PowerWay | undefined
223
224const ran = async ($: EngineInterface, argv: string[]) => {
225 try {
226 const result = await $.process.run(argv, { timeoutMs: 10_000 })
227
228 return result.exitCode === 0 ? result.stdout : undefined
229 } catch {
230 return undefined
231 }
232}
233
234const linuxMains = async ($: EngineInterface) => {
235 for (const name of LINUX_MAINS) {
236 const online = await $.fs.read(`/sys/class/power_supply/${name}/online`).catch(() => undefined)
237
238 if (online !== undefined) {
239 return online.trim()
240 }
241 }
242
243 return undefined
244}
245
246// Whether the computer runs on its battery or in its low power mode; false when it cannot tell.
247const onBattery = async ($: EngineInterface) => {
248 if (powerWay === undefined || powerWay === 'pmset') {
249 const battery = await ran($, ['pmset', '-g', 'batt'])
250
251 if (battery !== undefined) {
252 powerWay = 'pmset'
253 const settings = (await ran($, ['pmset', '-g'])) ?? ''
254
255 return battery.includes("'Battery Power'") || /lowpowermode\s+1/.test(settings)
256 }
257 }
258
259 if (powerWay === undefined || powerWay === 'linux') {
260 const online = await linuxMains($)
261
262 if (online !== undefined) {
263 powerWay = 'linux'
264
265 return online === '0'
266 }
267 }
268
269 if (powerWay === undefined || powerWay === 'windows') {
270 const status = await ran($, ['powershell', '-NoProfile', '-NonInteractive', '-Command', '(Get-CimInstance Win32_Battery).BatteryStatus'])
271
272 if (status !== undefined) {
273 powerWay = 'windows'
274
275 // 1: the battery is discharging, on battery power.
276 return status.trim().split(/\s+/).includes('1')
277 }
278 }
279
280 powerWay = 'none'
281
282 return false
283}
284
285let powerSetting = 'on battery'
286let powerWritten = { text: '', at: 0 }
287
288const checkPower = async ($: EngineInterface) => {
289 const isLow = powerSetting === 'on' || (powerSetting === 'on battery' && (await onBattery($)))
290 const now = await $.clock.now()
291 await update($, power, () => ({ setting: powerSetting, isLow }))
292 const text = JSON.stringify({ setting: powerSetting, lowPower: isLow })
293
294 // Written when it changes, and now and then so the scripts know it is still checked.
295 if (text !== powerWritten.text || now - powerWritten.at >= POWER_KEPT_MS / 2) {
296 const dir = await $.env.get('CLAUDE_CONFIG_DIR')
297 const path = `${dir ? slashed(dir) : `${await home($)}/.claude`}/${POWER_FILE}`
298 await $.fs
299 .write(path, `${JSON.stringify({ setting: powerSetting, lowPower: isLow, at: new Date(now).toISOString() })}\n`)
300 .then(() => {
301 powerWritten = { text, at: now }
302 })
303 .catch(() => undefined)
304 }
305}
306
307let powerTicking: { cancel: () => void } | undefined
308
309const startPower = async ($: EngineInterface) => {
310 powerTicking?.cancel()
311 await checkPower($)
312 powerTicking = $.clock.every(powerWay === 'windows' ? POWER_EVERY_WINDOWS_MS : POWER_EVERY_MS, () => void checkPower($).catch(() => undefined))
313}
314
315let loadAsked = false
316
317export const register: Register = (on, options) => {
318 powerSetting = typeof options.lowPower === 'string' ? options.lowPower : 'on battery'
319
320 on('session.start', async ($, e, next) => {
321 await $.command.register({ name: COMMAND, description: "Open the Configuration pane: every mod's settings" }).catch(() => undefined)
322 void startPower($).catch(() => undefined)
323
324 return next(e)
325 })
326
327 on('command.run', { command: COMMAND }, async $ => {
328 await openPane($)
329
330 return { text: 'Configuration pane opened.' }
331 })
332
333 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
334 const beneath = await next(e)
335
336 // A reload (a setting changed) brings no session start: the power check starts again here.
337 if (powerTicking === undefined) {
338 powerTicking = { cancel: () => undefined }
339 void startPower($).catch(() => undefined)
340 }
341
342 if (e.props.hasSurvey || e.props.view.agentId !== undefined) {
343 return beneath
344 }
345
346 const { Box, Button } = $.ui.resolve(e)
347 const { rest, buttons } = takeBandRow(beneath)
348 const mine = (
349 <Box key="band-4-configuration" flexDirection="row">
350 <Button key="configuration" label={TITLE} dimColor onPress={() => void openPane($)} />
351 </Box>
352 )
353
354 return (
355 <Box flexDirection="column">
356 {rest as never}
357 <Box key={BAND_ROW} flexDirection="row" gap={2}>
358 {[...buttons, mine].sort(byBandKey) as never}
359 </Box>
360 </Box>
361 )
362 })
363
364 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
365 const { Box, Button, Input, Text } = $.ui.resolve(e)
366 const shown = await read($, mods)
367 const where = await read($, scope)
368 const saved = await read($, saves)
369 const open = await read($, unfolded)
370 const hasDnd = (await read($, dndActive)) !== null
371 const width = Math.max(30, e.props.bodyColumns - 2)
372
373 // Read on the first draw after a load or reload: opened by another mod's button (dnd's), or kept
374 // open while a mod brought new settings.
375 if (!loadAsked) {
376 loadAsked = true
377 void loadMods($)
378 }
379
380 // One setting: a press flips a toggle or moves a choice on; a text or a number is typed.
381 const settingRow = (setting: ModSetting) => {
382 const title = `${setting.label}${setting.isHere ? ' (this computer)' : ''}`
383
384 if (setting.isLocked) {
385 return <Text key={`setting-${setting.key}`} dimColor>{truncate(`${title}: ${String(setting.value)} (locked)`, width - 4)}</Text>
386 }
387
388 if (setting.kind === 'boolean') {
389 return (
390 <Box key={`setting-${setting.key}`} flexDirection="row" hover={LIT_BOX}>
391 <Button
392 key={`set-${setting.key}`}
393 label={`${setting.value === true ? '[x]' : '[ ]'} ${truncate(title, width - 8)}`}
394 plain
395 hover={LIT_TEXT}
396 onPress={() => void setMod($, setting, setting.value !== true)}
397 />
398 </Box>
399 )
400 }
401
402 if (setting.kind === 'choice') {
403 const options = setting.options ?? []
404 const following = options[(options.indexOf(String(setting.value)) + 1) % Math.max(1, options.length)] ?? ''
405
406 return (
407 <Box key={`setting-${setting.key}`} flexDirection="row" hover={LIT_BOX}>
408 <Button
409 key={`set-${setting.key}`}
410 label={truncate(`${title}: ${String(setting.value)}`, width - 4)}
411 plain
412 hover={LIT_TEXT}
413 onPress={() => void setMod($, setting, following)}
414 />
415 </Box>
416 )
417 }
418
419 // The field keeps what was typed until it is drawn anew: a key per save draws the saved value
420 // again, even when it did not change. A number that does not read as one is not saved.
421 const save = (typed: string) => {
422 if (setting.kind !== 'number') {
423 void setMod($, setting, typed)
424 } else if (typed.trim() !== '' && Number.isFinite(Number(typed))) {
425 void setMod($, setting, Number(typed))
426 } else {
427 void update($, saves, count => count + 1)
428 }
429 }
430
431 return (
432 <Input
433 key={`set-${setting.key}-${saved}`}
434 label={`${truncate(title, Math.floor(width / 2))} `}
435 value={String(setting.value)}
436 submitLabel="save"
437 onSubmit={save}
438 />
439 )
440 }
441
442 const toggle = (key: string) => void update($, unfolded, all => ({ ...all, [key]: !(all[key] ?? true) }))
443
444 const header = (
445 <Box flexDirection="row" justifyContent="space-between" marginBottom={1}>
446 <Text bold>Mod settings</Text>
447 {hasDnd && <Button key="open-dnd" label={`${DND_TITLE} ›`} dimColor onPress={() => void $.ui.open({ id: DND_PANE, title: DND_TITLE, columns: 64 })} />}
448 </Box>
449 )
450
451 if (shown === null) {
452 return (
453 <Box flexDirection="column">
454 {header}
455 <Text dimColor>Loading…</Text>
456 </Box>
457 )
458 }
459
460 const plugins = [...new Set(shown.settings.map(setting => setting.plugin))]
461
462 return (
463 <Box flexDirection="column">
464 {header}
465 {shown.machine !== '' ? (
466 <Box flexDirection="row" gap={1}>
467 <Text>Changes apply to</Text>
468 <Button key="scope-all" label="all computers" dimColor={where !== 'all'} onPress={() => void update($, scope, () => 'all')} />
469 <Button key="scope-here" label={`this computer only (${shown.machine})`} dimColor={where !== 'here'} onPress={() => void update($, scope, () => 'here')} />
470 </Box>
471 ) : (
472 <Text dimColor>Changes apply to this computer: there is no private sync to share them.</Text>
473 )}
474 {plugins.length === 0 && <Text dimColor>No mod has settings.</Text>}
475 {plugins.map(plugin => {
476 const key = `mod|${plugin}`
477 const isOpen = open[key] ?? true
478 const settings = shown.settings.filter(setting => setting.plugin === plugin)
479
480 return (
481 <Box key={key} flexDirection="column" marginTop={1}>
482 <Box flexDirection="row" hover={LIT_BOX}>
483 <Button key={`fold-${key}`} label={`${isOpen ? '▾' : '▸'} ${truncate(`${plugin} (${settings.length})`, width - 4)}`} plain hover={LIT_TEXT} onPress={() => toggle(key)} />
484 </Box>
485 {isOpen && (
486 <Box flexDirection="column" paddingLeft={2}>
487 {settings.map(setting => (
488 <Box key={`row-${setting.key}`} flexDirection="column">
489 {settingRow(setting)}
490 {setting.isHere && <Text dimColor>{truncate(` used here instead of all computers' value: ${sharedText(setting)}`, width - 4)}</Text>}
491 </Box>
492 ))}
493 </Box>
494 )}
495 </Box>
496 )
497 })}
498 </Box>
499 )
500 })
501}
502types/index.d.ts 50 lines1/** One mod setting (a plugin's userConfig field) as the Mods list shows it. */
2export type ModSetting = {
3 /** `<plugin>.<field>`, as `$.config.set` takes it. */
4 key: string
5 plugin: string
6 field: string
7 label: string
8 description?: string
9 kind: 'boolean' | 'choice' | 'text' | 'number'
10 value: boolean | string | number | string[]
11 options?: string[]
12 isLocked: boolean
13 /** Kept for this computer alone, in the private sync's machine file, over the shared value. */
14 isHere: boolean
15 /** With isHere, the value every other computer has (the private sync's shared settings); absent: the default. */
16 shared?: boolean | string | number | string[]
17}
18
19/** Every user mod's settings, and this computer's name in the private sync ('' with none). */
20export type ModsData = { settings: ModSetting[]; machine: string }
21
22/** Low power mode: what the user chose, and whether it slows the kit now. */
23export type LowPower = { setting: string; isLow: boolean }
24
25/** What dnd shares under the shape `dnd-active/1`; only whether it is set is read here. */
26export type DndActiveSeen = Record<string, unknown>
27
28declare module 'claude-code' {
29 interface PluginState {
30 configuration: {
31 /** The settings listed; null before they are first read. */
32 mods: ModsData | null
33 /** Where a change goes: every computer, or this one alone. */
34 scope: 'all' | 'here'
35 /** Settings saved, counted: a typed field is drawn anew with each. */
36 saves: number
37 /** The mods folded (`mod|<plugin>`: false), open by default. */
38 unfolded: Record<string, boolean>
39 /**
40 * Low power mode as the other mods read it, under the shape `low-power/1`: the setting (`on`,
41 * `off`, `on battery`) and whether it is on now. null before the first check.
42 */
43 power: Shaped<LowPower | null>
44 }
45 dnd: {
46 active: Shaped<DndActiveSeen | null>
47 }
48 }
49}
50