From 4aa8779f030abbfd4fa5a8797bb30ed11af41548 Mon Sep 17 00:00:00 2001 From: Maximilian Noller Date: Fri, 16 Jan 2026 18:16:04 +0100 Subject: [PATCH 1/2] ci: add release automation and easy installation --- .github/workflows/build-release.yml | 59 +++++++++++ .github/workflows/ci.yml | 44 +++++++++ .github/workflows/commitlint.yml | 26 +++++ .github/workflows/release-please.yml | 18 ++++ .release-please-manifest.json | 3 + Cargo.toml | 4 + README.md | 29 ++++-- commitlint.config.js | 24 +++++ install.sh | 141 +++++++++++++++++++++++++++ release-please-config.json | 10 ++ 10 files changed, 352 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/build-release.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/commitlint.yml create mode 100644 .github/workflows/release-please.yml create mode 100644 .release-please-manifest.json create mode 100644 commitlint.config.js create mode 100644 install.sh create mode 100644 release-please-config.json diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml new file mode 100644 index 0000000..62905aa --- /dev/null +++ b/.github/workflows/build-release.yml @@ -0,0 +1,59 @@ +name: Build Release + +on: + release: + types: [created] + +permissions: + contents: write + +jobs: + build: + strategy: + matrix: + include: + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + name: nanocode-linux-x86_64 + - target: aarch64-unknown-linux-gnu + os: ubuntu-latest + name: nanocode-linux-aarch64 + - target: aarch64-apple-darwin + os: macos-latest + name: nanocode-darwin-aarch64 + + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Install dependencies (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libssl-dev pkg-config + if [ "${{ matrix.target }}" = "aarch64-unknown-linux-gnu" ]; then + sudo apt-get install -y gcc-aarch64-linux-gnu + fi + + - name: Build + run: | + if [ "${{ matrix.target }}" = "aarch64-unknown-linux-gnu" ]; then + export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc + fi + cargo build --release --target ${{ matrix.target }} + + - name: Package + run: | + cd target/${{ matrix.target }}/release + tar -czvf ../../../${{ matrix.name }}.tar.gz nanocode + cd ../../.. + + - name: Upload Release Asset + uses: softprops/action-gh-release@v1 + with: + files: ${{ matrix.name }}.tar.gz diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e65cb3b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: Check formatting + run: cargo fmt -- --check + + - name: Clippy + run: cargo clippy -- -D warnings + + - name: Build + run: cargo build --verbose + + - name: Run tests + run: cargo test --verbose diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml new file mode 100644 index 0000000..70b4ea4 --- /dev/null +++ b/.github/workflows/commitlint.yml @@ -0,0 +1,26 @@ +name: Commitlint + +on: + pull_request: + types: [opened, synchronize, reopened, edited] + +jobs: + commitlint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install commitlint + run: npm install --save-dev @commitlint/cli @commitlint/config-conventional + + - name: Validate PR title + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: echo "$PR_TITLE" | npx commitlint diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..1147603 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,18 @@ +name: Release Please + +on: + push: + branches: + - main + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + steps: + - uses: googleapis/release-please-action@v4 + with: + release-type: rust diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..466df71 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.1.0" +} diff --git a/Cargo.toml b/Cargo.toml index 1b8a03f..680e5f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,9 @@ name = "nanocode" version = "0.1.0" edition = "2021" +description = "A minimal AI-powered coding CLI assistant" +license = "MIT" +repository = "https://github.com/HybridAIOne/nano" [dependencies] colored = "2.1.0" @@ -10,3 +13,4 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" glob = "0.3" regex = "1.10" +openssl = { version = "0.10", features = ["vendored"] } diff --git a/README.md b/README.md index 83b4345..f7b1ea4 100644 --- a/README.md +++ b/README.md @@ -16,22 +16,39 @@ A minimal AI-powered coding CLI assistant written in Rust. Nanocode lets you int - **Color-Coded Output** - Easy-to-read terminal interface - **Safety Guards** - Limits tool iterations to prevent runaway executions +## Supported Platforms + +- Linux x86_64 +- Linux aarch64 +- macOS aarch64 (Apple Silicon) + +Intel Mac users: the installer will build from source automatically. + ## Prerequisites -- Rust and Cargo (install from [rustup.rs](https://rustup.rs)) - An OpenRouter API key or a HybridAI API key (HybridAI takes precedence if both are set) ## Installation -1. Clone this repository: +### Quick Install (Linux/macOS) + +```bash +curl -fsSL https://raw.githubusercontent.com/HybridAIOne/nano/main/install.sh | bash +``` + +This downloads a pre-built binary or builds from source if needed. Installs to `~/.local/bin/` by default. + +### Using Cargo + ```bash -git clone -cd nanocode +cargo install --git https://github.com/HybridAIOne/nano ``` -2. Build the project: +### From Source + ```bash -cargo build --release +git clone https://github.com/HybridAIOne/nano && cd nano +cargo install --path . ``` ## Configuration diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 0000000..3580460 --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1,24 @@ +module.exports = { + extends: ['@commitlint/config-conventional'], + rules: { + 'type-enum': [ + 2, + 'always', + [ + 'feat', + 'fix', + 'docs', + 'style', + 'refactor', + 'perf', + 'test', + 'build', + 'ci', + 'chore', + 'revert', + ], + ], + 'subject-case': [2, 'always', 'lower-case'], + 'header-max-length': [2, 'always', 100], + }, +}; diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..580bac0 --- /dev/null +++ b/install.sh @@ -0,0 +1,141 @@ +#!/bin/bash +set -e + +# Nanocode Installer +# Usage: curl -fsSL https://raw.githubusercontent.com/HybridAIOne/nano/main/install.sh | bash + +REPO="HybridAIOne/nano" +BINARY_NAME="nanocode" +INSTALL_DIR="${NANOCODE_INSTALL_DIR:-$HOME/.local/bin}" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +info() { echo -e "${GREEN}[INFO]${NC} $1"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; } + +# Detect OS and architecture +detect_platform() { + local os arch + + case "$(uname -s)" in + Linux*) os="linux" ;; + Darwin*) os="darwin" ;; + *) error "Unsupported OS: $(uname -s)" ;; + esac + + case "$(uname -m)" in + x86_64|amd64) arch="x86_64" ;; + aarch64|arm64) arch="aarch64" ;; + *) error "Unsupported architecture: $(uname -m)" ;; + esac + + echo "${os}-${arch}" +} + +# Get latest release version +get_latest_version() { + curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | \ + grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/' +} + +# Download and install binary +install_binary() { + local version="$1" + local platform="$2" + local asset_name="${BINARY_NAME}-${platform}.tar.gz" + local download_url="https://github.com/${REPO}/releases/download/${version}/${asset_name}" + + info "Downloading ${BINARY_NAME} ${version} for ${platform}..." + + local tmp_dir=$(mktemp -d) + trap "rm -rf $tmp_dir" EXIT + + if ! curl -fsSL "$download_url" -o "$tmp_dir/${BINARY_NAME}.tar.gz" 2>/dev/null; then + return 1 + fi + + tar -xzf "$tmp_dir/${BINARY_NAME}.tar.gz" -C "$tmp_dir" + + mkdir -p "$INSTALL_DIR" + mv "$tmp_dir/${BINARY_NAME}" "$INSTALL_DIR/${BINARY_NAME}" + chmod +x "$INSTALL_DIR/${BINARY_NAME}" + + return 0 +} + +# Build from source +build_from_source() { + info "Building from source..." + + if ! command -v cargo &>/dev/null; then + error "Rust/Cargo not found. Install from https://rustup.rs" + fi + + local tmp_dir=$(mktemp -d) + trap "rm -rf $tmp_dir" EXIT + + git clone --depth 1 "https://github.com/${REPO}.git" "$tmp_dir/${BINARY_NAME}" + cd "$tmp_dir/${BINARY_NAME}" + + cargo build --release + + mkdir -p "$INSTALL_DIR" + mv target/release/${BINARY_NAME} "$INSTALL_DIR/${BINARY_NAME}" + chmod +x "$INSTALL_DIR/${BINARY_NAME}" +} + +# Check if install dir is in PATH +check_path() { + if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then + warn "Add $INSTALL_DIR to your PATH:" + echo "" + echo " export PATH=\"\$PATH:$INSTALL_DIR\"" + echo "" + echo "Add this line to your ~/.bashrc, ~/.zshrc, or shell config." + fi +} + +main() { + info "Installing ${BINARY_NAME}..." + + local platform=$(detect_platform) + info "Detected platform: $platform" + + local version=$(get_latest_version) + + if [ -n "$version" ]; then + info "Latest version: $version" + + if install_binary "$version" "$platform"; then + info "Successfully installed ${BINARY_NAME} to $INSTALL_DIR/${BINARY_NAME}" + else + warn "Pre-built binary not available for $platform" + build_from_source + info "Successfully built and installed ${BINARY_NAME} to $INSTALL_DIR/${BINARY_NAME}" + fi + else + warn "Could not determine latest version, building from source" + build_from_source + info "Successfully built and installed ${BINARY_NAME} to $INSTALL_DIR/${BINARY_NAME}" + fi + + check_path + + echo "" + info "Installation complete!" + echo "" + echo "Don't forget to set your API key:" + echo " export OPENROUTER_API_KEY=\"your-api-key\"" + echo " # or" + echo " export HYBRIDAI_API_KEY=\"your-api-key\"" + echo "" + echo "Then run: ${BINARY_NAME}" + echo "" +} + +main "$@" diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..ff8a9d7 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "packages": { + ".": { + "release-type": "rust", + "bump-minor-pre-major": false, + "bump-patch-for-minor-pre-major": true + } + } +} From 5e45d8207bf9af761c2aba66ec5fc9197c1c583b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Jan 2026 18:20:28 +0000 Subject: [PATCH 2/2] fix: resolve formatting and clippy warnings - Apply cargo fmt formatting (expand single-line structs, proper imports) - Replace matches! macro with idiomatic match/if-let patterns --- src/main.rs | 543 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 451 insertions(+), 92 deletions(-) diff --git a/src/main.rs b/src/main.rs index da0d118..3845213 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,24 +5,55 @@ use reqwest::blocking::Client; use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use std::{env, fs, io::{self, BufRead, Write}, process::Command}; +use std::{ + env, fs, + io::{self, BufRead, Write}, + process::Command, +}; const VERSION: &str = "0.1"; -const SYSTEM_PROMPT: &str = "You are a minimal coding CLI. Use tools to read, write, edit, glob, grep, and bash. \ +const SYSTEM_PROMPT: &str = + "You are a minimal coding CLI. Use tools to read, write, edit, glob, grep, and bash. \ Keep responses concise and execute tools when needed."; #[derive(Serialize, Deserialize, Clone, Debug)] -struct Message { role: String, #[serde(skip_serializing_if = "Option::is_none")] content: Option, #[serde(skip_serializing_if = "Vec::is_empty", default)] tool_calls: Vec, #[serde(skip_serializing_if = "Option::is_none")] tool_call_id: Option } +struct Message { + role: String, + #[serde(skip_serializing_if = "Option::is_none")] + content: Option, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + tool_calls: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + tool_call_id: Option, +} #[derive(Serialize, Deserialize, Clone, Debug)] -struct ToolCall { id: String, #[serde(rename = "type")] r#type: String, function: ToolCallFunction } +struct ToolCall { + id: String, + #[serde(rename = "type")] + r#type: String, + function: ToolCallFunction, +} #[derive(Serialize, Deserialize, Clone, Debug)] -struct ToolCallFunction { name: String, arguments: String } +struct ToolCallFunction { + name: String, + arguments: String, +} -enum Provider { OpenRouter, HybridAI } -struct HybridConfig { chatbot_id: String } -struct ProviderConfig { provider: Provider, api_key: String, model: String, hybrid: Option } +enum Provider { + OpenRouter, + HybridAI, +} +struct HybridConfig { + chatbot_id: String, +} +struct ProviderConfig { + provider: Provider, + api_key: String, + model: String, + hybrid: Option, +} fn main() { let debug = env::args().any(|arg| arg == "--debug"); @@ -40,75 +71,184 @@ fn main() { io::stdout().flush().ok(); let mut input = String::new(); match io::stdin().read_line(&mut input) { - Ok(0) => { println!(); break; } - Err(_) => { println!("{}", "Failed to read input".red()); continue; } + Ok(0) => { + println!(); + break; + } + Err(_) => { + println!("{}", "Failed to read input".red()); + continue; + } Ok(_) => {} } let input = input.trim(); - if input.is_empty() { continue; } - if input == "/exit" { break; } - if input == "/clear" { history = vec![system_message()]; println!("{}", "History cleared.".yellow()); continue; } + if input.is_empty() { + continue; + } + if input == "/exit" { + break; + } + if input == "/clear" { + history = vec![system_message()]; + println!("{}", "History cleared.".yellow()); + continue; + } if input == "/model" { match switch_provider(&config.provider) { - Some(next) => { config = next; headers = build_headers(&config.provider, &config.api_key); model_label = display_model(&config.provider, &config.model); print_header(&model_label); println!(); } - None => print_error("LLM error: /model requires both OPENROUTER_API_KEY and HYBRIDAI_API_KEY"), + Some(next) => { + config = next; + headers = build_headers(&config.provider, &config.api_key); + model_label = display_model(&config.provider, &config.model); + print_header(&model_label); + println!(); + } + None => print_error( + "LLM error: /model requires both OPENROUTER_API_KEY and HYBRIDAI_API_KEY", + ), } continue; } - history.push(Message { role: "user".into(), content: Some(input.to_string()), tool_calls: Vec::new(), tool_call_id: None }); + history.push(Message { + role: "user".into(), + content: Some(input.to_string()), + tool_calls: Vec::new(), + tool_call_id: None, + }); let mut iterations = 0; loop { - if iterations >= 8 { println!("{}", "Too many tool iterations.".red()); break; } + if iterations >= 8 { + println!("{}", "Too many tool iterations.".red()); + break; + } iterations += 1; - let response = match call_llm(&client, &headers, &config.model, &history, &tools, &config.provider, config.hybrid.as_ref(), debug) { + let response = match call_llm( + &client, + &headers, + &config.model, + &history, + &tools, + &config.provider, + config.hybrid.as_ref(), + debug, + ) { Ok(msg) => msg, - Err(err) => { print_error(&format!("LLM error: {}", err)); break; } + Err(err) => { + print_error(&format!("LLM error: {}", err)); + break; + } }; let mut printed = false; - if let Some(content) = &response.content { if !content.trim().is_empty() { print_bullet(content); printed = true; } } + if let Some(content) = &response.content { + if !content.trim().is_empty() { + print_bullet(content); + printed = true; + } + } let tool_calls = response.tool_calls.clone(); history.push(response); - if tool_calls.is_empty() { if !printed { print_bullet(""); } break; } + if tool_calls.is_empty() { + if !printed { + print_bullet(""); + } + break; + } for call in tool_calls { print_tool_call(&call); - let tool_result = execute_tool(&call).unwrap_or_else(|e| format!("Tool error: {}", e)); + let tool_result = + execute_tool(&call).unwrap_or_else(|e| format!("Tool error: {}", e)); print_tool_output(&tool_result); - history.push(Message { role: "tool".into(), content: Some(tool_result), tool_calls: Vec::new(), tool_call_id: Some(call.id) }); + history.push(Message { + role: "tool".into(), + content: Some(tool_result), + tool_calls: Vec::new(), + tool_call_id: Some(call.id), + }); } } } } -fn env_required(key: &str) -> String { match env::var(key) { Ok(v) if !v.trim().is_empty() => v, _ => { eprintln!("{}", format!("Missing {}", key).red()); std::process::exit(1); } } } +fn env_required(key: &str) -> String { + match env::var(key) { + Ok(v) if !v.trim().is_empty() => v, + _ => { + eprintln!("{}", format!("Missing {}", key).red()); + std::process::exit(1); + } + } +} fn pick_provider() -> ProviderConfig { - if let Some(cfg) = load_hybrid() { return cfg; } - load_openrouter().unwrap_or_else(|| { eprintln!("{}", "Missing OPENROUTER_API_KEY or HYBRIDAI_API_KEY".red()); std::process::exit(1); }) + if let Some(cfg) = load_hybrid() { + return cfg; + } + load_openrouter().unwrap_or_else(|| { + eprintln!("{}", "Missing OPENROUTER_API_KEY or HYBRIDAI_API_KEY".red()); + std::process::exit(1); + }) } fn switch_provider(current: &Provider) -> Option { - let has_openrouter = env::var("OPENROUTER_API_KEY").map(|v| !v.trim().is_empty()).unwrap_or(false); - let has_hybrid = env::var("HYBRIDAI_API_KEY").map(|v| !v.trim().is_empty()).unwrap_or(false); - if !(has_openrouter && has_hybrid) { return None; } - match current { Provider::OpenRouter => load_hybrid(), Provider::HybridAI => load_openrouter() } + let has_openrouter = env::var("OPENROUTER_API_KEY") + .map(|v| !v.trim().is_empty()) + .unwrap_or(false); + let has_hybrid = env::var("HYBRIDAI_API_KEY") + .map(|v| !v.trim().is_empty()) + .unwrap_or(false); + if !(has_openrouter && has_hybrid) { + return None; + } + match current { + Provider::OpenRouter => load_hybrid(), + Provider::HybridAI => load_openrouter(), + } } fn load_hybrid() -> Option { - let key = env::var("HYBRIDAI_API_KEY").ok()?; if key.trim().is_empty() { return None; } + let key = env::var("HYBRIDAI_API_KEY").ok()?; + if key.trim().is_empty() { + return None; + } let model = env::var("HYBRIDAI_MODEL").unwrap_or_else(|_| "grok-4-fast".to_string()); let chatbot_id = env_required("HYBRIDAI_CHATBOT_ID"); - Some(ProviderConfig { provider: Provider::HybridAI, api_key: key, model, hybrid: Some(HybridConfig { chatbot_id }) }) + Some(ProviderConfig { + provider: Provider::HybridAI, + api_key: key, + model, + hybrid: Some(HybridConfig { chatbot_id }), + }) } fn load_openrouter() -> Option { - let key = env::var("OPENROUTER_API_KEY").ok()?; if key.trim().is_empty() { return None; } - let model = env::var("OPENROUTER_MODEL").unwrap_or_else(|_| "anthropic/claude-sonnet-4.5".to_string()); - Some(ProviderConfig { provider: Provider::OpenRouter, api_key: key, model, hybrid: None }) + let key = env::var("OPENROUTER_API_KEY").ok()?; + if key.trim().is_empty() { + return None; + } + let model = + env::var("OPENROUTER_MODEL").unwrap_or_else(|_| "anthropic/claude-sonnet-4.5".to_string()); + Some(ProviderConfig { + provider: Provider::OpenRouter, + api_key: key, + model, + hybrid: None, + }) } -fn display_model(provider: &Provider, model: &str) -> String { if matches!(provider, Provider::HybridAI) { "HybridAI/Codingbot".to_string() } else { model.to_string() } } -fn system_message() -> Message { Message { role: "system".into(), content: Some(SYSTEM_PROMPT.to_string()), tool_calls: Vec::new(), tool_call_id: None } } +fn display_model(provider: &Provider, model: &str) -> String { + match provider { + Provider::HybridAI => "HybridAI/Codingbot".to_string(), + Provider::OpenRouter => model.to_string(), + } +} +fn system_message() -> Message { + Message { + role: "system".into(), + content: Some(SYSTEM_PROMPT.to_string()), + tool_calls: Vec::new(), + tool_call_id: None, + } +} fn print_header(model: &str) { let dir = format_cwd(); @@ -124,35 +264,70 @@ fn print_header(model: &str) { let model_line = format!(" model: {}", model); let dir_line = format!(" directory: {}", dir); let width = title.len().max(model_line.len()).max(dir_line.len()); - for line in art { println!("{}", line.cyan()); } + for line in art { + println!("{}", line.cyan()); + } println!("{}", format!("╭{}╮", "─".repeat(width + 2)).cyan()); println!("{}", format!("│ {:width$} │", title, width = width).cyan()); println!("{}", format!("│ {:width$} │", "", width = width).cyan()); - println!("{}", format!("│ {:width$} │", model_line, width = width).cyan()); - println!("{}", format!("│ {:width$} │", dir_line, width = width).cyan()); + println!( + "{}", + format!("│ {:width$} │", model_line, width = width).cyan() + ); + println!( + "{}", + format!("│ {:width$} │", dir_line, width = width).cyan() + ); println!("{}", format!("╰{}╯", "─".repeat(width + 2)).cyan()); } fn format_cwd() -> String { - let cwd = env::current_dir().ok().and_then(|p| p.into_os_string().into_string().ok()).unwrap_or_else(|| ".".to_string()); - if let Ok(home) = env::var("HOME") { if cwd.starts_with(&home) { return cwd.replacen(&home, "~", 1); } } + let cwd = env::current_dir() + .ok() + .and_then(|p| p.into_os_string().into_string().ok()) + .unwrap_or_else(|| ".".to_string()); + if let Ok(home) = env::var("HOME") { + if cwd.starts_with(&home) { + return cwd.replacen(&home, "~", 1); + } + } cwd } fn print_bullet(text: &str) { let mut lines = text.lines(); - if let Some(first) = lines.next() { println!("{} {}", "•".blue(), first.trim_end()); for line in lines { println!(" {}", line.trim_end()); } } - else { println!("{}", "•".blue()); } + if let Some(first) = lines.next() { + println!("{} {}", "•".blue(), first.trim_end()); + for line in lines { + println!(" {}", line.trim_end()); + } + } else { + println!("{}", "•".blue()); + } } fn print_error(text: &str) { let mut lines = text.lines(); - if let Some(first) = lines.next() { println!("{} {}", "•".red(), first.trim_end().red()); for line in lines { println!(" {}", line.trim_end().red()); } } - else { println!("{}", "•".red()); } + if let Some(first) = lines.next() { + println!("{} {}", "•".red(), first.trim_end().red()); + for line in lines { + println!(" {}", line.trim_end().red()); + } + } else { + println!("{}", "•".red()); + } } -fn print_tool_call(call: &ToolCall) { let title = tool_title(call).unwrap_or_else(|| call.function.name.clone()); println!("{}", format!(" └ {}", title).bright_black()); } -fn print_tool_output(output: &str) { let summary = abbreviate_output(output); if !summary.is_empty() { println!("{}", format!(" {}", summary).bright_black()); } } +fn print_tool_call(call: &ToolCall) { + let title = tool_title(call).unwrap_or_else(|| call.function.name.clone()); + println!("{}", format!(" └ {}", title).bright_black()); +} +fn print_tool_output(output: &str) { + let summary = abbreviate_output(output); + if !summary.is_empty() { + println!("{}", format!(" {}", summary).bright_black()); + } +} fn tool_title(call: &ToolCall) -> Option { let args: Value = serde_json::from_str(&call.function.arguments).ok()?; @@ -162,8 +337,23 @@ fn tool_title(call: &ToolCall) -> Option { "write" => Some(format!("Write {}", get_string(&args, "path").ok()?)), "edit" => Some(format!("Edit {}", get_string(&args, "path").ok()?)), "glob" => Some(format!("Glob {}", get_string(&args, "pattern").ok()?)), - "grep" => { let pattern = get_string(&args, "pattern").ok()?; let path = args.get("path").and_then(|v| v.as_str()).unwrap_or(".").to_string(); Some(format!("Grep {} {}", pattern, path)) } - "bash" => { let cmd = get_string(&args, "command").ok()?; if cmd.trim_start().starts_with("ls ") || cmd.trim() == "ls" { Some(format!("List {}", cmd)) } else { Some(format!("Run {}", cmd)) } } + "grep" => { + let pattern = get_string(&args, "pattern").ok()?; + let path = args + .get("path") + .and_then(|v| v.as_str()) + .unwrap_or(".") + .to_string(); + Some(format!("Grep {} {}", pattern, path)) + } + "bash" => { + let cmd = get_string(&args, "command").ok()?; + if cmd.trim_start().starts_with("ls ") || cmd.trim() == "ls" { + Some(format!("List {}", cmd)) + } else { + Some(format!("Run {}", cmd)) + } + } _ => None, } } @@ -174,11 +364,18 @@ fn abbreviate_output(output: &str) -> String { for _ in 0..6 { if let Some(line) = lines.next() { let mut line = line.to_string(); - if line.len() > 200 { line.truncate(200); line.push_str("…"); } + if line.len() > 200 { + line.truncate(200); + line.push_str("…"); + } out.push(line); - } else { break; } + } else { + break; + } + } + if lines.next().is_some() { + out.push("…".to_string()); } - if lines.next().is_some() { out.push("…".to_string()); } out.join("\n") } @@ -187,7 +384,7 @@ fn build_headers(provider: &Provider, api_key: &str) -> HeaderMap { let auth_value = format!("Bearer {}", api_key); headers.insert(AUTHORIZATION, HeaderValue::from_str(&auth_value).unwrap()); headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - if matches!(provider, Provider::OpenRouter) { + if let Provider::OpenRouter = provider { add_opt_header(&mut headers, "OPENROUTER_REFERER", "HTTP-Referer"); add_opt_header(&mut headers, "OPENROUTER_TITLE", "X-Title"); } @@ -195,7 +392,14 @@ fn build_headers(provider: &Provider, api_key: &str) -> HeaderMap { } fn add_opt_header(headers: &mut HeaderMap, env_key: &str, header: &str) { - if let Ok(value) = env::var(env_key) { if let (Ok(name), Ok(value)) = (header.parse::(), HeaderValue::from_str(&value)) { headers.insert(name, value); } } + if let Ok(value) = env::var(env_key) { + if let (Ok(name), Ok(value)) = ( + header.parse::(), + HeaderValue::from_str(&value), + ) { + headers.insert(name, value); + } + } } fn call_llm( @@ -209,30 +413,77 @@ fn call_llm( debug: bool, ) -> Result { let (url, req_body) = match provider { - Provider::OpenRouter => ("https://openrouter.ai/api/v1/chat/completions", json!({ "model": model, "messages": history, "tools": tools, "tool_choice": "auto" })), - Provider::HybridAI => { let cfg = hybrid.ok_or_else(|| "Missing HYBRIDAI_CHATBOT_ID".to_string())?; ("https://hybridai.one/v1/completions", json!({ "model": model, "chatbot_id": cfg.chatbot_id, "prompt": build_prompt(history) })) } + Provider::OpenRouter => ( + "https://openrouter.ai/api/v1/chat/completions", + json!({ "model": model, "messages": history, "tools": tools, "tool_choice": "auto" }), + ), + Provider::HybridAI => { + let cfg = hybrid.ok_or_else(|| "Missing HYBRIDAI_CHATBOT_ID".to_string())?; + ( + "https://hybridai.one/v1/completions", + json!({ "model": model, "chatbot_id": cfg.chatbot_id, "prompt": build_prompt(history) }), + ) + } }; - log_debug(debug, &format!("REQUEST {}\n{}\n", url, req_body.to_string())); - let res = client.post(url).headers(headers.clone()).json(&req_body).send().map_err(|e| e.to_string())?; + log_debug( + debug, + &format!("REQUEST {}\n{}\n", url, req_body.to_string()), + ); + let res = client + .post(url) + .headers(headers.clone()) + .json(&req_body) + .send() + .map_err(|e| e.to_string())?; let status = res.status(); let text = res.text().map_err(|e| e.to_string())?; log_debug(debug, &format!("RESPONSE {}\n{}\n", status, text)); - if !status.is_success() { return Err(format!("HTTP {}: {}", status, text)); } + if !status.is_success() { + return Err(format!("HTTP {}: {}", status, text)); + } let parsed: Value = serde_json::from_str(&text).map_err(|e| e.to_string())?; - if let Some(msg) = parsed.get("choices").and_then(|c| c.get(0)).and_then(|c| c.get("message")) { return serde_json::from_value(msg.clone()).map_err(|e| e.to_string()); } - if let Some(text) = parsed.get("choices").and_then(|c| c.get(0)).and_then(|c| c.get("text")).and_then(|t| t.as_str()) { return Ok(Message { role: "assistant".into(), content: Some(text.to_string()), tool_calls: Vec::new(), tool_call_id: None }); } + if let Some(msg) = parsed + .get("choices") + .and_then(|c| c.get(0)) + .and_then(|c| c.get("message")) + { + return serde_json::from_value(msg.clone()).map_err(|e| e.to_string()); + } + if let Some(text) = parsed + .get("choices") + .and_then(|c| c.get(0)) + .and_then(|c| c.get("text")) + .and_then(|t| t.as_str()) + { + return Ok(Message { + role: "assistant".into(), + content: Some(text.to_string()), + tool_calls: Vec::new(), + tool_call_id: None, + }); + } Err("No choices in response".to_string()) } fn build_prompt(history: &[Message]) -> String { let mut out = String::new(); - for msg in history { if let Some(content) = &msg.content { out.push_str(&format!("{}: {}\n", msg.role, content.trim())); } } + for msg in history { + if let Some(content) = &msg.content { + out.push_str(&format!("{}: {}\n", msg.role, content.trim())); + } + } out } fn log_debug(enabled: bool, entry: &str) { - if !enabled { return; } - if let Ok(mut file) = fs::OpenOptions::new().create(true).append(true).open("debug.log") { + if !enabled { + return; + } + if let Ok(mut file) = fs::OpenOptions::new() + .create(true) + .append(true) + .open("debug.log") + { let _ = writeln!(file, "{}", entry.trim_end()); let _ = writeln!(file); } @@ -240,49 +491,131 @@ fn log_debug(enabled: bool, entry: &str) { fn build_tools() -> Vec { vec![ - tool("read", "Read a file and return its contents.", json!({"path": {"type": "string"}}), &["path"]), - tool("write", "Write contents to a file, overwriting if it exists.", json!({"path": {"type": "string"}, "contents": {"type": "string"}}), &["path", "contents"]), - tool("edit", "Replace text in a file. Use 'old' and 'new' strings and optional 'count'.", json!({"path": {"type": "string"}, "old": {"type": "string"}, "new": {"type": "string"}, "count": {"type": "integer"}}), &["path", "old", "new"]), - tool("glob", "List files matching a glob pattern.", json!({"pattern": {"type": "string"}}), &["pattern"]), - tool("grep", "Search for a regex pattern in files.", json!({"pattern": {"type": "string"}, "path": {"type": "string"}}), &["pattern"]), - tool("bash", "Run a shell command and return stdout/stderr.", json!({"command": {"type": "string"}}), &["command"]), + tool( + "read", + "Read a file and return its contents.", + json!({"path": {"type": "string"}}), + &["path"], + ), + tool( + "write", + "Write contents to a file, overwriting if it exists.", + json!({"path": {"type": "string"}, "contents": {"type": "string"}}), + &["path", "contents"], + ), + tool( + "edit", + "Replace text in a file. Use 'old' and 'new' strings and optional 'count'.", + json!({"path": {"type": "string"}, "old": {"type": "string"}, "new": {"type": "string"}, "count": {"type": "integer"}}), + &["path", "old", "new"], + ), + tool( + "glob", + "List files matching a glob pattern.", + json!({"pattern": {"type": "string"}}), + &["pattern"], + ), + tool( + "grep", + "Search for a regex pattern in files.", + json!({"pattern": {"type": "string"}, "path": {"type": "string"}}), + &["pattern"], + ), + tool( + "bash", + "Run a shell command and return stdout/stderr.", + json!({"command": {"type": "string"}}), + &["command"], + ), ] } -fn tool(name: &str, description: &str, properties: Value, required: &[&str]) -> Value { json!({ "type": "function", "function": { "name": name, "description": description, "parameters": {"type": "object", "properties": properties, "required": required} } }) } +fn tool(name: &str, description: &str, properties: Value, required: &[&str]) -> Value { + json!({ "type": "function", "function": { "name": name, "description": description, "parameters": {"type": "object", "properties": properties, "required": required} } }) +} fn execute_tool(call: &ToolCall) -> Result { - let args: Value = serde_json::from_str(&call.function.arguments).map_err(|e| format!("Invalid tool args: {}", e))?; + let args: Value = serde_json::from_str(&call.function.arguments) + .map_err(|e| format!("Invalid tool args: {}", e))?; match call.function.name.as_str() { - "read" => { let path = get_string(&args, "path")?; fs::read_to_string(path).map_err(|e| e.to_string()) } - "write" => { let path = get_string(&args, "path")?; let contents = get_string(&args, "contents")?; fs::write(path, contents).map_err(|e| e.to_string())?; Ok("ok".to_string()) } + "read" => { + let path = get_string(&args, "path")?; + fs::read_to_string(path).map_err(|e| e.to_string()) + } + "write" => { + let path = get_string(&args, "path")?; + let contents = get_string(&args, "contents")?; + fs::write(path, contents).map_err(|e| e.to_string())?; + Ok("ok".to_string()) + } "edit" => { - let path = get_string(&args, "path")?; let old = get_string(&args, "old")?; let new = get_string(&args, "new")?; let count = args.get("count").and_then(|v| v.as_u64()); + let path = get_string(&args, "path")?; + let old = get_string(&args, "old")?; + let new = get_string(&args, "new")?; + let count = args.get("count").and_then(|v| v.as_u64()); let content = fs::read_to_string(&path).map_err(|e| e.to_string())?; - let updated = match count { Some(n) => content.replacen(&old, &new, n as usize), None => content.replace(&old, &new) }; - fs::write(&path, updated).map_err(|e| e.to_string())?; Ok("ok".to_string()) + let updated = match count { + Some(n) => content.replacen(&old, &new, n as usize), + None => content.replace(&old, &new), + }; + fs::write(&path, updated).map_err(|e| e.to_string())?; + Ok("ok".to_string()) } "glob" => { - let pattern = get_string(&args, "pattern")?; let mut matches = Vec::new(); - for entry in glob(&pattern).map_err(|e| e.to_string())? { match entry { Ok(path) => matches.push(path.display().to_string()), Err(err) => matches.push(format!("glob error: {}", err)) } } + let pattern = get_string(&args, "pattern")?; + let mut matches = Vec::new(); + for entry in glob(&pattern).map_err(|e| e.to_string())? { + match entry { + Ok(path) => matches.push(path.display().to_string()), + Err(err) => matches.push(format!("glob error: {}", err)), + } + } Ok(matches.join("\n")) } - "grep" => { let pattern = get_string(&args, "pattern")?; let path = args.get("path").and_then(|v| v.as_str()).unwrap_or(".").to_string(); run_grep(&pattern, &path) } - "bash" => { let command = get_string(&args, "command")?; run_shell(&command) } + "grep" => { + let pattern = get_string(&args, "pattern")?; + let path = args + .get("path") + .and_then(|v| v.as_str()) + .unwrap_or(".") + .to_string(); + run_grep(&pattern, &path) + } + "bash" => { + let command = get_string(&args, "command")?; + run_shell(&command) + } _ => Err("Unknown tool".to_string()), } } -fn get_string(args: &Value, key: &str) -> Result { args.get(key).and_then(|v| v.as_str()).map(|s| s.to_string()).ok_or_else(|| format!("Missing or invalid '{}'", key)) } +fn get_string(args: &Value, key: &str) -> Result { + args.get(key) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| format!("Missing or invalid '{}'", key)) +} fn run_grep(pattern: &str, root: &str) -> Result { - if let Ok(output) = Command::new("rg").arg("--no-heading").arg("--line-number").arg("--max-count").arg("50").arg(pattern).arg(root).output() { + if let Ok(output) = Command::new("rg") + .arg("--no-heading") + .arg("--line-number") + .arg("--max-count") + .arg("50") + .arg(pattern) + .arg(root) + .output() + { let mut combined = String::new(); combined.push_str(&String::from_utf8_lossy(&output.stdout)); combined.push_str(&String::from_utf8_lossy(&output.stderr)); if output.status.success() || !combined.trim().is_empty() { let trimmed = combined.trim_end(); - return Ok(if trimmed.is_empty() { "none".to_string() } else { trimmed.to_string() }); + return Ok(if trimmed.is_empty() { + "none".to_string() + } else { + trimmed.to_string() + }); } } @@ -290,21 +623,47 @@ fn run_grep(pattern: &str, root: &str) -> Result { let mut hits = Vec::new(); let search_root = format!("{}/**", root.trim_end_matches('/')); for entry in glob(&search_root).map_err(|e| e.to_string())? { - let path = match entry { Ok(p) => p, Err(_) => continue }; - let file = match fs::File::open(&path) { Ok(f) => f, Err(_) => continue }; + let path = match entry { + Ok(p) => p, + Err(_) => continue, + }; + let file = match fs::File::open(&path) { + Ok(f) => f, + Err(_) => continue, + }; for (idx, line) in io::BufReader::new(file).lines().enumerate() { - let line = match line { Ok(l) => l, Err(_) => continue }; - if re.is_match(&line) { hits.push(format!("{}:{}:{}", path.display(), idx + 1, line)); if hits.len() >= 50 { return Ok(hits.join("\n")); } } + let line = match line { + Ok(l) => l, + Err(_) => continue, + }; + if re.is_match(&line) { + hits.push(format!("{}:{}:{}", path.display(), idx + 1, line)); + if hits.len() >= 50 { + return Ok(hits.join("\n")); + } + } } } - Ok(if hits.is_empty() { "none".to_string() } else { hits.join("\n") }) + Ok(if hits.is_empty() { + "none".to_string() + } else { + hits.join("\n") + }) } fn run_shell(command: &str) -> Result { - let output = Command::new("sh").arg("-c").arg(command).output().map_err(|e| e.to_string())?; + let output = Command::new("sh") + .arg("-c") + .arg(command) + .output() + .map_err(|e| e.to_string())?; let mut combined = String::new(); combined.push_str(&String::from_utf8_lossy(&output.stdout)); combined.push_str(&String::from_utf8_lossy(&output.stderr)); let status = output.status.code().unwrap_or(-1); - if combined.trim().is_empty() { Ok(format!("exit {}", status)) } else { Ok(format!("{}\n(exit {})", combined.trim_end(), status)) } + if combined.trim().is_empty() { + Ok(format!("exit {}", status)) + } else { + Ok(format!("{}\n(exit {})", combined.trim_end(), status)) + } }