feat: compact system inventory with queryable system_info tool
System prompt gets a one-line summary (~200 chars): OS, CPU, RAM, GPU, top runtimes, package count, PATH command count. Full details available on demand via system_info tool with sections: runtimes, packages, tools, hardware, all. LLM calls the tool when it needs specifics — saves thousands of tokens per request. Hardware detection: CPU model, core count, total RAM, GPU via lspci. Package manager: pacman/apt/dnf/brew with dev package filtering. PATH scan: 5541 executables. Runtime probing: 22 detected.
This commit is contained in:
@@ -23,6 +23,7 @@ import (
|
||||
"somegit.dev/Owlibou/gnoma/internal/tool"
|
||||
"somegit.dev/Owlibou/gnoma/internal/tool/bash"
|
||||
"somegit.dev/Owlibou/gnoma/internal/tool/fs"
|
||||
"somegit.dev/Owlibou/gnoma/internal/tool/sysinfo"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -89,6 +90,9 @@ func main() {
|
||||
// Re-register bash tool with aliases
|
||||
reg.Register(bash.New(bash.WithAliases(aliases)))
|
||||
|
||||
// Register system_info tool backed by the inventory
|
||||
reg.Register(sysinfo.New(inventory))
|
||||
|
||||
// Create router and register the provider as a single arm
|
||||
// (M4 foundation: one provider from CLI. Multi-provider routing comes with config.)
|
||||
rtr := router.New(router.Config{Logger: logger})
|
||||
@@ -114,10 +118,10 @@ func main() {
|
||||
Logger: logger,
|
||||
})
|
||||
|
||||
// Build system prompt with inventory
|
||||
// Build system prompt with compact inventory summary
|
||||
systemPrompt := *system
|
||||
if invStr := inventory.String(); invStr != "" {
|
||||
systemPrompt = systemPrompt + "\n\n" + invStr
|
||||
if summary := inventory.Summary(); summary != "" {
|
||||
systemPrompt = systemPrompt + "\n\n" + summary
|
||||
}
|
||||
|
||||
// Create engine
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -16,46 +17,216 @@ const inventoryTimeout = 15 * time.Second
|
||||
|
||||
// SystemInventory holds dynamically discovered system information.
|
||||
type SystemInventory struct {
|
||||
// Core
|
||||
OS string
|
||||
Shell string
|
||||
Tools []string // all executables found in PATH
|
||||
Runtimes []Runtime // detected runtimes with versions
|
||||
Hardware HardwareInfo
|
||||
|
||||
// Discovered
|
||||
Tools []string // all executables found in PATH
|
||||
Runtimes []Runtime // detected runtimes with versions
|
||||
PackageCount int // total installed packages
|
||||
PackageMgr string // detected package manager
|
||||
DevPackages []string // development-relevant packages
|
||||
}
|
||||
|
||||
type HardwareInfo struct {
|
||||
CPU string
|
||||
Cores int
|
||||
MemTotal string
|
||||
GPU string
|
||||
}
|
||||
|
||||
// Runtime is a detected language runtime or package manager.
|
||||
type Runtime struct {
|
||||
Name string
|
||||
Version string
|
||||
}
|
||||
|
||||
// HarvestInventory dynamically discovers installed tools and runtimes.
|
||||
// No hardcoded lists — scans $PATH and probes for version info.
|
||||
// HarvestInventory dynamically discovers system info.
|
||||
func HarvestInventory(ctx context.Context) *SystemInventory {
|
||||
ctx, cancel := context.WithTimeout(ctx, inventoryTimeout)
|
||||
defer cancel()
|
||||
|
||||
inv := &SystemInventory{
|
||||
OS: detectOS(ctx),
|
||||
Shell: detectShell(),
|
||||
OS: detectOS(ctx),
|
||||
Shell: os.Getenv("SHELL"),
|
||||
Hardware: detectHardware(ctx),
|
||||
}
|
||||
|
||||
// Scan all executables in PATH
|
||||
allBinaries := scanPATH()
|
||||
inv.Tools = allBinaries
|
||||
|
||||
// Probe for runtimes: try --version on known runtime name patterns
|
||||
inv.Runtimes = probeRuntimes(ctx, allBinaries)
|
||||
inv.Tools = scanPATH()
|
||||
inv.Runtimes = probeRuntimes(ctx, inv.Tools)
|
||||
inv.PackageMgr, inv.PackageCount, inv.DevPackages = queryPackageManager(ctx)
|
||||
|
||||
return inv
|
||||
}
|
||||
|
||||
// scanPATH collects all unique executable names from $PATH directories.
|
||||
// Summary returns a compact one-paragraph description for the system prompt.
|
||||
// Minimal tokens — just enough for the LLM to know the system's capabilities.
|
||||
func (inv *SystemInventory) Summary() string {
|
||||
if inv == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("System: ")
|
||||
|
||||
if inv.OS != "" {
|
||||
b.WriteString(inv.OS)
|
||||
}
|
||||
if inv.Shell != "" {
|
||||
fmt.Fprintf(&b, ", %s", filepath.Base(inv.Shell))
|
||||
}
|
||||
if inv.Hardware.CPU != "" {
|
||||
fmt.Fprintf(&b, ". CPU: %s (%d cores)", inv.Hardware.CPU, inv.Hardware.Cores)
|
||||
}
|
||||
if inv.Hardware.MemTotal != "" {
|
||||
fmt.Fprintf(&b, ", RAM: %s", inv.Hardware.MemTotal)
|
||||
}
|
||||
if inv.Hardware.GPU != "" {
|
||||
fmt.Fprintf(&b, ", GPU: %s", inv.Hardware.GPU)
|
||||
}
|
||||
|
||||
// Top runtimes (max 6)
|
||||
if len(inv.Runtimes) > 0 {
|
||||
top := inv.Runtimes
|
||||
if len(top) > 6 {
|
||||
top = top[:6]
|
||||
}
|
||||
names := make([]string, len(top))
|
||||
for i, rt := range top {
|
||||
names[i] = rt.Name
|
||||
}
|
||||
fmt.Fprintf(&b, ". Runtimes: %s", strings.Join(names, ", "))
|
||||
if len(inv.Runtimes) > 6 {
|
||||
fmt.Fprintf(&b, " +%d more", len(inv.Runtimes)-6)
|
||||
}
|
||||
}
|
||||
|
||||
if inv.PackageCount > 0 {
|
||||
fmt.Fprintf(&b, ". %d packages (%s)", inv.PackageCount, inv.PackageMgr)
|
||||
}
|
||||
fmt.Fprintf(&b, ", %d commands in PATH.", len(inv.Tools))
|
||||
b.WriteString(" Use system_info tool for full details.")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// QuerySection returns detailed info for a specific section.
|
||||
// Sections: "all", "runtimes", "packages", "tools", "hardware"
|
||||
func (inv *SystemInventory) QuerySection(section string) string {
|
||||
if inv == nil {
|
||||
return "no inventory available"
|
||||
}
|
||||
|
||||
switch strings.ToLower(section) {
|
||||
case "runtimes":
|
||||
return inv.formatRuntimes()
|
||||
case "packages", "dev":
|
||||
return inv.formatPackages()
|
||||
case "tools", "commands":
|
||||
return inv.formatTools()
|
||||
case "hardware", "hw":
|
||||
return inv.formatHardware()
|
||||
case "all", "":
|
||||
return inv.formatAll()
|
||||
default:
|
||||
return fmt.Sprintf("unknown section %q. Available: runtimes, packages, tools, hardware, all", section)
|
||||
}
|
||||
}
|
||||
|
||||
func (inv *SystemInventory) formatRuntimes() string {
|
||||
if len(inv.Runtimes) == 0 {
|
||||
return "no runtimes detected"
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "Detected runtimes (%d):\n", len(inv.Runtimes))
|
||||
for _, rt := range inv.Runtimes {
|
||||
fmt.Fprintf(&b, " %s: %s\n", rt.Name, rt.Version)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (inv *SystemInventory) formatPackages() string {
|
||||
var b strings.Builder
|
||||
if inv.PackageMgr != "" {
|
||||
fmt.Fprintf(&b, "Package manager: %s (%d total packages)\n", inv.PackageMgr, inv.PackageCount)
|
||||
}
|
||||
if len(inv.DevPackages) > 0 {
|
||||
fmt.Fprintf(&b, "Dev packages (%d):\n %s\n", len(inv.DevPackages), strings.Join(inv.DevPackages, ", "))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (inv *SystemInventory) formatTools() string {
|
||||
if len(inv.Tools) == 0 {
|
||||
return "no tools found in PATH"
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "Executables in PATH (%d):\n %s\n", len(inv.Tools), strings.Join(inv.Tools, ", "))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (inv *SystemInventory) formatHardware() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("Hardware:\n")
|
||||
fmt.Fprintf(&b, " OS: %s\n", inv.OS)
|
||||
fmt.Fprintf(&b, " Shell: %s\n", inv.Shell)
|
||||
hw := inv.Hardware
|
||||
if hw.CPU != "" {
|
||||
fmt.Fprintf(&b, " CPU: %s\n", hw.CPU)
|
||||
}
|
||||
fmt.Fprintf(&b, " Cores: %d\n", hw.Cores)
|
||||
if hw.MemTotal != "" {
|
||||
fmt.Fprintf(&b, " Memory: %s\n", hw.MemTotal)
|
||||
}
|
||||
if hw.GPU != "" {
|
||||
fmt.Fprintf(&b, " GPU: %s\n", hw.GPU)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (inv *SystemInventory) formatAll() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(inv.formatHardware())
|
||||
b.WriteString("\n")
|
||||
b.WriteString(inv.formatRuntimes())
|
||||
b.WriteString("\n")
|
||||
b.WriteString(inv.formatPackages())
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// --- Hardware Detection ---
|
||||
|
||||
func detectHardware(ctx context.Context) HardwareInfo {
|
||||
hw := HardwareInfo{
|
||||
Cores: runtime.NumCPU(),
|
||||
}
|
||||
|
||||
// CPU model
|
||||
if cpuInfo := runQuiet(ctx, "sh", "-c", `command grep -m1 'model name' /proc/cpuinfo 2>/dev/null | cut -d: -f2`); cpuInfo != "" {
|
||||
hw.CPU = strings.TrimSpace(cpuInfo)
|
||||
}
|
||||
|
||||
// Total memory
|
||||
if memInfo := runQuiet(ctx, "sh", "-c", `command grep MemTotal /proc/meminfo 2>/dev/null | awk '{printf "%.0f GB", $2/1024/1024}'`); memInfo != "" {
|
||||
hw.MemTotal = memInfo
|
||||
}
|
||||
|
||||
// GPU (try lspci first, then nvidia-smi)
|
||||
if gpu := runQuiet(ctx, "sh", "-c", `lspci 2>/dev/null | command grep -i 'vga\|3d\|display' | head -1 | sed 's/.*: //'`); gpu != "" {
|
||||
hw.GPU = gpu
|
||||
}
|
||||
|
||||
return hw
|
||||
}
|
||||
|
||||
// --- PATH Scanning ---
|
||||
|
||||
func scanPATH() []string {
|
||||
seen := make(map[string]bool)
|
||||
var names []string
|
||||
|
||||
pathDirs := filepath.SplitList(os.Getenv("PATH"))
|
||||
for _, dir := range pathDirs {
|
||||
for _, dir := range filepath.SplitList(os.Getenv("PATH")) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
@@ -68,7 +239,6 @@ func scanPATH() []string {
|
||||
if seen[name] {
|
||||
continue
|
||||
}
|
||||
// Check it's actually executable
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
@@ -84,18 +254,13 @@ func scanPATH() []string {
|
||||
return names
|
||||
}
|
||||
|
||||
// runtimeCandidate defines how to detect a runtime from a binary name.
|
||||
type runtimeCandidate struct {
|
||||
name string // display name
|
||||
binary string // executable name to look for
|
||||
args []string // version flag
|
||||
}
|
||||
// --- Runtime Probing ---
|
||||
|
||||
// knownRuntimePatterns returns binary names that are likely runtimes.
|
||||
// We still need a pattern list to know WHICH binaries to try --version on,
|
||||
// but the actual detection is dynamic (only probes what's in PATH).
|
||||
var runtimePatterns = []runtimeCandidate{
|
||||
// Systems languages
|
||||
var runtimePatterns = []struct {
|
||||
name string
|
||||
binary string
|
||||
args []string
|
||||
}{
|
||||
{"go", "go", []string{"version"}},
|
||||
{"rust", "rustc", []string{"--version"}},
|
||||
{"zig", "zig", []string{"version"}},
|
||||
@@ -104,7 +269,6 @@ var runtimePatterns = []runtimeCandidate{
|
||||
{"gcc", "gcc", []string{"--version"}},
|
||||
{"clang", "clang", []string{"--version"}},
|
||||
{"nasm", "nasm", []string{"-v"}},
|
||||
// Scripting
|
||||
{"python3", "python3", []string{"--version"}},
|
||||
{"python2", "python2", []string{"--version"}},
|
||||
{"perl", "perl", []string{"--version"}},
|
||||
@@ -112,50 +276,33 @@ var runtimePatterns = []runtimeCandidate{
|
||||
{"lua", "lua", []string{"-v"}},
|
||||
{"luajit", "luajit", []string{"-v"}},
|
||||
{"guile", "guile", []string{"--version"}},
|
||||
// tcl detection is tricky (needs stdin), skipped
|
||||
{"php", "php", []string{"--version"}},
|
||||
{"r", "R", []string{"--version"}},
|
||||
// JS/TS
|
||||
{"node", "node", []string{"--version"}},
|
||||
{"deno", "deno", []string{"--version"}},
|
||||
{"bun", "bun", []string{"--version"}},
|
||||
// JVM
|
||||
{"java", "java", []string{"-version"}},
|
||||
{"kotlin", "kotlin", []string{"-version"}},
|
||||
{"scala", "scala", []string{"-version"}},
|
||||
{"groovy", "groovy", []string{"--version"}},
|
||||
{"clojure", "clj", []string{"--version"}},
|
||||
// Functional
|
||||
{"haskell", "ghc", []string{"--version"}},
|
||||
{"ocaml", "ocaml", []string{"-version"}},
|
||||
{"elixir", "elixir", []string{"--version"}},
|
||||
{"erlang", "erl", []string{"-eval", "io:format(erlang:system_info(otp_release)),halt()."}},
|
||||
{"racket", "racket", []string{"--version"}},
|
||||
// Other
|
||||
{"dart", "dart", []string{"--version"}},
|
||||
{"julia", "julia", []string{"--version"}},
|
||||
{"swift", "swift", []string{"--version"}},
|
||||
// .NET
|
||||
{"dotnet", "dotnet", []string{"--version"}},
|
||||
{"mono", "mono", []string{"--version"}},
|
||||
// Package managers
|
||||
{"cargo", "cargo", []string{"--version"}},
|
||||
{"npm", "npm", []string{"--version"}},
|
||||
{"yarn", "yarn", []string{"--version"}},
|
||||
{"pnpm", "pnpm", []string{"--version"}},
|
||||
{"pip", "pip3", []string{"--version"}},
|
||||
{"gem", "gem", []string{"--version"}},
|
||||
{"composer", "composer", []string{"--version"}},
|
||||
{"mix", "mix", []string{"--version"}},
|
||||
{"cabal", "cabal", []string{"--version"}},
|
||||
{"stack", "stack", []string{"--version"}},
|
||||
{"opam", "opam", []string{"--version"}},
|
||||
{"maven", "mvn", []string{"--version"}},
|
||||
{"gradle", "gradle", []string{"--version"}},
|
||||
}
|
||||
|
||||
// probeRuntimes checks which runtime candidates exist in the discovered binaries
|
||||
// and gets their version info. Runs probes concurrently for speed.
|
||||
func probeRuntimes(ctx context.Context, available []string) []Runtime {
|
||||
availSet := make(map[string]bool, len(available))
|
||||
for _, name := range available {
|
||||
@@ -165,53 +312,39 @@ func probeRuntimes(ctx context.Context, available []string) []Runtime {
|
||||
var mu sync.Mutex
|
||||
var runtimes []Runtime
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Semaphore to limit concurrent version probes
|
||||
sem := make(chan struct{}, 10)
|
||||
|
||||
for _, candidate := range runtimePatterns {
|
||||
if !availSet[candidate.binary] {
|
||||
for _, p := range runtimePatterns {
|
||||
if !availSet[p.binary] {
|
||||
continue
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(c runtimeCandidate) {
|
||||
go func(name, binary string, args []string) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
|
||||
version := probeVersion(ctx, c.binary, c.args)
|
||||
if version != "" {
|
||||
if v := probeVersion(ctx, binary, args); v != "" {
|
||||
mu.Lock()
|
||||
runtimes = append(runtimes, Runtime{Name: c.name, Version: version})
|
||||
runtimes = append(runtimes, Runtime{Name: name, Version: v})
|
||||
mu.Unlock()
|
||||
}
|
||||
}(candidate)
|
||||
}(p.name, p.binary, p.args)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
sort.Slice(runtimes, func(i, j int) bool {
|
||||
return runtimes[i].Name < runtimes[j].Name
|
||||
})
|
||||
sort.Slice(runtimes, func(i, j int) bool { return runtimes[i].Name < runtimes[j].Name })
|
||||
return runtimes
|
||||
}
|
||||
|
||||
// probeVersion runs a binary with version args and extracts the first line.
|
||||
func probeVersion(ctx context.Context, binary string, args []string) string {
|
||||
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, binary, args...)
|
||||
cmd.Stdin = nil
|
||||
|
||||
// Some tools print version to stderr (java -version)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil && len(output) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// First non-empty line
|
||||
for _, line := range strings.Split(string(output), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
@@ -221,89 +354,92 @@ func probeVersion(ctx context.Context, binary string, args []string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// String formats the inventory for inclusion in a system prompt.
|
||||
func (inv *SystemInventory) String() string {
|
||||
if inv == nil {
|
||||
return ""
|
||||
// --- Package Manager ---
|
||||
|
||||
func queryPackageManager(ctx context.Context) (mgr string, total int, devPkgs []string) {
|
||||
managers := []struct {
|
||||
name, binary string
|
||||
args []string
|
||||
}{
|
||||
{"pacman", "pacman", []string{"-Qq"}},
|
||||
{"apt", "dpkg", []string{"--get-selections"}},
|
||||
{"dnf", "rpm", []string{"-qa", "--qf", "%{NAME}\\n"}},
|
||||
{"brew", "brew", []string{"list", "--formula", "-1"}},
|
||||
{"nix", "nix-env", []string{"-q"}},
|
||||
{"apk", "apk", []string{"list", "--installed", "-q"}},
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("System environment:\n")
|
||||
|
||||
if inv.OS != "" {
|
||||
fmt.Fprintf(&b, "- OS: %s\n", inv.OS)
|
||||
}
|
||||
if inv.Shell != "" {
|
||||
fmt.Fprintf(&b, "- Shell: %s\n", inv.Shell)
|
||||
}
|
||||
if len(inv.Runtimes) > 0 {
|
||||
b.WriteString("- Runtimes & package managers:\n")
|
||||
for _, rt := range inv.Runtimes {
|
||||
fmt.Fprintf(&b, " - %s: %s\n", rt.Name, rt.Version)
|
||||
for _, pm := range managers {
|
||||
if _, err := exec.LookPath(pm.binary); err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if len(inv.Tools) > 0 {
|
||||
fmt.Fprintf(&b, "- Available commands: %d executables in PATH", len(inv.Tools))
|
||||
// Include notable tools only (not the full list — saves tokens)
|
||||
notable := filterNotable(inv.Tools)
|
||||
if len(notable) > 0 {
|
||||
fmt.Fprintf(&b, " (notable: %s)", strings.Join(notable, ", "))
|
||||
cmd := exec.CommandContext(ctx, pm.binary, pm.args...)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
b.WriteString("\n")
|
||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
return pm.name, len(lines), filterDevPackages(lines)
|
||||
}
|
||||
|
||||
return b.String()
|
||||
return "", 0, nil
|
||||
}
|
||||
|
||||
// notableTools are development-relevant tools worth highlighting in the system prompt.
|
||||
var notableTools = map[string]bool{
|
||||
// VCS
|
||||
"git": true, "gh": true, "tea": true, "lazygit": true,
|
||||
// Build
|
||||
"make": true, "cmake": true, "just": true,
|
||||
// Containers
|
||||
"docker": true, "podman": true, "kubectl": true, "helm": true,
|
||||
// Data
|
||||
"jq": true, "yq": true, "rg": true, "fd": true, "fzf": true,
|
||||
// Modern CLI
|
||||
"bat": true, "eza": true, "delta": true, "sd": true, "dust": true,
|
||||
"hyperfine": true, "tokei": true, "scc": true,
|
||||
// Debug
|
||||
"gdb": true, "strace": true, "perf": true, "valgrind": true,
|
||||
// Database
|
||||
"sqlite3": true, "psql": true, "mysql": true, "mongosh": true, "redis-cli": true,
|
||||
// Media
|
||||
"ffmpeg": true, "convert": true,
|
||||
// Infra
|
||||
"terraform": true, "ansible": true,
|
||||
// Network
|
||||
"curl": true, "wget": true, "ssh": true,
|
||||
// Editors
|
||||
"nvim": true, "vim": true,
|
||||
// Multiplexer
|
||||
"tmux": true,
|
||||
var devPrefixes = []string{
|
||||
"python", "ruby", "nodejs", "go", "rustup", "lua", "luajit",
|
||||
"perl", "dart", "deno", "bun", "zig", "nim", "crystal",
|
||||
"elixir", "erlang", "ghc", "ocaml", "swift", "kotlin", "scala",
|
||||
"julia", "php", "dotnet", "mono", "clojure", "racket", "guile",
|
||||
"openjdk", "jdk", "gcc", "clang", "llvm", "cmake", "make",
|
||||
"ninja", "meson", "nasm", "gdb", "valgrind", "strace",
|
||||
"docker", "podman", "kubectl", "helm", "terraform", "ansible",
|
||||
"npm", "yarn", "pnpm", "cargo", "rubygems", "composer",
|
||||
"sqlite", "postgresql", "mysql", "redis", "mongodb",
|
||||
"git", "mercurial", "subversion", "neovim", "vim", "emacs",
|
||||
}
|
||||
|
||||
func filterNotable(tools []string) []string {
|
||||
var out []string
|
||||
for _, t := range tools {
|
||||
if notableTools[t] {
|
||||
out = append(out, t)
|
||||
func filterDevPackages(packages []string) []string {
|
||||
var devPkgs []string
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, pkg := range packages {
|
||||
pkg = strings.TrimSpace(pkg)
|
||||
if f := strings.Fields(pkg); len(f) > 0 {
|
||||
pkg = f[0]
|
||||
}
|
||||
if pkg == "" || seen[pkg] {
|
||||
continue
|
||||
}
|
||||
lower := strings.ToLower(pkg)
|
||||
|
||||
// Skip sub-packages
|
||||
if strings.HasPrefix(lower, "python-") || strings.HasPrefix(lower, "haskell-") ||
|
||||
strings.HasPrefix(lower, "perl-") || strings.HasPrefix(lower, "ruby-") ||
|
||||
strings.HasPrefix(lower, "lua-") || strings.HasPrefix(lower, "lua51-") ||
|
||||
strings.HasPrefix(lower, "lua54-") || strings.HasPrefix(lower, "lib32-") ||
|
||||
strings.HasPrefix(lower, "lib") || strings.HasPrefix(lower, "ttf-") ||
|
||||
strings.HasPrefix(lower, "otf-") || strings.HasPrefix(lower, "qt5-") ||
|
||||
strings.HasPrefix(lower, "qt6-") || strings.HasPrefix(lower, "gtk") ||
|
||||
strings.HasPrefix(lower, "gst-") {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, prefix := range devPrefixes {
|
||||
if lower == prefix || strings.HasPrefix(lower, prefix+"-") ||
|
||||
strings.HasPrefix(lower, prefix+"1") || strings.HasPrefix(lower, prefix+"2") ||
|
||||
strings.HasPrefix(lower, prefix+"3") {
|
||||
seen[pkg] = true
|
||||
devPkgs = append(devPkgs, pkg)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
sort.Strings(devPkgs)
|
||||
return devPkgs
|
||||
}
|
||||
|
||||
func detectOS(ctx context.Context) string {
|
||||
if output := runQuiet(ctx, "uname", "-srm"); output != "" {
|
||||
return strings.TrimSpace(output)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func detectShell() string {
|
||||
if s := os.Getenv("SHELL"); s != "" {
|
||||
return s
|
||||
if out := runQuiet(ctx, "uname", "-srm"); out != "" {
|
||||
return strings.TrimSpace(out)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -12,18 +12,6 @@ func TestScanPATH(t *testing.T) {
|
||||
t.Fatal("should find at least some executables in PATH")
|
||||
}
|
||||
t.Logf("Found %d executables in PATH", len(binaries))
|
||||
|
||||
// Basic sanity — ls should be in PATH
|
||||
hasLS := false
|
||||
for _, b := range binaries {
|
||||
if b == "ls" {
|
||||
hasLS = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasLS {
|
||||
t.Error("ls should be in PATH")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHarvestInventory(t *testing.T) {
|
||||
@@ -33,20 +21,23 @@ func TestHarvestInventory(t *testing.T) {
|
||||
t.Error("OS should be detected")
|
||||
}
|
||||
t.Logf("OS: %s", inv.OS)
|
||||
|
||||
if inv.Shell == "" {
|
||||
t.Error("Shell should be detected")
|
||||
}
|
||||
t.Logf("Shell: %s", inv.Shell)
|
||||
t.Logf("CPU: %s (%d cores)", inv.Hardware.CPU, inv.Hardware.Cores)
|
||||
t.Logf("Memory: %s", inv.Hardware.MemTotal)
|
||||
t.Logf("GPU: %s", inv.Hardware.GPU)
|
||||
t.Logf("Tools: %d in PATH", len(inv.Tools))
|
||||
|
||||
t.Logf("Tools: %d executables in PATH", len(inv.Tools))
|
||||
if inv.PackageMgr != "" {
|
||||
t.Logf("Packages: %d (%s)", inv.PackageCount, inv.PackageMgr)
|
||||
t.Logf("Dev packages: %d", len(inv.DevPackages))
|
||||
}
|
||||
|
||||
t.Logf("Runtimes (%d):", len(inv.Runtimes))
|
||||
t.Logf("Runtimes: %d", len(inv.Runtimes))
|
||||
for _, rt := range inv.Runtimes {
|
||||
t.Logf(" %s: %s", rt.Name, rt.Version)
|
||||
}
|
||||
|
||||
// Should find at least Go (we're running Go tests)
|
||||
// Should detect Go (we're running Go tests)
|
||||
hasGo := false
|
||||
for _, rt := range inv.Runtimes {
|
||||
if rt.Name == "go" {
|
||||
@@ -55,39 +46,94 @@ func TestHarvestInventory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
if !hasGo {
|
||||
t.Error("should detect Go runtime (we're running Go tests)")
|
||||
t.Error("should detect Go runtime")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInventory_String(t *testing.T) {
|
||||
func TestInventory_Summary(t *testing.T) {
|
||||
inv := &SystemInventory{
|
||||
OS: "Linux 6.18 x86_64",
|
||||
Shell: "/usr/bin/zsh",
|
||||
Tools: []string{"git", "make", "docker"},
|
||||
Runtimes: []Runtime{
|
||||
{"go", "go version go1.26.1"},
|
||||
{"python3", "Python 3.14.3"},
|
||||
Hardware: HardwareInfo{
|
||||
CPU: "AMD Ryzen 9", Cores: 16,
|
||||
MemTotal: "32 GB", GPU: "NVIDIA RTX 4090",
|
||||
},
|
||||
Tools: make([]string, 100),
|
||||
Runtimes: []Runtime{{"go", "1.26"}, {"python3", "3.14"}, {"node", "25.8"}},
|
||||
PackageCount: 2239,
|
||||
PackageMgr: "pacman",
|
||||
}
|
||||
|
||||
s := inv.String()
|
||||
if !strings.Contains(s, "Linux") {
|
||||
s := inv.Summary()
|
||||
if !strings.Contains(s, "Linux 6.18") {
|
||||
t.Error("should contain OS")
|
||||
}
|
||||
if !strings.Contains(s, "git") {
|
||||
t.Error("should contain tools")
|
||||
if !strings.Contains(s, "zsh") {
|
||||
t.Error("should contain shell")
|
||||
}
|
||||
if !strings.Contains(s, "go:") {
|
||||
t.Error("should contain runtimes")
|
||||
if !strings.Contains(s, "AMD Ryzen") {
|
||||
t.Error("should contain CPU")
|
||||
}
|
||||
if !strings.Contains(s, "3 executables in PATH") {
|
||||
t.Errorf("should show tool count, got: %s", s)
|
||||
if !strings.Contains(s, "32 GB") {
|
||||
t.Error("should contain RAM")
|
||||
}
|
||||
if !strings.Contains(s, "RTX 4090") {
|
||||
t.Error("should contain GPU")
|
||||
}
|
||||
if !strings.Contains(s, "go, python3, node") {
|
||||
t.Error("should contain top runtimes")
|
||||
}
|
||||
if !strings.Contains(s, "2239 packages") {
|
||||
t.Error("should contain package count")
|
||||
}
|
||||
if !strings.Contains(s, "system_info") {
|
||||
t.Error("should mention system_info tool")
|
||||
}
|
||||
// Should be compact — under 300 chars
|
||||
if len(s) > 500 {
|
||||
t.Errorf("summary too long (%d chars), should be compact: %s", len(s), s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInventory_NilString(t *testing.T) {
|
||||
var inv *SystemInventory
|
||||
if inv.String() != "" {
|
||||
t.Error("nil inventory should return empty string")
|
||||
func TestInventory_QuerySection(t *testing.T) {
|
||||
inv := &SystemInventory{
|
||||
OS: "Linux",
|
||||
Shell: "/bin/zsh",
|
||||
Hardware: HardwareInfo{CPU: "Test CPU", Cores: 4, MemTotal: "16 GB"},
|
||||
Tools: []string{"git", "make"},
|
||||
Runtimes: []Runtime{{"go", "1.26"}, {"rust", "1.91"}},
|
||||
DevPackages: []string{"docker", "kubectl"},
|
||||
PackageMgr: "pacman", PackageCount: 100,
|
||||
}
|
||||
|
||||
// Runtimes
|
||||
r := inv.QuerySection("runtimes")
|
||||
if !strings.Contains(r, "go: 1.26") {
|
||||
t.Errorf("runtimes section should contain go: %s", r)
|
||||
}
|
||||
|
||||
// Hardware
|
||||
h := inv.QuerySection("hardware")
|
||||
if !strings.Contains(h, "Test CPU") {
|
||||
t.Errorf("hardware section should contain CPU: %s", h)
|
||||
}
|
||||
|
||||
// Tools
|
||||
tools := inv.QuerySection("tools")
|
||||
if !strings.Contains(tools, "git") {
|
||||
t.Errorf("tools section should contain git: %s", tools)
|
||||
}
|
||||
|
||||
// Unknown
|
||||
u := inv.QuerySection("bogus")
|
||||
if !strings.Contains(u, "unknown section") {
|
||||
t.Errorf("unknown section should error: %s", u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInventory_NilSummary(t *testing.T) {
|
||||
var inv *SystemInventory
|
||||
if inv.Summary() != "" {
|
||||
t.Error("nil inventory should return empty summary")
|
||||
}
|
||||
}
|
||||
|
||||
50
internal/tool/sysinfo/sysinfo.go
Normal file
50
internal/tool/sysinfo/sysinfo.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package sysinfo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"somegit.dev/Owlibou/gnoma/internal/tool"
|
||||
"somegit.dev/Owlibou/gnoma/internal/tool/bash"
|
||||
)
|
||||
|
||||
var paramSchema = json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"section": {
|
||||
"type": "string",
|
||||
"description": "Section to query: runtimes, packages, tools, hardware, all",
|
||||
"enum": ["runtimes", "packages", "tools", "hardware", "all"]
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
// Tool provides queryable access to system inventory.
|
||||
type Tool struct {
|
||||
inventory *bash.SystemInventory
|
||||
}
|
||||
|
||||
func New(inv *bash.SystemInventory) *Tool {
|
||||
return &Tool{inventory: inv}
|
||||
}
|
||||
|
||||
func (t *Tool) Name() string { return "system_info" }
|
||||
func (t *Tool) Description() string { return "Query system information: installed runtimes, packages, tools, hardware" }
|
||||
func (t *Tool) Parameters() json.RawMessage { return paramSchema }
|
||||
func (t *Tool) IsReadOnly() bool { return true }
|
||||
func (t *Tool) IsDestructive() bool { return false }
|
||||
|
||||
type sysInfoArgs struct {
|
||||
Section string `json:"section,omitempty"`
|
||||
}
|
||||
|
||||
func (t *Tool) Execute(_ context.Context, args json.RawMessage) (tool.Result, error) {
|
||||
var a sysInfoArgs
|
||||
if args != nil {
|
||||
_ = json.Unmarshal(args, &a)
|
||||
}
|
||||
if a.Section == "" {
|
||||
a.Section = "all"
|
||||
}
|
||||
return tool.Result{Output: t.inventory.QuerySection(a.Section)}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user