Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
1b67060
Strip bidi and zero-width characters wherever remote text is cleaned
fstubner Oct 7, 2026
3046239
Clean remote text in the TUI before it is stored and before /export m…
fstubner Oct 7, 2026
61ff60f
Keep the history database off unless NETSCLI_HISTORY=1 asks for it
fstubner Oct 7, 2026
7507e78
Make exit codes follow outcomes
fstubner Oct 7, 2026
ea9bbf7
Stop installing a systemd unit that could never work
fstubner Oct 7, 2026
a6a959a
Make doctor and setup say what a build can actually do
fstubner Oct 7, 2026
44d894e
Pad the scan table before colouring it, so the columns line up in a t…
fstubner Oct 7, 2026
aab9543
Honour TERM=dumb and CLICOLOR_FORCE in the CLI and NO_COLOR in the te…
fstubner Oct 7, 2026
0b0eebe
Read /export paths the way a TUI user types them
fstubner Oct 7, 2026
ee601c8
Make the TUI commands refuse what they cannot read
fstubner Oct 7, 2026
cd815aa
Correct four things --help says
fstubner Oct 7, 2026
4dc1110
Stop the TUI running what was only pasted or half typed, and show its…
fstubner Oct 7, 2026
da6dc9b
Make the TUI's inspect output match the CLI's
fstubner Oct 7, 2026
e8d8a01
Show the machine's real host name in the TUI status line
fstubner Oct 7, 2026
ad5dae8
Say which address a scanned name resolved to
fstubner Oct 7, 2026
41928cb
Do not replace an existing capture file unless --force says to
fstubner Oct 7, 2026
9443612
End quietly when the reader of a pipe hangs up
fstubner Oct 7, 2026
fe0990d
Tidy three details of the CLI's text output
fstubner Oct 7, 2026
e88cec1
Remove a dependency and a field nothing used
fstubner Oct 7, 2026
159d672
Test that the TUI draws at any terminal size
fstubner Oct 7, 2026
794cdeb
Move tests into their own files so three files stay under the size limit
fstubner Oct 7, 2026
a10b7bc
Bring the CLI, terminal UI and operations pages in line with the program
fstubner Oct 7, 2026
3cb22be
Changelog entries for the CLI and terminal UI audit fixes
fstubner Oct 7, 2026
2e4a04c
Let netscli serve exit as soon as the server has stopped
fstubner Oct 7, 2026
efebbe6
Merge remote-tracking branch 'origin/main' into fix/cli-tui
fstubner Oct 7, 2026
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
159 changes: 159 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ its heading and collects entries. The date and the link go on with the tag.

### Added

- **The terminal UI honours `NO_COLOR`, and the CLI honours `TERM=dumb` and
`CLICOLOR_FORCE`.** The CLI's tables already went plain for `NO_COLOR` and
for output that is not a terminal. The terminal UI ignored `NO_COLOR` and
always drew in 24-bit colour. It now draws without colour when `NO_COLOR` is
set, keeping bold, and shows the selected row in `/config` reversed instead
of shaded. The CLI also stops colouring for `TERM=dumb`, and
`CLICOLOR_FORCE=1` keeps colour when the output is piped, for `less -R` or a
CI log. `netscli --help` now lists these, and `NETSCLI_HISTORY`, at the end.

- **The MCP server reports progress and can be cancelled.** Discover, port
scans and sweeps send progress notifications to clients that ask for them,
so a long sweep no longer looks stuck. A client that cancels a call now
Expand All @@ -28,6 +37,60 @@ its heading and collects entries. The date and the link go on with the tag.

### Changed

- **History is off unless you set `NETSCLI_HISTORY=1`.** Every `discover`,
`scan`, `inspect`, `sweep`, `dns`, `reverse` and `pcap` run, from the command
line and the terminal UI, kept its whole result in `~/.netscli/netscli.db`
for good, and nothing reads it back. Every other command created the file
too, just by starting, `completions` and `man` included. NetsCLI now opens
and creates nothing until the variable is set. With it set, history works as
before, and on Linux and macOS the folder and file it creates are readable by
you alone. A database an earlier version made is left where it is, and
deleting it is up to you. Where no home folder can be found, the database and
the terminal UI's default export folder no longer fall back to a `.netscli`
folder in the current directory. They say there is no home instead.

- **Exit codes follow what happened.** `0` means the command worked, `1` that
it ran and failed, and `2` that the command line was wrong. Until now `ping`
exited 0 when nothing answered, `trace` exited 0 when `tracert` or
`traceroute` had failed, `doctor` exited 0 with a dependency missing, and
asking for two output formats exited 1 like a failed scan. `ping` and `trace`
now exit 1 in those cases, `doctor` exits 1 when libpcap is missing from a
build that has packet capture, and two output formats, or `ping -c 0`, exit 2
before anything runs. `ping -c 0` used to print `loss=0.0%` for a ping that
sent nothing. A `dns --record ALL` where only some record types have records
still exits 0. A script that ignored the exit code of `ping` or `trace` will
start to see 1, and the CLI page lists every code.

