How I built PowershellOps, a terminal dashboard with 126 functions, 90 one-word commands, and a llama.cpp-powered AI hub that never touches the cloud. Full walkthrough, real code, and the forks in the road where you should disagree with me.
Every Windows machine ships with a diagnostics API that most developers never open. CIM classes report your CPU model, battery wear, thermal zones, Defender status, DNS cache, and signed-driver faults, all locally, all queryable from a shell you already have. Pair that with a small language model running on your own GPU and you get something no SaaS dashboard sells: an operations copilot that answers "why is my fan loud" without uploading a byte.
This post walks the whole build, zero to shipped product. By the end you will have a PowerShell profile where typing dash renders an 11-suite command center, fix pipes your last error to a local model for diagnosis, mem saves notes with automatic secret redaction, and 90 short verbs like temps, shield, and dnsbench resolve from anywhere in your shell. The finished project lives at github.com/shahriarhaqueabir/PowershellOps (v12.0.0, MIT licensed), so you can compare every step against the real source.
Some steps I hand you completely. Others I leave half-open on purpose, marked Your turn, because you learn the CIM layer by writing your own queries, not by pasting mine. And at each architectural fork I lay out the alternatives, including the ones I rejected and why.
What We Are Building
The product has four layers:
- A sensor layer. Plain functions that query CIM/WMI and return formatted output: hardware specs, uptime, RAM sticks, battery health, temperatures, fans, displays, network adapters, SMB shares, certificate stores.
-
An alias surface. Every sensor gets a one-word name.
raminfo,patchhistory,drivehealth. Ninety of them, all backed by a single ordered hashtable that doubles as the dashboard's data source. -
An AI hub. A local llama.cpp server speaking the OpenAI-compatible protocol on
127.0.0.1:8081. Functions pipe data into it:Get-Process | ai "what's using the most memory?". - A delivery layer. A staged installer that works from stock Windows PowerShell 5.1, a hardened profile template, and a 27-test suite that runs hermetically in CI-style isolation.
Why is this useful when Get-ComputerInfo exists? Because raw cmdlets answer questions one at a time. A copilot answers the questions you ask at 9am: is anything hot, is anything patched, did my disk start filling up, and what was that registry tweak I saved last month. Speed matters too. Each sensor is one CIM query, so stat returns a full system pulse in about a second.
There is also the privacy argument, which turned out to matter more than I expected. The moment you route system telemetry through a cloud API, you need a data policy. Route it through localhost and the policy is trivial: nothing leaves the machine. My memory store holds API keys and hostnames, and I sleep fine because every write passes a redaction filter first.
Fork Number One: Which Inference Engine
Before any code, pick how the model runs. The candidates:
| Option | Strengths | Weaknesses |
|---|---|---|
| llama.cpp server | Single exe, OpenAI-compatible HTTP endpoint, GGUF models, full control over flags | You manage download, port, lifecycle |
| Ollama | One-command install, model management built in | Daemon owns the port; harder to pin versions; another product to trust |
| LM Studio | Nice GUI for model shopping | GUI-first, weaker headless story |
| Cloud API | Zero setup, best models | Per-token billing, telemetry leaves the box, breaks offline |
I chose llama.cpp for three reasons. First, scriptability: the server is one process I start and stop from PowerShell, so the module can auto-start it on demand. Second, the OpenAI-compatible /v1/chat/completions endpoint means my client code would survive a future engine swap unchanged. Third, reproducibility: the installer pins the engine version in vars.ps1 and downloads the exact binary it expects.
If you run Ollama already, keep it. The client function in step 5 targets an HTTP contract, not a vendor. Point $script:HawkLlamaUri at Ollama's OpenAI shim and everything downstream works.
Your turn: decide your engine before writing client code, and write down its URI and health-check endpoint. That decision document will save you an hour later.
Step 1: A Module Skeleton That Scales
Skip the single-file monolith. It feels faster and costs you later, because PowerShell modules load their .psm1 once and every edit during development means a full reload cycle. Instead, split concerns into numbered files and let the loader dot-source them in filename order:
# HawkwardHybrid.psm1
foreach ($domainFile in Get-ChildItem -LiteralPath $PSScriptRoot -Filter '*.ps1' | Sort-Object Name) {
. $domainFile.FullName
}
Export-ModuleMember -Function @('Get-HawkConfig', 'Invoke-HawkAI', ...)
Export-ModuleMember -Alias @('ai', 'fix', 'dash', ...)
The numeric prefixes (00-Core.ps1, 20-AI.ps1, 80-LegacyMatrix.ps1) enforce load order: core helpers before features that call them. One manifest, one version, one export surface.
Now the low-level detail that bites everyone: PowerShell exports are a contract between three places, and they drift independently. The manifest's FunctionsToExport controls what Import-Module exposes. The .psm1's Export-ModuleMember controls the live session. And any alias map you keep in a separate file is a third list that can rot. During this project's final review wave, the manifest listed 90 aliases while the loader exported 57. Every direct Import-Module consumer saw phantom commands. The fix became a permanent test: assert set equality between all three lists on every run, never a threshold like "at least 80".
$m = Get-Module HawkwardHybrid
$manifest = Import-PowerShellDataFile .\HawkwardHybrid.psd1
@($manifest.AliasesToExport | Where-Object { $_ -notin $m.ExportedAliases.Keys }) | Should -BeNullOrEmpty
Your turn: create the folder, write a Get-HelloOps function, wire the manifest, and prove the import path works before adding anything else. If you skip this and the parity test, you will meet the drift bug eventually, on a worse day.
Step 2: The Sensor Layer, or Talking to Your Motherboard
CIM is the workhorse. Each sensor is a query plus formatting. Two examples show the whole pattern:
function Get-HawkRamInfo {
Get-CimInstance Win32_PhysicalMemory |
Select-Object BankLabel, Capacity, Speed, Manufacturer |
Format-Table -AutoSize
}
function Get-HawkThermals {
$zones = @(Get-CimInstance -Namespace root/wmi -ClassName MSAcpi_ThermalZoneTemperature `
-ErrorAction SilentlyContinue)
if (-not $zones) {
Write-Host "No ACPI thermal zones exposed by this hardware/driver stack." -ForegroundColor DarkGray
return
}
$zones | ForEach-Object {
$celsius = [Math]::Round(($_.CurrentTemperature / 10.0) - 273.15, 1)
$state = if ($celsius -ge 85) { 'CRITICAL' } elseif ($celsius -ge 70) { 'HOT' }
elseif ($celsius -ge 50) { 'WARM' } else { 'NOMINAL' }
[PSCustomObject]@{ Zone = $_.InstanceName; TemperatureC = "$celsius C"; State = $state }
} | Format-Table -AutoSize
}
Three things worth noticing in the thermal function, because each is a small lesson:
The Kelvin conversion. ACPI reports temperature in tenths of a degree Kelvin. Divide by 10, subtract 273.15. If your readings come back around 3000, you forgot the scale, and yes, that is exactly what the raw number looks like the first time.
Graceful degradation. Many consumer boards expose no thermal zones without vendor drivers, and Win32_Fan often returns nothing because fan curves live in the embedded controller, outside WMI's reach. Return a friendly dark-gray hint instead of an error. A diagnostics tool that errors on normal hardware states trains users to ignore it.
Objects, then format. Emit [PSCustomObject] and pipe through Format-Table at the end. Anyone can then do temps | Where-Object State -eq 'HOT' themselves. Format early and you've thrown away the data.
The fork here: Get-CimInstance versus Get-WmiObject. The latter is deprecated and absent from PowerShell 7, so the choice is made for you. But within CIM there are still decisions. Some data lives in the default root/cimv2 namespace, thermals hide in root/wmi, disk health sits in root\Microsoft\Windows\Storage with class MSFT_PhysicalDisk. I query the storage namespace directly rather than calling Get-PhysicalDisk, which keeps behavior identical across Windows builds.
Your turn: write battery health yourself. Query Win32_Battery, compute FullChargeCapacity / DesignCapacity * 100, round to one decimal, and handle the desktop case where the query returns nothing. Then try Win32_Processor, Win32_VideoController, and Get-HotFix on your own machine. Ten functions in, you'll stop needing my snippets.
Step 3: Ninety Aliases Without Losing Your Mind
Short names are the product. Nobody types Get-HawkScheduledTaskRiskAudit; they type taskaudit. But ninety Set-Alias calls scattered across files become unmaintainable, so the entire alias surface lives in one ordered hashtable:
$script:HawkAliasMap = [ordered]@{
ai = 'Invoke-HawkAI'
dash = 'Show-HawkDashboard'
temps = 'Get-HawkThermals'
shield = 'Get-HawkShield'
# ... 86 more
}
Registration happens twice, by design. Once in module scope at dot-source time, so Export-ModuleMember -Alias publishes a truthful surface to anyone who imports the module. Once in global scope via Set-HawkAliases at profile load, so interactive sessions get the shortcuts even without an explicit import. Same map, both scopes, one source of truth.
function Set-HawkAliases {
foreach ($name in $script:HawkAliasMap.Keys) {
Set-Alias -Scope Global -Name $name -Value $script:HawkAliasMap[$name] -Force
}
}
The honest objection: aliases die at the process boundary. Call temps from a script or another tool and PowerShell resolves it only if the profile loaded. Native executables survive everywhere. If your audience lives in cmd.exe and scheduled tasks, consider shipping a thin compiled CLI instead, or generating .cmd shims. I stayed with aliases because the target user is me, inside PowerShell, dozens of times a day, and the dashboard-to-command guarantee (every tile renders a name that resolves) only needs in-shell truth.
That guarantee is testable, by the way, and worth testing. Extract tile aliases from the dashboard source with a regex, diff them against the alias map, fail the suite on either direction of mismatch. This exact test caught dead tiles after I renamed functions without updating the menu.
Your turn: add three sensors of your own from step 2, give each an alias, and extend the parity test to cover them. Notice how the map format makes the dashboard update free: the menu iterates the same structure.
Step 4: A Dashboard Worth Staring At
dash prints eleven suites, each a titled grid of emoji-tiled commands. The implementation is an ordered dictionary of suites, each holding a title, an icon, and rows of (glyph, label, alias) triples, rendered by one loop. No TUI framework, no ncurses clone. Write-Host with colors covers it.
Two low-level notes from the trenches:
UTF-16 surrogate pairs. Emojis above the Basic Multilingual Plane (the robot 🤖 is U+1F916) encode as two UTF-16 code units. If you probe render output character-by-character, remember [char]0xD83E followed by [char]0xDD16 forms the glyph, and piping chars through some stringifications mangles them into decimal digits. Test with .Contains($pair) on the joined pair.
Console encoding. Wrap [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new() in try/catch. On some hosts the setter throws, and a crash inside your profile renderer poisons every session.
Your turn: design your own suite row before looking at mine. Decide first what belongs on a glanceable menu and what deserves omission. Curation is the actual product skill here; rendering is twenty lines.
Step 5: Wiring the Local Model
The AI hub starts with a server manager: download the pinned llama.cpp build (the installer handles this in steps/01-engine.ps1 and 02-model.ps1), place a GGUF model under ~\Models\GGUF, then start llama-server.exe with a port and context flags. Health check via TCP probe with a retry loop, because the server accepts TCP connections a moment before it can serve.
The client function carries the design weight. Its signature tells the story:
function Invoke-HawkAI {
[CmdletBinding()]
param(
[Parameter(ValueFromPipeline = $true)] $InputData,
[Parameter(Position = 0)][string]$Instruction,
[string]$Model = $script:HawkLlamaModelPath,
[int]$TimeoutSec = 300,
[switch]$RedactSensitive,
[switch]$PassThru
)
# buffer pipeline input, stringify, redact, POST to /v1/chat/completions, stream reply
}
Pipeline support is what makes it feel native. Get-EventLog System -Newest 50 | ai "summarize failures" buffers objects through the process block, stringifies with Out-String, and sends the bundle as evidence alongside your instruction. Streaming keeps perceived latency low; a 4B-parameter model on a modest GPU starts answering in about a second.
Redaction runs before the payload leaves the process, always available, opt-in per call:
[regex]::Replace($text,
'(?im)^(\s*[^=\r\n]*(?:secret|token|password|api.?key|private.?key)[^=\r\n]*\s*=\s*).+$',
'$1<REDACTED>')
plus a second pass for the JSON "key": "value" shape. Regex redaction is beatable, and I say so in the docs. Structured secrets in weird formats can slip. But the common cases, dotenv lines and JSON config blobs, get caught, and the defense costs zero dependencies.
Small local models bend prompt constraints, so expect to iterate on wording. Mine instructs the model to answer from provided data first, preserve units, stay concise, and suggest the smallest next check when unsure. Prompt engineering for 4B-class models is constraint engineering.
Fork number two: model choice. Bigger is better until your RAM disagrees. A Q4-quantized 4B model runs in under 4 GB and answers fast; an 8B wants about 6 GB and reasons better; anything above 14B turns your copilot into a slideshow unless you own real VRAM. I ship with Qwen3-family GGUFs via the Unsloth quants, and the installer lets you pick. Benchmark on your machine: time ten stat | ai calls at each size and let the numbers choose.
Your turn: implement the retry logic. On connection refused, wait two seconds, re-probe, auto-start the server if configured, and cap total attempts. My first version raced the server startup and failed at random, which is why Start-HawkLlamaServer now polls instead of sleeping on a fixed timer.
Step 6: Verbs That Do Something
Raw AI chat is table stakes; the value shows in composed verbs. fix is the best example, and it is twelve lines:
function Invoke-HawkShortFix {
param([string]$Context)
$err = $global:Error | Select-Object -First 1
if (-not $err) {
Write-Host 'No error found in $Error to remediate.' -ForegroundColor Yellow
return
}
$payload = "Last PowerShell error:`n$($err.ToString())`n`nInvocation context:`n$($err.InvocationInfo.PositionMessage)"
if ($Context) { $payload += "`n`nExtra context:`n$Context" }
$payload | Invoke-HawkAI -Instruction 'Diagnose this PowerShell error and give a concrete fix (commands where possible). Be concise.' -RedactSensitive
}
$Error[0] plus InvocationInfo.PositionMessage gives the model the exception text and the exact line where it threw. That pairing fixes most errors in one shot because the model sees what you saw.
On top of these sit router functions with ValidateSet parameters: hub fix, hub stat, sysdiag temp, auditdiag defender. A dispatcher is a switch statement over validated types, each case delegating to the underlying function. ValidateSet buys tab-completion and rejects typos before your switch ever runs.
Your turn: add an explain verb that takes a command name, pulls its help with Get-Help -Full, and asks the model to produce a plain-language explanation with two examples. You will need exactly one new concept beyond what this post covered.
Step 7: Memory That Redacts Itself
The memory system stores notes as JSON Lines, one entry per line, at Documents\PowerShell\Memory\hawk-memory.jsonl. Entries carry an id, typed tags (note/preference/fact/incident/command/link), a confidence score, a pinned flag, and the text. Search scores each term: plus two per term matching the text, plus one matching a tag, flat plus-two bonus for pinned entries, drop zeros, sort descending:
foreach ($t in $terms) {
if ($e.Text -match [regex]::Escape($t)) { $score += 2 }
if (($e.Tags -join ' ') -match [regex]::Escape($t)) { $score += 1 }
}
if ($e.Pinned) { $score += 2 }
The fork everyone argues: why not embeddings? Vector search would find "that quantization thing" when you search "model compression formats", which term matching misses. My counterargument: this store holds hundreds of entries, not millions. Term scoring with tags covers recall at this scale, adds zero dependencies, stays human-readable when you open the file, and never needs an embedding model loaded. When the store crosses a few thousand entries, revisit. Ship the boring version first and write down the migration trigger. SQLite would also work and gives you real indexes; JSONL won because the whole database fits in one Get-Content call and survives any editor.
Every write funnels through the same redaction filter as AI payloads, so mem api_key=sk-whatever stores <REDACTED>. Test this in the suite: save a fake key, read the file, assert the secret is gone. That test has earned its keep.
Your turn: implement rotation. When the file passes N entries, archive the oldest unpinned third to a dated sidecar file. Decide what N should be and defend it in a comment.
Step 8: The Installer Problem Nobody Escapes
Shipping a PowerShell product to other machines means answering an awkward question: which PowerShell? Windows still boots with 5.1, while serious modules want 7. My answer is a staged bootstrap: install.ps1 runs on whatever shell invoked it, checks for pwsh, installs it via winget if missing, then re-executes itself under 7 with the original arguments forwarded.
After the handoff, numbered steps run in order: fetch the pinned engine, fetch the model, copy the module into the user's Documents\PowerShell\Modules, write config JSON, install the hardened profile template, register an update check. Each step logs to a file and exits nonzero on failure, so automation can detect partial installs.
The profile template deserves its own sentence: wrap every initialization phase in try/catch. A profile that throws strands users in a broken shell, and since agents wrote much of this code, defensive loading is non-negotiable.
Step 9: Tests That Keep Everyone Honest
Twenty-seven tests run in seconds, and they share one property: they touch nothing outside a staging directory. The suite redirects the PowerShell profile root to a temp location (Set-HawkStagingRoot), isolates config via HAWK_CONFIG_PATH, and treats any network touch as a defect. Early versions could hit real downloads mid-test; the fence went up, and now any run that touches the network fails.
Beyond unit checks, the valuable tests are contracts:
- Parity: psd1 exports equal psm1 exports equal alias-map keys, set equality both directions.
- Surface: every dashboard tile resolves to a registered alias; every alias appears in the manual's index; counts match the header claims.
-
Smoke: stage the real profile template, load it in a fresh process, assert
dash,specs, andairesolve with zero errors. - Docs: the manual mentions only exported functions; phantom references fail the build. (This test exists because earlier docs referenced commands I had retired. Docs lie without getting caught; tests make lying expensive.)
When you build with AI assistance, as I did, this suite is the difference between delegation and hope. Agents generate plausible code without hesitation. Set-equality gates catch the drift that confidence hides. My final review wave found 17 issues across ten reviewer passes; one round fixed eight of them because the verification commands existed and ran green afterward.
Your turn: take whatever you built in steps 1 through 7 and write the parity test first, before the next feature. Then break something on purpose (rename a function without updating the manifest) and watch the right test fail with the right message.
Step 10: Ship It
Documentation earns its push. Two artifacts mattered most: a README whose alias index is generated-checked against the manifest (all 90 present, zero phantoms), and a manual mapping every command to its underlying cmdlets and CIM classes. Writing the sensor map exposed three stale references and two phantom aliases in my own docs, which is the point: the audit is for you.
GitHub-specific lessons, learned the hard way:
- Anchor links break when headings contain decorative characters. GitHub's slugger keeps trailing hyphens from
── SECTION ──styles, so#features404s while#-features-resolves. Strip decorations from headings or compute slugs exactly. - Tag the release:
git tag -a v12.0.0 -m "..."thengit push origin v12.0.0. Draft the Release page from the tag and paste real release notes; changelogs written from memory lie. - Fill the About description and topics. Searchable repos get contributors; invisible ones get neither.
- If you publish to PSGallery, know that the legacy
Publish-Moduleshells out todotnet packand demands a .NET SDK. The modernPublish-PSResourcepacks in-process and skips that requirement. I skipped gallery publishing altogether; GitHub plus the installer covers my distribution needs.
Where You Take It Next
The natural extensions, about in order of payoff: scheduled hawkdaily runs writing timestamped reports to disk; WMI event subscriptions pushing alerts instead of polling; more dispatch verbs as your sensor collection grows; swapping the term-scoring memory for embeddings when it earns the complexity. Fork the repo, keep what you use, delete the rest. Ninety aliases is a menu, and menus invite substitution.
If you want the companion piece, the repository also documents how this whole product was built with an AI coding agent: phase maps, prompt patterns, the review loop, and a museum of failures that became guardrails (docs/AGENTIC-IDE-TUTORIAL.md).
Build your own suite. Break my defaults. The whole point of a local copilot is that it answers to you.
Top comments (0)