SLOPSHOPPER

win-shell-hints

Catches commands that would fail on Windows (bash syntax in PowerShell 5.1, Windows paths in Git Bash) before they run and tells Claude the fix / Ловит…

newguardtoastprocess
★ 1v1.0.2MITupdated 2026-10-26ArtyaEXE/claude-mods/win-shell-hints
A shopper browsing a rack in a slop shop
README

win-shell-hints

Stops Windows-breaking shell commands before they run and tells Claude the right syntax.

English · Русский

English

Claude often writes bash habits into PowerShell 5.1 (&&, grep, head, rm -rf, curl -s, python3) or Windows paths and %VAR% into Git Bash. Each failure costs a round trip and tokens. This mod checks every PowerShell and Bash tool call first: a line that would certainly fail is refused with a one-line fix, so Claude rewrites it right away. When a command does fail with a known Windows error, a short hint is added to the result for Claude.

Quoted strings and here-strings are ignored. A line containing # win-hints:off is passed through untouched.

What it touches

Reads the command text of PowerShell / Bash tool calls and may refuse one. Runs nothing itself, except one PowerShell call at start to detect the language when language is auto.

Settings (/config)

  • language: auto (default; follows LANG, else the Windows UI language or regional format), ru or en.

Install

/plugin install win-shell-hints --marketplace ArtyaEXE/claude-mods

Part of claude-mods. MIT license.


Русский

Не даёт запустить команды, которые упадут на Windows, и сразу подсказывает Claude правильный синтаксис.

Claude часто пишет bash-привычки в PowerShell 5.1 (&&, grep, head, rm -rf, curl -s, python3) или Windows-пути и %VAR% в Git Bash. Каждая такая ошибка — лишний круг и токены. Мод проверяет каждый вызов PowerShell и Bash до запуска: строка, которая точно упадёт, не запускается, а Claude получает однострочную подсказку и сразу переписывает команду. Если команда всё же упала с известной ошибкой Windows, к результату добавляется короткая подсказка для Claude.

Строки в кавычках и here-strings не проверяются. Строку с # win-hints:off мод пропускает как есть.

Что мод делает в системе

Читает текст команд PowerShell / Bash и может отказать в запуске. Сам ничего не запускает, кроме одного вызова PowerShell при старте для определения языка в режиме auto.

Настройки (/config)

  • language: auto (по умолчанию; по LANG, иначе по языку интерфейса или региональному формату Windows), ru или en.

Установка

/plugin install win-shell-hints --marketplace ArtyaEXE/claude-mods

Часть claude-mods. Лицензия MIT.