- **`netscli mcp-service --install` no longer installs a systemd unit.** The
unit could not work. `netscli serve` speaks MCP over stdin and stdout and
stops when its input closes, and systemd gives a service no input, so the
server exited at once and `Restart=always` started it again every five
seconds. An MCP client starts the server itself. The command now says so,
points to the MCP setup guide, and exits 1. It also wrote the unit on Windows
and macOS, where nothing could read it. `--uninstall` and `--status` stay, on
every system, to find and remove a unit an earlier version left behind.
Running `mcp-service` with no flag is now a usage error instead of a hint and
exit 0.

- **The terminal UI's `/discover` looks up hostnames only when you add
`--resolve`.** It always looked them up, with no way to turn that off, where
the CLI, the desktop app, the MCP server and `/sweep` all leave it out unless
asked. Its summary line says which it did. The help for `/sweep` listed
`--no-resolve`, which does nothing, and not `--resolve`, which does. It now
lists `--resolve`.

- **`netscli pcap` does not replace an existing capture file unless you add
`--force`.** It writes `capture.pcap` in the current folder by default and
used to overwrite whatever was there, so a second capture quietly destroyed
the first. A script that captures to the same name every time now needs
`--force`.

- **Enter on half a command name in the terminal UI finishes the name instead
of running it.** `/e` then Enter ran `/export`, the first suggestion, which
writes a file, when the user was heading for `/exit`, and `/d` started a
discovery of the default subnet. The name is now completed, as `Tab` does,
and a second Enter runs it.

- **The desktop app stays responsive with large results.** It drew every
row of a result and redrew all of them on every keypress or scroll, so a
full 4,096-port scan took about a tenth of a second per arrow key, and a
Expand All @@ -45,6 +108,102 @@ its heading and collects entries. The date and the link go on with the tag.

### Fixed

- **Text from the network could reorder or hide itself.** The cleaning that
makes a hostname, banner or `TXT` value safe to print replaced control
characters and let everything else through. That included right-to-left
overrides, which make the text after them display backwards, and zero-width
characters, which hide text or make two different names look the same. They
are now replaced with `.` in the CLI's text, CSV and Markdown output, in
service banners (which the desktop app and the MCP server share), in the
computer name an SMB host reports, and in the terminal UI. `--json` and
`--yaml` still carry the text as it was received. A zero-width joiner inside
an emoji in a name now shows as a dot as well.

- **The terminal UI's `/export md` could write an escape sequence into a
file.** The screen dropped control characters, but the export wrote the
stored text as it was, so an mDNS name or a `TXT` value that carried an
escape sequence ran when someone printed the file to a terminal. The text is
now cleaned when a command's output is stored, and again when the file is
written.

- **The `scan` table did not line up in a colour terminal.** Rows were coloured
first and padded second, and the colour codes count as characters, so a cell
came out short and the rest of its row slid left. Output sent to a file or a
pipe was always fine, which is why the tests never saw it.

- **Pasting into the terminal UI on Linux and macOS ran commands.** Typed in
one key at a time, every pasted line break was a press of Enter, so pasting
two lines ran the first and went on to the second. A paste now arrives as one
piece of text with its line breaks turned into spaces. On Windows nothing
changed, because the terminal library does not support it there.

- **Terminal UI commands ignored words they could not read.** `/ping host abc`
pinged four times, `/mdns --timeout abc` used 3000, `/discover a b` dropped
the `b`, and `/arp add 1.2.3.4` with no MAC showed the ARP table. Each now
answers with a usage error. Worst was `/pcap eth0 --filter tcp port 80`,
which captured with the filter `tcp`, so every TCP packet, and said nothing.
`/pcap` now reads quotes, as in `--filter "tcp port 80"`, and says to use
them when a filter has spaces and none. `/mdns` also takes `--timeout-ms`,
the CLI's name for it, and `/arp` takes `delete` as well as `del`.

- **`/export --output` in the terminal UI mangled Windows paths and ignored
`~`.** The path went through a splitter that treats a backslash as an
escape, so `C:\Users\me\out.md` became `C:Usersmeout.md`, a path relative to
drive C. And `~/scans/out.md` made a folder named `~`. A backslash is now an
ordinary character, and a leading `~` is your home folder.

