diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c420e45..a2fe878 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,16 +2,15 @@ name: CI on: push: - branches: [main] + branches: [main, dev, "**"] pull_request: - branches: [main] + branches: [main, dev, "**"] permissions: contents: read env: CARGO_TERM_COLOR: always - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: check: @@ -25,10 +24,10 @@ jobs: - uses: Swatinem/rust-cache@v2 - - name: Install OpenSSL + - name: Install system dependencies run: | sudo apt-get update - sudo apt-get install -y libssl-dev pkg-config + sudo apt-get install -y libssl-dev pkg-config ripgrep - name: Check formatting run: cargo fmt --check --all diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 25e4d0a..5b4f42c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,17 +4,74 @@ on: push: branches: [main] tags: ["v*"] + pull_request: + branches: [main] +# Only run release jobs when: +# - A PR is merged to main (from dev or any branch) +# - A version tag is pushed (v*) permissions: contents: write env: CARGO_TERM_COLOR: always - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true BINARY_NAME: nca jobs: + # Determine if this is a release-worthy push + should-release: + runs-on: ubuntu-latest + outputs: + value: ${{ steps.check.outputs.should_release }} + steps: + - name: Check release conditions + id: check + run: | + # Release on PR merge to main + if [[ "${{ github.event.pull_request.merged }}" == "true" ]]; then + echo "should_release=true" >> "$GITHUB_OUTPUT" + echo "trigger=PR merge to main" >> "$GITHUB_OUTPUT" + # Release on tag push + elif [[ "${{ startsWith(github.ref, 'refs/tags/v') }}" == "true" ]]; then + echo "should_release=true" >> "$GITHUB_OUTPUT" + echo "trigger=Tag push" >> "$GITHUB_OUTPUT" + else + echo "should_release=false" >> "$GITHUB_OUTPUT" + echo "trigger=Non-release push" >> "$GITHUB_OUTPUT" + fi + + # CI check (required before release) + ci-check: + needs: should-release + if: needs.should-release.outputs.value == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - uses: Swatinem/rust-cache@v2 + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libssl-dev pkg-config ripgrep + + - name: Check formatting + run: cargo fmt --check --all + + - name: Clippy + run: cargo clippy --workspace -- -D warnings + + - name: Test + run: cargo test --workspace + + # Multi-platform build build: + needs: [should-release, ci-check] + if: needs.should-release.outputs.value == 'true' strategy: matrix: include: @@ -63,10 +120,11 @@ jobs: name: ${{ env.BINARY_NAME }}-${{ matrix.target }} path: ${{ env.BINARY_NAME }}-${{ matrix.target }}.${{ matrix.archive }} + # Create GitHub Release (only on tags) release: - needs: build + needs: [should-release, build] + if: needs.should-release.outputs.value == 'true' && startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/v') steps: - uses: actions/checkout@v4 @@ -83,10 +141,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Update install script (only on tags) update-install-script: - needs: release + needs: [should-release, release] + if: needs.should-release.outputs.value == 'true' && startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/v') steps: - uses: actions/checkout@v4 diff --git a/Cargo.lock b/Cargo.lock index 079c33f..627bff8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2072,7 +2072,7 @@ dependencies = [ [[package]] name = "nca-autoresearch" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "async-trait", @@ -2092,7 +2092,7 @@ dependencies = [ [[package]] name = "nca-cli" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "arboard", @@ -2128,7 +2128,7 @@ dependencies = [ [[package]] name = "nca-common" -version = "0.2.0" +version = "0.3.0" dependencies = [ "chrono", "serde", @@ -2142,15 +2142,17 @@ dependencies = [ [[package]] name = "nca-core" -version = "0.2.0" +version = "0.3.0" dependencies = [ "async-trait", "base64", + "chrono", "futures-util", "genai", "globset", "mcpr", "nca-common", + "regex", "reqwest 0.12.28", "scraper", "serde", @@ -2166,7 +2168,7 @@ dependencies = [ [[package]] name = "nca-runtime" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index a966455..6a0de46 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.2.0" +version = "0.3.0" edition = "2024" license = "MIT" repository = "https://github.com/madebyaris/native-cli-ai" diff --git a/README.md b/README.md index e496f4c..e580718 100644 --- a/README.md +++ b/README.md @@ -76,9 +76,7 @@ Pre-built binaries for every release are available on the [Releases](https://git |---|---| | macOS (Apple Silicon) | `aarch64-apple-darwin` | | macOS (Intel) | `x86_64-apple-darwin` | -| Linux (x86_64, glibc) | `x86_64-unknown-linux-gnu` | -| Linux (x86_64, static) | `x86_64-unknown-linux-musl` | -| Linux (ARM64) | `aarch64-unknown-linux-gnu` | +| Linux (x86_64) | `x86_64-unknown-linux-gnu` | ### Build from source @@ -273,6 +271,17 @@ The system prompt is layered in this order: The built-in tool surface includes filesystem editing, search, diffing, patching, shell execution, web access, `ask_question`, and `spawn_subagent`. MCP tools are loaded dynamically when configured, so the available tool set can grow with your environment. +### Search And Edit Tools + +Recent search/edit improvements are aimed at making agent file work less brittle: + +- `search_code` now returns structured JSON match objects instead of raw `rg` text. +- `search_code` treats ripgrep exit code `1` as a successful empty result, not a failure. +- `search_code` supports `path`, `glob`, `fixed_strings`, `case_sensitive`, `word`, `context_before`, `context_after`, and `max_results`. +- `query_symbols` is a literal Rust symbol lookup, not an implicit regex expansion of user input. +- `edit_file` and `apply_patch` now fail loudly on ambiguous single-match edits instead of silently changing the first occurrence. +- `replace_match` can edit a specific search result by exact `path`, `line`, and `column`, which makes search -> edit flows much safer. + ## Crate Layout | Crate | Responsibility | @@ -295,7 +304,22 @@ In practice, that means you can start small, branch out when a task gets bigger, ## Documentation -The root README is the quick-start guide. Use the docs folder for deeper detail: +Full user-facing documentation lives in [`docs/documentation/`](docs/documentation/index.md): + +| Page | Description | +|---|---| +| [Getting Started](docs/documentation/getting-started.md) | Installation, first run, and initial configuration | +| [Commands](docs/documentation/commands.md) | Complete CLI command and flag reference | +| [Interactive Mode](docs/documentation/interactive-mode.md) | TUI, REPL, slash commands, keyboard shortcuts | +| [Configuration](docs/documentation/configuration.md) | Config files, TOML format, and environment variables | +| [Providers](docs/documentation/providers.md) | LLM provider setup — MiniMax, Anthropic, OpenAI, OpenRouter | +| [Tools](docs/documentation/tools.md) | All agent tools — file ops, search, shell, web, and more | +| [Sessions](docs/documentation/sessions.md) | Session lifecycle, persistence, resume, and management | +| [Permissions](docs/documentation/permissions.md) | Approval system, permission modes, and safe mode | +| [Skills](docs/documentation/skills.md) | Skill discovery, installation, and authoring | +| [Advanced](docs/documentation/advanced.md) | Sub-agents, MCP servers, hooks, orchestration, and IPC | + +Internal design docs: - [Product Requirements](docs/prd.md) - [Tech Stack](docs/tech-stack.md) diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index f028d39..4b10680 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -200,9 +200,13 @@ enum Command { #[arg(long)] json: bool, }, + /// Manage skills: list, add, remove, update Skills { + /// Output as JSON (shorthand for `nca skills list --json`) #[arg(long)] json: bool, + #[command(subcommand)] + command: Option, }, Mcp { #[arg(long)] @@ -291,6 +295,39 @@ enum MemoryCommand { }, } +#[derive(clap::Subcommand, Debug)] +enum SkillsCommand { + /// List installed skills + List { + #[arg(long)] + json: bool, + }, + /// Install skills from a GitHub repo or local path + Add { + /// Source: owner/repo, GitHub URL, or local path + source: String, + /// Install specific skills by name (default: all) + #[arg(short, long)] + skill: Vec, + /// Install to ~/.nca/skills/ instead of .nca/skills/ + #[arg(short, long)] + global: bool, + }, + /// Remove an installed skill + Remove { + /// Skill command name to remove + name: String, + /// Remove from ~/.nca/skills/ instead of .nca/skills/ + #[arg(short, long)] + global: bool, + }, + /// Update installed skills from their source + Update { + /// Specific skill to update (default: all) + name: Option, + }, +} + #[derive(clap::ValueEnum, Clone, Copy, Debug)] enum CliPermissionMode { Default, @@ -505,9 +542,27 @@ async fn try_main() -> anyhow::Result<()> { Some(Command::Cancel { session_id, json }) => { cancel_session(&config, &workspace_root, &session_id, json).await?; } - Some(Command::Skills { json }) => { - list_skills(&config, &workspace_root, json)?; - } + Some(Command::Skills { json, command }) => match command { + None => { + list_skills(&config, &workspace_root, json)?; + } + Some(SkillsCommand::List { json: j }) => { + list_skills(&config, &workspace_root, j || json)?; + } + Some(SkillsCommand::Add { + source, + skill, + global, + }) => { + handle_skills_add(&source, &skill, global, &workspace_root)?; + } + Some(SkillsCommand::Remove { name, global }) => { + handle_skills_remove(&name, global, &workspace_root)?; + } + Some(SkillsCommand::Update { name }) => { + handle_skills_update(name.as_deref(), &workspace_root)?; + } + }, Some(Command::Mcp { json }) => { list_mcp_servers(&config, json)?; } @@ -1274,6 +1329,120 @@ fn list_skills(config: &NcaConfig, workspace_root: &Path, json: bool) -> anyhow: Ok(()) } +fn handle_skills_add( + source: &str, + skill_filter: &[String], + global: bool, + workspace_root: &Path, +) -> anyhow::Result<()> { + use nca_core::skill_installer::{install_skills, parse_source}; + + let parsed = parse_source(source).map_err(anyhow::Error::msg)?; + let installed = install_skills(&parsed, skill_filter, global, workspace_root) + .map_err(anyhow::Error::msg)?; + + let scope = if global { "(global)" } else { "(local)" }; + println!( + "Installed {} skill(s) {scope}: {}", + installed.len(), + installed.join(", ") + ); + Ok(()) +} + +fn handle_skills_remove(name: &str, global: bool, workspace_root: &Path) -> anyhow::Result<()> { + use nca_core::skill_installer::remove_skill; + + remove_skill(name, global, workspace_root).map_err(anyhow::Error::msg)?; + println!("Removed skill: {name}"); + Ok(()) +} + +fn handle_skills_update(name: Option<&str>, workspace_root: &Path) -> anyhow::Result<()> { + use nca_core::skill_installer::{ + SkillLock, SkillLockEntry, copy_skill_dir, discover_skills_in_dir, git_clone_to_temp, + git_head_commit, lock_file_path, skills_dir, + }; + + let mut updated = 0u32; + let mut up_to_date = 0u32; + let mut skipped = 0u32; + + for global in [false, true] { + let lock_path = lock_file_path(global, workspace_root); + let mut lock = SkillLock::load(&lock_path).map_err(anyhow::Error::msg)?; + let target = skills_dir(global, workspace_root); + let mut changed = false; + + let entries: Vec<_> = lock + .skills + .iter() + .filter(|(k, _)| name.is_none() || name == Some(k.as_str())) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + + for (skill_name, entry) in entries { + if entry.commit.is_none() { + eprintln!("Skipping '{skill_name}' (installed from local path)"); + skipped += 1; + continue; + } + + let clone_url = entry + .source + .strip_prefix("github:") + .map(|repo| format!("https://github.com/{repo}.git")); + + let Some(url) = clone_url else { + eprintln!("Skipping '{skill_name}' (unknown source format)"); + skipped += 1; + continue; + }; + + let tmp = git_clone_to_temp(&url).map_err(anyhow::Error::msg)?; + let new_commit = git_head_commit(tmp.path()).ok(); + + if new_commit.as_deref() == entry.commit.as_deref() { + up_to_date += 1; + continue; + } + + let skills_path = if tmp.path().join("skills").is_dir() { + tmp.path().join("skills") + } else { + tmp.path().to_path_buf() + }; + + let discovered = discover_skills_in_dir(&skills_path).map_err(anyhow::Error::msg)?; + + if let Some((_, src_dir)) = discovered.iter().find(|(n, _)| n == &skill_name) { + let dest = target.join(&skill_name); + copy_skill_dir(src_dir, &dest).map_err(anyhow::Error::msg)?; + lock.upsert( + &skill_name, + SkillLockEntry { + source: entry.source.clone(), + commit: new_commit, + installed_at: chrono::Utc::now().to_rfc3339(), + }, + ); + changed = true; + updated += 1; + } else { + eprintln!("Warning: skill '{skill_name}' no longer found in source repo"); + skipped += 1; + } + } + + if changed { + lock.save(&lock_path).map_err(anyhow::Error::msg)?; + } + } + + println!("Updated {updated}, already up-to-date {up_to_date}, skipped {skipped}"); + Ok(()) +} + fn list_mcp_servers(config: &NcaConfig, json: bool) -> anyhow::Result<()> { if json { print_json(&config.mcp, false)?; diff --git a/crates/cli/src/repl.rs b/crates/cli/src/repl.rs index b342888..2a1b402 100644 --- a/crates/cli/src/repl.rs +++ b/crates/cli/src/repl.rs @@ -1646,6 +1646,7 @@ impl Repl { let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::(); let st = tui_state.clone(); let banner = self.run_mode; + let cancel_flag = self.runtime.cancel_handle(); let ui = tokio::task::spawn_blocking(move || { run_blocking( st, @@ -1653,6 +1654,7 @@ impl Repl { Some(answer_for_tui), Some(approval_for_tui), banner, + Some(cancel_flag), ) }); diff --git a/crates/cli/src/runner.rs b/crates/cli/src/runner.rs index ca750c5..f5dc8ba 100644 --- a/crates/cli/src/runner.rs +++ b/crates/cli/src/runner.rs @@ -9,6 +9,7 @@ use nca_runtime::ipc::IpcHandle; use nca_runtime::supervisor::{Supervisor, SupervisorConfig, SupervisorHandle}; use std::path::Path; use std::sync::Arc; +use std::sync::atomic::AtomicBool; use tokio::sync::mpsc; /// Resolve a pending `ask_question` without going through `SessionRuntime` (e.g. TUI side task @@ -163,6 +164,10 @@ impl SessionRuntime { self.supervisor.request_cancel(); } + pub fn cancel_handle(&self) -> Arc { + self.supervisor.cancel_handle() + } + pub fn event_tx(&self) -> Option> { self.supervisor.event_tx() } diff --git a/crates/cli/src/tui/app.rs b/crates/cli/src/tui/app.rs index 50c724a..422ad55 100644 --- a/crates/cli/src/tui/app.rs +++ b/crates/cli/src/tui/app.rs @@ -24,7 +24,7 @@ use crossterm::{ }, }; use nca_common::config::ProviderKind; -use nca_common::event::QuestionSelection; +use nca_common::event::{BusyState, QuestionSelection}; use nca_core::approval::suggest_allow_pattern; use nca_core::skills::{SkillCatalog, SkillSource}; use ratatui::{ @@ -113,6 +113,7 @@ mod theme { pub const BG: Color = Color::Rgb(22, 22, 28); pub const SURFACE: Color = Color::Rgb(32, 32, 42); pub const BORDER: Color = Color::Rgb(55, 55, 70); + pub const MENTION_BG: Color = Color::Rgb(48, 62, 94); pub const USER: Color = Color::Rgb(56, 189, 248); pub const ASSISTANT: Color = Color::Rgb(167, 139, 250); @@ -207,6 +208,154 @@ fn apply_at_completion(buffer: &str, cursor_char_idx: usize, choice: &str) -> (S (new_buf, new_char) } +fn apply_selected_at_completion( + workspace_files: &[String], + buffer: &str, + cursor_char_idx: usize, + at_menu_index: usize, + append_space: bool, +) -> Option<(String, usize)> { + let at_matches = at_completion_matches(workspace_files, buffer, cursor_char_idx); + if at_matches.is_empty() || !at_completion_active(buffer, cursor_char_idx) { + return None; + } + + let pick = at_menu_index.min(at_matches.len().saturating_sub(1)); + let choice = at_matches.get(pick)?; + let (mut new_buf, mut new_cursor_char_idx) = + apply_at_completion(buffer, cursor_char_idx, choice); + + if append_space { + let insert_at = cursor_byte_index(&new_buf, new_cursor_char_idx); + new_buf.insert(insert_at, ' '); + new_cursor_char_idx += 1; + } + + Some((new_buf, new_cursor_char_idx)) +} + +fn at_mention_char_ranges(buffer: &str) -> Vec<(usize, usize)> { + file_mentions::parse_at_mentions(buffer) + .into_iter() + .map(|(start, end, _)| { + let start_char = buffer[..start].chars().count(); + let end_char = buffer[..end].chars().count(); + (start_char, end_char) + }) + .collect() +} + +fn completed_at_mention_range_before_cursor( + buffer: &str, + cursor_char_idx: usize, +) -> Option<(usize, usize)> { + let chars: Vec = buffer.chars().collect(); + for (start_char, end_char) in at_mention_char_ranges(buffer) { + if end_char == cursor_char_idx { + return Some((start_char, end_char)); + } + if end_char < chars.len() + && end_char + 1 == cursor_char_idx + && chars.get(end_char) == Some(&' ') + { + return Some((start_char, end_char + 1)); + } + } + None +} + +fn remove_char_range(buffer: &str, start_char_idx: usize, end_char_idx: usize) -> String { + let mut chars: Vec = buffer.chars().collect(); + chars.drain(start_char_idx..end_char_idx); + chars.into_iter().collect() +} + +fn delete_completed_at_mention(buffer: &str, cursor_char_idx: usize) -> Option<(String, usize)> { + let (start_char, end_char) = completed_at_mention_range_before_cursor(buffer, cursor_char_idx)?; + Some((remove_char_range(buffer, start_char, end_char), start_char)) +} + +fn push_styled_run( + spans: &mut Vec>, + text: &mut String, + current_style: &mut Option