SLOPSHOPPER

aim-widget

Where a command lands outside this folder: the kube context, cloud profile, Terraform workspace or database it is aimed at, named in the permission dialog

newpanebandspinnerguardcommand
★ 1v1.0.0MITupdated 2026-10-06oMaN-Rod/claude-code-widgets/plugins/aim-widget
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · aim-widget
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /aim-widget ⎿ aim-widget: Aim on; /widgets places it. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Claude Code Widgets

Small cards that sit under the Claude Code prompt, or docked beside the transcript in fullscreen. Each widget is its own mod, toggled with a slash command.

It started with a few useful ones (context, usage, file tree) and then got carried away: there are now 95 widgets, from git status and a turn timer to a pixel crab, a dungeon crawl and Tetris.

Try every widget in its own little terminal on the demo page: the real widget code runs in the browser against a simulated session, so you can type its slash commands, run a turn and play the games. It is a static page in docs/, published to GitHub Pages on every push to main that changes it; opening docs/index.html from a checkout works too.

Install

Add the marketplace, then install the layout plugin and whichever widgets you want:

/plugin marketplace add oMaN-Rod/claude-code-widgets
/plugin install widgets@claude-code-widgets
/plugin install context-widget@claude-code-widgets

Every widget needs widgets, the layout plugin that places the cards.

Requires a Claude Code version with mod (function hook) support. The mod API is early access and may change between releases.

Widgets

Layout

Required by every widget.

PluginCommandWhat it shows
widgets`/widgets [side\above\below\close\<columns>\width <widget> <columns\reset>]`Places the cards: below the prompt, above it, or docked beside the transcript in fullscreen

Session

What Claude is doing and what it is costing.

PluginCommandWhat it shows
context-widget`/context-widget [auto\detailed\grid\top\bar\line]`The context window as a stacked bar, one colour per /context category
usage-widget/usage-widgetA bar per rate-limit window, time to reset, session cost
timer-widget`/timer-widget [on\off\clear]`The running turn's elapsed time, plus last, average and longest turn and a sparkline
activity-widget`/activity-widget [on\off\clear]`The latest tool calls with their duration and whether they failed
tasks-widget`/tasks-widget [on\off\clear]`The task list Claude is working through, with a progress bar
checks-widget`/checks-widget [on\off\clear]`The latest test, lint and build runs: pass or fail, duration and how long ago
timeline-widget`/timeline-widget [on\off]`The turn as a chart: one bar per tool call on a time axis, parallel calls stacked
board-widget`/board-widget [on\off\clear]`A status board Claude writes itself through a pin tool: goal, findings and open questions
forecast-widget`/forecast-widget [on\off\clear]`Context growth per turn as a chart, with the turns left before compaction; warns once when three or fewer are left
guard-widget`/guard-widget [on\off\clear]`Permission checks this session: allowed, asked and denied, with what you keep being asked about
stream-widget`/stream-widget [on\off]`A live tokens-per-second gauge and sparkline while Claude is writing (estimated from text length)
sessions-widget/relay <number> <message>, `/sessions-widget [on\off]`Every Claude Code session open on this machine: folder, branch, working or waiting and for how long; /relay 1 <message> sends a line to one
collision-widget`/collision-widget [on\off\clear]`Stops an edit to a file another Claude Code session on this machine changed since this one last read it, and has Claude read it again first
sieve-widget`/sieve-widget [on\off]`A compaction that keeps every word you and Claude said and folds only the old tool traffic, with no summary and no tokens spent
redact-widget`/redact-widget [on\off\clear]`Replaces API keys, tokens, private keys and passwords in command output and file reads before Claude sees them
notebook-widget`/notebook-widget [on\off\show\drop <number>\clear]`Gives Claude a jot tool for writing down what its future self should know about this project, and reads the notes back next session
footnotes-widget`/footnotes-widget [on\off\show\clear]`Checks every file path, cited line and code symbol in Claude's last reply against the working tree, with no model call, and flags the ones that are not there
witness-widget`/witness-widget [on\off\show\clear]`Every line of hidden context the other widgets add to your prompts, word for word, with a running size
queue-widget`/queue-widget [on\off\add <prompt>\until <command\off>\start\drop <number>\report\clear]`A queue of prompts that run back to back while you are away, a gate command each must pass before the next starts, and a report of how each one ended
done-widget`/done-widget [on\off\add <criterion>\drop <number>\show\clear]`A definition of done you write once: Claude ticks each item with evidence through its tick tool, and is pulled up when it says done with items open
trial-widget`/trial-widget [on\off\test <widget>\clear]`A fair test of another widget: sessions alternate with it on and off, and the card compares how many turns end clean
aside-widget`/aside-widget [on\off\ask <question>\clear]`Ask a side question about the conversation, even mid-turn, and get the answer on a card without adding a turn to the transcript
tap-widget`/tap-widget [on\off\list [word]\add <server> <tool> [json] [anyway]\run <n>\show <n>\drop <n>\clear]`One line per tool of your connected MCP servers, called by the widget on a clock with no model: pull requests, errors, the next meeting
margin-widget`/margin-widget [on\off\mark [remark]\drop <n>\send\clear]`Mark passages of Claude's reply with the mouse, write a remark against each, and send them back as one quoted prompt
loupe-widget`/loupe-widget [on\off\look [text]\copy]`Select a hash, path, name, timestamp or colour in the transcript with the mouse and the card says what it is, with no model call
strays-widget`/strays-widget [on\off\stop <port>\forget <port>\clear]`Servers this session started that still hold a port, with age and turn, one verb to stop one, and the survivors shown when the next session opens
premise-widget`/premise-widget [on\off\show\fix <n>\clear]`Quotes thinking-summary sentences that name a gap and a choice made anyway, unless the reply flags it, and starts your correction; no model call; needs "showThinkingSummaries": true in settings.json
earpiece-widget`/earpiece-widget [on\off], /whisper <number> <note>`Every running subagent with the tool it is on and its last sentence, and /whisper slips one of them a note without stopping the turn
pen-widget`/pen-widget [on\off\show\clear]`The file, edit or command Claude is writing right now, drawn line by line as the tool call's arguments stream in, before the tool runs
outage-widget`/outage-widget [on\off\check\clear]`When a push or install fails on the network, checks the provider's status page and says whether GitHub, npm, PyPI or crates.io reports an incident
landmarks-widget`/landmarks-widget [on\off\list\go <n>\clear]`A numbered table of contents for the session, built as it happens from prompts, first edits, checks turning red or green, commits and questions; press a line to scroll the transcript to it
seen-widget`/seen-widget [on\off\show\open [n]\clear]`The pictures Claude looked at this session, image files it read and screenshots that came back from tools, drawn as Claude received them and opened full size with one command
amendments-widget`/amendments-widget [on\off\show [n]\clear]`What changed in the system prompt and the built-in tool descriptions Claude Code gives Claude since the version you last ran, with the before and after one command away
skimmed-widget`/skimmed-widget [on\off\show\clear]`The caveats in Claude's replies that left your screen while Claude was still writing and have not been back, quoted in full; it never claims that what was on screen was read
attic-widget`/attic-widget [on\off\show\keep <tool>\stow <tool>\clear]`Counts which tools Claude calls in this project, puts the unused ones behind ToolSearch, lists the daily ones up front, and reports how many schema tokens each request no longer carries
rehearsal-widget`/rehearsal-widget [on\off\show\forget <n>\clear]`Which of the commands and edits Claude has made in this project would stop for permission or be refused right now, asked of the engine's own permission check without running anything
aim-widget`/aim-widget [on\off\prod <word>\unprod <word>\show\clear]`Where a command lands outside this folder: the kube context, cloud profile, Terraform workspace or database it is aimed at, named in the permission dialog
earshot-widget`/earshot-widget [on\off\last\clear]`Whether Claude has got the message you typed while it was working: how late a request first carried it, and what Claude changed before it heard you

Project and git

The state of the working tree.

