SLOPSHOPPER

configuration

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…

newpanebandcommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · configuration
│ ┃ Configuration ✕ › fix the failing auth test and add an audit log call │ ┃ Mod settings │ ┃ ⏺ Read(src/auth.ts) │ ┃ Changes apply to this computer: there is no ⎿ Read 6 lines │ ┃ private sync to share them. ⏺ Update(src/auth.ts) │ ┃ No mod has settings. ⎿ 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 │ │ › /configuration │ ⎿ configuration: Configuration pane opened. │ │ ⟨Claude Code's own drawing⟩ [ Configuration ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ [ Configuration ]
Pane · Configuration
Mod settings Changes apply to this computer: there is no private sync to share them. No mod has settings.
README

claude-code-kit

tests

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.

Install

As Claude Code plugins (recommended)

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.

With install.sh

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:

  • the same as another one: its parts, and its answers taken as they are. Only secrets and the questions about this computer are asked: its name, its repos folder, the ~/.zshrc line, the status line it has.
  • starting from another one: the checklist and every question, with the other computer's picks and answers filled in, to keep or change.
  • from scratch, as before.

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.

What's inside

Base rules

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.

Skills

SkillWhat it doesNeeds
test-evidenceCaptures 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-reviewProposes, 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-builderTurns 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.
handoffWrites 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-syncSchedules 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
kbA 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-watchTracks 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

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>.

ModWhat it does
paste-previewShows 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-paneA 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-handoffsOne 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-promptsShows 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.
dndDo 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

Source 2 files
hooks/register.tsx 502 lines
1import { 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}
502
types/index.d.ts 50 lines
1/** 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