This release represents a comprehensive transformation of the codebase from Codex to LLMX, enhanced with LiteLLM integration to support 100+ LLM providers through a unified API. ## Major Changes ### Phase 1: Repository & Infrastructure Setup - Established new repository structure and branching strategy - Created comprehensive project documentation (CLAUDE.md, LITELLM-SETUP.md) - Set up development environment and tooling configuration ### Phase 2: Rust Workspace Transformation - Renamed all Rust crates from `codex-*` to `llmx-*` (30+ crates) - Updated package names, binary names, and workspace members - Renamed core modules: codex.rs → llmx.rs, codex_delegate.rs → llmx_delegate.rs - Updated all internal references, imports, and type names - Renamed directories: codex-rs/ → llmx-rs/, codex-backend-openapi-models/ → llmx-backend-openapi-models/ - Fixed all Rust compilation errors after mass rename ### Phase 3: LiteLLM Integration - Integrated LiteLLM for multi-provider LLM support (Anthropic, OpenAI, Azure, Google AI, AWS Bedrock, etc.) - Implemented OpenAI-compatible Chat Completions API support - Added model family detection and provider-specific handling - Updated authentication to support LiteLLM API keys - Renamed environment variables: OPENAI_BASE_URL → LLMX_BASE_URL - Added LLMX_API_KEY for unified authentication - Enhanced error handling for Chat Completions API responses - Implemented fallback mechanisms between Responses API and Chat Completions API ### Phase 4: TypeScript/Node.js Components - Renamed npm package: @codex/codex-cli → @valknar/llmx - Updated TypeScript SDK to use new LLMX APIs and endpoints - Fixed all TypeScript compilation and linting errors - Updated SDK tests to support both API backends - Enhanced mock server to handle multiple API formats - Updated build scripts for cross-platform packaging ### Phase 5: Configuration & Documentation - Updated all configuration files to use LLMX naming - Rewrote README and documentation for LLMX branding - Updated config paths: ~/.codex/ → ~/.llmx/ - Added comprehensive LiteLLM setup guide - Updated all user-facing strings and help text - Created release plan and migration documentation ### Phase 6: Testing & Validation - Fixed all Rust tests for new naming scheme - Updated snapshot tests in TUI (36 frame files) - Fixed authentication storage tests - Updated Chat Completions payload and SSE tests - Fixed SDK tests for new API endpoints - Ensured compatibility with Claude Sonnet 4.5 model - Fixed test environment variables (LLMX_API_KEY, LLMX_BASE_URL) ### Phase 7: Build & Release Pipeline - Updated GitHub Actions workflows for LLMX binary names - Fixed rust-release.yml to reference llmx-rs/ instead of codex-rs/ - Updated CI/CD pipelines for new package names - Made Apple code signing optional in release workflow - Enhanced npm packaging resilience for partial platform builds - Added Windows sandbox support to workspace - Updated dotslash configuration for new binary names ### Phase 8: Final Polish - Renamed all assets (.github images, labels, templates) - Updated VSCode and DevContainer configurations - Fixed all clippy warnings and formatting issues - Applied cargo fmt and prettier formatting across codebase - Updated issue templates and pull request templates - Fixed all remaining UI text references ## Technical Details **Breaking Changes:** - Binary name changed from `codex` to `llmx` - Config directory changed from `~/.codex/` to `~/.llmx/` - Environment variables renamed (CODEX_* → LLMX_*) - npm package renamed to `@valknar/llmx` **New Features:** - Support for 100+ LLM providers via LiteLLM - Unified authentication with LLMX_API_KEY - Enhanced model provider detection and handling - Improved error handling and fallback mechanisms **Files Changed:** - 578 files modified across Rust, TypeScript, and documentation - 30+ Rust crates renamed and updated - Complete rebrand of UI, CLI, and documentation - All tests updated and passing **Dependencies:** - Updated Cargo.lock with new package names - Updated npm dependencies in llmx-cli - Enhanced OpenAPI models for LLMX backend This release establishes LLMX as a standalone project with comprehensive LiteLLM integration, maintaining full backward compatibility with existing functionality while opening support for a wide ecosystem of LLM providers. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Sebastian Krüger <support@pivoine.art>
262 lines
8.4 KiB
Rust
262 lines
8.4 KiB
Rust
use tree_sitter::Node;
|
||
use tree_sitter::Parser;
|
||
use tree_sitter::Tree;
|
||
use tree_sitter_bash::LANGUAGE as BASH;
|
||
|
||
/// Parse the provided bash source using tree-sitter-bash, returning a Tree on
|
||
/// success or None if parsing failed.
|
||
pub fn try_parse_shell(shell_lc_arg: &str) -> Option<Tree> {
|
||
let lang = BASH.into();
|
||
let mut parser = Parser::new();
|
||
#[expect(clippy::expect_used)]
|
||
parser.set_language(&lang).expect("load bash grammar");
|
||
let old_tree: Option<&Tree> = None;
|
||
parser.parse(shell_lc_arg, old_tree)
|
||
}
|
||
|
||
/// Parse a script which may contain multiple simple commands joined only by
|
||
/// the safe logical/pipe/sequencing operators: `&&`, `||`, `;`, `|`.
|
||
///
|
||
/// Returns `Some(Vec<command_words>)` if every command is a plain word‑only
|
||
/// command and the parse tree does not contain disallowed constructs
|
||
/// (parentheses, redirections, substitutions, control flow, etc.). Otherwise
|
||
/// returns `None`.
|
||
pub fn try_parse_word_only_commands_sequence(tree: &Tree, src: &str) -> Option<Vec<Vec<String>>> {
|
||
if tree.root_node().has_error() {
|
||
return None;
|
||
}
|
||
|
||
// List of allowed (named) node kinds for a "word only commands sequence".
|
||
// If we encounter a named node that is not in this list we reject.
|
||
const ALLOWED_KINDS: &[&str] = &[
|
||
// top level containers
|
||
"program",
|
||
"list",
|
||
"pipeline",
|
||
// commands & words
|
||
"command",
|
||
"command_name",
|
||
"word",
|
||
"string",
|
||
"string_content",
|
||
"raw_string",
|
||
"number",
|
||
];
|
||
// Allow only safe punctuation / operator tokens; anything else causes reject.
|
||
const ALLOWED_PUNCT_TOKENS: &[&str] = &["&&", "||", ";", "|", "\"", "'"];
|
||
|
||
let root = tree.root_node();
|
||
let mut cursor = root.walk();
|
||
let mut stack = vec![root];
|
||
let mut command_nodes = Vec::new();
|
||
while let Some(node) = stack.pop() {
|
||
let kind = node.kind();
|
||
if node.is_named() {
|
||
if !ALLOWED_KINDS.contains(&kind) {
|
||
return None;
|
||
}
|
||
if kind == "command" {
|
||
command_nodes.push(node);
|
||
}
|
||
} else {
|
||
// Reject any punctuation / operator tokens that are not explicitly allowed.
|
||
if kind.chars().any(|c| "&;|".contains(c)) && !ALLOWED_PUNCT_TOKENS.contains(&kind) {
|
||
return None;
|
||
}
|
||
if !(ALLOWED_PUNCT_TOKENS.contains(&kind) || kind.trim().is_empty()) {
|
||
// If it's a quote token or operator it's allowed above; we also allow whitespace tokens.
|
||
// Any other punctuation like parentheses, braces, redirects, backticks, etc are rejected.
|
||
return None;
|
||
}
|
||
}
|
||
for child in node.children(&mut cursor) {
|
||
stack.push(child);
|
||
}
|
||
}
|
||
|
||
// Walk uses a stack (LIFO), so re-sort by position to restore source order.
|
||
command_nodes.sort_by_key(Node::start_byte);
|
||
|
||
let mut commands = Vec::new();
|
||
for node in command_nodes {
|
||
if let Some(words) = parse_plain_command_from_node(node, src) {
|
||
commands.push(words);
|
||
} else {
|
||
return None;
|
||
}
|
||
}
|
||
Some(commands)
|
||
}
|
||
|
||
pub fn is_well_known_sh_shell(shell: &str) -> bool {
|
||
if shell == "bash" || shell == "zsh" {
|
||
return true;
|
||
}
|
||
|
||
let shell_name = std::path::Path::new(shell)
|
||
.file_name()
|
||
.and_then(|s| s.to_str())
|
||
.unwrap_or(shell);
|
||
matches!(shell_name, "bash" | "zsh")
|
||
}
|
||
|
||
pub fn extract_bash_command(command: &[String]) -> Option<(&str, &str)> {
|
||
let [shell, flag, script] = command else {
|
||
return None;
|
||
};
|
||
if flag != "-lc" || !is_well_known_sh_shell(shell) {
|
||
return None;
|
||
}
|
||
Some((shell, script))
|
||
}
|
||
|
||
/// Returns the sequence of plain commands within a `bash -lc "..."` or
|
||
/// `zsh -lc "..."` invocation when the script only contains word-only commands
|
||
/// joined by safe operators.
|
||
pub fn parse_shell_lc_plain_commands(command: &[String]) -> Option<Vec<Vec<String>>> {
|
||
let (_, script) = extract_bash_command(command)?;
|
||
|
||
let tree = try_parse_shell(script)?;
|
||
try_parse_word_only_commands_sequence(&tree, script)
|
||
}
|
||
|
||
fn parse_plain_command_from_node(cmd: tree_sitter::Node, src: &str) -> Option<Vec<String>> {
|
||
if cmd.kind() != "command" {
|
||
return None;
|
||
}
|
||
let mut words = Vec::new();
|
||
let mut cursor = cmd.walk();
|
||
for child in cmd.named_children(&mut cursor) {
|
||
match child.kind() {
|
||
"command_name" => {
|
||
let word_node = child.named_child(0)?;
|
||
if word_node.kind() != "word" {
|
||
return None;
|
||
}
|
||
words.push(word_node.utf8_text(src.as_bytes()).ok()?.to_owned());
|
||
}
|
||
"word" | "number" => {
|
||
words.push(child.utf8_text(src.as_bytes()).ok()?.to_owned());
|
||
}
|
||
"string" => {
|
||
if child.child_count() == 3
|
||
&& child.child(0)?.kind() == "\""
|
||
&& child.child(1)?.kind() == "string_content"
|
||
&& child.child(2)?.kind() == "\""
|
||
{
|
||
words.push(child.child(1)?.utf8_text(src.as_bytes()).ok()?.to_owned());
|
||
} else {
|
||
return None;
|
||
}
|
||
}
|
||
"raw_string" => {
|
||
let raw_string = child.utf8_text(src.as_bytes()).ok()?;
|
||
let stripped = raw_string
|
||
.strip_prefix('\'')
|
||
.and_then(|s| s.strip_suffix('\''));
|
||
if let Some(s) = stripped {
|
||
words.push(s.to_owned());
|
||
} else {
|
||
return None;
|
||
}
|
||
}
|
||
_ => return None,
|
||
}
|
||
}
|
||
Some(words)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn parse_seq(src: &str) -> Option<Vec<Vec<String>>> {
|
||
let tree = try_parse_shell(src)?;
|
||
try_parse_word_only_commands_sequence(&tree, src)
|
||
}
|
||
|
||
#[test]
|
||
fn accepts_single_simple_command() {
|
||
let cmds = parse_seq("ls -1").unwrap();
|
||
assert_eq!(cmds, vec![vec!["ls".to_string(), "-1".to_string()]]);
|
||
}
|
||
|
||
#[test]
|
||
fn accepts_multiple_commands_with_allowed_operators() {
|
||
let src = "ls && pwd; echo 'hi there' | wc -l";
|
||
let cmds = parse_seq(src).unwrap();
|
||
let expected: Vec<Vec<String>> = vec![
|
||
vec!["ls".to_string()],
|
||
vec!["pwd".to_string()],
|
||
vec!["echo".to_string(), "hi there".to_string()],
|
||
vec!["wc".to_string(), "-l".to_string()],
|
||
];
|
||
assert_eq!(cmds, expected);
|
||
}
|
||
|
||
#[test]
|
||
fn extracts_double_and_single_quoted_strings() {
|
||
let cmds = parse_seq("echo \"hello world\"").unwrap();
|
||
assert_eq!(
|
||
cmds,
|
||
vec![vec!["echo".to_string(), "hello world".to_string()]]
|
||
);
|
||
|
||
let cmds2 = parse_seq("echo 'hi there'").unwrap();
|
||
assert_eq!(
|
||
cmds2,
|
||
vec![vec!["echo".to_string(), "hi there".to_string()]]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn accepts_numbers_as_words() {
|
||
let cmds = parse_seq("echo 123 456").unwrap();
|
||
assert_eq!(
|
||
cmds,
|
||
vec![vec![
|
||
"echo".to_string(),
|
||
"123".to_string(),
|
||
"456".to_string()
|
||
]]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_parentheses_and_subshells() {
|
||
assert!(parse_seq("(ls)").is_none());
|
||
assert!(parse_seq("ls || (pwd && echo hi)").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_redirections_and_unsupported_operators() {
|
||
assert!(parse_seq("ls > out.txt").is_none());
|
||
assert!(parse_seq("echo hi & echo bye").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_command_and_process_substitutions_and_expansions() {
|
||
assert!(parse_seq("echo $(pwd)").is_none());
|
||
assert!(parse_seq("echo `pwd`").is_none());
|
||
assert!(parse_seq("echo $HOME").is_none());
|
||
assert!(parse_seq("echo \"hi $USER\"").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_variable_assignment_prefix() {
|
||
assert!(parse_seq("FOO=bar ls").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_trailing_operator_parse_error() {
|
||
assert!(parse_seq("ls &&").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn parse_zsh_lc_plain_commands() {
|
||
let command = vec!["zsh".to_string(), "-lc".to_string(), "ls".to_string()];
|
||
let parsed = parse_shell_lc_plain_commands(&command).unwrap();
|
||
assert_eq!(parsed, vec![vec!["ls".to_string()]]);
|
||
}
|
||
}
|