Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ jobs:
check:
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
Expand All @@ -24,7 +24,11 @@ jobs:
- run: bun run check
- run: bun test
- run: bun run test:package
- run: zsh -n shell/cmdhelp.zsh
- if: runner.os != 'Windows'
run: zsh -n shell/cmdhelp.zsh
- name: Check PowerShell integration
shell: pwsh
run: ./test/widget.ps1
- if: runner.os == 'macOS'
run: script -q /dev/null zsh test/widget.zsh
- if: runner.os == 'Linux'
Expand Down
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# cmdhelp

Command suggestions and explanations, one shortcut away in zsh.
Command suggestions and explanations, one shortcut away in zsh or PowerShell.

Describe what you want to do, inspect the suggestion, and insert it into your shell prompt. Or paste a command and ask what it means. Responses stream into a small terminal panel, with follow-up questions and cancellation.

Expand All @@ -12,7 +12,7 @@ cmdhelp uses Pi's model API directly. It does not launch a coding agent or execu

## Install

Requires [Bun](https://bun.sh/docs/installation) 1.4+ and zsh on macOS or Linux.
Requires [Bun](https://bun.sh/docs/installation) 1.4+ and either zsh on macOS/Linux or PowerShell 7.4+ with PSReadLine on Windows/macOS/Linux.

```sh
bun add -g @abzy128/cmdhelp
Expand All @@ -26,6 +26,14 @@ eval "$(cmdhelp init zsh)"

Run the same line in your current shell to enable the shortcuts immediately. Configure a provider and model before making your first request.

For PowerShell, add this to `$PROFILE` and run it in your current session:

```powershell
cmdhelp init powershell | Out-String | Invoke-Expression
```

The init command loads the installed integration script. Suggestions are always inserted as literal text. See the [Windows setup instructions](docs/installation.md#enable-powershell-shortcuts) for profile creation and key bindings.

**[Installation guide](docs/installation.md)** · **[Configuration guide](docs/configuration.md)**

You can also install with `npm install -g @abzy128/cmdhelp`; Bun must still be installed and available on `PATH`.
Expand All @@ -52,6 +60,8 @@ cmdhelp doctor

Quote command arguments literally to prevent your shell from expanding `$()`, backticks, pipes, or redirections. Pipe command text on stdin when quoting is inconvenient.

PowerShell uses `Get-Content -Raw command.txt | cmdhelp explain` instead of `< command.txt`. The shell integrations select the correct syntax for model requests. Without integration, the CLI defaults to PowerShell on Windows and zsh elsewhere; use `--shell powershell` or `--shell zsh` to override it.

## Configuration

The default file is `~/.config/cmdhelp/config.json`, or `$XDG_CONFIG_HOME/cmdhelp/config.json`.
Expand Down
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ cmdhelp automatically refreshes supported OAuth credentials and saves refreshed

Create `~/.config/cmdhelp/config.json`, or `$XDG_CONFIG_HOME/cmdhelp/config.json` when `XDG_CONFIG_HOME` is set. The file is optional if Pi supplies a default provider and model.

On Windows, the default is `$HOME\.config\cmdhelp\config.json`, and Pi's default profile is `$HOME\.pi\agent`. PowerShell environment variables use `$env:CMDHELP_MODEL = 'model-id'` syntax. In JSON paths, use forward slashes (`C:/Users/you/.pi/agent`) or escape backslashes (`C:\\Users\\you\\.pi\\agent`); `~/.pi/agent` also works.

```json
{
"reasoning": "low",
Expand Down
66 changes: 64 additions & 2 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Requirements

- macOS or Linux with zsh.
- macOS or Linux with zsh, or Windows/macOS/Linux with PowerShell 7.4+ and PSReadLine. Windows PowerShell 5.1 and PowerShell ISE are not supported.
- [Bun](https://bun.sh/docs/installation) 1.4 or newer, available on `PATH`.
- A configured model provider: either an existing Pi profile or a provider API key. See [configuration](configuration.md).

Expand All @@ -21,7 +21,7 @@ Make sure the directory printed by `bun pm bin -g` is on `PATH` (normally `~/.bu
npm install -g @abzy128/cmdhelp
```

For npm installations, put `$(npm prefix -g)/bin` on `PATH` as well as Bun's executable directory. Installing through npm does not install Bun; the CLI requires Bun 1.4+ at runtime.
For npm installations, put `$(npm prefix -g)/bin` on `PATH` on macOS/Linux, or the directory printed by `npm prefix -g` on Windows, as well as Bun's executable directory. Installing through npm does not install Bun; the CLI requires Bun 1.4+ at runtime.

## Install from a checkout

Expand Down Expand Up @@ -66,6 +66,52 @@ bindkey '^X^E' _cmdhelp_explain

To remove a default binding before replacing it, use `bindkey -r '^X^G'` or `bindkey -r '^X^E'`. Check a candidate sequence with `bindkey 'sequence'` before assigning it.

## Enable PowerShell shortcuts

Use PowerShell 7.4+ (`pwsh`) in a terminal such as Windows Terminal. Install Bun and cmdhelp using the commands above; Bun's default Windows executable directory is `$HOME\.bun\bin`.

Create your profile if it does not exist, then open it in your editor:

```powershell
New-Item -ItemType Directory -Force (Split-Path $PROFILE) | Out-Null
if (-not (Test-Path -LiteralPath $PROFILE)) {
New-Item -ItemType File -Path $PROFILE | Out-Null
}
notepad $PROFILE # On Windows; use your editor on macOS/Linux
```

Add this line after other prompt plugins, and run it once in your current session:

```powershell
cmdhelp init powershell | Out-String | Invoke-Expression
```

This evaluates only the local initialization statement printed by cmdhelp. It loads PSReadLine, defines a Bun-backed `cmdhelp` function, and binds Ctrl-X then Ctrl-G to suggest, and Ctrl-X then Ctrl-E to explain. Generated commands are passed to PSReadLine's literal buffer replacement API and require a separate Enter to run. Escape/Ctrl-C restores the original input and cursor. Temporary files use an owner-only Windows ACL (Unix mode 0700 on macOS/Linux) and are removed when the handler exits.

To customize shortcuts, add bindings after initialization:

```powershell
Set-PSReadLineKeyHandler -Chord 'Ctrl+x,Ctrl+g' -ScriptBlock { Invoke-CmdhelpWidget suggest }
Set-PSReadLineKeyHandler -Chord 'Ctrl+x,Ctrl+e' -ScriptBlock { Invoke-CmdhelpWidget explain }
```

Inspect bindings with `Get-PSReadLineKeyHandler -Chord 'Ctrl+x,Ctrl+g'`. Remove one with `Remove-PSReadLineKeyHandler -Chord 'Ctrl+x,Ctrl+g'`. The Ctrl-X chord prefix replaces PSReadLine's default Ctrl-X cut binding in Windows editing mode; see the [PSReadLine function reference](https://learn.microsoft.com/en-us/powershell/module/psreadline/about/about_psreadline_functions).

If PowerShell says scripts are disabled, inspect `Get-ExecutionPolicy -List`. On a personal machine where policy permits it, `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` enables local profiles; organization policy takes precedence. No administrator session is required to install or use cmdhelp.

PowerShell stdin example:

```powershell
Get-Content -Raw command.txt | cmdhelp explain
cmdhelp suggest 'list the ten largest files in this directory'
```

For a checkout, you can also initialize directly before creating a package-manager link:

```powershell
bun src/cli.ts init powershell | Out-String | Invoke-Expression
```

## Configure a model

Follow the [configuration guide](configuration.md), then run:
Expand Down Expand Up @@ -136,6 +182,22 @@ script -qec 'zsh test/widget.zsh' /dev/null

These tests use fixtures, make no model requests, and check cancellation, failure handling, literal insertion, and cursor restoration.

For PowerShell on any supported OS:

```powershell
bun run check
bun test
pwsh -NoProfile -File test/widget.ps1
```

The PowerShell handler tests use a PSReadLine buffer double and verify private temporary directories, Unicode/multiline insertion, failure and cancellation cleanup, and cursor restoration. Separate tests load real PSReadLine bindings and exercise installation from paths containing spaces and quotes. On macOS/Linux with `pwsh` and `python3`, `bun test` also drives real PowerShell and Bun through a pseudo-terminal. A successful run prints:

```text
PowerShell terminal checks passed: typing, Enter, Tab, Escape, Ctrl-C, both shortcuts, and buffer restoration.
```

CI runs on Windows, macOS, and Linux. For a native Windows interactive smoke check, open a configured terminal, type an unfinished command, move the cursor, launch each shortcut, cancel, and confirm the input and cursor are restored; then insert a suggestion and confirm it waits for Enter.

## Troubleshooting

- **`cmdhelp` or `bun` not found:** check `PATH`, Bun's executable directory, and your package manager's global executable directory. For checkout installs, run `bun link` again.
Expand Down
43 changes: 28 additions & 15 deletions scripts/check-package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,29 +7,42 @@ import { fileURLToPath } from 'node:url';
import { version } from '../package.json';

const root = fileURLToPath(new URL('../', import.meta.url));
const windows = process.platform === 'win32';
function npm(args: string[], cwd: string): string {
// Let PowerShell resolve npm's Windows shim, passing arguments as data.
return windows
? execFileSync('pwsh', ['-NoProfile', '-Command', '& npm @($env:CMDHELP_NPM_ARGS | ConvertFrom-Json); exit $LASTEXITCODE'], {
cwd, encoding: 'utf8', env: { ...process.env, CMDHELP_NPM_ARGS: JSON.stringify(args) },
})
: execFileSync('npm', args, { cwd, encoding: 'utf8' });
}
const dir = await mkdtemp(join(tmpdir(), "cmdhelp user's npm install-"));
try {
const packed = JSON.parse(execFileSync('npm', ['pack', '--json', '--pack-destination', dir], {
cwd: root, encoding: 'utf8',
}))[0];
const packed = JSON.parse(npm(['pack', '--json', '--pack-destination', dir], root))[0];
const files = packed.files.map((file: { path: string }) => file.path) as string[];
for (const path of ['package.json', 'bin/cmdhelp', 'src/cli.ts', 'src/shell.ts', 'shell/cmdhelp.zsh']) {
for (const path of ['package.json', 'bin/cmdhelp', 'src/cli.ts', 'src/shell.ts', 'shell/cmdhelp.zsh', 'shell/cmdhelp.ps1']) {
assert.ok(files.includes(path), `Missing package file: ${path}`);
}
assert.ok(files.every(path => /^(package\.json|README\.md|LICENSE|config\.example\.json|bin\/|src\/|shell\/|docs\/)/.test(path)), 'Unexpected package contents');
execFileSync('npm', ['install', '--global', '--prefix', join(dir, 'install'), '--ignore-scripts', '--no-audit', '--no-fund', join(dir, packed.filename)], {
cwd: dir, stdio: 'inherit',
});
const command = join(dir, 'install/bin/cmdhelp');
const run = (...args: string[]) => execFileSync(command, args, { cwd: dir, encoding: 'utf8' });
npm(['install', '--global', '--prefix', join(dir, 'install'), '--ignore-scripts', '--no-audit', '--no-fund', join(dir, packed.filename)], dir);
const command = join(dir, windows ? 'install/cmdhelp.ps1' : 'install/bin/cmdhelp');
const run = (...args: string[]) => windows
? execFileSync('pwsh', ['-NoProfile', '-File', command, ...args], { cwd: dir, encoding: 'utf8' })
: execFileSync(command, args, { cwd: dir, encoding: 'utf8' });
assert.equal(run('--version').trim(), version);
assert.match(run('--help'), /cmdhelp suggest/);
const installedRoot = join(dir, 'install/lib/node_modules/@abzy128/cmdhelp');
const shellRoot = execFileSync('zsh', ['-fc', 'eval "$1"; print -r -- "$_cmdhelp_root"', 'test', run('init', 'zsh')], {
cwd: dir, encoding: 'utf8',
}).trim();
assert.equal(shellRoot, await realpath(installedRoot));
console.log(`Verified ${packed.name}@${version}: tarball contents, global executable, and zsh initialization.`);
if (!windows) {
const installedRoot = join(dir, 'install/lib/node_modules/@abzy128/cmdhelp');
const shellRoot = execFileSync('zsh', ['-fc', 'eval "$1"; print -r -- "$_cmdhelp_root"', 'test', run('init', 'zsh')], {
cwd: dir, encoding: 'utf8',
}).trim();
assert.equal(shellRoot, await realpath(installedRoot));
}
if (Bun.which('pwsh')) {
const output = execFileSync('pwsh', ['-NoProfile', '-Command', `${run('init', 'powershell')}\ncmdhelp --version`], { cwd: dir, encoding: 'utf8' });
assert.equal(output.trim(), version);
}
console.log(`Verified ${packed.name}@${version}: tarball contents, global executable, and shell initialization.`);
} finally {
await rm(dir, { recursive: true, force: true });
}
83 changes: 83 additions & 0 deletions shell/cmdhelp.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
#requires -Version 7.4
# Dot-source after other prompt plugins. Model output is data, never shell code.
Import-Module PSReadLine -ErrorAction Stop

$cmdhelpCli = Join-Path (Split-Path $PSScriptRoot -Parent) 'src/cli.ts'
Set-Item -Path Function:global:cmdhelp -Value {
if ($MyInvocation.ExpectingInput) {
$input | & bun $cmdhelpCli --shell powershell @args
} else {
& bun $cmdhelpCli --shell powershell @args
}
}.GetNewClosure()

Set-Item -Path Function:global:Invoke-CmdhelpPanel -Value {
param([string]$Mode, [string]$InputFile, [string]$OutputFile)
# PSReadLine captures handler pipeline output. Start the panel with inherited
# console handles so Bun sees a terminal, and pass every argument literally.
$start = [Diagnostics.ProcessStartInfo]::new()
$start.FileName = @(Get-Command bun -CommandType Application -ErrorAction Stop)[0].Source
$start.UseShellExecute = $false
foreach ($argument in @($cmdhelpCli, '--shell', 'powershell', 'ui', '--mode', $Mode, '--buffer-file', $InputFile, '--output-file', $OutputFile)) {
$start.ArgumentList.Add($argument)
}
$process = [Diagnostics.Process]::new()
$process.StartInfo = $start
try {
[void]$process.Start()
$process.WaitForExit()
return $process.ExitCode
} finally { $process.Dispose() }
}.GetNewClosure()

function global:Invoke-CmdhelpWidget {
param([ValidateSet('suggest', 'explain')][string]$Mode)

$savedBuffer = ''
$savedCursor = 0
[Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$savedBuffer, [ref]$savedCursor)
$taskDir = Join-Path ([IO.Path]::GetTempPath()) ('cmdhelp.' + [Guid]::NewGuid().ToString('N'))
$created = $false
$nextBuffer = $savedBuffer
$nextCursor = $savedCursor
try {
# Apply privacy at creation, before writing any shell input.
if ($IsWindows) {
$security = [Security.AccessControl.DirectorySecurity]::new()
$security.SetAccessRuleProtection($true, $false)
$sid = [Security.Principal.WindowsIdentity]::GetCurrent().User
$security.SetOwner($sid)
$rule = [Security.AccessControl.FileSystemAccessRule]::new(
$sid, 'FullControl', 'ContainerInherit, ObjectInherit', 'None', 'Allow')
$security.AddAccessRule($rule)
[void][IO.FileSystemAclExtensions]::CreateDirectory($security, $taskDir)
} else {
[void][IO.Directory]::CreateDirectory($taskDir, [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite -bor [IO.UnixFileMode]::UserExecute)
}
$created = $true
$inputFile = Join-Path $taskDir 'input'
$outputFile = Join-Path $taskDir 'result'
[IO.File]::WriteAllText($inputFile, $savedBuffer, [Text.UTF8Encoding]::new($false))
[Console]::WriteLine()
$exitCode = Invoke-CmdhelpPanel $Mode $inputFile $outputFile
if ($exitCode -eq 0 -and $Mode -eq 'suggest' -and [IO.File]::Exists($outputFile)) {
$selected = [IO.File]::ReadAllText($outputFile, [Text.Encoding]::UTF8).Replace("`r`n", "`n")
if ($selected.Length -gt 0 -and $selected -notmatch '[\x00-\x08\x0b-\x1f\x7f-\x9f]') {
$nextBuffer = $selected
$nextCursor = $selected.Length
}
}
} finally {
try {
if ($created) { Remove-Item -LiteralPath $taskDir -Recurse -Force -ErrorAction Stop }
} finally {
# Replace edits the buffer literally, including multiline commands. Never AcceptLine.
[Microsoft.PowerShell.PSConsoleReadLine]::Replace(0, $savedBuffer.Length, $nextBuffer)
[Microsoft.PowerShell.PSConsoleReadLine]::SetCursorPosition($nextCursor)
[Microsoft.PowerShell.PSConsoleReadLine]::InvokePrompt()
}
}
}

Set-PSReadLineKeyHandler -Chord 'Ctrl+x,Ctrl+g' -BriefDescription CmdhelpSuggest -ScriptBlock { Invoke-CmdhelpWidget suggest }
Set-PSReadLineKeyHandler -Chord 'Ctrl+x,Ctrl+e' -BriefDescription CmdhelpExplain -ScriptBlock { Invoke-CmdhelpWidget explain }
2 changes: 1 addition & 1 deletion shell/cmdhelp.zsh
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Source after history plugins. All model output is data, never shell code.
_cmdhelp_root=${${(%):-%x}:A:h:h}
cmdhelp() { command "$_cmdhelp_root/bin/cmdhelp" "$@"; }
cmdhelp() { command "$_cmdhelp_root/bin/cmdhelp" --shell zsh "$@"; }
_cmdhelp_widget() {
local saved_buffer=$BUFFER saved_cursor=$CURSOR task_dir selected
task_dir=$(mktemp -d "${TMPDIR:-/tmp}/cmdhelp.XXXXXXXX") || return
Expand Down
16 changes: 9 additions & 7 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,29 @@ import { parseArgs } from 'node:util';
import { readFile, writeFile } from 'node:fs/promises';
import { clean, createClient, type Mode } from './core.ts';
import { loadConfig } from './config.ts';
import { shellInit } from './shell.ts';
import { resolveShell, shellInit } from './shell.ts';
import { version } from '../package.json';

async function main() {
const { values, positionals } = parseArgs({ allowPositionals: true, options: {
help: { type: 'boolean', short: 'h' }, mode: { type: 'string' },
version: { type: 'boolean', short: 'v' },
shell: { type: 'string' },
'buffer-file': { type: 'string' }, 'output-file': { type: 'string' },
} });
if (values.version) { console.log(version); return; }
if (values.help || !positionals.length) {
console.log('cmdhelp suggest "task"\ncmdhelp explain \'command\'\ncmdhelp explain < command.txt\ncmdhelp ui --mode suggest|explain\ncmdhelp init zsh\ncmdhelp doctor\ncmdhelp --version\n\nZsh: Ctrl-X Ctrl-G suggests; Ctrl-X Ctrl-E explains.'); return;
console.log('cmdhelp suggest "task" [--shell zsh|powershell]\ncmdhelp explain \'command\'\ncmdhelp explain < command.txt (zsh)\nGet-Content -Raw command.txt | cmdhelp explain (PowerShell)\ncmdhelp ui --mode suggest|explain\ncmdhelp init zsh|powershell\ncmdhelp doctor\ncmdhelp --version\n\nZsh / PowerShell: Ctrl-X Ctrl-G suggests; Ctrl-X Ctrl-E explains.'); return;
}
const action = positionals[0];
if (action === 'init') {
if (positionals.length !== 2) throw new Error('Usage: cmdhelp init zsh');
if (positionals.length !== 2) throw new Error('Usage: cmdhelp init zsh|powershell');
console.log(shellInit(positionals[1])); return;
}
const shell = resolveShell(values.shell);
if (action === 'doctor') {
const config = await loadConfig();
const client = await createClient();
const client = await createClient(shell);
console.log(`Config: ${config.configPath}\nProfile: ${config.profile}\nModel: ${client.label}\nReasoning: ${config.reasoning}\nTimeout: ${config.timeoutMs}ms\nCatalog loaded (no inference request made).`); return;
}
const mode = (action === 'ui' ? values.mode || 'suggest' : action) as Mode;
Expand All @@ -31,17 +33,17 @@ async function main() {
if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error('Interactive mode requires a terminal.');
const initial = values['buffer-file'] ? await readFile(values['buffer-file'], 'utf8') : '';
const { panel } = await import('./ui.ts');
const selected = await panel(mode, initial);
const selected = await panel(mode, initial, shell);
if (selected && values['output-file']) await writeFile(values['output-file'], selected, { mode: 0o600 });
else if (selected) console.log(selected);
// The panel has drained input and settled any request/credential write.
// Provider keep-alive sockets must not delay returning control to zsh.
// Provider keep-alive sockets must not delay returning control to the shell.
process.exit(0);
}
let prompt = positionals.slice(1).join(' ');
if (!prompt && !process.stdin.isTTY) for await (const chunk of process.stdin) prompt += chunk;
if (!prompt.trim()) throw new Error('Provide a prompt or pipe command text on stdin.');
const client = await createClient();
const client = await createClient(shell);
const abort = new AbortController();
const stop = () => abort.abort();
process.once('SIGINT', stop);
Expand Down
Loading
Loading