use std::{
collections::{BTreeMap, HashMap},
fs,
path::PathBuf,
};
use color_eyre::{
Result,
eyre::{Context, ContextCompat, eyre},
};
use crossterm::{
event::{KeyCode, KeyEvent, KeyModifiers},
style::{Attribute, Attributes, Color, ContentStyle},
};
use directories::ProjectDirs;
use itertools::Itertools;
use serde::{
Deserialize,
de::{Deserializer, Error},
};
use crate::{
ai::{AiClient, AiProviderBase},
model::SearchMode,
};
#[derive(Clone, Deserialize)]
#[cfg_attr(test, derive(Debug, PartialEq))]
#[cfg_attr(not(test), serde(default))]
pub struct Config {
pub data_dir: PathBuf,
pub check_updates: bool,
pub inline: bool,
pub search: SearchConfig,
pub logs: LogsConfig,
pub keybindings: KeyBindingsConfig,
pub theme: Theme,
pub gist: GistConfig,
pub tuning: SearchTuning,
pub ai: AiConfig,
}
#[derive(Clone, Copy, Deserialize)]
#[cfg_attr(test, derive(Debug, PartialEq))]
#[cfg_attr(not(test), serde(default))]
pub struct SearchConfig {
pub delay: u64,
pub mode: SearchMode,
pub user_only: bool,
pub exec_on_alias_match: bool,
}
#[derive(Clone, Deserialize)]
#[cfg_attr(test, derive(Debug, PartialEq))]
#[cfg_attr(not(test), serde(default))]
pub struct LogsConfig {
pub enabled: bool,
pub filter: String,
}
#[derive(Clone, Deserialize)]
#[cfg_attr(test, derive(Debug, PartialEq))]
#[cfg_attr(not(test), serde(default))]
pub struct KeyBindingsConfig(
#[serde(deserialize_with = "deserialize_bindings_with_defaults")] BTreeMap<KeyBindingAction, KeyBinding>,
);
#[derive(Copy, Clone, Deserialize, PartialOrd, PartialEq, Eq, Ord, Debug)]
#[cfg_attr(test, derive(strum::EnumIter))]
#[serde(rename_all = "snake_case")]
pub enum KeyBindingAction {
Quit,
Update,
Delete,
Confirm,
Execute,
#[serde(rename = "ai")]
AI,
SearchMode,
SearchUserOnly,
VariableNext,
VariablePrev,
}
#[derive(Clone, Deserialize)]
#[cfg_attr(test, derive(Debug, PartialEq))]
pub struct KeyBinding(#[serde(deserialize_with = "deserialize_key_events")] Vec<KeyEvent>);
#[derive(Clone, Deserialize)]
#[cfg_attr(test, derive(Debug, PartialEq))]
#[cfg_attr(not(test), serde(default))]
pub struct Theme {
#[serde(deserialize_with = "deserialize_style")]
pub primary: ContentStyle,
#[serde(deserialize_with = "deserialize_style")]
pub secondary: ContentStyle,
#[serde(deserialize_with = "deserialize_style")]
pub accent: ContentStyle,
#[serde(deserialize_with = "deserialize_style")]
pub comment: ContentStyle,
#[serde(deserialize_with = "deserialize_style")]
pub error: ContentStyle,
#[serde(deserialize_with = "deserialize_color")]
pub highlight: Option<Color>,
pub highlight_symbol: String,
#[serde(deserialize_with = "deserialize_style")]
pub highlight_primary: ContentStyle,
#[serde(deserialize_with = "deserialize_style")]
pub highlight_secondary: ContentStyle,
#[serde(deserialize_with = "deserialize_style")]
pub highlight_accent: ContentStyle,
#[serde(deserialize_with = "deserialize_style")]
pub highlight_comment: ContentStyle,
}
#[derive(Clone, Default, Deserialize)]
#[cfg_attr(test, derive(Debug, PartialEq))]
pub struct GistConfig {
pub id: String,
pub token: String,
}
#[derive(Clone, Copy, Default, Deserialize)]
#[cfg_attr(test, derive(Debug, PartialEq))]
#[cfg_attr(not(test), serde(default))]
pub struct SearchTuning {
pub commands: SearchCommandTuning,
pub variables: SearchVariableTuning,
}
#[derive(Clone, Copy, Default, Deserialize)]
#[cfg_attr(test, derive(Debug, PartialEq))]
#[cfg_attr(not(test), serde(default))]
pub struct SearchCommandTuning {
pub text: SearchCommandsTextTuning,
pub path: SearchPathTuning,
pub usage: SearchUsageTuning,
}
#[derive(Clone, Copy, Deserialize)]
#[cfg_attr(test, derive(Debug, PartialEq))]
#[cfg_attr(not(test), serde(default))]
pub struct SearchCommandsTextTuning {
pub points: u32,
pub command: f64,
pub description: f64,
pub auto: SearchCommandsTextAutoTuning,
}
#[derive(Clone, Copy, Deserialize)]
#[cfg_attr(test, derive(Debug, PartialEq))]
#[cfg_attr(not(test), serde(default))]
pub struct SearchCommandsTextAutoTuning {
pub prefix: f64,
pub fuzzy: f64,
pub relaxed: f64,
pub root: f64,
}
#[derive(Clone, Copy, Deserialize)]
#[cfg_attr(test, derive(Debug, PartialEq))]
#[cfg_attr(not(test), serde(default))]
pub struct SearchPathTuning {
pub points: u32,
pub exact: f64,
pub ancestor: f64,
pub descendant: f64,
pub unrelated: f64,
}
#[derive(Clone, Copy, Deserialize)]
#[cfg_attr(test, derive(Debug, PartialEq))]
#[cfg_attr(not(test), serde(default))]
pub struct SearchUsageTuning {
pub points: u32,
}
#[derive(Clone, Copy, Default, Deserialize)]
#[cfg_attr(test, derive(Debug, PartialEq))]
#[cfg_attr(not(test), serde(default))]
pub struct SearchVariableTuning {
pub completion: SearchVariableCompletionTuning,
pub context: SearchVariableContextTuning,
pub path: SearchPathTuning,
}
#[derive(Clone, Copy, Deserialize)]
#[cfg_attr(test, derive(Debug, PartialEq))]
#[cfg_attr(not(test), serde(default))]
pub struct SearchVariableCompletionTuning {
pub points: u32,
}
#[derive(Clone, Copy, Deserialize)]
#[cfg_attr(test, derive(Debug, PartialEq))]
#[cfg_attr(not(test), serde(default))]
pub struct SearchVariableContextTuning {
pub points: u32,
}
#[derive(Clone, Deserialize)]
#[cfg_attr(test, derive(Debug, PartialEq))]
#[cfg_attr(not(test), serde(default))]
pub struct AiConfig {
pub enabled: bool,
pub prompts: AiPromptsConfig,
pub models: AiModelsConfig,
#[serde(deserialize_with = "deserialize_catalog_with_defaults")]
pub catalog: BTreeMap<String, AiModelConfig>,
}
#[derive(Clone, Deserialize)]
#[cfg_attr(test, derive(Debug, PartialEq))]
#[cfg_attr(not(test), serde(default))]
pub struct AiPromptsConfig {
pub suggest: String,
pub fix: String,
pub import: String,
pub completion: String,
}
#[derive(Clone, Deserialize)]
#[cfg_attr(test, derive(Debug, PartialEq))]
#[cfg_attr(not(test), serde(default))]
pub struct AiModelsConfig {
pub suggest: String,
pub fix: String,
pub import: String,
pub completion: String,
pub fallback: String,
}
#[derive(Clone, Deserialize)]
#[cfg_attr(test, derive(Debug, PartialEq))]
#[serde(tag = "provider", rename_all = "snake_case")]
pub enum AiModelConfig {
Openai(OpenAiModelConfig),
Gemini(GeminiModelConfig),
Anthropic(AnthropicModelConfig),
Ollama(OllamaModelConfig),
}
#[derive(Clone, Deserialize)]
#[cfg_attr(test, derive(Debug, PartialEq))]
pub struct OpenAiModelConfig {
pub model: String,
#[serde(default = "default_openai_url")]
pub url: String,
#[serde(default = "default_openai_api_key_env")]
pub api_key_env: String,
}
fn default_openai_url() -> String {
"https://api.openai.com/v1".to_string()
}
fn default_openai_api_key_env() -> String {
"OPENAI_API_KEY".to_string()
}
#[derive(Clone, Deserialize)]
#[cfg_attr(test, derive(Debug, PartialEq))]
pub struct GeminiModelConfig {
pub model: String,
#[serde(default = "default_gemini_url")]
pub url: String,
#[serde(default = "default_gemini_api_key_env")]
pub api_key_env: String,
}
fn default_gemini_url() -> String {
"https://generativelanguage.googleapis.com/v1beta".to_string()
}
fn default_gemini_api_key_env() -> String {
"GEMINI_API_KEY".to_string()
}
#[derive(Clone, Deserialize)]
#[cfg_attr(test, derive(Debug, PartialEq))]
pub struct AnthropicModelConfig {
pub model: String,
#[serde(default = "default_anthropic_url")]
pub url: String,
#[serde(default = "default_anthropic_api_key_env")]
pub api_key_env: String,
}
fn default_anthropic_url() -> String {
"https://api.anthropic.com/v1".to_string()
}
fn default_anthropic_api_key_env() -> String {
"ANTHROPIC_API_KEY".to_string()
}
#[derive(Clone, Deserialize)]
#[cfg_attr(test, derive(Debug, PartialEq))]
pub struct OllamaModelConfig {
pub model: String,
#[serde(default = "default_ollama_url")]
pub url: String,
#[serde(default = "default_ollama_api_key_env")]
pub api_key_env: String,
}
fn default_ollama_url() -> String {
"http://localhost:11434".to_string()
}
fn default_ollama_api_key_env() -> String {
"OLLAMA_API_KEY".to_string()
}
pub struct ConfigLoadStats {
pub default_config_path: bool,
pub config_path: PathBuf,
pub config_loaded: bool,
pub default_data_dir: bool,
}
impl Config {
pub fn init(config_file: Option<PathBuf>) -> Result<(Self, ConfigLoadStats)> {
let proj_dirs = ProjectDirs::from("org", "IntelliShell", "Intelli-Shell")
.wrap_err("Couldn't initialize project directory")?;
let config_dir = proj_dirs.config_dir().to_path_buf();
let mut stats = ConfigLoadStats {
default_config_path: config_file.is_none(),
config_path: config_file.unwrap_or_else(|| config_dir.join("config.toml")),
config_loaded: false,
default_data_dir: false,
};
let mut config = if stats.config_path.exists() {
stats.config_loaded = true;
let config_str = fs::read_to_string(&stats.config_path)
.wrap_err_with(|| format!("Couldn't read config file {}", stats.config_path.display()))?;
toml::from_str(&config_str)
.wrap_err_with(|| format!("Couldn't parse config file {}", stats.config_path.display()))?
} else {
Config::default()
};
if config.data_dir.as_os_str().is_empty() {
stats.default_data_dir = true;
config.data_dir = proj_dirs.data_dir().to_path_buf();
}
let conflicts = config.keybindings.find_conflicts();
if !conflicts.is_empty() {
return Err(eyre!(
"Couldn't parse config file {}\n\nThere are some key binding conflicts:\n{}",
stats.config_path.display(),
conflicts
.into_iter()
.map(|(_, a)| format!("- {}", a.into_iter().map(|a| format!("{a:?}")).join(", ")))
.join("\n")
));
}
if config.ai.enabled {
let AiModelsConfig {
suggest,
fix,
import,
completion,
fallback,
} = &config.ai.models;
let catalog = &config.ai.catalog;
let mut missing = Vec::new();
if !catalog.contains_key(suggest) {
missing.push((suggest, "suggest"));
}
if !catalog.contains_key(fix) {
missing.push((fix, "fix"));
}
if !catalog.contains_key(import) {
missing.push((import, "import"));
}
if !catalog.contains_key(completion) {
missing.push((completion, "completion"));
}
if !catalog.contains_key(fallback) {
missing.push((fallback, "fallback"));
}
if !missing.is_empty() {
return Err(eyre!(
"Couldn't parse config file {}\n\nMissing model definitions on the catalog:\n{}",
stats.config_path.display(),
missing
.into_iter()
.into_group_map()
.into_iter()
.map(|(k, v)| format!(
"- {k} used in {}",
v.into_iter().map(|v| format!("ai.models.{v}")).join(", ")
))
.join("\n")
));
}
}
fs::create_dir_all(&config.data_dir)
.wrap_err_with(|| format!("Could't create data dir {}", config.data_dir.display()))?;
Ok((config, stats))
}
}
impl KeyBindingsConfig {
pub fn get(&self, action: &KeyBindingAction) -> &KeyBinding {
self.0.get(action).unwrap()
}
pub fn get_action_matching(&self, event: &KeyEvent) -> Option<KeyBindingAction> {
self.0.iter().find_map(
|(action, binding)| {
if binding.matches(event) { Some(*action) } else { None }
},
)
}
pub fn find_conflicts(&self) -> Vec<(KeyEvent, Vec<KeyBindingAction>)> {
let mut event_to_actions_map: HashMap<KeyEvent, Vec<KeyBindingAction>> = HashMap::new();
for (action, key_binding) in self.0.iter() {
for event_in_binding in key_binding.0.iter() {
event_to_actions_map.entry(*event_in_binding).or_default().push(*action);
}
}
event_to_actions_map
.into_iter()
.filter_map(|(key_event, actions)| {
if actions.len() > 1 {
Some((key_event, actions))
} else {
None
}
})
.collect()
}
}
impl KeyBinding {
pub fn matches(&self, event: &KeyEvent) -> bool {
self.0
.iter()
.any(|e| e.code == event.code && e.modifiers == event.modifiers)
}
}
impl Theme {
pub fn highlight_primary_full(&self) -> ContentStyle {
if let Some(color) = self.highlight {
let mut ret = self.highlight_primary;
ret.background_color = Some(color);
ret
} else {
self.highlight_primary
}
}
pub fn highlight_secondary_full(&self) -> ContentStyle {
if let Some(color) = self.highlight {
let mut ret = self.highlight_secondary;
ret.background_color = Some(color);
ret
} else {
self.highlight_secondary
}
}
pub fn highlight_accent_full(&self) -> ContentStyle {
if let Some(color) = self.highlight {
let mut ret = self.highlight_accent;
ret.background_color = Some(color);
ret
} else {
self.highlight_accent
}
}
pub fn highlight_comment_full(&self) -> ContentStyle {
if let Some(color) = self.highlight {
let mut ret = self.highlight_comment;
ret.background_color = Some(color);
ret
} else {
self.highlight_comment
}
}
}
impl AiConfig {
pub fn suggest_client(&self) -> crate::errors::Result<AiClient<'_>> {
AiClient::new(
&self.models.suggest,
self.catalog.get(&self.models.suggest).unwrap(),
&self.models.fallback,
self.catalog.get(&self.models.fallback),
)
}
pub fn fix_client(&self) -> crate::errors::Result<AiClient<'_>> {
AiClient::new(
&self.models.fix,
self.catalog.get(&self.models.fix).unwrap(),
&self.models.fallback,
self.catalog.get(&self.models.fallback),
)
}
pub fn import_client(&self) -> crate::errors::Result<AiClient<'_>> {
AiClient::new(
&self.models.import,
self.catalog.get(&self.models.import).unwrap(),
&self.models.fallback,
self.catalog.get(&self.models.fallback),
)
}
pub fn completion_client(&self) -> crate::errors::Result<AiClient<'_>> {
AiClient::new(
&self.models.completion,
self.catalog.get(&self.models.completion).unwrap(),
&self.models.fallback,
self.catalog.get(&self.models.fallback),
)
}
}
impl AiModelConfig {
pub fn provider(&self) -> &dyn AiProviderBase {
match self {
AiModelConfig::Openai(conf) => conf,
AiModelConfig::Gemini(conf) => conf,
AiModelConfig::Anthropic(conf) => conf,
AiModelConfig::Ollama(conf) => conf,
}
}
}
impl Default for Config {
fn default() -> Self {
Self {
data_dir: PathBuf::new(),
check_updates: true,
inline: true,
search: SearchConfig::default(),
logs: LogsConfig::default(),
keybindings: KeyBindingsConfig::default(),
theme: Theme::default(),
gist: GistConfig::default(),
tuning: SearchTuning::default(),
ai: AiConfig::default(),
}
}
}
impl Default for SearchConfig {
fn default() -> Self {
Self {
delay: 250,
mode: SearchMode::Auto,
user_only: false,
exec_on_alias_match: false,
}
}
}
impl Default for LogsConfig {
fn default() -> Self {
Self {
enabled: false,
filter: String::from("info"),
}
}
}
impl Default for KeyBindingsConfig {
fn default() -> Self {
Self(BTreeMap::from([
(KeyBindingAction::Quit, KeyBinding(vec![KeyEvent::from(KeyCode::Esc)])),
(
KeyBindingAction::Update,
KeyBinding(vec![
KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL),
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL),
KeyEvent::from(KeyCode::F(2)),
]),
),
(
KeyBindingAction::Delete,
KeyBinding(vec![KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)]),
),
(
KeyBindingAction::Confirm,
KeyBinding(vec![KeyEvent::from(KeyCode::Tab), KeyEvent::from(KeyCode::Enter)]),
),
(
KeyBindingAction::Execute,
KeyBinding(vec![
KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL),
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
]),
),
(
KeyBindingAction::AI,
KeyBinding(vec![
KeyEvent::new(KeyCode::Char('i'), KeyModifiers::CONTROL),
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
]),
),
(
KeyBindingAction::SearchMode,
KeyBinding(vec![KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)]),
),
(
KeyBindingAction::SearchUserOnly,
KeyBinding(vec![KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)]),
),
(
KeyBindingAction::VariableNext,
KeyBinding(vec![KeyEvent::new(KeyCode::Tab, KeyModifiers::CONTROL)]),
),
(
KeyBindingAction::VariablePrev,
KeyBinding(vec![
KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT),
KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT),
]),
),
]))
}
}
impl Default for Theme {
fn default() -> Self {
let primary = ContentStyle::new();
let highlight_primary = primary;
let mut secondary = ContentStyle::new();
secondary.attributes.set(Attribute::Dim);
let highlight_secondary = ContentStyle::new();
let mut accent = ContentStyle::new();
accent.foreground_color = Some(Color::Yellow);
let highlight_accent = accent;
let mut comment = ContentStyle::new();
comment.foreground_color = Some(Color::Green);
comment.attributes.set(Attribute::Italic);
let highlight_comment = comment;
let mut error = ContentStyle::new();
error.foreground_color = Some(Color::DarkRed);
Self {
primary,
secondary,
accent,
comment,
error,
highlight: Some(Color::DarkGrey),
highlight_symbol: String::from("» "),
highlight_primary,
highlight_secondary,
highlight_accent,
highlight_comment,
}
}
}
impl Default for SearchCommandsTextTuning {
fn default() -> Self {
Self {
points: 600,
command: 2.0,
description: 1.0,
auto: SearchCommandsTextAutoTuning::default(),
}
}
}
impl Default for SearchCommandsTextAutoTuning {
fn default() -> Self {
Self {
prefix: 1.5,
fuzzy: 1.0,
relaxed: 0.5,
root: 2.0,
}
}
}
impl Default for SearchUsageTuning {
fn default() -> Self {
Self { points: 100 }
}
}
impl Default for SearchPathTuning {
fn default() -> Self {
Self {
points: 300,
exact: 1.0,
ancestor: 0.5,
descendant: 0.25,
unrelated: 0.1,
}
}
}
impl Default for SearchVariableCompletionTuning {
fn default() -> Self {
Self { points: 200 }
}
}
impl Default for SearchVariableContextTuning {
fn default() -> Self {
Self { points: 700 }
}
}
fn default_ai_catalog() -> BTreeMap<String, AiModelConfig> {
BTreeMap::from([
(
"main".to_string(),
AiModelConfig::Gemini(GeminiModelConfig {
model: "gemini-flash-latest".to_string(),
url: default_gemini_url(),
api_key_env: default_gemini_api_key_env(),
}),
),
(
"fallback".to_string(),
AiModelConfig::Gemini(GeminiModelConfig {
model: "gemini-flash-lite-latest".to_string(),
url: default_gemini_url(),
api_key_env: default_gemini_api_key_env(),
}),
),
])
}
impl Default for AiConfig {
fn default() -> Self {
Self {
enabled: false,
models: AiModelsConfig::default(),
prompts: AiPromptsConfig::default(),
catalog: default_ai_catalog(),
}
}
}
impl Default for AiModelsConfig {
fn default() -> Self {
Self {
suggest: "main".to_string(),
fix: "main".to_string(),
import: "main".to_string(),
completion: "main".to_string(),
fallback: "fallback".to_string(),
}
}
}
impl Default for AiPromptsConfig {
fn default() -> Self {
Self {
suggest: String::from(
r#"##OS_SHELL_INFO##
##WORKING_DIR##
### Instructions
You are an expert CLI assistant. Your task is to generate shell command templates based on the user's request.
Your entire response MUST be a single, valid JSON object conforming to the provided schema and nothing else.
### Shell Paradigm, Syntax, and Versioning
**This is the most important instruction.** Shells have fundamentally different syntaxes, data models, and features depending on their family and version. You MUST adhere strictly to these constraints.
1. **Recognize the Shell Paradigm:**
- **POSIX / Text-Stream (bash, zsh, fish):** Operate on **text streams**. Use tools like `grep`, `sed`, `awk`.
- **Object-Pipeline (PowerShell, Nushell):** Operate on **structured data (objects)**. You MUST use internal commands for filtering/selection. AVOID external text-processing tools.
- **Legacy (cmd.exe):** Has unique syntax for loops (`FOR`), variables (`%VAR%`), and filtering (`findstr`).
2. **Generate Idiomatic Code:**
- Use the shell's built-in features and standard library.
- Follow the shell's naming and style conventions (e.g., `Verb-Noun` in PowerShell).
- Leverage the shell's core strengths (e.g., object manipulation in Nushell).
3. **Ensure Syntactic Correctness:**
- Pay close attention to variable syntax (`$var`, `$env:VAR`, `$env.VAR`, `%VAR%`).
- Use the correct operators and quoting rules for the target shell.
4. **Pay Critical Attention to the Version:**
- The shell version is a primary constraint, not a suggestion. This is especially true for shells with rapid development cycles like **Nushell**.
- You **MUST** generate commands that are compatible with the user's specified version.
- Be aware of **breaking changes**. If a command was renamed, replaced, or deprecated in the user's version, you MUST provide the modern, correct equivalent.
### Command Template Syntax
When creating the `command` template string, you must use the following placeholder syntax:
- **Standard Placeholder**: `{{variable-name}}`
- Use for regular arguments that the user needs to provide.
- _Example_: `echo "Hello, {{user-name}}!"`
- **Choice Placeholder**: `{{option1|option2}}`
- Use when the user must choose from a specific set of options.
- _Example_: `git reset {{--soft|--hard}} HEAD~1`
- **Function Placeholder**: `{{variable:function}}`
- Use to apply a transformation function to the user's input. Multiple functions can be chained (e.g., `{{variable:snake:upper}}`).
- Allowed functions: `kebab`, `snake`, `upper`, `lower`, `url`.
- _Example_: For a user input of "My New Feature", `git checkout -b {{branch-name:kebab}}` would produce `git checkout -b my-new-feature`.
- **Secret/Ephemeral Placeholder**: `{{{...}}}`
- Use triple curly braces for sensitive values (like API keys, passwords) or for ephemeral content (like a commit message or a description).
This syntax can wrap any of the placeholder types above.
- _Example_: `export GITHUB_TOKEN={{{api-key}}}` or `git commit -m "{{{message}}}"`
### Suggestion Strategy
Your primary goal is to provide the most relevant and comprehensive set of command templates. Adhere strictly to the following principles when deciding how many suggestions to provide:
1. **Explicit Single Suggestion:**
- If the user's request explicitly asks for **a single suggestion**, you **MUST** return a list containing exactly one suggestion object.
- To cover variations within this single command, make effective use of choice placeholders (e.g., `git reset {{--soft|--hard}}`).
2. **Clear & Unambiguous Request:**
- If the request is straightforward and has one primary, standard solution, provide a **single, well-formed suggestion**.
3. **Ambiguous or Multi-faceted Request:**
- If a request is ambiguous, has multiple valid interpretations, or can be solved using several distinct tools or methods, you **MUST provide a comprehensive list of suggestions**.
- Each distinct approach or interpretation **must be a separate suggestion object**.
- **Be comprehensive and do not limit your suggestions**. For example, a request for "undo a git commit" could mean `git reset`, `git revert`, or `git checkout`. A request to "find files" could yield suggestions for `find`, `fd`, and `locate`. Provide all valid, distinct alternatives.
- **Order the suggestions by relevance**, with the most common or recommended solution appearing first.
"#,
),
fix: String::from(
r#"##OS_SHELL_INFO##
##WORKING_DIR##
##SHELL_HISTORY##
### Instructions
You are an expert command-line assistant. Your mission is to analyze a failed shell command and its error output,
diagnose the root cause, and provide a structured, actionable solution in a single JSON object.
### Output Schema
Your response MUST be a single, valid JSON object with no surrounding text or markdown. It must conform to the following structure:
- `summary`: A very brief, 2-5 word summary of the error category. Examples: "Command Not Found", "Permission Denied", "Invalid Argument", "Git Typo".
- `diagnosis`: A detailed, human-readable explanation of the root cause of the error. This section should explain *what* went wrong and *why*, based on the provided command and error message. It should not contain the solution.
- `proposal`: A human-readable description of the recommended next steps. This can be a description of a fix, diagnostic commands to run, or a suggested workaround.
- `fixed_command`: The corrected, valid, ready-to-execute command string. This field should *only* be populated if a direct command correction is the primary solution (e.g., fixing a typo). For complex issues requiring explanation or privilege changes, this should be an empty string.
### Core Rules
1. **JSON Only**: Your entire output must be a single, raw JSON object. Do not wrap it in code blocks or add any explanatory text.
2. **Holistic Analysis**: Analyze the command's context, syntax, and common user errors. Don't just parse the error message. Consider the user's likely intent.
3. **Strict Wrapping**: Hard-wrap all string values within the JSON to a maximum of 80 characters.
4. **`fixed_command` Logic**: Always populate `fixed_command` with the most likely command to resolve the error. Only leave this field as an empty string if the user's intent is unclear from the context.
"#,
),
import: String::from(
r#"### Instructions
You are an expert tool that extracts and generalizes shell command patterns from arbitrary text content. Your goal is to analyze the provided text, identify all unique command patterns, and present them as a list of suggestions.
Your entire response MUST be a single, valid JSON object conforming to the provided schema. Output nothing but the JSON object itself.
Refer to the syntax definitions, process, and example below to construct your response.
### Command Template Syntax
When creating the `command` template string, you must use the following placeholder syntax:
- **Standard Placeholder**: `{{variable-name}}`
- Use for regular arguments that the user needs to provide.
- _Example_: `echo "Hello, {{user-name}}!"`
- **Choice Placeholder**: `{{option1|option2}}`
- Use when the user must choose from a specific set of options.
- _Example_: `git reset {{--soft|--hard}} HEAD~1`
- **Function Placeholder**: `{{variable:function}}`
- Use to apply a transformation function to the user's input. Multiple functions can be chained (e.g., `{{variable:snake:upper}}`).
- Allowed functions: `kebab`, `snake`, `upper`, `lower`, `url`.
- _Example_: For a user input of "My New Feature", `git checkout -b {{branch-name:kebab}}` would produce `git checkout -b my-new-feature`.
- **Secret/Ephemeral Placeholder**: `{{{...}}}`
- Use triple curly braces for sensitive values (like API keys, passwords) or for ephemeral content (like a commit message or a description).
This syntax can wrap any of the placeholder types above.
- _Example_: `export GITHUB_TOKEN={{{api-key}}}` or `git commit -m "{{{message}}}"`
### Core Process
1. **Extract & Generalize**: Scan the text to find all shell commands. Generalize each one into a template by replacing specific values with the appropriate placeholder type defined in the **Command Template Syntax** section.
2. **Deduplicate**: Consolidate multiple commands that follow the same pattern into a single, representative template. For example, `git checkout bugfix/some-bug` and `git checkout feature/login` must be merged into a single `git checkout {{feature|bugfix}}/{{{description:kebab}}}` suggestion.
### Output Generation
For each unique and deduplicated command pattern you identify:
- Create a suggestion object containing a `description` and a `command`.
- The `description` must be a clear, single-sentence explanation of the command's purpose.
- The `command` must be the final, generalized template string from the core process.
"#,
),
completion: String::from(
r#"##OS_SHELL_INFO##
### Instructions
You are an expert CLI assistant. Your task is to generate a single-line shell command that will be executed in the background to fetch a list of dynamic command-line completions for a given variable.
Your entire response MUST be a single, valid JSON object conforming to the provided schema and nothing else.
### Core Task
The command you create will be run non-interactively to generate a list of suggestions for the user. It must adapt to information that is already known (the "context").
### Command Template Syntax
To make the command context-aware, you must use a special syntax for optional parts of the command. Any segment of the command that depends on contextual information must be wrapped in double curly braces `{{...}}`.
- **Syntax**: `{{--parameter {{variable-name}}}}`
- **Rule**: The entire block, including the parameter and its variable, will only be included in the final command if the `variable-name` exists in the context. If the variable is not present, the entire block is omitted.
- **All-or-Nothing**: If a block contains multiple variables, all of them must be present in the context for the block to be included.
- **_Example_**:
- **Template**: `kubectl get pods {{--context {{context}}}} {{-n {{namespace}}}}`
- If the context provides a `namespace`, the executed command becomes: `kubectl get pods -n prod`
- If the context provides both `namespace` and `context`, it becomes: `kubectl get pods --context my-cluster -n prod`
- If the context is empty, it is simply: `kubectl get pods`
### Requirements
1. **JSON Only**: Your entire output must be a single, raw JSON object. Do not add any explanatory text.
2. **Context is Key**: Every variable like `{{variable-name}}` must be part of a surrounding conditional block `{{...}}`. The command cannot ask for new information.
3. **Produce a List**: The final command, after resolving the context, must print a list of strings to standard output, with each item on a new line. This list will be the source for the completions.
4. **Executable**: The command must be syntactically correct and executable.
"#,
),
}
}
}
fn deserialize_bindings_with_defaults<'de, D>(
deserializer: D,
) -> Result<BTreeMap<KeyBindingAction, KeyBinding>, D::Error>
where
D: Deserializer<'de>,
{
let user_provided_bindings = BTreeMap::<KeyBindingAction, KeyBinding>::deserialize(deserializer)?;
#[cfg(test)]
{
use strum::IntoEnumIterator;
for action_variant in KeyBindingAction::iter() {
if !user_provided_bindings.contains_key(&action_variant) {
return Err(D::Error::custom(format!(
"Missing key binding for action '{action_variant:?}'."
)));
}
}
Ok(user_provided_bindings)
}
#[cfg(not(test))]
{
let mut final_bindings = user_provided_bindings;
let default_bindings = KeyBindingsConfig::default();
for (action, default_binding) in default_bindings.0 {
final_bindings.entry(action).or_insert(default_binding);
}
Ok(final_bindings)
}
}
fn deserialize_key_events<'de, D>(deserializer: D) -> Result<Vec<KeyEvent>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum StringOrVec {
Single(String),
Multiple(Vec<String>),
}
let strings = match StringOrVec::deserialize(deserializer)? {
StringOrVec::Single(s) => vec![s],
StringOrVec::Multiple(v) => v,
};
strings
.iter()
.map(String::as_str)
.map(parse_key_event)
.map(|r| r.map_err(D::Error::custom))
.collect()
}
fn deserialize_color<'de, D>(deserializer: D) -> Result<Option<Color>, D::Error>
where
D: Deserializer<'de>,
{
parse_color(&String::deserialize(deserializer)?).map_err(D::Error::custom)
}
fn deserialize_style<'de, D>(deserializer: D) -> Result<ContentStyle, D::Error>
where
D: Deserializer<'de>,
{
parse_style(&String::deserialize(deserializer)?).map_err(D::Error::custom)
}
fn parse_key_event(raw: &str) -> Result<KeyEvent, String> {
let raw_lower = raw.to_ascii_lowercase();
let (remaining, modifiers) = extract_key_modifiers(&raw_lower);
parse_key_code_with_modifiers(remaining, modifiers)
}
fn extract_key_modifiers(raw: &str) -> (&str, KeyModifiers) {
let mut modifiers = KeyModifiers::empty();
let mut current = raw;
loop {
match current {
rest if rest.starts_with("ctrl-") || rest.starts_with("ctrl+") => {
modifiers.insert(KeyModifiers::CONTROL);
current = &rest[5..];
}
rest if rest.starts_with("shift-") || rest.starts_with("shift+") => {
modifiers.insert(KeyModifiers::SHIFT);
current = &rest[6..];
}
rest if rest.starts_with("alt-") || rest.starts_with("alt+") => {
modifiers.insert(KeyModifiers::ALT);
current = &rest[4..];
}
_ => break,
};
}
(current, modifiers)
}
fn parse_key_code_with_modifiers(raw: &str, mut modifiers: KeyModifiers) -> Result<KeyEvent, String> {
let code = match raw {
"esc" => KeyCode::Esc,
"enter" => KeyCode::Enter,
"left" => KeyCode::Left,
"right" => KeyCode::Right,
"up" => KeyCode::Up,
"down" => KeyCode::Down,
"home" => KeyCode::Home,
"end" => KeyCode::End,
"pageup" => KeyCode::PageUp,
"pagedown" => KeyCode::PageDown,
"backtab" => {
modifiers.insert(KeyModifiers::SHIFT);
KeyCode::BackTab
}
"backspace" => KeyCode::Backspace,
"delete" => KeyCode::Delete,
"insert" => KeyCode::Insert,
"f1" => KeyCode::F(1),
"f2" => KeyCode::F(2),
"f3" => KeyCode::F(3),
"f4" => KeyCode::F(4),
"f5" => KeyCode::F(5),
"f6" => KeyCode::F(6),
"f7" => KeyCode::F(7),
"f8" => KeyCode::F(8),
"f9" => KeyCode::F(9),
"f10" => KeyCode::F(10),
"f11" => KeyCode::F(11),
"f12" => KeyCode::F(12),
"space" | "spacebar" => KeyCode::Char(' '),
"hyphen" => KeyCode::Char('-'),
"minus" => KeyCode::Char('-'),
"tab" => KeyCode::Tab,
c if c.len() == 1 => {
let mut c = c.chars().next().expect("just checked");
if modifiers.contains(KeyModifiers::SHIFT) {
c = c.to_ascii_uppercase();
}
KeyCode::Char(c)
}
_ => return Err(format!("Unable to parse key binding: {raw}")),
};
Ok(KeyEvent::new(code, modifiers))
}
fn parse_color(raw: &str) -> Result<Option<Color>, String> {
let raw_lower = raw.to_ascii_lowercase();
if raw.is_empty() || raw == "none" {
Ok(None)
} else {
Ok(Some(parse_color_inner(&raw_lower)?))
}
}
fn parse_style(raw: &str) -> Result<ContentStyle, String> {
let raw_lower = raw.to_ascii_lowercase();
let (remaining, attributes) = extract_style_attributes(&raw_lower);
let mut style = ContentStyle::new();
style.attributes = attributes;
if !remaining.is_empty() && remaining != "default" {
style.foreground_color = Some(parse_color_inner(remaining)?);
}
Ok(style)
}
fn extract_style_attributes(raw: &str) -> (&str, Attributes) {
let mut attributes = Attributes::none();
let mut current = raw;
loop {
match current {
rest if rest.starts_with("bold") => {
attributes.set(Attribute::Bold);
current = &rest[4..];
if current.starts_with(' ') {
current = ¤t[1..];
}
}
rest if rest.starts_with("dim") => {
attributes.set(Attribute::Dim);
current = &rest[3..];
if current.starts_with(' ') {
current = ¤t[1..];
}
}
rest if rest.starts_with("italic") => {
attributes.set(Attribute::Italic);
current = &rest[6..];
if current.starts_with(' ') {
current = ¤t[1..];
}
}
rest if rest.starts_with("underline") => {
attributes.set(Attribute::Underlined);
current = &rest[9..];
if current.starts_with(' ') {
current = ¤t[1..];
}
}
rest if rest.starts_with("underlined") => {
attributes.set(Attribute::Underlined);
current = &rest[10..];
if current.starts_with(' ') {
current = ¤t[1..];
}
}
_ => break,
};
}
(current.trim(), attributes)
}
fn parse_color_inner(raw: &str) -> Result<Color, String> {
Ok(match raw {
"black" => Color::Black,
"red" => Color::Red,
"green" => Color::Green,
"yellow" => Color::Yellow,
"blue" => Color::Blue,
"magenta" => Color::Magenta,
"cyan" => Color::Cyan,
"gray" | "grey" => Color::Grey,
"dark gray" | "darkgray" | "dark grey" | "darkgrey" => Color::DarkGrey,
"dark red" | "darkred" => Color::DarkRed,
"dark green" | "darkgreen" => Color::DarkGreen,
"dark yellow" | "darkyellow" => Color::DarkYellow,
"dark blue" | "darkblue" => Color::DarkBlue,
"dark magenta" | "darkmagenta" => Color::DarkMagenta,
"dark cyan" | "darkcyan" => Color::DarkCyan,
"white" => Color::White,
rgb if rgb.starts_with("rgb(") => {
let rgb = rgb.trim_start_matches("rgb(").trim_end_matches(")").split(',');
let rgb = rgb
.map(|c| c.trim().parse::<u8>())
.collect::<Result<Vec<u8>, _>>()
.map_err(|_| format!("Unable to parse color: {raw}"))?;
if rgb.len() != 3 {
return Err(format!("Unable to parse color: {raw}"));
}
Color::Rgb {
r: rgb[0],
g: rgb[1],
b: rgb[2],
}
}
hex if hex.starts_with("#") => {
let hex = hex.trim_start_matches("#");
if hex.len() != 6 {
return Err(format!("Unable to parse color: {raw}"));
}
let r = u8::from_str_radix(&hex[0..2], 16).map_err(|_| format!("Unable to parse color: {raw}"))?;
let g = u8::from_str_radix(&hex[2..4], 16).map_err(|_| format!("Unable to parse color: {raw}"))?;
let b = u8::from_str_radix(&hex[4..6], 16).map_err(|_| format!("Unable to parse color: {raw}"))?;
Color::Rgb { r, g, b }
}
c => {
if let Ok(c) = c.parse::<u8>() {
Color::AnsiValue(c)
} else {
return Err(format!("Unable to parse color: {raw}"));
}
}
})
}
fn deserialize_catalog_with_defaults<'de, D>(deserializer: D) -> Result<BTreeMap<String, AiModelConfig>, D::Error>
where
D: Deserializer<'de>,
{
#[allow(unused_mut)]
let mut user_catalog = BTreeMap::<String, AiModelConfig>::deserialize(deserializer)?;
#[cfg(not(test))]
for (key, default_model) in default_ai_catalog() {
user_catalog.entry(key).or_insert(default_model);
}
Ok(user_catalog)
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use strum::IntoEnumIterator;
use super::*;
#[test]
fn test_default_config() -> Result<()> {
let config_str = fs::read_to_string("default_config.toml").wrap_err("Couldn't read default config file")?;
let config: Config = toml::from_str(&config_str).wrap_err("Couldn't parse default config file")?;
assert_eq!(Config::default(), config);
Ok(())
}
#[test]
fn test_default_keybindings_complete() {
let config = KeyBindingsConfig::default();
for action in KeyBindingAction::iter() {
assert!(
config.0.contains_key(&action),
"Missing default binding for action: {action:?}"
);
}
}
#[test]
fn test_default_keybindings_no_conflicts() {
let config = KeyBindingsConfig::default();
let conflicts = config.find_conflicts();
assert_eq!(conflicts.len(), 0, "Key binding conflicts: {conflicts:?}");
}
#[test]
fn test_keybinding_matches() {
let binding = KeyBinding(vec![
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL),
KeyEvent::from(KeyCode::Enter),
]);
assert!(binding.matches(&KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)));
assert!(binding.matches(&KeyEvent::from(KeyCode::Enter)));
assert!(!binding.matches(&KeyEvent::new(
KeyCode::Char('a'),
KeyModifiers::CONTROL | KeyModifiers::ALT
)));
assert!(!binding.matches(&KeyEvent::from(KeyCode::Esc)));
}
#[test]
fn test_simple_keys() {
assert_eq!(
parse_key_event("a").unwrap(),
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())
);
assert_eq!(
parse_key_event("enter").unwrap(),
KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())
);
assert_eq!(
parse_key_event("esc").unwrap(),
KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())
);
}
#[test]
fn test_with_modifiers() {
assert_eq!(
parse_key_event("ctrl-a").unwrap(),
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)
);
assert_eq!(
parse_key_event("alt-enter").unwrap(),
KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT)
);
assert_eq!(
parse_key_event("shift-esc").unwrap(),
KeyEvent::new(KeyCode::Esc, KeyModifiers::SHIFT)
);
}
#[test]
fn test_multiple_modifiers() {
assert_eq!(
parse_key_event("ctrl-alt-a").unwrap(),
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL | KeyModifiers::ALT)
);
assert_eq!(
parse_key_event("ctrl-shift-enter").unwrap(),
KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT)
);
}
#[test]
fn test_invalid_keys() {
let res = parse_key_event("invalid-key");
assert_eq!(res, Err(String::from("Unable to parse key binding: invalid-key")));
}
#[test]
fn test_parse_color_none() {
let color = parse_color("none").unwrap();
assert_eq!(color, None);
}
#[test]
fn test_parse_color_simple() {
let color = parse_color("red").unwrap();
assert_eq!(color, Some(Color::Red));
}
#[test]
fn test_parse_color_rgb() {
let color = parse_color("rgb(50, 25, 15)").unwrap();
assert_eq!(color, Some(Color::Rgb { r: 50, g: 25, b: 15 }));
}
#[test]
fn test_parse_color_rgb_out_of_range() {
let res = parse_color("rgb(500, 25, 15)");
assert_eq!(res, Err(String::from("Unable to parse color: rgb(500, 25, 15)")));
}
#[test]
fn test_parse_color_rgb_invalid() {
let res = parse_color("rgb(50, 25, 15, 5)");
assert_eq!(res, Err(String::from("Unable to parse color: rgb(50, 25, 15, 5)")));
}
#[test]
fn test_parse_color_hex() {
let color = parse_color("#4287f5").unwrap();
assert_eq!(color, Some(Color::Rgb { r: 66, g: 135, b: 245 }));
}
#[test]
fn test_parse_color_hex_out_of_range() {
let res = parse_color("#4287fg");
assert_eq!(res, Err(String::from("Unable to parse color: #4287fg")));
}
#[test]
fn test_parse_color_hex_invalid() {
let res = parse_color("#4287f50");
assert_eq!(res, Err(String::from("Unable to parse color: #4287f50")));
}
#[test]
fn test_parse_color_index() {
let color = parse_color("6").unwrap();
assert_eq!(color, Some(Color::AnsiValue(6)));
}
#[test]
fn test_parse_color_fail() {
let res = parse_color("1234");
assert_eq!(res, Err(String::from("Unable to parse color: 1234")));
}
#[test]
fn test_parse_style_empty() {
let style = parse_style("").unwrap();
assert_eq!(style, ContentStyle::new());
}
#[test]
fn test_parse_style_default() {
let style = parse_style("default").unwrap();
assert_eq!(style, ContentStyle::new());
}
#[test]
fn test_parse_style_simple() {
let style = parse_style("red").unwrap();
assert_eq!(style.foreground_color, Some(Color::Red));
assert_eq!(style.attributes, Attributes::none());
}
#[test]
fn test_parse_style_only_modifier() {
let style = parse_style("bold").unwrap();
assert_eq!(style.foreground_color, None);
let mut expected_attributes = Attributes::none();
expected_attributes.set(Attribute::Bold);
assert_eq!(style.attributes, expected_attributes);
}
#[test]
fn test_parse_style_with_modifier() {
let style = parse_style("italic red").unwrap();
assert_eq!(style.foreground_color, Some(Color::Red));
let mut expected_attributes = Attributes::none();
expected_attributes.set(Attribute::Italic);
assert_eq!(style.attributes, expected_attributes);
}
#[test]
fn test_parse_style_multiple_modifier() {
let style = parse_style("underline dim dark red").unwrap();
assert_eq!(style.foreground_color, Some(Color::DarkRed));
let mut expected_attributes = Attributes::none();
expected_attributes.set(Attribute::Underlined);
expected_attributes.set(Attribute::Dim);
assert_eq!(style.attributes, expected_attributes);
}
}