Source 1 files
hooks/register.ts 216 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3type Lang = 'ru' | 'en'
4
5// `ru` / `en` as set; `auto`: the locale variables first, then Windows' UI language or regional format.
6async function resolveLang($: EngineInterface, setting: unknown): Promise<Lang> {
7  if (setting === 'ru' || setting === 'en') return setting
8  const env = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG'))
9  if (env) return env.toLowerCase().startsWith('ru') ? 'ru' : 'en'
10  try {
11    const ran = await $.process.run(
12      ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', '(Get-UICulture).Name; (Get-Culture).Name'],
13      { timeoutMs: 10_000 },
14    )
15    if (ran.exitCode === 0 && /^ru\b/im.test(ran.stdout)) return 'ru'
16  } catch {
17    // Not Windows: English.
18  }
19
20  return 'en'
21}
22
23// A command holding this marker is passed through untouched.
24const OPT_OUT = '# win-hints:off'
25
26type Rule = { test: RegExp; hint: string }
27
28type AfterRule = Rule & { tools: ('PowerShell' | 'Bash')[] }
29
30// Where a command word starts: the line's head or after a separator.
31const HEAD = String.raw`(?:^|[;|{(\n]\s*)`
32
33const POWERSHELL: Rule[] = [
34  {
35    test: /&&|\|\|/,
36    hint: 'PowerShell 5.1 has no `&&` / `||`. Use `A; if ($?) { B }` (or `A; B` to run unconditionally).',
37  },
38  {
39    test: /\d?>\s*\/dev\/null/,
40    hint: '`/dev/null` does not exist here: use `2>$null`, `> $null` or `| Out-Null`.',
41  },
42  {
43    test: /\?\?|\?\./,
44    hint: 'PowerShell 5.1 has no `??` / `?.`: use `if ($null -eq $x) { ... } else { ... }`.',
45  },
46  {
47    test: new RegExp(HEAD + String.raw`(?:if\s+\[|for\s+\w+\s+in\b|while\s+\[)`),
48    hint: 'Bash control flow is a parser error in PowerShell: use `if (Test-Path x) { }`, `foreach ($x in ...) { }`.',
49  },
50  {
51    test: new RegExp(HEAD + String.raw`(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*=\S+\s+[\w.]`),
52    hint: 'No inline env prefix in PowerShell: use `$env:VAR = "x"; cmd`.',
53  },
54  {
55    test: new RegExp(HEAD + String.raw`export\s+[A-Za-z_]`),
56    hint: '`export` is bash: use `$env:VAR = "value"`.',
57  },
58  {
59    test: new RegExp(HEAD + String.raw`grep\b`),
60    hint: '`grep` is not installed in this PowerShell: use the Grep tool, or `Select-String -Pattern ... -Path ...`.',
61  },
62  {
63    test: new RegExp(HEAD + String.raw`(?:head|tail)\b`),
64    hint: '`head`/`tail` are not installed: use `Get-Content f -TotalCount N` / `-Tail N`, or `| Select-Object -First N` / `-Last N`.',
65  },
66  {
67    test: new RegExp(HEAD + String.raw`which\b`),
68    hint: '`which` is not installed: use `(Get-Command name).Source`.',
69  },
70  {
71    test: new RegExp(HEAD + String.raw`touch\b`),
72    hint: '`touch` is not installed: use `if (-not (Test-Path p)) { New-Item -ItemType File p }`.',
73  },
74  {
75    test: new RegExp(HEAD + String.raw`(?:sed|awk|wc|xargs|cut)\b`),
76    hint: 'sed/awk/wc/xargs/cut are not installed in PowerShell: use the Bash tool (Git Bash) for this, or `-replace`, `Measure-Object`, `ForEach-Object`.',
77  },
78  {
79    test: new RegExp(HEAD + String.raw`(?:rm|del|rmdir)\s+-(?:rf|fr|r\s+-f|f\s+-r)\b`),
80    hint: '`rm -rf` fails in PowerShell (ambiguous parameter): use `Remove-Item -Recurse -Force path`.',
81  },
82  {
83    test: new RegExp(HEAD + String.raw`mkdir\s+-p\b`),
84    hint: '`mkdir -p` is bash: use `New-Item -ItemType Directory -Force path`.',
85  },
86  {
87    test: new RegExp(HEAD + String.raw`curl\s+-`),
88    hint: '`curl` in PowerShell 5.1 is an alias of Invoke-WebRequest and rejects curl flags: call `curl.exe ...` explicitly.',
89  },
90  {
91    test: new RegExp(HEAD + String.raw`python3\b`),
92    hint: '`python3` here is the Microsoft Store stub (WindowsApps), not a real Python: use `python`.',
93  },
94]
95
96const BASH: Rule[] = [
97  {
98    test: /(?:^|[\s=])[A-Za-z]:\\[^\s]/,
99    hint: 'Git Bash eats unquoted backslashes: write Windows paths with forward slashes (`C:/Users/...` or `/c/Users/...`) or quote them.',
100  },
101  {
102    test: /\d?>\s*NUL\b/i,
103    hint: '`NUL` is cmd.exe: in Git Bash use `/dev/null`.',
104  },
105  {
106    test: /%[A-Za-z_][A-Za-z0-9_]*%/,
107    hint: '`%VAR%` is cmd.exe: in Git Bash use `$VAR` (e.g. `$USERPROFILE`, `$APPDATA`).',
108  },
109  {
110    test: /\$env:[A-Za-z_]/,
111    hint: '`$env:VAR` is PowerShell: in Git Bash use `$VAR`.',
112  },
113  {
114    test: new RegExp(HEAD + String.raw`python3\b`),
115    hint: '`python3` here is the Microsoft Store stub (WindowsApps), not a real Python: use `python`.',
116  },
117  {
118    test: new RegExp(HEAD + String.raw`(?:Get|Set|New|Remove|Test|Select|Where|ForEach|Copy|Move)-[A-Z][A-Za-z]+\b`),
119    hint: 'This is a PowerShell cmdlet and Git Bash cannot run it: use the PowerShell tool, or the bash equivalent.',
120  },
121]
122
123// Known error texts after a run, and what to do instead next time; each from the shells that print it.
124const AFTER: AfterRule[] = [
125  {
126    tools: ['PowerShell'],
127    test: /is not recognized as the name of a cmdlet/,
128    hint: 'That command does not exist in this PowerShell 5.1. Use the PowerShell equivalent, or run the line through the Bash tool (Git Bash).',
129  },
130  {
131    tools: ['PowerShell'],
132    test: /is not a valid statement separator/,
133    hint: 'PowerShell 5.1: replace `&&` with `; if ($?) { ... }`.',
134  },
135  {
136    tools: ['PowerShell', 'Bash'],
137    test: /Python was not found; run without arguments to install from the Microsoft Store/,
138    hint: '`python3` is the Store stub here: use `python`.',
139  },
140  {
141    tools: ['PowerShell'],
142    test: /NativeCommandError/,
143    hint: 'PowerShell 5.1 wraps a native command\'s stderr as errors under `2>&1`: drop the redirect, stderr is captured anyway.',
144  },
145]
146
147// A command that prints files or history shows their text, error messages quoted in it included: not errors of its own.
148const SHOWS_FILES = new RegExp(
149  HEAD + String.raw`(?:cat|head|tail|less|more|grep|rg|sed|awk|type|gc|Get-Content|Select-String|sls|git\s+(?:show|diff|log|grep|blame))\b`,
150  'i',
151)
152
153// Quoted strings and here-strings are data, not code: blank them before matching.
154const codeOf = (command: string, isPowerShell: boolean): string => {
155  let code = command
156  if (isPowerShell) {
157    code = code.replace(/@'[\s\S]*?'@|@"[\s\S]*?"@/g, '""')
158    // A full-line comment is not code either.
159    code = code.replace(/^\s*#.*$/gm, '')
160  } else {
161    code = code.replace(/<<-?\s*'?(\w+)'?[\s\S]*?\n\1\b/g, '""')
162  }
163
164  return code.replace(/'[^'\n]*'|"(?:[^"\\\n]|\\.)*"/g, '""')
165}
166
167const hintsFor = (command: string, isPowerShell: boolean): string[] => {
168  if (command.includes(OPT_OUT)) return []
169  const code = codeOf(command, isPowerShell)
170
171  return (isPowerShell ? POWERSHELL : BASH).filter(r => r.test.test(code)).map(r => r.hint)
172}
173
174let lang: Lang = 'en'
175
176const FIXED = {
177  ru: (n: number) => `win-shell-hints: поправил синтаксис до запуска (${n})`,
178  en: (n: number) => `win-shell-hints: fixed the syntax before it ran (${n})`,
179}
180
181export const register: Register = (on, options) => {
182  on('session.start', async ($, e, next) => {
183    const started = await next(e)
184    lang = await resolveLang($, (options as { language?: unknown }).language)
185
186    return started
187  })
188
189  on('tool.call', async ($, e, next) => {
190    if (e.tool !== 'PowerShell' && e.tool !== 'Bash') return next(e)
191    const command = String((e as { command?: unknown }).command ?? '')
192    const isPowerShell = e.tool === 'PowerShell'
193
194    const hints = hintsFor(command, isPowerShell)
195    if (hints.length > 0) {
196      $.ui.toast(FIXED[lang](hints.length))
197
198      return {
199        deny:
200          `Not run: this ${e.tool} line would fail on this Windows machine.\n- ` +
201          hints.join('\n- ') +
202          `\nRewrite it and run again. (If the line is actually correct, add \`${OPT_OUT}\` to it.)`,
203      }
204    }
205
206    const ran = await next(e)
207    if (ran.deny !== undefined) return ran
208    if (SHOWS_FILES.test(codeOf(command, isPowerShell))) return ran
209    const text = ran.text ?? ''
210    const after = AFTER.filter(r => r.tools.includes(e.tool) && r.test.test(text)).map(r => r.hint)
211    if (after.length === 0) return ran
212
213    return { ...ran, context: [...(ran.context ?? []), `win-shell-hints: ${after.join(' ')}`] }
214  })
215}
216