PluginCommandWhat it shows
file-tree-widget/file-tree-widgetThe project directory; folders expand on click
git-widget/git-widgetBranch, ahead/behind, staged, changed and untracked counts, and the last commit
changes-widget`/changes-widget [on\off\clear]`Files edited this session, most recent first, with an edit count each
commits-widget/commits-widgetThe commits made since the session started
todos-widget/todos-widgetTODO, FIXME, HACK and XXX comments in tracked files, counted and listed
diff-widget`/diff-widget [on\off\clear]`The last edit as a syntax-highlighted diff
watch-widget`/watch-widget [on\off\run\stop], /watch <command>`/watch bun test reruns the command after every edit: a pass or fail light and the last failing lines
map-widget`/map-widget [on\off\clear]`A pixel map of the tracked files, lit blue where Claude has read and green where it has edited
stakes-widget`/stakes-widget [on\off\clear]`What a yes would lose, measured from git and written under the permission dialog for a destructive command
ledger-widget`/ledger-widget [on\off\scan\show\clear]`What a project has cost across every session, from any folder or worktree in it: measured as each turn ends, and read back from the sessions Claude Code saved
squiggle-widget`/squiggle-widget [on\off\check [text]]`Underlines file names that do not exist, as you type them in the prompt box, and paints the ones that do green
critic-widget`/critic-widget [on\off\review\tell\clear]`A second pair of eyes: a separate model reads the uncommitted diff and lists only real defects, which you can hand to Claude
customs-widget`/customs-widget [on\off\show\trust <name>\clear]`Looks up every package Claude installs on npm or PyPI before the command runs, and holds the install for a yes when the name is missing or under 30 days old. Scoped names and installs that name a registry, or sit beside a project .npmrc that does, are not looked up; user-level and environment registry settings, bunfig.toml, .yarnrc.yml and pip and uv config files are not seen
provenance-widget`/provenance-widget [on\off\scan\look [<path>:<line>]\copy\clear]`The conversation behind a line of code: for lines Claude is about to edit, or a path:line you ask about, the prompt you typed before they were written and the command that resumes that session
green-widget`/green-widget [on\off\tell\clear]`What changed in the working tree since each test, lint or build command last passed, measured against a hidden git snapshot taken at that moment and handed to Claude on request

Time and focus

Clocks, timers and reminders that do not depend on the session.

PluginCommandWhat it shows
pomodoro-widget`/pomodoro-widget [on\off\start\break\stop\<minutes>]`A focus timer with a countdown, a progress bar and a count of finished sessions
countdown-widget`/countdown-widget [on\off\clear], /countdown <HH:MM\<n>m\<n>h> [label]`Time left to a deadline you set, with a progress bar and a toast when it arrives
clocks-widget`/clocks-widget [on\off\add <zone>\remove <zone>\clear]`Your local time beside the time zones you add, such as Asia/Tokyo
notes-widget`/notes-widget [on\off\clear], /note <text\done <n>>`Pinned notes kept across sessions; /note <text> adds one, /note done <n> removes it
coffee-widget`/coffee-widget [on\off\refill\<minutes>]`A cup that empties over 90 minutes and nudges you to take a break

Scenes

Pixel art that reacts to turns, tool calls, checks and context usage.

PluginCommandWhat it shows
pet-widget`/pet-widget [on\off\<mood>]`Clawd, a pixel crab: works during a turn, dizzy when a tool call fails, happy when tests pass. Earns XP and levels up across sessions, wears a hat once badges-widget has awarded a badge, and with two sessions open stays in the one you last prompted. Remembers each project and greets you with how long you were away and whether the checks were red
aquarium-widget`/aquarium-widget [on\off\demo]`A fish for every running tool call and agent
skyline-widget`/skyline-widget [on\off\demo\clear]`One building per turn, one floor per tool call
train-widget`/train-widget [on\off]`A locomotive pulling one wagon per tool call this turn, coloured by tool
garden-widget`/garden-widget [on\off\reset]`A plant: a leaf per tool call, a flower when checks pass, wilting when a call fails
campfire-widget`/campfire-widget [on\off\stoke]`A campfire that burns higher with tool calls and dies down to embers when idle
constellation-widget`/constellation-widget [on\off\clear]`A star per turn, joined into a constellation that gets a name after nine
fireworks-widget`/fireworks-widget [on\off\demo]`A night sky that sets off fireworks when checks pass, and a dud when they fail
invaders-widget`/invaders-widget [on\off\demo\clear]`An invader arrives for every failing check; passing checks shoot them down
weather-widget`/weather-widget [on\off\live\<percent>]`Clear sky when context has room, a storm near compaction
boss-widget`/boss-widget [on\off]`Context usage as a boss health bar; compaction defeats it and starts the next level
sky-widget`/sky-widget [on\off]`The sky at your local time: sun by day, moon by night, more stars the longer the session runs
world-widget`/world-widget [on\off\clear]`One scene for everything: sky by the clock, weather by context, a tower per turn, a train of tool calls and a wandering crab
quest-widget`/quest-widget [on\off\reset]`The session as a dungeon crawl: a room per turn, a monster per tool call, loot when checks pass; the hero is kept across sessions
race-widget`/race-widget [on\off\clear]`Parallel subagents as cars on a track, one stride per tool call, with a podium as they finish
moon-widget`/moon-widget [on\off\north\south]`The real moon right now, drawn for your hemisphere, with a countdown to the next full moon and what it means for your deploy

Visualizers

Abstract pictures driven by tool activity.

PluginCommandWhat it shows
mosaic-widget`/mosaic-widget [on\off\clear]`One tile per tool call, coloured by tool; failures are red
sorting-widget`/sorting-widget [on\off\step]`A bar chart being sorted, one swap per tool call
equalizer-widget`/equalizer-widget [on\off]`Level meters that jump with each tool call and fall back to rest
rain-widget`/rain-widget [on\off]`Falling code rain that speeds up the busier the turn gets
orbit-widget`/orbit-widget [on\off]`A small solar system; the planets speed up while tool calls run
life-widget`/life-widget [on\off\reset]`Conway's Game of Life; every tool call drops a glider
donut-widget`/donut-widget [on\off]`The spinning 3D donut; it spins faster while tool calls run
pipes-widget`/pipes-widget [on\off]`The pipes screensaver; it grows while the session is idle and pauses while Claude works
maze-widget`/maze-widget [on\off\new]`A first-person walk through a maze seeded by the project folder
marquee-widget`/marquee-widget [on\off\clear\<text>]`An LED ticker scrolling session events; any other text posts your own headline

Games

Snake, 2048 and Tetris play themselves until you click the board and take the keys.

PluginCommandWhat it shows
snake-widget`/snake-widget [on\off]`Snake; it plays itself until you take the keys
2048-widget`/2048-widget [on\off]`2048; it plays itself until you take the keys
tetris-widget`/tetris-widget [on\off]`Tetris; it plays itself until you take the keys
minesweeper-widget`/minesweeper-widget [on\off]`Minesweeper: click to reveal, right-click or f to flag, r to restart
breakout-widget`/breakout-widget [on\off\reset]`A self-playing brick breaker; every tool call adds a row of bricks
typer-widget`/typer-widget [on\off]`A typing game: words from your own file names fall, and you type them before they land

Just for fun

PluginCommandWhat it shows
fortune-widget`/fortune-widget [on\off\next]`A one-line fortune that changes with every turn
8ball-widget`/8ball-widget [on\off], /8ball <question>`/8ball <question> gives an answer of doubtful reliability
badges-widget`/badges-widget [on\off\reset]`Achievements earned across sessions, with a toast when one unlocks
sigil-widget`/sigil-widget [on\off\clear]`A pixel emblem generated from this session's activity, beside a gallery of the ones from earlier sessions
sound-widget`/sound-widget [on\off\mute\test]`A note per tool call, a chord when checks pass, a buzz on failure, with a piano roll (audio plays on macOS only)

Placement, which widgets are on, and view modes are saved and restored in every session.

Notes

  • /widgets width pet 60 sets how wide one widget's card may grow (the default is 40 columns); /widgets width pet reset puts it back.
  • /widgets side docks the cards beside the transcript only in the fullscreen layout (/tui fullscreen). In the default layout it falls back to below the prompt.
  • The pixel art is drawn with terminal block characters and shows in the terminal only.
  • sound-widget plays audio on macOS only; elsewhere it stays silent and just draws the piano roll.
  • board-widget registers a pin tool that Claude can call, so its description is part of the context while the board is on.
  • watch-widget runs your command through sh -c, falling back to cmd /c.
  • Taking over a game, and playing Minesweeper at all, needs a mouse click on the board, which the fullscreen layout reports.

