diff --git a/CLAUDE.md b/CLAUDE.md index 0549577..e331097 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,6 +120,57 @@ so an update never silently changes what an existing user sees; the bridge to ma MEASURED in the e2e with the fix taken out. App now puts the update window away whenever a question (`ask`) appears; `updateGuard` holds it. The window's Tab trap also leaves Ctrl+Tab alone, which used to switch tabs AND move the focus inside the window in one press. +- **COMMAND HELP IS A POPUP THAT SHOWS AND COPIES, AND NOTHING ELSE** (#12; owner, 2026-09-19: "an + easy to use panel where you can find shell commands... searchable... metadata on each command so a + natural-language search finds it... optional in settings", and 2026-09-20: "a pop up with copy + icons for easy copying"). Built ONCE in `core/` for both apps: the catalogue and its search in + `shared/help/`, `renderer/lib/helpPrefs.ts`, `renderer/components/HelpPanel.tsx` (props only), + `renderer/settings/Help.tsx` + `helpOptions.ts`, and `writeClipboard` on the bridge. This app's + part is the way in: `F1`, the ? in the title bar, "Command help" in the terminal's right-click + menu, the row in Settings > General. The rules that must not regress: + - **IT NEVER INSERTS AND NEVER RUNS** (owner, 2026-09-19: "picking a command in the help panel + does NOT insert it into the shell"). The component is handed the clipboard and nothing else: no + session id, no `termInput`, no bridge. It is NOT a fifth exception to "the app never types into + your shell", and adding Run or Insert is a fresh owner decision. The `helpPanel` e2e reads the + terminal's text before the popup is touched and after every search, copy and Enter in it. + - **COPY IS EXACT.** What goes on the clipboard is the text on screen, placeholders and all: a + command with FOLDER in it fails loudly when pasted unedited, and a panel that guessed would + fail quietly. It goes through main (`clipboard:write`, text only, refused past 4000 characters, + never trimmed) because `navigator.clipboard` refuses when the document has no focus. The e2e + reads the clipboard back in main and compares it character for character, then restores what + the clipboard held. A "command" that is a KEY (`Ctrl+C`, `Esc`; `isKeyPress`) is drawn as key + caps with no copy button. + - **CURATED AND OFFLINE.** A few hundred hand-written entries, task first, with keywords in the + words of somebody who does not know the command; the search (`shared/help/search.ts`) is pure: + stop words, a light stemmer, a small synonym table, prefix and one-typo matching, no model, no + network, no dependency. The popup and its catalogue are a LAZY chunk (about 290 kB), loaded at + the first open; what the app needs before then is in `shared/help/shells.ts`. + - **`catalogue.test.ts` IS THE GATE FOR CONTENT.** To add an entry: put it in the file of its + shell (`powershell.ts`, `cmd.ts`, `bash.ts`, or `git.ts` / `agents.ts` / `packages.ts` for what + reads the same everywhere), RUN the command first where that is safe, and run `npm test`. The + test holds: unique ids with the shell's prefix, a short task with no full stop, 6 to 16 + lower-case keywords, every placeholder declared and used (UPPER_SNAKE, never angle brackets, + which a shell reads as redirection), no em-dash, and a `danger` line on EVERYTHING that matches + a destructive pattern (Remove-Item, rm, del, rmdir /s, taskkill, kill, git reset --hard, git + clean, a forced push, Set-Content, a single `>` over a file, a download over a file...). It + also asks about twenty real questions per shell of the + REAL catalogue and names the first answer each must get: a search can be right and the + catalogue still lack the keyword. + - **ON BY DEFAULT, AND OFF MEANS OFF.** It is a discoverability feature for exactly the people who + would never find a switch to turn it on; the owner asked only that it be optional. Off: no + button, no menu row, and `F1` is the shell's again (`ownsKey` and App's handler both read + `helpEnabled()`). Known cost of F1 while on, accepted: PSReadLine's own F1 (help for the command + under the cursor) and F1 inside a full-screen program under WSL do not arrive. + - **ONE LAYER, ONE THING IN IT.** The popup is the same layer as the update window and a close + question. It does not open over either, and App puts it away while RENDERING when one appears + (Ctrl+W and Alt+F4 still work over it), so a question is never underneath it. It also LEAVES + WHEN WHAT IS IN FRONT CHANGES (review, 2026-09-20): Ctrl+T, Ctrl+Tab and Ctrl+Shift+F work over + it, a terminal takes the focus as it attaches, and the popup left up sat over a focused shell + with the next "search" typed into it; `helpPanel` holds Ctrl+T and find. Escape returns + the keyboard to the shell that had it; Tab stays inside (a PLAIN Tab only, Ctrl+Tab is the + app's); the list draws a page at a time. + - Its option list is `helpOptions.ts`, NOT a row in `TERMINAL_OPTIONS`: Prism's gate reads that + file as text, and a row there would fail Prism's parity check until Prism wires the popup. - **A PAGE THAT WORKS IS NOT A PAGE THAT LOOKS RIGHT** (#20, 2026-09-19). Moving the settings into `core/` dropped every Tailwind class used only there (`core/` is outside the scanned root; the fix is the `@source` line at the top of `index.css`, do not remove it). All 14 e2e scenarios passed over @@ -181,7 +232,7 @@ Design spec and plan: `docs/superpowers/specs/2026-09-18-prism-terminal-design.m In: tabs (a tab is ONE shell and its folder, nothing else), the agent indicator, themes + custom theme + fonts + acrylic, tab restore with agent resume, the + with ask / fixed-folder modes and its pinned + recent list, the start screen, find in scrollback, the paste and drop rules, close -confirmation, Explorer verbs, the update chip and its window, single instance. +confirmation, Explorer verbs, the update chip and its window, command help (#12), single instance. Out, each a fresh owner decision and not a natural next step: split panes, several shells per tab, per-shell profiles, a tray icon, multiple windows, SSH management, app styles separate from the diff --git a/README.md b/README.md index e7b65ba..f040ea5 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,14 @@ and want to know, at a glance, which one has finished. model in Settings (Base runs well on any CPU; with an NVIDIA card one click adds the GPU engine and the large models answer in under a second), a language or auto-detect, and optionally pause your music while you talk. Off until you switch it on: nothing listens and nothing downloads before that. +- **Command help, one key away.** Press `F1` (or the ? in the title bar) and describe what you want + in your own words: "find big files", "what is using port 3000", "undo last commit". A few hundred + everyday commands for PowerShell, Command Prompt and Bash, plus Git, Claude Code, Codex, winget, + npm and pip, each with a plain explanation, its variations, what the placeholders mean and a + warning on anything that deletes or overwrites. Every command has a copy button. It opens on the + shell of the tab in front, works offline (the list ships with the app, no model and no network), + and it never types or runs anything: you copy, you paste, you press Enter. Optional: Settings > + General > Command help. - **Tabs that come back.** Close the app and reopen it: every tab returns in the folder its shell was in, and a tab that hosted Claude or Codex resumes that conversation (`claude --resume `, `codex resume --last`). @@ -92,6 +100,7 @@ lands on a start screen with the folders you were last in, each one press from a | `Ctrl+,` | Settings | | `Ctrl+scroll` | Zoom this tab's text | | `F11` | Fullscreen | +| `F1` | Command help (when switched on; off, the key is the shell's again) | | hold `Right Alt` | Dictate (when switched on; rebindable, or press-to-toggle) | Everything else belongs to the shell: `Escape` is still vim's, and `Ctrl+Backspace` deletes a word diff --git a/core/README.md b/core/README.md index df04315..79141c6 100644 --- a/core/README.md +++ b/core/README.md @@ -67,6 +67,29 @@ ask me."* And: *"why can't this repo be the core?"* It can, and this is it. and printed as text nodes; never HTML, never rendered markdown, never an anchor. **The chip never changes width**, in any phase: every label is laid out in one cell and only the current one is visible. + **COMMAND HELP (#12)** is the second thing in here that is not terminal, under + the same bar: `shared/help/` (the catalogue, six files of curated entries; its + pure offline search; `catalogue.test.ts`, the gate for content), + `renderer/lib/helpPrefs.ts`, `renderer/components/HelpPanel.tsx` and + `renderer/settings/Help.tsx` + `helpOptions.ts`. A host wires it with: + `helpEnabled()` / `useHelpEnabled()` to decide whether its way in exists at + all (on by default; off means no button and no key); ONE `` at its + root, lazy-loaded since it brings the catalogue with it, handed `shell` + (`shellOfShellId` of the shell in front, from the light `shared/help/shells.ts`), + `monoFont` (`termFontStack()`), `onCopy` (the bridge's `writeClipboard`, which + goes through main because `navigator.clipboard` refuses an unfocused + document), `onPickShell` and `onClose`; its key in `ownsKey`, tested against + `helpEnabled()`, so xterm yields it exactly when the host takes it (Prism + Terminal uses a bare F1); and `` on its settings + page, which says what that host's way in is. As with the update window, a + host that raises a question of its own while the popup is up must PUT IT + AWAY first and must not open it over one: they are the same layer. The same + goes for a chord that puts something ELSE in front while it is up (another + tab, a new tab, a find bar): a terminal takes the focus as it attaches, and a + popup left over a focused shell has its questions typed into that shell. Two rules + travel with it. **It never inserts and never runs**: the component has no + session id and no bridge, only `onCopy`, and a host must not give it more. + **Copy is exact**: the text on screen, placeholders included, never trimmed. 3. **A difference between the apps is DECLARED, never forked.** Every place the two legitimately differ is a field of `TermHostConfig` in [`renderer/host.ts`](renderer/host.ts): the default each untouched setting diff --git a/core/main/ipc.clipboard.test.ts b/core/main/ipc.clipboard.test.ts new file mode 100644 index 0000000..5218260 --- /dev/null +++ b/core/main/ipc.clipboard.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { CH } from '../shared/channels' +import { CLIPBOARD_WRITE_MAX, registerTermIpc } from './ipc' + +// THE ONE WRITE THE PAGE MAY MAKE TO THE CLIPBOARD (#12, the help panel's copy +// buttons). It is text, it is bounded, and an oversized write is REFUSED, not +// trimmed: a trimmed command is a different command. + +function wire(): { write: (text: unknown) => unknown; written: string[]; stop: () => void } { + const handlers = new Map unknown>() + const written: string[] = [] + const stop = registerTermIpc({ + ipcMain: { + handle: (channel, listener) => handlers.set(channel, listener), + on: () => undefined + }, + send: () => undefined, + clipboard: { + availableFormats: () => [], + readBuffer: () => Buffer.alloc(0), + readText: () => '', + writeText: (text) => { + written.push(text) + } + }, + openExternal: () => undefined, + spawnDir: async () => null, + mayPrewarm: async () => false, + mayCd: () => false + }) + return { write: (text) => handlers.get(CH.clipboardWrite)?.({}, text), written, stop } +} + +describe('clipboard:write', () => { + it('writes the exact text it was handed', () => { + const w = wire() + const command = "Get-ChildItem -Recurse -File | Sort-Object Length -Descending | Select-Object -First 20" + expect(w.write(command)).toBe(true) + expect(w.written).toEqual([command]) + w.stop() + }) + + it('refuses what is not text, what is empty and what is too long, and writes none of it', () => { + const w = wire() + for (const bad of [undefined, null, 7, {}, ['x'], '', 'x'.repeat(CLIPBOARD_WRITE_MAX + 1)]) expect(w.write(bad)).toBe(false) + expect(w.written).toEqual([]) + expect(w.write('x'.repeat(CLIPBOARD_WRITE_MAX))).toBe(true) + w.stop() + }) +}) diff --git a/core/main/ipc.ts b/core/main/ipc.ts index 35e384e..eb62040 100644 --- a/core/main/ipc.ts +++ b/core/main/ipc.ts @@ -33,8 +33,12 @@ interface ClipboardLike { availableFormats(): string[] readBuffer(format: string): Buffer readText(): string + writeText(text: string): void } +/** The most text the page may put on the clipboard in one write. */ +export const CLIPBOARD_WRITE_MAX = 4000 + export interface TermIpcDeps { ipcMain: IpcMainLike /** To the window that holds the terminals; a no-op while there is none. */ @@ -127,6 +131,20 @@ export function registerTermIpc(deps: TermIpcDeps): () => void { } }) + // The help panel's copy buttons (#12). Text only, and bounded: the longest + // command in the catalogue is a few hundred characters, so anything near the + // cap is not a command, and it is REFUSED rather than trimmed, since a + // trimmed command is a different command. + ipcMain.handle(CH.clipboardWrite, (_e: unknown, text: unknown) => { + if (typeof text !== 'string' || text.length === 0 || text.length > CLIPBOARD_WRITE_MAX) return false + try { + deps.clipboard.writeText(text) + return true + } catch { + return false + } + }) + // A link clicked in the terminal. The page is never trusted with a scheme. ipcMain.on(CH.openExternal, (_e: unknown, url: unknown) => { if (typeof url === 'string' && /^https?:\/\//i.test(url)) deps.openExternal(url) diff --git a/core/package.json b/core/package.json index 6e2a604..3bbf1c6 100644 --- a/core/package.json +++ b/core/package.json @@ -1,6 +1,6 @@ { "name": "prism-term-core", - "version": "0.3.0", + "version": "0.4.0", "description": "What Prism Terminal and Prism share: the terminal (pty, shells, agent detection and indicator, themes, links, the panel, dictation) and the update chip with its window. TypeScript source, compiled by the host.", "license": "MIT", "private": true, diff --git a/core/preload/api.ts b/core/preload/api.ts index cea3106..bb4fcf8 100644 --- a/core/preload/api.ts +++ b/core/preload/api.ts @@ -54,6 +54,14 @@ export interface TermPreloadApi { * reading, since a sandboxed preload has no clipboard module. */ readClipboard(): ClipboardRead + /** + * Put TEXT on the clipboard (#12, the help panel's copy buttons). Through + * main rather than `navigator.clipboard`: that one refuses unless the + * document has the focus ("Document is not focused"), and a copy button + * that works on some presses and not others is worse than none. Text only, + * and main caps its length. Answers whether it was written. + */ + writeClipboard(text: string): Promise /** The web-links addon's click-through: external URLs go to the OS browser. */ openExternal(url: string): void } @@ -79,6 +87,7 @@ export function createTermApi(ipc: IpcRendererLike): TermPreloadApi { onTermAgent: (cb) => on(CH.agent, cb), onTermExit: (cb) => on(CH.exit, cb), readClipboard: () => ipc.sendSync(CH.clipboardRead) as ClipboardRead, + writeClipboard: (text) => ipc.invoke(CH.clipboardWrite, text) as Promise, openExternal: (url) => { if (/^https?:/i.test(url)) ipc.send(CH.openExternal, url) } diff --git a/core/renderer/components/HelpPanel.tsx b/core/renderer/components/HelpPanel.tsx new file mode 100644 index 0000000..7be7d9a --- /dev/null +++ b/core/renderer/components/HelpPanel.tsx @@ -0,0 +1,572 @@ +import { useEffect, useMemo, useRef, useState, type JSX } from 'react' +import { + ALL_HELP, + HELP_CATEGORIES, + HELP_SHELLS, + isKeyPress, + type HelpEntry, + type HelpShellChoice +} from '../../shared/help/index' +import { searchHelp } from '../../shared/help/search' + +/** + * THE COMMAND HELP POPUP (#12). Owner, 2026-09-19: "an easy to use panel where + * you can find shell commands to use the shell more effectively... searchable... + * metadata on each command so a natural-language search finds it", and on + * 2026-09-20: "a pop up with copy icons for easy copying". One copy, for both + * apps. + * + * IT SHOWS, AND IT COPIES ON REQUEST. NOTHING ELSE. Owner, 2026-09-19: "picking + * a command in the help panel does NOT insert it into the shell". So this file + * has no way to reach a shell at all: it takes no session id, imports no + * bridge, and the one thing it can do to the outside world is hand a string to + * `onCopy`. A person reads the command, copies it, pastes it themselves and + * presses Enter themselves, which is the whole safety model: several of these + * commands delete things. Do not add "Run" or "Insert" without a fresh owner + * decision; it would be a fifth exception to "the app never types into your + * shell", and the first one that carries Enter in spirit. + * + * WHAT IS COPIED IS THE EXACT TEXT ON SCREEN, placeholders and all. A command + * with FOLDER in it fails loudly when pasted unedited, which is the right + * failure; a panel that guessed the folder would fail quietly. + * + * Props only, like the update window: no bridge, no store, no host. The + * clipboard is handed in because each host has its own way to it (and + * `navigator.clipboard` refuses when the document has no focus). The catalogue + * is curated text that ships with the app, so unlike the release notes there + * is nothing here off the network; it is still printed as text nodes only. + * + * It sits on the OPAQUE surface token for the reason the update window does: + * `--p-bg` carries the window's alpha on an acrylic theme, and nobody can read + * a command through the desktop. + */ + +/** How many entries are drawn at first, and how many more each time the end + * of the list comes into view. The catalogue is a few hundred entries with + * several command boxes each: all at once is some ten thousand nodes built + * before the first frame, for a list nobody has scrolled yet. */ +const FIRST_PAGE = 40 +const NEXT_PAGE = 60 +/** A search answers with at most this many. Past the first dozen nobody is + * reading; they are retyping. */ +const MAX_RESULTS = 50 +/** How long a copy button says so. */ +const COPIED_MS = 1500 + +const CATEGORY_NAME = new Map(HELP_CATEGORIES.map((c) => [c.id, c.name])) + +/** The list in the order it is drawn, which is the order Up and Down walk. */ +function rowsFor(shell: HelpShellChoice, query: string): HelpEntry[] { + if (query.trim() !== '') return searchHelp(ALL_HELP, query, { shell, limit: MAX_RESULTS }).map((h) => h.entry) + // Browsing: by category, in the panel's own order, catalogue order within. + const mine = ALL_HELP.filter((e) => e.shell === shell || e.shell === 'any') + return HELP_CATEGORIES.flatMap((c) => mine.filter((e) => e.category === c.id)) +} + +const CopyIcon = (): JSX.Element => ( + + + + +) +const CheckIcon = (): JSX.Element => ( + + + +) + +/** + * One command in its own box, with its copy button at the right edge. The + * button is a FIXED square and its answer is laid OVER the box beside it, so + * "Copied" arriving and leaving moves nothing: a list that twitches under the + * pointer on every copy is a list you mis-click in. + */ +function CommandBox({ + command, + copyKey, + copied, + failed, + mono, + onCopy +}: { + command: string + /** Names this box among all the boxes on screen: `#`. */ + copyKey: string + copied: boolean + failed: boolean + mono: string + onCopy: (key: string, command: string) => void +}): JSX.Element { + if (isKeyPress(command)) + return ( + // A key to press is drawn as key caps and has no copy button: the + // characters "Ctrl+C" on a clipboard help nobody. +
+ + {command.split('+').map((k, i) => ( + + {k} + + ))} + + A key to press, not text to paste +
+ ) + return ( +
+ + {command} + + {(copied || failed) && ( + // Over the box, not in the row: nothing is pushed aside. Opaque, so + // the end of a long command does not show through the word. + + {failed ? 'Could not copy' : 'Copied'} + + )} + +
+ ) +} + +export default function HelpPanel({ + shell, + monoFont, + onCopy, + onPickShell, + onClose +}: { + /** The command language of the shell in front; which chip starts selected. */ + shell: HelpShellChoice + /** The terminal's own font stack: a command is shown in the face it will be + * typed in. */ + monoFont: string + /** Put this exact text on the clipboard; answer whether it got there. */ + onCopy: (text: string) => Promise | boolean + /** Somebody chose a shell chip by hand: the host may remember it. */ + onPickShell?: (shell: HelpShellChoice) => void + onClose: () => void +}): JSX.Element { + const box = useRef(null) + const input = useRef(null) + const list = useRef(null) + const sentinel = useRef(null) + const [query, setQuery] = useState('') + const [chip, setChip] = useState(shell) + const [active, setActive] = useState(0) + const [limit, setLimit] = useState(FIRST_PAGE) + const [copied, setCopied] = useState<{ key: string; ok: boolean } | null>(null) + /** What a screen reader is told. Its own state, since two copies of the same + * command must both be announced and an unchanged string is not. */ + const [said, setSaid] = useState('') + + const rows = useMemo(() => rowsFor(chip, query), [chip, query]) + const searching = query.trim() !== '' + + // A new question is a new list: back to the top, first page, first entry. + // Done while RENDERING, not in an effect: an effect would draw one frame of + // the new list with the old highlight somewhere down it. + const [listKey, setListKey] = useState(`${chip}|${query}`) + if (listKey !== `${chip}|${query}`) { + setListKey(`${chip}|${query}`) + setActive(0) + setLimit(FIRST_PAGE) + } + useEffect(() => { + list.current?.scrollTo({ top: 0 }) + }, [listKey]) + + const copy = (key: string, command: string): void => { + void Promise.resolve(onCopy(command)) + .catch(() => false) + .then((ok) => { + setCopied({ key, ok }) + setSaid(ok ? `Copied: ${command}` : 'Could not copy to the clipboard') + }) + } + // The answer leaves by itself. Keyed on the OBJECT, so copying the same box + // twice restarts the clock instead of inheriting what is left of the first. + useEffect(() => { + if (!copied) return + const t = setTimeout(() => { + setCopied(null) + setSaid('') + }, COPIED_MS) + return () => clearTimeout(t) + }, [copied]) + + // The end of the drawn list came into view: draw the next page. + useEffect(() => { + const el = sentinel.current + const root = list.current + if (!el || !root) return + const io = new IntersectionObserver( + (hits) => { + if (hits.some((h) => h.isIntersecting)) setLimit((n) => n + NEXT_PAGE) + }, + { root, rootMargin: '600px' } + ) + io.observe(el) + return () => io.disconnect() + }, [listKey, limit]) + + // Only a move made BY KEY scrolls the list: a click on an entry is already + // where the eye is, and scrolling it under the pointer is how the next click + // lands on the wrong row. + const moved = useRef(false) + + // THE KEY LISTENER IS REGISTERED ONCE and reads the latest of everything from + // a ref, for the reason the update window's is (MEASURED there): keyed on + // its callbacks, the effect is torn down and put back on every render, and + // a keydown that lands between the two is heard by nobody. + const live = useRef({ rows, active, copy, onClose }) + useEffect(() => { + live.current = { rows, active, copy, onClose } + }) + + useEffect(() => { + // FOCUS RETURNS WHERE IT CAME FROM. The shell had the keyboard when the + // popup opened (its hidden textarea was the active element), so closing + // hands it straight back: the next keystroke must land in the shell, not + // on a button left behind the popup. A host that manages focus itself + // simply focuses again afterwards. + const before = document.activeElement as HTMLElement | null + input.current?.focus() + const onKey = (e: KeyboardEvent): void => { + const { rows: r, active: a } = live.current + if (e.key === 'Escape') { + e.stopPropagation() + e.preventDefault() + live.current.onClose() + return + } + // Chords are the host's (new tab, close tab, Settings...): they keep + // working over the popup, and none of them is read here. + if (e.ctrlKey || e.altKey || e.metaKey) return + if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'PageDown' || e.key === 'PageUp') { + if (!r.length) return + const step = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : e.key === 'PageDown' ? 5 : -5 + const next = Math.max(0, Math.min(r.length - 1, a + step)) + e.preventDefault() + e.stopPropagation() + // An entry past the drawn page has to exist before it can be shown. + setLimit((n) => (next + 5 > n ? next + NEXT_PAGE : n)) + setActive(next) + moved.current = true + return + } + if (e.key === 'Enter') { + // On a button, Enter is that button's own press (a chip, a variant's + // copy, Close). Anywhere else it copies the highlighted entry. + if ((e.target as HTMLElement | null)?.closest('button')) return + e.preventDefault() + e.stopPropagation() + const entry = r[a] + if (!entry) return + if (isKeyPress(entry.command)) setSaid(`${entry.command} is a key to press, not text to copy`) + else live.current.copy(`${entry.id}#0`, entry.command) + return + } + // A PLAIN Tab only: Ctrl+Tab is the host's chord (the lesson of the + // update window, where one press both switched tabs and moved the focus). + if (e.key !== 'Tab') return + // Tab stays inside. Behind this is a terminal, and a Tab that wandered + // out of the popup would be typed into somebody's shell. + const stops = [ + ...(box.current?.querySelectorAll('input, button:not([disabled]), [tabindex="0"]') ?? []) + ] + if (!stops.length) return + const at = stops.indexOf(document.activeElement as HTMLElement) + const next = at < 0 ? 0 : (at + (e.shiftKey ? -1 : 1) + stops.length) % stops.length + e.preventDefault() + e.stopPropagation() + stops[next].focus() + } + window.addEventListener('keydown', onKey, true) + return () => { + window.removeEventListener('keydown', onKey, true) + if (before && before.isConnected && before !== document.body) before.focus() + } + }, []) + + useEffect(() => { + if (!moved.current) return + moved.current = false + list.current?.querySelector(`[data-help-index="${active}"]`)?.scrollIntoView({ block: 'nearest' }) + }, [active, limit]) + + const drawn = rows.slice(0, limit) + const activeId = rows[active] ? `help-entry-${rows[active].id}` : undefined + + return ( + // data-owns-escape: both apps have a capture-phase Escape of their own that + // must stand down while an overlay is up. +
+
e.stopPropagation()} + className="flex h-[min(82vh,680px)] w-full max-w-[760px] flex-col overflow-hidden rounded-[var(--p-radius)] border border-[color:var(--p-divider)] bg-[var(--p-side-flat)] shadow-[0_24px_70px_rgba(0,0,0,.6)]" + > + {/* The question. */} +
+ + + + + setQuery(e.target.value)} + // It teaches by example: what to type is the first thing a + // newcomer does not know. + placeholder="Try: find big files, what is using port 3000, undo last commit" + className="min-w-0 flex-1 bg-transparent text-[14px] text-[var(--p-text)] placeholder:text-[var(--p-dim2)] focus:outline-none" + /> + +
+ + {/* Which shell. Git, the agents and the package managers read the same + in all three, so they are in every list and are not a chip. */} +
+
+ {HELP_SHELLS.map((s) => ( + + ))} +
+ + + {searching + ? rows.length === 0 + ? 'No results' + : `${rows.length}${rows.length === MAX_RESULTS ? '+' : ''} ${rows.length === 1 ? 'result' : 'results'}` + : `${rows.length} commands`} + +
+ + {/* The answers. The list scrolls, the popup does not. */} +
+ {rows.length === 0 && ( +

+ Nothing matches. Say it another way (what you want to DO, such as "delete a folder"), or try another + shell. +

+ )} + {drawn.map((entry, i) => { + const header = !searching && (i === 0 || drawn[i - 1].category !== entry.category) + const isActive = i === active + const commands = [ + { label: '', command: entry.command }, + ...(entry.variants ?? []).map((v) => ({ label: v.label, command: v.command })) + ] + return ( +
+ {header && ( +

+ {CATEGORY_NAME.get(entry.category) ?? entry.category} +

+ )} +
setActive(i)} + className={`border-b border-l-2 border-b-[color:var(--p-line)] px-5 py-3.5 ${ + isActive ? 'border-l-[color:var(--p-accent-hi)] bg-[var(--p-hover)]' : 'border-l-transparent' + }`} + > +
+

+ {entry.task} +

+ {searching && ( + + {CATEGORY_NAME.get(entry.category) ?? entry.category} + + )} +
+

{entry.summary}

+
+ {commands.map((c, n) => ( +
+ {c.label &&

{c.label}

} + +
+ ))} +
+ {entry.placeholders && ( +
+ {Object.entries(entry.placeholders).map(([name, what]) => ( +
+
+ {name} +
+
{what}
+
+ ))} +
+ )} + {entry.danger && ( + // The ink is the theme's own text and only the mark and + // the rule are amber: an amber sentence is unreadable on + // half the light themes, and this is the one line in the + // entry that has to be read. +
+ + + + + + Careful. + {entry.danger} + +
+ )} +
+
+ ) + })} + {drawn.length < rows.length &&
} +
+ + {/* The rule, said where it is read: a newcomer's first question about a + panel of commands is whether clicking one runs it. */} +
+ + Nothing is typed or run for you: copy a command, then paste it yourself. + + + Up + Down + move + Enter + copy + Esc + close + +
+ {/* What a screen reader hears: the visible "Copied" is decoration. */} + + {said} + +
+
+ ) +} + +function Key({ children }: { children: string }): JSX.Element { + return ( + + {children} + + ) +} diff --git a/core/renderer/lib/helpPrefs.test.ts b/core/renderer/lib/helpPrefs.test.ts new file mode 100644 index 0000000..b29c0f2 --- /dev/null +++ b/core/renderer/lib/helpPrefs.test.ts @@ -0,0 +1,38 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { HELP_KEYS, helpEnabled, helpShell, onHelpPrefs, setHelpEnabled, setHelpShell } from './helpPrefs' + +describe('the help panel settings', () => { + beforeEach(() => localStorage.clear()) + + it('is ON until somebody switches it off, and off means off', () => { + // A discoverability feature is for the people who would never find the + // switch that turns it on; the owner asked only that it be optional. + expect(helpEnabled()).toBe(true) + setHelpEnabled(false) + expect(helpEnabled()).toBe(false) + expect(localStorage.getItem(HELP_KEYS.enabled)).toBe('0') + setHelpEnabled(true) + expect(helpEnabled()).toBe(true) + }) + + it('remembers a shell picked by hand, and reads anything else as never picked', () => { + expect(helpShell()).toBeNull() + setHelpShell('bash') + expect(helpShell()).toBe('bash') + localStorage.setItem(HELP_KEYS.shell, 'fish') + expect(helpShell()).toBeNull() + }) + + it('tells its listeners when a value changes', () => { + let heard = 0 + const off = onHelpPrefs(() => (heard += 1)) + setHelpEnabled(false) + off() + setHelpEnabled(true) + expect(heard).toBe(1) + }) + + it('keeps its keys under prism.help', () => { + for (const k of Object.values(HELP_KEYS)) expect(k.startsWith('prism.help.')).toBe(true) + }) +}) diff --git a/core/renderer/lib/helpPrefs.ts b/core/renderer/lib/helpPrefs.ts new file mode 100644 index 0000000..a7b6185 --- /dev/null +++ b/core/renderer/lib/helpPrefs.ts @@ -0,0 +1,64 @@ +import { useSyncExternalStore } from 'react' +import type { HelpShellChoice } from '../../shared/help/shells' + +/** + * The help panel's settings (#12). VALUES ARE PER APP, like every other + * setting in the core: each app has its own localStorage. + * + * ON BY DEFAULT, unlike dictation, and every reader treats a missing key as + * on. The owner asked only that it be OPTIONAL ("optional in settings whether + * a user wants to see them"); it is a discoverability feature for people who + * do not yet know the shell, who are exactly the people who would never find + * a switch to turn it on. It costs nothing while shut: no process, no + * network, no listener beyond the one key. Off means off all the same: the + * button goes, and the key goes back to the shell. + */ +const K = { + enabled: 'prism.help.enabled', + shell: 'prism.help.shell' +} as const + +export const HELP_KEYS = K + +const listeners = new Set<() => void>() +const notify = (): void => listeners.forEach((l) => l()) +const sub = (l: () => void): (() => void) => { + listeners.add(l) + return () => { + listeners.delete(l) + } +} + +const read = (key: string): string | null => { + try { + return localStorage.getItem(key) + } catch { + return null + } +} +const write = (key: string, value: string): void => { + try { + localStorage.setItem(key, value) + } catch { + /* a full or blocked store loses the setting, not the app */ + } + notify() +} + +/** On unless switched off. */ +export const helpEnabled = (): boolean => read(K.enabled) !== '0' +export const setHelpEnabled = (on: boolean): void => write(K.enabled, on ? '1' : '0') + +/** + * The shell chip somebody last PICKED BY HAND, or null when they never have. + * The panel opens on the shell of the tab in front; this is only what it falls + * back to where there is no such tab (the start screen, Settings). + */ +export const helpShell = (): HelpShellChoice | null => { + const v = read(K.shell) + return v === 'powershell' || v === 'cmd' || v === 'bash' ? v : null +} +export const setHelpShell = (shell: HelpShellChoice): void => write(K.shell, shell) + +export const useHelpEnabled = (): boolean => useSyncExternalStore(sub, helpEnabled) +export const onHelpPrefs = sub diff --git a/core/renderer/settings/Help.tsx b/core/renderer/settings/Help.tsx new file mode 100644 index 0000000..22c5a34 --- /dev/null +++ b/core/renderer/settings/Help.tsx @@ -0,0 +1,27 @@ +import type { JSX } from 'react' +import { setHelpEnabled, useHelpEnabled } from '../lib/helpPrefs' +import { Pref, Switch } from './fields' + +// THE COMMAND HELP SWITCH, for both hosts (#12). A row rather than a page, as +// the shell and indicator rows are: where it sits is each app's own layout. +// +// On by default; the owner asked only that it be OPTIONAL ("optional in +// settings whether a user wants to see them"). Off means off: the host hides +// its button and gives the key back to the shell. + +/** + * `opensWith` is the host's own way in ("F1, or the ? in the title bar"): the + * key is a chord each host claims for itself, so each host says which. + */ +export function HelpSetting({ opensWith }: { opensWith: string }): JSX.Element { + const on = useHelpEnabled() + return ( + + + + ) +} diff --git a/core/renderer/settings/helpOptions.ts b/core/renderer/settings/helpOptions.ts new file mode 100644 index 0000000..d7d342d --- /dev/null +++ b/core/renderer/settings/helpOptions.ts @@ -0,0 +1,23 @@ +import { HELP_KEYS } from '../lib/helpPrefs' + +/** + * EVERY COMMAND-HELP OPTION, BY ID (#12): the same deal `options.ts` makes for + * the terminal's and `dictationOptions.ts` for dictation's. The row renders as + * `data-pref=""`, and each app's e2e asserts that its page shows this list. + * + * A list of its own, of one, rather than a row in `TERMINAL_OPTIONS`: Prism's + * terminal parity check reads THAT file as text, so a row added there would + * turn Prism's gate red until Prism had wired the panel. A host that has not + * wired it simply does not read this file yet. + */ +export interface HelpOption { + id: string + label: string + type: 'switch' + /** The localStorage key behind it. Same key in both apps, separate stores. */ + key: string +} + +export const HELP_OPTIONS: readonly HelpOption[] = [ + { id: 'help-enabled', label: 'Command help', type: 'switch', key: HELP_KEYS.enabled } +] diff --git a/core/renderer/settings/options.test.ts b/core/renderer/settings/options.test.ts index d5a4f4f..27ec1b8 100644 --- a/core/renderer/settings/options.test.ts +++ b/core/renderer/settings/options.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { readdirSync, readFileSync } from 'fs' import { join } from 'path' import { DICTATION_OPTIONS, dictationOptionIds } from './dictationOptions' +import { HELP_OPTIONS } from './helpOptions' import { TERMINAL_OPTIONS, terminalOptionIds } from './options' // The list and the sections must say the same thing: the list is what each @@ -21,7 +22,9 @@ const rendered = new Set( describe('the terminal options list', () => { it('names every row the shared sections render, and nothing they do not', () => { - expect([...rendered].sort()).toEqual([...TERMINAL_OPTIONS, ...DICTATION_OPTIONS].map((o) => o.id).sort()) + expect([...rendered].sort()).toEqual( + [...TERMINAL_OPTIONS, ...DICTATION_OPTIONS, ...HELP_OPTIONS].map((o) => o.id).sort() + ) }) it('has one storage key per option, all under prism.term', () => { @@ -43,6 +46,14 @@ describe('the terminal options list', () => { expect(DICTATION_OPTIONS.filter((o) => o.key === null).map((o) => o.id)).toEqual(['dictation-gpu']) }) + it('keeps command help in a list of its own, under prism.help', () => { + // NOT in TERMINAL_OPTIONS: Prism's gate reads that file as text, and a row + // there would fail Prism's parity check until Prism had wired the panel. + expect(HELP_OPTIONS.map((o) => o.id)).toEqual(['help-enabled']) + for (const o of HELP_OPTIONS) expect(o.key.startsWith('prism.help.')).toBe(true) + expect(TERMINAL_OPTIONS.some((o) => o.id.startsWith('help'))).toBe(false) + }) + it('offers the GPU row only where an NVIDIA adapter is present', () => { const all = dictationOptionIds({ nvidia: true }) const without = dictationOptionIds({ nvidia: false }) diff --git a/core/shared/channels.ts b/core/shared/channels.ts index 9265924..7e7a5dd 100644 --- a/core/shared/channels.ts +++ b/core/shared/channels.ts @@ -15,6 +15,7 @@ export const CH = { agent: 'term:agent', exit: 'term:exit', clipboardRead: 'clipboard:read', + clipboardWrite: 'clipboard:write', openExternal: 'shell:open-external' } as const diff --git a/core/shared/help/agents.ts b/core/shared/help/agents.ts new file mode 100644 index 0000000..90ab2b5 --- /dev/null +++ b/core/shared/help/agents.ts @@ -0,0 +1,845 @@ +/** + * THE HELP PANEL'S CATALOGUE: the coding agents, Claude Code and Codex (#12). + * + * Most people open this terminal to run an agent, and an agent is a program + * with two languages: the flags you start it with, which are shell commands + * (the same text in every shell, hence 'any'), and what you type INSIDE it, + * slash commands and keys. Entries of the second sort say so in their summary, + * because "/clear" pasted at a PowerShell prompt is an error and a beginner + * cannot tell the two apart by looking. + * + * HOW THESE WERE CHECKED (2026-09-20): flags and subcommands against + * `claude --help` (Claude Code 2.1.278) and `codex --help`, `codex resume + * --help` and `codex exec --help` (codex-cli 0.153.2) on this machine. No + * session of either was started, so slash commands and keys could not be + * pressed; each one listed was instead found by name in the installed binary's + * own command table (which is also how /usage turned out to be the command and + * /cost its alias, and how /code-review was found where older versions had + * /review). Anything not found that way was left out rather than guessed. + * + * Both tools change quickly. When a slash command here is missing in a newer + * version, /help inside the agent is the list that is always right. + */ +import type { HelpEntry } from './types' + +export const AGENTS_HELP: readonly HelpEntry[] = [ + // ---- Claude Code: starting and coming back ---- + { + id: 'claude-start', + shell: 'any', + category: 'agents', + task: 'Start Claude Code in this folder', + summary: + 'Opens an interactive Claude Code session that can read and edit the folder you are in. Move to your project folder first: that folder is what it works on.', + command: 'claude', + variants: [ + { label: 'Start with a first request already typed', command: 'claude "PROMPT"' }, + { label: 'Start on a particular model (an alias such as sonnet or opus, or a full model name)', command: 'claude --model MODEL' } + ], + placeholders: { PROMPT: 'What you want done, in plain words', MODEL: 'A model alias or full model name' }, + keywords: [ + 'claude', + 'claude code', + 'start claude', + 'open claude', + 'launch the ai', + 'run the agent', + 'anthropic', + 'ai assistant', + 'coding agent', + 'new session' + ] + }, + { + id: 'claude-continue', + shell: 'any', + category: 'agents', + task: 'Carry on the last Claude conversation', + summary: + 'Reopens the most recent conversation that was held in THIS folder, with its whole history, as if you had never left.', + command: 'claude -c', + variants: [{ label: 'The long spelling', command: 'claude --continue' }], + keywords: [ + 'continue', + 'last conversation', + 'pick up where i left off', + 'carry on', + 'closed the window by accident', + 'previous session', + 'get my chat back', + 'claude -c', + 'same conversation' + ] + }, + { + id: 'claude-resume', + shell: 'any', + category: 'agents', + task: 'Choose an older Claude conversation to resume', + summary: + 'Shows a list of past conversations for this folder to pick from, so you can go back further than the last one.', + command: 'claude -r', + variants: [ + { label: 'Resume one conversation directly by its id', command: 'claude --resume SESSION_ID' }, + { label: 'From inside a session (typed in Claude, not in the shell)', command: '/resume' } + ], + placeholders: { SESSION_ID: 'The session id of the conversation' }, + keywords: [ + 'resume', + 'old conversation', + 'past sessions', + 'session picker', + 'history of chats', + 'find an earlier chat', + 'go back to a conversation', + 'claude --resume', + 'session id' + ] + }, + { + id: 'claude-one-shot', + shell: 'any', + category: 'agents', + task: 'Ask Claude one question and get the answer printed', + summary: + 'Runs a single request without the interactive screen: the answer is printed and the command ends, which is what you want in a script or a pipe. It skips the trust question, so use it only in folders you trust.', + command: 'claude -p "PROMPT"', + variants: [{ label: 'Get the result as JSON, for a script to read', command: 'claude -p "PROMPT" --output-format json' }], + placeholders: { PROMPT: 'The question or instruction' }, + keywords: [ + 'one shot', + 'print mode', + 'non-interactive', + 'headless', + 'single question', + 'script claude', + 'claude -p', + 'without the ui', + 'automation', + 'quick question' + ] + }, + { + id: 'claude-pipe-file', + shell: 'any', + category: 'agents', + task: 'Send a file or command output to Claude', + summary: + 'Pipes text into a one-shot request: Claude gets the file as input and your prompt as the instruction. cat works in PowerShell and bash; cmd calls it type.', + command: 'cat FILE | claude -p "PROMPT"', + variants: [ + { label: 'In cmd', command: 'type FILE | claude -p "PROMPT"' }, + { label: 'Explain the output of another command (here, the last 20 commits)', command: 'git log --oneline -n 20 | claude -p "Summarise these commits"' } + ], + placeholders: { FILE: 'The file to send, such as error.log', PROMPT: 'What to do with it, such as "Explain this error"' }, + keywords: [ + 'pipe', + 'pipe a file', + 'send a file to claude', + 'explain this log', + 'stdin', + 'feed output to claude', + 'explain this error', + 'summarise a file', + 'summarize' + ] + }, + + // ---- Claude Code: inside a session ---- + { + id: 'claude-help', + shell: 'any', + category: 'agents', + task: 'See every command Claude Code has', + summary: + 'Typed inside Claude Code, not in the shell. Lists the slash commands your version really has; typing a lone / does the same as you go.', + command: '/help', + keywords: [ + 'help', + 'slash commands', + 'list of commands', + 'what can i type', + 'claude commands', + 'commands available', + 'how do i use claude', + 'cheat sheet' + ] + }, + { + id: 'claude-clear', + shell: 'any', + category: 'agents', + task: 'Start a fresh conversation in Claude', + summary: + 'Typed inside Claude Code. Empties the context and starts a new conversation, which is the right move between unrelated tasks: answers are better and cheaper without the old one in the way. The old conversation stays on disk and can be resumed.', + command: '/clear', + keywords: [ + 'clear', + 'new conversation', + 'start over', + 'fresh start', + 'reset the chat', + 'forget everything', + 'new chat', + 'wipe context', + 'claude is confused' + ] + }, + { + id: 'claude-compact', + shell: 'any', + category: 'agents', + task: 'Shrink a long Claude conversation', + summary: + 'Typed inside Claude Code. Replaces the conversation so far with a summary of it, freeing room to carry on with the same task when the context is nearly full.', + command: '/compact', + variants: [ + { label: 'Say what the summary must keep', command: '/compact KEEP_THIS' }, + { label: 'See how full the context is, and with what', command: '/context' } + ], + placeholders: { KEEP_THIS: 'A note such as "keep the list of failing tests"' }, + keywords: [ + 'compact', + 'context full', + 'context window', + 'running out of context', + 'conversation too long', + 'summarise the conversation', + 'free up context', + 'context left until auto-compact', + 'tokens' + ] + }, + { + id: 'claude-model', + shell: 'any', + category: 'agents', + task: 'Change which model Claude uses', + summary: 'Typed inside Claude Code. Opens a picker of the models your account can use; the choice applies from the next message.', + command: '/model', + variants: [{ label: 'Choose when starting instead (typed in the shell)', command: 'claude --model MODEL' }], + placeholders: { MODEL: 'An alias such as sonnet or opus, or a full model name' }, + keywords: [ + 'model', + 'switch model', + 'change model', + 'opus', + 'sonnet', + 'haiku', + 'faster model', + 'smarter model', + 'cheaper model', + 'which model am i using' + ] + }, + { + id: 'claude-init', + shell: 'any', + category: 'agents', + task: 'Teach Claude about your project (CLAUDE.md)', + summary: + 'Typed inside Claude Code. Has Claude read the codebase and write a CLAUDE.md: the notes (how to build, how to test, the conventions) that it then reads at the start of every session in this folder.', + command: '/init', + keywords: [ + 'init', + 'claude.md', + 'project instructions', + 'set up a project', + 'project memory', + 'onboard claude', + 'first time in a repo', + 'explain my codebase to claude', + 'conventions' + ] + }, + { + id: 'claude-memory', + shell: 'any', + category: 'agents', + task: 'Edit what Claude remembers between sessions', + summary: + 'Typed inside Claude Code. Opens the CLAUDE.md files, the project one and your personal one, where standing instructions live ("always use pnpm", "never touch the migrations folder").', + command: '/memory', + keywords: [ + 'memory', + 'remember this', + 'standing instructions', + 'claude keeps forgetting', + 'edit claude.md', + 'rules for claude', + 'preferences', + 'always do this', + 'stop doing that' + ] + }, + { + id: 'claude-review', + shell: 'any', + category: 'agents', + task: 'Have Claude review your changes', + summary: + 'Typed inside Claude Code. Reviews the current changes for bugs and reports what it finds. This is the name in Claude Code 2.1; older versions call it /review, so check /help if it is not recognised.', + command: '/code-review', + variants: [{ label: 'Look only for security problems in the pending changes', command: '/security-review' }], + keywords: [ + 'review', + 'code review', + 'check my code', + 'find bugs', + 'review my changes', + 'review a pull request', + 'second opinion', + 'security review', + 'before i commit' + ] + }, + { + id: 'claude-usage', + shell: 'any', + category: 'agents', + task: 'See what the Claude session has cost', + summary: + 'Typed inside Claude Code. Shows the cost of this session and how much of your plan you have used. /cost is an older name for the same command.', + command: '/usage', + variants: [{ label: 'The older name, still accepted', command: '/cost' }], + keywords: [ + 'cost', + 'usage', + 'how much have i spent', + 'tokens used', + 'rate limit', + 'plan limit', + 'quota', + 'billing', + 'how much is left', + 'usage limit reached' + ] + }, + { + id: 'claude-permissions', + shell: 'any', + category: 'agents', + task: 'Choose what Claude may do without asking', + summary: + 'Typed inside Claude Code. Shows and edits the allow and deny rules: which tools and commands run without a prompt (say, npm test) and which are never allowed.', + command: '/permissions', + variants: [{ label: 'Cycle the permission mode for this session (a key, pressed inside Claude)', command: 'Shift+Tab' }], + keywords: [ + 'permissions', + 'stop asking me', + 'allow a command', + 'always allow', + 'approve', + 'deny', + 'too many prompts', + 'allowed tools', + 'accept edits', + 'auto accept' + ] + }, + { + id: 'claude-plan-mode', + shell: 'any', + category: 'agents', + task: 'Make Claude plan before it changes anything', + summary: + 'Starts Claude in plan mode: it reads and investigates, then proposes a plan, and edits nothing until you approve it. Inside a session, Shift+Tab cycles through the modes, plan among them.', + command: 'claude --permission-mode plan', + variants: [{ label: 'Switch mode in a running session (a key, pressed inside Claude)', command: 'Shift+Tab' }], + keywords: [ + 'plan mode', + 'plan first', + 'read only', + 'do not edit yet', + 'look but do not touch', + 'propose a plan', + 'think before coding', + 'safe mode', + 'shift tab' + ] + }, + { + id: 'claude-skip-permissions', + shell: 'any', + category: 'agents', + task: 'Run Claude without any permission prompts', + summary: + 'Starts Claude with every permission check off: it edits files and runs commands without asking. Meant for a sandbox or a throwaway copy; in a real project, prefer allowing specific commands with /permissions.', + command: 'claude --dangerously-skip-permissions', + keywords: [ + 'skip permissions', + 'yolo', + 'no prompts', + 'never ask', + 'bypass permissions', + 'unattended', + 'fully automatic', + 'dangerously', + 'let it run' + ], + danger: + 'Nothing stands between the agent and your machine: it can delete or overwrite files and run any command without asking first. Commit your work before using it.' + }, + { + id: 'claude-agents', + shell: 'any', + category: 'agents', + task: 'Create or manage subagents in Claude', + summary: + 'Typed inside Claude Code. Lists the subagents (helpers with their own instructions and tools, such as a test runner or a reviewer) and lets you make new ones.', + command: '/agents', + keywords: [ + 'agents', + 'subagents', + 'sub-agents', + 'custom agent', + 'helper agents', + 'delegate', + 'specialised agent', + 'parallel work' + ] + }, + { + id: 'claude-mcp', + shell: 'any', + category: 'agents', + task: 'See the tools (MCP servers) connected to Claude', + summary: + 'Typed inside Claude Code. Shows the MCP servers, the plug-ins that give Claude extra tools such as a browser or a database, and whether each one connected.', + command: '/mcp', + variants: [{ label: 'The same list from the shell, without starting a session', command: 'claude mcp list' }], + keywords: [ + 'mcp', + 'model context protocol', + 'connected tools', + 'plugins', + 'integrations', + 'server failed to connect', + 'add a tool', + 'extensions' + ] + }, + { + id: 'claude-rewind', + shell: 'any', + category: 'agents', + task: 'Undo what Claude just did', + summary: + 'Typed inside Claude Code. Goes back to an earlier point in the conversation, and can put the files back as they were at that point too. Pressing Esc twice on an empty prompt opens the same list.', + command: '/rewind', + keywords: [ + 'rewind', + 'undo', + 'checkpoint', + 'go back', + 'claude broke my code', + 'revert what claude did', + 'restore files', + 'take that back', + 'esc esc' + ], + danger: + 'Restoring the code discards the edits Claude made after the chosen point. Changes made by shell commands it ran (deleted files, installs) are not undone.' + }, + { + id: 'claude-mention-file', + shell: 'any', + category: 'agents', + task: 'Point Claude at a particular file', + summary: + 'Typed inside Claude Code, as part of your message. An @ followed by a path attaches that file; start typing after the @ and pick from the suggestions.', + command: '@PATH', + placeholders: { PATH: 'The file or folder, relative to where Claude was started, such as src/app.ts' }, + keywords: [ + 'mention a file', + 'attach a file', + 'reference a file', + 'at sign', + 'look at this file', + 'include a file', + 'file path', + 'autocomplete files' + ] + }, + { + id: 'claude-shell-command', + shell: 'any', + category: 'agents', + task: 'Run a shell command without leaving Claude', + summary: + 'Typed inside Claude Code. A message that starts with ! is run as a shell command directly, and its output joins the conversation so Claude can see it.', + command: '!COMMAND', + placeholders: { COMMAND: 'Any shell command, such as git status' }, + keywords: [ + 'bash mode', + 'run a command inside claude', + 'exclamation mark', + 'shell from claude', + 'quick command', + 'without leaving', + 'bang' + ] + }, + { + id: 'claude-paste-image', + shell: 'any', + category: 'agents', + task: 'Paste a screenshot or image into Claude', + summary: + 'A key, pressed inside Claude Code. Copy an image (Win+Shift+S takes a screenshot to the clipboard), then paste: this terminal hands the keystroke to Claude, which reads the image off the clipboard itself and shows an [Image] marker. Dragging an image file onto the terminal types its path instead, which works as well.', + command: 'Ctrl+V', + variants: [{ label: "If Ctrl+V does nothing: Claude Code's own default key on Windows", command: 'Alt+V' }], + keywords: [ + 'paste image', + 'screenshot', + 'picture', + 'show claude a screenshot', + 'image from clipboard', + 'attach an image', + 'paste not working', + 'snipping tool', + 'ctrl v' + ] + }, + { + id: 'claude-new-line', + shell: 'any', + category: 'agents', + task: 'Type a new line in Claude without sending', + summary: + 'A key, pressed inside Claude Code. Enter sends the message; Shift+Enter starts a new line in it instead. Ending a line with a backslash and then pressing Enter does the same in any terminal.', + command: 'Shift+Enter', + variants: [ + { label: 'The same, where Shift+Enter is not passed through', command: 'Ctrl+J' }, + { label: 'Works everywhere: type a backslash at the end of the line, then press Enter', command: '\\' } + ], + keywords: [ + 'new line', + 'newline', + 'multi-line', + 'multiline message', + 'line break', + 'enter sends too early', + 'shift enter', + 'paragraph', + 'long prompt' + ] + }, + { + id: 'claude-interrupt', + shell: 'any', + category: 'agents', + task: 'Stop Claude in the middle of something', + summary: + 'A key, pressed inside Claude Code. Esc interrupts what Claude is doing and hands the prompt back, keeping the conversation, so you can correct it and carry on. Ctrl+C is the wrong reflex here: pressed twice it quits.', + command: 'Esc', + keywords: [ + 'interrupt', + 'stop claude', + 'cancel', + 'it is going the wrong way', + 'escape', + 'halt', + 'stop generating', + 'how do i stop this', + 'abort' + ] + }, + { + id: 'claude-exit', + shell: 'any', + category: 'agents', + task: 'Leave Claude Code', + summary: + 'Typed inside Claude Code. Ends the session and returns you to the shell. The conversation is saved: claude -c brings it back.', + command: '/exit', + variants: [ + { label: 'The same, under another name', command: '/quit' }, + { label: 'By key: press it twice', command: 'Ctrl+C' }, + { label: 'By key, on an empty prompt', command: 'Ctrl+D' } + ], + keywords: [ + 'exit', + 'quit', + 'close claude', + 'leave', + 'get out', + 'back to the shell', + 'end session', + 'how do i close this', + 'ctrl c' + ] + }, + { + id: 'claude-login', + shell: 'any', + category: 'agents', + task: 'Sign in to Claude, or switch account', + summary: + 'Typed inside Claude Code. Opens the browser sign-in; use it after an "invalid API key" or "please log in" message, or to change to another account.', + command: '/login', + variants: [{ label: 'Sign out', command: '/logout' }], + keywords: [ + 'login', + 'log in', + 'sign in', + 'switch account', + 'logout', + 'authentication', + 'invalid api key', + 'not logged in', + 'subscription' + ] + }, + { + id: 'claude-update', + shell: 'any', + category: 'agents', + task: 'Update Claude Code, or check it is healthy', + summary: 'Checks for a newer Claude Code and installs it. Run it in the shell, with no session open.', + command: 'claude update', + variants: [ + { label: 'Which version is installed', command: 'claude --version' }, + { label: 'Diagnose a broken installation', command: 'claude doctor' } + ], + keywords: [ + 'update claude', + 'upgrade', + 'latest version', + 'claude version', + 'claude not working', + 'doctor', + 'diagnose', + 'reinstall', + 'out of date' + ] + }, + + // ---- Codex ---- + { + id: 'codex-start', + shell: 'any', + category: 'agents', + task: 'Start Codex in this folder', + summary: + "Opens an interactive session of OpenAI's Codex CLI on the folder you are in. The first time in a folder it asks whether you trust it; answer before typing anything else.", + command: 'codex', + variants: [ + { label: 'Start with a first request already typed', command: 'codex "PROMPT"' }, + { label: 'Start on a particular model', command: 'codex -m MODEL' }, + { label: 'Attach an image to the first request', command: 'codex -i IMAGE_FILE "PROMPT"' } + ], + placeholders: { + PROMPT: 'What you want done, in plain words', + MODEL: 'The model name', + IMAGE_FILE: 'The path of a screenshot or other image' + }, + keywords: [ + 'codex', + 'start codex', + 'open codex', + 'openai', + 'chatgpt in the terminal', + 'gpt', + 'coding agent', + 'run the agent', + 'do you trust this folder' + ] + }, + { + id: 'codex-resume', + shell: 'any', + category: 'agents', + task: 'Carry on the last Codex conversation', + summary: 'Reopens the most recent Codex session for this folder without showing the picker.', + command: 'codex resume --last', + variants: [ + { label: 'Choose from a list of past sessions instead', command: 'codex resume' }, + { label: 'List sessions from every folder, not only this one', command: 'codex resume --all' } + ], + keywords: [ + 'resume', + 'continue', + 'last session', + 'pick up where i left off', + 'previous conversation', + 'get my chat back', + 'session picker', + 'closed codex by accident' + ] + }, + { + id: 'codex-one-shot', + shell: 'any', + category: 'agents', + task: 'Run Codex once, without the interactive screen', + summary: 'Runs a single request non-interactively and exits, for scripts and quick jobs.', + command: 'codex exec "PROMPT"', + placeholders: { PROMPT: 'The instruction to carry out' }, + keywords: [ + 'codex exec', + 'non-interactive', + 'headless', + 'one shot', + 'script codex', + 'automation', + 'single task', + 'without the ui' + ] + }, + { + id: 'codex-model', + shell: 'any', + category: 'agents', + task: 'Change the model or reasoning effort in Codex', + summary: 'Typed inside Codex, not in the shell. Opens a picker for the model and for how hard it should think.', + command: '/model', + keywords: [ + 'codex model', + 'switch model', + 'reasoning effort', + 'think harder', + 'faster model', + 'gpt-5', + 'change model' + ] + }, + { + id: 'codex-permissions', + shell: 'any', + category: 'agents', + task: 'Choose what Codex may do without asking', + summary: + 'Typed inside Codex. Sets how much Codex is allowed to do on its own: read only, edit within the folder, or more. Older versions call this /approvals.', + command: '/permissions', + keywords: [ + 'permissions', + 'approvals', + 'stop asking me', + 'approve', + 'sandbox', + 'read only', + 'auto approve', + 'too many prompts', + 'full access' + ] + }, + { + id: 'codex-new', + shell: 'any', + category: 'agents', + task: 'Start a fresh conversation in Codex', + summary: + 'Typed inside Codex. Begins a new chat without leaving; the previous one stays in the history and can be resumed.', + command: '/new', + variants: [{ label: 'Keep the same chat but summarise it, to free up context', command: '/compact' }], + keywords: [ + 'new chat', + 'clear', + 'start over', + 'fresh start', + 'reset', + 'context full', + 'compact', + 'conversation too long', + 'codex is confused' + ] + }, + { + id: 'codex-diff', + shell: 'any', + category: 'agents', + task: 'See what Codex has changed', + summary: 'Typed inside Codex. Shows the git diff of the folder, new untracked files included, so you can read its work before keeping it.', + command: '/diff', + variants: [{ label: 'Have Codex review the changes and look for problems', command: '/review' }], + keywords: [ + 'diff', + 'what did codex change', + 'show changes', + 'review changes', + 'check its work', + 'code review', + 'find bugs' + ] + }, + { + id: 'codex-status', + shell: 'any', + category: 'agents', + task: 'See the Codex session settings and token usage', + summary: 'Typed inside Codex. Shows the model, the folder, the permission level and how many tokens the session has used.', + command: '/status', + variants: [{ label: 'List the MCP tools that are configured', command: '/mcp' }], + keywords: ['status', 'token usage', 'usage', 'which model', 'session info', 'limits', 'context left', 'mcp', 'tools'] + }, + { + id: 'codex-init', + shell: 'any', + category: 'agents', + task: 'Teach Codex about your project (AGENTS.md)', + summary: + 'Typed inside Codex. Writes an AGENTS.md: the project notes and instructions Codex reads at the start of every session in this folder.', + command: '/init', + keywords: [ + 'agents.md', + 'init', + 'project instructions', + 'set up a project', + 'onboard codex', + 'conventions', + 'first time in a repo' + ] + }, + { + id: 'codex-interrupt-exit', + shell: 'any', + category: 'agents', + task: 'Stop Codex, or leave it', + summary: + 'Esc (a key, pressed inside Codex) interrupts what it is doing and keeps the conversation. /quit ends the session and returns you to the shell; codex resume --last brings it back.', + command: '/quit', + variants: [ + { label: 'Interrupt the current work but stay in the session (a key)', command: 'Esc' }, + { label: 'The same as /quit, under another name', command: '/exit' } + ], + keywords: [ + 'exit', + 'quit', + 'close codex', + 'stop codex', + 'interrupt', + 'cancel', + 'how do i stop this', + 'leave', + 'back to the shell', + 'escape' + ] + }, + { + id: 'codex-login', + shell: 'any', + category: 'agents', + task: 'Sign in to Codex, update it, or check it is healthy', + summary: 'Signs in with your ChatGPT account or an API key. Run it in the shell, with no session open.', + command: 'codex login', + variants: [ + { label: 'Sign out', command: 'codex logout' }, + { label: 'Update Codex to the latest version', command: 'codex update' }, + { label: 'Diagnose the installation, configuration and sign-in', command: 'codex doctor' }, + { label: 'Which version is installed', command: 'codex --version' } + ], + keywords: [ + 'login', + 'sign in', + 'logout', + 'chatgpt account', + 'api key', + 'update codex', + 'codex version', + 'codex not working', + 'doctor', + 'authentication' + ] + } +] diff --git a/core/shared/help/bash.ts b/core/shared/help/bash.ts new file mode 100644 index 0000000..dbd4d88 --- /dev/null +++ b/core/shared/help/bash.ts @@ -0,0 +1,1784 @@ +/** + * THE HELP CATALOGUE, bash: WSL, Linux and Git Bash (#12). + * + * Written for a Windows user who has landed in Ubuntu under WSL, usually to run + * an agent there: the everyday file and folder work, finding text, processes + * and ports, apt, the environment and ~/.bashrc, and the WSL seams a Windows + * user trips on (the drives under /mnt/c, explorer.exe, line endings, chmod +x). + * What is run from the WINDOWS side (wsl --shutdown, wsl -l -v) lives with the + * PowerShell and cmd entries, not here. The keywords carry the Windows names + * ("dir", "findstr", "tasklist") because that is what this reader knows. + * + * HOW THESE WERE CHECKED (2026-09-20): this machine has WSL but no distribution + * installed, so the commands were RUN in Git Bash (GNU coreutils, the same + * ls/cp/find/grep/sed/tar/du/df) inside a scratch folder made for the purpose: + * every file, folder, text, redirection, chaining, environment, PATH, alias, + * jobs and kill entry, plus curl, ssh -V, file and the CRLF fix. NOT RUN, since + * Git Bash does not ship them, and written from the tools' documented + * behaviour on Ubuntu: apt, sudo, man, lsof, ss, ip, pgrep and pkill, top, + * free, lsb_release, getent, wget, zip, wslpath, /mnt/c and the .exe interop. + * Where a tool is not in a stock Ubuntu image (unzip, zip, tree, dos2unix) the + * summary says so and gives the apt line. + */ + +import type { HelpEntry } from './types' + +export const BASH_HELP: readonly HelpEntry[] = [ + // ---------------------------------------------------------------- folders + { + id: 'sh-where-am-i', + shell: 'bash', + category: 'folders', + task: 'See which folder I am in', + summary: 'Prints the full path of the current folder ("print working directory").', + command: 'pwd', + keywords: [ + 'where am i', + 'current folder', + 'current directory', + 'working directory', + 'pwd', + 'get-location', + 'cd with no arguments', + 'which folder' + ] + }, + { + id: 'sh-change-folder', + shell: 'bash', + category: 'folders', + task: 'Go to another folder', + summary: + 'Moves into a folder. Names are case-sensitive here (Documents is not documents), and the slashes lean forward.', + command: 'cd FOLDER', + variants: [ + { label: 'Your home folder', command: 'cd ~' }, + { label: 'Back to the folder you were in just before', command: 'cd -' }, + { label: 'A folder with spaces in its name: keep the quotes', command: 'cd "FOLDER"' } + ], + placeholders: { FOLDER: 'The folder to go to, for example ~/projects/site' }, + keywords: [ + 'cd', + 'change directory', + 'change folder', + 'go to folder', + 'navigate', + 'set-location', + 'home folder', + 'tilde', + 'no such file or directory', + 'previous folder' + ] + }, + { + id: 'sh-go-up', + shell: 'bash', + category: 'folders', + task: 'Go back up a folder', + summary: + 'Two dots mean "the folder that holds this one". The space after cd is required here, unlike in Command Prompt.', + command: 'cd ..', + variants: [{ label: 'Up two levels', command: 'cd ../..' }], + keywords: [ + 'go back', + 'back a folder', + 'parent folder', + 'up one level', + 'cd ..', + 'cd..', + 'dot dot', + 'leave folder', + 'command not found cd..' + ] + }, + { + id: 'sh-windows-drives', + shell: 'bash', + category: 'folders', + task: 'Reach my Windows files from WSL', + summary: + 'WSL mounts each Windows drive under /mnt by its letter, so C:\\Users is /mnt/c/Users. It works, but it is slow: keep projects you build in WSL inside the Linux home folder instead.', + command: 'cd /mnt/c/Users/NAME', + variants: [ + { label: 'See which drives are mounted', command: 'ls /mnt' }, + { + label: 'Turn a Windows path into the Linux one', + command: "wslpath 'C:\\Users\\NAME\\Documents'" + }, + { label: 'Turn the current folder into a Windows path', command: 'wslpath -w .' } + ], + placeholders: { NAME: 'Your Windows user name' }, + keywords: [ + 'mnt c', + '/mnt/c', + 'c drive', + 'windows files', + 'windows drive', + 'access windows from wsl', + 'wslpath', + 'convert path', + 'where are my documents', + 'desktop folder', + 'downloads' + ] + }, + { + id: 'sh-open-explorer', + shell: 'bash', + category: 'folders', + task: 'Open this folder in File Explorer', + summary: + 'WSL can start Windows programs by their .exe name. The dot means "here", so Explorer opens on the current Linux folder (under \\\\wsl.localhost).', + command: 'explorer.exe .', + variants: [ + { label: 'Open this folder in VS Code', command: 'code .' }, + { label: 'Open a file in Notepad', command: 'notepad.exe FILE' } + ], + placeholders: { FILE: 'A file in the current folder' }, + keywords: [ + 'explorer', + 'file explorer', + 'open folder', + 'open in explorer', + 'show in explorer', + 'start .', + 'xdg-open', + 'see linux files in windows', + 'wsl$', + 'vs code', + 'run windows program' + ] + }, + { + id: 'sh-make-folder', + shell: 'bash', + category: 'folders', + task: 'Make a new folder', + summary: + '-p makes any missing folders on the way, and does not complain if the folder is already there.', + command: 'mkdir -p NAME', + variants: [{ label: 'Several levels at once', command: 'mkdir -p PARENT/CHILD/GRANDCHILD' }], + placeholders: { + NAME: 'The new folder', + PARENT: 'The outer folder', + CHILD: 'A folder inside it', + GRANDCHILD: 'A folder inside that' + }, + keywords: [ + 'mkdir', + 'md', + 'create folder', + 'new folder', + 'make directory', + 'new directory', + 'new-item', + 'nested folders' + ] + }, + { + id: 'sh-delete-folder', + shell: 'bash', + category: 'folders', + task: 'Delete a folder and everything in it', + summary: + '-r means "and everything inside". Read the path twice before pressing Enter, and never run it on a path built from a variable that might be empty.', + command: 'rm -r FOLDER', + variants: [ + { label: 'Only if it is empty (the safe form)', command: 'rmdir FOLDER' }, + { label: 'Without any questions, write-protected files included', command: 'rm -rf FOLDER' } + ], + placeholders: { FOLDER: 'The folder to remove' }, + keywords: [ + 'rm -rf', + 'rmdir', + 'remove folder', + 'delete directory', + 'rd /s', + 'remove-item -recurse', + 'directory not empty', + 'is a directory', + 'delete node_modules' + ], + danger: + 'There is no Recycle Bin in Linux. The folder and everything inside it is gone for good.' + }, + { + id: 'sh-folder-size', + shell: 'bash', + category: 'folders', + task: 'See how big a folder is', + summary: + '-s gives one total instead of a line per subfolder, and -h prints it in K, M and G rather than blocks.', + command: 'du -sh FOLDER', + variants: [ + { label: 'Each thing in this folder, smallest to biggest', command: 'du -sh * | sort -h' }, + { label: 'One level of subfolders only', command: 'du -h --max-depth=1 .' } + ], + placeholders: { FOLDER: 'The folder to measure, or . for the current one' }, + keywords: [ + 'du', + 'du -sh', + 'folder size', + 'directory size', + 'how big', + 'disk usage', + 'space used', + 'what is taking up space', + 'total size' + ] + }, + { + id: 'sh-tree', + shell: 'bash', + category: 'folders', + task: 'Show the folder structure', + summary: + 'Lists the folders two levels down using find, which is always there. The prettier tree command is not installed on a stock Ubuntu: sudo apt install tree.', + command: 'find . -maxdepth 2 -type d', + variants: [{ label: 'With tree, once installed', command: 'tree -L 2' }], + keywords: [ + 'tree', + 'folder structure', + 'directory structure', + 'project layout', + 'list subfolders', + 'show hierarchy', + 'tree command not found' + ] + }, + + // ------------------------------------------------------------------ files + { + id: 'sh-list-files', + shell: 'bash', + category: 'files', + task: 'List the files in this folder', + summary: + '-l shows sizes, dates and permissions, -a includes hidden files, and -h prints sizes in K, M and G.', + command: 'ls -lah', + variants: [ + { label: 'Names only', command: 'ls' }, + { label: 'Newest first', command: 'ls -lt' }, + { label: 'Biggest first', command: 'ls -lhS' } + ], + keywords: [ + 'ls', + 'dir', + 'list files', + 'show files', + 'see files', + 'files here', + 'what files are here', + 'whats in this folder', + 'what is in this folder', + 'list everything', + 'll', + 'sort by date', + 'sort by size', + 'newest files' + ] + }, + { + id: 'sh-hidden-files', + shell: 'bash', + category: 'files', + task: 'See hidden files too', + summary: + 'In Linux a file is hidden simply because its name starts with a dot (.env, .git, .bashrc). -a lists those as well.', + command: 'ls -a', + keywords: [ + 'hidden files', + 'show hidden', + 'dotfiles', + 'dot files', + 'dir /a', + 'get-childitem -force', + '.env missing', + 'where is .bashrc', + 'invisible files' + ] + }, + { + id: 'sh-read-file', + shell: 'bash', + category: 'files', + task: 'Read a text file in the terminal', + summary: + 'cat prints the whole file. For a long one use less: arrows and Page Down to move, / to search, Q to leave.', + command: 'cat FILE', + variants: [ + { label: 'Scroll through a long file (Q to leave)', command: 'less FILE' }, + { label: 'Only the first 20 lines', command: 'head -n 20 FILE' }, + { label: 'Only the last 20 lines', command: 'tail -n 20 FILE' } + ], + placeholders: { FILE: 'The file to read' }, + keywords: [ + 'cat', + 'type', + 'read file', + 'show file', + 'print file', + 'view contents', + 'get-content', + 'less', + 'more', + 'head', + 'tail', + 'how do i exit less' + ] + }, + { + id: 'sh-follow-log', + shell: 'bash', + category: 'files', + task: 'Watch a log file as it grows', + summary: + 'Prints the end of the file and then every new line as it is written. Ctrl+C stops watching.', + command: 'tail -f FILE', + placeholders: { FILE: 'The log file' }, + keywords: [ + 'tail -f', + 'follow log', + 'watch file', + 'live log', + 'get-content -wait', + 'stream log', + 'monitor file', + 'log output' + ] + }, + { + id: 'sh-make-file', + shell: 'bash', + category: 'files', + task: 'Make a new empty file', + summary: + 'Creates the file if it is missing. If it already exists its contents are left alone and only its date changes.', + command: 'touch NAME', + variants: [ + { + label: 'A file with one line of text in it (REPLACES the file if it exists)', + command: 'echo "TEXT" > NAME' + } + ], + placeholders: { + NAME: 'The file to create, for example notes.txt', + TEXT: 'The first line of the file' + }, + danger: + 'The echo variant replaces everything in the file if it already exists. touch never does.', + keywords: [ + 'touch', + 'create file', + 'new file', + 'make file', + 'empty file', + 'new-item', + 'type nul', + 'blank file', + 'create .env' + ] + }, + { + id: 'sh-edit-file', + shell: 'bash', + category: 'files', + task: 'Edit a file in the terminal', + summary: + 'nano is the friendly editor: type as normal, Ctrl+O then Enter saves, Ctrl+X leaves. The shortcuts are listed along the bottom, where ^ means Ctrl.', + command: 'nano FILE', + variants: [ + { label: 'In VS Code instead', command: 'code FILE' }, + { + label: 'Stuck in vim? Press Esc, then type this and Enter to leave without saving', + command: ':q!' + } + ], + placeholders: { FILE: 'The file to edit (it is created if missing)' }, + keywords: [ + 'nano', + 'edit file', + 'text editor', + 'notepad', + 'vim', + 'vi', + 'how do i exit vim', + 'quit vim', + 'save in nano', + 'open file to edit' + ] + }, + { + id: 'sh-copy', + shell: 'bash', + category: 'files', + task: 'Copy a file or a folder', + summary: + 'Copies a file to a new name or into a folder. A folder needs -r ("and everything inside").', + command: 'cp SOURCE DESTINATION', + variants: [ + { label: 'A whole folder', command: 'cp -r SOURCE DESTINATION' }, + { label: 'Ask before replacing anything', command: 'cp -i SOURCE DESTINATION' } + ], + placeholders: { + SOURCE: 'What to copy', + DESTINATION: 'A folder to copy it into, or the new name' + }, + keywords: [ + 'cp', + 'copy', + 'copy file', + 'copy folder', + 'cp -r', + 'robocopy', + 'xcopy', + 'copy-item', + 'duplicate', + 'back up a file', + 'omitting directory' + ], + danger: + 'A file of the same name at the destination is replaced without asking, unless you add -i.' + }, + { + id: 'sh-move-rename', + shell: 'bash', + category: 'files', + task: 'Move or rename a file or folder', + summary: + 'Linux has one command for both: moving a file to a new name in the same folder IS renaming it.', + command: 'mv SOURCE DESTINATION', + variants: [{ label: 'Ask before replacing anything', command: 'mv -i SOURCE DESTINATION' }], + placeholders: { + SOURCE: 'The file or folder as it is now', + DESTINATION: 'The new name, or the folder to move it into' + }, + keywords: [ + 'mv', + 'move', + 'rename', + 'ren', + 'move-item', + 'rename-item', + 'change file name', + 'put file in folder', + 'relocate' + ], + danger: + 'A file of the same name at the destination is replaced without asking, unless you add -i.' + }, + { + id: 'sh-delete-file', + shell: 'bash', + category: 'files', + task: 'Delete a file', + summary: + 'Removes the file. A wildcard removes every match, so run ls with the same pattern first to see what it will take.', + command: 'rm FILE', + variants: [ + { label: 'Ask about each one first', command: 'rm -i FILE' }, + { label: 'Every file of one type in this folder', command: 'rm *.EXT' } + ], + placeholders: { FILE: 'The file to delete', EXT: 'The file extension, for example log' }, + keywords: [ + 'rm', + 'del', + 'erase', + 'delete file', + 'remove file', + 'remove-item', + 'get rid of file', + 'unlink' + ], + danger: 'There is no Recycle Bin in Linux. A deleted file is gone for good.' + }, + { + id: 'sh-find-file-by-name', + shell: 'bash', + category: 'files', + task: 'Find a file by name, in this folder and below', + summary: + 'Searches every subfolder. -iname ignores upper and lower case and the stars mean "anything", so part of the name is enough. Keep the quotes, or the shell expands the stars itself.', + command: 'find . -iname "*NAME*"', + variants: [ + { + label: 'Files only, and hide the "Permission denied" noise', + command: 'find . -type f -iname "*NAME*" 2>/dev/null' + }, + { label: 'Changed in the last day', command: 'find . -type f -mtime -1' }, + { label: 'Changed in the last hour', command: 'find . -type f -mmin -60' } + ], + placeholders: { NAME: 'Any part of the file name' }, + keywords: [ + 'find', + 'find file', + 'search for file', + 'locate', + 'where is my file', + 'dir /s', + 'get-childitem -recurse', + 'recently changed files', + 'modified today', + 'wildcard' + ] + }, + { + id: 'sh-biggest-files', + shell: 'bash', + category: 'files', + task: 'Find the biggest files and folders here', + summary: + 'Measures everything under this folder, sorts by size with the biggest on top, and keeps the first twenty.', + command: 'du -ah . | sort -rh | head -n 20', + keywords: [ + 'big files', + 'biggest files', + 'largest files', + 'what is using disk space', + 'disk full', + 'no space left on device', + 'find large files', + 'clean up space', + 'du sort' + ] + }, + { + id: 'sh-make-executable', + shell: 'bash', + category: 'files', + task: 'Run a script that says "Permission denied"', + summary: + 'A script has to be marked executable before ./ will run it. Do that once, then start it with ./ in front: Linux does not look in the current folder for commands by itself.', + command: 'chmod +x SCRIPT.sh', + variants: [ + { label: 'Then run it', command: './SCRIPT.sh' }, + { label: 'Or run it without marking it', command: 'bash SCRIPT.sh' } + ], + placeholders: { SCRIPT: 'The name of the script file' }, + keywords: [ + 'chmod', + 'chmod +x', + 'permission denied', + 'make executable', + 'run script', + 'run sh file', + 'dot slash', + './', + 'command not found script', + 'execute bit' + ] + }, + { + id: 'sh-permissions', + shell: 'bash', + category: 'files', + task: 'See or fix who may read and write a file', + summary: + 'The first column of ls -l reads r (read), w (write), x (run) three times over: for the owner, the group, and everyone else. The name after it is the owner.', + command: 'ls -l FILE', + variants: [ + { label: 'Give yourself read and write on a whole folder', command: 'chmod -R u+rwX FOLDER' }, + { + label: 'Take a folder back that sudo left owned by root', + command: 'sudo chown -R $USER FOLDER' + }, + { + label: 'What an SSH private key needs (it is refused otherwise)', + command: 'chmod 600 ~/.ssh/id_ed25519' + } + ], + placeholders: { FILE: 'The file to look at', FOLDER: 'The folder to fix' }, + keywords: [ + 'permissions', + 'chmod', + 'chown', + 'permission denied', + 'access denied', + 'read only', + 'owned by root', + 'eacces', + 'icacls', + 'unprotected private key file', + 'file mode' + ] + }, + { + id: 'sh-line-endings', + shell: 'bash', + category: 'files', + task: 'Fix a script broken by Windows line endings', + summary: + 'A file saved on Windows ends its lines with CR LF, and bash reads the CR as part of the command: "bad interpreter: /bin/bash^M" or "$\'\\r\': command not found". This strips the CR from every line.', + command: "sed -i 's/\\r$//' FILE", + variants: [ + { label: 'Check first: it says "with CRLF line terminators" if so', command: 'file FILE' }, + { label: 'The dedicated tool (sudo apt install dos2unix)', command: 'dos2unix FILE' }, + { + label: 'Stop git converting line endings on checkout', + command: 'git config --global core.autocrlf input' + } + ], + placeholders: { FILE: 'The script or text file to fix' }, + keywords: [ + 'line endings', + 'crlf', + 'lf', + 'carriage return', + 'bad interpreter', + '^m', + '\\r command not found', + 'dos2unix', + 'windows line endings', + 'script wont run in wsl', + 'autocrlf' + ], + danger: + 'The file is rewritten in place. Harmless for text and scripts, but never run it on a binary file.' + }, + { + id: 'sh-tar-extract', + shell: 'bash', + category: 'files', + task: 'Unpack a .tar.gz archive', + summary: + 'x extracts, f names the file, and modern tar works out the compression (.gz, .bz2, .xz) by itself.', + command: 'tar -xf FILE.tar.gz', + variants: [ + { label: 'Into another folder (it must exist)', command: 'tar -xf FILE.tar.gz -C FOLDER' }, + { label: 'Just list what is inside', command: 'tar -tf FILE.tar.gz' } + ], + placeholders: { FILE: 'The archive, without its ending', FOLDER: 'Where to unpack it' }, + keywords: [ + 'tar', + 'untar', + 'extract', + 'unpack', + 'tar.gz', + 'tgz', + 'tarball', + 'expand-archive', + 'decompress', + 'gunzip' + ], + danger: 'Files already there with the same names are replaced without asking.' + }, + { + id: 'sh-tar-create', + shell: 'bash', + category: 'files', + task: 'Pack a folder into a .tar.gz archive', + summary: + 'c creates, z compresses with gzip, f names the archive. The folder itself is left as it is.', + command: 'tar -czf NAME.tar.gz FOLDER', + placeholders: { NAME: 'What to call the archive', FOLDER: 'The folder to pack' }, + keywords: [ + 'tar', + 'compress', + 'archive folder', + 'make tarball', + 'tar.gz', + 'compress-archive', + 'back up folder', + 'gzip' + ], + danger: 'An archive of the same name is replaced without asking.' + }, + { + id: 'sh-unzip', + shell: 'bash', + category: 'files', + task: 'Unzip a .zip file', + summary: + 'Unpacks into the folder named after -d, making it if needed. A stock Ubuntu has neither unzip nor zip: sudo apt install unzip zip.', + command: 'unzip FILE.zip -d FOLDER', + variants: [ + { label: 'Just list what is inside', command: 'unzip -l FILE.zip' }, + { label: 'Make a zip of a folder', command: 'zip -r NAME.zip FOLDER' } + ], + placeholders: { + FILE: 'The zip, without its ending', + FOLDER: 'Where to unpack, or what to pack', + NAME: 'What to call the new zip' + }, + keywords: [ + 'unzip', + 'zip', + 'extract zip', + 'expand-archive', + 'compress-archive', + 'unzip command not found', + 'open zip', + 'decompress' + ] + }, + + // ------------------------------------------------------------------- text + { + id: 'sh-grep-recursive', + shell: 'bash', + category: 'text', + task: 'Find text inside files', + summary: + '-r searches this folder and every subfolder, -n shows the line number and -i ignores upper and lower case.', + command: 'grep -rni "TEXT" .', + variants: [ + { label: 'Just the names of the files that contain it', command: 'grep -rli "TEXT" .' }, + { label: 'Only in one type of file', command: 'grep -rni --include="*.EXT" "TEXT" .' }, + { label: 'Skip node_modules', command: 'grep -rni --exclude-dir=node_modules "TEXT" .' } + ], + placeholders: { TEXT: 'The words to look for', EXT: 'The file extension, for example ts' }, + keywords: [ + 'grep', + 'grep -r', + 'findstr', + 'select-string', + 'search in files', + 'find text', + 'find string', + 'search code', + 'which file contains', + 'find word' + ] + }, + { + id: 'sh-filter-output', + shell: 'bash', + category: 'text', + task: 'Show only the lines of output that mention something', + summary: 'Pipes a command into grep so a long list is cut down to the lines you care about.', + command: 'COMMAND | grep -i "TEXT"', + variants: [{ label: 'The lines that do NOT mention it', command: 'COMMAND | grep -iv "TEXT"' }], + placeholders: { + COMMAND: 'Any command that prints a lot, for example ps aux', + TEXT: 'The word to keep' + }, + keywords: [ + 'filter output', + 'grep', + 'pipe to grep', + 'search output', + 'where-object', + 'findstr', + 'only matching lines', + 'too much output' + ] + }, + { + id: 'sh-count-lines', + shell: 'bash', + category: 'text', + task: 'Count lines, words or files', + summary: + 'wc -l counts lines, and anything can be piped into it: count the files here, the matches of a grep, and so on.', + command: 'wc -l FILE', + variants: [ + { label: 'How many things are in this folder', command: 'ls | wc -l' }, + { label: 'Words rather than lines', command: 'wc -w FILE' } + ], + placeholders: { FILE: 'The file to count' }, + keywords: [ + 'wc', + 'count lines', + 'line count', + 'how many lines', + 'how many files', + 'word count', + 'measure-object', + 'find /c' + ] + }, + { + id: 'sh-compare-files', + shell: 'bash', + category: 'text', + task: 'Compare two files', + summary: + 'Lines starting - are only in the first file, lines starting + only in the second. No output at all means they are the same.', + command: 'diff -u FILE1 FILE2', + variants: [{ label: 'Two whole folders', command: 'diff -rq FOLDER1 FOLDER2' }], + placeholders: { + FILE1: 'The first file', + FILE2: 'The second file', + FOLDER1: 'The first folder', + FOLDER2: 'The second folder' + }, + keywords: [ + 'diff', + 'compare files', + 'fc', + 'compare-object', + 'what changed', + 'are these the same', + 'differences', + 'compare folders' + ] + }, + { + id: 'sh-replace-text', + shell: 'bash', + category: 'text', + task: 'Replace a word throughout a file', + summary: + 's/OLD/NEW/g swaps every OLD for NEW, and -i writes the result back into the file. Run the preview variant first: it only prints.', + command: "sed -i 's/OLD/NEW/g' FILE", + variants: [ + { label: 'Preview: print the result, change nothing', command: "sed 's/OLD/NEW/g' FILE" } + ], + placeholders: { + OLD: 'The text to find (a / or a . in it needs a backslash in front)', + NEW: 'What to put in its place', + FILE: 'The file to change' + }, + keywords: [ + 'sed', + 'replace text', + 'find and replace', + 'search and replace', + 'substitute', + 'rename variable', + '-replace', + 'change word in file' + ], + danger: + 'The file is changed in place with no backup. Use sed -i.bak to keep the original as FILE.bak.' + }, + { + id: 'sh-sort-unique', + shell: 'bash', + category: 'text', + task: 'Sort lines and count the duplicates', + summary: + 'Sorts the lines, folds identical ones together with a count, and puts the most frequent on top.', + command: 'sort FILE | uniq -c | sort -nr', + variants: [{ label: 'Just sort and drop the duplicates', command: 'sort -u FILE' }], + placeholders: { FILE: 'The file to sort' }, + keywords: [ + 'sort', + 'uniq', + 'unique lines', + 'remove duplicates', + 'count occurrences', + 'most common', + 'sort-object', + 'group-object', + 'dedupe' + ] + }, + { + id: 'sh-pick-column', + shell: 'bash', + category: 'text', + task: 'Keep one column of some output', + summary: + 'awk splits each line on spaces and $2 is the second piece. For comma-separated text use cut.', + command: "COMMAND | awk '{print $2}'", + variants: [ + { label: 'The second field of comma-separated lines', command: "cut -d',' -f2 FILE" } + ], + placeholders: { + COMMAND: 'Any command that prints columns, for example ps aux', + FILE: 'A CSV file' + }, + keywords: [ + 'awk', + 'cut', + 'column', + 'field', + 'extract column', + 'select-object', + 'csv column', + 'split line', + 'second word' + ] + }, + + // -------------------------------------------------------------- processes + { + id: 'sh-list-processes', + shell: 'bash', + category: 'processes', + task: 'See what is running', + summary: + 'Lists every process with its owner, its PID (the number you need to stop it), and its CPU and memory share.', + command: 'ps aux', + variants: [ + { label: 'Only the ones mentioning a name', command: 'ps aux | grep -i NAME' }, + { label: 'Just PIDs and command lines for a name', command: 'pgrep -fl NAME' }, + { label: 'A live view, busiest first (Q to leave)', command: 'top' } + ], + placeholders: { NAME: 'The program, for example node' }, + keywords: [ + 'ps', + 'ps aux', + 'tasklist', + 'get-process', + 'running programs', + 'processes', + 'task manager', + 'top', + 'htop', + 'is node running', + 'pid', + 'cpu usage' + ] + }, + { + id: 'sh-kill-process', + shell: 'bash', + category: 'processes', + task: 'Stop a program by its PID or name', + summary: + 'Plain kill asks the program to stop and lets it tidy up. Reach for -9 only when it ignores that: -9 cannot be refused and nothing gets saved.', + command: 'kill PID', + variants: [ + { label: 'Force it, when asking did not work', command: 'kill -9 PID' }, + { label: 'Every process with this name', command: 'pkill NAME' }, + { label: 'Anything whose command line mentions a word', command: 'pkill -f TEXT' } + ], + placeholders: { + PID: 'The number from ps aux or lsof', + NAME: 'The program name, for example node', + TEXT: 'Part of the command line, for example vite' + }, + keywords: [ + 'kill', + 'kill -9', + 'pkill', + 'killall', + 'taskkill', + 'stop-process', + 'stop program', + 'end task', + 'frozen', + 'not responding', + 'kill node' + ], + danger: + 'The program stops without the chance to save, and pkill stops EVERY match, not just one.' + }, + { + id: 'sh-port-in-use', + shell: 'bash', + category: 'processes', + task: 'Find what is using a port', + summary: + 'Names the program and PID holding the port. No output means nothing of YOURS holds it: put sudo in front to see other users. The ss variant is always installed, should lsof be missing.', + command: 'lsof -i :PORT', + variants: [ + { label: 'Everything that is listening, with the program names', command: 'ss -ltnp' }, + { label: 'Just that port, with ss', command: 'ss -ltnp | grep :PORT' } + ], + placeholders: { PORT: 'The port number, for example 3000' }, + keywords: [ + 'port in use', + 'port already in use', + 'eaddrinuse', + 'address already in use', + 'what is using port 3000', + 'lsof', + 'lsof -i', + 'netstat', + 'ss', + 'listening ports', + 'free a port' + ] + }, + { + id: 'sh-cancel-command', + shell: 'bash', + category: 'processes', + task: 'Stop a command that is still running', + summary: + 'Not a command but a key: hold Ctrl and press C. If the program ignores it, Ctrl+Z parks it and hands the prompt back, and the variant then finishes it off.', + command: 'Ctrl+C', + variants: [ + { label: 'After Ctrl+Z: force-stop the job you just parked', command: 'kill -9 %1' } + ], + keywords: [ + 'cancel', + 'stop', + 'abort', + 'interrupt', + 'ctrl c', + 'ctrl z', + 'how do i stop this', + 'stuck', + 'hung', + 'wont stop', + 'exit running command', + 'sigint' + ], + danger: 'The kill variant stops the parked program without the chance to save.' + }, + { + id: 'sh-background-jobs', + shell: 'bash', + category: 'processes', + task: 'Run something in the background', + summary: + 'An & at the end starts the command and hands the prompt straight back. jobs lists what this shell has running, and fg brings one to the front again.', + command: 'COMMAND &', + variants: [ + { label: 'List the background jobs of this shell', command: 'jobs' }, + { label: 'Bring the last one back to the front', command: 'fg' }, + { label: 'Let a job parked with Ctrl+Z carry on in the background', command: 'bg' }, + { + label: 'Keep it running after the terminal closes', + command: 'nohup COMMAND > output.log 2>&1 &' + } + ], + placeholders: { COMMAND: 'The command to run, for example npm run dev' }, + danger: 'The nohup variant replaces output.log if a file of that name is already there.', + keywords: [ + 'background', + 'ampersand', + 'jobs', + 'fg', + 'bg', + 'nohup', + 'start-job', + 'start /b', + 'keep running after close', + 'ctrl z', + 'suspended', + 'there are stopped jobs' + ] + }, + + // ---------------------------------------------------------------- network + { + id: 'sh-ip-address', + shell: 'bash', + category: 'network', + task: 'Find my IP address', + summary: + "Prints this machine's addresses. Under WSL that is the Linux side's own address, which is usually not the one Windows has on your network.", + command: 'hostname -I', + variants: [ + { label: 'Every network interface in full', command: 'ip addr' }, + { label: 'The address the internet sees', command: 'curl -s https://ifconfig.me' } + ], + keywords: [ + 'ip address', + 'my ip', + 'local ip', + 'ipconfig', + 'ifconfig', + 'ip addr', + 'public ip', + 'wsl ip', + 'network interface' + ] + }, + { + id: 'sh-ping', + shell: 'bash', + category: 'network', + task: 'Check whether a site or machine can be reached', + summary: + 'Linux ping goes on for ever unless told otherwise, so -c 4 sends four packets and stops.', + command: 'ping -c 4 HOST', + variants: [{ label: 'Look the name up (DNS and /etc/hosts)', command: 'getent hosts HOST' }], + placeholders: { HOST: 'A name or an address, for example github.com' }, + keywords: [ + 'ping', + 'is the internet working', + 'connection test', + 'test-connection', + 'dns lookup', + 'nslookup', + 'site down', + 'no internet', + 'ping never stops', + 'temporary failure in name resolution' + ] + }, + { + id: 'sh-download', + shell: 'bash', + category: 'network', + task: 'Download a file from a URL', + summary: + '-L follows redirects (most download links have one) and -O keeps the name the file has on the server.', + command: 'curl -L -O URL', + variants: [ + { label: 'Choose the name to save it under', command: 'curl -L -o FILE URL' }, + { label: 'The same with wget', command: 'wget URL' } + ], + placeholders: { + URL: 'The full address, starting https://', + FILE: 'What to call the saved file' + }, + danger: + 'curl replaces a file of the same name without a question. wget keeps it and saves beside it.', + keywords: [ + 'curl', + 'wget', + 'download', + 'fetch url', + 'invoke-webrequest', + 'save file from internet', + 'get file', + 'download script' + ] + }, + { + id: 'sh-check-server', + shell: 'bash', + category: 'network', + task: 'Check that a website or my local server answers', + summary: + '-I asks for the headers only, so the first line is the verdict: 200 is fine, 404 not found, "Connection refused" means nothing is listening there.', + command: 'curl -I http://localhost:PORT', + variants: [ + { label: 'Show the reply body as well as the headers', command: 'curl -i URL' }, + { + label: 'Send JSON to an API', + command: 'curl -X POST -H "Content-Type: application/json" -d \'{"KEY":"VALUE"}\' URL' + } + ], + placeholders: { + PORT: 'The port your server runs on, for example 3000', + URL: 'The full address', + KEY: 'A field name in the JSON', + VALUE: 'Its value' + }, + keywords: [ + 'curl', + 'http request', + 'test api', + 'is my server running', + 'localhost', + 'connection refused', + 'status code', + 'post json', + 'invoke-restmethod', + 'headers' + ] + }, + { + id: 'sh-ssh', + shell: 'bash', + category: 'network', + task: 'Log in to another machine over SSH', + summary: 'Opens a shell on the remote machine. exit, or Ctrl+D, brings you back.', + command: 'ssh USER@HOST', + variants: [ + { label: 'On a port other than 22', command: 'ssh -p PORT USER@HOST' }, + { label: 'With a particular key file', command: 'ssh -i ~/.ssh/KEYFILE USER@HOST' }, + { label: 'Copy a file to the other machine', command: 'scp FILE USER@HOST:FOLDER' } + ], + placeholders: { + USER: 'Your user name on the other machine', + HOST: 'Its name or address', + PORT: 'The SSH port', + KEYFILE: 'The private key, for example id_ed25519', + FILE: 'The local file to send', + FOLDER: 'The folder on the other machine' + }, + keywords: [ + 'ssh', + 'remote login', + 'connect to server', + 'scp', + 'copy file to server', + 'remote shell', + 'vps', + 'putty', + 'permission denied publickey' + ] + }, + { + id: 'sh-ssh-keygen', + shell: 'bash', + category: 'network', + task: 'Make an SSH key (for GitHub or a server)', + summary: + 'Creates a key pair in ~/.ssh and asks where to save it and for an optional passphrase. The .pub half is the one you paste into GitHub; the other half never leaves this machine.', + command: 'ssh-keygen -t ed25519 -C "EMAIL"', + variants: [ + { label: 'Print the public key, ready to paste', command: 'cat ~/.ssh/id_ed25519.pub' }, + { label: 'Test the connection to GitHub', command: 'ssh -T git@github.com' } + ], + placeholders: { EMAIL: 'A label for the key, usually your email address' }, + keywords: [ + 'ssh key', + 'ssh-keygen', + 'github ssh', + 'public key', + 'id_ed25519', + 'id_rsa', + 'permission denied publickey', + 'deploy key', + 'generate key' + ], + danger: + 'If it asks "Overwrite (y/n)?", a key is already there: answering y destroys it, and everything that trusted it stops working.' + }, + + // ----------------------------------------------------------------- system + { + id: 'sh-disk-free', + shell: 'bash', + category: 'system', + task: 'See how much disk space is free', + summary: 'One line per disk with its size, what is used and what is left, in K, M and G.', + command: 'df -h', + variants: [{ label: 'Just the Windows C: drive, from WSL', command: 'df -h /mnt/c' }], + keywords: [ + 'df', + 'df -h', + 'disk space', + 'free space', + 'drive full', + 'no space left on device', + 'get-psdrive', + 'storage', + 'how much space left' + ] + }, + { + id: 'sh-memory', + shell: 'bash', + category: 'system', + task: 'See how much memory is in use', + summary: + 'The "available" column is the one that matters: it is what programs can still have. Under WSL the total is what Windows lends to Linux, not the whole machine.', + command: 'free -h', + keywords: [ + 'free', + 'memory', + 'ram', + 'how much ram', + 'out of memory', + 'swap', + 'memory usage', + 'killed', + 'wsl memory' + ] + }, + { + id: 'sh-env-show', + shell: 'bash', + category: 'system', + task: 'See my environment variables', + summary: + 'printenv lists them all. A single value is read with a dollar sign in front of its name.', + command: 'printenv', + variants: [ + { label: 'The value of one', command: 'echo "$NAME"' }, + { label: 'The ones whose name or value mentions a word', command: 'printenv | grep -i TEXT' } + ], + placeholders: { + NAME: 'The variable, for example HOME', + TEXT: 'Part of the name, for example api' + }, + keywords: [ + 'environment variables', + 'env', + 'printenv', + 'set', + 'show variable', + '$env:', + 'is my api key set', + 'echo variable', + 'dollar sign' + ] + }, + { + id: 'sh-env-set', + shell: 'bash', + category: 'system', + task: 'Set an environment variable', + summary: + 'export sets it for this shell and everything started from it, until the window closes. No spaces around the =. The ~/.bashrc variant makes it permanent for new shells.', + command: 'export NAME="VALUE"', + variants: [ + { label: 'For one command only', command: 'NAME="VALUE" COMMAND' }, + { + label: 'For good: add the line to ~/.bashrc', + command: 'echo \'export NAME="VALUE"\' >> ~/.bashrc' + }, + { label: 'Then load it into this shell', command: 'source ~/.bashrc' } + ], + placeholders: { + NAME: 'The variable name, for example ANTHROPIC_API_KEY', + VALUE: 'Its value', + COMMAND: 'The command that should see it' + }, + keywords: [ + 'export', + 'set environment variable', + 'env var', + 'api key', + 'setx', + '$env:', + 'permanent variable', + 'bashrc', + 'variable gone after restart', + 'command not found after =' + ] + }, + { + id: 'sh-add-to-path', + shell: 'bash', + category: 'system', + task: 'Add a folder to PATH', + summary: + 'PATH is the list of folders searched for commands, separated by colons. This adds one for the current shell; the variant saves it for every new one. Keep the $PATH part, or every other command disappears.', + command: 'export PATH="$PATH:FOLDER"', + variants: [ + { + label: 'For good: add the line to ~/.bashrc', + command: 'echo \'export PATH="$PATH:FOLDER"\' >> ~/.bashrc' + }, + { + label: 'See what is on PATH now, one folder per line', + command: "echo \"$PATH\" | tr ':' '\\n'" + } + ], + placeholders: { FOLDER: 'The folder that holds the program, for example $HOME/.local/bin' }, + keywords: [ + 'path', + 'add to path', + 'export path', + 'command not found', + 'not on path', + '.local/bin', + 'edit path', + '$env:path', + 'bashrc', + 'installed but not found' + ] + }, + { + id: 'sh-which', + shell: 'bash', + category: 'system', + task: 'Find where a program is installed', + summary: + 'Prints the full path of what would run if you typed that name. No output means it is not on PATH. type also knows about aliases and built-ins.', + command: 'which NAME', + variants: [ + { label: 'Aliases and shell built-ins too', command: 'type NAME' }, + { label: 'Every match on PATH, in order', command: 'which -a NAME' } + ], + placeholders: { NAME: 'The command, for example node or python3' }, + keywords: [ + 'which', + 'where', + 'type', + 'command -v', + 'get-command', + 'find program', + 'is it installed', + 'which version runs', + 'path of executable' + ] + }, + { + id: 'sh-versions', + shell: 'bash', + category: 'system', + task: 'See which Linux version this is', + summary: + 'Names the distribution and its release, which is what an install guide means by "Ubuntu 24.04".', + command: 'lsb_release -a', + variants: [ + { label: 'The same from a file every distribution has', command: 'cat /etc/os-release' }, + { label: 'The kernel (it mentions WSL2 when you are in WSL)', command: 'uname -a' }, + { label: 'Which user I am', command: 'whoami' } + ], + keywords: [ + 'linux version', + 'ubuntu version', + 'lsb_release', + 'uname', + 'os-release', + 'which distro', + 'am i in wsl', + 'whoami', + 'ver', + 'systeminfo' + ] + }, + { + id: 'sh-sudo', + shell: 'bash', + category: 'system', + task: 'Run a command as administrator', + summary: + 'sudo runs ONE command as root and asks for YOUR Linux password (the one chosen when Ubuntu was set up). Nothing appears while you type it; that is normal.', + command: 'sudo COMMAND', + variants: [{ label: 'Run the previous command again, with sudo', command: 'sudo !!' }], + placeholders: { COMMAND: 'The command that was refused' }, + keywords: [ + 'sudo', + 'run as admin', + 'administrator', + 'root', + 'permission denied', + 'access denied', + 'are you root', + 'password not showing', + 'elevated', + 'runas' + ], + danger: + 'Root can change or delete anything in Linux. Only put sudo in front of a command you understand.' + }, + + // --------------------------------------------------------------- packages + { + id: 'sh-apt-install', + shell: 'bash', + category: 'packages', + task: 'Install a program or package on Ubuntu', + summary: + 'apt update refreshes the list of what exists (a fresh WSL has none, so installs fail without it), then install fetches the program.', + command: 'sudo apt update && sudo apt install -y NAME', + placeholders: { NAME: 'The package, for example unzip or build-essential' }, + keywords: [ + 'apt', + 'apt install', + 'apt-get', + 'install program', + 'install a package', + 'winget', + 'command not found', + 'unable to locate package', + 'package manager', + 'choco' + ] + }, + { + id: 'sh-apt-upgrade', + shell: 'bash', + category: 'packages', + task: 'Update everything installed on Ubuntu', + summary: + 'Refreshes the package list, then upgrades what has a newer version. It shows the list and asks before going ahead.', + command: 'sudo apt update && sudo apt upgrade', + variants: [ + { + label: 'Afterwards: clear out packages nothing needs any more', + command: 'sudo apt autoremove' + } + ], + keywords: [ + 'apt upgrade', + 'apt update', + 'update ubuntu', + 'update packages', + 'security updates', + 'winget upgrade', + 'upgrade all', + 'system update' + ] + }, + { + id: 'sh-apt-search', + shell: 'bash', + category: 'packages', + task: 'Find a package, or check whether one is installed', + summary: 'Searches the names and descriptions of everything apt can install.', + command: 'apt search TEXT', + variants: [ + { + label: 'Is it installed, and which version', + command: 'apt list --installed 2>/dev/null | grep -i NAME' + }, + { label: 'What a package is, before installing it', command: 'apt show NAME' } + ], + placeholders: { TEXT: 'A word describing what you need', NAME: 'The package name' }, + keywords: [ + 'apt search', + 'find package', + 'is it installed', + 'apt list', + 'apt show', + 'dpkg -l', + 'winget search', + 'package name', + 'installed version' + ] + }, + { + id: 'sh-apt-remove', + shell: 'bash', + category: 'packages', + task: 'Uninstall a program on Ubuntu', + summary: + 'Removes the program and keeps its settings, in case it comes back. It lists what will go and asks first.', + command: 'sudo apt remove NAME', + variants: [{ label: 'Its system-wide settings too', command: 'sudo apt purge NAME' }], + placeholders: { NAME: 'The package to remove' }, + keywords: [ + 'apt remove', + 'uninstall', + 'remove package', + 'apt purge', + 'winget uninstall', + 'delete program', + 'get rid of package' + ], + danger: + 'The program is uninstalled, and purge deletes its configuration as well. Read the list apt shows before answering y.' + }, + + // ------------------------------------------------------------------ shell + { + id: 'sh-clear', + shell: 'bash', + category: 'shell', + task: 'Clear the screen', + summary: + 'Wipes what is on screen; Ctrl+L does the same from the keyboard. Neither touches your files or history.', + command: 'clear', + variants: [ + { label: 'The terminal is printing garbage: reset it completely', command: 'reset' } + ], + keywords: [ + 'clear', + 'cls', + 'clear screen', + 'clear-host', + 'ctrl l', + 'wipe terminal', + 'too much text', + 'garbled terminal', + 'reset' + ] + }, + { + id: 'sh-history', + shell: 'bash', + category: 'shell', + task: 'See or search the commands I have typed', + summary: + 'Lists your commands, numbered, across sessions. Ctrl+R is the keyboard way: press it, type a few letters, and it finds the last command containing them (Ctrl+R again for older, Enter to run).', + command: 'history', + variants: [ + { label: 'Only the ones mentioning a word', command: 'history | grep -i TEXT' }, + { label: 'The last twenty', command: 'history | tail -n 20' }, + { label: 'Run number N from the list again', command: '!N' } + ], + placeholders: { + TEXT: 'Part of the command you are looking for', + N: 'The number shown beside the command' + }, + keywords: [ + 'history', + 'previous commands', + 'what did i type', + 'ctrl r', + 'reverse search', + 'get-history', + 'doskey /history', + 'recall command', + 'up arrow', + 'bash_history' + ] + }, + { + id: 'sh-help', + shell: 'bash', + category: 'shell', + task: 'Get help on a command', + summary: + 'man opens the full manual (arrows to scroll, / to search, Q to leave). --help is the short version, and works for nearly everything.', + command: 'man COMMAND', + variants: [ + { label: 'The short version', command: 'COMMAND --help' }, + { label: 'For what is built into bash (cd, export, alias)', command: 'help COMMAND' }, + { label: 'Find commands by what they do', command: 'man -k TEXT' } + ], + placeholders: { + COMMAND: 'The command to read about, for example tar', + TEXT: 'A word such as compress' + }, + keywords: [ + 'man', + 'help', + '--help', + 'manual', + 'get-help', + '/?', + 'what does this command do', + 'options', + 'flags', + 'usage', + 'how do i exit man' + ] + }, + { + id: 'sh-bashrc', + shell: 'bash', + category: 'shell', + task: 'Change what runs every time a shell opens', + summary: + '~/.bashrc is read by every new bash window: aliases, exports and PATH additions go at its end. After editing, source it to apply the change to the window you are in.', + command: 'nano ~/.bashrc', + variants: [{ label: 'Apply the changes to this shell now', command: 'source ~/.bashrc' }], + keywords: [ + 'bashrc', + '.bashrc', + 'profile', + '$profile', + 'startup script', + 'shell config', + 'source', + 'reload shell', + 'bash_profile', + 'settings file', + 'autoexec' + ] + }, + { + id: 'sh-alias', + shell: 'bash', + category: 'shell', + task: 'Make a short name for a long command', + summary: + 'Lasts until this shell closes; the second variant saves it for every new one. No spaces around the =.', + command: "alias SHORT='COMMAND'", + variants: [ + { label: 'List the aliases that exist', command: 'alias' }, + { + label: 'For good: add the line to ~/.bashrc', + command: 'echo "alias SHORT=\'COMMAND\'" >> ~/.bashrc' + }, + { label: 'Remove one', command: 'unalias SHORT' } + ], + placeholders: { + SHORT: 'The name to type, for example gs', + COMMAND: 'What it should run, for example git status' + }, + keywords: [ + 'alias', + 'shortcut command', + 'set-alias', + 'doskey', + 'abbreviation', + 'custom command', + 'nickname', + 'unalias', + 'll' + ] + }, + { + id: 'sh-redirect', + shell: 'bash', + category: 'shell', + task: 'Save the output of a command to a file', + summary: + 'A single > writes a fresh file each time; a double >> adds to the end of what is there.', + command: 'COMMAND > FILE', + variants: [ + { label: 'Add to the end of the file instead', command: 'COMMAND >> FILE' }, + { label: 'Error messages too', command: 'COMMAND > FILE 2>&1' }, + { label: 'Throw the error messages away', command: 'COMMAND 2>/dev/null' }, + { label: 'See it on screen AND save it', command: 'COMMAND | tee FILE' } + ], + placeholders: { COMMAND: 'Any command', FILE: 'The file to write, for example output.txt' }, + keywords: [ + 'redirect', + 'redirection', + 'save output', + 'write to file', + 'log to file', + 'out-file', + 'append', + 'stderr', + '2>&1', + '/dev/null', + 'tee', + 'hide errors' + ], + danger: + 'A single > (and tee) replaces everything the file held before. Use >> (or tee -a) to keep it.' + }, + { + id: 'sh-pipe', + shell: 'bash', + category: 'shell', + task: 'Send the output of one command into another', + summary: + 'The bar hands what the first command prints to the second. less pages it (Q to leave); clip.exe puts it on the Windows clipboard.', + command: 'COMMAND | less', + variants: [ + { + label: 'Copy the output to the Windows clipboard (WSL and Git Bash)', + command: 'COMMAND | clip.exe' + }, + { + label: 'Use each line of output as an argument to another command', + command: 'COMMAND | xargs OTHER' + } + ], + placeholders: { + COMMAND: 'Any command', + OTHER: 'The command that should receive the lines, for example ls -l' + }, + keywords: [ + 'pipe', + 'pipeline', + 'vertical bar', + 'less', + 'more', + 'page output', + 'clip', + 'copy output to clipboard', + 'pbcopy', + 'xclip', + 'set-clipboard', + 'xargs' + ] + }, + { + id: 'sh-several-commands', + shell: 'bash', + category: 'shell', + task: 'Run several commands on one line', + summary: + '&& runs the second command only if the first one worked, which is nearly always what you want.', + command: 'FIRST && SECOND', + variants: [ + { label: 'Run the second whatever happens', command: 'FIRST ; SECOND' }, + { label: 'Run the second only if the first FAILED', command: 'FIRST || SECOND' } + ], + placeholders: { FIRST: 'The first command', SECOND: 'The command to run after it' }, + keywords: [ + 'chain commands', + 'multiple commands', + 'one line', + 'and and', + 'semicolon', + 'run after', + 'if it fails', + 'then run', + 'ampersand' + ] + }, + { + id: 'sh-exit', + shell: 'bash', + category: 'shell', + task: 'Leave the shell, or an SSH session', + summary: + 'Closes the shell you are in: out of an SSH login back to your own machine, or out of WSL back to Windows. Ctrl+D on an empty line does the same.', + command: 'exit', + keywords: [ + 'exit', + 'quit', + 'logout', + 'log out', + 'close terminal', + 'leave ssh', + 'ctrl d', + 'disconnect', + 'leave wsl', + 'end session' + ] + } +] diff --git a/core/shared/help/catalogue.test.ts b/core/shared/help/catalogue.test.ts new file mode 100644 index 0000000..69d6634 --- /dev/null +++ b/core/shared/help/catalogue.test.ts @@ -0,0 +1,448 @@ +import { describe, expect, it } from 'vitest' +import { readdirSync, readFileSync } from 'fs' +import { join } from 'path' +import { + ALL_HELP, + HELP_CATEGORIES, + HELP_ID_PREFIXES, + HELP_SHELLS, + helpFor, + isKeyPress, + shellOfShellId, + type HelpShellChoice +} from './index' +import { searchHelp } from './search' +import type { HelpEntry } from './types' + +/** + * THE GATE FOR THE CATALOGUE'S CONTENT (#12). The catalogue is curated text, + * several hundred entries of it, written to be copied into somebody's shell: + * what a compiler cannot see is exactly what matters here. So every rule in + * `types.ts` is held by a test, the destructive commands most of all: an entry + * that deletes, kills or overwrites and does not SAY so is the one mistake in + * this feature that can cost somebody their work. + * + * Each check collects every offender and reports them together, so one run + * names everything there is to fix. + */ + +/** Built from its code point, so this file does not hold the character it forbids. */ +const EM_DASH = String.fromCharCode(0x2014) + +/** Every command an entry offers: its own, then each variant's. */ +const commandsOf = (e: HelpEntry): string[] => [e.command, ...(e.variants ?? []).map((v) => v.command)] + +/** Every string a person reads or copies, for the rules that cover all text. */ +const textsOf = (e: HelpEntry): string[] => [ + e.id, + e.task, + e.summary, + e.danger ?? '', + ...commandsOf(e), + ...(e.variants ?? []).map((v) => v.label), + ...Object.entries(e.placeholders ?? {}).flat(), + ...e.keywords +] + +/** + * UPPER-CASE WORDS THAT ARE THE COMMAND'S OWN, not something to replace: git's + * HEAD, netstat's LISTENING, an environment variable's real name. Everything + * else in capitals (three letters or more) must be a declared placeholder, + * which is what keeps a reader from pasting the word FOLDER into a shell. + */ +const LITERAL_CAPS = new Set([ + 'HEAD', + 'LISTENING', + 'PATH', + 'PATHEXT', + 'DATE', + 'TIME', + 'HOME', + 'USERPROFILE', + 'USERNAME', + 'APPDATA', + 'LOCALAPPDATA', + 'TEMP', + 'PROFILE', + 'PWD', + 'LASTEXITCODE', + 'ERRORLEVEL', + 'PSVERSIONTABLE', + 'MX', + 'TXT', + 'POST', + 'GET', + 'JSON', + 'CSV', + 'README', + 'CLAUDE', + 'AGENTS', + 'SHA256', + 'SHA1', + 'LTS', + 'FALSE', + 'GEQ', + 'MD5', + 'UTF8', + 'WSL', + 'PID', + 'CPU', + 'WS', + 'AND', + 'TCP', + 'UDP' +]) + +/** The capitalised words of a command that stand for something the reader + * supplies. A word right after `$`, `%` or `env:` is a variable's real name. */ +function placeholdersIn(command: string): string[] { + const found: string[] = [] + for (const m of command.matchAll(/(^|[^A-Za-z0-9_$%:])([A-Z][A-Z0-9_]{2,})(?![A-Za-z0-9_])/g)) { + if (!LITERAL_CAPS.has(m[2])) found.push(m[2]) + } + return found +} + +/** + * WHAT COUNTS AS DESTRUCTIVE. A pattern is tested against each COMMAND (the + * main one and every variant); a hit anywhere obliges the ENTRY to carry a + * `danger` line. Written against the start of a pipeline stage where the word + * is also an everyday English one ("del", "kill", "format"), so a summary-like + * command such as `Format-Table` or `git log --format` is not swept in. + */ +const STAGE = String.raw`(?:^|[|;&(]\s*|\bsudo\s+|\bxargs\s+(?:-\S+\s+)*)` +const DESTRUCTIVE: ReadonlyArray<{ name: string; re: RegExp }> = [ + // Env:, Alias:, Function: and Variable: are the session's own drives: removing + // from one forgets a name until the window closes, and touches no file. + // Nor is ASKING about it: `Get-Help Remove-Item` reads a manual page. + { + name: 'Remove-Item', + re: /(? writes OVER the file it names. Not >>, not a numbered stream's + // own (2>nul, 2>&1), not the bin (nul, /dev/null, $null), not an arrow (->, =>). + { name: 'redirect over a file', re: /(?0-9&=-])>(?!>)\s*(?!nul\b|\/dev\/null|\$null|&)\S/i }, + { name: 'download over a file', re: /\bcurl(?:\.exe)?\b.*\s-\w*[oO]\b|-OutFile\b/ } +] + +const destructiveHits = (e: HelpEntry): string[] => + DESTRUCTIVE.filter((d) => commandsOf(e).some((c) => d.re.test(c))).map((d) => d.name) + +describe('the help catalogue', () => { + it('is a few hundred entries, and one constant list', () => { + expect(ALL_HELP.length).toBeGreaterThan(250) + expect(ALL_HELP.length).toBeLessThan(600) + }) + + it('gives every entry a unique, kebab-case id', () => { + const seen = new Map() + for (const e of ALL_HELP) seen.set(e.id, (seen.get(e.id) ?? 0) + 1) + expect([...seen].filter(([, n]) => n > 1).map(([id]) => id)).toEqual([]) + expect(ALL_HELP.filter((e) => !/^[a-z][a-z0-9]*(-[a-z0-9]+)+$/.test(e.id)).map((e) => e.id)).toEqual([]) + }) + + it('prefixes an id by its shell, and an any-shell id by its tool', () => { + const anyPrefix: Partial> = { + git: /^git-/, + agents: /^(claude|codex)-/, + packages: /^pkg-/ + } + const wrong = ALL_HELP.filter((e) => + e.shell === 'any' ? !anyPrefix[e.category]?.test(e.id) : !e.id.startsWith(HELP_ID_PREFIXES[e.shell]) + ) + expect(wrong.map((e) => `${e.id} (${e.shell}/${e.category})`)).toEqual([]) + }) + + it('files every entry under a category the panel lists', () => { + const listed = new Set(HELP_CATEGORIES.map((c) => c.id)) + expect(ALL_HELP.filter((e) => !listed.has(e.category)).map((e) => e.id)).toEqual([]) + // ...and lists no category that is empty for every shell. + for (const c of HELP_CATEGORIES) expect(ALL_HELP.some((e) => e.category === c.id)).toBe(true) + }) + + it('writes the task as a short sentence with no full stop', () => { + const bad = ALL_HELP.filter( + (e) => e.task.length < 8 || e.task.length > 62 || /\.$/.test(e.task) || !/^["A-Z]/.test(e.task) + ) + expect(bad.map((e) => `${e.id}: ${e.task}`)).toEqual([]) + }) + + it('gives every entry a summary, and every variant a label and a command', () => { + expect(ALL_HELP.filter((e) => e.summary.trim().length < 20).map((e) => e.id)).toEqual([]) + const bad = ALL_HELP.filter((e) => (e.variants ?? []).some((v) => !v.label.trim() || !v.command.trim())) + expect(bad.map((e) => e.id)).toEqual([]) + }) + + it('keeps every command to one line of plain ASCII, trimmed, with no angle-bracket placeholder', () => { + const bad: string[] = [] + for (const e of ALL_HELP) + for (const c of commandsOf(e)) { + // A shell reads "" as redirection from a file called NAME. A lone + // "<" or ">" is real redirection and is allowed; a bracketed word is not. + // Printable ASCII only (which also rules out a line break): a curly quote + // or a non-breaking space looks right on screen and is a different + // command to a shell. + if (!c || c !== c.trim() || /[^\x20-\x7e]/.test(c) || /<[A-Za-z][A-Za-z0-9_ -]*>/.test(c)) + bad.push(`${e.id}: ${c}`) + } + expect(bad).toEqual([]) + }) + + it('has no em-dash anywhere, in the entries or in the files that hold them', () => { + const inEntries = ALL_HELP.filter((e) => textsOf(e).some((t) => t.includes(EM_DASH))) + expect(inEntries.map((e) => e.id)).toEqual([]) + const files = readdirSync(__dirname).filter((f) => f.endsWith('.ts')) + expect(files.filter((f) => readFileSync(join(__dirname, f), 'utf8').includes(EM_DASH))).toEqual([]) + }) + + it('gives every entry 6 to 16 keywords, lower case, none twice', () => { + const bad = ALL_HELP.filter( + (e) => + e.keywords.length < 6 || + e.keywords.length > 16 || + new Set(e.keywords).size !== e.keywords.length || + e.keywords.some((k) => !k.trim() || k !== k.trim() || k !== k.toLowerCase()) + ) + expect(bad.map((e) => `${e.id} (${e.keywords.length})`)).toEqual([]) + }) + + it('declares every placeholder a command uses, and uses every one it declares', () => { + const bad: string[] = [] + for (const e of ALL_HELP) { + const declared = Object.keys(e.placeholders ?? {}) + const used = new Set(commandsOf(e).flatMap(placeholdersIn)) + for (const name of declared) { + if (!/^[A-Z][A-Z0-9_]*$/.test(name)) bad.push(`${e.id}: "${name}" is not UPPER_SNAKE`) + else if (!commandsOf(e).some((c) => new RegExp(`(^|[^A-Za-z0-9_])${name}(?![A-Za-z0-9_])`).test(c))) + bad.push(`${e.id}: declares ${name}, which no command uses`) + if (!(e.placeholders?.[name] ?? '').trim()) bad.push(`${e.id}: ${name} has no explanation`) + } + for (const name of used) if (!declared.includes(name)) bad.push(`${e.id}: uses ${name}, undeclared`) + } + expect(bad).toEqual([]) + }) + + it('carries a danger line on everything that deletes, kills or overwrites', () => { + const silent = ALL_HELP.filter((e) => !e.danger?.trim() && destructiveHits(e).length > 0) + expect(silent.map((e) => `${e.id}: ${destructiveHits(e).join(', ')}`)).toEqual([]) + }) + + it('knows a destructive command when it sees one, and an innocent one too', () => { + const probe = (command: string): string[] => + destructiveHits({ id: 'x-y', shell: 'any', category: 'files', task: '', summary: '', command, keywords: [] }) + for (const c of [ + 'Remove-Item FILE', + 'rm -rf FOLDER', + 'sudo rm FILE', + 'find . -name "*.tmp" | xargs rm', + 'rmdir /s /q FOLDER', + 'del /q *.tmp', + 'format D:', + 'Stop-Process -Id 12', + 'taskkill /pid 12 /f', + 'kill -9 12', + 'git reset --hard HEAD~1', + 'git clean -fd', + 'git push --force-with-lease', + 'git checkout -- FILE', + 'git branch -D NAME', + 'dd if=/dev/zero of=/dev/sda', + 'mkfs.ext4 /dev/sdb1', + 'tree /f /a > tree.txt', + 'echo TEXT> NAME', + 'COMMAND *> FILE', + 'curl -L -o FILE URL', + 'curl -LO URL', + 'Invoke-WebRequest URL -OutFile FILE' + ]) + expect(probe(c), c).not.toEqual([]) + for (const c of [ + 'Get-ChildItem | Format-Table', + 'git log --format=%H', + 'git clean -nd', + 'Remove-Item FOLDER -Recurse -WhatIf', + 'Get-Help Remove-Item', + 'git branch -d NAME', + 'git restore --staged FILE', + 'sed -i.bak s/A/B/ FILE', + 'COMMAND >> FILE', + 'COMMAND 2>nul', + 'COMMAND *> $null', + 'COMMAND 2>/dev/null', + 'whoami /groups | findstr /c:"S-1-16-12288" >nul && echo elevated', + "COMMAND | awk '{print $2}'", + 'curl -I URL', + 'curl -s https://ifconfig.me' + ]) + expect(probe(c), c).toEqual([]) + }) + + it('writes every danger line as a sentence', () => { + const bad = ALL_HELP.filter((e) => e.danger !== undefined && (e.danger.trim().length < 15 || !/[.!]$/.test(e.danger.trim()))) + expect(bad.map((e) => e.id)).toEqual([]) + }) +}) + +describe('a key to press', () => { + it('is told from a command to paste', () => { + for (const k of ['Ctrl+C', 'Esc', 'Shift+Tab', 'Ctrl+D', 'Alt+V', 'Shift+Enter', 'Ctrl+J', 'F1']) expect(isKeyPress(k), k).toBe(true) + for (const c of ['cd ..', ':q!', '/compact', 'git status', 'Get-ChildItem', 'ls', 'Ctrl+C twice', '@PATH', 'q']) + expect(isKeyPress(c), c).toBe(false) + }) + + it('is what every key-shaped entry in the catalogue is read as, so none gets a copy button', () => { + const keys = ALL_HELP.flatMap(commandsOf).filter((c) => /^(Ctrl|Alt|Shift|Esc)\b/.test(c)) + expect(keys.length).toBeGreaterThan(5) + expect(keys.filter((c) => !isKeyPress(c))).toEqual([]) + }) +}) + +describe('the shells', () => { + it('reads a detected shell id as the language it speaks', () => { + expect(shellOfShellId('pwsh')).toBe('powershell') + expect(shellOfShellId('powershell')).toBe('powershell') + expect(shellOfShellId('cmd')).toBe('cmd') + expect(shellOfShellId('wsl-Ubuntu')).toBe('bash') + expect(shellOfShellId('wsl-Debian-12')).toBe('bash') + expect(shellOfShellId('git-bash')).toBe('bash') + // A shell that has gone falls back to PowerShell, as the spawn does. + expect(shellOfShellId('')).toBe('powershell') + expect(shellOfShellId(undefined)).toBe('powershell') + expect(shellOfShellId(null)).toBe('powershell') + }) + + it('offers each shell its own entries and every any-shell one, and no other shell', () => { + for (const s of HELP_SHELLS) { + const list = helpFor(s.id) + expect(list.length).toBeGreaterThan(100) + expect(list.every((e) => e.shell === s.id || e.shell === 'any')).toBe(true) + expect(list.filter((e) => e.category === 'git').length).toBeGreaterThan(30) + expect(list.filter((e) => e.category === 'agents').length).toBeGreaterThan(30) + } + }) +}) + +/** + * REAL QUESTIONS AGAINST THE REAL CATALOGUE. `search.test.ts` proves the + * ranking on a fixture; this proves the pairing, since a search can be right + * and the catalogue still lack the keyword that would have let it land. Each + * question names the entries that would be a sensible FIRST answer. + */ +const QUESTIONS: Readonly>> = { + powershell: [ + ['how do I find big files', ['ps-biggest-files']], + ['what is using port 3000', ['ps-port-in-use']], + ['undo last commit', ['git-undo-last-commit']], + ['where am i', ['ps-where-am-i']], + ['delete a folder', ['ps-delete-folder']], + ['grep', ['ps-search-in-files']], + ['go back a folder', ['ps-go-up-a-folder']], + ['kill a process', ['ps-stop-process']], + ['unzip a file', ['ps-unzip']], + ['see hidden files', ['ps-show-hidden-files']], + ['running scripts is disabled', ['ps-scripts-disabled']], + ['set an environment variable', ['ps-env-set-session']], + ['how much disk space is left', ['ps-disk-space']], + ['/compact', ['claude-compact']], + ['install a program', ['pkg-winget-install']], + ['how do i see whats in this folder', ['ps-list-files']], + ['what files are here', ['ps-list-files']], + ['ls -la', ['ps-list-files']], + ['newest files', ['ps-recent-files', 'ps-list-files']], + ['search for a word in all files', ['ps-search-in-files']], + ['direcotry size', ['ps-folder-size']], + ['close a frozen program', ['ps-stop-process']] + ], + cmd: [ + ['list files', ['cmd-list-files']], + ['how do I find big files', ['cmd-biggest-files']], + ['unzip a file', ['cmd-unzip']], + ['what is using port 3000', ['cmd-port-in-use']], + ['delete a folder', ['cmd-delete-folder']], + ['where am i', ['cmd-where-am-i']], + ['kill a process', ['cmd-kill-process']], + ['search for text in files', ['cmd-find-text-in-files']], + ['go back a folder', ['cmd-go-up']], + ['copy a folder', ['cmd-copy-folder']], + ['what is my ip address', ['cmd-ip-address']], + ['rename a file', ['cmd-rename']], + ['undo last commit', ['git-undo-last-commit']], + ['add to path', ['cmd-add-to-path']], + ['how do i see whats in this folder', ['cmd-list-files']], + ['show me the files here', ['cmd-list-files']], + ['newest files', ['cmd-list-files']], + ['copy output to clipboard', ['cmd-pipe']] + ], + bash: [ + ['how do I find big files', ['sh-biggest-files']], + ['what is using port 3000', ['sh-port-in-use']], + ['delete a folder', ['sh-delete-folder']], + ['permission denied when running a script', ['sh-make-executable']], + ['where am i', ['sh-where-am-i']], + ['grep', ['sh-grep-recursive']], + ['kill a process', ['sh-kill-process']], + ['extract a tar.gz', ['sh-tar-extract']], + ['install a package with apt', ['sh-apt-install']], + ['how much disk space is left', ['sh-disk-free']], + ['undo last commit', ['git-undo-last-commit']], + ['resume my last claude conversation', ['claude-continue', 'claude-resume']], + ['how do i see whats in this folder', ['sh-list-files']], + ['what files are here', ['sh-list-files']], + ['how do i exit vim', ['sh-edit-file']], + ['where are my windows files', ['sh-windows-drives']], + ['script says bad interpreter ^M', ['sh-line-endings']] + ] +} + +describe('real questions, real catalogue', () => { + it('names only entries that exist', () => { + const ids = new Set(ALL_HELP.map((e) => e.id)) + const missing = Object.values(QUESTIONS) + .flat() + .flatMap(([, want]) => want) + .filter((id) => !ids.has(id)) + expect([...new Set(missing)]).toEqual([]) + }) + + for (const shell of HELP_SHELLS) { + it(`finds a sensible first answer in ${shell.name}`, () => { + const wrong: string[] = [] + for (const [question, want] of QUESTIONS[shell.id]) { + const first = searchHelp(ALL_HELP, question, { shell: shell.id, limit: 3 })[0]?.entry.id + if (!first || !want.includes(first)) wrong.push(`"${question}" -> ${first ?? 'nothing'} (wanted ${want.join(' or ')})`) + } + expect(wrong).toEqual([]) + }) + } + + it('answers a keystroke quickly, on the whole catalogue', () => { + searchHelp(ALL_HELP, 'warm the index', { shell: 'powershell' }) + const t0 = performance.now() + for (let i = 0; i < 20; i += 1) searchHelp(ALL_HELP, 'how do I find the biggest files in a folder', { shell: 'powershell' }) + // Well under a millisecond on a desktop; the bound leaves a busy CI runner + // an order of magnitude of room and still catches a search gone quadratic. + expect((performance.now() - t0) / 20).toBeLessThan(25) + }) +}) diff --git a/core/shared/help/cmd.ts b/core/shared/help/cmd.ts new file mode 100644 index 0000000..a8e75e5 --- /dev/null +++ b/core/shared/help/cmd.ts @@ -0,0 +1,1212 @@ +/** + * THE HELP CATALOGUE, Command Prompt (#12). + * + * The everyday only: moving around, files and folders, finding text, what is + * running and on which port, the network basics, environment variables and the + * prompt's own grammar (redirection, pipes, chaining). Command Prompt is the + * shell a beginner meets in old tutorials, so the keywords lean on what the + * same thing is called in bash and PowerShell ("ls", "grep", "which"). + * + * HOW THESE WERE CHECKED (2026-09-20, stock cmd.exe on Windows 11 26200): every + * read-only command, and every file command inside a scratch folder made for + * the purpose, was RUN and its output read. taskkill was run against a ping + * this check had started itself. Three things were found that way and shaped + * the entries: 'fsutil volume diskfree' answers "Access is denied" without + * elevation, so free space is read off 'dir'; 'type nul > NAME' EMPTIES a file + * that already exists, so the entry appends instead; and robocopy's exit code 1 + * means "copied", not "failed". Not run, on purpose: setx (it writes the + * registry), the elevation prompt, the Environment Variables dialog, 'start' + * and 'clip' (they open windows or replace the clipboard). + */ + +import type { HelpEntry } from './types' + +export const CMD_HELP: readonly HelpEntry[] = [ + // ---------------------------------------------------------------- folders + { + id: 'cmd-where-am-i', + shell: 'cmd', + category: 'folders', + task: 'See which folder I am in', + summary: + 'cd on its own prints the current folder rather than changing it, which is the same thing the prompt shows before the >.', + command: 'cd', + variants: [ + { label: 'The same, as a variable you can use in another command', command: 'echo %cd%' } + ], + keywords: [ + 'where am i', + 'current folder', + 'current directory', + 'working directory', + 'pwd', + 'get-location', + 'path of this folder', + 'which folder' + ] + }, + { + id: 'cmd-change-folder', + shell: 'cmd', + category: 'folders', + task: 'Go to another folder', + summary: + 'Moves the prompt into a folder. Add /d whenever the folder may be on another drive: without it cd will not leave the drive you are on, and appears to do nothing.', + command: 'cd /d FOLDER', + variants: [ + { label: 'A folder inside this one', command: 'cd NAME' }, + { label: 'Your own user folder', command: 'cd /d "%USERPROFILE%"' }, + { label: 'Switch drive only', command: 'D:' } + ], + placeholders: { + FOLDER: 'The full path, in quotes if it has spaces: "D:\\My Projects\\site"', + NAME: 'The name of a folder inside the current one' + }, + keywords: [ + 'cd', + 'change directory', + 'change folder', + 'open folder', + 'go to folder', + 'navigate', + 'set-location', + 'switch drive', + 'cd does nothing', + 'home folder' + ] + }, + { + id: 'cmd-go-up', + shell: 'cmd', + category: 'folders', + task: 'Go back up a folder', + summary: 'Two dots mean "the folder that holds this one", so this steps out one level.', + command: 'cd ..', + variants: [ + { label: 'Up two levels', command: 'cd ..\\..' }, + { label: 'All the way to the top of the drive', command: 'cd \\' } + ], + keywords: [ + 'go back', + 'back a folder', + 'parent folder', + 'up one level', + 'previous folder', + 'cd ..', + 'dot dot', + 'leave folder', + 'root of drive' + ] + }, + { + id: 'cmd-make-folder', + shell: 'cmd', + category: 'folders', + task: 'Make a new folder', + summary: + 'Creates a folder, and any folders missing on the way to it, in one go. md is the same command.', + command: 'mkdir NAME', + variants: [{ label: 'Several levels at once', command: 'mkdir PARENT\\CHILD\\GRANDCHILD' }], + placeholders: { + NAME: 'The new folder, in quotes if it has spaces', + PARENT: 'The outer folder', + CHILD: 'A folder inside it', + GRANDCHILD: 'A folder inside that' + }, + keywords: [ + 'mkdir', + 'md', + 'create folder', + 'new folder', + 'make directory', + 'new directory', + 'new-item', + 'nested folders' + ] + }, + { + id: 'cmd-delete-folder', + shell: 'cmd', + category: 'folders', + task: 'Delete a folder and everything in it', + summary: + '/s takes the contents too and /q skips the "are you sure". Without /s, rmdir only removes a folder that is already empty and otherwise says "The directory is not empty".', + command: 'rmdir /s /q FOLDER', + variants: [{ label: 'Only if it is empty (the safe form)', command: 'rmdir FOLDER' }], + placeholders: { FOLDER: 'The folder to remove' }, + keywords: [ + 'rmdir', + 'rd', + 'remove folder', + 'delete directory', + 'rm -rf', + 'remove-item', + 'the directory is not empty', + 'delete node_modules', + 'wipe folder' + ], + danger: + 'The folder and everything inside it is deleted at once and does NOT go to the Recycle Bin. There is no undo.' + }, + { + id: 'cmd-tree', + shell: 'cmd', + category: 'folders', + task: 'Show the folder structure as a tree', + summary: 'Draws the folders under this one as a tree. /f lists the files as well.', + command: 'tree /f', + variants: [ + { label: 'Folders only', command: 'tree' }, + { label: 'Save it as plain text you can paste anywhere', command: 'tree /f /a > tree.txt' } + ], + danger: + 'The variant that saves to tree.txt replaces a file of that name if one is already there.', + keywords: [ + 'tree', + 'folder structure', + 'directory structure', + 'project layout', + 'show hierarchy', + 'list subfolders', + 'outline of folders' + ] + }, + { + id: 'cmd-folder-size', + shell: 'cmd', + category: 'folders', + task: 'See how big a folder is', + summary: + 'Lists everything under the folder, hidden files included. The answer is at the very END of the output: "Total Files Listed", with the file count and the bytes.', + command: 'dir /s /a FOLDER', + placeholders: { FOLDER: 'The folder to measure, or . for the current one' }, + keywords: [ + 'folder size', + 'directory size', + 'how big', + 'disk usage', + 'du -sh', + 'space used', + 'total size', + 'how much space' + ] + }, + { + id: 'cmd-biggest-files', + shell: 'cmd', + category: 'files', + task: 'Find the biggest files in a folder', + summary: + 'Walks this folder and everything under it and prints each file over a size you choose, in bytes, with its full path. 104857600 is 100 MB. It reads every file entry, so a big drive takes a while.', + command: 'forfiles /s /c "cmd /c if @isdir==FALSE if @fsize GEQ BYTES echo @fsize @path"', + variants: [ + { label: 'Just this folder, biggest first', command: 'dir /o-s /a-d' }, + { label: 'Everything over 1 GB', command: 'forfiles /s /c "cmd /c if @isdir==FALSE if @fsize GEQ 1073741824 echo @fsize @path"' } + ], + placeholders: { BYTES: 'The smallest size to report, in bytes: 104857600 is 100 MB' }, + keywords: [ + 'big files', + 'largest files', + 'large files', + 'what is taking up space', + 'disk full', + 'free up space', + 'huge files', + 'du', + 'sort by size', + 'file size' + ] + }, + { + id: 'cmd-unzip', + shell: 'cmd', + category: 'files', + task: 'Unzip a file, or make a zip', + summary: + 'Windows 10 and 11 ship tar, which reads and writes zip files as well as tar ones. Extracting puts the contents in the current folder and writes over files of the same name.', + command: 'tar -xf ARCHIVE.zip', + variants: [ + { label: 'Into another folder, which must already exist', command: 'tar -xf ARCHIVE.zip -C FOLDER' }, + { label: 'See what is inside without extracting', command: 'tar -tf ARCHIVE.zip' }, + { label: 'Make a zip of a folder', command: 'tar -a -c -f ARCHIVE.zip FOLDER' } + ], + placeholders: { + ARCHIVE: 'The zip file name without its .zip ending', + FOLDER: 'The folder to extract into, or to pack' + }, + keywords: [ + 'unzip', + 'zip', + 'extract', + 'extract archive', + 'expand-archive', + 'compress', + 'compressed folder', + 'tar', + 'open zip', + 'unpack' + ], + danger: 'Extracting replaces files of the same name in the folder it extracts into, without asking.' + }, + { + id: 'cmd-open-explorer', + shell: 'cmd', + category: 'folders', + task: 'Open this folder in File Explorer', + summary: + 'The dot means "here", so this opens an Explorer window on the folder the prompt is in.', + command: 'start .', + variants: [ + { label: 'The same, by another name', command: 'explorer .' }, + { label: 'Some other folder', command: 'start "" "FOLDER"' } + ], + placeholders: { FOLDER: 'The folder to open' }, + keywords: [ + 'explorer', + 'file explorer', + 'open folder', + 'open in explorer', + 'show in explorer', + 'windows explorer', + 'invoke-item', + 'ii .', + 'open here' + ] + }, + + // ------------------------------------------------------------------ files + { + id: 'cmd-list-files', + shell: 'cmd', + category: 'files', + task: 'List the files in this folder', + summary: + 'Shows what is in the current folder, with sizes and dates. Give it a folder to list that one instead.', + command: 'dir', + variants: [ + { label: 'Names only, one per line', command: 'dir /b' }, + { label: 'Newest first', command: 'dir /o-d' }, + { label: 'Biggest first', command: 'dir /o-s' }, + { label: 'Only one type of file', command: 'dir *.EXT' } + ], + placeholders: { EXT: 'The file extension, for example txt' }, + keywords: [ + 'dir', + 'ls', + 'list files', + 'show files', + 'see files', + 'files here', + 'what files are here', + 'whats in this folder', + 'what is in this folder', + 'list everything', + 'directory listing', + 'sort by date', + 'sort by size', + 'newest files' + ] + }, + { + id: 'cmd-hidden-files', + shell: 'cmd', + category: 'files', + task: 'See hidden files too', + summary: + 'Plain dir leaves hidden and system files out. /a lists everything, which is how you find .git, .env and the like when Windows has marked them hidden.', + command: 'dir /a', + variants: [ + { label: 'ONLY the hidden ones', command: 'dir /ah' }, + { label: 'Show each file with its hidden (H) and read-only (R) marks', command: 'attrib' } + ], + keywords: [ + 'hidden files', + 'show hidden', + 'ls -a', + 'dotfiles', + 'system files', + 'file missing from dir', + 'get-childitem -force', + 'attrib', + 'invisible files' + ] + }, + { + id: 'cmd-find-file-by-name', + shell: 'cmd', + category: 'files', + task: 'Find a file by name, in this folder and below', + summary: + '/s searches every subfolder and /b prints just the full path of each match. The stars mean "anything", so part of the name is enough.', + command: 'dir /s /b *NAME*', + variants: [{ label: 'Hidden files as well', command: 'dir /s /b /a *NAME*' }], + placeholders: { NAME: 'Any part of the file name' }, + keywords: [ + 'find file', + 'search for file', + 'locate file', + 'where is my file', + 'find by name', + 'recursive search', + 'find .', + 'get-childitem -recurse', + 'wildcard' + ] + }, + { + id: 'cmd-read-file', + shell: 'cmd', + category: 'files', + task: 'Read a text file in the terminal', + summary: + 'Prints the whole file. For a long one use more, which shows a screen at a time: Space for the next, Q to stop.', + command: 'type FILE', + variants: [{ label: 'A screen at a time', command: 'more FILE' }], + placeholders: { FILE: 'The file to read' }, + keywords: [ + 'type', + 'cat', + 'read file', + 'show file', + 'print file', + 'view contents', + 'get-content', + 'open text file', + 'more', + 'less' + ] + }, + { + id: 'cmd-make-file', + shell: 'cmd', + category: 'files', + task: 'Make a new empty file', + summary: + 'Creates the file if it is missing and leaves it untouched if it is already there (the double >> appends nothing, where a single > would empty it).', + command: 'type nul >> NAME', + variants: [ + { + label: 'A file with one line of text in it (REPLACES the file if it exists)', + command: 'echo TEXT> NAME' + } + ], + placeholders: { + NAME: 'The file to create, for example notes.txt', + TEXT: 'The first line of the file' + }, + danger: + 'The echo variant replaces everything in the file if it already exists. The main command never does.', + keywords: [ + 'touch', + 'create file', + 'new file', + 'make file', + 'empty file', + 'new-item', + 'blank file', + 'create .env' + ] + }, + { + id: 'cmd-copy-file', + shell: 'cmd', + category: 'files', + task: 'Copy a file', + summary: + 'Copies one file to a new name or into a folder. Typed at the prompt it asks before replacing a file of the same name; add /y to stop it asking.', + command: 'copy SOURCE DESTINATION', + placeholders: { + SOURCE: 'The file to copy', + DESTINATION: 'A folder to copy it into, or the new file name' + }, + keywords: [ + 'copy', + 'cp', + 'duplicate file', + 'copy-item', + 'make a copy', + 'back up a file', + 'copy to folder' + ] + }, + { + id: 'cmd-copy-folder', + shell: 'cmd', + category: 'files', + task: 'Copy a whole folder', + summary: + 'copy does not do folders; robocopy does. /e brings every subfolder, empty ones included. It prints a summary table, and an exit code of 1 means "files were copied", not an error.', + command: 'robocopy SOURCE DESTINATION /e', + variants: [{ label: 'The older tool, same job', command: 'xcopy SOURCE DESTINATION /e /i' }], + placeholders: { + SOURCE: 'The folder to copy', + DESTINATION: 'Where the copy goes (it is created if missing)' + }, + keywords: [ + 'robocopy', + 'xcopy', + 'copy folder', + 'copy directory', + 'cp -r', + 'copy recursive', + 'back up folder', + 'copy-item -recurse', + 'duplicate folder' + ], + danger: + 'Files already in the destination with the same name are replaced by the source version without asking.' + }, + { + id: 'cmd-move-file', + shell: 'cmd', + category: 'files', + task: 'Move a file or folder somewhere else', + summary: + 'Moves it rather than copying. Typed at the prompt it asks before replacing a file of the same name.', + command: 'move SOURCE DESTINATION', + placeholders: { + SOURCE: 'The file or folder to move', + DESTINATION: 'The folder it should end up in' + }, + keywords: [ + 'move', + 'mv', + 'move-item', + 'relocate', + 'put file in folder', + 'cut and paste', + 'move folder' + ] + }, + { + id: 'cmd-rename', + shell: 'cmd', + category: 'files', + task: 'Rename a file or folder', + summary: 'The new name is a name only, not a path: ren renames in place and never moves.', + command: 'ren OLDNAME NEWNAME', + variants: [ + { label: 'Change the extension of many files at once', command: 'ren *.OLDEXT *.NEWEXT' } + ], + placeholders: { + OLDNAME: 'The file or folder as it is now', + NEWNAME: 'The new name, without a folder in front', + OLDEXT: 'The extension they have, for example txt', + NEWEXT: 'The extension they should get, for example md' + }, + keywords: [ + 'ren', + 'rename', + 'rename-item', + 'mv', + 'change file name', + 'change extension', + 'bulk rename' + ] + }, + { + id: 'cmd-delete-file', + shell: 'cmd', + category: 'files', + task: 'Delete a file', + summary: + 'Removes the file. A wildcard removes every match, so list them with dir first when you use one.', + command: 'del FILE', + variants: [ + { label: 'Every file of one type here, without asking', command: 'del /q *.EXT' }, + { label: 'That type in every subfolder too', command: 'del /s /q *.EXT' } + ], + placeholders: { FILE: 'The file to delete', EXT: 'The file extension, for example tmp' }, + keywords: [ + 'del', + 'erase', + 'rm', + 'delete file', + 'remove file', + 'remove-item', + 'get rid of file', + 'delete all tmp' + ], + danger: 'Deleted files do NOT go to the Recycle Bin. They are gone.' + }, + { + id: 'cmd-open-file', + shell: 'cmd', + category: 'files', + task: 'Open a file in its usual program', + summary: + 'Does what a double-click does. The empty "" is not a mistake: start takes its first quoted word as a window title, so without it a quoted path opens nothing.', + command: 'start "" "FILE"', + variants: [{ label: 'Open a web page in the browser', command: 'start "" "https://URL"' }], + placeholders: { FILE: 'The file to open', URL: 'The address, without the https:// in front' }, + keywords: [ + 'open file', + 'start', + 'double click', + 'default program', + 'launch', + 'invoke-item', + 'open in browser', + 'open url', + 'xdg-open' + ] + }, + + // ------------------------------------------------------------------- text + { + id: 'cmd-find-text-in-files', + shell: 'cmd', + category: 'text', + task: 'Find text inside files', + summary: + 'Searches every file here and in every subfolder. /i ignores upper and lower case, /n shows the line number, and /c: keeps a phrase with spaces together.', + command: 'findstr /s /i /n /c:"TEXT" *.*', + variants: [ + { + label: 'Just the names of the files that contain it', + command: 'findstr /s /i /m /c:"TEXT" *.*' + }, + { label: 'Only in one type of file', command: 'findstr /s /i /n /c:"TEXT" *.EXT' }, + { label: 'In a single file', command: 'findstr /i /n /c:"TEXT" FILE' } + ], + placeholders: { + TEXT: 'The words to look for', + EXT: 'The file extension, for example js', + FILE: 'The file to search' + }, + keywords: [ + 'findstr', + 'grep', + 'grep -r', + 'search in files', + 'find text', + 'find string', + 'select-string', + 'search code', + 'which file contains', + 'find word' + ] + }, + { + id: 'cmd-filter-output', + shell: 'cmd', + category: 'text', + task: 'Show only the lines of output that mention something', + summary: 'Pipes a command into findstr so a long list is cut down to the lines you care about.', + command: 'COMMAND | findstr /i "TEXT"', + variants: [ + { label: 'The lines that do NOT mention it', command: 'COMMAND | findstr /i /v "TEXT"' } + ], + placeholders: { + COMMAND: 'Any command that prints a lot, for example tasklist', + TEXT: 'The word to keep' + }, + keywords: [ + 'filter output', + 'grep', + 'pipe to findstr', + 'search output', + 'where-object', + 'narrow down', + 'only matching lines', + 'too much output' + ] + }, + { + id: 'cmd-compare-files', + shell: 'cmd', + category: 'text', + task: 'Compare two files', + summary: + 'Prints the lines that differ, or "no differences encountered" when the two are the same.', + command: 'fc FILE1 FILE2', + variants: [ + { label: 'Byte by byte, for files that are not text', command: 'fc /b FILE1 FILE2' } + ], + placeholders: { FILE1: 'The first file', FILE2: 'The second file' }, + keywords: [ + 'fc', + 'diff', + 'compare files', + 'compare-object', + 'what changed', + 'are these files the same', + 'differences' + ] + }, + + // -------------------------------------------------------------- processes + { + id: 'cmd-list-processes', + shell: 'cmd', + category: 'processes', + task: 'See what is running', + summary: + 'Lists every running program with its PID (the number you need to stop it) and its memory use.', + command: 'tasklist', + variants: [ + { label: 'Only one program', command: 'tasklist /fi "imagename eq NAME.exe"' }, + { label: 'Anything whose name contains a word', command: 'tasklist | findstr /i "NAME"' } + ], + placeholders: { NAME: 'The program, for example node' }, + keywords: [ + 'tasklist', + 'ps', + 'ps aux', + 'running programs', + 'processes', + 'get-process', + 'task manager', + 'is node running', + 'pid' + ] + }, + { + id: 'cmd-kill-process', + shell: 'cmd', + category: 'processes', + task: 'Force a program to stop', + summary: + '/f forces it and /im picks it by name, which stops EVERY copy of that program. Use the PID form to stop just one.', + command: 'taskkill /im NAME.exe /f', + variants: [ + { label: 'Just one, by its PID', command: 'taskkill /pid PID /f' }, + { label: 'It and everything it started', command: 'taskkill /pid PID /t /f' } + ], + placeholders: { + NAME: 'The program, for example node', + PID: 'The number from tasklist or netstat' + }, + keywords: [ + 'taskkill', + 'kill', + 'kill process', + 'stop program', + 'end task', + 'stop-process', + 'frozen', + 'not responding', + 'kill node', + 'pkill' + ], + danger: + 'The program is stopped at once, without the chance to save. Unsaved work in it is lost.' + }, + { + id: 'cmd-port-in-use', + shell: 'cmd', + category: 'processes', + task: 'Find what is using a port', + summary: + 'The LAST column of the matching line is the PID of the program holding the port. It also matches longer numbers that start the same (3000 finds 30001), so read the line.', + command: 'netstat -ano | findstr :PORT', + variants: [ + { label: 'Then: which program has that PID', command: 'tasklist /fi "pid eq PID"' }, + { label: 'Every port something is listening on', command: 'netstat -ano | findstr LISTENING' } + ], + placeholders: { + PORT: 'The port number, for example 3000', + PID: 'The number from the last column' + }, + keywords: [ + 'port in use', + 'port already in use', + 'eaddrinuse', + 'address already in use', + 'what is using port 3000', + 'netstat', + 'lsof -i', + 'listening ports', + 'get-nettcpconnection', + 'free a port' + ] + }, + { + id: 'cmd-cancel-command', + shell: 'cmd', + category: 'processes', + task: 'Stop a command that is still running', + summary: + 'Not a command but a key: hold Ctrl and press C in the terminal. A batch file then asks "Terminate batch job (Y/N)?", to which the answer is Y. With text selected, Ctrl+C copies instead, so click once to clear the selection first.', + command: 'Ctrl+C', + variants: [ + { + label: 'If it ignores the key: stop the program from another window', + command: 'taskkill /im NAME.exe /f' + } + ], + placeholders: { NAME: 'The program that will not stop, for example node' }, + danger: + 'The taskkill variant stops every copy of that program at once, without the chance to save.', + keywords: [ + 'cancel', + 'stop', + 'abort', + 'interrupt', + 'ctrl c', + 'how do i stop this', + 'stuck', + 'hung', + 'wont stop', + 'terminate batch job', + 'exit running command' + ] + }, + + // ---------------------------------------------------------------- network + { + id: 'cmd-ip-address', + shell: 'cmd', + category: 'network', + task: 'Find my IP address', + summary: + "Shows this computer's address on your own network (the IPv4 line), not the one the internet sees.", + command: 'ipconfig', + variants: [ + { label: 'Just the address lines', command: 'ipconfig | findstr IPv4' }, + { label: 'Everything: DNS servers, MAC address, DHCP', command: 'ipconfig /all' } + ], + keywords: [ + 'ipconfig', + 'ip address', + 'my ip', + 'local ip', + 'ifconfig', + 'ip addr', + 'network settings', + 'mac address', + 'gateway', + 'dns server' + ] + }, + { + id: 'cmd-ping', + shell: 'cmd', + category: 'network', + task: 'Check whether a site or machine can be reached', + summary: + 'Sends four small packets and reports whether they came back and how fast. "Request timed out" means no answer.', + command: 'ping HOST', + variants: [ + { label: 'Look the name up in DNS', command: 'nslookup HOST' }, + { label: 'Show each hop on the way there (slow)', command: 'tracert HOST' } + ], + placeholders: { HOST: 'A name or an address, for example github.com' }, + keywords: [ + 'ping', + 'is the internet working', + 'connection test', + 'test-connection', + 'nslookup', + 'dns lookup', + 'tracert', + 'traceroute', + 'site down', + 'no internet' + ] + }, + { + id: 'cmd-download', + shell: 'cmd', + category: 'network', + task: 'Download a file from a URL', + summary: + 'curl ships with Windows. -L follows redirects (most download links have one) and -o names the file to save.', + command: 'curl -L -o FILE URL', + variants: [ + { label: 'Only check that a site answers (headers, no download)', command: 'curl -I URL' } + ], + placeholders: { + FILE: 'What to call the saved file', + URL: 'The full address, starting https://' + }, + danger: 'A file of the same name is replaced without a question.', + keywords: [ + 'curl', + 'download', + 'wget', + 'fetch url', + 'invoke-webrequest', + 'save file from internet', + 'http request', + 'check website' + ] + }, + + // ----------------------------------------------------------------- system + { + id: 'cmd-env-show', + shell: 'cmd', + category: 'system', + task: 'See my environment variables', + summary: + 'set on its own lists them all. Followed by a few letters it lists the ones whose names start that way.', + command: 'set', + variants: [ + { label: 'The ones starting with some letters', command: 'set NAME' }, + { label: 'The value of one', command: 'echo %NAME%' } + ], + placeholders: { NAME: 'The variable, for example PATH or ANTHROPIC' }, + keywords: [ + 'environment variables', + 'env', + 'printenv', + 'show variable', + 'set', + '$env:', + 'is my api key set', + 'echo variable', + 'get-childitem env:' + ] + }, + { + id: 'cmd-env-set', + shell: 'cmd', + category: 'system', + task: 'Set an environment variable', + summary: + 'set lasts for this window only and works at once. setx saves it for good, but only windows opened AFTERWARDS see it: this window does not.', + command: 'set NAME=VALUE', + variants: [{ label: 'Permanently, for new windows', command: 'setx NAME "VALUE"' }], + placeholders: { + NAME: 'The variable name', + VALUE: 'Its value; no spaces around the = and no quotes with set' + }, + keywords: [ + 'set environment variable', + 'export', + 'setx', + 'api key', + 'env var', + '$env:', + 'permanent variable', + 'variable not found after setx', + 'setenvironmentvariable' + ], + danger: + 'setx replaces whatever value the variable already had, and cuts a value longer than 1024 characters short.' + }, + { + id: 'cmd-add-to-path', + shell: 'cmd', + category: 'system', + task: 'Add a folder to PATH', + summary: + 'This adds it for the current window only, which is safe. To add it for good, use the Windows dialog in the variant: doing it with setx is a known trap that can cut PATH short.', + command: 'set PATH=%PATH%;FOLDER', + variants: [ + { + label: 'Open the Environment Variables dialog to add it permanently', + command: 'rundll32 sysdm.cpl,EditEnvironmentVariables' + }, + { label: 'See what is on PATH now', command: 'path' } + ], + placeholders: { FOLDER: 'The folder that holds the program, without quotes' }, + keywords: [ + 'path', + 'add to path', + 'is not recognized as an internal or external command', + 'command not found', + 'export path', + 'environment variables dialog', + 'edit path', + '$env:path' + ] + }, + { + id: 'cmd-where', + shell: 'cmd', + category: 'system', + task: 'Find where a program is installed', + summary: + 'Prints the full path of what would run if you typed that name, every match on PATH in order. "Could not find files" means it is not on PATH.', + command: 'where NAME', + placeholders: { NAME: 'The command, for example node or git' }, + keywords: [ + 'where', + 'which', + 'get-command', + 'find program', + 'is it installed', + 'which version runs', + 'path of executable', + 'command location', + 'not recognized' + ] + }, + { + id: 'cmd-system-info', + shell: 'cmd', + category: 'system', + task: 'See my Windows version and machine details', + summary: + 'A full page on the machine: Windows edition and build, memory, uptime. It takes a few seconds.', + command: 'systeminfo', + variants: [ + { label: 'Just the Windows version', command: 'ver' }, + { label: "This computer's name", command: 'hostname' }, + { label: 'Which user I am signed in as', command: 'whoami' } + ], + keywords: [ + 'systeminfo', + 'windows version', + 'ver', + 'uname', + 'how much ram', + 'memory', + 'computer name', + 'hostname', + 'whoami', + 'get-computerinfo', + 'uptime' + ] + }, + { + id: 'cmd-disk-space', + shell: 'cmd', + category: 'system', + task: 'See how much disk space is free', + summary: + 'The LAST line of a dir says how many bytes are free on that drive. Change the letter for another drive.', + command: 'dir C:\\', + keywords: [ + 'disk space', + 'free space', + 'df -h', + 'drive full', + 'how much space left', + 'get-psdrive', + 'get-volume', + 'storage' + ] + }, + { + id: 'cmd-run-as-admin', + shell: 'cmd', + category: 'system', + task: 'Open a Command Prompt as administrator', + summary: + 'For when a command answers "Access is denied" or "requires elevation". Windows asks for permission, then opens a NEW window with full rights; this one stays as it was.', + command: 'powershell -Command "Start-Process cmd -Verb RunAs"', + variants: [ + { + label: 'Check whether THIS window is already elevated', + command: + 'whoami /groups | findstr /c:"S-1-16-12288" >nul && echo elevated || echo not elevated' + } + ], + keywords: [ + 'run as admin', + 'administrator', + 'elevated', + 'access is denied', + 'access denied', + 'sudo', + 'requires elevation', + 'permission denied', + 'uac', + 'runas' + ] + }, + + // ------------------------------------------------------------------ shell + { + id: 'cmd-clear', + shell: 'cmd', + category: 'shell', + task: 'Clear the screen', + summary: 'Wipes what is on screen. It does not touch your files or your command history.', + command: 'cls', + keywords: [ + 'cls', + 'clear', + 'clear screen', + 'clear-host', + 'ctrl l', + 'wipe terminal', + 'clean up screen', + 'too much text' + ] + }, + { + id: 'cmd-history', + shell: 'cmd', + category: 'shell', + task: 'See the commands I have typed', + summary: + 'Lists what was typed in THIS window. Command Prompt keeps no history between windows. The Up arrow walks back through the same list, and F7 shows it as a menu.', + command: 'doskey /history', + variants: [ + { label: 'Save it to a file before closing', command: 'doskey /history > history.txt' } + ], + danger: + 'The variant that saves to history.txt replaces a file of that name if one is already there.', + keywords: [ + 'history', + 'doskey', + 'previous commands', + 'what did i type', + 'get-history', + 'command log', + 'f7', + 'up arrow', + 'recall command' + ] + }, + { + id: 'cmd-help', + shell: 'cmd', + category: 'shell', + task: 'Get help on a command', + summary: + 'Nearly every Command Prompt command explains itself when given /?. help on its own lists the built-in ones.', + command: 'COMMAND /?', + variants: [ + { label: 'The list of built-in commands', command: 'help' }, + { label: 'A long help page, a screen at a time', command: 'COMMAND /? | more' } + ], + placeholders: { COMMAND: 'The command to read about, for example robocopy' }, + keywords: [ + 'help', + 'man', + '--help', + 'get-help', + 'what does this command do', + 'options', + 'switches', + 'flags', + 'usage', + 'manual' + ] + }, + { + id: 'cmd-echo', + shell: 'cmd', + category: 'shell', + task: 'Print some text or the value of a variable', + summary: + 'echo repeats what follows it, with any %VARIABLE% filled in. Handy for checking a value before using it.', + command: 'echo TEXT', + variants: [ + { label: 'An empty line', command: 'echo.' }, + { label: "Today's date and the time", command: 'echo %DATE% %TIME%' } + ], + placeholders: { TEXT: 'What to print' }, + keywords: [ + 'echo', + 'print', + 'write-output', + 'write-host', + 'show text', + 'print variable', + 'date', + 'time', + 'blank line' + ] + }, + { + id: 'cmd-redirect', + shell: 'cmd', + category: 'shell', + task: 'Save the output of a command to a file', + summary: + 'A single > writes a fresh file each time; a double >> adds to the end of what is there.', + command: 'COMMAND > FILE', + variants: [ + { label: 'Add to the end of the file instead', command: 'COMMAND >> FILE' }, + { label: 'Error messages too', command: 'COMMAND > FILE 2>&1' }, + { label: 'Throw the error messages away', command: 'COMMAND 2>nul' } + ], + placeholders: { COMMAND: 'Any command', FILE: 'The file to write, for example output.txt' }, + keywords: [ + 'redirect', + 'redirection', + 'save output', + 'write to file', + 'log to file', + 'out-file', + 'append', + 'stderr', + '2>&1', + 'hide errors', + '/dev/null' + ], + danger: 'A single > replaces everything the file held before. Use >> to keep it.' + }, + { + id: 'cmd-pipe', + shell: 'cmd', + category: 'shell', + task: 'Send the output of one command into another', + summary: + 'The bar hands what the first command prints to the second. more pages it, sort orders it, clip puts it on the clipboard.', + command: 'COMMAND | more', + variants: [ + { label: 'Copy the output to the clipboard', command: 'COMMAND | clip' }, + { label: 'Sort the lines', command: 'COMMAND | sort' } + ], + placeholders: { COMMAND: 'Any command' }, + keywords: [ + 'pipe', + 'pipeline', + 'vertical bar', + 'more', + 'less', + 'page output', + 'clip', + 'copy output to clipboard', + 'set-clipboard', + 'pbcopy', + 'sort output' + ] + }, + { + id: 'cmd-several-commands', + shell: 'cmd', + category: 'shell', + task: 'Run several commands on one line', + summary: + '&& runs the second command only if the first one worked, which is nearly always what you want.', + command: 'FIRST && SECOND', + variants: [ + { label: 'Run the second whatever happens', command: 'FIRST & SECOND' }, + { label: 'Run the second only if the first FAILED', command: 'FIRST || SECOND' } + ], + placeholders: { FIRST: 'The first command', SECOND: 'The command to run after it' }, + keywords: [ + 'chain commands', + 'multiple commands', + 'one line', + 'and and', + 'ampersand', + 'semicolon', + 'run after', + 'if it fails', + 'then run' + ] + }, + { + id: 'cmd-alias', + shell: 'cmd', + category: 'shell', + task: 'Make a short name for a long command', + summary: + 'A doskey macro. $* passes along whatever you type after the short name. It lasts until this window closes.', + command: 'doskey SHORT=COMMAND $*', + variants: [{ label: 'List the macros I have made', command: 'doskey /macros' }], + placeholders: { + SHORT: 'The name to type, for example ll', + COMMAND: 'What it should run, for example dir /a' + }, + keywords: [ + 'alias', + 'doskey', + 'macro', + 'shortcut command', + 'set-alias', + 'abbreviation', + 'custom command', + 'nickname' + ] + } +] diff --git a/core/shared/help/git.ts b/core/shared/help/git.ts new file mode 100644 index 0000000..ba1ecdf --- /dev/null +++ b/core/shared/help/git.ts @@ -0,0 +1,1029 @@ +/** + * THE HELP PANEL'S CATALOGUE: git (#12). + * + * Git is the same text in PowerShell, cmd and bash, so every entry here is + * shell 'any'. That is a promise, and it shaped the wording: no `&&` (Windows + * PowerShell 5.1 has no such operator), no `HEAD^` (cmd reads the caret as its + * escape character, so `HEAD~1` is used throughout), and no `stash@{0}` or + * `@{u}` (PowerShell reads `@{` as the start of a hashtable). + * + * HOW THESE WERE CHECKED (2026-09-20, git 2.55 for Windows): every command + * and variant below was run in a throwaway repository with a throwaway bare + * remote and its own GIT_CONFIG_GLOBAL, the destructive ones included, since + * nothing in that folder mattered. The quoting-sensitive ones (-S "TEXT", + * --grep="TEXT", main...HEAD, -m "MESSAGE") were run again under PowerShell + * 7.6 and Windows PowerShell 5.1. `git merge --continue` and `git cherry-pick` + * were not exercised to completion; they are long-standing git and are listed + * on that strength. + * + * The newer verbs (`switch`, `restore`, git 2.23 and later) lead, because + * they each do one thing; the `checkout` spelling is kept as a variant, since + * that is what most tutorials and most agents still type. + */ +import type { HelpEntry } from './types' + +export const GIT_HELP: readonly HelpEntry[] = [ + // ---- Looking before you touch anything ---- + { + id: 'git-status', + shell: 'any', + category: 'git', + task: 'See what has changed', + summary: + 'Lists the files you have changed, which of them are staged for the next commit, and which branch you are on. Safe to run at any time, and the first thing to run when unsure.', + command: 'git status', + variants: [ + { label: 'Short form, one line per file', command: 'git status --short' }, + { label: 'Short form, plus how far ahead or behind the remote you are', command: 'git status -sb' } + ], + keywords: [ + 'what changed', + 'changed files', + 'modified', + 'untracked', + 'staged', + 'which branch am i on', + 'state of the repo', + 'anything to commit', + 'status', + 'where am i in git' + ] + }, + { + id: 'git-diff', + shell: 'any', + category: 'git', + task: 'See the exact lines you changed', + summary: + 'Shows your uncommitted edits line by line: red lines were removed, green lines were added. Press q to leave the viewer.', + command: 'git diff', + variants: [ + { label: 'Only what is already staged', command: 'git diff --staged' }, + { label: 'Only one file', command: 'git diff FILE' }, + { label: 'Just the file names and line counts', command: 'git diff --stat' } + ], + placeholders: { FILE: 'The path of the file to look at' }, + keywords: [ + 'diff', + 'what did i change', + 'show changes', + 'line by line', + 'compare', + 'before commit', + 'staged changes', + 'cached', + 'review my edits', + 'how do i get out of the diff' + ] + }, + { + id: 'git-log', + shell: 'any', + category: 'git', + task: 'See the history of commits', + summary: + 'Lists recent commits, one line each, with the branches drawn as a graph. Press q to leave the viewer.', + command: 'git log --oneline --graph --decorate -n 20', + variants: [ + { label: 'Full messages, authors and dates', command: 'git log -n 10' }, + { label: 'Only commits by one person', command: 'git log --author="NAME" --oneline' }, + { label: 'Only commits whose message mentions a word', command: 'git log --grep="TEXT" --oneline' } + ], + placeholders: { NAME: "Part of the author's name or email", TEXT: 'A word from the commit message' }, + keywords: [ + 'history', + 'log', + 'past commits', + 'recent commits', + 'what happened', + 'timeline', + 'graph', + 'who committed', + 'commit list', + 'stuck in log press q' + ] + }, + { + id: 'git-show', + shell: 'any', + category: 'git', + task: 'See what one commit changed', + summary: + 'Shows the message and the full diff of a single commit. Take the COMMIT id from git log; HEAD means the latest one.', + command: 'git show COMMIT', + variants: [ + { label: 'The latest commit', command: 'git show HEAD' }, + { label: 'Only which files it touched', command: 'git show COMMIT --stat' } + ], + placeholders: { COMMIT: 'A commit id from git log (the first 7 characters are enough)' }, + keywords: [ + 'show commit', + 'what did this commit do', + 'inspect commit', + 'commit details', + 'commit diff', + 'hash', + 'sha', + 'last commit' + ] + }, + { + id: 'git-file-history', + shell: 'any', + category: 'git', + task: 'See the history of one file', + summary: 'Lists only the commits that touched one file, newest first.', + command: 'git log --oneline -- FILE', + variants: [ + { label: 'With the lines each commit changed in it', command: 'git log -p -- FILE' }, + { label: 'Follow the file through renames', command: 'git log --oneline --follow -- FILE' } + ], + placeholders: { FILE: 'The path of the file' }, + keywords: [ + 'file history', + 'when did this file change', + 'changes to one file', + 'log for a file', + 'who touched this file', + 'renamed file history', + 'follow' + ] + }, + { + id: 'git-blame', + shell: 'any', + category: 'git', + task: 'Find out who changed a line, and when', + summary: + 'Prints the file with the commit, author and date that last changed each line. Follow up with git show COMMIT to see why.', + command: 'git blame FILE', + variants: [{ label: 'Only a range of lines (here 10 to 20)', command: 'git blame -L 10,20 FILE' }], + placeholders: { FILE: 'The path of the file' }, + keywords: [ + 'blame', + 'who wrote this', + 'who changed this line', + 'author of a line', + 'annotate', + 'when was this changed', + 'last modified by', + 'praise' + ] + }, + { + id: 'git-search-history', + shell: 'any', + category: 'git', + task: 'Find the commit that added or removed some text', + summary: + 'Searches the whole history for commits where the number of times TEXT appears changed, which is how you find where a function or a setting came from or went.', + command: 'git log -S "TEXT" --oneline', + variants: [{ label: 'Search commit messages instead', command: 'git log --grep="TEXT" --oneline' }], + placeholders: { TEXT: 'The exact text to look for' }, + keywords: [ + 'search history', + 'when was this added', + 'when was this deleted', + 'find in old commits', + 'pickaxe', + 'where did this code go', + 'grep history', + 'find commit' + ] + }, + { + id: 'git-compare-main', + shell: 'any', + category: 'git', + task: 'Compare your branch with main', + summary: + 'Shows everything your branch has changed since it left main, which is what a pull request would contain. The three dots matter: two dots would also show what main gained meanwhile.', + command: 'git diff main...HEAD', + variants: [ + { label: 'Just the file names and line counts', command: 'git diff --stat main...HEAD' }, + { label: 'The commits on your branch that main does not have', command: 'git log main..HEAD --oneline' }, + { label: 'Compare the files as they are right now, uncommitted edits included', command: 'git diff main' } + ], + keywords: [ + 'compare branches', + 'diff with main', + 'diff against master', + 'what is in my branch', + 'pull request contents', + 'pr diff', + 'three dots', + 'ahead of main', + 'branch changes' + ] + }, + { + id: 'git-unpushed', + shell: 'any', + category: 'git', + task: 'See which commits you have not pushed yet', + summary: 'Lists commits that exist on your machine and on no remote, so you know what a push would send.', + command: 'git log --branches --not --remotes --oneline', + variants: [{ label: 'Just the count, as "ahead N" beside the branch name', command: 'git status -sb' }], + keywords: [ + 'unpushed', + 'not pushed', + 'ahead of origin', + 'local commits', + 'what will push send', + 'did i push', + 'outgoing' + ] + }, + + // ---- The everyday loop: stage, commit, sync ---- + { + id: 'git-add', + shell: 'any', + category: 'git', + task: 'Stage files for the next commit', + summary: + 'Staging is choosing what goes into the next commit. Stage one file, or everything; nothing is saved to history until you commit.', + command: 'git add FILE', + variants: [ + { label: 'Everything in this folder and below', command: 'git add .' }, + { label: 'Every change in the whole repository, deletions included', command: 'git add -A' }, + { label: 'Choose piece by piece within a file (y to take, n to skip, q to stop)', command: 'git add -p' } + ], + placeholders: { FILE: 'The path of the file to stage' }, + keywords: [ + 'stage', + 'add', + 'add files', + 'track a new file', + 'include in commit', + 'index', + 'untracked file', + 'add everything', + 'partial', + 'changes not staged for commit' + ] + }, + { + id: 'git-unstage', + shell: 'any', + category: 'git', + task: 'Unstage a file (keep the edits)', + summary: 'Takes a file back out of the next commit. Your edits stay exactly as they are; only the staging is undone.', + command: 'git restore --staged FILE', + variants: [ + { label: 'Unstage everything', command: 'git restore --staged .' }, + { label: 'The older spelling of the same thing', command: 'git reset HEAD FILE' } + ], + placeholders: { FILE: 'The path of the file to unstage' }, + keywords: [ + 'unstage', + 'undo add', + 'remove from commit', + 'added the wrong file', + 'un-add', + 'reset file', + 'take out of staging', + 'git add undo' + ] + }, + { + id: 'git-commit', + shell: 'any', + category: 'git', + task: 'Commit your staged changes', + summary: + 'Saves what you staged as one point in history, with a message saying what it does. It stays on your machine until you push.', + command: 'git commit -m "MESSAGE"', + variants: [ + { label: 'Stage every already-tracked file and commit in one go (new files are not included)', command: 'git commit -am "MESSAGE"' }, + { label: 'Write a longer message in your editor', command: 'git commit' } + ], + placeholders: { MESSAGE: 'A short sentence describing the change' }, + keywords: [ + 'commit', + 'save changes', + 'save my work', + 'snapshot', + 'checkpoint', + 'commit message', + 'record changes', + 'nothing added to commit', + 'check in' + ] + }, + { + id: 'git-amend', + shell: 'any', + category: 'git', + task: 'Fix the last commit message', + summary: + 'Replaces the message of the most recent commit. Do it before pushing: an amended commit is a new commit, so one that was already pushed needs a force push afterwards.', + command: 'git commit --amend -m "NEW_MESSAGE"', + variants: [ + { label: 'Add a forgotten file to the last commit, message unchanged (stage it first)', command: 'git commit --amend --no-edit' } + ], + placeholders: { NEW_MESSAGE: 'The corrected commit message' }, + keywords: [ + 'amend', + 'typo in commit message', + 'change commit message', + 'edit last commit', + 'reword', + 'forgot a file', + 'add to last commit', + 'fix commit' + ] + }, + { + id: 'git-undo-last-commit', + shell: 'any', + category: 'git', + task: 'Undo the last commit but keep the work', + summary: + 'Removes the most recent commit and leaves all of its changes staged, as if you had not committed yet. Meant for a commit you have NOT pushed; for a pushed one, use revert instead.', + command: 'git reset --soft HEAD~1', + variants: [ + { label: 'Same, but leave the changes unstaged', command: 'git reset HEAD~1' }, + { label: 'Already pushed: add a new commit that reverses it', command: 'git revert HEAD' } + ], + keywords: [ + 'undo commit', + 'uncommit', + 'committed too early', + 'committed to the wrong branch', + 'take back commit', + 'reset soft', + 'keep my changes', + 'go back one commit', + 'remove last commit' + ] + }, + { + id: 'git-revert', + shell: 'any', + category: 'git', + task: 'Undo a commit that was already pushed', + summary: + 'Adds a NEW commit that does the opposite of an old one. History is not rewritten, so it is safe on a shared branch and needs no force push.', + command: 'git revert COMMIT', + variants: [{ label: 'Reverse the latest commit without opening the editor', command: 'git revert --no-edit HEAD' }], + placeholders: { COMMIT: 'The id of the commit to reverse, from git log' }, + keywords: [ + 'revert', + 'undo pushed commit', + 'back out a change', + 'reverse a commit', + 'rollback', + 'roll back', + 'safe undo', + 'undo on main' + ] + }, + { + id: 'git-pull', + shell: 'any', + category: 'git', + task: 'Update from the remote', + summary: + "Downloads the new commits for your branch and merges them into it. Commit or stash your own edits first if git refuses because they would be overwritten.", + command: 'git pull', + variants: [ + { label: 'Replay your own commits on top instead of making a merge commit', command: 'git pull --rebase' }, + { label: 'Only download, change nothing in your files yet', command: 'git fetch' }, + { label: 'Download, and forget remote branches that were deleted', command: 'git fetch --prune' } + ], + keywords: [ + 'pull', + 'update', + 'get latest', + 'sync', + 'download changes', + 'fetch', + 'refresh from github', + 'behind origin', + 'your local changes would be overwritten', + 'get new commits' + ] + }, + { + id: 'git-push', + shell: 'any', + category: 'git', + task: 'Push your commits to the remote', + summary: + 'Uploads your committed work to the remote (GitHub, for most people). If it is rejected because the remote has commits you lack, pull first and push again.', + command: 'git push', + keywords: [ + 'push', + 'upload', + 'send to github', + 'publish commits', + 'share my work', + 'rejected', + 'non-fast-forward', + 'failed to push some refs', + 'updates were rejected' + ] + }, + { + id: 'git-push-new-branch', + shell: 'any', + category: 'git', + task: 'Push a new branch for the first time', + summary: + 'A branch you just made does not exist on the remote yet. This creates it there under the same name and links the two, so plain git push and git pull work from then on.', + command: 'git push -u origin HEAD', + variants: [{ label: 'The same, naming the branch yourself', command: 'git push -u origin BRANCH' }], + placeholders: { BRANCH: 'The name of your branch' }, + keywords: [ + 'push new branch', + 'no upstream branch', + 'set upstream', + 'has no upstream', + 'publish branch', + 'first push', + 'set-upstream', + 'track remote branch', + 'open a pull request' + ] + }, + + // ---- Branches ---- + { + id: 'git-new-branch', + shell: 'any', + category: 'git', + task: 'Make a new branch and switch to it', + summary: + 'Starts a branch from where you are now and moves you onto it. Uncommitted edits come along, so this is also how you move work you started on main by mistake.', + command: 'git switch -c BRANCH', + variants: [ + { label: 'The older spelling of the same thing', command: 'git checkout -b BRANCH' }, + { label: 'Start it from the freshly fetched main rather than from here', command: 'git switch -c BRANCH --no-track origin/main' } + ], + placeholders: { BRANCH: 'The new branch name, no spaces (for example fix/login-button)' }, + keywords: [ + 'new branch', + 'create branch', + 'make a branch', + 'start a feature', + 'checkout -b', + 'branch off', + 'working on main by mistake', + 'feature branch' + ] + }, + { + id: 'git-switch-branch', + shell: 'any', + category: 'git', + task: 'Switch to another branch', + summary: + 'Moves you to an existing branch and updates your files to match it. Commit or stash first if git says your changes would be overwritten.', + command: 'git switch BRANCH', + variants: [ + { label: 'Back to the branch you were on before', command: 'git switch -' }, + { label: 'The older spelling of the same thing', command: 'git checkout BRANCH' } + ], + placeholders: { BRANCH: 'The name of the branch to move to' }, + keywords: [ + 'switch branch', + 'change branch', + 'checkout', + 'go to main', + 'move to another branch', + 'previous branch', + 'go back to main', + 'please commit your changes or stash them' + ] + }, + { + id: 'git-list-branches', + shell: 'any', + category: 'git', + task: 'List branches, and see which one you are on', + summary: 'Lists your local branches with a star beside the current one.', + command: 'git branch', + variants: [ + { label: 'Include the branches on the remote', command: 'git branch -a' }, + { label: 'Print only the name of the current branch', command: 'git branch --show-current' } + ], + keywords: [ + 'list branches', + 'which branch', + 'current branch', + 'what branch am i on', + 'show branches', + 'remote branches', + 'all branches', + 'branch name' + ] + }, + { + id: 'git-rename-branch', + shell: 'any', + category: 'git', + task: 'Rename the branch you are on', + summary: + 'Changes the local name of the current branch. If it was already pushed, push it again under the new name and delete the old one on the remote.', + command: 'git branch -m NEW_NAME', + placeholders: { NEW_NAME: 'The new name for the current branch' }, + keywords: ['rename branch', 'change branch name', 'typo in branch name', 'branch -m', 'master to main', 'move branch'] + }, + { + id: 'git-delete-branch', + shell: 'any', + category: 'git', + task: 'Delete a branch', + summary: + 'Removes a local branch you have finished with. The lower-case -d refuses when the branch holds work that is merged nowhere, which is the safety net; switch off the branch before deleting it.', + command: 'git branch -d BRANCH', + variants: [ + { label: 'Delete it even though it is not merged (its commits become hard to find)', command: 'git branch -D BRANCH' }, + { label: 'Delete the branch on the remote too', command: 'git push origin --delete BRANCH' } + ], + placeholders: { BRANCH: 'The name of the branch to delete' }, + keywords: [ + 'delete branch', + 'remove branch', + 'clean up branches', + 'old branches', + 'not fully merged', + 'delete remote branch', + 'tidy branches', + 'cannot delete branch checked out' + ], + danger: + 'The -D and --delete forms remove a branch whether or not its commits exist anywhere else; unmerged work is then reachable only through git reflog, and only for a while.' + }, + { + id: 'git-merge', + shell: 'any', + category: 'git', + task: 'Merge another branch into this one', + summary: + 'Brings the commits of BRANCH into the branch you are on. Switch to the branch that should RECEIVE the work first (usually main), then merge the other one in.', + command: 'git merge BRANCH', + variants: [{ label: 'Bring the latest main into your feature branch', command: 'git merge origin/main' }], + placeholders: { BRANCH: 'The branch whose work you want to bring in' }, + keywords: [ + 'merge', + 'combine branches', + 'bring in changes', + 'merge into main', + 'join branches', + 'fast-forward', + 'merge commit', + 'integrate' + ] + }, + { + id: 'git-rebase-main', + shell: 'any', + category: 'git', + task: 'Rebase your branch onto the latest main', + summary: + 'Fetches main and replays your commits on top of it, so your branch reads as if it had started from today\'s main. If the branch was already pushed, the next push needs --force-with-lease.', + command: 'git pull --rebase origin main', + variants: [ + { label: 'The two-step form: download first', command: 'git fetch origin' }, + { label: 'Then replay your commits onto it', command: 'git rebase origin/main' } + ], + keywords: [ + 'rebase', + 'rebase onto main', + 'update my branch', + 'branch is behind main', + 'out of date with base branch', + 'replay commits', + 'linear history', + 'catch up with main' + ], + danger: + 'A rebase rewrites the commits on your branch. Anyone else working on the same branch will conflict with it, so rebase only branches that are yours alone.' + }, + { + id: 'git-conflict', + shell: 'any', + category: 'git', + task: 'Resolve a merge conflict', + summary: + 'Lists the files in conflict. Open each one: between <<<<<<< and ======= is YOUR side, between ======= and >>>>>>> is the INCOMING side. Edit it to what it should be, delete all three marker lines, save, stage the file, then continue.', + command: 'git diff --name-only --diff-filter=U', + variants: [ + { label: 'Mark a file as resolved once you have edited it', command: 'git add FILE' }, + { label: 'Finish, when it was a merge', command: 'git merge --continue' }, + { label: 'Finish, when it was a rebase', command: 'git rebase --continue' }, + { label: 'Take one whole side for a file: --ours, or --theirs (in a rebase the two are swapped: ours is the branch you are rebasing onto)', command: 'git checkout --theirs FILE' } + ], + placeholders: { FILE: 'The path of a conflicted file' }, + keywords: [ + 'conflict', + 'merge conflict', + 'conflict markers', + 'what do the arrows mean', + 'head and equals signs', + 'both modified', + 'unmerged paths', + 'automatic merge failed', + 'fix conflicts and then commit', + 'ours theirs', + 'resolve' + ] + }, + { + id: 'git-abort', + shell: 'any', + category: 'git', + task: 'Abort a merge or rebase and go back', + summary: + 'Gives up on a merge that went wrong and puts everything back as it was before you started. Nothing is lost except the conflict resolutions you had made so far.', + command: 'git merge --abort', + variants: [ + { label: 'The same for a rebase', command: 'git rebase --abort' }, + { label: 'The same for a cherry-pick', command: 'git cherry-pick --abort' } + ], + keywords: [ + 'abort', + 'cancel merge', + 'cancel rebase', + 'stop the merge', + 'get out of rebase', + 'rebase in progress', + 'start over', + 'go back to before the merge', + 'too many conflicts' + ] + }, + { + id: 'git-cherry-pick', + shell: 'any', + category: 'git', + task: 'Copy one commit onto this branch', + summary: + 'Applies the change from a single commit on another branch as a new commit here, without merging the rest of that branch.', + command: 'git cherry-pick COMMIT', + placeholders: { COMMIT: 'The id of the commit to copy, from git log on the other branch' }, + keywords: [ + 'cherry-pick', + 'cherry pick', + 'copy a commit', + 'move commit to another branch', + 'one commit only', + 'apply a commit', + 'backport', + 'committed to the wrong branch' + ] + }, + { + id: 'git-detached-head', + shell: 'any', + category: 'git', + task: 'Get back from a "detached HEAD"', + summary: + 'Detached HEAD means you are looking at an old commit rather than standing on a branch. Nothing is broken. If you made no commits there, just switch back; if you did, put them on a branch first or they will be left behind.', + command: 'git switch -', + variants: [ + { label: 'Go to a named branch instead', command: 'git switch main' }, + { label: 'Keep the commits you made while detached, on a new branch', command: 'git switch -c BRANCH' } + ], + placeholders: { BRANCH: 'A name for the branch that will hold those commits' }, + keywords: [ + 'detached head', + 'head detached at', + 'you are in detached head state', + 'not on a branch', + 'not currently on any branch', + 'how do i get back', + 'checked out a commit', + 'lost my branch' + ] + }, + + // ---- Putting work aside ---- + { + id: 'git-stash', + shell: 'any', + category: 'git', + task: 'Put your changes aside, and get them back', + summary: + 'Stash shelves your uncommitted edits and gives you a clean folder, for when you must switch branch or pull in the middle of something. Pop brings the newest stash back.', + command: 'git stash', + variants: [ + { label: 'Get the changes back (and remove them from the shelf)', command: 'git stash pop' }, + { label: 'Include brand-new files that git is not tracking yet', command: 'git stash -u' }, + { label: 'See what is on the shelf', command: 'git stash list' }, + { label: 'Get them back but keep a copy on the shelf', command: 'git stash apply' } + ], + keywords: [ + 'stash', + 'put aside', + 'shelve', + 'save for later', + 'temporarily remove changes', + 'clean working tree', + 'unstash', + 'stash pop', + 'where did my changes go', + 'switch branch without committing' + ] + }, + + // ---- Starting and configuring ---- + { + id: 'git-clone', + shell: 'any', + category: 'git', + task: 'Download (clone) a repository', + summary: + 'Copies a repository from GitHub or elsewhere, with its full history, into a new folder under the one you are in.', + command: 'git clone URL', + variants: [ + { label: 'Choose the folder name', command: 'git clone URL FOLDER' }, + { label: 'Only the latest snapshot, much faster on a huge repository', command: 'git clone --depth 1 URL' } + ], + placeholders: { + URL: 'The address from the green Code button, such as https://github.com/OWNER/REPO.git', + FOLDER: 'The folder to create' + }, + keywords: [ + 'clone', + 'download repo', + 'get the code', + 'copy from github', + 'checkout a project', + 'shallow clone', + 'download a project', + 'repository url' + ] + }, + { + id: 'git-init', + shell: 'any', + category: 'git', + task: 'Start tracking a folder with git', + summary: + 'Turns the folder you are in into a new, empty repository. Nothing is uploaded anywhere; add and commit your files next.', + command: 'git init', + variants: [{ label: 'Make sure the first branch is called main', command: 'git init -b main' }], + keywords: [ + 'init', + 'new repository', + 'new repo', + 'start git', + 'create a repository', + 'not a git repository', + 'fatal not a git repository', + 'version control this folder' + ] + }, + { + id: 'git-remote', + shell: 'any', + category: 'git', + task: 'See or change where the repository pushes to', + summary: 'Shows the remote addresses this repository downloads from and uploads to; origin is the usual name.', + command: 'git remote -v', + variants: [ + { label: 'Connect a new local repository to GitHub', command: 'git remote add origin URL' }, + { label: 'Point origin somewhere else (a renamed repo, or https to ssh)', command: 'git remote set-url origin URL' } + ], + placeholders: { URL: 'The address of the repository on the server' }, + keywords: [ + 'remote', + 'origin', + 'where does it push', + 'github url', + 'change remote', + 'add remote', + 'repository moved', + 'no configured push destination', + 'does not appear to be a git repository' + ] + }, + { + id: 'git-identity', + shell: 'any', + category: 'git', + task: 'Set your name and email for commits', + summary: + 'Git stamps every commit with a name and an email, and refuses to commit until they are set. Run both lines once per machine; use the email of your GitHub account so commits link to you.', + command: 'git config --global user.name "YOUR_NAME"', + variants: [ + { label: 'The email, the second half', command: 'git config --global user.email "EMAIL"' }, + { label: 'Check what is set', command: 'git config --global --list' } + ], + placeholders: { YOUR_NAME: 'Your name as it should appear in history (spaces are fine inside the quotes)', EMAIL: 'Your email address' }, + keywords: [ + 'name and email', + 'user.name', + 'user.email', + 'please tell me who you are', + 'author identity unknown', + 'configure git', + 'set up git', + 'git config', + 'wrong author' + ] + }, + { + id: 'git-untrack-ignored', + shell: 'any', + category: 'git', + task: 'Ignore a file that git is already tracking', + summary: + 'A .gitignore entry only affects files git is not tracking yet. This stops tracking the file while leaving it on your disk; add it to .gitignore and commit, and git leaves it alone from then on.', + command: 'git rm --cached FILE', + variants: [ + { label: 'A whole folder', command: 'git rm -r --cached FOLDER' }, + { label: 'Ask which .gitignore rule is (or is not) catching a file', command: 'git check-ignore -v FILE' } + ], + placeholders: { FILE: 'The path of the file to stop tracking', FOLDER: 'The path of the folder to stop tracking' }, + keywords: [ + 'gitignore', + 'gitignore not working', + 'ignore file', + 'stop tracking', + 'untrack', + 'committed .env by mistake', + 'node_modules in git', + 'remove from git keep file', + 'rm cached' + ], + danger: + 'The file stays on YOUR disk, but the commit records it as deleted, so it disappears from the folder of everyone else who pulls. It also remains in the older history, so a committed secret must still be changed.' + }, + { + id: 'git-tag', + shell: 'any', + category: 'git', + task: 'Tag a version', + summary: + 'A tag is a permanent name for one commit, normally a release such as v1.2.0. Tags are not sent by a plain push; push them by name.', + command: 'git tag -a TAG -m "MESSAGE"', + variants: [ + { label: 'Send that tag to the remote', command: 'git push origin TAG' }, + { label: 'List the tags', command: 'git tag' }, + { label: 'Delete a local tag', command: 'git tag -d TAG' } + ], + placeholders: { TAG: 'The tag name, such as v1.2.0', MESSAGE: 'A short note, such as Release 1.2.0' }, + keywords: [ + 'tag', + 'version', + 'release', + 'mark a release', + 'v1.0.0', + 'push tags', + 'list tags', + 'annotated tag', + 'label a commit' + ] + }, + + // ---- Getting things back ---- + { + id: 'git-restore-old-version', + shell: 'any', + category: 'git', + task: 'Get a file back as it was in an older commit', + summary: + 'Overwrites one file in your folder with the version from an earlier commit, and leaves everything else alone. Commit the result to keep it.', + command: 'git restore --source=COMMIT FILE', + variants: [{ label: 'Bring back a file you deleted and have not committed', command: 'git restore FILE' }], + placeholders: { COMMIT: 'The commit id to take the file from (or HEAD~1 for one commit ago)', FILE: 'The path of the file' }, + keywords: [ + 'old version of a file', + 'restore file', + 'recover deleted file', + 'get file from previous commit', + 'go back to earlier version', + 'undelete', + 'accidentally deleted', + 'checkout file from commit' + ], + danger: 'The current contents of FILE are replaced; uncommitted edits in it are lost.' + }, + { + id: 'git-reflog', + shell: 'any', + category: 'git', + task: 'Find commits you thought you had lost', + summary: + 'Lists everywhere HEAD has been lately, including commits no branch points at any more (after a bad reset, a rebase, a deleted branch). Find the id, then put a branch on it.', + command: 'git reflog -n 30', + variants: [{ label: 'Put a branch on a commit you found, so it is safe again', command: 'git branch BRANCH COMMIT' }], + placeholders: { BRANCH: 'A name for the rescue branch', COMMIT: 'The id from the reflog' }, + keywords: [ + 'reflog', + 'lost commits', + 'recover commit', + 'undo reset hard', + 'undo rebase', + 'deleted branch by mistake', + 'get my work back', + 'where did my commits go', + 'rescue' + ] + }, + + // ---- The ones that destroy work ---- + { + id: 'git-discard-file', + shell: 'any', + category: 'git', + task: 'Discard your changes to a file', + summary: + 'Throws away the uncommitted edits in one file and puts back the last committed version. Run git diff FILE first to see what you are about to lose.', + command: 'git restore FILE', + variants: [ + { label: 'Discard the edits in every tracked file under this folder', command: 'git restore .' }, + { label: 'The older spelling of the same thing', command: 'git checkout -- FILE' } + ], + placeholders: { FILE: 'The path of the file to put back' }, + keywords: [ + 'discard changes', + 'undo changes', + 'undo my edits', + 'revert file', + 'throw away changes', + 'reset a file', + 'go back to last commit', + 'start over on a file', + 'the agent broke my file' + ], + danger: 'Uncommitted edits were never saved anywhere, so git cannot bring them back. There is no undo.' + }, + { + id: 'git-force-push', + shell: 'any', + category: 'git', + task: 'Force-push safely after a rebase or amend', + summary: + 'After rewriting commits that were already pushed, a normal push is rejected. --force-with-lease overwrites the remote branch ONLY if nobody has pushed to it since you last fetched; plain --force checks nothing, so prefer this always.', + command: 'git push --force-with-lease', + keywords: [ + 'force push', + 'force-with-lease', + 'push rejected after rebase', + 'push after amend', + 'overwrite remote', + 'push -f', + 'diverged', + 'your branch and origin have diverged', + 'non-fast-forward' + ], + danger: + 'The commits on the remote branch are replaced by yours. Never do this to main or to a branch other people work on.' + }, + { + id: 'git-reset-hard', + shell: 'any', + category: 'git', + task: 'Throw away ALL uncommitted changes', + summary: + 'Resets every tracked file to the last commit, staged or not. New untracked files are left alone. Run git status first, and consider git stash, which does the same tidy-up and keeps a way back.', + command: 'git reset --hard HEAD', + variants: [ + { label: 'Also drop your local commits: make the branch identical to the remote main (fetch first)', command: 'git reset --hard origin/main' } + ], + keywords: [ + 'reset hard', + 'discard everything', + 'throw away all changes', + 'start over', + 'back to last commit', + 'wipe my changes', + 'match the remote', + 'make it like github', + 'nuke changes' + ], + danger: + 'Every uncommitted edit in every tracked file is gone for good. Commits dropped by the origin/main form can be found again through git reflog; edits that were never committed cannot.' + }, + { + id: 'git-clean', + shell: 'any', + category: 'git', + task: 'Delete untracked files and folders', + summary: + 'Removes everything git is not tracking: build leftovers, scratch files, new files never added. ALWAYS run the preview variant first; it lists what would go and deletes nothing.', + command: 'git clean -fd', + variants: [ + { label: 'PREVIEW: list what would be deleted, delete nothing', command: 'git clean -nfd' }, + { label: 'Also delete files that .gitignore hides (node_modules, build output, .env)', command: 'git clean -fdx' } + ], + keywords: [ + 'clean', + 'remove untracked files', + 'delete untracked', + 'delete new files', + 'clean working directory', + 'remove build files', + 'get rid of junk files', + 'dry run', + 'fresh checkout' + ], + danger: + 'The files are deleted from disk straight away, not moved to the Recycle Bin, and git never had a copy. The -x form also takes ignored files such as .env.' + } +] diff --git a/core/shared/help/index.ts b/core/shared/help/index.ts new file mode 100644 index 0000000..fee9776 --- /dev/null +++ b/core/shared/help/index.ts @@ -0,0 +1,76 @@ +import { AGENTS_HELP } from './agents' +import { BASH_HELP } from './bash' +import { CMD_HELP } from './cmd' +import { GIT_HELP } from './git' +import { PACKAGES_HELP } from './packages' +import { POWERSHELL_HELP } from './powershell' +import type { HelpCategory, HelpEntry } from './types' +import type { HelpShellChoice } from './shells' + +/** + * THE HELP CATALOGUE, WHOLE (#12). Six files, one list: a file per command + * language, plus the three that read the same in every shell (git, the agents' + * own CLIs, the package managers). + * + * ONE CONSTANT ARRAY, and that is load-bearing: the search builds its index + * once per array and finds it again by identity, so a list joined afresh on + * every call would be indexed afresh on every keystroke. + * + * `catalogue.test.ts` is the gate for everything in here: ids, prefixes, + * keywords, placeholders, and a `danger` line on anything destructive. A new + * entry is added to the file of its shell and is not done until that passes. + */ +export const ALL_HELP: readonly HelpEntry[] = [ + ...POWERSHELL_HELP, + ...CMD_HELP, + ...BASH_HELP, + ...GIT_HELP, + ...AGENTS_HELP, + ...PACKAGES_HELP +] + +/** The id prefix an entry of each shell wears, held by the catalogue test. + * 'any' has three, one per file, since the prefix there names the TOOL. */ +export const HELP_ID_PREFIXES: Readonly> = { + powershell: 'ps-', + cmd: 'cmd-', + bash: 'sh-' +} + +/** + * The order the categories are browsed in with an empty query: the everyday + * first (where am I, what is here), the tools a developer reaches for next, + * and the shell's own housekeeping last. + */ +export const HELP_CATEGORIES: ReadonlyArray<{ id: HelpCategory; name: string }> = [ + { id: 'folders', name: 'Folders' }, + { id: 'files', name: 'Files' }, + { id: 'text', name: 'Text and search' }, + { id: 'processes', name: 'Processes and ports' }, + { id: 'network', name: 'Network' }, + { id: 'system', name: 'System' }, + { id: 'git', name: 'Git' }, + { id: 'agents', name: 'Claude Code and Codex' }, + { id: 'packages', name: 'Installing things' }, + { id: 'shell', name: 'The shell itself' } +] + +/** What one shell's reader is offered: its own entries and every 'any' one, + * in catalogue order. */ +export function helpFor(shell: HelpShellChoice): HelpEntry[] { + return ALL_HELP.filter((e) => e.shell === shell || e.shell === 'any') +} + +/** + * A "command" that is a KEY TO PRESS, not text to paste: `Ctrl+C`, `Esc`, + * `Shift+Tab`. The catalogue holds a handful, because "how do I stop this" is + * one of the first things anybody asks. The panel draws them as key caps and + * gives them NO copy button: the characters "Ctrl+C" on a clipboard are of no + * use to anyone, and pasted into a shell they are a command that fails. + */ +export function isKeyPress(command: string): boolean { + return /^(?:(?:Ctrl|Alt|Shift|Win)\+)*(?:[A-Z]|Esc|Tab|Enter|Space|Backspace|F\d{1,2})$/.test(command) +} + +export { HELP_SHELLS, shellOfShellId, type HelpShellChoice } from './shells' +export type { HelpCategory, HelpEntry, HelpShell } from './types' diff --git a/core/shared/help/packages.ts b/core/shared/help/packages.ts new file mode 100644 index 0000000..acafc86 --- /dev/null +++ b/core/shared/help/packages.ts @@ -0,0 +1,549 @@ +/** + * THE HELP PANEL'S CATALOGUE: installing things, winget, npm, pip and venv (#12). + * + * winget, npm and pip are programs rather than shell features, so their + * commands read the same in PowerShell, cmd and bash and the entries are shell + * 'any'. Where a task genuinely differs by shell (activating a virtual + * environment is the one that always does), the variants are labelled with the + * shell they belong to. "Running scripts is disabled", the first thing a + * newcomer meets after installing Node, is NOT here: its fix is a PowerShell + * setting, so it is PowerShell's entry (ps-scripts-disabled), which carries + * the npm.cmd way round it and the words npm and Activate print. + * + * Two things were kept OUT on purpose. `npm run SCRIPT -- ARGS`: PowerShell + * resolves npm to npm.ps1 and swallows the `--`, so the line works in cmd and + * bash and silently does something else in PowerShell. And `&&` anywhere: + * Windows PowerShell 5.1 has no such operator. + * + * HOW THESE WERE CHECKED (2026-09-20, npm 11.17, Python 3.14, pip 26.1): the + * read-only npm and pip commands were run in a throwaway folder (npm run, npm + * outdated, npm ls, npm ls -g, npm view, npm cache verify, npm init -y, pip + * list, pip list --outdated, pip show, pip freeze). A venv was created there + * and activated four ways: PowerShell 7.6, Windows PowerShell 5.1, cmd.exe and + * Git Bash. That `pip freeze > requirements.txt` from Windows PowerShell 5.1 + * (which writes UTF-16) still reads back was confirmed in pip's own parser, + * which sniffs the byte-order mark. NOTHING THAT INSTALLS, REMOVES OR NEEDS + * ELEVATION WAS RUN: npm install, uninstall, update, cache clean and pip + * install are listed from their documentation and long use. + * winget could not be run at all on the machine this was written on, so its + * five entries are from its documentation. WSL was not available either; the + * Ubuntu variants (python3, .venv/bin/activate) were reasoned, not run. + */ +import type { HelpEntry } from './types' + +export const PACKAGES_HELP: readonly HelpEntry[] = [ + // ---- winget: programs for Windows itself ---- + { + id: 'pkg-winget-search', + shell: 'any', + category: 'packages', + task: 'Find a program to install (winget)', + summary: + 'Searches the Windows package catalogue by name. winget is built into Windows 11, the equivalent of apt or brew. Note the Id column in the results: that is what you install by.', + command: 'winget search NAME', + variants: [{ label: 'See the details of one package before installing it', command: 'winget show --id PACKAGE_ID' }], + placeholders: { NAME: 'Part of the program name, such as node or python', PACKAGE_ID: 'The Id from the search results, such as Git.Git' }, + keywords: [ + 'winget', + 'search for a program', + 'find an app', + 'package manager for windows', + 'app store', + 'apt', + 'brew', + 'choco', + 'is there a package for', + 'install software' + ] + }, + { + id: 'pkg-winget-install', + shell: 'any', + category: 'packages', + task: 'Install a program (winget)', + summary: + 'Downloads and installs a program by its exact Id. Windows may show its usual permission prompt. Open a NEW terminal tab afterwards, or the shell will not find the new command.', + command: 'winget install --id PACKAGE_ID -e', + variants: [ + { label: 'Git', command: 'winget install --id Git.Git -e' }, + { label: 'Node.js (the long-term support version), which brings npm', command: 'winget install --id OpenJS.NodeJS.LTS -e' }, + { label: 'PowerShell 7', command: 'winget install --id Microsoft.PowerShell -e' } + ], + placeholders: { PACKAGE_ID: 'The Id shown by winget search, such as Python.Python.3.13' }, + keywords: [ + 'install', + 'install a program', + 'install git', + 'install node', + 'install python', + 'download an app', + 'apt install', + 'brew install', + 'setup', + 'get a tool' + ] + }, + { + id: 'pkg-winget-upgrade', + shell: 'any', + category: 'packages', + task: 'Update all installed programs (winget)', + summary: + 'Upgrades every program winget knows a newer version of. Run plain winget upgrade first to see the list without changing anything.', + command: 'winget upgrade --all', + variants: [ + { label: 'Only list what has an update', command: 'winget upgrade' }, + { label: 'Update one program', command: 'winget upgrade --id PACKAGE_ID -e' } + ], + placeholders: { PACKAGE_ID: 'The Id from the upgrade list' }, + keywords: [ + 'update everything', + 'upgrade all', + 'update my apps', + 'update programs', + 'apt upgrade', + 'brew upgrade', + 'newer version', + 'out of date software', + 'patch' + ] + }, + { + id: 'pkg-winget-list', + shell: 'any', + category: 'packages', + task: 'See which programs are installed (winget)', + summary: 'Lists installed programs with their versions, including ones that were not installed through winget.', + command: 'winget list', + variants: [{ label: 'Only those matching a name', command: 'winget list NAME' }], + placeholders: { NAME: 'Part of the program name' }, + keywords: [ + 'installed programs', + 'what is installed', + 'list apps', + 'is it installed', + 'which version do i have', + 'add remove programs', + 'apt list', + 'installed software' + ] + }, + { + id: 'pkg-winget-uninstall', + shell: 'any', + category: 'packages', + task: 'Uninstall a program (winget)', + summary: "Removes an installed program, running its own uninstaller. Take the exact Id from winget list.", + command: 'winget uninstall --id PACKAGE_ID -e', + placeholders: { PACKAGE_ID: 'The Id from winget list' }, + keywords: [ + 'uninstall', + 'remove a program', + 'delete an app', + 'get rid of', + 'apt remove', + 'brew uninstall', + 'remove software' + ], + danger: 'The program is removed from the machine, and some uninstallers delete its settings and data with it.' + }, + + // ---- npm: packages for a JavaScript project ---- + { + id: 'pkg-npm-install', + shell: 'any', + category: 'packages', + task: 'Install what a project needs (npm)', + summary: + 'Run it in a folder that has a package.json, typically straight after cloning: it downloads every dependency the project lists into node_modules.', + command: 'npm install', + variants: [ + { label: 'Exactly the versions in package-lock.json, from scratch (it deletes node_modules first)', command: 'npm ci' }, + { label: 'Start a brand-new project: create a package.json', command: 'npm init -y' } + ], + keywords: [ + 'npm install', + 'install dependencies', + 'node_modules', + 'after cloning', + 'cannot find module', + 'module not found', + 'set up the project', + 'npm i', + 'yarn', + 'pnpm', + 'package.json' + ] + }, + { + id: 'pkg-npm-add', + shell: 'any', + category: 'packages', + task: 'Add a package to your project (npm)', + summary: 'Downloads a package and records it under dependencies in package.json, so everyone who installs the project gets it too.', + command: 'npm install PACKAGE', + variants: [ + { label: 'A particular version', command: 'npm install PACKAGE@VERSION' }, + { label: 'See what the newest published version is, without installing', command: 'npm view PACKAGE version' } + ], + placeholders: { PACKAGE: 'The package name on npmjs.com, such as express', VERSION: 'A version number, such as 4.18.2' }, + keywords: [ + 'add a package', + 'add a library', + 'add a dependency', + 'npm i', + 'install a library', + 'yarn add', + 'pnpm add', + 'specific version', + 'latest version of a package' + ] + }, + { + id: 'pkg-npm-dev-dependency', + shell: 'any', + category: 'packages', + task: 'Add a dev-only package (npm)', + summary: + 'For tools needed while developing but not by the finished program: test runners, linters, TypeScript, bundlers. Recorded under devDependencies.', + command: 'npm install --save-dev PACKAGE', + variants: [{ label: 'The short spelling', command: 'npm install -D PACKAGE' }], + placeholders: { PACKAGE: 'The package name, such as typescript or vitest' }, + keywords: [ + 'dev dependency', + 'devdependencies', + 'save-dev', + 'development only', + 'install typescript', + 'install eslint', + 'test framework', + 'yarn add -d', + 'build tool' + ] + }, + { + id: 'pkg-npm-uninstall', + shell: 'any', + category: 'packages', + task: 'Remove a package from your project (npm)', + summary: 'Deletes the package from node_modules and takes it out of package.json. Putting it back is one npm install away.', + command: 'npm uninstall PACKAGE', + placeholders: { PACKAGE: 'The package name' }, + keywords: ['remove a package', 'uninstall a library', 'delete a dependency', 'npm remove', 'npm rm', 'yarn remove', 'unused package'] + }, + { + id: 'pkg-npm-scripts', + shell: 'any', + category: 'packages', + task: 'See which scripts a project has (npm)', + summary: + 'With no script name, npm run lists every script defined in package.json (dev, build, test and so on) and the command behind each. The quickest way to learn how a project is started.', + command: 'npm run', + keywords: [ + 'what scripts exist', + 'list scripts', + 'available commands', + 'how do i start this project', + 'how do i run this', + 'package.json scripts', + 'what can i run', + 'missing script' + ] + }, + { + id: 'pkg-npm-run', + shell: 'any', + category: 'packages', + task: 'Run a project script (npm)', + summary: + 'Runs one of the scripts from package.json. A dev server keeps running until you press Ctrl+C in its tab.', + command: 'npm run SCRIPT', + variants: [ + { label: 'The usual development server', command: 'npm run dev' }, + { label: 'The usual production build', command: 'npm run build' }, + { label: 'test and start need no "run"', command: 'npm test' } + ], + placeholders: { SCRIPT: 'A script name from npm run, such as dev or build' }, + keywords: [ + 'run a script', + 'start the dev server', + 'npm start', + 'npm run dev', + 'build the project', + 'run the tests', + 'missing script', + 'yarn dev', + 'how do i stop the server' + ] + }, + { + id: 'pkg-npx', + shell: 'any', + category: 'packages', + task: 'Run a package once without installing it (npx)', + summary: + 'Runs a command that ships inside an npm package. It uses the copy in this project when there is one; otherwise it downloads the package to a cache (asking first) and runs it from there.', + command: 'npx PACKAGE', + variants: [ + { label: 'Example: check TypeScript types with the project\'s own compiler', command: 'npx tsc --noEmit' }, + { label: 'Answer yes to the download question in advance', command: 'npx --yes PACKAGE' } + ], + placeholders: { PACKAGE: 'The package whose command to run, such as create-vite' }, + keywords: [ + 'npx', + 'run without installing', + 'one-off tool', + 'create an app', + 'scaffold', + 'run a local binary', + 'node_modules bin', + 'pnpm dlx', + 'try a package' + ] + }, + { + id: 'pkg-npm-outdated', + shell: 'any', + category: 'packages', + task: 'See which packages have newer versions (npm)', + summary: + 'Lists each dependency with the version you have, the newest that package.json allows (Wanted) and the newest that exists (Latest). It changes nothing; no output means everything is current.', + command: 'npm outdated', + keywords: [ + 'outdated', + 'old packages', + 'newer versions', + 'check for updates', + 'what needs updating', + 'dependency versions', + 'wanted latest', + 'stale dependencies' + ] + }, + { + id: 'pkg-npm-update', + shell: 'any', + category: 'packages', + task: 'Update the packages in a project (npm)', + summary: + 'Moves every dependency to the newest version that the ranges in package.json allow (the Wanted column of npm outdated). It does not jump to a new major version; ask for that by name.', + command: 'npm update', + variants: [ + { label: 'Only one package', command: 'npm update PACKAGE' }, + { label: 'Jump one package to the very latest, major versions included', command: 'npm install PACKAGE@latest' } + ], + placeholders: { PACKAGE: 'The package name' }, + keywords: [ + 'update packages', + 'upgrade dependencies', + 'bump versions', + 'latest', + 'npm upgrade', + 'yarn upgrade', + 'security update', + 'npm audit' + ] + }, + { + id: 'pkg-npm-global', + shell: 'any', + category: 'packages', + task: 'See or install global npm tools', + summary: + 'Global packages are command-line tools available in every folder (the Codex CLI is one) rather than parts of one project. This lists the ones you have.', + command: 'npm ls -g --depth=0', + variants: [ + { label: 'Install a tool globally', command: 'npm install -g PACKAGE' }, + { label: 'Remove one', command: 'npm uninstall -g PACKAGE' }, + { label: 'Where global tools live (that folder must be on PATH)', command: 'npm config get prefix' } + ], + placeholders: { PACKAGE: 'The package name, such as @openai/codex' }, + keywords: [ + 'global packages', + 'npm list global', + 'globally installed', + 'install -g', + 'cli tools', + 'where are global packages', + 'npm prefix', + 'installed but command not found' + ] + }, + { + id: 'pkg-npm-cache', + shell: 'any', + category: 'packages', + task: 'Clear the npm cache', + summary: + 'A last resort for installs that fail with integrity or corrupted-download errors. Try the verify variant first: it checks the cache and removes only what is broken or unneeded.', + command: 'npm cache clean --force', + variants: [{ label: 'Check and tidy the cache instead of emptying it', command: 'npm cache verify' }], + keywords: [ + 'clear cache', + 'npm cache', + 'integrity check failed', + 'eintegrity', + 'corrupted download', + 'install keeps failing', + 'sha512', + 'free disk space', + 'cache clean' + ], + danger: 'Every cached download is deleted; nothing in your projects is touched, but the next installs fetch everything again.' + }, + // ---- Python: venv and pip ---- + { + id: 'pkg-venv', + shell: 'any', + category: 'packages', + task: 'Create and activate a Python virtual environment', + summary: + "A venv is a private set of Python packages for ONE project, kept in a .venv folder, so projects cannot break each other. Create it once, then activate it in every new terminal tab before working: the prompt shows (.venv) while it is on.", + command: 'python -m venv .venv', + variants: [ + { label: 'Activate, in PowerShell', command: '.\\.venv\\Scripts\\Activate.ps1' }, + { label: 'Activate, in bash on Linux or WSL (where the create step is python3 -m venv .venv)', command: 'source .venv/bin/activate' }, + { label: 'Activate, in cmd', command: '.venv\\Scripts\\activate.bat' }, + { label: 'Activate, in Git Bash on Windows', command: 'source .venv/Scripts/activate' }, + { label: 'Switch it off again, in any shell', command: 'deactivate' } + ], + keywords: [ + 'venv', + 'virtual environment', + 'virtualenv', + 'activate', + 'isolated python', + 'externally-managed-environment', + 'externally managed', + 'conda', + 'python environment', + 'pip installs in the wrong place', + 'no module named', + 'deactivate' + ] + }, + { + id: 'pkg-pip-install', + shell: 'any', + category: 'packages', + task: 'Install a Python package (pip)', + summary: + 'Installs into the active virtual environment (activate one first). Spelling it python -m pip makes sure the pip belongs to the Python you are actually running. On Ubuntu the command is python3.', + command: 'python -m pip install PACKAGE', + variants: [ + { label: 'A particular version', command: 'python -m pip install PACKAGE==VERSION' }, + { label: 'Upgrade one you already have', command: 'python -m pip install --upgrade PACKAGE' }, + { label: 'Remove one', command: 'python -m pip uninstall PACKAGE' } + ], + placeholders: { PACKAGE: 'The package name on pypi.org, such as requests', VERSION: 'A version number, such as 2.32.0' }, + keywords: [ + 'pip install', + 'install a python library', + 'modulenotfounderror', + 'no module named', + 'importerror', + 'pip3', + 'pypi', + 'python package', + 'upgrade a package', + 'pip is not recognized' + ] + }, + { + id: 'pkg-pip-requirements', + shell: 'any', + category: 'packages', + task: 'Install everything in requirements.txt (pip)', + summary: + 'Python projects list what they need in requirements.txt. After cloning one: create and activate a venv, then run this.', + command: 'python -m pip install -r requirements.txt', + keywords: [ + 'requirements.txt', + 'install requirements', + 'install dependencies python', + 'after cloning', + 'set up a python project', + 'pip -r', + 'project dependencies', + 'pyproject' + ] + }, + { + id: 'pkg-pip-freeze', + shell: 'any', + category: 'packages', + task: 'Save the installed packages to requirements.txt', + summary: + 'Writes every package in the active environment, with its exact version, to requirements.txt, so someone else (or you, later) can recreate it. Do it inside the venv, or the file lists your whole machine.', + command: 'python -m pip freeze > requirements.txt', + variants: [{ label: 'Only print the list, write nothing', command: 'python -m pip freeze' }], + keywords: [ + 'freeze', + 'pip freeze', + 'export dependencies', + 'create requirements.txt', + 'lock versions', + 'save packages', + 'pin versions', + 'share my environment' + ], + danger: 'An existing requirements.txt in this folder is overwritten.' + }, + { + id: 'pkg-pip-list', + shell: 'any', + category: 'packages', + task: 'See which Python packages are installed (pip)', + summary: 'Lists the packages in the active environment with their versions.', + command: 'python -m pip list', + variants: [ + { label: 'Only those with a newer version available', command: 'python -m pip list --outdated' }, + { label: 'Details of one package: version, location, what it depends on', command: 'python -m pip show PACKAGE' }, + { label: 'Which Python and which pip this shell is using', command: 'python -m pip --version' } + ], + placeholders: { PACKAGE: 'The package name' }, + keywords: [ + 'pip list', + 'installed python packages', + 'which version of a library', + 'is it installed', + 'outdated python packages', + 'pip show', + 'which python', + 'python version' + ] + }, + + // ---- The one everybody hits ---- + { + id: 'pkg-command-not-found', + shell: 'any', + category: 'packages', + task: 'Fix "command not found" right after installing', + summary: + 'A shell reads PATH, the list of folders it looks for programs in, once, when it starts, and an installer changes PATH for shells started AFTER it. So first close this tab and open a new one. This command then shows where Windows finds the program: no result means its folder is not on PATH (or, for a global npm tool, see npm config get prefix).', + command: 'where.exe NAME', + variants: [ + { label: 'In bash', command: 'which NAME' }, + { + label: 'PowerShell: re-read PATH in this tab without restarting it', + command: + '$env:Path = [Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [Environment]::GetEnvironmentVariable("Path","User")' + } + ], + placeholders: { NAME: 'The command that is not found, such as node or claude' }, + keywords: [ + 'command not found', + 'is not recognized as an internal or external command', + 'is not recognized as the name of a cmdlet', + 'not recognized', + 'just installed but not found', + 'restart the terminal', + 'path', + 'refresh path', + 'refreshenv', + 'where is it installed', + 'which' + ] + } +] diff --git a/core/shared/help/powershell.ts b/core/shared/help/powershell.ts new file mode 100644 index 0000000..687ff51 --- /dev/null +++ b/core/shared/help/powershell.ts @@ -0,0 +1,2798 @@ +/** + * THE HELP CATALOGUE, PowerShell (#12). + * + * PowerShell is the app's default shell, so this is the file most people will + * be searching. It covers the everyday first (where am I, list, read, copy, + * delete, find text, what is on this port, environment variables and PATH, + * "access denied", "scripts are disabled") and then the PowerShell answer to + * each Unix habit: ls, cat, grep, touch, which, tail -f, head, wc, find, df, du, + * ps, kill, curl, export, alias, man, history, clear. Those Unix words sit in + * the keywords, so typing "grep" finds Select-String. + * + * Every command is written to work in BOTH Windows PowerShell 5.1 (what a stock + * Windows 11 ships) and PowerShell 7. Where something is 7 only, it is a variant + * and its label says so: 'cd -', '&&', 'COMMAND &' and Get-Uptime. + * + * HOW THESE WERE CHECKED (2026-09-20, Windows 11 26200, pwsh 7.6.6 AND + * powershell.exe 5.1.26100): every read-only command was RUN in both shells and + * its output read, and every file command (make, copy, move, rename, delete, + * zip, unzip, junction, replace in file, Recycle Bin) was run in both inside a + * scratch folder made for the purpose. Stop-Process and the free-a-port line + * were run with -WhatIf only. Four things were found that way and shaped the + * entries: Get-NetTCPConnection answers a free port with a wall of red, so the + * port entries silence it and say that nothing printed means nothing listening; + * a junction wants a FULL path in 7 and takes a relative one in 5.1; a symbolic + * link is refused without an administrator shell; and in 5.1 'curl' and 'wget' + * are aliases of Invoke-WebRequest, so the real curl is 'curl.exe'. Not run, on + * purpose: anything that opens a window or a prompt (explorer, Invoke-Item, + * notepad, Start-Process on a URL, -Verb RunAs, Out-Host -Paging), anything that + * writes outside the scratch folder (Set-ExecutionPolicy, the permanent + * environment variable and PATH lines, the profile), Set-Clipboard (it would + * replace the owner's clipboard), Get-Content -Wait (it never returns) and + * ipconfig /flushdns. + */ + +import type { HelpEntry } from './types' + +export const POWERSHELL_HELP: readonly HelpEntry[] = [ + // ------------------------------------------------------------------ files + { + id: 'ps-list-files', + shell: 'powershell', + category: 'files', + task: 'List the files in this folder', + summary: + 'Shows what is in the current folder, with sizes and dates. ls and dir are built-in short names for the same command.', + command: 'Get-ChildItem', + variants: [ + { label: 'Another folder', command: 'Get-ChildItem FOLDER' }, + { label: 'Newest first', command: 'Get-ChildItem | Sort-Object LastWriteTime -Descending' }, + { label: 'Biggest first', command: 'Get-ChildItem -File | Sort-Object Length -Descending' }, + { label: 'Names only', command: 'Get-ChildItem -Name' }, + { label: 'Folders only', command: 'Get-ChildItem -Directory' }, + { label: 'Only one kind of file', command: "Get-ChildItem -Filter '*.EXTENSION'" } + ], + placeholders: { + FOLDER: 'The folder to look in', + EXTENSION: 'A file ending, such as txt or json' + }, + keywords: [ + 'ls', + 'dir', + 'ls -l', + 'ls -la', + 'list everything', + 'list files', + 'show files', + 'see files', + 'files here', + 'what files are here', + 'whats in this folder', + 'what is in this folder', + 'directory listing', + 'folder contents', + 'gci', + 'sort by date' + ] + }, + { + id: 'ps-show-hidden-files', + shell: 'powershell', + category: 'files', + task: 'See hidden files and folders', + summary: + 'Adds hidden and system items to the listing, such as .git and AppData, which a plain listing leaves out.', + command: 'Get-ChildItem -Force', + variants: [{ label: 'Only the hidden ones', command: 'Get-ChildItem -Hidden' }], + keywords: [ + 'ls -a', + 'dir /a', + 'hidden files', + 'show hidden', + 'dotfiles', + '.git folder', + '.env file', + 'invisible files', + 'cannot see file', + 'appdata' + ] + }, + { + id: 'ps-read-file', + shell: 'powershell', + category: 'files', + task: 'Read a file in the terminal', + summary: + 'Prints the whole file to the screen. cat and type are short names for the same command.', + command: 'Get-Content FILE', + variants: [ + { + label: 'A page at a time (Space for more, Q to stop)', + command: 'Get-Content FILE | Out-Host -Paging' + }, + { label: 'Open it in Notepad instead', command: 'notepad FILE' } + ], + placeholders: { FILE: 'The file to read' }, + keywords: [ + 'cat', + 'type', + 'print file', + 'show file contents', + 'view file', + 'display file', + 'open text file', + 'read text', + 'less', + 'more', + 'gc' + ] + }, + { + id: 'ps-first-lines', + shell: 'powershell', + category: 'files', + task: 'See the first lines of a file', + summary: 'Prints only the top of a file, which is what you want for a big log or a CSV header.', + command: 'Get-Content FILE -TotalCount 20', + variants: [ + { label: 'Lines 11 to 15', command: 'Get-Content FILE | Select-Object -Skip 10 -First 5' }, + { label: 'The first rows of any command', command: 'COMMAND | Select-Object -First 10' } + ], + placeholders: { FILE: 'The file to read', COMMAND: 'Any command that prints a list' }, + keywords: [ + 'head', + 'head -n', + 'top of file', + 'first 10 lines', + 'beginning of file', + 'start of file', + 'preview file', + 'peek' + ] + }, + { + id: 'ps-last-lines', + shell: 'powershell', + category: 'files', + task: 'See the last lines of a file', + summary: 'Prints only the end of a file, where a log keeps its newest entries.', + command: 'Get-Content FILE -Tail 20', + variants: [ + { label: 'The last rows of any command', command: 'COMMAND | Select-Object -Last 10' } + ], + placeholders: { FILE: 'The file to read', COMMAND: 'Any command that prints a list' }, + keywords: [ + 'tail', + 'tail -n', + 'end of file', + 'last 10 lines', + 'bottom of file', + 'latest log lines', + 'recent log entries' + ] + }, + { + id: 'ps-follow-file', + shell: 'powershell', + category: 'files', + task: 'Watch a log file as it grows', + summary: + 'Prints the last lines and then keeps printing new ones as they are written. Press Ctrl+C to stop watching.', + command: 'Get-Content FILE -Tail 20 -Wait', + variants: [ + { + label: 'Only the new lines that mention something', + command: "Get-Content FILE -Tail 20 -Wait | Select-String 'TEXT'" + } + ], + placeholders: { FILE: 'The log file to follow', TEXT: 'The word to look for, such as error' }, + keywords: [ + 'tail -f', + 'follow log', + 'watch file', + 'live log', + 'stream log', + 'monitor file', + 'log keeps updating', + 'real time log' + ] + }, + { + id: 'ps-make-file', + shell: 'powershell', + category: 'files', + task: 'Make a new empty file', + summary: + 'Creates an empty file and refuses if one of that name already exists, so it can never wipe anything.', + command: 'New-Item FILE -ItemType File', + variants: [ + { + label: "Set an existing file's modified time to now (the other thing touch does)", + command: '(Get-Item FILE).LastWriteTime = Get-Date' + } + ], + placeholders: { FILE: 'The name of the new file, such as notes.txt' }, + keywords: [ + 'touch', + 'create file', + 'new file', + 'empty file', + 'blank file', + 'make a file', + 'ni', + 'type nul', + 'add file' + ] + }, + { + id: 'ps-write-text-to-file', + shell: 'powershell', + category: 'files', + task: 'Put a line of text into a file', + summary: + 'Writes the text as the whole content of the file, creating it if needed. Use the Add-Content variant to keep what is there.', + command: "Set-Content FILE 'TEXT'", + variants: [{ label: 'Add a line to the end instead', command: "Add-Content FILE 'TEXT'" }], + placeholders: { FILE: 'The file to write', TEXT: 'The text to put in it' }, + keywords: [ + 'echo to file', + 'echo >', + 'echo >>', + 'write file', + 'append to file', + 'add line to file', + 'create file with text', + 'save text', + '.gitignore', + '.env' + ], + danger: 'Set-Content replaces everything already in the file.' + }, + { + id: 'ps-copy-file', + shell: 'powershell', + category: 'files', + task: 'Copy a file', + summary: + 'Copies a file to a new name or into another folder. cp and copy are short names for it.', + command: 'Copy-Item SOURCE DESTINATION', + variants: [ + { label: 'Every file of one kind into a folder', command: "Copy-Item '*.EXTENSION' FOLDER" } + ], + placeholders: { + SOURCE: 'The file to copy', + DESTINATION: 'The new name, or the folder to copy it into', + EXTENSION: 'A file ending, such as txt', + FOLDER: 'The folder to copy into' + }, + keywords: ['cp', 'copy', 'duplicate file', 'make a copy', 'backup file', 'clone file', 'cpi'], + danger: 'A file of the same name at the destination is replaced without a question.' + }, + { + id: 'ps-move-file', + shell: 'powershell', + category: 'files', + task: 'Move a file or folder somewhere else', + summary: + 'Moves a file or a whole folder. It stops with an error rather than replacing something of the same name at the destination.', + command: 'Move-Item SOURCE DESTINATION', + placeholders: { + SOURCE: 'The file or folder to move', + DESTINATION: 'The folder to move it into, or its new path' + }, + keywords: [ + 'mv', + 'move', + 'relocate', + 'put file in folder', + 'cut and paste', + 'transfer file', + 'mi' + ] + }, + { + id: 'ps-rename-file', + shell: 'powershell', + category: 'files', + task: 'Rename a file or folder', + summary: 'Gives a file or folder a new name where it sits.', + command: 'Rename-Item OLD_NAME NEW_NAME', + placeholders: { OLD_NAME: 'The name it has now', NEW_NAME: 'The name it should have' }, + keywords: ['ren', 'rename', 'mv', 'change file name', 'change extension', 'new name', 'rni'] + }, + { + id: 'ps-delete-file', + shell: 'powershell', + category: 'files', + task: 'Delete a file', + summary: + 'Removes the file straight away. It does NOT go to the Recycle Bin, so use the Recycle Bin variant when you might want it back.', + command: 'Remove-Item FILE', + variants: [ + { label: 'See what would be deleted, without deleting', command: 'Remove-Item FILE -WhatIf' }, + { + label: 'Send it to the Recycle Bin instead', + command: + "Add-Type -AssemblyName Microsoft.VisualBasic; [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile((Resolve-Path FILE), 'OnlyErrorDialogs', 'SendToRecycleBin')" + }, + { label: 'Every file of one kind in this folder', command: "Remove-Item '*.EXTENSION'" } + ], + placeholders: { FILE: 'The file to delete', EXTENSION: 'A file ending, such as log' }, + keywords: [ + 'rm', + 'del', + 'erase', + 'delete file', + 'remove file', + 'get rid of file', + 'recycle bin', + 'trash', + 'unlink', + 'ri' + ], + danger: 'The file is gone for good: Remove-Item does not use the Recycle Bin.' + }, + { + id: 'ps-find-file-by-name', + shell: 'powershell', + category: 'files', + task: 'Find a file by name, in any subfolder', + summary: + 'Searches this folder and everything under it for names containing the word. Folders it may not read are skipped quietly.', + command: "Get-ChildItem -Recurse -Filter '*NAME*' -ErrorAction SilentlyContinue", + variants: [ + { + label: 'Just the full paths', + command: "(Get-ChildItem -Recurse -Filter '*NAME*' -ErrorAction SilentlyContinue).FullName" + }, + { + label: 'Every file of one kind', + command: "Get-ChildItem -Recurse -File -Filter '*.EXTENSION' -ErrorAction SilentlyContinue" + }, + { + label: 'Folders only', + command: "Get-ChildItem -Recurse -Directory -Filter '*NAME*' -ErrorAction SilentlyContinue" + }, + { + label: 'Start from another folder', + command: "Get-ChildItem FOLDER -Recurse -Filter '*NAME*' -ErrorAction SilentlyContinue" + } + ], + placeholders: { + NAME: 'Part of the file name', + EXTENSION: 'A file ending, such as pdf', + FOLDER: 'Where to start looking' + }, + keywords: [ + 'find', + 'find . -name', + 'dir /s', + 'locate', + 'search for file', + 'where is my file', + 'lost file', + 'look for file', + 'recursive search', + 'file name search' + ] + }, + { + id: 'ps-biggest-files', + shell: 'powershell', + category: 'files', + task: 'Find the biggest files in a folder', + summary: + 'Lists the twenty largest files under the current folder with their size in megabytes: the first thing to run when a disk is full.', + command: + "Get-ChildItem -Recurse -File -ErrorAction SilentlyContinue | Sort-Object Length -Descending | Select-Object -First 20 FullName, @{n='MB';e={[math]::Round($_.Length/1MB,1)}}", + variants: [ + { + label: 'Only files over 100 MB', + command: + 'Get-ChildItem -Recurse -File -ErrorAction SilentlyContinue | Where-Object Length -gt 100MB | Sort-Object Length -Descending | Select-Object FullName, Length' + } + ], + keywords: [ + 'big files', + 'large files', + 'largest files', + 'what is taking up space', + 'disk full', + 'free up space', + 'du', + 'find -size', + 'huge files', + 'out of disk space', + 'sort by size' + ] + }, + { + id: 'ps-recent-files', + shell: 'powershell', + category: 'files', + task: 'Find files changed recently', + summary: + 'Lists the files under this folder that were modified in the last day, newest first. Handy for seeing what an agent or an installer just touched.', + command: + 'Get-ChildItem -Recurse -File | Where-Object LastWriteTime -gt (Get-Date).AddDays(-1) | Sort-Object LastWriteTime -Descending | Select-Object LastWriteTime, FullName', + variants: [ + { + label: 'In the last hour', + command: + 'Get-ChildItem -Recurse -File | Where-Object LastWriteTime -gt (Get-Date).AddHours(-1) | Sort-Object LastWriteTime -Descending | Select-Object LastWriteTime, FullName' + } + ], + keywords: [ + 'find -mtime', + 'recently modified', + 'changed today', + 'what changed', + 'new files', + 'latest files', + 'newest files', + 'modified in the last', + 'what did it touch', + 'files edited' + ] + }, + { + id: 'ps-search-in-files', + shell: 'powershell', + category: 'files', + task: 'Find text inside the files of a folder', + summary: + 'Searches every file under this folder and prints file, line number and the matching line. The text is a regular expression and case does not matter.', + command: "Get-ChildItem -Recurse -File | Select-String 'TEXT'", + variants: [ + { + label: 'Only in some kinds of file', + command: "Get-ChildItem -Recurse -File -Include *.ts, *.js | Select-String 'TEXT'" + }, + { + label: 'Skip node_modules', + command: + "Get-ChildItem -Recurse -File | Where-Object FullName -notlike '*\\node_modules\\*' | Select-String 'TEXT'" + }, + { + label: 'Only the names of the files that match', + command: + "Get-ChildItem -Recurse -File | Select-String 'TEXT' -List | Select-Object -ExpandProperty Path" + }, + { + label: 'Exact text, not a regular expression', + command: "Get-ChildItem -Recurse -File | Select-String 'TEXT' -SimpleMatch" + } + ], + placeholders: { TEXT: 'The text to look for' }, + keywords: [ + 'grep', + 'grep -r', + 'grep -rn', + 'rg', + 'ripgrep', + 'findstr', + 'findstr /s', + 'search in files', + 'search all files for a word', + 'find text in files', + 'which file contains', + 'search code', + 'find in folder', + 'sls' + ] + }, + { + id: 'ps-zip', + shell: 'powershell', + category: 'files', + task: 'Zip a folder or some files', + summary: + 'Packs a folder (or files) into a .zip. It refuses to replace a zip that already exists.', + command: 'Compress-Archive -Path FOLDER -DestinationPath NAME.zip', + variants: [ + { + label: 'Add to, or refresh, a zip that already exists', + command: 'Compress-Archive -Path FOLDER -DestinationPath NAME.zip -Update' + }, + { + label: 'A .tar.gz instead (tar ships with Windows)', + command: 'tar -czf NAME.tar.gz FOLDER' + } + ], + placeholders: { FOLDER: 'The folder or file to pack', NAME: 'The name of the archive to make' }, + keywords: [ + 'zip', + 'compress', + 'archive', + 'make a zip', + 'pack folder', + 'tar', + 'tar -czf', + 'create archive', + 'send folder', + 'shrink' + ] + }, + { + id: 'ps-unzip', + shell: 'powershell', + category: 'files', + task: 'Unzip an archive', + summary: + 'Unpacks a .zip into a folder, making the folder if needed. It stops if a file is already there, unless you add -Force.', + command: 'Expand-Archive NAME.zip -DestinationPath FOLDER', + variants: [ + { label: 'See what is inside without unpacking', command: 'tar -tf NAME.zip' }, + { label: 'Unpack a .tar.gz or .tgz into this folder', command: 'tar -xzf NAME.tar.gz' } + ], + placeholders: { NAME: 'The archive to unpack', FOLDER: 'Where the contents should go' }, + keywords: [ + 'unzip', + 'extract', + 'unpack', + 'decompress', + 'open zip', + 'tar -xzf', + 'untar', + 'expand archive', + 'extract zip', + 'tgz' + ] + }, + { + id: 'ps-file-hash', + shell: 'powershell', + category: 'files', + task: 'Check the checksum of a download', + summary: + 'Prints the SHA-256 hash of a file, to compare with the one a download page publishes. If they differ, the file is damaged or not the real one.', + command: 'Get-FileHash FILE', + variants: [ + { label: 'MD5 instead', command: 'Get-FileHash FILE -Algorithm MD5' }, + { label: 'SHA-1 instead', command: 'Get-FileHash FILE -Algorithm SHA1' } + ], + placeholders: { FILE: 'The file to check' }, + keywords: [ + 'sha256sum', + 'md5sum', + 'shasum', + 'checksum', + 'hash', + 'verify download', + 'integrity', + 'certutil -hashfile', + 'file fingerprint', + 'is this file the same' + ] + }, + { + id: 'ps-open-file', + shell: 'powershell', + category: 'files', + task: 'Open a file in its usual app', + summary: + 'Opens the file the way a double-click in Explorer would: a PDF in the PDF reader, a picture in the photo viewer.', + command: 'Invoke-Item FILE', + variants: [ + { label: 'In Notepad', command: 'notepad FILE' }, + { label: 'In VS Code, if it is installed', command: 'code FILE' } + ], + placeholders: { FILE: 'The file to open' }, + keywords: [ + 'open', + 'xdg-open', + 'start', + 'double click', + 'launch file', + 'default app', + 'open with', + 'ii', + 'edit file', + 'open in editor' + ] + }, + { + id: 'ps-file-info', + shell: 'powershell', + category: 'files', + task: 'See the size and dates of a file', + summary: 'Shows how big a file is in bytes, when it was made and when it last changed.', + command: 'Get-Item FILE | Select-Object Name, Length, CreationTime, LastWriteTime, Attributes', + variants: [ + { label: 'Everything PowerShell knows about it', command: 'Get-Item FILE | Format-List *' } + ], + placeholders: { FILE: 'The file to look at' }, + keywords: [ + 'stat', + 'file size', + 'file details', + 'properties', + 'modified date', + 'created date', + 'how big is this file', + 'when was it changed', + 'read only', + 'attributes' + ] + }, + { + id: 'ps-full-path', + shell: 'powershell', + category: 'files', + task: 'Get the full path of a file', + summary: + 'Turns a short or relative name into the complete path from the drive letter, ready to paste into another app or an agent.', + command: '(Resolve-Path FILE).Path', + variants: [ + { + label: 'Copy it straight to the clipboard', + command: '(Resolve-Path FILE).Path | Set-Clipboard' + }, + { label: 'Check whether a path exists at all (True or False)', command: 'Test-Path FILE' } + ], + placeholders: { FILE: 'The file or folder' }, + keywords: [ + 'realpath', + 'readlink -f', + 'absolute path', + 'full path', + 'copy path', + 'path of file', + 'complete path', + 'does file exist', + 'test -f', + 'if exist' + ] + }, + { + id: 'ps-make-link', + shell: 'powershell', + category: 'files', + task: 'Make a folder appear in a second place (a link)', + summary: + 'Creates a junction: a folder that is really another folder. It needs no administrator rights, and the target must be written as a full path.', + command: 'New-Item -ItemType Junction -Path LINK -Target FULL_PATH', + variants: [ + { + label: 'A symbolic link, which also works for files (needs an administrator shell)', + command: 'New-Item -ItemType SymbolicLink -Path LINK -Target FULL_PATH' + } + ], + placeholders: { + LINK: 'The name of the new link', + FULL_PATH: 'The full path of the real folder, from the drive letter' + }, + keywords: [ + 'ln -s', + 'symlink', + 'symbolic link', + 'mklink', + 'junction', + 'shortcut folder', + 'link folder', + 'alias folder', + 'administrator privilege required' + ] + }, + + // ---------------------------------------------------------------- folders + { + id: 'ps-where-am-i', + shell: 'powershell', + category: 'folders', + task: 'See which folder I am in', + summary: + 'Prints the current folder, the one every command without a path acts on. pwd is a short name for it.', + command: 'Get-Location', + variants: [ + { label: 'Copy it to the clipboard', command: '(Get-Location).Path | Set-Clipboard' } + ], + keywords: [ + 'pwd', + 'where am i', + 'current folder', + 'current directory', + 'working directory', + 'cd', + 'which folder', + 'path of this folder', + 'gl' + ] + }, + { + id: 'ps-change-folder', + shell: 'powershell', + category: 'folders', + task: 'Go to another folder', + summary: + 'Moves the shell into a folder. cd is a short name for it. Put quotes round a path that has spaces, and press Tab to complete a name.', + command: 'Set-Location FOLDER', + variants: [ + { + label: 'A path with spaces needs quotes (an example)', + command: "Set-Location 'C:\\Program Files'" + }, + { label: 'Your home folder', command: 'Set-Location ~' }, + { label: 'Your Downloads folder', command: 'Set-Location ~\\Downloads' }, + { label: 'Another drive', command: 'Set-Location D:' }, + { + label: 'Back to the folder you were in before (PowerShell 7 only)', + command: 'Set-Location -' + } + ], + placeholders: { FOLDER: 'The folder to go to' }, + keywords: [ + 'cd', + 'chdir', + 'change directory', + 'go to folder', + 'navigate', + 'enter folder', + 'switch drive', + 'home folder', + 'cd ~', + 'cd -', + 'path with spaces', + 'sl' + ] + }, + { + id: 'ps-go-up-a-folder', + shell: 'powershell', + category: 'folders', + task: 'Go back up a folder', + summary: 'Two dots mean "the folder that holds this one", so this steps out one level.', + command: 'Set-Location ..', + variants: [ + { label: 'Two levels up', command: 'Set-Location ..\\..' }, + { label: 'Straight to the top of the drive', command: 'Set-Location \\' } + ], + keywords: [ + 'cd ..', + 'go back', + 'parent folder', + 'up one level', + 'previous folder', + 'back a folder', + 'leave folder', + 'go up', + 'out of this folder' + ] + }, + { + id: 'ps-make-folder', + shell: 'powershell', + category: 'folders', + task: 'Make a new folder', + summary: + 'Creates a folder, and any missing folders on the way to it, so A\\B\\C works in one go. mkdir is a short name for it.', + command: 'New-Item -ItemType Directory FOLDER', + variants: [{ label: 'The short way', command: 'mkdir FOLDER' }], + placeholders: { FOLDER: 'The name of the new folder' }, + keywords: [ + 'mkdir', + 'mkdir -p', + 'md', + 'create folder', + 'new folder', + 'make directory', + 'create directory', + 'nested folders', + 'add folder' + ] + }, + { + id: 'ps-delete-folder', + shell: 'powershell', + category: 'folders', + task: 'Delete a folder and everything in it', + summary: + 'Removes the folder with all its files and subfolders. Run the -WhatIf variant first to read the list of what would go.', + command: 'Remove-Item FOLDER -Recurse', + variants: [ + { + label: 'See what would be deleted, without deleting', + command: 'Remove-Item FOLDER -Recurse -WhatIf' + }, + { + label: 'Also remove read-only and hidden files (node_modules, .git)', + command: 'Remove-Item FOLDER -Recurse -Force' + } + ], + placeholders: { FOLDER: 'The folder to delete' }, + keywords: [ + 'rm -rf', + 'rmdir', + 'rd /s', + 'delete folder', + 'remove directory', + 'delete node_modules', + 'wipe folder', + 'directory not empty', + 'remove folder' + ], + danger: 'Everything inside the folder is deleted for good, with no Recycle Bin.' + }, + { + id: 'ps-copy-folder', + shell: 'powershell', + category: 'folders', + task: 'Copy a whole folder', + summary: + 'Copies a folder with everything in it. If DESTINATION already exists the copy lands INSIDE it, otherwise DESTINATION becomes the copy.', + command: 'Copy-Item FOLDER DESTINATION -Recurse', + variants: [ + { + label: 'A big folder, with progress and retries (robocopy ships with Windows)', + command: 'robocopy FOLDER DESTINATION /E' + } + ], + placeholders: { FOLDER: 'The folder to copy', DESTINATION: 'Where the copy goes' }, + keywords: [ + 'cp -r', + 'xcopy', + 'robocopy', + 'copy directory', + 'duplicate folder', + 'backup folder', + 'copy everything', + 'rsync', + 'clone folder' + ], + danger: 'Files of the same name already at the destination are replaced.' + }, + { + id: 'ps-folder-size', + shell: 'powershell', + category: 'folders', + task: 'See how big a folder is', + summary: + 'Adds up every file under the folder, hidden ones included, and prints the total in megabytes. A very large folder takes a moment.', + command: + "'{0:N1} MB' -f ((Get-ChildItem FOLDER -Recurse -File -Force -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum / 1MB)", + variants: [ + { + label: 'The size of each subfolder here, biggest first', + command: + 'Get-ChildItem -Directory | ForEach-Object { [pscustomobject]@{ Folder = $_.Name; MB = [math]::Round((Get-ChildItem $_.FullName -Recurse -File -Force -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum / 1MB, 1) } } | Sort-Object MB -Descending' + } + ], + placeholders: { FOLDER: 'The folder to measure, or . for this one' }, + keywords: [ + 'du', + 'du -sh', + 'folder size', + 'directory size', + 'how big is this folder', + 'size of node_modules', + 'disk usage', + 'what is taking up space', + 'total size' + ] + }, + { + id: 'ps-folder-tree', + shell: 'powershell', + category: 'folders', + task: 'See a folder as a tree', + summary: + 'Draws the folders and files under this one as an indented tree. Avoid running it on a folder holding node_modules: the output runs for minutes.', + command: 'tree /F', + variants: [ + { label: 'Folders only', command: 'tree' }, + { + label: 'Another folder, in plain characters that paste well', + command: 'tree FOLDER /F /A' + }, + { + label: 'Only two levels deep, as a plain list', + command: 'Get-ChildItem -Recurse -Depth 1 -Name' + } + ], + placeholders: { FOLDER: 'The folder to draw' }, + keywords: [ + 'tree', + 'folder structure', + 'project structure', + 'directory tree', + 'show hierarchy', + 'ls -r', + 'nested folders', + 'outline of folder', + 'layout' + ] + }, + { + id: 'ps-open-in-explorer', + shell: 'powershell', + category: 'folders', + task: 'Open this folder in File Explorer', + summary: 'Opens a File Explorer window on the current folder. The dot means "here".', + command: 'explorer .', + variants: [ + { label: 'Another folder', command: 'Invoke-Item FOLDER' }, + { + label: 'Open the folder with one file already selected', + command: 'explorer "/select,$(Resolve-Path FILE)"' + } + ], + placeholders: { FOLDER: 'The folder to open', FILE: 'The file to highlight' }, + keywords: [ + 'explorer', + 'open folder', + 'file explorer', + 'finder', + 'open .', + 'xdg-open', + 'show in explorer', + 'reveal in explorer', + 'windows explorer', + 'start .' + ] + }, + { + id: 'ps-count-files', + shell: 'powershell', + category: 'folders', + task: 'Count the files in a folder', + summary: 'Counts every file under this folder, subfolders included.', + command: '(Get-ChildItem -Recurse -File | Measure-Object).Count', + variants: [ + { + label: 'This folder only, no subfolders', + command: '(Get-ChildItem -File | Measure-Object).Count' + }, + { + label: 'How many of each kind', + command: + 'Get-ChildItem -Recurse -File | Group-Object Extension | Sort-Object Count -Descending | Select-Object Count, Name' + } + ], + keywords: [ + 'ls | wc -l', + 'how many files', + 'count files', + 'number of files', + 'file count', + 'count by extension', + 'how many items', + 'find | wc -l' + ] + }, + + // ------------------------------------------------------------------- text + { + id: 'ps-filter-output', + shell: 'powershell', + category: 'text', + task: 'Keep only the lines of output that mention something', + summary: + 'Filters what a command prints down to the lines containing the text. Out-String -Stream is what makes it work for PowerShell commands as well as for programs such as npm or git.', + command: "COMMAND | Out-String -Stream | Select-String 'TEXT'", + variants: [ + { + label: 'Lines that do NOT mention it', + command: "COMMAND | Out-String -Stream | Select-String 'TEXT' -NotMatch" + }, + { + label: 'Match upper and lower case exactly', + command: "COMMAND | Out-String -Stream | Select-String 'TEXT' -CaseSensitive" + } + ], + placeholders: { + COMMAND: 'The command whose output you want to filter', + TEXT: 'The text to keep' + }, + keywords: [ + '| grep', + 'grep -v', + 'grep -i', + 'findstr', + 'filter output', + 'search output', + 'only lines containing', + 'pipe to grep', + 'too much output', + 'sls' + ] + }, + { + id: 'ps-search-in-one-file', + shell: 'powershell', + category: 'text', + task: 'Find a word in one file', + summary: + 'Prints each matching line of the file with its line number. The pattern is a regular expression and ignores case.', + command: "Select-String -Path FILE -Pattern 'TEXT'", + variants: [ + { + label: 'With two lines of context either side', + command: "Select-String -Path FILE -Pattern 'TEXT' -Context 2" + }, + { + label: 'Exact text, not a regular expression', + command: "Select-String -Path FILE -Pattern 'TEXT' -SimpleMatch" + }, + { + label: 'Every log file in this folder', + command: "Select-String -Path *.log -Pattern 'TEXT'" + }, + { label: 'Count the matches', command: "(Select-String -Path FILE -Pattern 'TEXT').Count" } + ], + placeholders: { FILE: 'The file to search', TEXT: 'The text to look for' }, + keywords: [ + 'grep one file', + 'grep -n', + 'grep -c', + 'grep context lines', + 'findstr', + 'search file', + 'find word in file', + 'find error in log', + 'line number', + 'regex search' + ] + }, + { + id: 'ps-count-lines', + shell: 'powershell', + category: 'text', + task: 'Count the lines in a file', + summary: 'Prints how many lines the file has.', + command: '(Get-Content FILE | Measure-Object -Line).Lines', + variants: [ + { + label: 'Lines, words and characters together', + command: 'Get-Content FILE | Measure-Object -Line -Word -Character' + }, + { label: 'Count the rows any command prints', command: '(COMMAND | Measure-Object).Count' } + ], + placeholders: { FILE: 'The file to count', COMMAND: 'Any command that prints a list' }, + keywords: [ + 'wc', + 'wc -l', + 'wc -w', + 'line count', + 'word count', + 'how many lines', + 'count rows', + 'character count', + 'length of file' + ] + }, + { + id: 'ps-replace-text-in-file', + shell: 'powershell', + category: 'text', + task: 'Replace a word everywhere in a file', + summary: + 'Reads the file, swaps every match and writes it back. OLD is a regular expression, so put a backslash before characters such as . ( ) or [.', + command: "(Get-Content FILE -Raw) -replace 'OLD', 'NEW' | Set-Content FILE -NoNewline", + variants: [ + { + label: 'Show the result without changing the file', + command: "(Get-Content FILE -Raw) -replace 'OLD', 'NEW'" + } + ], + placeholders: { + FILE: 'The file to change', + OLD: 'The text to find', + NEW: 'The text to put in its place' + }, + keywords: [ + 'sed', + 'sed -i', + 'find and replace', + 'search and replace', + 'substitute', + 'swap text', + 'change word in file', + 'replace string', + 'regex replace' + ], + danger: 'The file is rewritten in place and there is no undo, so copy it first if it matters.' + }, + { + id: 'ps-compare-files', + shell: 'powershell', + category: 'text', + task: 'See what differs between two files', + summary: + 'Lists the lines that are in one file and not the other: => means only in the second file, <= only in the first. No output means they match.', + command: 'Compare-Object (Get-Content FILE_A) (Get-Content FILE_B)', + variants: [ + { + label: 'The classic Windows comparison (plain fc means something else in PowerShell)', + command: 'fc.exe FILE_A FILE_B' + }, + { + label: 'With git installed, a proper coloured diff', + command: 'git diff --no-index FILE_A FILE_B' + } + ], + placeholders: { FILE_A: 'The first file', FILE_B: 'The second file' }, + keywords: [ + 'diff', + 'compare', + 'fc', + 'difference between files', + 'what changed', + 'are these files the same', + 'side by side', + 'compare two files' + ] + }, + { + id: 'ps-sort-unique-lines', + shell: 'powershell', + category: 'text', + task: 'Sort lines and drop the duplicates', + summary: + 'Prints the lines of a file in order with each one appearing once. Upper and lower case count as the same.', + command: 'Get-Content FILE | Sort-Object -Unique', + variants: [ + { + label: 'How often each line appears, most common first', + command: + 'Get-Content FILE | Group-Object | Sort-Object Count -Descending | Select-Object Count, Name' + } + ], + placeholders: { FILE: 'The file to read' }, + keywords: [ + 'sort', + 'uniq', + 'sort -u', + 'uniq -c', + 'sort | uniq', + 'remove duplicates', + 'dedupe', + 'unique lines', + 'most common', + 'count occurrences' + ] + }, + { + id: 'ps-pick-columns', + shell: 'powershell', + category: 'text', + task: 'Show only some columns of a result', + summary: + 'PowerShell commands return rows with named properties, so you pick columns by name rather than cutting text apart.', + command: 'COMMAND | Select-Object PROPERTY_ONE, PROPERTY_TWO', + variants: [ + { + label: 'An example: the name and ID of every process', + command: 'Get-Process | Select-Object Name, Id' + }, + { + label: 'Find out which properties a result has', + command: 'COMMAND | Get-Member -MemberType Property' + }, + { + label: 'Just the bare values of one column', + command: 'COMMAND | Select-Object -ExpandProperty PROPERTY_ONE' + } + ], + placeholders: { + COMMAND: 'Any PowerShell command', + PROPERTY_ONE: 'A column name, such as Name', + PROPERTY_TWO: 'Another column name' + }, + keywords: [ + 'awk', + 'cut', + 'awk print $1', + 'select columns', + 'choose fields', + 'only show name', + 'fewer columns', + 'get-member', + 'properties of object' + ] + }, + { + id: 'ps-sort-and-top', + shell: 'powershell', + category: 'text', + task: 'Sort a result and keep the top ten', + summary: + 'Orders the rows of any PowerShell result by one column, largest first, and keeps the first ten.', + command: 'COMMAND | Sort-Object PROPERTY -Descending | Select-Object -First 10', + placeholders: { + COMMAND: 'Any PowerShell command', + PROPERTY: 'The column to sort by, such as Length or CPU' + }, + keywords: [ + 'sort', + 'sort -r', + 'sort | head', + 'order by', + 'top 10', + 'largest first', + 'highest', + 'ranking', + 'sort descending' + ] + }, + { + id: 'ps-filter-rows', + shell: 'powershell', + category: 'text', + task: 'Keep only the rows that meet a condition', + summary: + 'Filters a PowerShell result by one of its columns. Swap -like for -eq (equals), -gt (greater than), -lt (less than) or -match (regular expression).', + command: "COMMAND | Where-Object PROPERTY -like '*TEXT*'", + variants: [ + { + label: 'An example: files over 10 MB here', + command: 'Get-ChildItem -File | Where-Object Length -gt 10MB' + }, + { + label: 'Two conditions at once', + command: + "COMMAND | Where-Object { $_.PROPERTY -like '*TEXT*' -and $_.OTHER_PROPERTY -gt NUMBER }" + } + ], + placeholders: { + COMMAND: 'Any PowerShell command', + PROPERTY: 'The column to test, such as Name', + TEXT: 'What the column should contain', + OTHER_PROPERTY: 'A second column', + NUMBER: 'A number to compare with' + }, + keywords: [ + 'where', + 'where-object', + 'filter', + 'awk', + 'select where', + 'only matching', + 'condition', + 'greater than', + 'rows where', + 'query results' + ] + }, + { + id: 'ps-output-cut-off', + shell: 'powershell', + category: 'text', + task: 'Stop output being cut off with three dots', + summary: + 'A table that does not fit is shortened with three dots. Showing each row as a list gives every value its full width.', + command: 'COMMAND | Format-List', + variants: [ + { label: 'Every property, not just the usual ones', command: 'COMMAND | Format-List *' }, + { + label: 'Keep the table but wrap long values', + command: 'COMMAND | Format-Table -AutoSize -Wrap' + } + ], + placeholders: { COMMAND: 'The command whose output is being shortened' }, + keywords: [ + 'truncated', + 'cut off', + 'three dots', + 'ellipsis', + '...', + 'cannot see full path', + 'show everything', + 'full output', + 'wider', + 'fl', + 'ft' + ] + }, + { + id: 'ps-page-output', + shell: 'powershell', + category: 'text', + task: 'Read long output a page at a time', + summary: + 'Pauses after each screenful: Space shows the next page, Enter the next line, Q stops.', + command: 'COMMAND | Out-Host -Paging', + variants: [ + { label: 'The classic way, which also works for programs', command: 'COMMAND | more' } + ], + placeholders: { COMMAND: 'The command with a lot of output' }, + keywords: [ + 'less', + 'more', + 'pager', + 'scrolls past too fast', + 'too much output', + 'page by page', + 'pause output', + 'read slowly' + ] + }, + { + id: 'ps-read-json', + shell: 'powershell', + category: 'text', + task: 'Read a value out of a JSON file', + summary: + 'Turns the JSON into an object so you can walk into it with dots, for example .scripts on a package.json.', + command: '(Get-Content FILE.json -Raw | ConvertFrom-Json).KEY', + variants: [ + { + label: 'Show the whole file as an object', + command: 'Get-Content FILE.json -Raw | ConvertFrom-Json' + }, + { label: 'Turn any result INTO JSON', command: 'COMMAND | ConvertTo-Json -Depth 5' }, + { label: 'Read a CSV file as a table', command: 'Import-Csv FILE.csv | Format-Table' } + ], + placeholders: { + FILE: 'The file name without its ending', + KEY: 'The property to read, such as version or scripts.build', + COMMAND: 'Any PowerShell command' + }, + keywords: [ + 'jq', + 'json', + 'parse json', + 'package.json', + 'read config', + 'convertfrom-json', + 'csv', + 'pretty print json', + 'get value from json' + ] + }, + + // -------------------------------------------------------------- processes + { + id: 'ps-list-processes', + shell: 'powershell', + category: 'processes', + task: 'See what is running', + summary: + 'Lists every running program with its ID, memory and CPU time. ps is a short name for it.', + command: 'Get-Process', + variants: [ + { + label: 'The ten using the most memory', + command: + "Get-Process | Sort-Object WorkingSet64 -Descending | Select-Object -First 10 Name, Id, @{n='MB';e={[math]::Round($_.WorkingSet64/1MB)}}" + }, + { + label: 'The ten that have used the most CPU time', + command: 'Get-Process | Sort-Object CPU -Descending | Select-Object -First 10 Name, Id, CPU' + }, + { label: 'The classic Windows list', command: 'tasklist' } + ], + keywords: [ + 'ps', + 'ps aux', + 'top', + 'htop', + 'tasklist', + 'task manager', + 'running programs', + 'what is using memory', + 'what is using cpu', + 'computer is slow', + 'gps' + ] + }, + { + id: 'ps-find-process', + shell: 'powershell', + category: 'processes', + task: 'Check whether a program is running', + summary: + 'Looks for running programs whose name contains the word, without the .exe. If it prints nothing, no such program is running.', + command: "Get-Process -Name '*NAME*'", + variants: [ + { + label: 'With the path of the program file', + command: "Get-Process -Name '*NAME*' | Select-Object Id, Name, Path" + }, + { + label: 'When it was started', + command: "Get-Process -Name '*NAME*' | Select-Object Id, Name, StartTime" + } + ], + placeholders: { NAME: 'Part of the program name, such as node or chrome' }, + keywords: [ + 'pgrep', + 'pidof', + 'ps | grep', + 'is it running', + 'find process', + 'process id', + 'pid', + 'is node running', + 'still running', + 'tasklist | findstr' + ] + }, + { + id: 'ps-process-command-line', + shell: 'powershell', + category: 'processes', + task: 'See how a running program was started', + summary: + "Shows the full command line and the parent of each matching process, which tells ten node.exe rows apart. Other users' processes show an empty command line unless the shell is elevated.", + command: + 'Get-CimInstance Win32_Process -Filter "Name=\'NAME.exe\'" | Select-Object ProcessId, ParentProcessId, CommandLine | Format-List', + variants: [ + { + label: 'Search every command line for a word', + command: + "Get-CimInstance Win32_Process | Where-Object CommandLine -like '*TEXT*' | Select-Object ProcessId, Name, CommandLine | Format-List" + } + ], + placeholders: { + NAME: 'The program name without .exe, such as node', + TEXT: 'Text to look for, such as a script name' + }, + keywords: [ + 'ps -ef', + 'ps aux', + 'command line of process', + 'which node is which', + 'arguments', + 'parent process', + 'ppid', + 'who started this', + 'process tree', + 'wmic process' + ] + }, + { + id: 'ps-stop-process', + shell: 'powershell', + category: 'processes', + task: 'Force a program to close', + summary: + 'Ends every running program of that name, without the .exe. Reach for it when a window has frozen or a server will not stop.', + command: 'Stop-Process -Name NAME', + variants: [ + { + label: 'See what would be stopped, without stopping it', + command: 'Stop-Process -Name NAME -WhatIf' + }, + { label: 'One process only, by its ID', command: 'Stop-Process -Id PROCESS_ID' }, + { label: 'It refuses to go', command: 'Stop-Process -Id PROCESS_ID -Force' }, + { label: 'A program AND everything it started', command: 'taskkill /F /T /PID PROCESS_ID' } + ], + placeholders: { + NAME: 'The program name without .exe, such as node', + PROCESS_ID: 'The number in the Id column of Get-Process' + }, + keywords: [ + 'kill', + 'kill -9', + 'killall', + 'pkill', + 'taskkill', + 'end task', + 'force quit', + 'frozen', + 'not responding', + 'close program', + 'hung', + 'spps' + ], + danger: 'The program closes at once and anything unsaved in it is lost.' + }, + { + id: 'ps-port-in-use', + shell: 'powershell', + category: 'processes', + task: 'Find out what is using a port', + summary: + 'Names the program listening on a port, which is the answer to "port 3000 is already in use". If it prints nothing, nothing is listening there.', + command: + 'Get-NetTCPConnection -LocalPort PORT -State Listen -ErrorAction SilentlyContinue | ForEach-Object { Get-Process -Id $_.OwningProcess } | Select-Object -Unique Id, ProcessName, Path', + variants: [ + { + label: 'The classic way: the last column is the process ID', + command: 'netstat -ano | findstr :PORT' + } + ], + placeholders: { PORT: 'The port number, such as 3000' }, + keywords: [ + 'lsof -i', + 'lsof -i :3000', + 'netstat', + 'ss -tulpn', + 'port already in use', + 'eaddrinuse', + 'address already in use', + 'what is on port 3000', + 'who is using port', + 'port taken', + 'localhost', + 'fuser' + ] + }, + { + id: 'ps-free-port', + shell: 'powershell', + category: 'processes', + task: 'Stop whatever is holding a port', + summary: + 'Finds the program listening on the port and ends it, so your own server can start. Check what it is first with "Find out what is using a port".', + command: + 'Get-NetTCPConnection -LocalPort PORT -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique | ForEach-Object { Stop-Process -Id $_ -Force }', + variants: [ + { + label: 'See what would be stopped, without stopping it', + command: + 'Get-NetTCPConnection -LocalPort PORT -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique | ForEach-Object { Stop-Process -Id $_ -Force -WhatIf }' + } + ], + placeholders: { PORT: 'The port number, such as 3000' }, + keywords: [ + 'kill port', + 'free port', + 'kill-port', + 'fuser -k', + 'npx kill-port', + 'port already in use', + 'eaddrinuse', + 'release port', + 'stop dev server', + 'kill $(lsof -t -i:3000)' + ], + danger: 'The program holding the port is ended at once, whatever it is, and loses unsaved work.' + }, + { + id: 'ps-cancel-command', + shell: 'powershell', + category: 'processes', + task: 'Stop a command that is running right now', + summary: + 'This is a key press, not something to paste: hold Ctrl and press C in the terminal. It interrupts the running command and gives you the prompt back. Press it twice if the first is ignored.', + command: 'Ctrl+C', + keywords: [ + 'cancel', + 'abort', + 'interrupt', + 'stop command', + 'how do i stop this', + 'stuck', + 'it will not stop', + 'exit running program', + 'break', + 'sigint', + 'quit server', + 'get my prompt back' + ] + }, + { + id: 'ps-run-in-background', + shell: 'powershell', + category: 'processes', + task: 'Run something without blocking the prompt', + summary: + 'Starts the program in a window of its own, so this prompt stays free. Closing that window stops it.', + command: "Start-Process PROGRAM -ArgumentList 'ARGUMENTS'", + variants: [ + { + label: 'As a hidden background job in this shell', + command: '$job = Start-Job { COMMAND }' + }, + { label: 'Read what a job has printed so far', command: 'Receive-Job $job' }, + { + label: 'List jobs, then stop and clear them all', + command: 'Get-Job; Get-Job | Remove-Job -Force' + }, + { label: 'The short form (PowerShell 7 only)', command: 'COMMAND &' } + ], + placeholders: { + PROGRAM: 'The program to start, such as npm', + ARGUMENTS: 'What you would type after it, such as run dev', + COMMAND: 'The whole command to run' + }, + keywords: [ + '&', + 'nohup', + 'bg', + 'background', + 'start-job', + 'run in new window', + 'keep using terminal', + 'detach', + 'run server and keep typing', + 'jobs', + 'fg' + ] + }, + + // ---------------------------------------------------------------- network + { + id: 'ps-ping', + shell: 'powershell', + category: 'network', + task: 'Check whether a site or machine answers', + summary: + 'Sends four pings and shows how long each reply took. No replies means it is down, blocked or unreachable.', + command: 'Test-Connection HOST -Count 4', + variants: [ + { label: 'Is my internet working at all?', command: 'Test-Connection 1.1.1.1 -Count 2' }, + { label: 'The classic ping', command: 'ping HOST' } + ], + placeholders: { HOST: 'A name or address, such as github.com' }, + keywords: [ + 'ping', + 'is it up', + 'is my internet working', + 'connection test', + 'latency', + 'no internet', + 'offline', + 'reach server', + 'network down', + 'timed out' + ] + }, + { + id: 'ps-test-port', + shell: 'powershell', + category: 'network', + task: 'Check whether a port on a server is open', + summary: + 'Tries a real TCP connection and reports TcpTestSucceeded True or False. It answers "is the server down or is it just me" better than ping, which many servers ignore.', + command: 'Test-NetConnection HOST -Port PORT', + variants: [ + { + label: 'Is my own dev server answering?', + command: 'Test-NetConnection localhost -Port PORT' + } + ], + placeholders: { + HOST: 'A name or address, such as github.com', + PORT: 'The port, such as 443 or 22' + }, + keywords: [ + 'nc -zv', + 'netcat', + 'telnet', + 'port open', + 'port check', + 'connection refused', + 'firewall', + 'can i reach', + 'tnc', + 'is the server up', + 'econnrefused' + ] + }, + { + id: 'ps-my-ip', + shell: 'powershell', + category: 'network', + task: 'Find my IP address', + summary: + 'Lists the address of each network adapter. The one starting 192.168 or 10 is your address on the local network; 169.254 means that adapter is not connected.', + command: 'Get-NetIPAddress -AddressFamily IPv4 | Select-Object InterfaceAlias, IPAddress', + variants: [ + { label: 'The classic full report', command: 'ipconfig' }, + { + label: 'With the router (gateway) address', + command: + "Get-NetIPConfiguration | Select-Object InterfaceAlias, @{n='IPv4';e={$_.IPv4Address.IPAddress}}, @{n='Gateway';e={$_.IPv4DefaultGateway.NextHop}}" + }, + { + label: 'My public address, as the internet sees it', + command: 'Invoke-RestMethod https://api.ipify.org' + } + ], + keywords: [ + 'ipconfig', + 'ifconfig', + 'ip a', + 'ip addr', + 'my ip', + 'local ip', + 'public ip', + 'what is my ip', + 'network address', + 'gateway', + 'router address', + 'hostname -i' + ] + }, + { + id: 'ps-download-file', + shell: 'powershell', + category: 'network', + task: 'Download a file from a URL', + summary: + 'Saves what is at the URL to a file. In Windows PowerShell 5.1 the words curl and wget are only aliases of this command, so for the real curl type curl.exe.', + command: 'Invoke-WebRequest URL -OutFile FILE -UseBasicParsing', + variants: [ + { label: 'With the real curl, following redirects', command: 'curl.exe -L -o FILE URL' }, + { + label: 'Much faster in 5.1: hide the progress bar first', + command: + "$ProgressPreference = 'SilentlyContinue'; Invoke-WebRequest URL -OutFile FILE -UseBasicParsing" + } + ], + placeholders: { URL: 'The full address, starting https://', FILE: 'The name to save it as' }, + keywords: [ + 'curl', + 'curl -o', + 'curl --output', + 'wget', + 'download', + 'fetch file', + 'save url', + 'iwr', + 'get file from internet', + 'download is slow' + ], + danger: 'A file of the same name is replaced without a question.' + }, + { + id: 'ps-call-api', + shell: 'powershell', + category: 'network', + task: 'Call a web API and read the answer', + summary: + 'Fetches a URL and, when the answer is JSON, hands it back as an object you can read with dots. It is what curl plus jq does elsewhere.', + command: 'Invoke-RestMethod URL', + variants: [ + { + label: 'Send JSON with a POST', + command: + 'Invoke-RestMethod URL -Method Post -ContentType \'application/json\' -Body \'{"KEY":"VALUE"}\'' + }, + { + label: 'With a bearer token', + command: "Invoke-RestMethod URL -Headers @{ Authorization = 'Bearer TOKEN' }" + }, + { label: 'Only the status code and headers', command: 'curl.exe -sI URL' }, + { label: 'The raw text, with the real curl', command: 'curl.exe -s URL' } + ], + placeholders: { + URL: 'The full address, starting https://', + KEY: 'A field name in the JSON you send', + VALUE: 'Its value', + TOKEN: 'Your API key or token' + }, + keywords: [ + 'curl', + 'curl -x post', + 'curl -h', + 'api', + 'rest', + 'http request', + 'get request', + 'post json', + 'irm', + 'test endpoint', + 'httpie', + 'status code' + ] + }, + { + id: 'ps-dns-lookup', + shell: 'powershell', + category: 'network', + task: 'Look up the address behind a domain name', + summary: + 'Asks DNS which IP addresses a name points to, which shows whether a new domain has taken effect yet.', + command: 'Resolve-DnsName HOST', + variants: [ + { + label: 'Another record type (MX, TXT, CNAME, NS)', + command: 'Resolve-DnsName HOST -Type TXT' + }, + { label: 'Ask a particular DNS server', command: 'Resolve-DnsName HOST -Server 1.1.1.1' }, + { label: 'The classic tool', command: 'nslookup HOST' } + ], + placeholders: { HOST: 'The domain name, such as github.com' }, + keywords: [ + 'nslookup', + 'dig', + 'host', + 'dns', + 'domain ip', + 'resolve name', + 'mx record', + 'txt record', + 'dns propagation', + 'could not resolve host', + 'enotfound' + ] + }, + { + id: 'ps-listening-ports', + shell: 'powershell', + category: 'network', + task: 'List every port this PC is listening on', + summary: 'Shows each open port with the program that owns it, lowest port first.', + command: + "Get-NetTCPConnection -State Listen | Sort-Object LocalPort | Select-Object LocalPort, OwningProcess, @{n='Process';e={(Get-Process -Id $_.OwningProcess).ProcessName}} -Unique", + variants: [{ label: 'The classic way, connections included', command: 'netstat -ano' }], + keywords: [ + 'netstat', + 'netstat -tulpn', + 'ss -tulpn', + 'lsof -i', + 'open ports', + 'listening ports', + 'which ports are in use', + 'what servers are running', + 'local servers', + 'port list' + ] + }, + { + id: 'ps-flush-dns', + shell: 'powershell', + category: 'network', + task: 'Clear the DNS cache', + summary: + 'Makes Windows forget the addresses it has remembered, so a site that has just moved is looked up afresh. Harmless: the cache refills by itself.', + command: 'ipconfig /flushdns', + variants: [ + { label: 'The PowerShell command for the same thing', command: 'Clear-DnsClientCache' } + ], + keywords: [ + 'flush dns', + 'dns cache', + 'site not loading', + 'old ip', + 'reset dns', + 'resolvectl flush-caches', + 'dscacheutil', + 'wrong server', + 'domain moved' + ] + }, + { + id: 'ps-trace-route', + shell: 'powershell', + category: 'network', + task: 'See where a connection gets stuck', + summary: + 'Lists each router between you and the destination with its delay. Where the replies stop or slow down is where the trouble is.', + command: 'tracert HOST', + variants: [{ label: 'The PowerShell way', command: 'Test-NetConnection HOST -TraceRoute' }], + placeholders: { HOST: 'A name or address, such as github.com' }, + keywords: [ + 'traceroute', + 'tracert', + 'mtr', + 'route', + 'hops', + 'slow connection', + 'where is it failing', + 'network path', + 'lag' + ] + }, + { + id: 'ps-open-url', + shell: 'powershell', + category: 'network', + task: 'Open a web page in my browser', + summary: + 'Opens the address in your default browser, which is handy for localhost while a dev server runs.', + command: "Start-Process 'URL'", + variants: [{ label: 'A local dev server', command: "Start-Process 'http://localhost:PORT'" }], + placeholders: { + URL: 'The full address, starting https://', + PORT: 'The port your server printed, such as 3000' + }, + keywords: [ + 'open', + 'xdg-open', + 'start', + 'open browser', + 'open link', + 'launch website', + 'open localhost', + 'default browser', + 'view site' + ] + }, + + // ----------------------------------------------------------------- system + { + id: 'ps-disk-space', + shell: 'powershell', + category: 'system', + task: 'See how much disk space is free', + summary: 'Lists each drive with the gigabytes used and free.', + command: 'Get-PSDrive -PSProvider FileSystem', + variants: [{ label: 'With the total size and health of each volume', command: 'Get-Volume' }], + keywords: [ + 'df', + 'df -h', + 'free space', + 'disk space', + 'disk full', + 'storage left', + 'how full is my drive', + 'list drives', + 'drive letters', + 'no space left on device', + 'enospc' + ] + }, + { + id: 'ps-memory', + shell: 'powershell', + category: 'system', + task: 'See how much memory is free', + summary: 'Prints the total and the currently free RAM in gigabytes.', + command: + "Get-CimInstance Win32_OperatingSystem | Select-Object @{n='TotalGB';e={[math]::Round($_.TotalVisibleMemorySize/1MB,1)}}, @{n='FreeGB';e={[math]::Round($_.FreePhysicalMemory/1MB,1)}}", + keywords: [ + 'free', + 'free -h', + 'ram', + 'memory', + 'how much ram', + 'out of memory', + 'memory usage', + 'vmstat', + 'available memory', + 'heap out of memory' + ] + }, + { + id: 'ps-powershell-version', + shell: 'powershell', + category: 'system', + task: 'Check which PowerShell I am using', + summary: + 'Prints the version. 5.1 is Windows PowerShell, the one built into Windows; 7 and up is the newer PowerShell, whose program is pwsh.', + command: '$PSVersionTable.PSVersion', + variants: [{ label: 'Is PowerShell 7 installed at all?', command: 'Get-Command pwsh' }], + keywords: [ + 'version', + 'powershell version', + 'pwsh', + 'which shell', + 'bash --version', + '5.1 or 7', + 'windows powershell', + 'powershell core', + 'what shell is this' + ] + }, + { + id: 'ps-windows-version', + shell: 'powershell', + category: 'system', + task: 'See my Windows version and hardware', + summary: 'Prints the Windows edition, build number and whether it is 64-bit.', + command: + 'Get-CimInstance Win32_OperatingSystem | Select-Object Caption, Version, OSArchitecture', + variants: [ + { + label: 'The processor and its core count', + command: + 'Get-CimInstance Win32_Processor | Select-Object Name, NumberOfCores, NumberOfLogicalProcessors' + }, + { + label: 'The graphics card and driver', + command: 'Get-CimInstance Win32_VideoController | Select-Object Name, DriverVersion' + }, + { label: 'The long classic report', command: 'systeminfo' } + ], + keywords: [ + 'uname', + 'uname -a', + 'lsb_release', + 'winver', + 'os version', + 'windows build', + 'system info', + 'lscpu', + 'what cpu', + 'what gpu', + 'specs', + '64 bit' + ] + }, + { + id: 'ps-env-list', + shell: 'powershell', + category: 'system', + task: 'See my environment variables', + summary: 'Lists every environment variable this shell has, with its value.', + command: 'Get-ChildItem Env:', + variants: [ + { label: 'Just one of them', command: '$env:NAME' }, + { + label: 'Those whose name contains a word', + command: "Get-ChildItem Env: | Where-Object Name -like '*TEXT*'" + } + ], + placeholders: { + NAME: 'The variable name, such as USERPROFILE', + TEXT: 'Part of a name, such as API' + }, + keywords: [ + 'env', + 'printenv', + 'set', + 'echo $var', + 'echo %var%', + 'environment variables', + 'show variable', + 'is my api key set', + 'list env', + 'gci env:' + ] + }, + { + id: 'ps-env-set-session', + shell: 'powershell', + category: 'system', + task: 'Set an environment variable for this session', + summary: + 'Sets the variable for this terminal and every program started from it. It is forgotten when the terminal closes, which suits an API key you would rather not store.', + command: "$env:NAME = 'VALUE'", + variants: [{ label: 'Remove it again', command: 'Remove-Item Env:NAME' }], + placeholders: { + NAME: 'The variable name, such as ANTHROPIC_API_KEY', + VALUE: 'The value to give it' + }, + keywords: [ + 'export', + 'export var=value', + 'set', + 'set var=value', + 'environment variable', + 'api key', + 'temporary variable', + 'unset', + 'env var', + 'node_env' + ] + }, + { + id: 'ps-env-set-permanent', + shell: 'powershell', + category: 'system', + task: 'Set an environment variable permanently', + summary: + 'Stores the variable for your Windows user, so every NEW terminal and program sees it. Terminals that are already open, this one included, do not: open a fresh one.', + command: "[Environment]::SetEnvironmentVariable('NAME', 'VALUE', 'User')", + variants: [ + { + label: 'Read back what is stored', + command: "[Environment]::GetEnvironmentVariable('NAME', 'User')" + }, + { + label: 'Delete the stored variable', + command: "[Environment]::SetEnvironmentVariable('NAME', $null, 'User')" + } + ], + placeholders: { NAME: 'The variable name', VALUE: 'The value to store' }, + keywords: [ + 'setx', + 'export in .bashrc', + 'permanent environment variable', + 'persist variable', + 'save api key', + 'user variable', + 'system properties', + 'variable not found after restart', + 'environment variables dialog' + ], + danger: 'Any value already stored under that name for your user is replaced.' + }, + { + id: 'ps-show-path', + shell: 'powershell', + category: 'system', + task: 'See the folders on my PATH', + summary: + 'PATH is the list of folders Windows searches when you type a program name. This prints it one folder per line.', + command: "$env:Path -split ';'", + keywords: [ + 'echo $path', + 'echo %path%', + 'path', + 'show path', + 'path variable', + 'where does it look for commands', + 'command not found', + 'is not recognized' + ] + }, + { + id: 'ps-path-add-session', + shell: 'powershell', + category: 'system', + task: 'Add a folder to PATH for this session', + summary: + 'Lets you run the programs in that folder by name, until this terminal closes. A safe way to try a PATH change before making it permanent.', + command: "$env:Path += ';FOLDER'", + placeholders: { FOLDER: 'The full path of the folder holding the program' }, + keywords: [ + 'export path=$path:', + 'add to path', + 'path', + 'temporary path', + 'command not found', + 'is not recognized as the name of a cmdlet', + 'set path', + 'append path' + ] + }, + { + id: 'ps-path-add-permanent', + shell: 'powershell', + category: 'system', + task: 'Add a folder to PATH permanently', + summary: + 'Appends the folder to the PATH stored for your Windows user. New terminals pick it up; ones that are already open do not.', + command: + "[Environment]::SetEnvironmentVariable('Path', [Environment]::GetEnvironmentVariable('Path', 'User') + ';FOLDER', 'User')", + variants: [ + { + label: 'Look at your stored user PATH first (changes nothing)', + command: "[Environment]::GetEnvironmentVariable('Path', 'User') -split ';'" + } + ], + placeholders: { FOLDER: 'The full path of the folder holding the program' }, + keywords: [ + 'add to path', + 'setx path', + 'export path in .bashrc', + 'permanent path', + 'edit path', + 'install location', + 'command not found after install', + 'is not recognized', + 'environment variables dialog' + ], + danger: + 'This rewrites your stored user PATH. A slip here stops programs being found, so read it with the variant first.' + }, + { + id: 'ps-which', + shell: 'powershell', + category: 'system', + task: 'Find out where a command lives', + summary: + 'Shows whether a name is a program, an alias or a function, and the file it runs. Note that where on its own means Where-Object in PowerShell: the Windows finder is where.exe.', + command: 'Get-Command NAME', + variants: [ + { label: 'Just the path of the program file', command: '(Get-Command NAME).Source' }, + { label: 'Every copy on PATH, in the order they win', command: 'Get-Command NAME -All' }, + { label: 'The classic Windows finder', command: 'where.exe NAME' } + ], + placeholders: { NAME: 'The command, such as node, git or python' }, + keywords: [ + 'which', + 'where', + 'whereis', + 'type', + 'command -v', + 'is it installed', + 'which python', + 'which node', + 'path of program', + 'wrong version runs', + 'gcm' + ] + }, + { + id: 'ps-run-as-admin', + shell: 'powershell', + category: 'system', + task: 'Open an administrator PowerShell', + summary: + 'Opens a new elevated window after the Windows permission prompt. Use it when a command answers "Access is denied" or asks for elevation; it starts in System32, so cd back to your folder.', + command: 'Start-Process powershell -Verb RunAs', + variants: [ + { label: 'PowerShell 7 instead', command: 'Start-Process pwsh -Verb RunAs' }, + { + label: 'PowerShell 7, already in this folder', + command: + "Start-Process pwsh -Verb RunAs -ArgumentList '-NoExit', '-Command', \"Set-Location '$PWD'\"" + }, + { + label: 'Am I an administrator right now? (True or False)', + command: + '([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)' + }, + { + label: + 'One command only (Windows 11 24H2 and later, once sudo is switched on in Settings, System, For developers)', + command: 'sudo COMMAND' + } + ], + placeholders: { COMMAND: 'The command that needs administrator rights' }, + keywords: [ + 'sudo', + 'su', + 'run as administrator', + 'admin', + 'elevated', + 'access denied', + 'access is denied', + 'permission denied', + 'requires elevation', + 'uac', + 'eperm', + 'not allowed' + ] + }, + { + id: 'ps-whoami', + shell: 'powershell', + category: 'system', + task: 'See my user name and computer name', + summary: 'Prints the account this shell runs as, in the form COMPUTER\\user.', + command: 'whoami', + variants: [ + { label: 'The computer name alone', command: '$env:COMPUTERNAME' }, + { label: 'The path of my user folder', command: '$env:USERPROFILE' } + ], + keywords: [ + 'whoami', + 'id', + 'hostname', + 'username', + 'current user', + 'computer name', + 'pc name', + 'home directory', + 'echo $user', + 'echo $home' + ] + }, + { + id: 'ps-services', + shell: 'powershell', + category: 'system', + task: 'See which Windows services are running', + summary: 'Lists the background services that are running now.', + command: "Get-Service | Where-Object Status -eq 'Running'", + variants: [ + { label: 'Find one by part of its name', command: "Get-Service -DisplayName '*NAME*'" }, + { + label: 'Restart one (needs an administrator PowerShell)', + command: 'Restart-Service SERVICE_NAME' + } + ], + placeholders: { + NAME: 'Part of the name, such as docker or audio', + SERVICE_NAME: 'The value in the Name column' + }, + keywords: [ + 'systemctl', + 'systemctl status', + 'service', + 'services.msc', + 'daemon', + 'background service', + 'is docker running', + 'restart service', + 'sc query', + 'gsv' + ] + }, + { + id: 'ps-uptime', + shell: 'powershell', + category: 'system', + task: 'See how long the PC has been on', + summary: 'Prints the time since Windows last started, as days, hours and minutes.', + command: + '(Get-Date) - (Get-CimInstance Win32_OperatingSystem).LastBootUpTime | Select-Object Days, Hours, Minutes', + variants: [{ label: 'The short form (PowerShell 7 only)', command: 'Get-Uptime' }], + keywords: [ + 'uptime', + 'last boot', + 'last restart', + 'when did it reboot', + 'how long running', + 'boot time', + 'did it restart', + 'since when' + ] + }, + { + id: 'ps-date', + shell: 'powershell', + category: 'system', + task: 'Get the date and time', + summary: 'Prints the current date and time, or formats it for a file name or a log line.', + command: 'Get-Date', + variants: [ + { label: 'Sortable, for a log line', command: "Get-Date -Format 'yyyy-MM-dd HH:mm:ss'" }, + { label: 'Safe to use in a file name', command: "Get-Date -Format 'yyyyMMdd-HHmmss'" }, + { label: 'UTC in ISO 8601', command: "(Get-Date).ToUniversalTime().ToString('o')" } + ], + keywords: [ + 'date', + 'time', + 'now', + 'timestamp', + 'date +%y-%m-%d', + 'today', + 'current time', + 'utc', + 'iso date', + 'date in file name' + ] + }, + + // ------------------------------------------------------------------ shell + { + id: 'ps-clear-screen', + shell: 'powershell', + category: 'shell', + task: 'Clear the screen', + summary: + 'Wipes the terminal so the prompt is back at the top. clear and cls are short names for it, and Ctrl+L does the same without typing. Your history is kept.', + command: 'Clear-Host', + variants: [{ label: 'The short way', command: 'cls' }], + keywords: [ + 'clear', + 'cls', + 'ctrl+l', + 'wipe screen', + 'clean terminal', + 'reset screen', + 'too cluttered', + 'empty screen' + ] + }, + { + id: 'ps-history', + shell: 'powershell', + category: 'shell', + task: 'See the commands I typed before', + summary: + 'Lists what you ran in this session. The variants read the file that keeps your history across sessions. At the prompt, Up walks back through it and Ctrl+R searches it as you type.', + command: 'Get-History', + variants: [ + { + label: 'The last 50 from every session', + command: 'Get-Content (Get-PSReadLineOption).HistorySavePath -Tail 50' + }, + { + label: 'Search everything I have ever typed', + command: "Get-Content (Get-PSReadLineOption).HistorySavePath | Select-String 'TEXT'" + }, + { label: 'Run entry number 12 of this session again', command: 'Invoke-History 12' } + ], + placeholders: { TEXT: 'Part of the command you are trying to remember' }, + keywords: [ + 'history', + 'history | grep', + 'ctrl+r', + 'previous commands', + 'what did i run', + 'command i typed yesterday', + 'recall', + 'doskey /history', + '!!', + 'repeat command', + 'h' + ] + }, + { + id: 'ps-get-help', + shell: 'powershell', + category: 'shell', + task: 'Get help and examples for a command', + summary: + 'Prints worked examples for a PowerShell command. Until the help files have been downloaded the local text is thin, so the -Online variant, which opens the documentation page, is often the better read.', + command: 'Get-Help NAME -Examples', + variants: [ + { label: 'Open the full documentation in the browser', command: 'Get-Help NAME -Online' }, + { label: 'Everything, in the terminal', command: 'Get-Help NAME -Full' }, + { label: 'For an ordinary program, ask the program', command: 'PROGRAM --help' } + ], + placeholders: { + NAME: 'A PowerShell command, such as Get-ChildItem', + PROGRAM: 'A program, such as git or npm' + }, + keywords: [ + 'man', + 'help', + '--help', + '/?', + 'tldr', + 'how do i use', + 'manual', + 'documentation', + 'examples', + 'what does this command do', + 'usage' + ] + }, + { + id: 'ps-find-command', + shell: 'powershell', + category: 'shell', + task: 'Find a command when I only know roughly what it does', + summary: + 'Lists every command whose name contains the word. PowerShell names are Verb-Noun, so searching for the thing (clipboard, archive, service) usually finds it.', + command: "Get-Command '*WORD*'", + variants: [ + { label: 'Everything that acts on one kind of thing', command: "Get-Command -Noun '*WORD*'" }, + { label: 'Search the help text as well as the names', command: "Get-Help '*WORD*'" } + ], + placeholders: { WORD: 'A word such as clipboard, zip, service or dns' }, + keywords: [ + 'apropos', + 'man -k', + 'compgen -c', + 'search commands', + 'list commands', + 'what is the command for', + 'is there a command', + 'discover', + 'cmdlet', + 'gcm' + ] + }, + { + id: 'ps-make-alias', + shell: 'powershell', + category: 'shell', + task: 'Make a short name for a command', + summary: + 'An alias is another name for ONE command, with no arguments. For a command with arguments use the function variant. Both last until the terminal closes unless you put them in your profile.', + command: 'Set-Alias SHORT_NAME COMMAND', + variants: [ + { + label: 'A shortcut that carries arguments', + command: 'function SHORT_NAME { COMMAND ARGUMENTS @args }' + }, + { label: 'An example: gs for git status', command: 'function gs { git status @args }' }, + { label: 'What does this short name really run?', command: 'Get-Alias SHORT_NAME' }, + { + label: 'Which short names does a command have?', + command: 'Get-Alias -Definition Get-ChildItem' + } + ], + placeholders: { + SHORT_NAME: 'The name you want to type', + COMMAND: 'The command it stands for', + ARGUMENTS: 'What always follows the command' + }, + keywords: [ + 'alias', + "alias ll='ls -la'", + 'shortcut', + 'short name', + 'abbreviation', + 'doskey', + 'custom command', + 'unalias', + 'what is ls', + 'function' + ] + }, + { + id: 'ps-edit-profile', + shell: 'powershell', + category: 'shell', + task: 'Run something every time PowerShell starts', + summary: + 'Opens your profile script in Notepad, making it first if there is none. Whatever you put in it (aliases, functions, variables) runs at the start of every new session. Windows PowerShell 5.1 and PowerShell 7 each have their own.', + command: + 'if (!(Test-Path $PROFILE)) { New-Item $PROFILE -ItemType File -Force }; notepad $PROFILE', + variants: [ + { label: 'Where is my profile?', command: '$PROFILE' }, + { label: 'Load the profile again after editing it', command: '. $PROFILE' } + ], + keywords: [ + '.bashrc', + '.zshrc', + '.bash_profile', + 'profile', + 'startup script', + 'autoexec', + 'keep alias after restart', + 'permanent alias', + 'source ~/.bashrc', + 'on startup', + 'dotfiles' + ] + }, + { + id: 'ps-scripts-disabled', + shell: 'powershell', + category: 'shell', + task: 'Fix "running scripts is disabled on this system"', + summary: + 'Windows blocks every .ps1 script by default, which is also what stops npm, pnpm and other tools installed as scripts. This lets your own user run local scripts while still refusing unsigned ones from the internet. No administrator rights needed.', + command: 'Set-ExecutionPolicy RemoteSigned -Scope CurrentUser', + variants: [ + { + label: 'See the policy at every level (changes nothing)', + command: 'Get-ExecutionPolicy -List' + }, + { label: 'Trust one script you downloaded', command: 'Unblock-File SCRIPT.ps1' }, + { + label: 'Run one script once without changing the policy', + command: 'powershell -ExecutionPolicy Bypass -File SCRIPT.ps1' + }, + { label: 'Sidestep it for npm: call the .cmd version', command: 'npm.cmd install' } + ], + placeholders: { SCRIPT: 'The script file name without its .ps1 ending' }, + keywords: [ + 'running scripts is disabled', + 'execution policy', + 'cannot be loaded', + 'is not digitally signed', + 'pssecurityexception', + 'unauthorizedaccess', + 'npm.ps1 cannot be loaded', + 'activate.ps1', + 'npm does not work in powershell', + 'chmod +x', + 'allow scripts', + 'remotesigned', + 'unblock' + ] + }, + { + id: 'ps-run-local-program', + shell: 'powershell', + category: 'shell', + task: 'Run a program or script that is in this folder', + summary: + 'PowerShell never runs things from the current folder by bare name, which is why a file you can see is "not recognized". Put .\\ in front to say "the one right here".', + command: '.\\PROGRAM', + variants: [ + { label: 'A PowerShell script', command: '.\\SCRIPT.ps1' }, + { + label: 'A program whose path has spaces', + command: "& 'C:\\Program Files\\FOLDER\\PROGRAM.exe'" + } + ], + placeholders: { + PROGRAM: 'The program file, such as setup.exe', + SCRIPT: 'The script file name without its .ps1 ending', + FOLDER: 'The folder the program is in' + }, + keywords: [ + './', + './script.sh', + 'is not recognized as the name of a cmdlet', + 'not recognized', + 'command not found', + 'run exe', + 'run script', + 'execute file', + 'call operator', + 'path with spaces', + 'bash script.sh' + ] + }, + { + id: 'ps-chain-commands', + shell: 'powershell', + category: 'shell', + task: 'Run one command after another', + summary: + 'A semicolon runs the second command whatever happened to the first. The && that tutorials use only exists in PowerShell 7: in 5.1 it is an error, so use the if ($?) variant there.', + command: 'COMMAND_ONE; COMMAND_TWO', + variants: [ + { + label: 'Only if the first succeeded (works everywhere)', + command: 'COMMAND_ONE; if ($?) { COMMAND_TWO }' + }, + { + label: 'Only if the first succeeded (PowerShell 7 only)', + command: 'COMMAND_ONE && COMMAND_TWO' + }, + { + label: 'Only if the first FAILED (PowerShell 7 only)', + command: 'COMMAND_ONE || COMMAND_TWO' + } + ], + placeholders: { + COMMAND_ONE: 'The command to run first', + COMMAND_TWO: 'The command to run after it' + }, + keywords: [ + '&&', + '||', + ';', + 'and then', + 'chain commands', + 'two commands on one line', + "the token '&&' is not a valid statement separator", + 'run in sequence', + 'npm install && npm run dev', + 'one after another' + ] + }, + { + id: 'ps-save-output', + shell: 'powershell', + category: 'shell', + task: "Save a command's output to a file", + summary: + 'Writes what the command prints into a file instead of the screen. Windows PowerShell 5.1 writes UTF-16 here, which some tools cannot read, so the command names UTF-8.', + command: 'COMMAND | Out-File FILE -Encoding utf8', + variants: [ + { + label: 'Add to the end of the file instead', + command: 'COMMAND | Out-File FILE -Encoding utf8 -Append' + }, + { label: 'See it on screen AND save it', command: 'COMMAND | Tee-Object FILE' }, + { label: 'Errors and warnings too, not only the normal output', command: 'COMMAND *> FILE' }, + { label: 'Throw the output away (the /dev/null of PowerShell)', command: 'COMMAND *> $null' } + ], + placeholders: { + COMMAND: 'The command whose output you want', + FILE: 'The file to write, such as output.txt' + }, + keywords: [ + '>', + '>>', + '2>&1', + 'redirect', + 'tee', + '/dev/null', + 'save output', + 'write output to file', + 'log to file', + 'capture output', + 'nul', + 'silence output' + ], + danger: 'Without -Append the file is replaced if it already exists.' + }, + { + id: 'ps-clipboard', + shell: 'powershell', + category: 'shell', + task: "Copy a command's output to the clipboard", + summary: + 'Puts what the command prints on the clipboard, ready to paste into a chat, an issue or an agent.', + command: 'COMMAND | Set-Clipboard', + variants: [ + { label: 'A whole file', command: 'Get-Content FILE -Raw | Set-Clipboard' }, + { label: 'Print what is on the clipboard now', command: 'Get-Clipboard' }, + { label: 'Save the clipboard into a file', command: 'Get-Clipboard | Set-Content FILE' } + ], + placeholders: { + COMMAND: 'The command whose output you want', + FILE: 'The file to copy or write' + }, + keywords: [ + 'pbcopy', + 'pbpaste', + 'xclip', + 'clip', + 'clip.exe', + 'wl-copy', + 'copy output', + 'copy to clipboard', + 'paste', + 'copy error message', + 'scb', + 'gcb' + ], + danger: 'Saving the clipboard into a file replaces what that file held, if it already exists.' + }, + { + id: 'ps-time-command', + shell: 'powershell', + category: 'shell', + task: 'Time how long a command takes', + summary: + 'Runs the command and prints how long it took. The output of the command itself is hidden unless you use the variant.', + command: 'Measure-Command { COMMAND }', + variants: [ + { label: 'Show the output as well', command: 'Measure-Command { COMMAND | Out-Default }' }, + { label: 'Just the seconds', command: '(Measure-Command { COMMAND }).TotalSeconds' } + ], + placeholders: { COMMAND: 'The command to time' }, + keywords: [ + 'time', + 'time npm run build', + 'how long does it take', + 'duration', + 'benchmark', + 'stopwatch', + 'speed', + 'slow build', + 'elapsed' + ] + }, + { + id: 'ps-exit-code', + shell: 'powershell', + category: 'shell', + task: 'Check whether the last command worked', + summary: + 'Prints the exit code of the last PROGRAM that ran (git, npm, node): 0 means success and anything else is a failure. For PowerShell commands use the $? variant.', + command: '$LASTEXITCODE', + variants: [{ label: 'True or False, for any kind of command', command: '$?' }], + keywords: [ + 'echo $?', + 'exit code', + 'errorlevel', + 'echo %errorlevel%', + 'return code', + 'did it fail', + 'did it work', + 'status', + 'exit status', + 'non-zero' + ] + }, + { + id: 'ps-run-for-each-file', + shell: 'powershell', + category: 'shell', + task: 'Run a command on every file of a kind', + summary: + 'Hands each matching file, one at a time, to the command. Inside the braces $_.FullName is the full path of the current file and $_.Name its name.', + command: "Get-ChildItem -Filter '*.EXTENSION' | ForEach-Object { COMMAND $_.FullName }", + variants: [ + { + label: 'Subfolders too', + command: + "Get-ChildItem -Recurse -File -Filter '*.EXTENSION' | ForEach-Object { COMMAND $_.FullName }" + }, + { label: 'Repeat a command five times', command: '1..5 | ForEach-Object { COMMAND }' }, + { + label: 'Repeat every two seconds until Ctrl+C', + command: 'while ($true) { COMMAND; Start-Sleep -Seconds 2 }' + } + ], + placeholders: { + EXTENSION: 'A file ending, such as png', + COMMAND: 'The command to run on each file' + }, + keywords: [ + 'for loop', + 'for f in *', + 'xargs', + 'find -exec', + 'foreach', + 'batch', + 'loop over files', + 'each file', + 'watch', + 'repeat command', + 'sleep' + ] + }, + { + id: 'ps-preview-whatif', + shell: 'powershell', + category: 'shell', + task: 'Preview what a command would do, without doing it', + summary: + 'Most PowerShell commands that change something accept -WhatIf: they print what they WOULD do and touch nothing. It works on PowerShell commands only, not on programs such as git or npm.', + command: 'COMMAND -WhatIf', + variants: [ + { + label: 'An example: which log files would a delete remove?', + command: 'Get-ChildItem -Recurse -File -Filter *.log | Remove-Item -WhatIf' + }, + { label: 'Be asked about each item instead', command: 'COMMAND -Confirm' } + ], + placeholders: { + COMMAND: 'A PowerShell command that changes something, such as Remove-Item FOLDER -Recurse' + }, + keywords: [ + 'dry run', + '--dry-run', + 'whatif', + 'simulate', + 'preview', + 'test before running', + 'is this safe', + 'rm -i', + 'confirm', + 'afraid to run' + ] + } +] diff --git a/core/shared/help/search.test.ts b/core/shared/help/search.test.ts new file mode 100644 index 0000000..cb123b9 --- /dev/null +++ b/core/shared/help/search.test.ts @@ -0,0 +1,1024 @@ +import { describe, expect, it } from 'vitest' +import { expand, meaningfulWords, searchHelp, stem, tokenise, withinOneEdit } from './search' +import type { HelpCategory, HelpEntry, HelpShell } from './types' + +/** + * A FIXTURE WRITTEN THE WAY THE CATALOGUE IS: task first, generous keywords + * that name the same thing in other shells and the symptom someone would + * type. The rankings below are only worth asserting against entries that look + * like the real ones, so nothing here is shaped to make a test pass. + */ +const e = ( + id: string, + shell: HelpShell, + category: HelpCategory, + task: string, + summary: string, + command: string, + keywords: string[], + more: Partial = {} +): HelpEntry => ({ id, shell, category, task, summary, command, keywords, ...more }) + +const FIXTURE: readonly HelpEntry[] = [ + // PowerShell + e( + 'ps-biggest-files', + 'powershell', + 'files', + 'Find the biggest files in a folder', + 'Lists the ten largest files under a folder, biggest first, to see what is eating the disk.', + 'Get-ChildItem FOLDER -Recurse -File | Sort-Object Length -Descending | Select-Object -First 10 FullName, Length', + [ + 'large files', + 'big files', + 'huge', + 'size', + 'disk full', + 'what is taking space', + 'du', + 'largest', + 'sort by size' + ], + { placeholders: { FOLDER: 'The folder to look through' } } + ), + e( + 'ps-find-file-by-name', + 'powershell', + 'files', + 'Find a file by its name', + 'Looks through a folder and everything under it for names containing a word.', + 'Get-ChildItem FOLDER -Recurse -Filter "*NAME*"', + [ + 'search', + 'locate', + 'where is my file', + 'lost file', + 'filename', + 'wildcard', + 'dir /s', + 'find -name' + ] + ), + e( + 'ps-list-files', + 'powershell', + 'files', + 'List the files in a folder', + 'Shows what is in the folder you are in, or in the one you name.', + 'Get-ChildItem', + ['ls', 'dir', 'show files', 'directory listing', 'contents', 'what is in here', 'gci'] + ), + e( + 'ps-hidden-files', + 'powershell', + 'files', + 'Show hidden files', + 'Lists a folder including the hidden and system items a plain listing leaves out.', + 'Get-ChildItem -Force', + ['ls -a', 'dir /a', 'dotfiles', 'invisible', 'hidden folders', 'show all', 'system files'] + ), + e( + 'ps-delete-file', + 'powershell', + 'files', + 'Delete a file', + 'Removes one file for good.', + 'Remove-Item FILE', + ['remove', 'rm', 'del', 'erase', 'trash', 'get rid of'], + { danger: 'The file does not go to the Recycle Bin.' } + ), + e( + 'ps-delete-folder', + 'powershell', + 'folders', + 'Delete a folder and everything in it', + 'Removes a folder with all of its contents, without asking.', + 'Remove-Item FOLDER -Recurse -Force', + ['remove directory', 'rmdir', 'rm -rf', 'rd /s', 'erase folder', 'delete directory', 'wipe'], + { danger: 'Everything inside is gone for good.' } + ), + e( + 'ps-make-folder', + 'powershell', + 'folders', + 'Make a new folder', + 'Creates an empty folder where you are.', + 'New-Item -ItemType Directory NAME', + ['mkdir', 'md', 'create directory', 'new directory', 'add folder'] + ), + e( + 'ps-up-one-folder', + 'powershell', + 'folders', + 'Go up one folder', + 'Moves to the folder that contains the one you are in.', + 'cd ..', + [ + 'go back', + 'back a folder', + 'parent folder', + 'up a level', + 'cd ..', + 'leave folder', + 'out of this folder' + ] + ), + e( + 'ps-previous-folder', + 'powershell', + 'folders', + 'Return to the folder I was just in', + 'Jumps to wherever you were before the last move, and back again.', + 'cd -', + ['previous folder', 'last folder', 'cd -', 'popd', 'where i was', 'toggle'] + ), + e( + 'ps-where-am-i', + 'powershell', + 'folders', + 'Show which folder I am in', + 'Prints the full path of the current folder.', + 'Get-Location', + [ + 'pwd', + 'where am i', + 'current folder', + 'current directory', + 'cwd', + 'working directory', + 'this path' + ] + ), + e( + 'ps-search-text', + 'powershell', + 'text', + 'Find text inside files', + 'Searches the contents of files for a word or pattern and shows the matching lines.', + 'Select-String -Path FOLDER\\* -Pattern "TEXT"', + [ + 'grep', + 'findstr', + 'search in files', + 'find a word', + 'contains', + 'look inside', + 'string', + 'match' + ] + ), + e( + 'ps-list-processes', + 'powershell', + 'processes', + 'See what is running', + 'Lists every running program with its id, memory and processor time.', + 'Get-Process', + ['ps', 'tasklist', 'task manager', 'processes', 'running programs', 'apps', 'top'] + ), + e( + 'ps-kill-process', + 'powershell', + 'processes', + 'Stop a program that will not close', + 'Ends a program by name when it has frozen and its window ignores you.', + 'Stop-Process -Name NAME -Force', + [ + 'kill', + 'kill process', + 'taskkill', + 'end task', + 'terminate', + 'force quit', + 'frozen', + 'not responding', + 'hung' + ], + { danger: 'Unsaved work in that program is lost.' } + ), + e( + 'ps-port-in-use', + 'powershell', + 'processes', + 'See what is using a port', + 'Names the process holding a port, for when a server says the address is already in use.', + 'Get-NetTCPConnection -LocalPort PORT | Select-Object OwningProcess', + [ + 'port already in use', + 'address already in use', + 'eaddrinuse', + 'listening', + 'netstat', + 'lsof', + 'localhost' + ] + ), + e( + 'ps-test-port', + 'powershell', + 'network', + 'Check whether a port is open on another machine', + 'Tries a connection to a host and port and says whether it got through.', + 'Test-NetConnection HOST -Port PORT', + ['telnet', 'nc', 'reachable', 'firewall', 'connect', 'can i reach'] + ), + e( + 'ps-ip-address', + 'powershell', + 'network', + 'Show my IP address', + 'Lists the addresses this machine has on each network.', + 'Get-NetIPAddress -AddressFamily IPv4', + ['ipconfig', 'ifconfig', 'ip a', 'local ip', 'network address', 'what is my ip', 'lan'] + ), + e( + 'ps-unzip', + 'powershell', + 'files', + 'Unzip an archive', + 'Unpacks a zip file into a folder.', + 'Expand-Archive FILE.zip -DestinationPath FOLDER', + ['unzip', 'extract', 'decompress', 'open zip', 'unpack', 'tar -x'] + ), + e( + 'ps-zip', + 'powershell', + 'files', + 'Zip a folder', + 'Packs a folder into one zip file.', + 'Compress-Archive FOLDER FILE.zip', + ['zip', 'compress', 'archive', 'pack', 'make a zip'] + ), + e( + 'ps-env-var', + 'powershell', + 'system', + 'See an environment variable', + 'Prints the value of one variable, PATH for instance.', + '$env:NAME', + ['env', 'printenv', 'echo %path%', 'path variable', 'environment', 'variables'] + ), + e( + 'ps-history', + 'powershell', + 'shell', + 'See the commands I typed before', + 'Lists what you ran in this session, oldest first.', + 'Get-History', + ['history', 'previous commands', 'last command', 'recall', 'what did i run'] + ), + e( + 'ps-clear', + 'powershell', + 'shell', + 'Clear the screen', + 'Empties the window and puts the prompt at the top.', + 'Clear-Host', + ['cls', 'clear', 'clean screen', 'wipe terminal', 'ctrl l'] + ), + e( + 'ps-admin', + 'powershell', + 'system', + 'Open a shell as administrator', + 'Starts a second PowerShell with elevated rights, after the Windows prompt.', + 'Start-Process pwsh -Verb RunAs', + [ + 'admin', + 'elevated', + 'sudo', + 'run as administrator', + 'uac', + 'permission denied', + 'access denied' + ] + ), + e( + 'ps-copy-file', + 'powershell', + 'files', + 'Copy a file', + 'Makes a second copy somewhere else.', + 'Copy-Item FILE DESTINATION', + ['cp', 'copy', 'duplicate', 'clone', 'backup'] + ), + e( + 'ps-rename', + 'powershell', + 'files', + 'Rename a file', + 'Gives a file a new name where it is.', + 'Rename-Item OLD NEW', + ['ren', 'mv', 'move', 'change name'] + ), + e( + 'ps-whoami', + 'powershell', + 'system', + 'Show which user I am signed in as', + 'Prints the account this shell runs under.', + 'whoami', + ['who am i', 'username', 'current user', 'account', 'logged in'] + ), + // cmd + e( + 'cmd-list-files', + 'cmd', + 'files', + 'List the files in a folder', + 'Shows what is in the folder you are in.', + 'dir', + ['ls', 'show files', 'contents', 'directory listing'] + ), + e( + 'cmd-delete-folder', + 'cmd', + 'folders', + 'Delete a folder and everything in it', + 'Removes a folder with all of its contents, without asking.', + 'rmdir /s /q FOLDER', + ['remove directory', 'rd', 'rm -rf', 'erase folder', 'delete directory'], + { danger: 'Everything inside is gone for good.' } + ), + e( + 'cmd-find-program', + 'cmd', + 'system', + 'Find where a program is installed', + 'Prints the full path of the exe a command name runs.', + 'where NAME', + ['which', 'path of exe', 'locate command', 'is it installed', 'get-command'] + ), + e( + 'cmd-kill', + 'cmd', + 'processes', + 'Stop a program that will not close', + 'Ends a program by its exe name.', + 'taskkill /IM NAME.exe /F', + ['kill', 'kill process', 'end task', 'terminate', 'force quit', 'frozen'], + { danger: 'Unsaved work in that program is lost.' } + ), + e( + 'cmd-port', + 'cmd', + 'processes', + 'See what is using a port', + 'Finds the line for a port, whose last column is the process id.', + 'netstat -ano | findstr :PORT', + ['port already in use', 'address already in use', 'listening', 'lsof'] + ), + e('cmd-clear', 'cmd', 'shell', 'Clear the screen', 'Empties the window.', 'cls', [ + 'clear', + 'clean screen', + 'wipe' + ]), + // bash + e( + 'bash-biggest-files', + 'bash', + 'files', + 'Find the biggest files in a folder', + 'Lists the ten largest files under a folder, biggest first.', + 'find FOLDER -type f -exec du -h {} + | sort -rh | head -n 10', + ['large files', 'big files', 'huge', 'size', 'disk full', 'what is taking space', 'largest'] + ), + e( + 'bash-grep', + 'bash', + 'text', + 'Find text inside files', + 'Searches the contents of every file under a folder and shows the matching lines.', + 'grep -rn "TEXT" FOLDER', + [ + 'search in files', + 'find a word', + 'contains', + 'select-string', + 'findstr', + 'look inside', + 'string' + ] + ), + e( + 'bash-filter-output', + 'bash', + 'text', + 'Keep only the lines of output that mention something', + 'Pipes any command through a filter so only matching lines are shown.', + 'COMMAND | grep TEXT', + ['filter', 'pipe', 'only lines with', 'narrow down', 'where-object'] + ), + e( + 'bash-delete-folder', + 'bash', + 'folders', + 'Delete a folder and everything in it', + 'Removes a folder with all of its contents, without asking.', + 'rm -rf FOLDER', + ['remove directory', 'rmdir', 'erase folder', 'delete directory'], + { danger: 'Everything inside is gone for good.' } + ), + e( + 'bash-kill', + 'bash', + 'processes', + 'Stop a running program', + 'Asks a process to end, by its id.', + 'kill PID', + ['kill process', 'end task', 'terminate', 'quit', 'frozen', 'taskkill', 'stop-process'], + { + variants: [{ label: 'It ignores the polite request', command: 'kill -9 PID' }], + danger: 'Unsaved work is lost.' + } + ), + e( + 'bash-port', + 'bash', + 'processes', + 'See what is using a port', + 'Names the process holding a port.', + 'lsof -i :PORT', + ['port already in use', 'address already in use', 'eaddrinuse', 'listening', 'netstat'] + ), + e( + 'bash-unzip', + 'bash', + 'files', + 'Unzip an archive', + 'Unpacks a zip file into a folder.', + 'unzip FILE.zip -d FOLDER', + ['extract', 'decompress', 'unpack', 'expand-archive'] + ), + e( + 'bash-hidden', + 'bash', + 'files', + 'Show hidden files', + 'Lists a folder including the names that start with a dot.', + 'ls -la', + ['dotfiles', 'invisible', 'show all', 'dir /a'] + ), + e( + 'bash-up', + 'bash', + 'folders', + 'Go up one folder', + 'Moves to the folder that contains this one.', + 'cd ..', + ['go back', 'back a folder', 'parent folder', 'up a level'] + ), + e( + 'bash-pwd', + 'bash', + 'folders', + 'Show which folder I am in', + 'Prints the full path of the current folder.', + 'pwd', + ['where am i', 'current folder', 'current directory', 'cwd', 'get-location'] + ), + e( + 'bash-less', + 'bash', + 'files', + 'Read a long file a page at a time', + 'Opens a file in a pager: space for the next page, q to leave.', + 'less FILE', + ['pager', 'more', 'scroll a file', 'view file'] + ), + // any shell + e( + 'git-undo-commit', + 'any', + 'git', + 'Undo the last commit but keep the changes', + 'Takes the newest commit back and leaves its changes staged.', + 'git reset --soft HEAD~1', + ['uncommit', 'revert commit', 'take back', 'committed too early', 'wrong commit'], + { danger: 'Rewrites history: do not do it to a commit you have pushed.' } + ), + e( + 'git-discard', + 'any', + 'git', + 'Throw away my changes to a file', + 'Puts a file back to how the last commit had it.', + 'git restore FILE', + ['undo', 'revert', 'discard', 'checkout', 'reset file'], + { danger: 'The edits are gone for good.' } + ), + e( + 'git-log', + 'any', + 'git', + 'See the commit history', + 'Lists the last twenty commits, one line each.', + 'git log --oneline -n 20', + ['history', 'log', 'past commits', 'what changed'] + ), + e( + 'git-status', + 'any', + 'git', + 'See what has changed', + 'Shows the files that are modified, staged or new.', + 'git status', + ['changes', 'modified', 'staged', 'dirty'] + ), + e( + 'claude-compact', + 'any', + 'agents', + 'Shrink a long Claude conversation', + 'Typed inside Claude Code: summarises the conversation so far to free up context.', + '/compact', + ['compact', 'context full', 'summarise', 'running out of context', 'slash command'] + ), + e( + 'claude-resume', + 'any', + 'agents', + 'Pick up a Claude conversation where it stopped', + 'Opens a list of earlier sessions for this folder.', + 'claude --resume', + ['resume', 'continue', 'previous session', 'come back'] + ), + e( + 'winget-install', + 'any', + 'packages', + 'Install a program', + 'Downloads and installs an application by name.', + 'winget install NAME', + ['install', 'download app', 'apt install', 'brew', 'setup'] + ) +] + +const ids = (query: string, shell?: HelpShell, limit = 5): string[] => + searchHelp(FIXTURE, query, { shell, limit }).map((h) => h.entry.id) +const top = (query: string, shell?: HelpShell): string | undefined => ids(query, shell, 1)[0] + +describe('tokenise', () => { + it('lower-cases, strips punctuation and folds accents', () => { + expect(tokenise('How do I find BIG files?!')).toEqual([ + 'how', + 'do', + 'i', + 'find', + 'big', + 'files' + ]) + expect(tokenise("what's using port 3000")).toEqual(['whats', 'using', 'port', '3000']) + expect(tokenise('Get-ChildItem | Sort-Object')).toEqual(['get', 'childitem', 'sort', 'object']) + expect(tokenise('café')).toEqual(['cafe']) + expect(tokenise(' ')).toEqual([]) + expect(tokenise('🙂🙂 ???')).toEqual([]) + }) +}) + +describe('meaningfulWords', () => { + it('drops the scaffolding of a question', () => { + expect(meaningfulWords('how do I find big files')).toEqual(['find', 'big', 'files']) + expect(meaningfulWords('please, what is the way to delete a folder in my shell')).toEqual([ + 'delete', + 'folder', + 'shell' + ]) + }) + + it('keeps the small words that carry meaning here', () => { + for (const w of ['all', 'not', 'up', 'out', 'back', 'kill', 'who', 'where']) { + expect(meaningfulWords(`the ${w} thing`)).toContain(w) + } + }) + + it('falls back to every word when nothing else is left', () => { + expect(meaningfulWords('how do i')).toEqual(['how', 'do', 'i']) + }) + + it('says each word once and is bounded', () => { + expect(meaningfulWords('file file file')).toEqual(['file']) + const many = Array.from({ length: 500 }, (_, i) => `word${i}`).join(' ') + expect(meaningfulWords(many).length).toBeLessThanOrEqual(24) + }) +}) + +describe('stem', () => { + it('folds plurals, -ing, -ed, -er and -est', () => { + expect(stem('biggest')).toBe('big') + // "newest files" is about what changed last, "new file" about making one. + expect(stem('newest')).not.toBe('new') + expect(stem('bigger')).toBe('big') + expect(stem('largest')).toBe('large') + expect(stem('files')).toBe('file') + expect(stem('running')).toBe('run') + expect(stem('deleted')).toBe('delete') + expect(stem('deleting')).toBe('delete') + expect(stem('directories')).toBe('directory') + expect(stem('processes')).toBe('process') + expect(stem('stopped')).toBe('stop') + expect(stem('killed')).toBe('kill') + expect(stem('renamed')).toBe('rename') + expect(stem('moving')).toBe('move') + expect(stem('copied')).toBe('copy') + expect(stem('branches')).toBe('branch') + expect(stem('using')).toBe('use') + }) + + it('leaves short words, command names and look-alikes alone', () => { + for (const w of [ + 'ls', + 'ps', + 'cls', + 'less', + 'rm', + 'dir', + 'dns', + 'status', + 'this', + 'alias', + 'process', + 'address' + ]) { + expect(stem(w)).toBe(w) + } + // Nouns that only look like comparatives or participles. + for (const w of [ + 'folder', + 'server', + 'user', + 'string', + 'test', + 'latest', + 'speed', + 'ping', + 'docker' + ]) { + expect(stem(w)).toBe(w) + } + expect(stem('sha256')).toBe('sha256') + }) +}) + +describe('expand', () => { + it('knows the everyday vocabulary of a shell', () => { + expect(expand('big')).toEqual(expect.arrayContaining(['large', 'huge', 'size'])) + expect(expand('remove')).toEqual(expect.arrayContaining(['delete', 'erase', 'rm', 'del'])) + expect(expand('directories')).toEqual(expect.arrayContaining(['folder', 'dir'])) + expect(expand('kill')).toEqual(expect.arrayContaining(['stop', 'end', 'terminate', 'quit'])) + expect(expand('sudo')).toEqual(expect.arrayContaining(['admin', 'administrator', 'elevated'])) + expect(expand('unzip')).toEqual(expect.arrayContaining(['extract', 'decompress'])) + expect(expand('unzip')).not.toContain('zip') + }) + + it('never lists the word itself, and has nothing for a stranger', () => { + expect(expand('big')).not.toContain('big') + expect(expand('kubernetes')).toEqual([]) + expect(expand('')).toEqual([]) + }) + + it('reads "running" as a process without making "run" one', () => { + expect(expand('running')).toContain('process') + expect(expand('run')).toEqual([]) + expect(expand('process')).not.toContain('run') + }) +}) + +describe('withinOneEdit', () => { + it('accepts one slip and no more', () => { + expect(withinOneEdit('proccess', 'process')).toBe(true) // a letter too many + expect(withinOneEdit('proces', 'process')).toBe(true) // one too few + expect(withinOneEdit('direcotry', 'directory')).toBe(true) // two swapped + expect(withinOneEdit('directary', 'directory')).toBe(true) // one wrong + expect(withinOneEdit('dirctary', 'directory')).toBe(false) + expect(withinOneEdit('process', 'process')).toBe(true) + }) +}) + +describe('searchHelp, the rankings', () => { + it('"how do I find big files" lands on the biggest-files entry of the shell asked for', () => { + expect(top('how do I find big files', 'powershell')).toBe('ps-biggest-files') + expect(top('how do I find big files', 'bash')).toBe('bash-biggest-files') + expect(top('what is taking up all the space', 'powershell')).toBe('ps-biggest-files') + expect(top('largest file', 'powershell')).toBe('ps-biggest-files') + }) + + it('"whats using port 3000"', () => { + expect(top('whats using port 3000', 'powershell')).toBe('ps-port-in-use') + expect(top("what's using port 3000?", 'bash')).toBe('bash-port') + expect(top('port already in use', 'cmd')).toBe('cmd-port') + expect(top('EADDRINUSE', 'powershell')).toBe('ps-port-in-use') + }) + + it('"kill a process"', () => { + expect(top('kill a process', 'powershell')).toBe('ps-kill-process') + expect(top('kill a process', 'cmd')).toBe('cmd-kill') + expect(top('kill a process', 'bash')).toBe('bash-kill') + expect(top('program is frozen', 'powershell')).toBe('ps-kill-process') + }) + + it('"go back a folder"', () => { + expect(top('go back a folder', 'powershell')).toBe('ps-up-one-folder') + expect(top('go back a folder', 'bash')).toBe('bash-up') + expect(top('how do i get out of this directory', 'powershell')).toBe('ps-up-one-folder') + }) + + it('"delete a folder"', () => { + expect(top('delete a folder', 'powershell')).toBe('ps-delete-folder') + expect(top('remove directory', 'cmd')).toBe('cmd-delete-folder') + expect(top('erase a dir', 'bash')).toBe('bash-delete-folder') + // The file entry is about deleting too, and must come after. + const both = ids('delete a folder', 'powershell') + expect(both.indexOf('ps-delete-folder')).toBeLessThan(both.indexOf('ps-delete-file')) + }) + + it('"grep" finds Select-String through its keywords, and grep itself in bash', () => { + expect(top('grep', 'powershell')).toBe('ps-search-text') + expect(top('grep', 'bash')).toBe('bash-grep') + // With no shell named, the command called grep is the answer. + expect(top('grep')).toBe('bash-grep') + expect(ids('grep', 'bash')).toContain('bash-filter-output') + }) + + it('an exact command name puts that entry first', () => { + expect(top('taskkill')).toBe('cmd-kill') + expect(top('Get-ChildItem', 'powershell')).toBe('ps-list-files') + expect(top('git status')).toBe('git-status') + expect(top('less')).toBe('bash-less') + expect(top('pwd')).toBe('bash-pwd') + expect(top('/compact')).toBe('claude-compact') + expect(top('/compact', 'powershell')).toBe('claude-compact') + expect(top('/comp', 'cmd')).toBe('claude-compact') // still being typed + }) + + it('"undo last commit"', () => { + expect(top('undo last commit')).toBe('git-undo-commit') + expect(top('how can I undo my last commit', 'powershell')).toBe('git-undo-commit') + expect(top('discard my changes', 'bash')).toBe('git-discard') + }) + + it('"unzip"', () => { + expect(top('unzip', 'powershell')).toBe('ps-unzip') + expect(top('unzip', 'bash')).toBe('bash-unzip') + expect(top('extract a zip', 'powershell')).toBe('ps-unzip') + expect(top('compress a folder', 'powershell')).toBe('ps-zip') + }) + + it('"where am i"', () => { + expect(top('where am i', 'powershell')).toBe('ps-where-am-i') + expect(top('where am I?', 'bash')).toBe('bash-pwd') + expect(top('who am i', 'powershell')).toBe('ps-whoami') + }) + + it('"see hidden files"', () => { + expect(top('see hidden files', 'powershell')).toBe('ps-hidden-files') + expect(top('see hidden files', 'bash')).toBe('bash-hidden') + expect(top('display invisible files', 'powershell')).toBe('ps-hidden-files') + }) + + it('other everyday phrasings', () => { + expect(top('what is my ip', 'powershell')).toBe('ps-ip-address') + expect(top('create a directory', 'powershell')).toBe('ps-make-folder') + expect(top('run as admin', 'powershell')).toBe('ps-admin') + expect(top('clean the screen', 'cmd')).toBe('cmd-clear') + expect(top('previous commands', 'powershell')).toBe('ps-history') + expect(top('duplicate a file', 'powershell')).toBe('ps-copy-file') + expect(top('what is running', 'powershell')).toBe('ps-list-processes') + }) + + it('forgives one typo in a long word, and scores it below the real spelling', () => { + expect(top('kill proccess', 'powershell')).toBe('ps-kill-process') + expect(top('make a direcotry', 'powershell')).toBe('ps-make-folder') + expect(top('hiden files', 'bash')).toBe('bash-hidden') + const typo = searchHelp(FIXTURE, 'kill proccess', { shell: 'powershell' })[0] + const right = searchHelp(FIXTURE, 'kill process', { shell: 'powershell' })[0] + expect(typo.entry.id).toBe(right.entry.id) + expect(typo.score).toBeLessThan(right.score) + }) + + it('a corrected typo brings its synonyms along', () => { + // Nothing about zipping says "directory": the slip has to be read as + // "directory" and THEN as "folder" for every word to have matched. + const hit = searchHelp(FIXTURE, 'compress a direcotry', { shell: 'powershell' })[0] + expect(hit.entry.id).toBe('ps-zip') + expect(hit.score).toBeGreaterThanOrEqual(1000) + }) + + it('a short form is the same word as its long form', () => { + expect(top('run as admin', 'powershell')).toBe('ps-admin') + expect(top('run as administrator', 'powershell')).toBe('ps-admin') + const short = searchHelp(FIXTURE, 'run as admin', { shell: 'powershell' })[0] + const long = searchHelp(FIXTURE, 'run as administrator', { shell: 'powershell' })[0] + expect(short.score).toBe(long.score) + expect(top('env variable', 'powershell')).toBe('ps-env-var') + }) + + it('finds a word that is still being typed', () => { + expect(top('unz', 'powershell')).toBe('ps-unzip') + expect(ids('proc', 'powershell')).toContain('ps-list-processes') + }) + + it('ranks an entry matching every word above one matching only some', () => { + const hits = searchHelp(FIXTURE, 'find big files', { shell: 'powershell' }) + const names = hits.map((h) => h.entry.id) + expect(names[0]).toBe('ps-biggest-files') + expect(names).toContain('ps-find-file-by-name') // matches "find" and "files" only + expect(hits[0].score).toBeGreaterThan(hits[1].score) + }) + + it('breaks a tie by the shorter task, then by id', () => { + const pair: HelpEntry[] = [ + e('b-two', 'any', 'files', 'Frobnicate a widget now', 'x', 'frob', ['zzz']), + e('a-two', 'any', 'files', 'Frobnicate a widget now', 'x', 'frob', ['zzz']), + e('c-one', 'any', 'files', 'Frobnicate a widget', 'x', 'frob', ['zzz']) + ] + expect(searchHelp(pair, 'frobnicate widget').map((h) => h.entry.id)).toEqual([ + 'c-one', + 'a-two', + 'b-two' + ]) + }) +}) + +describe('searchHelp, the options', () => { + it('an empty query is the catalogue of that shell, in catalogue order', () => { + const all = searchHelp(FIXTURE, '') + expect(all.map((h) => h.entry.id)).toEqual(FIXTURE.map((x) => x.id)) + expect(all.every((h) => h.score === 0)).toBe(true) + + const bash = searchHelp(FIXTURE, ' ', { shell: 'bash' }) + expect(bash.map((h) => h.entry.id)).toEqual( + FIXTURE.filter((x) => x.shell === 'bash' || x.shell === 'any').map((x) => x.id) + ) + expect( + searchHelp(FIXTURE, '', { shell: 'bash', category: 'git' }).map((h) => h.entry.id) + ).toEqual(['git-undo-commit', 'git-discard', 'git-log', 'git-status']) + expect(searchHelp(FIXTURE, '', { limit: 3 })).toHaveLength(3) + }) + + it('the shell filter keeps that shell and "any", and nothing else', () => { + for (const shell of ['powershell', 'cmd', 'bash'] as const) { + for (const q of ['delete', 'port', 'files', 'git', 'kill', 'grep', 'ls', '']) { + for (const h of searchHelp(FIXTURE, q, { shell })) { + expect([shell, 'any']).toContain(h.entry.shell) + } + } + } + expect( + searchHelp(FIXTURE, 'commit', { shell: 'any' }).every((h) => h.entry.shell === 'any') + ).toBe(true) + }) + + it('the category filter and the limit', () => { + const hits = searchHelp(FIXTURE, 'folder', { shell: 'powershell', category: 'folders' }) + expect(hits.length).toBeGreaterThan(0) + expect(hits.every((h) => h.entry.category === 'folders')).toBe(true) + expect(searchHelp(FIXTURE, 'file', { limit: 2 })).toHaveLength(2) + // A limit that makes no sense is no limit. + expect(searchHelp(FIXTURE, 'file', { limit: -1 }).length).toBeGreaterThan(2) + expect(searchHelp(FIXTURE, 'file', { limit: Number.NaN }).length).toBeGreaterThan(2) + }) + + it('a query that matches nothing is an empty list, not the catalogue', () => { + expect(searchHelp(FIXTURE, 'xylophone quokka')).toEqual([]) + expect(searchHelp(FIXTURE, '???')).toEqual([]) + expect(searchHelp(FIXTURE, '🙂')).toEqual([]) + }) +}) + +describe('searchHelp, whatever it is handed', () => { + const JUNK: string[] = [ + '', + ' ', + '\n\t', + '🙂🔥💾', + '\ud800', // a lone surrogate + '.*+?^${}()|[]\\', + '(((((', + '[a-z]+$', + '\\', + '%PATH% && del /s /q C:\\*', + '', + "'; DROP TABLE help; --", + 'a'.repeat(5000), + 'find big files '.repeat(400), + '١٢٣ файлы 大きい ファイル', + '__proto__ constructor toString hasOwnProperty', + '-', + '/', + '0', + 'file' + ] + + it('never throws, and always answers with unique entries in score order', () => { + for (const q of JUNK) { + for (const shell of [undefined, 'powershell', 'bash', 'cmd', 'any'] as const) { + let hits: ReturnType = [] + expect(() => { + hits = searchHelp(FIXTURE, q, { shell }) + }).not.toThrow() + expect(new Set(hits.map((h) => h.entry.id)).size).toBe(hits.length) + for (let i = 1; i < hits.length; i++) + expect(hits[i - 1].score).toBeGreaterThanOrEqual(hits[i].score) + for (const h of hits) expect(Number.isFinite(h.score)).toBe(true) + } + } + }) + + it('survives input that is not what the types promise', () => { + const bad = [ + { id: 'x', shell: 'any', category: 'files' }, + null, + { + id: 'y', + shell: 'any', + category: 'files', + task: 'Find a thing', + keywords: 'nope', + variants: 7 + } + ] as unknown as HelpEntry[] + expect(() => searchHelp(bad, 'find thing')).not.toThrow() + expect(() => searchHelp(undefined as unknown as HelpEntry[], 'x')).not.toThrow() + expect(() => searchHelp(FIXTURE, undefined as unknown as string)).not.toThrow() + expect(() => + searchHelp(FIXTURE, 42 as unknown as string, null as unknown as undefined) + ).not.toThrow() + expect(searchHelp([], 'anything')).toEqual([]) + }) + + it('does not read a word off the prototype as a hit', () => { + expect(searchHelp(FIXTURE, 'constructor')).toEqual([]) + expect(searchHelp(FIXTURE, '__proto__')).toEqual([]) + }) + + it('scores are sorted and results unique for every real phrasing too', () => { + for (const q of ['how do I find big files', 'kill', 'git', 'folder', 'show', 'a', 'the']) { + const hits = searchHelp(FIXTURE, q) + expect(new Set(hits.map((h) => h.entry)).size).toBe(hits.length) + for (let i = 1; i < hits.length; i++) + expect(hits[i - 1].score).toBeGreaterThanOrEqual(hits[i].score) + } + }) + + it('is stable: the same question gets the same answer', () => { + const a = searchHelp(FIXTURE, 'delete files in a folder').map((h) => h.entry.id) + const b = searchHelp([...FIXTURE].reverse(), 'delete files in a folder').map((h) => h.entry.id) + expect(b).toEqual(a) + }) +}) + +describe('searchHelp, the cost', () => { + // The catalogue is a few hundred entries and the panel searches on every + // keystroke, so this is measured rather than hoped for. + const big = (): HelpEntry[] => + Array.from({ length: 300 }, (_, i) => { + const src = FIXTURE[i % FIXTURE.length] + return { + ...src, + id: `${src.id}-${i}`, + task: `${src.task} number ${i}`, + keywords: [...src.keywords, `variant${i}`, `extra word ${i % 17}`] + } + }) + const LONG = + 'how do I find the biggest and largest files in my folders and then delete the hidden ones that are not running ' + + 'as a process on port 3000 while I undo my last commit and unzip the archive please, also direcotry and proccess ' + + 'and whatever elze might be taking up all the space on this machine right now because the disk is completely full' + + const median = (xs: number[]): number => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)] + + it('300 entries and a long question: a keystroke stays under 20 ms, and the first one under 150', () => { + // Cold: a catalogue this function has never seen, so the index is built. + const cold: number[] = [] + for (let i = 0; i < 5; i++) { + const entries = big() + const t = performance.now() + const hits = searchHelp(entries, LONG) + cold.push(performance.now() - t) + expect(hits.length).toBeGreaterThan(0) + } + // Warm: the same array again, which is what every keystroke after the + // first one is. + const entries = big() + searchHelp(entries, LONG) + const warm: number[] = [] + for (let i = 0; i < 20; i++) { + const t = performance.now() + searchHelp(entries, `${LONG} ${i}`) + warm.push(performance.now() - t) + } + console.log( + `searchHelp 300 entries: cold median ${median(cold).toFixed(2)} ms, warm median ${median(warm).toFixed(2)} ms` + ) + // WARM is the number that matters: it is every keystroke. COLD happens once + // per run of the app, when the popup is first opened, and its bound is + // looser ON PURPOSE: measured at 4.9 ms on the machine this was written on + // and at 21.8 ms on a shared CI runner with the rest of the suite running + // beside it, where a 20 ms bound failed the build over nothing a person + // could feel. It is here to catch an index that has gone quadratic, which + // would miss 150 ms by a mile, not to benchmark the runner. + expect(median(warm)).toBeLessThan(20) + expect(median(cold)).toBeLessThan(150) + }) +}) diff --git a/core/shared/help/search.ts b/core/shared/help/search.ts new file mode 100644 index 0000000..334ef67 --- /dev/null +++ b/core/shared/help/search.ts @@ -0,0 +1,687 @@ +/** + * THE HELP PANEL'S SEARCH (#12). Pure, offline and instant: no model, no + * network, no dependency, because the panel searches on every keystroke and + * has to work on a machine with no connection at all. + * + * Owner, 2026-09-19: "metadata on each command so a natural-language search + * finds it ('how do I find big files', not only the command's name)". So the + * query is read the way a person wrote it rather than matched as a substring: + * + * 1. the scaffolding of a question is dropped ("how do I", "the", "please"), + * but not the small words that carry meaning in a shell ("all", "not", + * "up", "out", "back", "who"); + * 2. words are folded to a stem, so "biggest files" meets "big file"; + * 3. a small synonym table covers the everyday vocabulary, so "erase a + * directory" meets "Delete a folder"; + * 4. a word still being typed matches by prefix, and a word of five letters + * or more that matches nothing at all is allowed ONE slip ("proccess"), + * each scored below the real thing. + * + * Someone who DOES know the command is served first: a query that is the + * command's own name ("grep", "taskkill", "/compact") puts that entry on top. + * + * THE SCORE, highest tier first, so the order can be reasoned about: + * 12000 / 10000 the query IS the command / its leading words + * 6000 the same, for one of the entry's variants + * 2000 the query is the start of the command (still being typed), + * or names a later stage of its pipeline + * 1000 EVERY meaningful word of the query matched this entry + * under 1000 the words themselves: per word, the best field it was found + * in (task 10, keywords 6, summary 3, command 2, the rest 1), + * scaled by how it matched and by how rare the word is, plus + * up to 30 for a whole phrase ("where am i") found verbatim. + * Ties break by the shorter task and then by id, so the order never depends on + * where an entry sits in the catalogue. + */ +import type { HelpCategory, HelpEntry, HelpShell } from './types' + +export interface HelpSearchOptions { + /** Keep entries written for this shell AND those written for 'any'. */ + shell?: HelpShell + category?: HelpCategory + /** At most this many results. Anything that is not a positive number is no limit. */ + limit?: number +} + +export interface HelpSearchHit { + entry: HelpEntry + score: number +} + +// --------------------------------------------------------------------------- +// Words +// --------------------------------------------------------------------------- + +/** A query is cut here before anything reads it: a pasted page is not a + * question, and the work below must stay bounded whatever arrives. */ +const MAX_QUERY_CHARS = 400 +/** No real word is longer, and a 5000-letter "word" must not be stemmed. */ +const MAX_WORD_CHARS = 40 +/** How many distinct meaningful words of a query are scored. */ +const MAX_QUERY_WORDS = 24 + +const APOSTROPHES = /['’ʼ`]/g +const MARKS = /\p{M}+/gu +const NOT_WORD = /[^\p{L}\p{N}]+/u + +/** + * Lower-case words, nothing else: punctuation gone, accents folded, and an + * apostrophe closed up rather than split on, so "what's" is "whats" (a stop + * word) and not a stray "s". A command splits into its parts, which is what + * lets "childitem" find Get-ChildItem. + */ +export function tokenise(text: string): string[] { + if (typeof text !== 'string' || text === '') return [] + const flat = text.normalize('NFKD').replace(MARKS, '').replace(APOSTROPHES, '').toLowerCase() + const out: string[] = [] + for (const w of flat.split(NOT_WORD)) { + if (w !== '') out.push(w.length > MAX_WORD_CHARS ? w.slice(0, MAX_WORD_CHARS) : w) + } + return out +} + +/** + * The scaffolding of a question. Deliberately NOT here, because in a shell + * they are the point: all, not, no, up, down, out, back, off, kill, who, + * where, new, last, first, set, run, use. "get" IS here although it is + * PowerShell's commonest verb: in "how do I get out of this folder" it matched + * every Get- command in the catalogue, and someone who types Get-Process is + * answered by the command's own name, which no stop word touches. + */ +const STOP_WORDS: ReadonlySet = new Set( + ( + 'a an the this that these those there here it its ' + + 'i im ive id me my mine we our you your ' + + 'is are am be been was were do does did doing done ' + + 'can could would should will shall may might must ' + + 'how what whats which why when ' + + 'to of in on at by for with from into onto as about than then so and or but if ' + + 'please way ways want wants need needs like just some any thing things something someone ' + + 'tell know let lets help get one s t' + ).split(' ') +) + +/** + * The words of a query that are worth scoring, each once, in the order they + * were said. A query made of nothing but scaffolding ("how do i") keeps all + * of its words: an answer to something beats an answer to nothing. + */ +export function meaningfulWords(query: string): string[] { + const all = tokenise(typeof query === 'string' ? query.slice(0, MAX_QUERY_CHARS) : '') + const kept = all.filter((w) => !STOP_WORDS.has(w)) + const seen = new Set() + const out: string[] = [] + for (const w of kept.length > 0 ? kept : all) { + if (seen.has(w)) continue + seen.add(w) + out.push(w) + if (out.length === MAX_QUERY_WORDS) break + } + return out +} + +// --------------------------------------------------------------------------- +// Stems +// --------------------------------------------------------------------------- + +/** Forms no suffix rule reaches. */ +const IRREGULAR: ReadonlyMap = new Map([ + ['using', 'use'], + ['used', 'use'], + ['uses', 'use'], + ['going', 'go'], + ['goes', 'go'], + ['went', 'go'], + ['ran', 'run'], + ['made', 'make'], + ['took', 'take'], + ['taken', 'take'], + ['wrote', 'write'], + ['written', 'write'], + ['found', 'find'], + ['gone', 'go'], + ['dirs', 'dir'] +]) + +/** + * -er and -est are folded ONLY onto these. English has far more nouns ending + * that way than comparatives (folder, server, user, docker, test, latest), and + * a search that turns "folder" into "fold" has broken the commonest word in + * the catalogue. "new" is NOT one of them, on purpose: "newest files" asks + * what changed last and "new file" asks how to make one, and folded together + * the first question was answered with the second's command. + */ +const COMPARABLE: ReadonlySet = new Set( + 'big large small old fast slow long short high low early heavy great few deep wide recent'.split( + ' ' + ) +) + +const isVowel = (w: string, i: number): boolean => { + const c = w[i] + return c === 'a' || c === 'e' || c === 'i' || c === 'o' || c === 'u' || (c === 'y' && i > 0) +} +const hasVowel = (w: string): boolean => { + for (let i = 0; i < w.length; i++) if (isVowel(w, i)) return true + return false +} + +/** + * What is left once -ing / -ed / -er / -est has come off, put back into a + * word: "stopp" is "stop", "delet" is "delete". Null when too little is left + * to be a word at all ("ping" is not "p" plus -ing, "string" is not "str"). + */ +function repair(root: string): string | null { + if (root.length < 3 || !hasVowel(root)) return null + const n = root.length + // A doubled consonant was doubled BY the suffix (running, stopped, biggest), + // except l, s and z, which double on their own (kill, miss, buzz). "add" is + // left alone: what would remain is not a word. + if (root[n - 1] === root[n - 2] && !isVowel(root, n - 1) && !'lsz'.includes(root[n - 1])) { + return n > 3 ? root.slice(0, -1) : root + } + // The suffix ate a silent e. English cannot tell "deleted" from "edited" + // by shape alone, so this is a guess, and a wrong one is harmless: see + // `keyOf`, which is what the search actually compares. + if (/(?:at|iz|bl|rg|ang|ur)$/.test(root)) return root + 'e' + const cvc = !isVowel(root, n - 1) && isVowel(root, n - 2) && !isVowel(root, n - 3) + if (cvc && !'wxy'.includes(root[n - 1]) && !/(?:en|er|on|or|el|al|it)$/.test(root)) + return root + 'e' + return root +} + +/** + * A LIGHT stemmer: plurals, -ing, -ed, and -er / -est on a short list of + * adjectives. Light on purpose. Words of three letters or fewer, and anything + * that is not plain a-z, come back untouched, which is what keeps `ls`, `ps`, + * `cls` and `sha256` themselves; "less", "status", "this" and "alias" survive + * because -ss, -us, -is and -as are not plurals. + */ +export function stem(word: string): string { + if (typeof word !== 'string') return '' + if (word.length <= 3 || word.length > MAX_WORD_CHARS || !/^[a-z]+$/.test(word)) return word + const irregular = IRREGULAR.get(word) + if (irregular !== undefined) return irregular + + let w = word + if (w.endsWith('sses')) w = w.slice(0, -2) + else if (w.endsWith('ies') && w.length > 4) w = w.slice(0, -3) + 'y' + else if (/(?:ch|sh|x)es$/.test(w)) w = w.slice(0, -2) + else if (w.endsWith('s') && !/(?:ss|us|is|as)$/.test(w)) w = w.slice(0, -1) + if (w.length <= 3) return w + + if (w.endsWith('ing')) return repair(w.slice(0, -3)) ?? w + if (w.endsWith('ied') && w.length > 4) return w.slice(0, -3) + 'y' + if (w.endsWith('ed') && !w.endsWith('eed')) return repair(w.slice(0, -2)) ?? w + for (const [suffix, y] of [ + ['iest', true], + ['ier', true], + ['est', false], + ['er', false] + ] as const) { + if (!w.endsWith(suffix)) continue + const root = y ? w.slice(0, -suffix.length) + 'y' : repair(w.slice(0, -suffix.length)) + if (root !== null && COMPARABLE.has(root)) return root + break + } + return w +} + +/** + * A short form and its long form are ONE word, not two that mean alike, so + * they compare equal rather than as synonyms: "run as admin" has to meet the + * keyword "run as administrator" as the same phrase. Kept to the handful a + * shell's own vocabulary shortens; `dir` is not here, being a command too. + */ +const LONG_FORM: ReadonlyMap = new Map([ + ['admin', 'administrator'], + ['env', 'environment'], + ['app', 'application'], + ['repo', 'repository'], + ['config', 'configuration'] +]) + +const keyCache = new Map() + +/** + * What two words are COMPARED by: the stem, less a final e on anything of + * five letters or more. "deleted" and "edited" have the same shape and only + * one of them lost an e, so `stem` sometimes restores one that was never + * there ("develope"); ignoring that letter on both sides makes the guess + * free. Four-letter words keep theirs, or "note" would meet "not". + */ +function keyOf(word: string): string { + let k = keyCache.get(word) + if (k === undefined) { + k = stem(word) + k = LONG_FORM.get(k) ?? k + if (k.length >= 5 && k.endsWith('e')) k = k.slice(0, -1) + // Bounded: query words are whatever anyone types, for as long as the app + // runs. The catalogue's own few thousand words come straight back. + if (keyCache.size > 20000) keyCache.clear() + keyCache.set(word, k) + } + return k +} + +// --------------------------------------------------------------------------- +// Synonyms +// --------------------------------------------------------------------------- + +/** + * The everyday vocabulary of this domain and nothing more. It is SMALL on + * purpose: the catalogue's own keywords are where an entry says what else it + * is called, and a big table here would make every query match everything. + */ +const SYNONYM_GROUPS: ReadonlyArray> = [ + ['big', 'large', 'huge', 'size', 'heavy'], + ['delete', 'remove', 'erase', 'rm', 'del', 'trash'], + ['folder', 'directory', 'dir'], + ['find', 'search', 'locate', 'look'], + ['show', 'list', 'see', 'display', 'view', 'print'], + ['stop', 'kill', 'end', 'terminate', 'quit'], + ['make', 'create', 'new'], + ['rename', 'move', 'mv'], + ['copy', 'duplicate', 'cp'], + ['text', 'string', 'word'], + ['process', 'task', 'program', 'app', 'application'], + ['port', 'listening'], + ['ip', 'address'], + ['path', 'environment', 'env', 'variable'], + ['undo', 'revert', 'discard', 'restore'], + ['go', 'change', 'navigate', 'cd'], + ['hidden', 'invisible', 'dotfile'], + ['unzip', 'extract', 'decompress', 'unpack'], + ['zip', 'compress', 'archive'], + ['admin', 'administrator', 'elevated', 'sudo'], + ['history', 'previous', 'last', 'recent'], + ['newest', 'newer', 'latest'], + ['clear', 'cls', 'clean', 'wipe'], + ['install', 'setup'], + ['download', 'fetch'], + ['file', 'document'], + ['frozen', 'hung', 'stuck', 'unresponsive'] +] + +/** + * A word that reads as another ONE WAY, keyed by the word as typed. "running" + * means a process, but its stem is "run", and "run a script" must not start + * matching every entry about processes. + */ +const ONE_WAY: ReadonlyMap = new Map([['running', 'process']]) + +const synonymsByKey: ReadonlyMap> = (() => { + const m = new Map() + for (const group of SYNONYM_GROUPS) { + for (const w of group) { + const list = m.get(keyOf(w)) ?? [] + for (const other of group) if (other !== w && !list.includes(other)) list.push(other) + m.set(keyOf(w), list) + } + } + return m +})() + +/** + * The other everyday words for this one, as written in the table (never the + * word itself). Empty for a word the table does not know. + */ +export function expand(word: string): string[] { + if (typeof word !== 'string' || word === '') return [] + const w = word.toLowerCase() + const via = ONE_WAY.get(w) + if (via !== undefined) return [via, ...(synonymsByKey.get(keyOf(via)) ?? [])] + const k = keyOf(w) + return (synonymsByKey.get(k) ?? []).filter((s) => keyOf(s) !== k) +} + +// --------------------------------------------------------------------------- +// One typo +// --------------------------------------------------------------------------- + +/** + * True when `a` is `b` with at most ONE slip: a letter wrong, missing, extra, + * or two neighbours swapped ("direcotry"). Written out rather than as the + * usual distance table because the bound is fixed at one, which makes it a + * single pass with no allocation, and it runs against the whole vocabulary. + */ +export function withinOneEdit(a: string, b: string): boolean { + const la = a.length + const lb = b.length + if (la - lb > 1 || lb - la > 1) return false + let i = 0 + const n = la < lb ? la : lb + while (i < n && a.charCodeAt(i) === b.charCodeAt(i)) i++ + if (i === la && i === lb) return true + if (la === lb) { + if (a.slice(i + 1) === b.slice(i + 1)) return true + return i + 1 < la && a[i] === b[i + 1] && a[i + 1] === b[i] && a.slice(i + 2) === b.slice(i + 2) + } + return la > lb ? a.slice(i + 1) === b.slice(i) : a.slice(i) === b.slice(i + 1) +} + +// --------------------------------------------------------------------------- +// The index +// --------------------------------------------------------------------------- + +const W_TASK = 10 +const W_KEYWORDS = 6 +const W_SUMMARY = 3 +const W_COMMAND = 2 +const W_REST = 1 +/** A second field holding the same word is a little more evidence, never + * enough to lift a lower field over a higher one. */ +const ALSO = 0.25 + +/** How a query word can meet an entry's word, and what that is worth. A + * synonym sits just under 0.6 so the person's own word in an entry's + * keywords (6) beats a synonym of it in another entry's task (5.5). */ +const M_EXACT = 1 +const M_SYNONYM = 0.55 +const M_PREFIX = 0.5 +const M_FUZZY = 0.4 + +const BONUS_COMMAND_IS = 12000 +const BONUS_COMMAND_LEADS = 10000 +const BONUS_VARIANT = 6000 +const BONUS_COMMAND_PARTIAL = 2000 +const BONUS_ALL_WORDS = 1000 +const BONUS_PHRASE_IS = 30 +const BONUS_PHRASE_INSIDE = 15 +const BONUS_PHRASE_AROUND = 12 + +interface Index { + count: number + /** key -> the entries holding it, and what holding it there is worth. */ + postings: Map + vocabulary: string[] + rarity: Map + /** Per entry: the command and its variants, lower-cased and single-spaced. */ + commands: string[] + variants: string[][] + /** Per entry: the first word of each LATER stage of the pipeline. */ + stages: string[][] + /** Per entry: the task and each keyword as ' key key key '. */ + phrases: string[][] +} + +const str = (v: unknown): string => (typeof v === 'string' ? v : '') +const flatCommand = (v: unknown): string => str(v).toLowerCase().replace(/\s+/g, ' ').trim() + +function buildIndex(entries: readonly HelpEntry[]): Index { + const count = entries.length + const postings: Index['postings'] = new Map() + const commands: string[] = [] + const variants: string[][] = [] + const stages: string[][] = [] + const phrases: string[][] = [] + + for (let i = 0; i < count; i++) { + // The types promise a well-formed entry; the panel must still open if a + // catalogue file ever ships a broken one, so nothing here trusts a field. + const entry = (entries[i] ?? {}) as Partial + const fields = new Map() + const phrase: string[] = [] + const read = (text: unknown, worth: number, asPhrase: boolean): void => { + const keys = tokenise(str(text)).map(keyOf) + for (const k of keys) { + const held = fields.get(k) + if (held === undefined) fields.set(k, [worth]) + else if (!held.includes(worth)) held.push(worth) + } + if (asPhrase && keys.length > 0) phrase.push(` ${keys.join(' ')} `) + } + + read(entry.task, W_TASK, true) + const keywords = Array.isArray(entry.keywords) ? entry.keywords : [] + for (const k of keywords) read(k, W_KEYWORDS, true) + read(entry.summary, W_SUMMARY, false) + read(entry.command, W_COMMAND, false) + const vs = Array.isArray(entry.variants) ? entry.variants : [] + for (const v of vs) { + read(v?.label, W_REST, false) + read(v?.command, W_REST, false) + } + if (entry.placeholders && typeof entry.placeholders === 'object') { + for (const v of Object.values(entry.placeholders)) read(v, W_REST, false) + } + read(entry.danger, W_REST, false) + + for (const [k, held] of fields) { + held.sort((a, b) => b - a) + let worth = held[0] + for (let j = 1; j < held.length; j++) worth += ALSO * held[j] + let p = postings.get(k) + if (p === undefined) postings.set(k, (p = { at: [], worth: [] })) + p.at.push(i) + p.worth.push(worth) + } + + const command = flatCommand(entry.command) + commands.push(command) + variants.push(vs.map((v) => flatCommand(v?.command)).filter((c) => c !== '')) + stages.push( + command + .split(/\|\||&&|[|;]/) + .slice(1) + .map( + (s) => + s + .trim() + .replace(/^sudo /, '') + .split(' ')[0] + ) + .filter((s) => s !== '') + ) + phrases.push(phrase) + } + + // A word in every other entry ("file") says less than one in two ("port"). + // Between 1 and 2, so it tilts a ranking without overturning the fields. + const rarity = new Map() + const scale = Math.log(count + 1) || 1 + for (const [k, p] of postings) + rarity.set(k, 1 + Math.log((count + 1) / (p.at.length + 1)) / scale) + + return { + count, + postings, + vocabulary: [...postings.keys()], + rarity, + commands, + variants, + stages, + phrases + } +} + +/** + * One index per catalogue ARRAY. The catalogue is a constant that ships with + * the app, so every keystroke after the first finds its index already made; + * a WeakMap, so a test's throwaway catalogue is not kept alive by it. + */ +const indexes = new WeakMap() + +function indexOf(entries: readonly HelpEntry[]): Index { + let ix = indexes.get(entries) + if (ix === undefined || ix.count !== entries.length) { + ix = buildIndex(entries) + indexes.set(entries, ix) + } + return ix +} + +// --------------------------------------------------------------------------- +// The search +// --------------------------------------------------------------------------- + +/** Every way this word of the query may meet a word of the catalogue. */ +function candidates(word: string, ix: Index): Map { + const out = new Map() + const key = keyOf(word) + out.set(key, M_EXACT) + const others = expand(word) + for (const s of others) { + const k = keyOf(s) + if (!out.has(k)) out.set(k, M_SYNONYM) + } + // A word the catalogue holds needs no guessing, and a number is a value for + // a placeholder (the 3000 of "port 3000"), never a misspelling. + if (ix.postings.has(key) || /^\d+$/.test(word)) return out + + // A guessed word brings its synonyms with it, or "direcotry" would find the + // entries that say "directory" and miss the many more that say "folder". + const guess = (v: string, how: number): void => { + out.set(v, how) + for (const s of synonymsByKey.get(v) ?? []) { + const k = keyOf(s) + if (!out.has(k)) out.set(k, how * M_SYNONYM) + } + } + if (word.length >= 3) { + for (const v of ix.vocabulary) { + if ( + v.length > word.length && + (v.startsWith(word) || (key.length >= 3 && v.startsWith(key))) && + !out.has(v) + ) { + guess(v, M_PREFIX) + } + } + } + // ONE typo, only in a word long enough for one slip to leave it recognisable, + // and only when nothing else explained the word. The first letter has to be + // right: people rarely miss it, and without that rule "mount" meets "count". + if (out.size === 1 && others.length === 0 && word.length >= 5) { + for (const v of ix.vocabulary) { + if ( + !out.has(v) && + v.length >= 4 && + v.charCodeAt(0) === word.charCodeAt(0) && + (withinOneEdit(word, v) || withinOneEdit(key, v)) + ) { + guess(v, M_FUZZY) + } + } + } + return out +} + +function commandBonus(q: string, i: number, ix: Index): number { + if (q === '' || q.length > 200) return 0 + const command = ix.commands[i] + if (command === q) return BONUS_COMMAND_IS + const lead = q + ' ' + if (command.startsWith(lead)) return BONUS_COMMAND_LEADS + const vs = ix.variants[i] + for (const v of vs) if (v === q || v.startsWith(lead)) return BONUS_VARIANT + if (ix.stages[i].includes(q)) return BONUS_COMMAND_PARTIAL + // Still being typed: "/comp", "get-ch". One letter is too little to mean a + // command, except a slash, which means nothing else. + if (q.length >= 2 || q === '/') { + if (command.startsWith(q)) return BONUS_COMMAND_PARTIAL + for (const v of vs) if (v.startsWith(q)) return BONUS_COMMAND_PARTIAL + } + return 0 +} + +function phraseBonus(padded: string, wordCount: number, phrases: readonly string[]): number { + let best = 0 + for (const p of phrases) { + if (p === padded) return BONUS_PHRASE_IS + // A one-word keyword inside a sentence is just a word, already scored. + if (best < BONUS_PHRASE_INSIDE && p.indexOf(' ', 1) < p.length - 1 && padded.includes(p)) + best = BONUS_PHRASE_INSIDE + else if (best < BONUS_PHRASE_AROUND && wordCount >= 2 && p.includes(padded)) + best = BONUS_PHRASE_AROUND + } + return best +} + +/** + * Search the catalogue. An EMPTY query is not a search: it answers with the + * entries of that shell in catalogue order, score 0, so the panel can browse + * by category. A query that matches nothing answers with nothing. Never + * throws, whatever it is handed. + */ +export function searchHelp( + entries: readonly HelpEntry[], + query: string, + opts?: HelpSearchOptions +): HelpSearchHit[] { + if (!Array.isArray(entries) || entries.length === 0) return [] + const shell = opts?.shell + const category = opts?.category + const limit = + typeof opts?.limit === 'number' && opts.limit >= 1 ? Math.floor(opts.limit) : Infinity + const wanted = (entry: HelpEntry | undefined | null): entry is HelpEntry => + entry != null && + typeof entry === 'object' && + (shell === undefined || entry.shell === shell || entry.shell === 'any') && + (category === undefined || entry.category === category) + + const text = typeof query === 'string' ? query.slice(0, MAX_QUERY_CHARS) : '' + if (text.trim() === '') { + const all: HelpSearchHit[] = [] + for (const entry of entries) { + if (wanted(entry)) all.push({ entry, score: 0 }) + if (all.length >= limit) break + } + return all + } + + const ix = indexOf(entries) + const n = ix.count + const words = meaningfulWords(text) + const sum = new Float64Array(n) + const matched = new Uint8Array(n) + const best = new Float64Array(n) + // A number is never REQUIRED: "port 3000" is fully answered by the entry + // about ports, whose command says PORT where the 3000 will go. + let required = 0 + + for (const word of words) { + const counts = !/^\d+$/.test(word) + if (counts) required++ + best.fill(0) + for (const [key, how] of candidates(word, ix)) { + const p = ix.postings.get(key) + if (p === undefined) continue + const scale = how * (ix.rarity.get(key) ?? 1) + for (let j = 0; j < p.at.length; j++) { + const v = p.worth[j] * scale + if (v > best[p.at[j]]) best[p.at[j]] = v + } + } + for (let i = 0; i < n; i++) { + if (best[i] === 0) continue + sum[i] += best[i] + if (counts) matched[i]++ + } + } + + const q = flatCommand(text) + const spoken = tokenise(text) + .slice(0, MAX_QUERY_WORDS * 2) + .map(keyOf) + const padded = ` ${spoken.join(' ')} ` + + const hits: HelpSearchHit[] = [] + for (let i = 0; i < n; i++) { + const entry = entries[i] + if (!wanted(entry)) continue + const named = commandBonus(q, i, ix) + const phrase = spoken.length > 0 ? phraseBonus(padded, spoken.length, ix.phrases[i]) : 0 + if (sum[i] === 0 && named === 0 && phrase === 0) continue + const everyWord = required > 0 && matched[i] === required ? BONUS_ALL_WORDS : 0 + hits.push({ entry, score: Math.round((sum[i] + phrase + everyWord + named) * 100) / 100 }) + } + + hits.sort( + (a, b) => + b.score - a.score || + str(a.entry.task).length - str(b.entry.task).length || + (str(a.entry.id) < str(b.entry.id) ? -1 : str(a.entry.id) > str(b.entry.id) ? 1 : 0) + ) + return hits.length > limit ? hits.slice(0, limit) : hits +} diff --git a/core/shared/help/shells.ts b/core/shared/help/shells.ts new file mode 100644 index 0000000..5bb4e9d --- /dev/null +++ b/core/shared/help/shells.ts @@ -0,0 +1,32 @@ +import type { HelpShell } from './types' + +/** + * WHICH SHELLS THE HELP PANEL KNOWS (#12), in a file of its own ON PURPOSE: + * `index.ts` imports the whole catalogue, several hundred entries of text, and + * a host wants to load that only when the popup is first opened. What a host + * needs BEFORE then (which chip to preselect, the type of a stored choice) + * lives here and imports nothing heavy. + */ + +/** The shells somebody can be IN. 'any' is not one of them: it marks an entry + * that reads the same in all three, and is never a chip. */ +export type HelpShellChoice = Exclude + +export const HELP_SHELLS: ReadonlyArray<{ id: HelpShellChoice; name: string }> = [ + { id: 'powershell', name: 'PowerShell' }, + { id: 'cmd', name: 'Command Prompt' }, + { id: 'bash', name: 'Bash' } +] + +/** + * Which command language a DETECTED shell speaks, from its id + * (`core/main/shells.ts`: 'pwsh', 'powershell', 'cmd', and 'wsl-' per + * WSL distribution). An id nobody knows reads as PowerShell, which is what + * `shellById` falls back to when a saved shell has gone. + */ +export function shellOfShellId(id: string | null | undefined): HelpShellChoice { + const v = typeof id === 'string' ? id.toLowerCase() : '' + if (v === 'cmd') return 'cmd' + if (v.startsWith('wsl') || v.includes('bash')) return 'bash' + return 'powershell' +} diff --git a/core/shared/help/types.ts b/core/shared/help/types.ts new file mode 100644 index 0000000..403c529 --- /dev/null +++ b/core/shared/help/types.ts @@ -0,0 +1,62 @@ +/** + * THE HELP PANEL'S CATALOGUE, its shape (#12). + * + * Owner, 2026-09-19: "make the shell more user friendly and the learning curve + * less harsh: an easy to use panel where you can find shell commands", with + * "metadata on each command so a natural-language search finds it ('how do I + * find big files', not only the command's name)". And on 2026-09-20: "a pop up + * with copy icons for easy copying, searchable, natural language". + * + * So an entry is written TASK FIRST: what someone wants to do, in the words + * they would use, and only then the command that does it. The catalogue is + * CURATED and ships with the app: it works offline, needs no model and no + * network, and can be reviewed line by line. Nothing in it is ever run or typed + * by the app (owner, 2026-09-19: picking a command does NOT insert it into the + * shell). The panel shows, and copies on request. + */ + +/** Which command language an entry is written in. 'any' is the same text in + * every shell (git, the agents' own CLIs, winget). */ +export type HelpShell = 'powershell' | 'cmd' | 'bash' | 'any' + +export type HelpCategory = + | 'files' // find, list, copy, move, delete, read, search inside + | 'folders' // move around, make, size, tree + | 'text' // search, filter, count, compare, replace + | 'processes' // what is running, stop it, ports + | 'network' // connectivity, downloads, ports, DNS + | 'system' // disk, memory, environment, versions, services + | 'packages' // winget, apt, npm, pip + | 'git' + | 'shell' // history, aliases, variables, the prompt, help itself + | 'agents' // Claude Code and Codex + +export interface HelpEntry { + /** Stable, unique, kebab-case, prefixed by its shell: 'ps-biggest-files'. */ + id: string + shell: HelpShell + category: HelpCategory + /** What the person wants to do, as they would say it, sentence case, no full + * stop: "Find the biggest files in a folder". Under about 60 characters. */ + task: string + /** One plain sentence on what the command does and when to reach for it. */ + summary: string + /** THE command, copied as is. Placeholders are UPPER_SNAKE words the reader + * replaces (FOLDER, NAME, PORT): never angle brackets, which a shell reads + * as redirection. One line where the shell allows it. */ + command: string + /** Optional: further ready-to-copy variations, each with what it changes. */ + variants?: ReadonlyArray<{ label: string; command: string }> + /** What each placeholder stands for, when the command has any. */ + placeholders?: Readonly> + /** + * WORDS SOMEONE MIGHT TYPE WHO DOES NOT KNOW THE COMMAND: synonyms, the + * everyday phrasing, the name of the same thing in another shell ("ls", + * "dir", "grep"), the symptom ("port already in use"). Lower case. This is + * what makes "how do I find big files" land, so be generous: 6 to 16. + */ + keywords: ReadonlyArray + /** Set on anything that deletes, overwrites, kills or cannot be undone: one + * short sentence saying what is lost. The panel shows it as a warning. */ + danger?: string +} diff --git a/package-lock.json b/package-lock.json index 96f3a95..3bfda36 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "prism-terminal", - "version": "0.4.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "prism-terminal", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "dependencies": { "@xterm/addon-fit": "^0.11.0", diff --git a/package.json b/package.json index 4209f95..8bd1cbb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "prism-terminal", "productName": "Prism Terminal", - "version": "0.4.0", + "version": "0.5.0", "description": "A tabbed Windows terminal for AI CLIs.", "main": "./out/main/index.js", "author": "Max", diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 2704d7f..826c35d 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -36,6 +36,8 @@ import { quotePaths } from '@core/renderer/lib/termPaste' import { ContextMenu } from './components/ContextMenu' import { rememberRoot } from '@core/renderer/lib/recentRoots' import { savedShellId } from '@core/renderer/lib/termPrefs' +import { helpEnabled, helpShell, setHelpShell, useHelpEnabled } from '@core/renderer/lib/helpPrefs' +import { shellOfShellId, type HelpShellChoice } from '@core/shared/help/shells' import { newTabFolder, newTabMode } from './lib/newTabPrefs' import { AGENT_NAMES, @@ -43,12 +45,21 @@ import { closeQuestionTitle, holdsWindowClose } from '@core/renderer/lib/agentClose' -import { onTermLookChange, termAcrylic, termOpacity, termThemeId } from '@core/renderer/lib/termLook' +import { + onTermLookChange, + termAcrylic, + termFontStack, + termOpacity, + termThemeId +} from '@core/renderer/lib/termLook' import { presetAccent, resolveTermTheme } from '@core/renderer/lib/termTheme' import { applyChrome, chromeTokens } from './lib/chromeTheme' import { onWindowEdgesChange, windowEdges } from './lib/edgesPrefs' const Settings = lazy(() => import('./components/Settings')) +// Loaded when it is first opened: the popup brings the whole catalogue with it, +// several hundred entries of text that a launch has no use for. +const HelpPanel = lazy(() => import('@core/renderer/components/HelpPanel')) /** What a close question is about to interrupt. */ interface Ask { @@ -94,6 +105,16 @@ export default function App(): JSX.Element { const [ask, setAsk] = useState(null) /** The terminal's own right-click menu, at the pointer. */ const [termMenu, setTermMenu] = useState<{ x: number; y: number } | null>(null) + /** The command help popup (#12). The core's, the same in Prism; what is this + * app's is the way in (F1, the ? in the title bar, the terminal's menu). */ + const [helpOpen, setHelpOpen] = useState(false) + /** The chip it opens on, decided at the press that opens it. */ + const [helpFor, setHelpFor] = useState('powershell') + const helpOn = useHelpEnabled() + /** Which shell each tab was SPAWNED with. The Shell setting only names what + * the next terminal launches, so a tab opened before it was changed still + * speaks its old language, and the popup preselects by what is running. */ + const shellIds = useRef(new Map()) const { tabs, activeId } = state const active = tabs.find((t) => t.id === activeId) ?? null const activeShell = active && active.kind !== 'settings' ? active : null @@ -106,10 +127,7 @@ export default function App(): JSX.Element { const { agentIds, workingIds, doneIds, agentKinds } = indicator // The latest of everything, for listeners registered once. - const live = useRef({ state, workingIds, agentIds }) - useEffect(() => { - live.current = { state, workingIds, agentIds } - }) + const live = useRef({ state, workingIds, agentIds, blocked: false, front: '' }) useEffect(() => { paintChrome() @@ -127,7 +145,9 @@ export default function App(): JSX.Element { // the id rides the spawn, nothing is ever visibly typed. if (resume) markResume(id, resume) rememberRoot(cwd) - ensureTermSession(id, cwd, savedShellId()) + const shellId = savedShellId() + shellIds.current.set(id, shellId) + ensureTermSession(id, cwd, shellId) setState((s) => addTab(s, id, cwd)) return id }, []) @@ -189,6 +209,7 @@ export default function App(): JSX.Element { disposeTermSession(id) forgetSession(id) indicator.forget(id) + shellIds.current.delete(id) } setState((s) => closeTab(s, id)) }, @@ -294,6 +315,46 @@ export default function App(): JSX.Element { useEffect(() => { if (ask) cancelUpdate() }, [ask, cancelUpdate]) + + // COMMAND HELP (#12). It is the same layer as the update window and a close + // question, so the same rule holds it: it never sits over either. It does not + // OPEN while one of them is up, and it is PUT AWAY when one appears (Ctrl+W + // and Alt+F4 still work over it, and a question mounted underneath would + // take the focus where nobody can see it). Switched off in Settings, it goes. + // Put away while RENDERING, not in an effect: an effect would paint one + // frame of the popup over the question first. + const helpBlocked = !!ask || update.state.open || !helpOn + // AND IT LEAVES WHEN WHAT IS IN FRONT CHANGES. The app's chords keep working + // over the popup, and three of them hand the keyboard to something BEHIND it: + // Ctrl+T and Ctrl+Tab mount a terminal, which takes the focus as it attaches, + // and Ctrl+Shift+F opens the find bar. Left up, the popup then sat over a + // focused shell, and a question typed "into the search field" was typed into + // that shell instead. So it remembers what was in front when it opened (the + // tab, and whether find was showing) and goes as soon as that is no longer so. + const inFront = `${activeId ?? ''}|${findFor ?? ''}` + const [helpFront, setHelpFront] = useState(inFront) + const helpStale = helpFront !== inFront + if (helpOpen && (helpBlocked || helpStale)) setHelpOpen(false) + const toggleHelp = useCallback(() => { + // The chip it opens on is the language of the shell in front; with no + // shell in front, the last one picked by hand, else what a new terminal + // would launch. + const { state: st } = live.current + const front = st.tabs.find((t) => t.id === st.activeId) + setHelpFor( + front && front.kind !== 'settings' + ? shellOfShellId(shellIds.current.get(front.id)) + : (helpShell() ?? shellOfShellId(savedShellId())) + ) + setHelpFront(live.current.front) + setHelpOpen((was) => (was ? false : helpEnabled() && !live.current.blocked)) + }, []) + // The latest of everything, for listeners registered once. `blocked` is + // whether something that outranks the help popup is up (see above). + const updateOpen = update.state.open + useEffect(() => { + live.current = { state, workingIds, agentIds, blocked: !!ask || updateOpen, front: inFront } + }) /** The running version, for the window's "You have" line. Asked once. */ const [version, setVersion] = useState('') useEffect(() => { @@ -324,8 +385,8 @@ export default function App(): JSX.Element { // Whatever a tab interaction did to DOM focus, the shell in front gets the // keyboard back: clicking or dragging a tab is not "I left the shell". useEffect(() => { - if (activeShell && !ask && !findOpen && !update.state.open) focusTermSession(activeShell.id) - }, [activeShell, ask, findOpen, update.state.open]) + if (activeShell && !ask && !findOpen && !update.state.open && !helpOpen) focusTermSession(activeShell.id) + }, [activeShell, ask, findOpen, update.state.open, helpOpen]) useEffect(() => { const onKey = (e: KeyboardEvent): void => { @@ -334,6 +395,15 @@ export default function App(): JSX.Element { window.prism.windowToggleFullscreen() return } + // F1 is command help, bare, and only while the setting is on: off, the + // key is the shell's (termHost's ownsKey makes the same test, so xterm + // yields it exactly when this takes it). + if (e.key === 'F1' && !e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && helpEnabled()) { + e.preventDefault() + e.stopPropagation() + toggleHelp() + return + } if (!e.ctrlKey || e.altKey) return const k = e.key.toLowerCase() const hit = (): void => { @@ -372,7 +442,7 @@ export default function App(): JSX.Element { } window.addEventListener('keydown', onKey, true) return () => window.removeEventListener('keydown', onKey, true) - }, [newTab, requestClose]) + }, [newTab, requestClose, toggleHelp]) const openRecent = useCallback( (path: string) => { @@ -386,6 +456,7 @@ export default function App(): JSX.Element {
setState(openSettings)} + onHelp={helpOn ? toggleHelp : undefined} chip={ void pasteInto(activeShell.id) }, { label: 'Find in scrollback', hint: 'Ctrl+Shift+F', onPick: () => setFindFor(activeShell.id) }, - { label: 'Close tab', hint: 'Ctrl+Shift+W', onPick: () => requestClose(activeShell.id) } + { label: 'Close tab', hint: 'Ctrl+Shift+W', onPick: () => requestClose(activeShell.id) }, + // Only while the setting is on: off means the app offers it nowhere. + ...(helpOn ? [{ label: 'Command help', hint: 'F1', onPick: toggleHelp }] : []) ]} /> )} @@ -546,6 +619,22 @@ export default function App(): JSX.Element { /> )} + {/* COMMAND HELP (#12), mounted once. It is handed the clipboard and + NOTHING ELSE: no session id, no termInput. It cannot type into a shell + because it has no way to reach one, which is the owner's rule (picking + a command does NOT insert it). */} + {helpOpen && !helpBlocked && !helpStale && ( + + window.prism.writeClipboard(text)} + onPickShell={setHelpShell} + onClose={() => setHelpOpen(false)} + /> + + )} + {/* Mounted once, here: the chip lives in the title bar, the window it opens belongs to the whole app. */} {update.state.open && update.state.info && ( diff --git a/src/renderer/src/components/Settings.tsx b/src/renderer/src/components/Settings.tsx index 83385fc..aae1cd8 100644 --- a/src/renderer/src/components/Settings.tsx +++ b/src/renderer/src/components/Settings.tsx @@ -4,6 +4,7 @@ import { setWindowEdges, useWindowEdges } from '../lib/edgesPrefs' import { WINDOW_EDGES, type WindowEdges } from '@shared/windowEdges' import { Pref, ROWS, ROW_BUTTON, Segmented, Switch } from '@core/renderer/settings/fields' import { DictationSettings } from '@core/renderer/settings/Dictation' +import { HelpSetting } from '@core/renderer/settings/Help' import { TerminalAppearanceSettings } from '@core/renderer/settings/TerminalAppearance' import { AgentIndicatorSetting, ShellSetting } from '@core/renderer/settings/TerminalBehaviour' @@ -97,6 +98,8 @@ function GeneralTab(): JSX.Element { + {/* The core's row (#12); the way in is this app's, so this app says it. */} + {/* Explorer's own menu. Windows 11 hides classic verbs behind "Show more options", and saying so is better than the user hunting for it. The hint QUOTES the entry (#27): it no longer names the app, so the row diff --git a/src/renderer/src/components/TitleBar.tsx b/src/renderer/src/components/TitleBar.tsx index d73d9c5..0cc258a 100644 --- a/src/renderer/src/components/TitleBar.tsx +++ b/src/renderer/src/components/TitleBar.tsx @@ -2,8 +2,8 @@ import type { JSX, ReactNode } from 'react' /** * The frameless window's top strip: the mark and the name, a drag region, the - * update chip when there is one, the settings cog, and the three window - * buttons. A cog and not a menu (owner, 2026-09-18): a menu of one row is + * update chip when there is one, the command help button (#12, when that + * setting is on), the settings cog, and the three window buttons. A cog and not a menu (owner, 2026-09-18): a menu of one row is * chrome. * * Prism's bar, by subtraction: no file name, no panel toggle, no Tools. What @@ -20,9 +20,13 @@ const BTN = export default function TitleBar({ onSettings, + onHelp, chip }: { onSettings: () => void + /** Open the command help popup; absent when the setting is off, and the + * button goes with it. */ + onHelp?: () => void /** The update chip, or nothing when there is no update. */ chip?: ReactNode }): JSX.Element { @@ -55,6 +59,31 @@ export default function TitleBar({ {chip}
+ {onHelp && ( + + )}