From 76bd455fa0ebadb60c67b14938f8b93b00e20ed8 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 3 Jun 2026 13:39:32 +0000 Subject: [PATCH] feat(cli): add Codex-style Codra terminal experience --- README.md | 19 +- crates/codra-cli/README.md | 15 ++ crates/codra-cli/src/doctor.rs | 178 ++++++++++++++++++ crates/codra-cli/src/init.rs | 174 +++++++++++++++++ crates/codra-cli/src/lib.rs | 6 +- crates/codra-cli/src/main.rs | 32 ++-- crates/codra-cli/src/project.rs | 70 +++++++ .../codra-cli/src/tasks/summarize_context.rs | 19 +- crates/codra-cli/src/terminal.rs | 66 +++++++ .../tests/terminal_experience_test.rs | 113 +++++++++++ packages/codra-npm-cli/README.md | 125 +++++++----- packages/codra-npm-cli/scripts/test.js | 6 + 12 files changed, 754 insertions(+), 69 deletions(-) create mode 100644 crates/codra-cli/src/doctor.rs create mode 100644 crates/codra-cli/src/init.rs create mode 100644 crates/codra-cli/src/project.rs create mode 100644 crates/codra-cli/src/terminal.rs create mode 100644 crates/codra-cli/tests/terminal_experience_test.rs diff --git a/README.md b/README.md index 5e82dbc..b688e76 100644 --- a/README.md +++ b/README.md @@ -105,14 +105,25 @@ See [crates/codra-cli/README.md](crates/codra-cli/README.md). ## Installable CLI roadmap -The Rust CLI will be installable globally via npm (not published yet): +The `@codra/cli` package is publish-ready work in progress and is not published to npm yet. Once maintainers verify the release tarball includes every selected platform binary, users will be able to install Codra globally: ```bash -npm install -g @codra/cli # coming soon -codra run --task summarize-context --jsonl +pnpm add -g @codra/cli +npm install -g @codra/cli +codra --help +codra ``` -The [`@codra/cli`](packages/codra-npm-cli/) package is a thin Node wrapper that spawns the native `codra` binary built from `codra-cli`. Multi-platform npm distribution is in progress (linux/macOS/Windows targets); a manual [release workflow](.github/workflows/codra-cli-release.yml) packages platform binaries before publish. Intel macOS (`darwin-x64`) is optional in dry runs because `macos-13` runners can queue for a long time; npm publish stays guarded and off by default (see [packages/codra-npm-cli/README.md](packages/codra-npm-cli/README.md)). +Today, local development can run the same command surface from the Rust crate or npm wrapper: + +```bash +cargo run -p codra-cli -- +cargo run -p codra-cli -- doctor +cargo run -p codra-cli -- init +cargo run -p codra-cli -- run --task summarize-context --jsonl +``` + +The [`@codra/cli`](packages/codra-npm-cli/) package is a thin Node wrapper that spawns the native `codra` binary built from `codra-cli`. Multi-platform npm distribution targets Linux, macOS, and Windows; the manual [release workflow](.github/workflows/codra-cli-release.yml) packages platform binaries before guarded publish. npm publish remains off by default and must not run until tarball contents are verified (see [packages/codra-npm-cli/README.md](packages/codra-npm-cli/README.md)). ## Roadmap diff --git a/crates/codra-cli/README.md b/crates/codra-cli/README.md index 730fbed..0700409 100644 --- a/crates/codra-cli/README.md +++ b/crates/codra-cli/README.md @@ -8,6 +8,17 @@ Local-first Codra CLI with JSONL event protocol and GitHub context adapter. # Build cargo build -p codra-cli +# Codex-style terminal entrypoint +codra +codra --help + +# Project setup and environment checks +codra init +codra init --force +codra init --dry-run +codra doctor +codra doctor --json + # JSONL event stream (no AI keys required) codra run --task review-pr --jsonl codra run --task explain-issue --jsonl @@ -18,6 +29,10 @@ codra run --task summarize-context codra run --task review-pr ``` +`codra init` creates `CODRA.md`, `.codra/commands/`, and `.codra/agents/` starter files at the git root when available. It does not overwrite existing files unless `--force` is passed. + +`codra doctor` checks git, cargo, Node, npm/pnpm, GitHub Actions environment, `GITHUB_TOKEN` presence without printing the value, Codra project files, the `codra` binary on `PATH`, and the npm platform key. Missing optional tools are warnings and exit 0. + ## GitHub context Real GitHub Actions mode is enabled only when `GITHUB_ACTIONS=true`. If `GITHUB_EVENT_PATH` is set outside Actions, the CLI parses it as a local fixture and keeps `mode` as `local`. diff --git a/crates/codra-cli/src/doctor.rs b/crates/codra-cli/src/doctor.rs new file mode 100644 index 0000000..edbce99 --- /dev/null +++ b/crates/codra-cli/src/doctor.rs @@ -0,0 +1,178 @@ +use std::env; +use std::path::PathBuf; + +use serde::Serialize; + +use crate::project; + +#[derive(Debug, Serialize)] +struct DoctorReport { + checks: Vec, +} + +#[derive(Debug, Serialize)] +struct DoctorCheck { + name: &'static str, + status: &'static str, + detail: String, +} + +pub fn execute_doctor(args: &[String]) -> Result<(), String> { + if args.iter().any(|arg| arg == "--help" || arg == "-h") { + println!("codra doctor [--json]"); + println!(" Checks local environment readiness without printing secret values."); + return Ok(()); + } + + let json = parse_doctor_args(args)?; + let report = collect_report(); + + if json { + let body = serde_json::to_string_pretty(&report).map_err(|err| err.to_string())?; + println!("{body}"); + } else { + print_human_report(&report); + } + + Ok(()) +} + +fn parse_doctor_args(args: &[String]) -> Result { + let mut json = false; + for arg in args { + match arg.as_str() { + "--json" => json = true, + flag if flag.starts_with("--") => return Err(format!("unknown flag: {flag}")), + other => return Err(format!("unexpected argument: {other}")), + } + } + Ok(json) +} + +fn collect_report() -> DoctorReport { + let cwd = project::current_dir(); + let root = project::project_root(); + let git_installed = project::command_exists("git"); + let inside_git = git_installed && project::is_inside_git_repo(); + let branch = if inside_git { + project::git_branch().unwrap_or_else(|| "detached".to_string()) + } else { + "n/a".to_string() + }; + let working_tree = if inside_git { + match project::git_status_short() { + Some(value) if !value.trim().is_empty() => "dirty".to_string(), + _ => "clean".to_string(), + } + } else { + "n/a".to_string() + }; + + DoctorReport { + checks: vec![ + ok("current directory", cwd.display().to_string()), + check("git installed", git_installed, detail_or_missing("git")), + check("inside git repo", inside_git, yes_no(inside_git)), + ok("branch", branch), + ok("working tree", working_tree), + check( + "cargo available", + project::command_exists("cargo"), + detail_or_missing("cargo"), + ), + check( + "node available", + project::command_exists("node"), + detail_or_missing("node"), + ), + check( + "npm available", + project::command_exists("npm"), + detail_or_missing("npm"), + ), + check( + "pnpm available", + project::command_exists("pnpm"), + detail_or_missing("pnpm"), + ), + check( + "GitHub Actions env", + env::var("GITHUB_ACTIONS") + .map(|value| value == "true") + .unwrap_or(false), + yes_no( + env::var("GITHUB_ACTIONS") + .map(|value| value == "true") + .unwrap_or(false), + ), + ), + check( + "GITHUB_TOKEN present", + env::var("GITHUB_TOKEN") + .map(|value| !value.is_empty()) + .unwrap_or(false), + yes_no( + env::var("GITHUB_TOKEN") + .map(|value| !value.is_empty()) + .unwrap_or(false), + ), + ), + check( + "CODRA.md exists", + root.join("CODRA.md").exists(), + path_detail(root.join("CODRA.md")), + ), + check( + ".codra directory exists", + root.join(".codra").is_dir(), + path_detail(root.join(".codra")), + ), + check( + "codra binary on PATH", + project::which("codra").is_some(), + project::which("codra").unwrap_or_else(|| "not found".to_string()), + ), + ok("npm platform key", project::npm_platform_key()), + ], + } +} + +fn print_human_report(report: &DoctorReport) { + println!("Codra doctor"); + println!(); + for check in &report.checks { + println!("{:<24} {:<7} {}", check.name, check.status, check.detail); + } +} + +fn ok(name: &'static str, detail: String) -> DoctorCheck { + DoctorCheck { + name, + status: "ok", + detail, + } +} + +fn check(name: &'static str, ok: bool, detail: String) -> DoctorCheck { + DoctorCheck { + name, + status: if ok { "ok" } else { "warn" }, + detail, + } +} + +fn yes_no(value: bool) -> String { + if value { + "yes".to_string() + } else { + "no".to_string() + } +} + +fn detail_or_missing(program: &str) -> String { + project::which(program).unwrap_or_else(|| "not found".to_string()) +} + +fn path_detail(path: PathBuf) -> String { + path.display().to_string() +} diff --git a/crates/codra-cli/src/init.rs b/crates/codra-cli/src/init.rs new file mode 100644 index 0000000..4531ac1 --- /dev/null +++ b/crates/codra-cli/src/init.rs @@ -0,0 +1,174 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::project; + +#[derive(Debug, Clone, Copy, Default)] +pub struct InitOptions { + pub force: bool, + pub dry_run: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum InitAction { + Created, + Exists, + WouldCreate, + WouldOverwrite, + Overwritten, +} + +pub fn execute_init(args: &[String]) -> Result<(), String> { + if args.iter().any(|arg| arg == "--help" || arg == "-h") { + println!("codra init [--force] [--dry-run]"); + println!(" Creates CODRA.md, .codra/commands, and .codra/agents."); + return Ok(()); + } + + let opts = parse_init_args(args)?; + init_project(opts) +} + +pub fn parse_init_args(args: &[String]) -> Result { + let mut opts = InitOptions::default(); + + for arg in args { + match arg.as_str() { + "--force" => opts.force = true, + "--dry-run" => opts.dry_run = true, + flag if flag.starts_with("--") => return Err(format!("unknown flag: {flag}")), + other => return Err(format!("unexpected argument: {other}")), + } + } + + Ok(opts) +} + +pub fn init_project(opts: InitOptions) -> Result<(), String> { + let root = project::project_root(); + let targets = starter_targets(&root); + + println!("Codra project setup"); + println!("Directory: {}", root.display()); + + for dir in [ + root.join(".codra"), + root.join(".codra/commands"), + root.join(".codra/agents"), + ] { + let action = ensure_dir(&dir, opts)?; + print_action(action, &dir); + } + + for (path, content) in targets { + let action = ensure_file(&path, content, opts)?; + print_action(action, &path); + } + + if opts.dry_run { + println!("Dry run only; no files changed."); + } + + Ok(()) +} + +fn starter_targets(root: &Path) -> Vec<(PathBuf, &'static str)> { + vec![ + (root.join("CODRA.md"), CODRA_MD), + (root.join(".codra/commands/review-pr.md"), REVIEW_PR_MD), + ( + root.join(".codra/commands/explain-issue.md"), + EXPLAIN_ISSUE_MD, + ), + ( + root.join(".codra/agents/code-reviewer.md"), + CODE_REVIEWER_MD, + ), + ] +} + +fn ensure_dir(path: &Path, opts: InitOptions) -> Result { + if path.exists() { + return Ok(InitAction::Exists); + } + + if opts.dry_run { + return Ok(InitAction::WouldCreate); + } + + fs::create_dir_all(path) + .map_err(|err| format!("failed to create {}: {err}", path.display()))?; + Ok(InitAction::Created) +} + +fn ensure_file(path: &Path, content: &str, opts: InitOptions) -> Result { + if path.exists() { + if opts.force { + if opts.dry_run { + return Ok(InitAction::WouldOverwrite); + } + fs::write(path, content) + .map_err(|err| format!("failed to write {}: {err}", path.display()))?; + return Ok(InitAction::Overwritten); + } + return Ok(InitAction::Exists); + } + + if opts.dry_run { + return Ok(InitAction::WouldCreate); + } + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|err| format!("failed to create {}: {err}", parent.display()))?; + } + fs::write(path, content).map_err(|err| format!("failed to write {}: {err}", path.display()))?; + Ok(InitAction::Created) +} + +fn print_action(action: InitAction, path: &Path) { + let label = match action { + InitAction::Created => "created", + InitAction::Exists => "exists", + InitAction::WouldCreate => "would create", + InitAction::WouldOverwrite => "would overwrite", + InitAction::Overwritten => "overwritten", + }; + println!(" {label}: {}", path.display()); +} + +const CODRA_MD: &str = r#"# CODRA.md + +## Project Overview +Describe what this repository builds and who it serves. + +## Coding Standards +List language, formatting, testing, and review expectations. + +## Commands +Document common setup, test, build, and verification commands. + +## Agent Instructions +Tell Codra how to inspect, edit, and validate this repo. + +## Safety Rules +Note files, secrets, generated outputs, or operations Codra must avoid. +"#; + +const REVIEW_PR_MD: &str = r#"# review-pr + +Review the current pull request for correctness, risk, and missing tests. +Prioritize concrete findings with file and line references when available. +"#; + +const EXPLAIN_ISSUE_MD: &str = r#"# explain-issue + +Summarize the issue, identify likely affected areas, and propose next steps. +Keep recommendations practical and grounded in repository context. +"#; + +const CODE_REVIEWER_MD: &str = r#"# code-reviewer + +Focus on correctness, maintainability, tests, security, and user impact. +Avoid speculative feedback when repository evidence is missing. +"#; diff --git a/crates/codra-cli/src/lib.rs b/crates/codra-cli/src/lib.rs index b7f77aa..a4b279d 100644 --- a/crates/codra-cli/src/lib.rs +++ b/crates/codra-cli/src/lib.rs @@ -1,10 +1,14 @@ pub mod context; +pub mod doctor; pub mod events; +pub mod init; +pub mod project; pub mod run; pub mod tasks; +pub mod terminal; pub mod utils; pub use run::{ args_want_jsonl, emit_argument_validation_failed, execute_run, parse_run_args, peek_task_label, run_task, RunOptions, VALID_TASKS, -}; \ No newline at end of file +}; diff --git a/crates/codra-cli/src/main.rs b/crates/codra-cli/src/main.rs index 283a1fd..656eb5c 100644 --- a/crates/codra-cli/src/main.rs +++ b/crates/codra-cli/src/main.rs @@ -1,4 +1,7 @@ +use codra_cli::doctor::execute_doctor; +use codra_cli::init::execute_init; use codra_cli::run::{args_want_jsonl, execute_run}; +use codra_cli::terminal::{help, welcome}; use codra_core::provider::{create_provider, EchoMockProvider, IntelligenceProvider}; use codra_core::provider_config::ProviderConfigService; use codra_protocol::{McpServerInfo, ProviderConfig, ProviderKind}; @@ -13,8 +16,18 @@ use std::path::PathBuf; fn main() { let mut args = env::args().skip(1).collect::>(); - let command = args.first().map(String::as_str).unwrap_or("help"); + let command = args.first().map(String::as_str).unwrap_or(""); let result = match command { + "" => welcome(), + "--help" | "-h" | "help" => help(), + "init" => { + args.remove(0); + execute_init(&args) + } + "doctor" => { + args.remove(0); + execute_doctor(&args) + } "smoke" => smoke(), "provider" => { args.remove(0); @@ -528,20 +541,3 @@ fn parse_worker_url(url: &str) -> Result<(String, u16), String> { .unwrap_or(80); Ok((host, port)) } - -fn help() -> Result<(), String> { - println!("codra "); - println!(" run --task [--jsonl] Run a task with optional JSONL event stream"); - println!(" Tasks: review-pr, explain-issue, summarize-context"); - println!(" smoke Validate local tool registry and workspace readiness"); - println!(" provider check Check active provider health"); - println!(" worker add Register a remote worker"); - println!(" worker check Probe a registered worker's health endpoint"); - println!(" worker list List registered workers"); - println!(" worker pair Interactive pair and verify a remote worker"); - println!(" worker trust Update a worker's trust level"); - println!(" worker remove Remove/unpair a registered worker"); - println!(" headless Run a dry-run headless planning surface"); - println!(" mcp-server Print MCP-compatible server/tool metadata"); - Ok(()) -} diff --git a/crates/codra-cli/src/project.rs b/crates/codra-cli/src/project.rs new file mode 100644 index 0000000..666828c --- /dev/null +++ b/crates/codra-cli/src/project.rs @@ -0,0 +1,70 @@ +use std::env; +use std::path::PathBuf; +use std::process::Command; + +pub fn current_dir() -> PathBuf { + env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) +} + +pub fn command_output(program: &str, args: &[&str]) -> Option { + let output = Command::new(program).args(args).output().ok()?; + if !output.status.success() { + return None; + } + + let text = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if text.is_empty() { + None + } else { + Some(text) + } +} + +pub fn command_exists(program: &str) -> bool { + Command::new(program).arg("--version").output().is_ok() +} + +pub fn git_root() -> Option { + command_output("git", &["rev-parse", "--show-toplevel"]).map(PathBuf::from) +} + +pub fn project_root() -> PathBuf { + git_root().unwrap_or_else(current_dir) +} + +pub fn git_branch() -> Option { + command_output("git", &["branch", "--show-current"]) +} + +pub fn git_status_short() -> Option { + command_output("git", &["status", "--short"]) +} + +pub fn is_inside_git_repo() -> bool { + command_output("git", &["rev-parse", "--is-inside-work-tree"]) + .map(|value| value == "true") + .unwrap_or(false) +} + +pub fn which(program: &str) -> Option { + if cfg!(windows) { + command_output("where", &[program]) + .and_then(|value| value.lines().next().map(str::to_string)) + } else { + command_output("which", &[program]) + } +} + +pub fn npm_platform_key() -> String { + let platform = match env::consts::OS { + "macos" => "darwin", + "windows" => "win32", + other => other, + }; + let arch = match env::consts::ARCH { + "x86_64" => "x64", + "aarch64" => "arm64", + other => other, + }; + format!("{platform}-{arch}") +} diff --git a/crates/codra-cli/src/tasks/summarize_context.rs b/crates/codra-cli/src/tasks/summarize_context.rs index 7eed242..d01dc9e 100644 --- a/crates/codra-cli/src/tasks/summarize_context.rs +++ b/crates/codra-cli/src/tasks/summarize_context.rs @@ -1,3 +1,5 @@ +use std::{env, path::PathBuf}; + use serde_json::{json, Value}; use crate::context::CodraGitHubContext; @@ -5,12 +7,20 @@ use crate::events::EventEmitter; pub fn run(emitter: &EventEmitter, ctx: &CodraGitHubContext) -> Result { let mode = serde_json::to_value(&ctx.mode).unwrap_or(json!("local")); + let root = ctx + .local_git + .as_ref() + .and_then(|git| git.root.as_ref()) + .map(PathBuf::from) + .unwrap_or_else(|| env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); + let codra_md_exists = root.join("CODRA.md").exists(); let summary = json!({ "overview": format!( - "Context mode: {mode}\nAvailable: {}\nRepository: {}\nEvent: {}", + "Context mode: {mode}\nAvailable: {}\nRepository: {}\nEvent: {}\nCODRA.md: {}", ctx.available, ctx.repository.as_deref().unwrap_or("n/a"), - ctx.event_name.as_deref().unwrap_or("n/a") + ctx.event_name.as_deref().unwrap_or("n/a"), + if codra_md_exists { "present" } else { "missing" } ), "changedFiles": [], "riskAreas": [], @@ -31,9 +41,10 @@ pub fn run(emitter: &EventEmitter, ctx: &CodraGitHubContext) -> Result Result<(), String> { + let cwd = project::current_dir(); + let git = git_summary(); + + println!("Codra"); + println!(); + println!("Local-first AI coding agent for your repo."); + println!(); + println!("Directory: {}", cwd.display()); + println!("Git: {}", git); + println!("Mode: local"); + println!("Model: not configured"); + println!("GitHub context: local"); + println!(); + println!("Commands:"); + println!(" codra run --task review-pr --jsonl"); + println!(" codra run --task summarize-context"); + println!(" codra init"); + println!(" codra doctor"); + println!(" codra --help"); + println!(); + println!("Next:"); + println!(" Run \"codra init\" to create CODRA.md for this repo."); + println!(" Run \"codra doctor\" to check your environment."); + Ok(()) +} + +pub fn help() -> Result<(), String> { + println!("codra "); + println!(" init [--force] [--dry-run] Create CODRA.md and .codra project skeleton"); + println!(" doctor [--json] Check local Codra environment readiness"); + println!(" run --task [--jsonl] Run a task with optional JSONL event stream"); + println!(" Tasks: review-pr, explain-issue, summarize-context"); + println!( + " smoke Validate local tool registry and workspace readiness" + ); + println!(" provider check Check active provider health"); + println!(" worker add Register a remote worker"); + println!(" worker check Probe a registered worker's health endpoint"); + println!(" worker list List registered workers"); + println!(" worker pair Interactive pair and verify a remote worker"); + println!(" worker trust Update a worker's trust level"); + println!(" worker remove Remove/unpair a registered worker"); + println!(" headless Run a dry-run headless planning surface"); + println!(" mcp-server Print MCP-compatible server/tool metadata"); + Ok(()) +} + +fn git_summary() -> String { + if !project::command_exists("git") { + return "not available".to_string(); + } + + if !project::is_inside_git_repo() { + return "not a git repo".to_string(); + } + + let branch = project::git_branch().unwrap_or_else(|| "detached".to_string()); + let status = match project::git_status_short() { + Some(value) if !value.trim().is_empty() => "dirty", + _ => "clean", + }; + format!("branch {branch}, {status}") +} diff --git a/crates/codra-cli/tests/terminal_experience_test.rs b/crates/codra-cli/tests/terminal_experience_test.rs new file mode 100644 index 0000000..8f3a5b8 --- /dev/null +++ b/crates/codra-cli/tests/terminal_experience_test.rs @@ -0,0 +1,113 @@ +use std::fs; +use std::path::PathBuf; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn codra_bin() -> String { + std::env::var("CARGO_BIN_EXE_codra").expect("CARGO_BIN_EXE_codra must be set") +} + +fn temp_workspace(name: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time must be available") + .as_nanos(); + let path = std::env::temp_dir().join(format!("codra-{name}-{}-{nanos}", std::process::id())); + fs::create_dir_all(&path).expect("create temp workspace"); + path +} + +#[test] +fn default_command_prints_welcome() { + let workspace = temp_workspace("welcome"); + let output = Command::new(codra_bin()) + .current_dir(&workspace) + .output() + .expect("run codra binary"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Codra")); + assert!(stdout.contains("Local-first AI coding agent for your repo.")); + assert!(stdout.contains("codra init")); + assert!(stdout.contains("codra doctor")); + + fs::remove_dir_all(workspace).ok(); +} + +#[test] +fn init_creates_project_skeleton() { + let workspace = temp_workspace("init"); + let output = Command::new(codra_bin()) + .current_dir(&workspace) + .arg("init") + .output() + .expect("run codra init"); + + assert!(output.status.success()); + assert!(workspace.join("CODRA.md").is_file()); + assert!(workspace.join(".codra").is_dir()); + assert!(workspace.join(".codra/commands").is_dir()); + assert!(workspace.join(".codra/agents").is_dir()); + assert!(workspace.join(".codra/commands/review-pr.md").is_file()); + assert!(workspace.join(".codra/commands/explain-issue.md").is_file()); + assert!(workspace.join(".codra/agents/code-reviewer.md").is_file()); + + fs::remove_dir_all(workspace).ok(); +} + +#[test] +fn init_does_not_overwrite_existing_codra_md() { + let workspace = temp_workspace("init-existing"); + let codra_md = workspace.join("CODRA.md"); + fs::write(&codra_md, "keep this").expect("write existing CODRA.md"); + + let output = Command::new(codra_bin()) + .current_dir(&workspace) + .arg("init") + .output() + .expect("run codra init"); + + assert!(output.status.success()); + assert_eq!(fs::read_to_string(&codra_md).unwrap(), "keep this"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("exists")); + + fs::remove_dir_all(workspace).ok(); +} + +#[test] +fn doctor_exits_zero() { + let workspace = temp_workspace("doctor"); + let output = Command::new(codra_bin()) + .current_dir(&workspace) + .arg("doctor") + .output() + .expect("run codra doctor"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Codra doctor")); + assert!(stdout.contains("current directory")); + assert!(stdout.contains("npm platform key")); + + fs::remove_dir_all(workspace).ok(); +} + +#[test] +fn run_summarize_context_still_emits_jsonl_events() { + let workspace = temp_workspace("run"); + let output = Command::new(codra_bin()) + .current_dir(&workspace) + .args(["run", "--task", "summarize-context", "--jsonl"]) + .output() + .expect("run summarize-context"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("codra.run.started")); + assert!(stdout.contains("codra.run.completed")); + assert!(stdout.contains("codraMdExists")); + + fs::remove_dir_all(workspace).ok(); +} diff --git a/packages/codra-npm-cli/README.md b/packages/codra-npm-cli/README.md index da97acf..9a40bff 100644 --- a/packages/codra-npm-cli/README.md +++ b/packages/codra-npm-cli/README.md @@ -2,16 +2,68 @@ npm package wrapper for the [Codra](https://github.com/talocode/codra) Rust CLI (`codra-cli` crate). Installs a global `codra` command that forwards to the native binary for your platform. -**Status: not published to npm yet.** +**Status: publish-ready / coming soon. `@codra/cli` is not published to npm yet. Do not publish until the release tarball contains every selected platform binary.** -## Installation (coming soon) +## Installation + +When published, install globally with pnpm or npm: ```bash +pnpm add -g @codra/cli npm install -g @codra/cli codra --help +codra +``` + +Until the package is published, use [local development](#local-development). + +## Quick Start + +```bash +codra +codra init +codra doctor +codra run --task summarize-context --jsonl +``` + +`codra` prints a clean terminal welcome with the current directory, git state, local mode, model status, GitHub context, and useful next commands. + +## Commands + +```bash +# Terminal entrypoint +codra +codra --help + +# Initialize repo guidance files +codra init +codra init --force +codra init --dry-run + +# Check local readiness +codra doctor +codra doctor --json + +# Existing JSONL task protocol +codra run --task review-pr --jsonl +codra run --task explain-issue --jsonl +codra run --task summarize-context --jsonl ``` -Until the package is published, use [local development](#local-development) below. +## Init + +`codra init` detects the git root when available, then creates: + +- `CODRA.md` +- `.codra/commands/review-pr.md` +- `.codra/commands/explain-issue.md` +- `.codra/agents/code-reviewer.md` + +Existing files are preserved unless `--force` is passed. `--dry-run` reports planned changes without writing files. + +## Doctor + +`codra doctor` exits 0 unless an internal error occurs. It reports warnings instead of failing hard when optional tools are missing. Checks include current directory, git, branch, working tree state, cargo, Node, npm, pnpm, GitHub Actions detection, `GITHUB_TOKEN` presence without printing the value, `CODRA.md`, `.codra`, the `codra` binary on `PATH`, and the npm platform key. ## Supported platforms @@ -23,29 +75,31 @@ Until the package is published, use [local development](#local-development) belo | `darwin-arm64` | `bin/native/darwin-arm64/codra` | `codra-darwin-arm64` | | `win32-x64` | `bin/native/win32-x64/codra.exe` | `codra-win32-x64.exe` | -Optional per-platform npm packages may be added later if tarball size becomes too large. For now, all targets ship in `@codra/cli`. +Optional per-platform npm packages may be added later if tarball size becomes too large. For now, all selected targets ship in `@codra/cli`. -## Local development (current host only) +## Local development ```bash cd packages/codra-npm-cli npm run build node bin/codra.js --help +node bin/codra.js +node bin/codra.js doctor node bin/codra.js run --task summarize-context --jsonl npm test ``` `npm run build` runs `cargo build -p codra-cli --release` and copies the binary into `bin/native/-/` only. -## Multi-platform release (artifacts) +## Multi-platform release Release maintainers build per-platform binaries in CI, then package them into one npm tarball. ### Artifact naming -Place prebuilt files in `packages/codra-npm-cli/artifacts/` (or set `CODRA_ARTIFACTS_DIR`): +Place prebuilt files in `packages/codra-npm-cli/artifacts/` or set `CODRA_ARTIFACTS_DIR`: -``` +```text artifacts/codra-linux-x64 artifacts/codra-linux-arm64 artifacts/codra-darwin-x64 @@ -59,53 +113,39 @@ Package into `bin/native/`: npm run build:from-artifacts ``` -- Fails if any artifact is missing (default). -- Set `CODRA_ALLOW_PARTIAL_BINARIES=1` to package only available artifacts (local testing or CI dry runs). +- Fails if any artifact is missing by default. +- Set `CODRA_ALLOW_PARTIAL_BINARIES=1` only for local testing or explicit preview packaging. ### Manual GitHub Actions release Workflow: [`.github/workflows/codra-cli-release.yml`](../../.github/workflows/codra-cli-release.yml) -- Trigger: **workflow_dispatch** only (not automatic on push). -- **Always builds:** linux-x64, linux-arm64, darwin-arm64, win32-x64. -- **Optional:** darwin-x64 (Intel macOS) when `include_darwin_x64: true`. Off by default because `macos-13` runner availability can be slow and block the workflow. -- Job `package-npm` runs after the selected matrix finishes (it does not wait for darwin-x64 when that input is false). -- **Dry run (`publish: false`):** may package partial binaries (`CODRA_ALLOW_PARTIAL_BINARIES=1`) so tarball verification is not blocked by one scarce runner. -- **Real publish (`publish: true`):** requires every platform in the selected matrix unless `allow_partial_binaries: true` is set explicitly. `NPM_TOKEN` secret is required; the publish step is skipped when `publish: false`. -- Recommended dry-run dispatch: `publish=false`, `include_darwin_x64=false`. +- Trigger: `workflow_dispatch` only. +- Always builds linux-x64, linux-arm64, darwin-arm64, and win32-x64. +- Optional darwin-x64 can be enabled when Intel macOS packaging is required. +- Dry runs use `publish=false` and can package partial binaries for verification. +- Real publish uses `publish=true`, requires `NPM_TOKEN`, and must include every selected platform binary unless partial binaries are explicitly allowed. ## Local vs release packaging | Flow | Command | Result | |------|---------|--------| | Local dev | `npm run build` | Current host binary only | -| Release | `npm run build:from-artifacts` | All artifacts → `bin/native/*` | -| `npm pack` / `npm publish` | `prepack` | Uses artifacts if present, else host `build` | +| Release | `npm run build:from-artifacts` | CI artifacts into `bin/native/*` | +| `npm pack` / `npm publish` | `prepack` | Uses artifacts if present, else host build | ## Publishing checklist -When ready to publish (maintainers only): +When ready to publish, maintainers should: -1. Run **Codra CLI release** workflow (or supply all artifacts locally). -2. `npm login` (only if publishing manually). -3. `npm test` -4. `CODRA_EXPECT_PLATFORMS=linux-x64,linux-arm64,darwin-arm64,win32-x64 npm run pack:dry` (add `darwin-x64` when Intel macOS is included). +1. Run the Codra CLI release workflow or supply all artifacts locally. +2. Run `npm test`. +3. Run `CODRA_EXPECT_PLATFORMS=linux-x64,linux-arm64,darwin-arm64,win32-x64 npm run pack:dry`. +4. Add `darwin-x64` to `CODRA_EXPECT_PLATFORMS` when Intel macOS is selected. 5. Verify tarball lists every required `bin/native//` binary. -6. Publish via workflow with `publish: true` **or** `npm publish --access public` (guarded). - -Do not publish until all required target binaries are included unless intentionally shipping a preview with `allow_partial_binaries: true`. - -## Supported commands - -Same as the Rust CLI: - -```bash -codra run --task review-pr --jsonl -codra run --task explain-issue --jsonl -codra run --task summarize-context --jsonl -``` +6. Publish only via guarded workflow or `npm publish --access public` after verification. -## GitHub context (optional) +## GitHub context | Variable | Purpose | |----------|---------| @@ -113,14 +153,15 @@ codra run --task summarize-context --jsonl | `GITHUB_REPOSITORY` | Repository slug | | `GITHUB_EVENT_NAME` | Workflow event name | | `GITHUB_EVENT_PATH` | Path to event JSON payload | -| `GITHUB_TOKEN` | Optional API enrichment (never printed) | +| `GITHUB_TOKEN` | Optional API enrichment, never printed | ## Security -- No AI provider API calls in this CLI layer yet. +- No AI provider API calls are required for basic CLI usage. - Does not print `GITHUB_TOKEN` or other secrets in output. -- Wraps the existing Rust binary unchanged. +- Does not create `.env` files. +- Wraps the existing Rust binary and keeps the JSONL task protocol intact. ## License -MIT — see repository [LICENSE](https://github.com/talocode/codra/blob/main/LICENSE). \ No newline at end of file +MIT. See repository [LICENSE](https://github.com/talocode/codra/blob/main/LICENSE). diff --git a/packages/codra-npm-cli/scripts/test.js b/packages/codra-npm-cli/scripts/test.js index 708db6f..8615d43 100644 --- a/packages/codra-npm-cli/scripts/test.js +++ b/packages/codra-npm-cli/scripts/test.js @@ -155,6 +155,12 @@ function main() { const help = run(['--help']); assertIncludes(help.stdout + help.stderr, 'codra', 'help output'); + const welcome = run([]); + assertIncludes(welcome.stdout, 'Local-first AI coding agent for your repo.', 'welcome output'); + + const doctor = run(['doctor']); + assertIncludes(doctor.stdout, 'Codra doctor', 'doctor output'); + const valid = run(['run', '--task', 'summarize-context', '--jsonl']); assertIncludes(valid.stdout, 'codra.run.started', 'run started event'); assertIncludes(valid.stdout, 'codra.run.completed', 'run completed event');