Develop

Each plugin under plugins/ is a mod: a manifest, a hooks module and its tests.

claude --plugin-dir plugins            # load every plugin from this checkout
claude plugin validate plugins/<name>  # check a manifest and hooks module
claude plugin test plugins/<name>      # run its tests

Plugin storage ($.store) is read once per session, so a widget that has to see other live sessions keeps a file under its own folder instead ($.plugin.root).

Plugins cannot import from one another, so widgets shares card stacking and the pixel renderer as $.widgets (plugins/widgets/hooks/kit.tsx), which every widget calls.

Demo page

docs/ is the demo page. bun run site/build.ts bundles every widget's hooks module into docs/mods.js and writes docs/catalog.js from the tables above, so a new widget appears on the page once it has a row here. docs/engine.js is a small stand-in for the mod engine (state, store, clock, commands, a made-up project and a scripted turn), and docs/view.js draws the card trees. bun run site/smoke.ts boots every widget in that engine and runs a turn; a widget that needs an engine call the stand-in lacks shows up there. docs/widgets.js holds recordings from the real interface, shown only if a widget fails to start.

The Widget Factory

New widgets are made by the Widget Factory in factory/: a crew of Claude Code agents that takes each widget through ideation, design, build, inspection and shipping, with a different agent inspecting than built it. Clone the repository and you can run your own:

https://github.com/user-attachments/assets/6833eb82-8b2e-4b4c-80ca-4194d48d10e7

bun run --cwd factory setup   # checks what you need and prepares the floor
bun run --cwd factory floor   # the factory floor in your browser
bun run --cwd factory line    # opens a Claude Code session that runs the line

https://github.com/user-attachments/assets/d0953871-4935-4fa9-8ab3-f58b51739a17

factory/README.md explains the stations, the crew, the widget standard and how to contribute a widget.

License

MIT