- **A crash in the terminal UI left no message.** The panic message went to the
alternate screen, which is thrown away when the UI closes, so a crash looked
like the program closing by itself with exit code 101. The terminal is now
put back first, so the message is where you can read it.

- **`netscli ... | head` ended in a panic message.** When the reader closed the
pipe, `scan 127.0.0.1 -p 1-4096 --json | head` printed `failed printing to
stdout` and a panic, and exited 101. A reader that stops early now just ends
the output, with exit 0, for the machine-readable formats and the large
reports (`scan`, `discover`, `sweep`, `inspect` and `pcap --read`).

- **`netscli serve` could keep running for minutes after its client had gone.**
When the client closes its end, the server drops every request it was
working on. A ping or capture already running on a blocking thread cannot be
dropped, and the process waited for it, up to the request's own timeout,
which the MCP tools allow to be ten minutes. The server now ends the process
as soon as it has stopped, with every response already written.

- **`netscli scan <name>` did not say which address it scanned.** A name can
have several, the scan used the first, and nothing in the output said which.
The text header now reads `example.com (93.184.216.34)`. The JSON is the
same as before.

- **`doctor` said a standard build had libpcap installed.** Standard builds
have no packet capture, and `doctor` is how people find that out, but it
printed a tick. It now reports libpcap as not installed and not compiled in,
and still exits 0. `setup` no longer offers to install it for such a build,
where it could not help. And `setup --print`, which is meant to print only,
no longer writes `~/.netscli/config.json`.

- **Smaller things in what the CLI and the terminal UI print.**
- `/inspect` showed the round-trip time as `(RTT: Some(3))`, left out a MAC
address unless its maker was known, and never showed the host name.
- The terminal UI's status line said `host n/a` in most Linux and macOS
terminals, because it read `$HOSTNAME`, which zsh does not export. It asks
the system.
- In the discover table a long vendor name pushed the hostname column out of
line, a name with an accent was measured in bytes, and a single host was
"1 hosts". `sweep` had the same plural.
- `mdns --timeout-ms 999999` waits 30 seconds and then said nothing was found
within 999999ms.
- `--help` said `-j` defaults to 256 when `/config` can change that, gave
`_http._tcp` as an mDNS type when only `_http._tcp.local.` works, and gave a
PowerShell completions example that wrote to a file path that is not one.

- **The CLI and terminal UI pages match the program.** The CLI page showed scan
output from before 0.3.4, and did not list the exit codes, the environment
variables or the limits that cut a value to fit. The operations page said
`inspect` with no ports is a host-only check, when it checks 22, 80 and 443.
The terminal UI page described the real screen with an illustration that
did not match it, and listed neither the keys nor `/arp add`.

- **Discovery on Windows listed devices that had left the network.** The
Windows device table keeps an entry for a while after its device goes,
and discovery reported those entries as devices. On one network that was
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion apps/netscli-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ tokio.workspace = true
serde.workspace = true
serde_json.workspace = true
anyhow.workspace = true
thiserror.workspace = true
chrono.workspace = true
ipnet.workspace = true
clap = { version = "4.6", features = ["derive"] }
Expand All @@ -33,6 +32,9 @@ mac_address = "1.1.8"
shell-words = "1.1"
dialoguer = "0.12"
unicode-width = "0.2"
# Already in the build through netscli-core. This crate asks it for the
# machine's host name.
sysinfo = "0.39"

[features]
default = []
Expand Down
9 changes: 5 additions & 4 deletions apps/netscli-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,12 @@ netscli serve # MCP server (JSON-RPC over stdio)
## Surfaces

- **CLI** — `netscli <cmd>` for scripts, cron, and piping into `jq`.
`--json` and `--yaml` output on every non-interactive subcommand, and
`--csv` and `--md` on the ones that return a list.
`--json` and `--yaml` output on every command that reports a result, and
`--csv` and `--md` on the ones that return a list. Exit codes are 0 for
success, 1 for a failed command and 2 for a wrong command line.
- **TUI** — `netscli` alone opens a ratatui-based terminal UI with
autocomplete, command history, in-place progress, status footer,
and native scrollback/selection.
autocomplete, command history, live progress, and a status line with your
host, IP address and network traffic. It scrolls with PageUp and PageDown.
- **MCP** — `netscli serve` exposes nine tools over JSON-RPC on stdio
for Claude Code, Cursor, or any MCP client.

Expand Down
67 changes: 53 additions & 14 deletions apps/netscli-cli/src/args.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
use clap::{Args, Parser, Subcommand};
use clap_complete::Shell;

const ENVIRONMENT_HELP: &str = "\
Environment:
NETSCLI_HISTORY=1 Keep every result in ~/.netscli/netscli.db (off by default)
NO_COLOR No colour in the output or the terminal UI
CLICOLOR_FORCE=1 Colour the output even when it is piped";

