From be10e336278f76099c7c152fa613f44cbee3316c Mon Sep 17 00:00:00 2001 From: Ole Valente Date: Fri, 10 Jul 2026 20:39:42 +0200 Subject: [PATCH] initial commit --- .gitignore | 1 + README.md | 56 ++++++++++ ask.1 | 93 ++++++++++++++++ go.mod | 3 + main.go | 311 ++++++++++++++++++++++++++++++++++++++++++++++++++++ test_ask.sh | 154 ++++++++++++++++++++++++++ 6 files changed, 618 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 ask.1 create mode 100644 go.mod create mode 100644 main.go create mode 100644 test_ask.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1817255 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +ask diff --git a/README.md b/README.md new file mode 100644 index 0000000..821e90f --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# ask + +A CLI tool that answers command-line questions using an OpenAI-compatible LLM API. + +## Requirements + +- **Go** 1.26+ (to build) +- Runtime tools: `tldr`, `man` / `col -b`, `glow`, `less` + +## Installation + +```bash +git clone && cd ask +go build -o ask . +``` + +Or place the binary somewhere on your `PATH`. + +## Usage + +``` +ask [options] +``` + +| Flag | Mode | Description | +|------|------|-------------| +| | default | Generates a runnable shell command (uses TLDR + man page context) | +| `-q` | question | Answers general command-line questions (rendered with glow) | +| `-H` | history | Answers questions about your shell history (last 200 lines) | +| `-p` | paginate | Pipes output through `less -R` | +| `-h` | help | Prints help text | + +### Examples + +```bash +ask how to find large files +ask -q how does rsync work +ask -H what command did I use for docker yesterday +ask -p how to compress a directory +``` + +## Configuration + +Environment variables: + +| Variable | Default | Description | +|---|---|---| +| `OPENAI_BASE_URL` | `http://10.0.2.145:8090/v1` | API endpoint | +| `OPENAI_API_KEY` | — | API key (optional) | +| `OPENAI_MODEL` | `gpt-4o-mini` | Model name | + +## Tests + +```bash +./test_ask.sh +``` diff --git a/ask.1 b/ask.1 new file mode 100644 index 0000000..84668bc --- /dev/null +++ b/ask.1 @@ -0,0 +1,93 @@ +.TH ASK 1 "July 2026" "ask" "User Commands" +.SH NAME +ask \- answer command-line questions using an LLM +.SH SYNOPSIS +.B ask +[OPTION...] \fIquestion\fR +.SH DESCRIPTION +.B ask +consults an OpenAI-compatible LLM backend to answer questions about +Unix/Linux command-line tools. It has three operating modes. +.PP +In the default (command) mode, +.B ask +identifies the relevant tool, fetches its TLDR page and man page for +context, and returns a single runnable command line with placeholders +for arguments you need to customize. +.PP +In question mode (\fB\-q\fR), it answers general command-line questions +and renders the response in markdown through +.BR glow (1). +.PP +In history mode (\fB\-H\fR), it reads the last 200 lines of your shell +history (\fI~/.zsh_history\fR, \fI~/.bash_history\fR, or +\fI~/.history\fR) and answers questions about commands you have run. +.SH OPTIONS +.TP +.B \-h +Display help text and exit. +.TP +.B \-q +Question mode. Answer general command-line questions with markdown +output rendered through +.BR glow (1). +.TP +.B \-H +History mode. Answer questions about your shell history. +.TP +.B \-p +Paginate output through +.BR less (1) +with raw character support (\fB\-R\fR). +.SH ENVIRONMENT +.TP +.B OPENAI_BASE_URL +API endpoint URL. +Default: \fBhttp://10.0.2.145:8090/v1\fR +.TP +.B OPENAI_API_KEY +API key to include as a Bearer token in requests. +Optional, depending on the server. +.TP +.B OPENAI_MODEL +Model identifier to use. +Default: \fBgpt-4o-mini\fR +.SH EXAMPLES +.TP +Generate a command to find large files: +.B ask how to find large files +.TP +Explain how rsync works (question mode): +.B ask \-q how does rsync work +.TP +Check recent Docker usage (history mode): +.B ask \-H what command did I use for docker yesterday +.TP +Paginate output with less: +.B ask \-p how to compress a directory +.SH EXIT STATUS +.TP +0 +Successful execution. +.TP +1 +No question provided, or an API error occurred. +.SH FILES +.TP +.I ~/.zsh_history +.TP +.I ~/.bash_history +.TP +.I ~/.history +Shell history files consulted in \fB\-H\fR mode. +.SH DEPENDENCIES +The following tools are expected at runtime: +.BR tldr (1), +.BR man (1), +.BR col (1), +.BR glow (1), +.BR less (1). +.SH BUGS +Report issues at \fIhttps://github.com/anomalyco/opencode/issues\fR. +.SH AUTHORS +Ole diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1c7275f --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module ask + +go 1.26.2 diff --git a/main.go b/main.go new file mode 100644 index 0000000..9bdc91e --- /dev/null +++ b/main.go @@ -0,0 +1,311 @@ +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" +) + +const defaultBaseURL = "http://10.0.2.145:8090/v1" + +type chatMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type chatRequest struct { + Model string `json:"model"` + Messages []chatMessage `json:"messages"` +} + +type chatResponse struct { + Choices []struct { + Message chatMessage `json:"message"` + } `json:"choices"` +} + +func main() { + help := flag.Bool("h", false, "Show help") + history := flag.Bool("H", false, "Answer questions about shell history") + question := flag.Bool("q", false, "Answer general questions, render with glow") + paginate := flag.Bool("p", false, "Paginate output using less") + flag.Parse() + + if *help { + printHelp() + return + } + + args := flag.Args() + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "Error: no question provided") + os.Exit(1) + } + query := strings.Join(args, " ") + + if *history { + handleHistory(query, *paginate) + return + } + + if *question { + handleQuestion(query, *paginate) + return + } + + handleCommand(query, *paginate) +} + +func printHelp() { + fmt.Println(`Usage: ask [options] + +Options: + -h Show this help + -H Answer questions about your shell history + -q Answer general command questions (output rendered with glow) + -p Paginate output using less + +Examples: + ask how to find large files + ask -H what command did I use for docker yesterday + ask -q how does rsync work + ask -p how to compress a directory`) +} + +func getBaseURL() string { + if v := os.Getenv("OPENAI_BASE_URL"); v != "" { + return strings.TrimRight(v, "/") + } + return defaultBaseURL +} + +func getAPIKey() string { + return os.Getenv("OPENAI_API_KEY") +} + +func getModel() string { + if v := os.Getenv("OPENAI_MODEL"); v != "" { + return v + } + return "gpt-4o-mini" +} + +func chat(messages []chatMessage) (string, error) { + reqBody := chatRequest{ + Model: getModel(), + Messages: messages, + } + + body, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("marshal request: %w", err) + } + + url := getBaseURL() + "/chat/completions" + req, err := http.NewRequest("POST", url, bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + if key := getAPIKey(); key != "" { + req.Header.Set("Authorization", "Bearer "+key) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("http request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != 200 { + return "", fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody)) + } + + var cr chatResponse + if err := json.Unmarshal(respBody, &cr); err != nil { + return "", fmt.Errorf("parse response: %w", err) + } + + if len(cr.Choices) == 0 { + return "", fmt.Errorf("no choices in response") + } + + return cr.Choices[0].Message.Content, nil +} + +func runCmd(name string, arg ...string) (string, error) { + var out, stderr bytes.Buffer + cmd := exec.Command(name, arg...) + cmd.Stdout = &out + cmd.Stderr = &stderr + err := cmd.Run() + if err != nil { + return strings.TrimSpace(out.String()), fmt.Errorf("%s: %w\n%s", name, err, strings.TrimSpace(stderr.String())) + } + return strings.TrimSpace(out.String()), nil +} + +func getTLDR(tool string) string { + out, err := runCmd("tldr", tool) + if err != nil { + return "" + } + return out +} + +func getManPage(tool string) string { + // man pages can be long; limit via col -b for cleaner text + cmd := exec.Command("sh", "-c", fmt.Sprintf("man %s 2>/dev/null | col -b", tool)) + var out bytes.Buffer + cmd.Stdout = &out + _ = cmd.Run() + result := strings.TrimSpace(out.String()) + if result == "" { + return "" + } + if len(result) > 6000 { + result = result[:6000] + "\n... (truncated)" + } + return result +} + +func getShellHistory() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + + candidates := []string{ + filepath.Join(home, ".zsh_history"), + filepath.Join(home, ".bash_history"), + filepath.Join(home, ".history"), + } + + for _, p := range candidates { + data, err := os.ReadFile(p) + if err == nil && len(data) > 0 { + lines := strings.Split(string(data), "\n") + if len(lines) > 200 { + lines = lines[len(lines)-200:] + } + return strings.Join(lines, "\n") + } + } + return "" +} + +func writeOutput(text string, paginate bool) { + if paginate { + cmd := exec.Command("less", "-R") + cmd.Stdin = strings.NewReader(text) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + _ = cmd.Run() + } else { + fmt.Print(text) + } +} + +func handleCommand(query string, paginate bool) { + messages := []chatMessage{ + {Role: "system", Content: "You identify Unix/Linux command-line tools from user questions. Respond with ONLY the tool name, nothing else. Example: 'how to find large files' -> 'find'. 'how to compress a directory' -> 'tar'. If no single tool fits, respond with 'general'."}, + {Role: "user", Content: query}, + } + + tool, err := chat(messages) + if err != nil { + fmt.Fprintf(os.Stderr, "Error identifying tool: %v\n", err) + os.Exit(1) + } + tool = strings.TrimSpace(strings.ToLower(tool)) + + var contextParts []string + if tool != "general" && tool != "" { + if t := getTLDR(tool); t != "" { + contextParts = append(contextParts, "=== TLDR ===\n"+t) + } + if m := getManPage(tool); m != "" { + contextParts = append(contextParts, "=== MAN PAGE ===\n"+m) + } + } + context := strings.Join(contextParts, "\n\n") + + systemPrompt := "You are a command-line expert. Given a user's question, provide the exact command line they need.\n" + if context != "" { + systemPrompt += "Use the context below for reference.\n\n" + context + } + systemPrompt += "\n\nIMPORTANT: Provide ONLY the command line, nothing else. Use placeholders like /path/to/dir or filename for things the user needs to customize. The command should be directly runnable after replacing placeholders. Do NOT include any explanation, markdown formatting, or backticks." + + cmdMessages := []chatMessage{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: query}, + } + + result, err := chat(cmdMessages) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + writeOutput(strings.TrimSpace(result)+"\n", paginate) +} + +func handleHistory(query string, paginate bool) { + history := getShellHistory() + if history == "" { + fmt.Fprintln(os.Stderr, "Error: no shell history found") + os.Exit(1) + } + + messages := []chatMessage{ + {Role: "system", Content: fmt.Sprintf("You are analyzing a user's shell history. Answer their question about their command history based on the data below. Be specific and reference actual commands from their history.\n\nShell history (last 500 lines):\n%s", history)}, + {Role: "user", Content: query}, + } + + result, err := chat(messages) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + writeOutput(strings.TrimSpace(result)+"\n", paginate) +} + +func handleQuestion(query string, paginate bool) { + messages := []chatMessage{ + {Role: "system", Content: "You are a command-line expert. Answer the user's question about commands and tools. Provide a terse, informative response in markdown format. Include specific command examples where relevant. Keep it concise but thorough."}, + {Role: "user", Content: query}, + } + + result, err := chat(messages) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + if paginate { + cmd := exec.Command("less", "-R") + cmd.Stdin = strings.NewReader(result) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + _ = cmd.Run() + } else { + cmd := exec.Command("glow") + cmd.Stdin = strings.NewReader(result) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + _ = cmd.Run() + } +} diff --git a/test_ask.sh b/test_ask.sh new file mode 100644 index 0000000..057b6a4 --- /dev/null +++ b/test_ask.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +ASK="${ASK:-$(dirname "$0")/ask}" +PASS=0 +FAIL=0 +RESULTS=() +OUTFILE=$(mktemp /tmp/ask_test_out.XXXXXX) +ERRFILE=$(mktemp /tmp/ask_test_err.XXXXXX) + +cleanup() { + rm -f "$OUTFILE" "$ERRFILE" + rm -f /tmp/ask_test_*.out /tmp/ask_test_*.err /tmp/ask_test_*.tmp /tmp/ask_test_*.tmp2 +} + +err() { echo " FAIL: $*" >&2; ((FAIL++)); } +ok() { echo " PASS"; ((PASS++)); } + +run_test() { + local label=$1 expected_exit=$2 + shift 2 + > "$OUTFILE" > "$ERRFILE" + set +e + "$ASK" "$@" >"$OUTFILE" 2>"$ERRFILE" + local ec=$? + set -e + if [[ "$ec" -ne "$expected_exit" ]]; then + err "$label: expected exit $expected_exit, got $ec" + echo " stderr: $(head -c 200 "$ERRFILE" 2>/dev/null)" + return 1 + fi + ok + RESULTS+=("$label: PASS") + return 0 +} + +check_stdout_not_empty() { + local label=$1 + if [[ ! -s "$OUTFILE" ]]; then + err "$label: stdout is empty" + return 1 + fi + return 0 +} + +check_stdout_contains() { + local label=$1 pattern=$2 + if ! grep -q "$pattern" "$OUTFILE" 2>/dev/null; then + err "$label: stdout missing '$pattern'" + echo " stdout: $(head -c 200 "$OUTFILE")" + return 1 + fi + return 0 +} + +check_stderr_contains() { + local label=$1 pattern=$2 + if ! grep -q "$pattern" "$ERRFILE" 2>/dev/null; then + err "$label: stderr missing '$pattern'" + echo " stderr: $(head -c 200 "$ERRFILE")" + return 1 + fi + return 0 +} + +check_exit_ok() { + local label=$1 + if ! run_test "$label" 0 "${@:2}"; then + return 1 + fi + return 0 +} + +echo "=== Test suite: ask CLI ===" +echo "" + +# --- test 1: help output --- +echo "1) -h flag prints help" +if run_test "1) -h" 0 -h; then + check_stdout_contains "1) -h" "Usage: ask" +fi + +# --- test 2: no arguments exits 1 --- +echo "2) no arguments exits with error" +if run_test "2) no args" 1; then + check_stderr_contains "2) no args" "no question" +fi + +# --- test 3: default mode — simple command query --- +echo "3) default mode: list files by size" +if run_test "3) default" 0 how to list files by size; then + check_stdout_not_empty "3) default" +fi + +# --- test 4: default mode — different tool --- +echo "4) default mode: kill process by name" +if run_test "4) default" 0 how to kill a process by name; then + check_stdout_not_empty "4) default" +fi + +# --- test 5: -q flag (general question) --- +echo "5) -q mode: explain grep" +if run_test "5) -q" 0 -q explain grep; then + check_stdout_not_empty "5) -q" +fi + +# --- test 6: -H flag (history query) --- +echo "6) -H mode: ask about docker usage" +if run_test "6) -H" 0 -H "did I use docker recently"; then + check_stdout_not_empty "6) -H" +fi + +# --- test 7: -p flag paginates via less --- +echo "7) -p flag paginates via less" +# non-interactive: timeout after 2s, less exits 0 on normal, 141 on SIGPIPE, 124 on timeout +set +e +timeout 2 "$ASK" -p how to find large files >/dev/null 2>&1 +ec=$? +set -e +if [[ "$ec" -eq 0 || "$ec" -eq 141 || "$ec" -eq 124 ]]; then + ok + RESULTS+=("7) -p paginate: PASS") +else + err "7) -p: unexpected exit $ec" +fi + +# --- test 8: -q with -p combined --- +echo "8) -q -p combined" +set +e +timeout 2 "$ASK" -q -p "what is awk" >/dev/null 2>&1 +ec=$? +set -e +if [[ "$ec" -eq 0 || "$ec" -eq 141 || "$ec" -eq 124 ]]; then + ok + RESULTS+=("8) -q -p: PASS") +else + err "8) -q -p: unexpected exit $ec" +fi + +# --- test 9: query about a less common tool --- +echo "9) default mode: monitor disk IO" +if run_test "9) default" 0 how to monitor disk IO; then + check_stdout_not_empty "9) default" +fi + +# --- test 10: -H with a different question --- +echo "10) -H mode: what commands today" +if run_test "10) -H" 0 -H "what commands did I run today"; then + check_stdout_not_empty "10) -H" +fi + +cleanup + +echo "" +echo "=== Results: $PASS pass, $FAIL fail ===" +exit $FAIL