Source 3 files
hooks/register.tsx 812 lines
1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, PluginState, Register, RenderElement, RenderSurface, Timer } from 'claude-code'
3import type { WidgetsPlace } from 'widgets'
4
5import type { AimKind, AimRow, AimShot } from '../types'
6import { fit, plural, span } from './lib'
7
8type Tags = Pick<Elements[RenderSurface], 'Box' | 'Text'>
9type Aim = PluginState['aim-widget']['aim']
10type Env = Readonly<Record<string, string | undefined>>
11type Over = Readonly<Record<string, string>>
12type Place = { cwd: string; isWindows: boolean }
13type Probes = Map<string, Promise<string>>
14type Target = { name: string; phrase: string; source: string }
15type Ask = { kind: AimKind; ask: 'ambient' | 'psql' | 'url'; dir: string | undefined; over: Over; base?: string }
16type Marks = Partial<Record<AimKind, string>>
17type Settled = { kind: AimKind } & (Target | { why: string })
18type Shot = Settled | Ask
19type Frame = { words: string[]; word: string; shut: string; isGroup: boolean }
20type Reading = { over: Over; dir: string | undefined; set: Marks; moot: Marks; isChanged: boolean; shots: Shot[]; outer: readonly Reading[] }
21
22const PANE = 'widgets'
23const CARD_COLUMNS = 40
24const USAGE = 'Usage: /aim-widget [on|off|prod <word>|unprod <word>|show|clear]'
25const OFF = 'Aim is off.'
26const LOOKING = 'Looking for targets…'
27const NONE = 'No outside targets found here.'
28const EMPTY = `${NONE} A kube context, cloud profile, project, workspace or database host shows once one is set on this machine.`
29const WINDOWS = /^[a-z]:[\\/]|^\\\\/i
30const WORD = /^[a-z0-9._-]{2,32}$/
31const ASSIGNED = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s
32const MENTIONED = /kubectl|helm|aws|gcloud|terraform|psql/i
33const LINK = /^postgres(?:ql)?:\/\//
34const HEREDOC = /<<-?\s*(["']?)([A-Za-z_]\w*)\1/
35const ENV_DRIVE = /(?:^|[^a-z0-9_])env:|SetEnvironmentVariable/i
36const UNSET_FLAG = /^(?:--unset=|-u)(\w+)$/
37const LINK_VARIABLE = /^(?:--dbname=)?\$\{?DATABASE_URL\}?$/
38const CONTINUED = /\\\r?\n/g
39const TICK_CONTINUED = /`\r?\n/g
40const PSQL_VALUED =
41  /^(?:-[aAbeEnqsStxXwWlz1]*[cdfvoLFPRThpU]|--(?:command|dbname|file|set|variable|output|log-file|field-separator|pset|record-separator|table-attr|host|port|username))$/
42const URL_PARTS = /^[a-z][a-z0-9+.-]*:\/\/(?:[^/?#]*@)?(\[[^\]]*\]|[^:/?#]*)(?::\d*)?(?:\/([^?#]*))?/i
43const TOOLS = new Map<string, AimKind>([
44  ['kubectl', 'kube'],
45  ['helm', 'kube'],
46  ['aws', 'aws'],
47  ['gcloud', 'gcp'],
48  ['terraform', 'tf'],
49  ['psql', 'db'],
50])
51const KINDS: readonly AimKind[] = ['kube', 'aws', 'gcp', 'tf', 'db']
52const PHRASES: Readonly<Record<AimKind, string>> = { kube: 'kube context', aws: 'aws profile', gcp: 'gcloud project', tf: 'terraform workspace', db: 'database' }
53const VARIABLES: Readonly<Record<AimKind, readonly string[]>> = {
54  kube: [],
55  aws: ['AWS_ACCESS_KEY_ID', 'AWS_PROFILE', 'AWS_DEFAULT_PROFILE'],
56  gcp: ['CLOUDSDK_CORE_PROJECT'],
57  tf: ['TF_WORKSPACE', 'TF_DATA_DIR'],
58  db: ['DATABASE_URL', 'PGHOST', 'PGDATABASE'],
59}
60const WRAPPERS = ['sudo', 'env', 'time', 'command', 'exec', 'nohup', 'xargs', 'if', 'elif', 'then', 'else', 'while', 'until', 'do', '!']
61const SOURCING = ['source', '.', 'eval']
62const EXPORTING = ['export', 'declare', 'typeset', 'readonly', 'local']
63const MOVING = ['cd', 'chdir', 'pushd', 'popd', 'set-location', 'sl', 'push-location', 'pop-location']
64const UNSETTING = ['-u', '--unset']
65const VALUED = [...UNSETTING, '-I']
66const ELSEWHERE: Readonly<Record<string, readonly string[]>> = { kubectl: ['--cluster', '--server', '-s', '--user'], helm: ['--kube-apiserver'] }
67const OPEN = '('
68const SHUT = ')'
69const HOLE = '$()'
70const OTHER_USER = /^(?:-u|--user(?:=|$))/
71const SHELL = ['cmd', '/d', '/c']
72const KUBECTL = ['kubectl', 'config', 'current-context']
73const GCLOUD = ['gcloud', 'config', 'get-value', 'project']
74const NAMED = 'named in the command'
75const ENVIRONMENT = 'from the environment'
76const FROM_VARIABLE = 'set from a variable'
77const INDIRECT = 'not a direct call'
78const RECONFIGURED = 'another configuration given'
79const REPOINTED = 'context changed in the command'
80const GIVEN = 'connection string given'
81const UNSET = '$'
82const PROBE_MS = 3000
83const TICK_MS = 1000
84const REFRESH_MS = 30_000
85const WORDS_MAX = 8
86const LINES_MAX = 3
87const COMMAND_MAX = 80
88const WIDE_COLUMNS = 30
89const BLANK: Aim = { at: 0, isBusy: false, rows: [], last: null }
90const site = { plugin: 'widgets', key: 'site' } as const
91const widths = { plugin: 'widgets', key: 'widths' } as const
92const isOn = atom({ plugin: 'aim-widget', key: 'isOn' } as const, false)
93const words = atom({ plugin: 'aim-widget', key: 'words' } as const, ['prod'])
94const aim = atom({ plugin: 'aim-widget', key: 'aim' } as const, BLANK)
95
96let timer: Timer | undefined
97
98const cut = (text: string, room: number): string => {
99  if (text.length <= room) return text
100
101  return room < 1 ? '' : `${text.slice(0, room - 1).replace(/…$/, '')}…`
102}
103
104const wrapped = (text: string, room: number): string[] =>
105  text.split(' ').reduce<string[]>((lines, word) => {
106    const last = lines.at(-1)
107
108    return last !== undefined && last.length + 1 + word.length <= room ? [...lines.slice(0, -1), `${last} ${word}`] : [...lines, cut(word, room)]
109  }, [])
110
111const ago = (ms: number): string => span(ms).split(' ')[0] ?? ''
112
113const bare = (word: string): string => (word.length >= 2 && (word.startsWith('"') || word.startsWith("'")) && word.endsWith(word.charAt(0)) ? word.slice(1, -1) : word)
114
115const valued = (value: string): string => (value.replace(/\\/g, '').trim() === '' || /["'$`]/.test(value) ? UNSET : value)
116
117const toolOf = (word: string): string => (word.split(/[\\/]/).at(-1) ?? '').replace(/\.exe$/i, '').toLowerCase()
118
119// A heredoc's body is text handed to the command, not commands of its own.
120const spoken = (command: string): string => {
121  let end: string | undefined
122
123  return command
124    .split('\n')
125    .filter(line => {
126      if (end !== undefined) {
127        if (line.trim() === end) end = undefined
128
129        return false
130      }
131      end = HEREDOC.exec(line.replace(/<<</g, ' '))?.[2]
132
133      return true
134    })
135    .join('\n')
136}
137
138// Quotes and an escape (a backslash in Bash, a backtick in PowerShell) keep a separator as text, so a commit message or a query is one word.
139// Whatever opens with a bracket or a backtick is read first, between OPEN and SHUT, which no unquoted word can equal. Unless it began a
140// command of its own, the command it was written in then carries on, with HOLE where its output would stand.
141// A brace separates only as a word of its own, or in PowerShell around a script block; inside a word it is text.
142// A lone & ends a command as ; does, and in PowerShell starts one; the & of 2>&1 and &> belongs to its redirection.
143const segmentsOf = (command: string, isBash: boolean): string[][] => {
144  const segments: string[][] = []
145  const outer: Frame[] = []
146  const escape = isBash ? '\\' : '`'
147  let frame: Frame = { words: [], word: '', shut: '', isGroup: false }
148  let quote = ''
149  let blocks = 0
150  const close = (): void => {
151    if (frame.word !== '' && frame.word !== '\\') frame.words.push(frame.word)
152    frame.word = ''
153  }
154  const end = (): void => {
155    close()
156    if (frame.words.length > 0) segments.push(frame.words)
157    frame.words = []
158  }
159  const open = (shut: string, isGroup: boolean): void => {
160    segments.push([OPEN])
161    outer.push(frame)
162    frame = { words: [], word: '', shut, isGroup }
163  }
164  const shut = (): void => {
165    end()
166    segments.push([SHUT])
167    const before = outer.pop()
168    if (before === undefined) return
169    if (!frame.isGroup) before.word += HOLE
170    frame = before
171  }
172  for (let at = 0; at < command.length; at += 1) {
173    const letter = command.charAt(at)
174    const after = command.charAt(at + 1)
175    const pair = command.slice(at, at + 2)
176    const isEdge = after === '' || /[\s;|&)]/.test(after)
177    if (letter === escape && quote !== "'" && after !== '') {
178      frame.word += pair
179      at += 1
180    } else if (quote !== '') {
181      frame.word += letter
182      if (letter === quote) quote = ''
183    } else if (letter === '"' || letter === "'") {
184      quote = letter
185      frame.word += letter
186    } else if (letter === '#' && frame.word === '') {
187      const rest = command.indexOf('\n', at)
188      at = rest < 0 ? command.length : rest - 1
189    } else if (pair === '$(') {
190      open(SHUT, false)
191      at += 1
192    } else if (letter === OPEN) open(SHUT, frame.word === '' && frame.words.length === 0)
193    else if (letter === '`' && isBash) {
194      if (frame.shut === letter) shut()
195      else open(letter, false)
196    } else if (letter === SHUT) {
197      if (frame.shut === letter) shut()
198      else end()
199    } else if (letter === '{' && frame.word === '' && (after === '' || /\s/.test(after))) end()
200    else if (letter === '{' && frame.word === '' && !isBash && after !== '{' && after !== '}') {
201      blocks += 1
202      end()
203    } else if (letter === '}' && frame.word === '' && isEdge) end()
204    else if (letter === '}' && !isBash && blocks > 0 && isEdge) {
205      blocks -= 1
206      end()
207    } else if (pair === '&&' || pair === '||') {
208      end()
209      at += 1
210    } else if (';|\n'.includes(letter) || (letter === '&' && !/[<>]$/.test(frame.word) && after !== '>')) end()
211    else if (/\s/.test(letter)) close()
212    else frame.word += letter
213  }
214  while (outer.length > 0) shut()
215  end()
216
217  return segments
218}
219
220
221const headOf = (raws: readonly string[]): number => {
222  let at = 0
223  let isWrapped = false
224  for (; at < raws.length; at += 1) {
225    const raw = raws[at] ?? ''
226    if (WRAPPERS.includes(raw)) isWrapped = true
227    else if (!ASSIGNED.test(raw) && !(isWrapped && (raw.startsWith('-') || VALUED.includes(raws[at - 1] ?? '')))) break
228  }
229
230  return at
231}
232
233const assigned = (over: Over, raw: string): Over => {
234  const [, name, value = ''] = ASSIGNED.exec(raw) ?? []
235
236  return name === undefined ? over : { ...over, [name]: valued(bare(value)) }
237}
238
239const dropped = (over: Over, name: string): Over => ({ ...over, [name]: '' })
240
241const prefixed = (over: Over, raws: readonly string[]): Over =>
242  raws.reduce((held, raw, at) => {
243    const name = UNSETTING.includes(raws[at - 1] ?? '') ? raw : UNSET_FLAG.exec(raw)?.[1]
244
245    return name === undefined ? assigned(held, raw) : dropped(held, name)
246  }, over)
247
248const attached = (texts: readonly string[], flag: string): string | undefined => {
249  const text = texts.find(other => other.startsWith(flag) && other.length > flag.length && !other.startsWith('--'))
250
251  return text === undefined ? undefined : valued(bare(text.slice(flag.length)))
252}
253
254// A flag given twice is read as these tools read it: the last one wins.
255const flagged = (texts: readonly string[], names: readonly string[]): string | undefined =>
256  texts.reduce<string | undefined>((found, text, at) => {
257    const [flag = '', ...rest] = text.split('=')
258
259    return names.includes(flag) ? valued(rest.length > 0 ? bare(rest.join('=')) : (texts[at + 1] ?? '')) : found
260  }, undefined)
261
262// sudo -u runs the tool as someone else, whose kubeconfig, profile and environment are not this user's.
263const isBorrowed = (raws: readonly string[]): boolean => {
264  let wrapper = ''
265
266  return raws.some(raw => {
267    if (WRAPPERS.includes(raw)) wrapper = raw
268
269    return wrapper === 'sudo' && OTHER_USER.test(raw)
270  })
271}
272
273const partsOf = (url: string): [host: string, base: string] | undefined => {
274  const [, host, base = ''] = URL_PARTS.exec(url) ?? []
275
276  return host === undefined ? undefined : [host === '' ? 'localhost' : host, base]
277}
278
279const reachOf = (url: string): string | undefined => {
280  const [host, base] = partsOf(url) ?? []
281
282  return host === undefined ? undefined : base ? `${host}/${base}` : host
283}
284
285const moved = (dir: string | undefined, path: string | undefined, isWindows: boolean): string | undefined => {
286  if (dir === undefined || path === undefined || valued(path) === UNSET || path.startsWith('~')) return undefined
287  if (WINDOWS.test(path)) return isWindows ? path : undefined
288  // A Git Bash path such as /c/infra is not one $.fs can be trusted to resolve.
289  if (path.startsWith('/')) return isWindows ? undefined : path
290
291  return `${dir}/${path}`
292}
293
294const setterOf = (head: string, plain: readonly string[]): string | undefined => {
295  const [first, second, third, fourth] = plain
296  if (head === 'kubectl' && first === 'config' && (second === 'use-context' || second === 'use')) return valued(third ?? '')
297  if (head === 'kubectl' && first === 'config' && second === 'set' && third === 'current-context') return valued(fourth ?? '')
298  if (head === 'terraform' && first === 'workspace' && (second === 'select' || second === 'new')) return valued(third ?? '')
299  if (head === 'gcloud' && first === 'config' && second === 'set' && (third === 'project' || third === 'core/project')) return valued(fourth ?? '')
300
301  return undefined
302}
303
304// These rewrite the kubeconfig or gcloud's active configuration after the probe has answered for the old one.
305const mootOf = (head: string, plain: readonly string[]): Marks => {
306  const isRepointing =
307    head === 'kubectx' ||
308    (head === 'aws' && plain.includes('update-kubeconfig')) ||
309    ((head === 'gcloud' || head === 'az') && plain.includes('get-credentials')) ||
310    (head === 'kubectl' && plain[0] === 'config' && plain[1] === 'unset' && plain[2] === 'current-context')
311  if (isRepointing) return { kube: REPOINTED }
312
313  return head === 'gcloud' && plain.includes('configurations') && plain.includes('activate') ? { gcp: RECONFIGURED } : {}
314}
315
316const named = (kind: AimKind, name: string | undefined, phrase = PHRASES[kind]): Settled =>
317  name === undefined || name === UNSET ? { kind, why: FROM_VARIABLE } : { kind, name, phrase, source: NAMED }
318
319const direct = (head: string, kind: AimKind, texts: readonly string[], held: Reading, set: string | undefined, isWindows: boolean): Shot => {
320  const { over, dir } = held
321  const ask: Ask = { kind, ask: 'ambient', dir, over }
322  if (kind === 'kube') {
323    const until = texts.indexOf('--')
324    const own = until < 0 ? texts : texts.slice(0, until)
325    if (flagged(own, ['--kubeconfig']) !== undefined || 'KUBECONFIG' in over) return { kind, why: 'another kubeconfig given' }
326    if (flagged(own, ELSEWHERE[head] ?? []) !== undefined) return { kind, why: 'another cluster given' }
327    const context = flagged(own, [head === 'helm' ? '--kube-context' : '--context']) ?? set
328
329    return context === undefined ? ask : named(kind, context)
330  }
331  if (kind === 'aws') {
332    const endpoint = flagged(texts, ['--endpoint-url'])
333    if (endpoint !== undefined) return named(kind, partsOf(endpoint)?.[0], 'aws endpoint')
334    const profile = flagged(texts, ['--profile'])
335
336    return profile === undefined ? ask : named(kind, profile)
337  }
338  if (kind === 'gcp') {
339    const project = flagged(texts, ['--project']) ?? set
340    if (project !== undefined) return named(kind, project)
341
342    return flagged(texts, ['--configuration', '--account']) === undefined && !over.CLOUDSDK_ACTIVE_CONFIG_NAME ? ask : { kind, why: RECONFIGURED }
343  }
344  if (kind === 'tf') {
345    const chdir = texts.find(text => text.startsWith('-chdir='))
346    if (set !== undefined && !over.TF_WORKSPACE) return named(kind, set)
347
348    return chdir === undefined ? ask : { ...ask, dir: moved(dir, bare(chdir.slice('-chdir='.length)), isWindows) }
349  }
350
351  const lone = texts.find((text, at) => !text.startsWith('-') && !text.includes('=') && !PSQL_VALUED.test(texts[at - 1] ?? ''))
352  const base = flagged(texts, ['-d', '--dbname']) ?? attached(texts, '-d') ?? (lone === undefined ? undefined : valued(lone))
353  const link = [base, ...texts].find(text => text !== undefined && LINK.test(text))
354  if (link !== undefined) return named(kind, valued(link) === UNSET ? undefined : reachOf(link))
355  if (texts.some(text => LINK_VARIABLE.test(text))) return { ...ask, ask: 'url' }
356  const host = flagged(texts, ['-h', '--host']) ?? attached(texts, '-h')
357  if (host !== undefined) return named(kind, host === UNSET || base === UNSET ? undefined : base === undefined ? host : `${host}/${base}`)
358  if (texts.some(text => /(?:^|\s)(?:host|service)=/.test(text))) return { kind, why: GIVEN }
359
360  return { ...ask, ask: 'psql', ...(base === undefined ? {} : { base }) }
361}
362
363const headsIn = (text: string, isBash: boolean): AimKind[] =>
364  segmentsOf(text, isBash).flatMap(raws => {
365    const kind = TOOLS.get(toolOf(bare(raws[headOf(raws)] ?? '')))
366
367    return [...(kind === undefined ? [] : [kind]), ...raws.filter(raw => bare(raw) !== raw).flatMap(raw => headsIn(bare(raw), isBash))]
368  })
369
370const stepOf = (held: Reading, raws: readonly string[], isWindows: boolean, isBash: boolean): Reading => {
371  const at = headOf(raws)
372  const texts = raws.map(bare)
373  const head = toolOf(texts[at] ?? '')
374  const rest = texts.slice(at + 1)
375  const plain = rest.filter(text => !text.startsWith('-'))
376  const local = prefixed(held.over, raws.slice(0, at))
377  if (at === raws.length) return { ...held, over: local }
378  if (EXPORTING.includes(head)) return { ...held, over: raws.slice(at + 1).reduce(assigned, held.over) }
379  if (head === 'unset') return { ...held, over: plain.reduce(dropped, held.over) }
380  if (MOVING.includes(head)) {
381    const { tf: _tf, ...set } = held.set
382
383    return { ...held, set, dir: moved(held.dir, plain[0], isWindows) }
384  }
385
386  const kind = TOOLS.get(head)
387  const set = kind === undefined ? undefined : setterOf(head, plain)
388  // A workspace chosen or read under -chdir belongs to that folder, so it neither carries on nor is carried in.
389  const isAside = kind === 'tf' && rest.some(text => text.startsWith('-chdir='))
390  const carried = kind === undefined || isAside ? undefined : held.set[kind]
391  const quoted = raws.filter((raw, index) => bare(raw) !== raw && (kind === undefined || index !== at)).flatMap(raw => headsIn(bare(raw), isBash))
392  // Inside a listed tool's own call another tool's name is an argument, a namespace or a chart, so only other commands are read for a mention.
393  const loose = kind === undefined ? raws.flatMap((raw, index) => (bare(raw) === raw ? (TOOLS.get(toolOf(texts[index] ?? '')) ?? []) : [])) : []
394  const taken = kind === undefined ? undefined : direct(head, kind, rest, { ...held, over: local }, set ?? carried, isWindows)
395  const shot: Shot | undefined = taken !== undefined && 'ask' in taken && isBorrowed(raws.slice(0, at)) ? { kind: taken.kind, why: 'another user given' } : taken
396  const fresh = mootOf(head, plain)
397  const moot = { ...held.moot, ...fresh }
398  const doubt = shot !== undefined && 'ask' in shot && shot.ask === 'ambient' ? moot[shot.kind] : undefined
399  const mine: Shot[] =
400    kind === undefined || shot === undefined ? [] : [held.isChanged ? { kind, why: 'environment changed in the command' } : doubt === undefined ? shot : { kind, why: doubt }]
401  const kept: Marks = Object.fromEntries(Object.entries(held.set).filter(([other]) => !(other in fresh)))
402
403  return {
404    ...held,
405    moot,
406    set: kind === undefined || set === undefined || isAside ? kept : { ...kept, [kind]: set },
407    isChanged: held.isChanged || SOURCING.includes(head) || raws.some(raw => ENV_DRIVE.test(raw)),
408    shots: [...held.shots, ...mine, ...[...new Set([...quoted, ...loose])].filter(other => other !== kind).map(other => ({ kind: other, why: INDIRECT }))],
409  }
410}
411
412// What a subshell sets ends with it, but what it wrote to a config file stays written: a context or project it chose, and a workspace
413// when it chose it in the folder the reading returns to. PowerShell's parentheses group and do not scope, so there the reading carries on.
414const scoped = (held: Reading, mark: string, isScoping: boolean): Reading => {
415  if (!isScoping) return held
416  if (mark === OPEN) return { ...held, outer: [...held.outer, held] }
417  const before = held.outer.at(-1)
418  if (before === undefined) return held
419  const { tf: _tf, ...written } = held.set
420  const set: Marks = held.dir === before.dir ? held.set : { ...written, ...(before.set.tf === undefined ? {} : { tf: before.set.tf }) }
421
422  return { ...before, shots: held.shots, moot: held.moot, set }
423}
424
425// A backslash before a newline (a backtick in PowerShell) carries the command on, so the next line's flags are this command's.
426const shotsOf = (command: string, { cwd, isWindows }: Place, isScoping: boolean): Shot[] =>
427  segmentsOf(isScoping ? spoken(command).replace(CONTINUED, '') : spoken(command).replace(TICK_CONTINUED, ' '), isScoping).reduce<Reading>(
428    (held, raws) => (raws.length === 1 && (raws[0] === OPEN || raws[0] === SHUT) ? scoped(held, raws[0], isScoping) : stepOf(held, raws, isWindows, isScoping)),
429    { over: {}, dir: cwd, set: {}, moot: {}, isChanged: false, shots: [], outer: [] },
430  ).shots
431
432const envOf = async ($: EngineInterface): Promise<Env> => ({
433  AWS_PROFILE: await $.env.get('AWS_PROFILE'),
434  AWS_DEFAULT_PROFILE: await $.env.get('AWS_DEFAULT_PROFILE'),
435  AWS_ACCESS_KEY_ID: await $.env.get('AWS_ACCESS_KEY_ID'),
436  CLOUDSDK_CORE_PROJECT: await $.env.get('CLOUDSDK_CORE_PROJECT'),
437  TF_WORKSPACE: await $.env.get('TF_WORKSPACE'),
438  TF_DATA_DIR: await $.env.get('TF_DATA_DIR'),
439  DATABASE_URL: await $.env.get('DATABASE_URL'),
440  PGHOST: await $.env.get('PGHOST'),
441  PGDATABASE: await $.env.get('PGDATABASE'),
442  PGSERVICE: await $.env.get('PGSERVICE'),
443  PGHOSTADDR: await $.env.get('PGHOSTADDR'),
444  USERPROFILE: await $.env.get('USERPROFILE'),
445  HOME: await $.env.get('HOME'),
446})
447
448const probed = ($: EngineInterface, argv: readonly string[], dir: string, isWindows: boolean, probes: Probes): Promise<string> => {
449  const [tool = ''] = argv
450  const asked =
451    probes.get(tool) ??
452    $.process.run(isWindows ? [...SHELL, ...argv] : argv, { cwd: dir, timeoutMs: PROBE_MS }).then(
453      ran => (ran.exitCode === 0 ? (ran.stdout.split('\n')[0] ?? '').trim() : ''),
454      () => '',
455    )
456  probes.set(tool, asked)
457
458  return asked
459}
460
461// The CLI's versions do not agree on which of the two names wins, so two different ones name no profile.
462const isSplit = (env: Env): boolean => !!env.AWS_PROFILE && !!env.AWS_DEFAULT_PROFILE && env.AWS_PROFILE !== env.AWS_DEFAULT_PROFILE
463
464const ambient = async ($: EngineInterface, shot: Ask, env: Env, place: Place, probes: Probes): Promise<Target | undefined> => {
465  const { kind, dir, over } = shot
466  const phrase = PHRASES[kind]
467  const from = (name: string): string => (name in over ? NAMED : ENVIRONMENT)
468  if (kind === 'kube') {
469    const name = await probed($, KUBECTL, dir ?? place.cwd, place.isWindows, probes)
470
471    return name === '' ? undefined : { name, phrase, source: '' }
472  }
473  if (kind === 'gcp') {
474    if (env.CLOUDSDK_CORE_PROJECT) return { name: env.CLOUDSDK_CORE_PROJECT, phrase, source: from('CLOUDSDK_CORE_PROJECT') }
475    const name = await probed($, GCLOUD, dir ?? place.cwd, place.isWindows, probes)
476
477    return name === '' || name === '(unset)' ? undefined : { name, phrase, source: '' }
478  }
479  if (kind === 'aws') {
480    if (env.AWS_ACCESS_KEY_ID) return { name: `key …${env.AWS_ACCESS_KEY_ID.slice(-4)}`, phrase: 'aws key', source: from('AWS_ACCESS_KEY_ID') }
481    if (isSplit(env)) return undefined
482    if (env.AWS_PROFILE) return { name: env.AWS_PROFILE, phrase, source: from('AWS_PROFILE') }
483    if (env.AWS_DEFAULT_PROFILE) return { name: env.AWS_DEFAULT_PROFILE, phrase, source: from('AWS_DEFAULT_PROFILE') }
484    const home = env.USERPROFILE || env.HOME
485    const isSetUp = home !== undefined && home !== '' && ((await $.fs.exists(`${home}/.aws/config`)) || (await $.fs.exists(`${home}/.aws/credentials`)))
486
487    return isSetUp ? { name: 'default', phrase, source: '' } : undefined
488  }
489  if (kind === 'tf') {
490    if (env.TF_WORKSPACE) return { name: env.TF_WORKSPACE, phrase, source: from('TF_WORKSPACE') }
491    if (dir === undefined || env.TF_DATA_DIR) return undefined
492    const name = await $.fs.read(`${dir}/.terraform/environment`).then(
493      text => text.trim(),
494      () => '',
495    )
496    if (name !== '') return { name, phrase, source: '' }
497
498    return (await $.fs.exists(`${dir}/.terraform.lock.hcl`)) ? { name: 'default', phrase, source: '' } : undefined
499  }
500
501  const reach = reachOf(env.DATABASE_URL ?? '')
502  if (reach !== undefined) return { name: reach, phrase, source: from('DATABASE_URL') }
503
504  return env.PGHOST ? { name: env.PGDATABASE ? `${env.PGHOST}/${env.PGDATABASE}` : env.PGHOST, phrase, source: from('PGHOST') } : undefined
505}
506
507const settle = async ($: EngineInterface, shot: Ask, base: Env, place: Place, probes: Probes): Promise<Target | string> => {
508  const { kind, over } = shot
509  const env = { ...base, ...over }
510  const phrase = PHRASES[kind]
511  const isUnset = (names: readonly string[]): boolean => names.some(name => over[name] === UNSET)
512  if (shot.ask === 'url') {
513    const reach = isUnset(['DATABASE_URL']) ? undefined : reachOf(env.DATABASE_URL ?? '')
514
515    return reach === undefined ? FROM_VARIABLE : { name: reach, phrase, source: 'DATABASE_URL' in over ? NAMED : ENVIRONMENT }
516  }
517  if (shot.ask === 'psql') {
518    if (env.PGSERVICE || env.PGHOSTADDR) return GIVEN
519    if (shot.base === UNSET || isUnset(['PGHOST', 'PGDATABASE'])) return FROM_VARIABLE
520    // psql itself does not read DATABASE_URL, so only PGHOST moves it off its default.
521    const host = env.PGHOST || 'localhost'
522    const base = shot.base ?? env.PGDATABASE
523
524    return { name: base ? `${host}/${base}` : host, phrase, source: !env.PGHOST ? 'psql default' : 'PGHOST' in over ? NAMED : ENVIRONMENT }
525  }
526  if (isUnset(VARIABLES[kind])) return FROM_VARIABLE
527  if (kind === 'aws' && !env.AWS_ACCESS_KEY_ID && isSplit(env)) return 'two profiles set'
528  if (kind === 'tf' && !env.TF_WORKSPACE && env.TF_DATA_DIR) return 'another data folder given'
529  const found = await ambient($, shot, env, place, probes)
530  if (found !== undefined) return found
531  if (kind === 'kube' || kind === 'gcp') return `${kind === 'kube' ? 'kubectl' : 'gcloud'} did not answer`
532  if (kind === 'aws') return 'no profile found'
533
534  return shot.dir === undefined ? 'folder changed in the command' : 'no workspace found here'
535}
536
537const merged = (rows: readonly AimRow[], seen: ReadonlyMap<AimKind, string | undefined>, now: number): AimRow[] =>
538  KINDS.flatMap(kind => {
539    const held = rows.find(row => row.kind === kind)
540    if (!seen.has(kind)) return held ?? []
541    const name = seen.get(kind)
542    if (name === undefined) return []
543
544    return [{ kind, name, since: held === undefined || held.name === name ? (held?.since ?? 0) : now }]
545  })
546
547const isHot = (name: string, held: readonly string[]): boolean => held.some(word => name.toLowerCase().includes(word))
548
549const tidied = (command: string): string =>
550  command
551    .replace(/:\/\/[^\s/]*@([^\s/:?#"']*)(?::\d+)?([^\s?#"']*)(?:[?#][^\s"']*)?/g, '://…@$1$2')
552    .replace(/\b(\w*(?:PASSWORD|SECRET|TOKEN)\w*)=\S+/gi, '$1=…')
553    .replace(/(?<![\w-])(-{1,2}(?!-?no-)[\w-]*(?:password|secret|token)[\w-]*)\s+(?!-)\S+/gi, '$1 …')
554    .replace(/\s+/g, ' ')
555    .trim()
556    .slice(0, COMMAND_MAX)
557
558const aimed = async ($: EngineInterface, command: string, isScoping: boolean): Promise<string | undefined> => {
559  if (!MENTIONED.test(command)) return undefined
560
561  const cwd = await $.session.cwd()
562  const place = { cwd, isWindows: WINDOWS.test(cwd) }
563  const shots = shotsOf(command, place, isScoping)
564  if (shots.length === 0) return undefined
565
566  const env = shots.some(shot => 'ask' in shot) ? await envOf($) : {}
567  const probes: Probes = new Map()
568  const seen = new Map<AimKind, string | undefined>()
569  const settled: Settled[] = []
570  for (const shot of shots) {
571    if (!('ask' in shot)) {
572      settled.push(shot)
573      continue
574    }
575    const found = await settle($, shot, env, place, probes)
576    const isAmbient = shot.ask === 'ambient' && shot.dir === cwd && !VARIABLES[shot.kind].some(name => name in shot.over)
577    if (isAmbient) seen.set(shot.kind, typeof found === 'string' ? undefined : found.name)
578    settled.push(typeof found === 'string' ? { kind: shot.kind, why: found } : { kind: shot.kind, ...found })
579  }
580
581  const now = await $.clock.now()
582  const held = await read($, words)
583  const rows = merged((await read($, aim)).rows, seen, now)
584  const lines = settled.map(shot => {
585    if ('why' in shot) return { text: `${PHRASES[shot.kind]} not known: ${shot.why}`, isProduction: false, isKnown: false }
586    const since = shot.source !== NAMED && seen.get(shot.kind) === shot.name ? (rows.find(row => row.kind === shot.kind)?.since ?? 0) : 0
587    const said = [shot.phrase, shot.source, since === 0 ? '' : `changed, seen ${ago(now - since)} ago`].filter(part => part !== '').join(', ')
588    const isProduction = isHot(shot.name, held)
589
590    return { text: `${isProduction ? 'PRODUCTION: goes' : 'Goes'} to ${shot.name} (${said})`, isProduction, isKnown: true }
591  })
592  const texts = [...new Set(lines.map(line => line.text))]
593  const line = `${texts.slice(0, LINES_MAX).join('; ')}${texts.length > LINES_MAX ? ` (+${texts.length - LINES_MAX} more)` : ''}`
594  const last: AimShot = { command: tidied(command), line, isProduction: lines.some(made => made.isProduction), isKnown: lines.every(made => made.isKnown) }
595  if (!(await read($, isOn))) return undefined
596  await update($, aim, kept => ({ ...(kept ?? BLANK), rows: merged((kept ?? BLANK).rows, seen, now), last }))
597
598  return line
599}
600
601const judged = async <Verdict extends { decision: string; reason?: string }>(
602  $: EngineInterface,
603  input: unknown,
604  id: string | undefined,
605  verdict: Verdict,
606  isScoping: boolean,
607): Promise<Verdict> => {
608  const command = (input as { command?: unknown } | null)?.command
609  if (verdict.decision !== 'ask' || typeof id !== 'string' || typeof command !== 'string') return verdict
610
611  const line = await aimed($, command, isScoping)
612  if (line === undefined) return verdict
613
614  return { ...verdict, reason: verdict.reason ? `${line} · ${verdict.reason}` : line }
615}
616
617const refresh = async ($: EngineInterface): Promise<void> => {
618  const cwd = await $.session.cwd()
619  const place = { cwd, isWindows: WINDOWS.test(cwd) }
620  const env = await envOf($)
621  const probes: Probes = new Map()
622  const found = await Promise.all(KINDS.map(kind => ambient($, { kind, ask: 'ambient', dir: cwd, over: {} }, env, place, probes)))
623  const seen = new Map(KINDS.map((kind, at) => [kind, found[at]?.name]))
624  const now = await $.clock.now()
625  if (await read($, isOn)) await update($, aim, kept => ({ ...(kept ?? BLANK), rows: merged((kept ?? BLANK).rows, seen, now) }))
626}
627
628const tick = async ($: EngineInterface): Promise<void> => {
629  if (!(await read($, isOn))) return
630
631  const { at, isBusy } = await read($, aim)
632  if (isBusy || (at !== 0 && (await $.clock.now()) - at < REFRESH_MS)) return
633
634  await update($, aim, kept => ({ ...(kept ?? BLANK), isBusy: true }))
635  try {
636    await refresh($)
637  } finally {
638    const ended = await $.clock.now()
639    if (await read($, isOn)) await update($, aim, kept => ({ ...(kept ?? BLANK), at: ended, isBusy: false }))
640  }
641}
642
643const sync = async ($: EngineInterface): Promise<void> => {
644  const isWanted = await read($, isOn)
645  if (isWanted && timer === undefined) {
646    timer = $.clock.every(TICK_MS, () => {
647      void tick($).catch(() => undefined)
648    })
649  }
650  if (!isWanted && timer !== undefined) {
651    timer.cancel()
652    timer = undefined
653  }
654}
655
656const listed = (held: readonly string[]): string => (held.length === 0 ? 'No production words.' : `Production words: ${held.join(', ')}.`)
657
658const add = async ($: EngineInterface, word: string): Promise<string> => {
659  const held = await read($, words)
660  if (held.includes(word)) return listed(held)
661  if (held.length >= WORDS_MAX) return `Aim holds ${WORDS_MAX} production words; unprod one first.`
662
663  const kept = await update($, words, () => [...held, word])
664  await $.store.set('words', kept)
665
666  return listed(kept)
667}
668
669const remove = async ($: EngineInterface, word: string): Promise<string> => {
670  const held = await read($, words)
671  if (!held.includes(word)) return `"${word}" is not a production word.`
672
673  const kept = await update($, words, () => held.filter(other => other !== word))
674  await $.store.set('words', kept)
675
676  return listed(kept)
677}
678
679const told = async ($: EngineInterface): Promise<string> => {
680  const { at, rows, last } = await read($, aim)
681
682  return [
683    rows.length === 0 ? (at === 0 ? LOOKING : NONE) :`Targets: ${rows.map(row => `${row.kind} ${row.name}`).join(', ')}.`,
684    listed(await read($, words)),
685    ...(last === null ? [] : [`Last: ${last.command}`, last.line]),
686  ].join('\n')
687}
688
689const clear = async ($: EngineInterface): Promise<string> => {
690  await update($, aim, kept => ({ ...(kept ?? BLANK), rows: (kept ?? BLANK).rows.map(row => ({ ...row, since: 0 })), last: null }))
691
692  return 'Aim cleared.'
693}
694
695const rowOf = (row: AimRow, at: number, width: number): string => {
696  const inner = width - 4
697  if (width < WIDE_COLUMNS) return cut(`${row.kind} ${row.name}`, inner)
698
699  const left = `${row.kind.padEnd(5)} ${row.name}`
700  const right = row.since === 0 ? '' : `changed ${ago(at - row.since)}`
701
702  return right === '' ? cut(left, inner) : `${cut(left, inner - right.length - 2).padEnd(inner - right.length)}${right}`
703}
704
705const show = async (
706  $: EngineInterface,
707  { Box, Text }: Tags,
708  beneath: RenderElement,
709  place: WidgetsPlace,
710  columns: number,
711): Promise<RenderElement> => {
712  if (!(await read($, isOn))) return beneath
713  if ((await $.state.get(site)).value !== place) return beneath
714
715  const width = fit((await $.state.get(widths)).value?.['aim-widget'] ?? CARD_COLUMNS, columns)
716  const inner = width - 4
717  const { at, rows, last } = await read($, aim)
718  const held = await read($, words)
719  const isBare = rows.length === 0 && last === null
720  const isProduction = last?.isProduction === true || rows.some(row => isHot(row.name, held))
721  const tone = last === null ? undefined : last.isProduction ? 'red' : last.isKnown ? undefined : 'yellow'
722
723  return $.widgets.card({
724    beneath,
725    width,
726    title: 'Aim',
727    note: isProduction ? 'prod' : last?.isKnown === false ? 'not known' : rows.length === 0 ? undefined : plural(rows.length, 'target'),
728    body: (
729      <Box key="aim" flexDirection="column">
730        {(isBare ? wrapped(at === 0 ? LOOKING : EMPTY, inner) : []).map((line, index) => (
731          <Text key={`say-${index}`} dimColor wrap="truncate-end">
732            {line}
733          </Text>
734        ))}
735        {rows.map(row => (
736          <Text key={`row-${row.kind}`} color={isHot(row.name, held) ? 'red' : undefined} wrap="truncate-end">
737            {rowOf(row, at, width)}
738          </Text>
739        ))}
740        {last !== null && (
741          <Text key="command" dimColor wrap="truncate-end">
742            {cut(last.command, inner)}
743          </Text>
744        )}
745        {(last === null ? [] : wrapped(last.line, inner)).map((line, index) => (
746          <Text key={`line-${index}`} color={tone} wrap="truncate-end">
747            {line}
748          </Text>
749        ))}
750      </Box>
751    ),
752  })
753}
754
755export const register: Register = on => {
756  on('session.start', async ($, e, next) => {
757    await $.command.register({
758      name: 'aim-widget',
759      description: 'Toggle the Aim card, or set the words that mark a target as production',
760      argumentHint: '[on|off|prod <word>|unprod <word>|show|clear]',
761    })
762    if ((await $.store.get('isOn')) === true) await update($, isOn, () => true)
763    const saved = await $.store.get('words')
764    if (Array.isArray(saved)) await update($, words, () => saved.filter(word => typeof word === 'string' && WORD.test(word)).slice(0, WORDS_MAX))
765    if (await read($, isOn)) await update($, aim, kept => ({ ...(kept ?? BLANK), isBusy: false }))
766    await sync($)
767
768    return next(e)
769  })
770
771  on('command.run', { command: 'aim-widget' }, async ($, e) => {
772    const [first = '', second = '', ...others] = e.args.trim().split(/\s+/)
773    const verb = first.toLowerCase()
774
775    if (verb === 'prod' || verb === 'unprod' || verb === 'show' || verb === 'clear') {
776      const word = second.toLowerCase()
777      const isWorded = verb === 'prod' || verb === 'unprod'
778      if (isWorded ? !WORD.test(word) || others.length > 0 : second !== '') return { text: USAGE }
779      if (!(await read($, isOn))) return { text: OFF }
780      if (isWorded) return { text: verb === 'prod' ? await add($, word) : await remove($, word) }
781
782      return { text: verb === 'show' ? await told($) : await clear($) }
783    }
784
785    const arg = e.args.trim().toLowerCase()
786    if (arg !== '' && arg !== 'on' && arg !== 'off') return { text: USAGE }
787
788    const isShown = await update($, isOn, shown => (arg === '' ? !(shown ?? false) : arg === 'on'))
789    await $.store.set('isOn', isShown)
790    if (!isShown) await update($, aim, () => BLANK)
791    await sync($)
792
793    return { text: isShown ? 'Aim on; /widgets places it.' : 'Aim off.' }
794  })
795
796  on('tool.check', { tool: 'Bash' }, async ($, e, next) => ((await read($, isOn)) ? judged($, e.input, e.tool_use_id, await next(e), true) : next(e)))
797
798  on('tool.check', { tool: 'PowerShell' }, async ($, e, next) => ((await read($, isOn)) ? judged($, e.input, e.tool_use_id, await next(e), false) : next(e)))
799
800  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) =>
801    show($, $.ui.resolve(e), await next(e), 'side', e.props.bodyColumns),
802  )
803
804  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) =>
805    e.props.hasSurvey ? next(e) : show($, $.ui.resolve(e), await next(e), 'above', e.props.bodyColumns),
806  )
807
808  on('ui.render', { component: 'PromptHint' }, async ($, e, next) =>
809    show($, $.ui.resolve(e), await next(e), 'below', e.viewport?.columns ?? 80),
810  )
811}
812
hooks/lib.ts 45 lines
1const MIN_CARD = 20
2const BARS = '▁▂▃▄▅▆▇█'
3
4export const fit = (wanted: number, columns: number): number => Math.min(wanted, Math.max(MIN_CARD, columns))
5
6export const plural = (count: number, word: string): string => `${count} ${word}${count === 1 ? '' : 's'}`
7
8export const span = (ms: number): string => {
9  const seconds = Math.max(0, Math.round(ms / 1000))
10  if (seconds < 60) return `${seconds}s`
11  const minutes = Math.floor(seconds / 60)
12  if (minutes < 60) return `${minutes}m ${String(seconds % 60).padStart(2, '0')}s`
13  const hours = Math.floor(minutes / 60)
14  if (hours < 24) return `${hours}h ${String(minutes % 60).padStart(2, '0')}m`
15
16  return `${Math.floor(hours / 24)}d ${hours % 24}h`
17}
18
19export const spark = (values: readonly number[], width: number): string => {
20  const shown = width > 0 ? values.slice(-width) : []
21  const top = Math.max(...shown, 0)
22
23  return shown
24    .map(value => (top <= 0 ? BARS[0] : BARS[Math.min(BARS.length - 1, Math.round((Math.max(0, value) / top) * (BARS.length - 1)))]))
25    .join('')
26}
27
28export const hash = (text: string): number => {
29  let held = 2166136261
30  for (const letter of text) held = Math.imul(held ^ (letter.codePointAt(0) ?? 0), 16777619)
31
32  return held >>> 0
33}
34
35export const shade = (color: number, factor: number): number =>
36  (Math.round((color >> 16) * factor) << 16) |
37  (Math.round(((color >> 8) & 255) * factor) << 8) |
38  Math.round((color & 255) * factor)
39
40export const folder = (path: string): string => {
41  const flat = path.replaceAll('\\', '/').replace(/\/+$/, '')
42
43  return /^[a-z]:/i.test(flat) ? flat.toLowerCase() : flat
44}
45
types/index.d.ts 23 lines
1export type AimSwitch = boolean
2
3export type AimKind = 'kube' | 'aws' | 'gcp' | 'tf' | 'db'
4
5export type AimRow = { kind: AimKind; name: string; since: number }
6
7export type AimShot = { command: string; line: string; isProduction: boolean; isKnown: boolean }
8
9declare module 'claude-code' {
10  interface PluginState {
11    'aim-widget': {
12      isOn: AimSwitch
13      words: string[]
14      aim: {
15        at: number
16        isBusy: boolean
17        rows: AimRow[]
18        last: AimShot | null
19      }
20    }
21  }
22}
23