#[derive(Parser)]
#[command(name = "netscli", version, about = "Modern network scanner", long_about = None)]
#[command(
name = "netscli",
version,
about = "Modern network scanner",
long_about = None,
after_help = ENVIRONMENT_HELP
)]
pub struct Cli {
/// Max in-flight network operations (default 256; clamped to [1, 1024];
/// mDNS discovery sub-caps internally at 32). Lower this on fragile home
/// gateways that struggle with hundreds of simultaneous probes.
/// Max in-flight network operations (default 256, or the value saved by
/// the terminal UI's /config; clamped to [1, 1024]; mDNS discovery
/// sub-caps internally at 32). Lower this on fragile home gateways that
/// struggle with hundreds of simultaneous probes.
#[arg(short = 'j', long, global = true, value_name = "N")]
pub concurrency: Option<usize>,

Expand All @@ -15,7 +28,11 @@ pub struct Cli {
}

/// `--json` / `--yaml`, for commands whose result is not a list of rows.
///
/// `multiple = false` makes the flags exclusive, so asking for two is a usage
/// error from clap (exit 2) and not a failure of the command (exit 1).
#[derive(Args, Clone, Copy, Debug)]
#[group(multiple = false)]
pub struct StructuredOutput {
/// Output JSON
#[arg(long)]
Expand All @@ -27,8 +44,10 @@ pub struct StructuredOutput {
}

/// `--json` / `--yaml` / `--csv` / `--md`, for commands that return a list:
/// one table row per host, port, record or packet.
/// one table row per host, port, record or packet. Exclusive, like
/// [`StructuredOutput`].
#[derive(Args, Clone, Copy, Debug)]
#[group(multiple = false)]
pub struct ListOutput {
/// Output JSON
#[arg(long)]
Expand Down Expand Up @@ -153,8 +172,13 @@ pub enum Commands {
/// Host to ping (IP or hostname)
host: String,

/// Number of pings to send
#[arg(short = 'c', long, default_value_t = 4)]
/// Number of pings to send, at least 1 (more than 256 is cut to 256)
#[arg(
short = 'c',
long,
default_value_t = 4,
value_parser = clap::value_parser!(u32).range(1..)
)]
count: u32,

#[command(flatten)]
Expand Down Expand Up @@ -244,6 +268,10 @@ pub enum Commands {
#[arg(long, default_value = "capture.pcap")]
output: String,

/// Overwrite the output file if it already exists
#[arg(long)]
force: bool,

/// Only check pcap support and list capture devices
#[arg(long)]
check: bool,
Expand All @@ -265,8 +293,9 @@ pub enum Commands {
#[arg(long, default_value_t = 3000)]
timeout_ms: u64,

/// Service types to browse (repeatable). Defaults to a curated set
/// of common types (_http._tcp, _ssh._tcp, _airplay._tcp, etc.).
/// Service types to browse (repeatable), written in full, such as
/// `_ipp._tcp.local.`. Defaults to a curated set of common types
/// (_http._tcp.local., _ssh._tcp.local., _airplay._tcp.local., etc.).
#[arg(long = "type", short = 't')]
service_types: Vec<String>,

Expand All @@ -284,7 +313,7 @@ pub enum Commands {
/// bash: netscli completions bash > ~/.local/share/bash-completion/completions/netscli
/// zsh: netscli completions zsh > ~/.zsh/completions/_netscli
/// fish: netscli completions fish > ~/.config/fish/completions/netscli.fish
/// powershell: netscli completions powershell > $PROFILE/netscli.ps1
/// powershell: netscli completions powershell | Out-String | Invoke-Expression
#[command(name = "completions")]
Completions { shell: Shell },

Expand All @@ -294,17 +323,27 @@ pub enum Commands {
#[command(name = "man")]
Man,

/// Manage MCP server auto-start (systemd service)
/// Remove the systemd unit that netscli 0.3.4 and earlier installed
///
/// `netscli serve` speaks MCP over stdin and stdout, so the MCP client has
/// to start it. Run as a service it gets no input and exits at once, which
/// is why this no longer installs a unit. See https://netscli.com/docs/mcp/
#[command(group(
clap::ArgGroup::new("mcp_service_action")
.args(["install", "uninstall", "status"])
.required(true)
.multiple(false)
))]
McpService {
/// Generate systemd user service file
/// Explain why there is no longer a service to install
#[arg(long)]
install: bool,

/// Remove systemd user service file
/// Remove the systemd user unit an earlier version installed
#[arg(long)]
uninstall: bool,

/// Show service status
/// Show whether such a unit is still there
#[arg(long)]
status: bool,
},
Expand Down
Loading
Loading