Skip to content

About

一个类似 Linux ssh 的远程终端客户端,用于连接并以纯命令行方式操控 Windows

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

wincli

License: MIT 🇨🇳 中文

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.

Features

  • 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 to ssh.
  • SSH-like interactive shell: command history (↑/↓), history search (Ctrl+R).

  • Remote path tab completion (absolute and relative-to-cwd):

    • Type C:\Us<TAB> → offers Users\; press Tab to expand the line to cd C:\Users\.
    • After cd Temp, dir admin<TAB> offers admin\ (bare-token completion, relative to current remote cwd).
    • 30-second remote cache; cleared immediately on every cd.
  • 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 --posix wincli 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 / rmdir Get-ChildItem / Get-Content / Copy-Item / Move-Item / Remove-Item / New-Item
    pwd / cd Get-Location / Set-Location
    echo / env Write-Output / Get-ChildItem Env:
    whoami / hostname / type pass-through (system binaries)

    Commands translated by wincli (enabled with --posix):

    POSIX command Translated to
    cp [-r] SRC DST Copy-Item -LiteralPath SRC -Destination DST [-Recurse]
    mv SRC DST Move-Item -LiteralPath SRC -Destination DST
    rm [-r] [-f] PATH... Remove-Item -LiteralPath PATH [-Recurse] [-Force]
    touch PATH New-Item -ItemType File (or update LastWriteTime)
    grep [-i] [-r] [-n] PATTERN [PATH...] Select-String -Pattern
    sed -i 's/FIND/REPLACE/' FILE (Get-Content) -replace + Set-Content
    tail [-f] [-n N] FILE Get-Content -Wait / -Tail N
    head [-n N] FILE Get-Content -Head N
    ip [addr / route / link] Get-NetIPAddress / Get-NetRoute / Get-NetAdapter
    netstat [-tulpn] / ss [-tulpn] Get-NetTCPConnection -State Listen
    firewalld [--list-all] / firewall-cmd Get-NetFirewallRule | Where-Object Enabled
    firewalld --state / --get-default-zone Get-NetFirewallProfile
    firewalld --add-port=PORT/PROTO New-NetFirewallRule -Direction Inbound -LocalPort PORT -Protocol PROTO -Action Allow
    firewalld --add-service=NAME New-NetFirewallRule -Direction Inbound -Service NAME -Action Allow
    firewalld --remove-port=PORT/PROTO Remove-NetFirewallRule (matched by wincli: display-name prefix)
    firewalld --reload netsh advfirewall refresh (Windows applies rules immediately; this is a no-op refresh)
    df [-h] Get-Volume (with GB column)
    du [-sh] PATH aggregate Get-ChildItem bytes
    free [-h] Get-CimInstance Win32_OperatingSystem (TotalGB / UsedGB / FreeGB)
    uname [-a / -r] Get-CimInstance Win32_OperatingSystem
    ps [aux] Get-Process
    kill PID / pgrep NAME / pkill NAME Stop-Process
    find PATH -name PATTERN Get-ChildItem -Recurse -Filter
    wc -l FILE (Get-Content).Count
    export FOO=bar / unset FOO $env:FOO = 'bar' / Remove-Item Env:FOO
    which CMD Get-Command CMD
    vi / vim FILE Write-Error (suggesting "download-edit-upload")
    clear Clear-Host
    history client-side readline history (wincli built-in, doesn't hit remote)
    date / env / true / false Get-Date / Get-ChildItem Env: / exit 0 / exit 1

    User & group management (PowerShell 5.1 compatible; matches Linux semantics as closely as possible):

    POSIX command Translated to Notes
    id [USER] / id -un / id -G SID + LocalGroups listing Linux-style uid=... gid=... groups=... output
    passwd [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; use wincli auth set + keyring for production)
    useradd [-m] [-G GRPS] [-c DESC] USER New-LocalUser -NoPassword + optional home + Add-LocalGroupMember No password by default (matches Linux useradd locked-account default); -p is ignored with a warning
    userdel [-r] USER Remove-LocalUser + optional home removal (-r)
    usermod -aG GRP USER Add-LocalGroupMember (append semantics)
    usermod -G GRP USER Add-LocalGroupMember + warning Windows has no atomic "replace groups" operation, -aG is recommended
    usermod -l NEWNAME USER Set-LocalUser -NewName NEWNAME
    usermod -c "DESC" USER Set-LocalUser -Description "DESC"
    gpasswd -a/-d USER GRP Add-/Remove-LocalGroupMember
    groupadd NAME / groupdel NAME New-/Remove-LocalGroup

    Usage 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

    PowerShell 5.1 compatibility (Windows 10 / Server 2016 / 2019 default)

    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-line Write-Output '...'
    • Single quotes inside single-quoted strings are escaped as '' (two single quotes)

    Known limitations:

    • id USER shows empty groups for an account just created with useradd (no password): PS 5.1's Get-LocalGroupMember -Member hangs (a known Win10 WMI bug), and the WinRM channel doesn't support Start-Job-based background jobs. Logging in once and re-running id returns the full group list.
    • whoami /groups is not installed on some Server 2016 images; the script silently falls back to Get-LocalGroup.
  • One-shot execution mode: wincli user@host "ipconfig" (or wincli 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+).

Quick start

# 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 -

Command-line syntax

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.

⚠️ If the target is followed by an option (-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 reference

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 command vs. interactive

# 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@host

Special syntax for put / get

put 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.exe

Credentials and authentication

Order of precedence:

  1. -P 'password' — plaintext on the command line (prints a security warning on every connection; test only — leaves a trail in shell history)
  2. WINCLI_PASSWORD environment variable — also insecure, but doesn't enter history
  3. Windows Credential Manager — wincli auth set user@host writes it; strongly recommended
  4. Interactive getpass prompt
# 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.2

Interactive REPL

Inside 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

REPL built-in commands

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.

Path tab completion

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 Tab presses don't hammer WinRM. Cleared immediately on every cd.
  • Completion results come from real Get-ChildItem -LiteralPath listings (hidden files included via -Force).
  • PowerShell's CLIXML progress, verbose, and information streams are filtered out.

One-time configuration on the remote host

One-shot script (recommended)

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.ps1

setup-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=1 so local accounts (Administrator etc.) can sign in over WinRM.

The script is idempotent — re-running it is safe.

Manual step-by-step (only if you don't want to run the script)

WinRM (recommended)

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 DWord

OpenSSH

Add-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 22

Configuration

wincli 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.X explicitly set in a later file overrides the earlier file's value.
  • hosts.<name>.X: a later file with the same host name overrides X; new host names are added.
  • Missing / empty values do not override. For example, if ~/.wincli/config.yaml has vm1.user: "" (unset), it won't clear vm1.user: alice in project_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-lyl

Use defaults: to simplify bulk connections

Set 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.dev

If 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

Full config example

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_ed25519

Usage: wincli prod connects to the pre-defined host.

Multi-session manager (winmgr)

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 wincli gives you winmgr too — no GUI libraries required.

Launching

# Option 1: standalone command
winmgr

# Option 2: spawn from wincli
wincli gui

Main window layout:

┌──────────────┬──────────────────────────────────┐
│  Hosts       │  [Welcome] [vm1] [vm2] [SFTP]   │
│              │                                  │
│  vm1         │   $ ls                           │
│  vm2         │   ...                            │
│  dev-vm      │   █                              │
│              │  ┌─────────────────────────────┐ │
│              │  │ > ls                        │ │
│              │  └─────────────────────────────┘ │
│              │  connected · transport: winrm    │
└──────────────┴──────────────────────────────────┘

Keyboard shortcuts

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

Features

  • Multi-tab SSH / WinRM terminal — each tab has its own cwd and command history.
  • Host sidebar — reads every host from ~/.wincli/config.yaml (or the config.yaml next to winmgr.exe); double-click to connect, right-click to copy user@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@host without leaving the GUI.

Building standalone .exe files

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 -SkipBump

Steps performed:

  1. Check / create .venv (Python 3.12)
  2. Install project dependencies + PyInstaller
  3. Clean up old build/ and dist/
  4. Stamping _version.py (sentinel + literal __version__)
  5. 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.

Known caveats

  • PowerShell invocation: when calling from a PowerShell host and the command string contains $variable or $_, wrap the whole string in single quotes (PowerShell single quotes don't expand variables):

    wincli user@host '$x = "hello"; Get-Date; Write-Output $x'
  • -P password warning. Supplying a password with -P produces:

    ⚠ 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: because pywinrm spawns a fresh process for every run_ps, the remote cwd doesn't persist. wincli tracks cwd on the client side and runs Set-Location -LiteralPath <resolved> in the same run_ps call to verify and apply the change, so cd .. 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.

  • --posix user-management caveats:

    • id shows groups= empty for an account just created with useradd (no password). This is a PS 5.1 bug — Get-LocalGroupMember -Member hangs via WMI, and the WinRM channel doesn't support Start-Job background jobs. Have the user log in once, then re-run id to get the full list.
    • passwd USER SECRET forwards the plaintext password via ConvertTo-SecureString, matching Linux passwd; it also lands in shell history, so use wincli auth set + keyring in production.

Credits

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.

About

一个类似 Linux ssh 的远程终端客户端,用于连接并以纯命令行方式操控 Windows

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages