An SSH-like terminal client for connecting to and operating Windows hosts from the
command line (no RDP / Desktop required). Built on top of the existing
community projects pywinrm and
paramiko, wincli unifies them behind a single,
configurable, and more pleasant CLI.
-
Two transport backends, auto-detected or selected manually:
- WinRM (default): built into Windows, no extra service required; supports NTLM, Basic, and CredSSP.
- SSH: connects to Windows' built-in OpenSSH Server (
sshd), identical experience tossh.
-
SSH-like interactive shell: command history (
↑/↓), history search (Ctrl+R). -
Remote path tab completion (absolute and relative-to-cwd):
- Type
C:\Us<TAB>→ offersUsers\; pressTabto expand the line tocd C:\Users\. - After
cd Temp,dir admin<TAB>offersadmin\(bare-token completion, relative to current remote cwd). - 30-second remote cache; cleared immediately on every
cd.
- Type
-
POSIX command style (
--posix): drive Windows with familiar Linux-style commands. PowerShell already aliases most common commands (ls/cp/mv/rm/cat/mkdir/rmdir/pwd/cd/echo/env/whoami/hostname/type, etc.); with--posixwincli translates the remaining commands that PowerShell doesn't recognise or whose arguments differ — so Linux users operate Windows seamlessly.PowerShell built-in aliases work out of the box (no translation):
Command PowerShell equivalent ls/cat/cp/mv/rm/mkdir/rmdirGet-ChildItem/Get-Content/Copy-Item/Move-Item/Remove-Item/New-Itempwd/cdGet-Location/Set-Locationecho/envWrite-Output/Get-ChildItem Env:whoami/hostname/typepass-through (system binaries) Commands translated by wincli (enabled with
--posix):POSIX command Translated to cp [-r] SRC DSTCopy-Item -LiteralPath SRC -Destination DST [-Recurse]mv SRC DSTMove-Item -LiteralPath SRC -Destination DSTrm [-r] [-f] PATH...Remove-Item -LiteralPath PATH [-Recurse] [-Force]touch PATHNew-Item -ItemType File(or update LastWriteTime)grep [-i] [-r] [-n] PATTERN [PATH...]Select-String -Patternsed -i 's/FIND/REPLACE/' FILE(Get-Content) -replace+Set-Contenttail [-f] [-n N] FILEGet-Content -Wait/-Tail Nhead [-n N] FILEGet-Content -Head Nip [addr / route / link]Get-NetIPAddress/Get-NetRoute/Get-NetAdapternetstat [-tulpn]/ss [-tulpn]Get-NetTCPConnection -State Listenfirewalld [--list-all]/firewall-cmdGet-NetFirewallRule | Where-Object Enabledfirewalld --state/--get-default-zoneGet-NetFirewallProfilefirewalld --add-port=PORT/PROTONew-NetFirewallRule -Direction Inbound -LocalPort PORT -Protocol PROTO -Action Allowfirewalld --add-service=NAMENew-NetFirewallRule -Direction Inbound -Service NAME -Action Allowfirewalld --remove-port=PORT/PROTORemove-NetFirewallRule(matched bywincli:display-name prefix)firewalld --reloadnetsh advfirewall refresh(Windows applies rules immediately; this is a no-op refresh)df [-h]Get-Volume(with GB column)du [-sh] PATHaggregate Get-ChildItembytesfree [-h]Get-CimInstance Win32_OperatingSystem(TotalGB / UsedGB / FreeGB)uname [-a / -r]Get-CimInstance Win32_OperatingSystemps [aux]Get-Processkill PID/pgrep NAME/pkill NAMEStop-Processfind PATH -name PATTERNGet-ChildItem -Recurse -Filterwc -l FILE(Get-Content).Countexport FOO=bar/unset FOO$env:FOO = 'bar'/Remove-Item Env:FOOwhich CMDGet-Command CMDvi / vim FILEWrite-Error(suggesting "download-edit-upload")clearClear-Hosthistoryclient-side readline history (wincli built-in, doesn't hit remote) date/env/true/falseGet-Date/Get-ChildItem Env:/exit 0/exit 1User & group management (PowerShell 5.1 compatible; matches Linux semantics as closely as possible):
POSIX command Translated to Notes id [USER]/id -un/id -GSID + LocalGroups listing Linux-style uid=... gid=... groups=...outputpasswd [USER] [NEWPASSWORD]Set-LocalUser -Password (ConvertTo-SecureString ... -AsPlainText -Force)Must supply the new password: the WinRM channel has no TTY; the plaintext password lands in shell history (matching Linux passwd; usewincli auth set+ keyring for production)useradd [-m] [-G GRPS] [-c DESC] USERNew-LocalUser -NoPassword+ optional home +Add-LocalGroupMemberNo password by default (matches Linux useraddlocked-account default);-pis ignored with a warninguserdel [-r] USERRemove-LocalUser+ optional home removal (-r)usermod -aG GRP USERAdd-LocalGroupMember(append semantics)usermod -G GRP USERAdd-LocalGroupMember+ warningWindows has no atomic "replace groups" operation, -aGis recommendedusermod -l NEWNAME USERSet-LocalUser -NewName NEWNAMEusermod -c "DESC" USERSet-LocalUser -Description "DESC"gpasswd -a/-d USER GRPAdd-/Remove-LocalGroupMembergroupadd NAME/groupdel NAMENew-/Remove-LocalGroupUsage examples:
# One-shot commands wincli --posix Administrator@192.168.1.10 "ps aux" wincli --posix Administrator@192.168.1.10 "grep -i error C:\Windows\*.log" wincli --posix Administrator@192.168.1.10 "ip addr" wincli --posix Administrator@192.168.1.10 "du -sh C:\Users" wincli --posix Administrator@192.168.1.10 "id" wincli --posix Administrator@192.168.1.10 "id alice" wincli --posix Administrator@192.168.1.10 "useradd -m -G Remote alice" wincli --posix Administrator@192.168.1.10 "passwd alice 'Str0ngP@ss!'" wincli --posix Administrator@192.168.1.10 "userdel -r alice" # Also works inside the interactive shell wincli --posix --transport ssh lyl@host lyl@host powershell $ touch test.txt lyl@host powershell $ cp test.txt copy.txt lyl@host powershell $ rm -f test.txt lyl@host powershell $ history lyl@host powershell $ env
Enable permanently in the config file:
# ~/.wincli/config.yaml defaults: posix: true
All of the above translations have been specifically tested on PowerShell 5.1 and use no 7+ features. Key compatibility points:
- No use of the
??null-coalescing operator (PS 7+ only) - No
(New-Object X).Method()glued-dot calls (PS 5.1 parser doesn't accept them) — always assign to a variable first, then call - Multi-line here-strings (
@'...'@) get tokenised to a single line over the WinRM channel, so error messages use single-lineWrite-Output '...' - Single quotes inside single-quoted strings are escaped as
''(two single quotes)
Known limitations:
id USERshows empty groups for an account just created withuseradd(no password): PS 5.1'sGet-LocalGroupMember -Memberhangs (a known Win10 WMI bug), and the WinRM channel doesn't supportStart-Job-based background jobs. Logging in once and re-runningidreturns the full group list.whoami /groupsis not installed on some Server 2016 images; the script silently falls back toGet-LocalGroup.
- No use of the
-
One-shot execution mode:
wincli user@host "ipconfig"(orwincli exec user@host "..."). -
File upload / download (SFTP over SSH / Base64 over WinRM):
wincli put/get. -
Multi-host YAML config at
~/.wincli/config.yaml, similar in spirit to~/.ssh/config. -
Credential storage: prefers the OS keyring (Windows Credential Manager) to keep secrets out of plaintext.
-
Coloured / rich-text output (via
rich): errors and status are immediately readable. -
Pure Python, cross-platform controller; remote side is standard Windows (Win10 1809+ / Server 2016+).
# 1. Create a virtualenv and install
python -m venv .venv
.\.venv\Scripts\pip install -e .
# 2. Interactive login (ssh-like)
.\.venv\Scripts\wincli admin@192.168.1.10
# 3. Run a single command
.\.venv\Scripts\wincli admin@192.168.1.10 "ipconfig /all"
# 4. Choose a transport
.\.venv\Scripts\wincli --transport ssh admin@192.168.1.10
.\.venv\Scripts\wincli --transport winrm --port 5986 --ssl admin@192.168.1.10
# 5. File transfer (subcommand goes first)
.\.venv\Scripts\wincli put admin@192.168.1.10 .\local.exe C:\Windows\Temp\local.exe
.\.venv\Scripts\wincli get admin@192.168.1.10 C:\Windows\Temp\log.txt .\log.txt
# 6. Pipe a multi-line script via stdin (use `-` for the command string)
# Tip: wrapping a multi-line script in single quotes on Windows breaks
# because CommandLineToArgvW mangles the inner double quotes. Use a file:
@'
"line1" | Out-File -Encoding utf8 C:\Temp\multi.log
"line2" | Out-File -Append -Encoding utf8 C:\Temp\multi.log
'@ | Out-File -Encoding utf8 script.ps1
Get-Content .\script.ps1 -Raw | .\.venv\Scripts\wincli admin@192.168.1.10 -The goal of wincli is "as easy to type as ssh", but because options must also reach subcommands, three variants are supported:
| Style | Example |
|---|---|
| ssh style (recommended) | wincli user@host -u admin -P 'xxx' -p 5986 --ssl |
Explicit connect |
wincli connect user@host -u admin -P 'xxx' --transport winrm |
| Options first | wincli --transport winrm -p 5986 --ssl user@host |
Options, target, and flags can be written in any order; they're spliced
into the unified connect subcommand for parsing.
-u / -P / -p etc.)
those are treated as wincli options. If the target is followed by a
normal command (e.g. cd Temp, ls), they become the ssh-style
one-shot command.
| Flag | Purpose |
|---|---|
--transport winrm|ssh |
Transport backend (default: winrm) |
-p, --port |
Remote port (default: winrm=5985, ssh=22) |
--ssl / --no-ssl |
WinRM over HTTPS (default: false) |
-u, --user |
Remote user |
-P, --password |
Plaintext password (test only, prints a security warning) |
--key <path> |
SSH private key path |
--shell powershell|cmd |
Default remote shell |
-c, --command "..." |
One-shot command, then exit |
--config <path> |
Use a single yaml, skip auto-merge |
--posix |
Enable POSIX → PowerShell translation |
--host <host> |
Override the host resolved from the target |
# One-shot: run and exit
wincli user@host "Get-Service | Where-Object {$_.Status -eq 'Running'}"
# One-shot (subcommand form)
wincli exec user@host "Get-Process | Select-Object Name,Id -First 5"
# Interactive (no -c → enter REPL)
wincli user@hostput and get are wincli subcommands and must come before the target
(standard Click style):
# ✓ Correct
wincli put user@host .\local.exe C:\Temp\remote.exe
wincli get user@host C:\Temp\remote.exe .\downloaded.exe
# ✗ Don't do this (ssh-style splicing would treat ``put`` as a literal one-shot command)
wincli user@host put .\local.exe C:\Temp\remote.exeOrder of precedence:
-P 'password'— plaintext on the command line (prints a security warning on every connection; test only — leaves a trail in shell history)WINCLI_PASSWORDenvironment variable — also insecure, but doesn't enter history- Windows Credential Manager —
wincli auth set user@hostwrites it; strongly recommended - Interactive
getpassprompt
# Store a password in the OS keyring (one-time)
wincli auth set administrator@192.168.1.2
# Prompt: password for [email protected]: ********
# Future `wincli [email protected]` reads it automatically — no -P
# Remove it
wincli auth delete administrator@192.168.1.2Inside wincli user@host, the common key bindings are:
| Key | Action |
|---|---|
↑ / ↓ |
Command history |
Ctrl+R |
Substring history search (press again for next match) |
Tab |
Path completion (see below) |
Ctrl+C |
Cancel the current input line |
Ctrl+D |
Disconnect and exit |
| Command | Description |
|---|---|
help |
Show built-in help |
info |
Show the current connection (host / transport / shell) |
cd <path> / chdir |
Change remote directory; supports .., ../foo, ~/foo; bare cd = $HOME |
shell powershell|cmd |
Switch the remote shell |
put <local> <remote> |
Upload a file |
get <remote> <local> |
Download a file |
history [N] |
Show the most recent N client commands (default 50) |
clear |
Clear the screen |
exit / quit |
Exit (same as Ctrl+D) |
Anything else is sent to the remote PowerShell / cmd as a command.
Completion is automatic:
| Input | Behaviour |
|---|---|
C:\Us<TAB> |
Offers Users\ (absolute path) |
cd C:\<TAB> |
Offers Users\, Windows\, Program Files\, ... |
cd Temp then dir admin<TAB> |
Offers admin\ (bare-token, relative to cwd) |
cat readme<TAB> |
Offers readme.md (no \ suffix for files) |
./foo<TAB> / ..\foo<TAB> / ~/foo<TAB> |
relative / parent / home directory |
\\server\share\foo<TAB> |
UNC path |
hel<TAB> |
Offers help (provided by WordCompleter) |
Behaviour details:
- Bare-token completion only triggers when the previous token is a
"path-taking verb" (
dir,cat,cd,cp,rm,del,Get-ChildItem,Get-Content,gci,ls, …), so it doesn't clash with command completion. - 30-second remote cache — repeated
Tabpresses don't hammer WinRM. Cleared immediately on everycd. - Completion results come from real
Get-ChildItem -LiteralPathlistings (hidden files included via-Force). - PowerShell's CLIXML progress, verbose, and information streams are filtered out.
In an elevated PowerShell on the remote host:
# Install straight from GitHub (replace <repo>)
irm https://raw.githubusercontent.com/lylarcher/windows-connect-cli/master/setup-server.ps1 | iex
# Or run the local copy from the repo
powershell -ExecutionPolicy Bypass -File .\setup-server.ps1setup-server.ps1 will automatically:
- Switch the current network profile to Private (a WinRM prerequisite).
- Enable WinRM (5985/5986), open the firewall, enable Basic auth +
AllowUnencrypted + TrustedHosts=
*. - Install and start OpenSSH Server (port 22), open the firewall, and change sshd's default shell to PowerShell.
- Set
LocalAccountTokenFilterPolicy=1so local accounts (Administrator etc.) can sign in over WinRM.
The script is idempotent — re-running it is safe.
In an elevated PowerShell on the remote host:
# WinRM is refused on public networks by default; switch to private first
Get-NetConnectionProfile | Set-NetConnectionProfile -NetworkCategory Private
Enable-PSRemoting -Force
Set-NetFirewallRule -Name "WINRM-HTTP-In-TCP" -Enabled True -Profile Any
Set-NetFirewallRule -Name "WINRM-HTTPS-In-TCP" -Enabled True -Profile Any
winrm set winrm/config/service/auth @{Basic="true"}
winrm set winrm/config/service @{AllowUnencrypted="true"}
winrm set winrm/config/client @{TrustedHosts="*"}
# Let local accounts log on remotely
Set-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" `
-Name "LocalAccountTokenFilterPolicy" -Value 1 -Type DWordAdd-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
Start-Service sshd
Set-Service -Name sshd -StartupType 'Automatic'
New-NetFirewallRule -Name sshd -DisplayName 'OpenSSH Server' -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22wincli loads every existing config file below and merges them into a single effective config (union semantics; later files override earlier ones):
| # | Location | Purpose |
|---|---|---|
| 1 | %USERPROFILE%\.wincli\config.yaml (Windows) / ~/.wincli/config.yaml (POSIX) |
User-global defaults |
| 2 | <project_root>/config.yaml (walks up from src/wincli looking for pyproject.toml / setup.py / setup.cfg) |
Dev-mode project config |
| 3 | <exe_dir>/config.yaml (PyInstaller one-file build) |
Single-file deployment config |
| 4 | $WINCLI_CONFIG env var, if set |
CI / ad-hoc override |
CLI flags have the highest priority and override the same-named option in any config file.
Merge rules:
- Same field: a
defaults.Xexplicitly set in a later file overrides the earlier file's value. hosts.<name>.X: a later file with the same host name overridesX; new host names are added.- Missing / empty values do not override. For example, if
~/.wincli/config.yamlhasvm1.user: ""(unset), it won't clearvm1.user: aliceinproject_root/config.yaml.
Debug which files loaded and at what priority:
.\.venv\Scripts\wincli config-path
# C:\Users\lyl\.wincli\config.yaml exists (merge priority #1)
# C:\TraeProjects\tools\windows-connect-cli\config.yaml missing
# → C:\TraeProjects\tools\windows-connect-cli\.venv\Scripts\config.yaml exists (merge priority #2)You can also point at a single file with --config <path> and skip auto-merge:
.\.venv\Scripts\wincli --config D:\my-config.yaml vm-win10-lylSet defaults.user / defaults.port / defaults.ssl once and bare
IPs / hostnames work without any hosts: entries:
# ~/.wincli/config.yaml
defaults:
transport: winrm
port: 5985
ssl: false
user: Administrator
shell: powershell
timeout: 30# Any IP / hostname uses the defaults above
wincli 192.168.1.10
wincli 10.0.0.5
wincli winserver.corp.local
# Per-target overrides via -u or hosts:
wincli 192.168.1.10 -u other-user
wincli dev # uses hosts.devIf defaults.user is unset and the command didn't pass -u, wincli
errors with a clear fix path:
no username resolved for target '192.168.1.99'; fix one of:
1. pass `-u <user>` on the command line
2. write `user: <user>` under `defaults:` in `C:\Users\lyl\.wincli\config.yaml`
3. write `user: <user>` under `hosts.<alias>:` for a named entry
defaults:
transport: winrm # winrm | ssh
port: 5985
ssl: false
user: Administrator
shell: powershell # powershell | cmd
posix: true
timeout: 30
hosts:
dev:
host: 192.168.1.10
user: devops
transport: winrm
prod:
host: prod-win.example.com
user: Administrator
transport: ssh
port: 22
key: ~/.ssh/id_ed25519Usage: wincli prod connects to the pre-defined host.
winmgr is a Tkinter desktop GUI (Python standard library,
zero extra dependencies) — a MobaXterm / WindTerm style multi-tab
terminal, host tree, two-pane SFTP browser, and credentials manager.
It reuses wincli's protocol stack (WinRM / SSH / POSIX translation / file transfer) — one install, two entry points.
Zero-dependency: Tkinter is in the Python standard library, so
pip install wincligives youwinmgrtoo — no GUI libraries required.
# Option 1: standalone command
winmgr
# Option 2: spawn from wincli
wincli guiMain window layout:
┌──────────────┬──────────────────────────────────┐
│ Hosts │ [Welcome] [vm1] [vm2] [SFTP] │
│ │ │
│ vm1 │ $ ls │
│ vm2 │ ... │
│ dev-vm │ █ │
│ │ ┌─────────────────────────────┐ │
│ │ │ > ls │ │
│ │ └─────────────────────────────┘ │
│ │ connected · transport: winrm │
└──────────────┴──────────────────────────────────┘
| Shortcut | Action |
|---|---|
Ctrl+N |
New session (pick from the host list) |
Ctrl+W |
Close the current tab |
Ctrl+T |
Open the SFTP browser (current session) |
Ctrl+Q |
Quit winmgr |
↑ / ↓ (in input) |
Scroll command history |
- Multi-tab SSH / WinRM terminal — each tab has its own cwd and command history.
- Host sidebar — reads every host from
~/.wincli/config.yaml(or theconfig.yamlnext towinmgr.exe); double-click to connect, right-click to copyuser@host. - Two-pane SFTP browser — local on the left, remote on the right, toolbar buttons for upload / download / new / delete / refresh.
- Credentials management — add / update / remove OS-keyring entries
for
user@hostwithout leaving the GUI.
build.ps1 ships with the repo; run it from the project root for a
one-shot build:
# Default: build both wincli.exe (CLI) and winmgr.exe (GUI)
.\build.ps1
# GUI only
.\build.ps1 -SkipCli
# CLI only
.\build.ps1 -SkipGui
# Custom version number (default 0.1.0)
.\build.ps1 -Version 1.2.3
# Skip the version bump in src\wincli\_version.py (use existing sentinel)
.\build.ps1 -SkipBumpSteps performed:
- Check / create
.venv(Python 3.12) - Install project dependencies + PyInstaller
- Clean up old
build/anddist/ - Stamping
_version.py(sentinel + literal__version__) - Run PyInstaller once / twice (CLI + GUI)
Artifacts:
| File | Size | Purpose |
|---|---|---|
dist\wincli.exe |
~21 MB | Command-line, zero GUI dependencies |
dist\winmgr.exe |
~25 MB | Tkinter GUI multi-session manager |
Both are self-contained: they bundle a Python interpreter plus every dependency, so you can copy them to any Windows machine and run with no Python install.
-
PowerShell invocation: when calling from a PowerShell host and the command string contains
$variableor$_, wrap the whole string in single quotes (PowerShell single quotes don't expand variables):wincli user@host '$x = "hello"; Get-Date; Write-Output $x'
-
-Ppassword warning. Supplying a password with-Pproduces:⚠ WARNING: password supplied on the command line is visible in your shell history and process list. Avoid this in shared environments — prefer `-u <user>` with an interactive prompt, or store the password in your OS keyring via `wincli auth set`.For debug / automation only. Use the keyring in production.
-
CLIXML output: the first time PowerShell runs a command you may see "module loading" chatter (CLIXML). wincli already filters
$ProgressPreference='SilentlyContinue'and strips<Objs>blocks on the server side, so under normal conditions you won't see any XML. -
cd ..behaviour: becausepywinrmspawns a fresh process for everyrun_ps, the remote cwd doesn't persist. wincli tracks cwd on the client side and runsSet-Location -LiteralPath <resolved>in the samerun_pscall to verify and apply the change, socd ..always moves exactly one level. -
PowerShell version compatibility: if the remote runs PowerShell 5.1 (default on Windows 10 / Server 2016 / 2019), the POSIX translations in wincli avoid every PS 7+ construct (
??, glued-dot(expr).Method()calls, multi-line here-strings, etc.), so you're safe. If the remote has been upgraded to PS 7+ (pwsh), wincli is fully compatible there too. -
--posixuser-management caveats:idshowsgroups=empty for an account just created withuseradd(no password). This is a PS 5.1 bug —Get-LocalGroupMember -Memberhangs via WMI, and the WinRM channel doesn't supportStart-Jobbackground jobs. Have the user log in once, then re-runidto get the full list.passwd USER SECRETforwards the plaintext password viaConvertTo-SecureString, matching Linuxpasswd; it also lands in shell history, so usewincli auth set+ keyring in production.
Built on the shoulders of these open-source projects:
- pywinrm — WinRM protocol client for Python
- paramiko — SSHv2 protocol implementation in Python
- prompt_toolkit — interactive REPL
- rich — rich-text terminal output
- click — Python CLI framework
- Tkinter — standard
library GUI toolkit used by
winmgr
No reinventing the wheel — just higher-level integration and ergonomics.