zarz 0.3.1-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.rs ADDED
@@ -0,0 +1,201 @@
1
+ use std::path::PathBuf;
2
+
3
+ use clap::{Args, Parser, Subcommand, ValueEnum};
4
+
5
+ #[derive(Debug, Clone, PartialEq, ValueEnum)]
6
+ pub enum Provider {
7
+ Anthropic,
8
+ OpenAi,
9
+ Glm,
10
+ }
11
+
12
+ impl Provider {
13
+ pub fn as_str(&self) -> &'static str {
14
+ match self {
15
+ Provider::Anthropic => "anthropic",
16
+ Provider::OpenAi => "openai",
17
+ Provider::Glm => "glm",
18
+ }
19
+ }
20
+
21
+ pub fn from_str(name: &str) -> Option<Self> {
22
+ match name.to_ascii_lowercase().as_str() {
23
+ "anthropic" => Some(Provider::Anthropic),
24
+ "openai" => Some(Provider::OpenAi),
25
+ "glm" => Some(Provider::Glm),
26
+ _ => None,
27
+ }
28
+ }
29
+
30
+ #[allow(dead_code)]
31
+ pub fn from_env_or_default() -> Self {
32
+ match std::env::var("ZARZ_PROVIDER")
33
+ .ok()
34
+ .as_deref()
35
+ .map(|v| v.to_ascii_lowercase())
36
+ {
37
+ Some(ref v) if v == "openai" => Provider::OpenAi,
38
+ Some(ref v) if v == "anthropic" => Provider::Anthropic,
39
+ Some(ref v) if v == "glm" => Provider::Glm,
40
+ _ => Provider::Anthropic,
41
+ }
42
+ }
43
+ }
44
+
45
+ #[derive(Debug, Parser)]
46
+ #[command(
47
+ name = "zarz",
48
+ version,
49
+ about = "ZarzCLI · AI-assisted code refactoring and rewrites",
50
+ author = "Fapzarz",
51
+ long_about = "ZarzCLI - Interactive AI coding assistant\n\nUsage:\n zarz Start interactive chat\n zarz --message \"prompt\" Send a single prompt and exit\n zarz ask \"question\" Ask mode (legacy)\n zarz chat Chat mode (legacy)"
52
+ )]
53
+ pub struct Cli {
54
+ /// Send a message and exit (like Claude Code)
55
+ #[arg(long, visible_alias = "msg")]
56
+ pub message: Option<String>,
57
+
58
+ /// Additional files to include as context
59
+ #[arg(short = 'f', long)]
60
+ pub files: Vec<PathBuf>,
61
+
62
+ #[command(flatten)]
63
+ pub model_args: CommonModelArgs,
64
+
65
+ /// Working directory for the session
66
+ #[arg(long)]
67
+ pub directory: Option<PathBuf>,
68
+
69
+ #[command(subcommand)]
70
+ pub command: Option<Commands>,
71
+ }
72
+
73
+ #[derive(Debug, Subcommand)]
74
+ pub enum Commands {
75
+ /// Ask the model a question with optional code context.
76
+ Ask(AskArgs),
77
+ /// Rewrite one or more files using the model's response.
78
+ Rewrite(RewriteArgs),
79
+ /// Start an interactive chat session with AI code assistance.
80
+ Chat(ChatArgs),
81
+ /// Configure API keys and settings.
82
+ Config(ConfigArgs),
83
+ /// Manage MCP (Model Context Protocol) servers.
84
+ Mcp(McpArgs),
85
+ }
86
+
87
+ #[derive(Debug, Args)]
88
+ pub struct CommonModelArgs {
89
+ /// Target model identifier (e.g. claude-3-5-sonnet-20241022).
90
+ #[arg(short, long)]
91
+ pub model: Option<String>,
92
+ /// Override the default provider.
93
+ #[arg(long, value_enum)]
94
+ pub provider: Option<Provider>,
95
+ /// Override the default API endpoint.
96
+ #[arg(long)]
97
+ pub endpoint: Option<String>,
98
+ /// Optional system prompt override.
99
+ #[arg(long)]
100
+ pub system_prompt: Option<String>,
101
+ /// Timeout in seconds for the request.
102
+ #[arg(long)]
103
+ pub timeout: Option<u64>,
104
+ }
105
+
106
+ #[derive(Debug, Args)]
107
+ pub struct AskArgs {
108
+ #[command(flatten)]
109
+ pub model_args: CommonModelArgs,
110
+ /// Inline prompt text. If omitted, reads from STDIN.
111
+ #[arg(short, long)]
112
+ pub prompt: Option<String>,
113
+ /// Optional file containing additional instructions.
114
+ #[arg(long)]
115
+ pub prompt_file: Option<PathBuf>,
116
+ /// Additional context files to include in the request.
117
+ #[arg(value_name = "FILE", num_args = 0..)]
118
+ pub context_files: Vec<PathBuf>,
119
+ }
120
+
121
+ #[derive(Debug, Args)]
122
+ pub struct RewriteArgs {
123
+ #[command(flatten)]
124
+ pub model_args: CommonModelArgs,
125
+ /// High-level instructions for the rewrite.
126
+ #[arg(short, long)]
127
+ pub instructions: Option<String>,
128
+ /// File containing rewrite instructions.
129
+ #[arg(long)]
130
+ pub instructions_file: Option<PathBuf>,
131
+ /// Apply the changes without confirmation.
132
+ #[arg(long)]
133
+ pub yes: bool,
134
+ /// Preview diff without writing files.
135
+ #[arg(long)]
136
+ pub dry_run: bool,
137
+ /// Target files that will be rewritten.
138
+ #[arg(value_name = "FILE", num_args = 1..)]
139
+ pub files: Vec<PathBuf>,
140
+ }
141
+
142
+ #[derive(Debug, Args)]
143
+ pub struct ChatArgs {
144
+ #[command(flatten)]
145
+ pub model_args: CommonModelArgs,
146
+ /// Working directory for the session (defaults to current directory).
147
+ #[arg(long)]
148
+ pub directory: Option<PathBuf>,
149
+ }
150
+
151
+ #[derive(Debug, Clone, Args)]
152
+ pub struct ConfigArgs {
153
+ /// Reset configuration and run interactive setup.
154
+ #[arg(long)]
155
+ pub reset: bool,
156
+ /// Show current configuration.
157
+ #[arg(long)]
158
+ pub show: bool,
159
+ }
160
+
161
+ #[derive(Debug, Clone, Args)]
162
+ pub struct McpArgs {
163
+ #[command(subcommand)]
164
+ pub command: McpCommands,
165
+ }
166
+
167
+ #[derive(Debug, Clone, Subcommand)]
168
+ pub enum McpCommands {
169
+ /// Add a new MCP server
170
+ Add {
171
+ /// Server name
172
+ name: String,
173
+ /// Server command (for stdio servers)
174
+ #[arg(long)]
175
+ command: Option<String>,
176
+ /// Server arguments
177
+ #[arg(long, num_args = 0..)]
178
+ args: Vec<String>,
179
+ /// Environment variables (KEY=VALUE)
180
+ #[arg(long = "env")]
181
+ env_vars: Vec<String>,
182
+ /// Server URL (for http/sse servers)
183
+ #[arg(long)]
184
+ url: Option<String>,
185
+ /// Transport type: stdio, http, sse
186
+ #[arg(long, default_value = "stdio")]
187
+ transport: String,
188
+ },
189
+ /// List all configured MCP servers
190
+ List,
191
+ /// Get details of a specific MCP server
192
+ Get {
193
+ /// Server name
194
+ name: String,
195
+ },
196
+ /// Remove an MCP server
197
+ Remove {
198
+ /// Server name
199
+ name: String,
200
+ },
201
+ }
package/src/config.rs ADDED
@@ -0,0 +1,249 @@
1
+ use anyhow::{Context, Result};
2
+ use crossterm::style::{Color, Stylize};
3
+ use dialoguer::{theme::ColorfulTheme, Select};
4
+ use serde::{Deserialize, Serialize};
5
+ use std::fs;
6
+ use std::io::{self, Write};
7
+ use std::path::PathBuf;
8
+
9
+ #[derive(Debug, Clone, Serialize, Deserialize, Default)]
10
+ pub struct Config {
11
+ #[serde(skip_serializing_if = "Option::is_none")]
12
+ pub anthropic_api_key: Option<String>,
13
+ #[serde(skip_serializing_if = "Option::is_none")]
14
+ pub openai_api_key: Option<String>,
15
+ #[serde(skip_serializing_if = "Option::is_none")]
16
+ pub glm_api_key: Option<String>,
17
+ }
18
+
19
+ impl Config {
20
+ /// Get the path to the config file (~/.zarz/config.toml)
21
+ pub fn config_path() -> Result<PathBuf> {
22
+ let home = dirs::home_dir()
23
+ .context("Could not determine home directory")?;
24
+ Ok(home.join(".zarz").join("config.toml"))
25
+ }
26
+
27
+ /// Load config from file, or return default if file doesn't exist
28
+ pub fn load() -> Result<Self> {
29
+ let path = Self::config_path()?;
30
+
31
+ if !path.exists() {
32
+ return Ok(Self::default());
33
+ }
34
+
35
+ let content = fs::read_to_string(&path)
36
+ .context("Failed to read config file")?;
37
+
38
+ let config: Config = toml::from_str(&content)
39
+ .context("Failed to parse config file")?;
40
+
41
+ Ok(config)
42
+ }
43
+
44
+ /// Save config to file
45
+ pub fn save(&self) -> Result<()> {
46
+ let path = Self::config_path()?;
47
+
48
+ // Create parent directory if it doesn't exist
49
+ if let Some(parent) = path.parent() {
50
+ fs::create_dir_all(parent)
51
+ .context("Failed to create config directory")?;
52
+ }
53
+
54
+ let content = toml::to_string_pretty(self)
55
+ .context("Failed to serialize config")?;
56
+
57
+ fs::write(&path, content)
58
+ .context("Failed to write config file")?;
59
+
60
+ Ok(())
61
+ }
62
+
63
+ /// Check if at least one API key is configured
64
+ pub fn has_api_key(&self) -> bool {
65
+ self.anthropic_api_key.is_some() || self.openai_api_key.is_some() || self.glm_api_key.is_some()
66
+ }
67
+
68
+ /// Interactive setup to get API keys from user
69
+ pub fn interactive_setup() -> Result<Self> {
70
+ let theme = ColorfulTheme::default();
71
+
72
+ println!(
73
+ "\n{}\n",
74
+ "ZarzCLI Setup".bold().with(Color::Cyan)
75
+ );
76
+ println!(
77
+ "{}",
78
+ "Choose a provider to configure. Use the arrow keys and press Enter.".with(Color::DarkGrey)
79
+ );
80
+ println!(
81
+ "{}",
82
+ "API keys are displayed while typing so you can verify them, then hidden before storing.".with(Color::DarkGrey)
83
+ );
84
+ println!(
85
+ "{}\n",
86
+ "You can configure additional providers later with `zarz config --reset`.".with(Color::DarkGrey)
87
+ );
88
+
89
+ let options = vec![
90
+ "Anthropic Claude (recommended for coding)".bold().with(Color::Yellow).to_string(),
91
+ "OpenAI GPT".bold().with(Color::Yellow).to_string(),
92
+ "GLM (Z.AI - International GLM-4.6)".bold().with(Color::Yellow).to_string(),
93
+ ];
94
+
95
+ let selection = Select::with_theme(&theme)
96
+ .with_prompt("Select a provider to set up")
97
+ .items(&options)
98
+ .default(0)
99
+ .interact()?;
100
+
101
+ let mut config = Self::default();
102
+ let mut enabled = Vec::new();
103
+
104
+ match selection {
105
+ 0 => {
106
+ let key = Self::prompt_for_key("Anthropic API key")?;
107
+ config.anthropic_api_key = Some(key);
108
+ enabled.push("Anthropic Claude");
109
+ println!("{}\n", "✓ Anthropic ready".with(Color::Green));
110
+ }
111
+ 1 => {
112
+ let key = Self::prompt_for_key("OpenAI API key")?;
113
+ config.openai_api_key = Some(key);
114
+ enabled.push("OpenAI GPT");
115
+ println!("{}\n", "✓ OpenAI ready".with(Color::Green));
116
+ }
117
+ _ => {
118
+ let key = Self::prompt_for_key("GLM API key")?;
119
+ config.glm_api_key = Some(key);
120
+ enabled.push("GLM 4.6");
121
+ println!("{}\n", "✓ GLM ready".with(Color::Green));
122
+ }
123
+ }
124
+
125
+ if !config.has_api_key() {
126
+ anyhow::bail!("At least one API key is required to use ZarzCLI");
127
+ }
128
+
129
+ config.save()?;
130
+ println!(
131
+ "{} {}\n",
132
+ "✅".with(Color::Green),
133
+ format!(
134
+ "Configuration saved to {}",
135
+ Self::config_path()?.display()
136
+ )
137
+ .bold()
138
+ );
139
+ println!(
140
+ "{}",
141
+ format!("Enabled providers: {}", enabled.join(", ")).with(Color::Green)
142
+ );
143
+ println!(
144
+ "{}\n",
145
+ "Run `zarz` any time to start chatting.".with(Color::DarkGrey)
146
+ );
147
+
148
+ Ok(config)
149
+ }
150
+
151
+ fn prompt_for_key(label: &str) -> Result<String> {
152
+ loop {
153
+ print!("Enter your {}: ", label);
154
+ io::stdout().flush().ok();
155
+
156
+ let mut key = String::new();
157
+ io::stdin()
158
+ .read_line(&mut key)
159
+ .context("Failed to read API key from stdin")?;
160
+
161
+ let trimmed = key.trim();
162
+ if trimmed.is_empty() {
163
+ println!("{}", "Key cannot be empty. Please try again.".with(Color::Red));
164
+ continue;
165
+ }
166
+
167
+ println!("{}", "Key captured ✔".with(Color::Green));
168
+ println!(
169
+ "{}",
170
+ "(The key is now stored securely and will no longer be displayed.)".with(Color::DarkGrey)
171
+ );
172
+
173
+ return Ok(trimmed.to_string());
174
+ }
175
+ }
176
+
177
+ /// Get Anthropic API key from config or environment
178
+ pub fn get_anthropic_key(&self) -> Option<String> {
179
+ std::env::var("ANTHROPIC_API_KEY")
180
+ .ok()
181
+ .or_else(|| self.anthropic_api_key.clone())
182
+ }
183
+
184
+ /// Get OpenAI API key from config or environment
185
+ pub fn get_openai_key(&self) -> Option<String> {
186
+ std::env::var("OPENAI_API_KEY")
187
+ .ok()
188
+ .or_else(|| self.openai_api_key.clone())
189
+ }
190
+
191
+ /// Get GLM API key from config or environment
192
+ pub fn get_glm_key(&self) -> Option<String> {
193
+ std::env::var("GLM_API_KEY")
194
+ .ok()
195
+ .or_else(|| self.glm_api_key.clone())
196
+ }
197
+
198
+ /// Get default provider based on available API keys
199
+ /// Priority: Anthropic > OpenAI > GLM
200
+ pub fn get_default_provider(&self) -> Option<crate::cli::Provider> {
201
+ if self.get_anthropic_key().is_some() {
202
+ Some(crate::cli::Provider::Anthropic)
203
+ } else if self.get_openai_key().is_some() {
204
+ Some(crate::cli::Provider::OpenAi)
205
+ } else if self.get_glm_key().is_some() {
206
+ Some(crate::cli::Provider::Glm)
207
+ } else {
208
+ None
209
+ }
210
+ }
211
+
212
+ /// Apply config to environment variables
213
+ pub fn apply_to_env(&self) {
214
+ if let Some(key) = &self.anthropic_api_key {
215
+ if std::env::var("ANTHROPIC_API_KEY").is_err() {
216
+ unsafe { std::env::set_var("ANTHROPIC_API_KEY", key); }
217
+ }
218
+ }
219
+ if let Some(key) = &self.openai_api_key {
220
+ if std::env::var("OPENAI_API_KEY").is_err() {
221
+ unsafe { std::env::set_var("OPENAI_API_KEY", key); }
222
+ }
223
+ }
224
+ if let Some(key) = &self.glm_api_key {
225
+ if std::env::var("GLM_API_KEY").is_err() {
226
+ unsafe { std::env::set_var("GLM_API_KEY", key); }
227
+ }
228
+ }
229
+ }
230
+
231
+ /// Remove all stored API keys from disk
232
+ pub fn clear_api_keys(&mut self) -> Result<bool> {
233
+ let mut removed = false;
234
+
235
+ if self.anthropic_api_key.take().is_some() {
236
+ removed = true;
237
+ }
238
+ if self.openai_api_key.take().is_some() {
239
+ removed = true;
240
+ }
241
+ if self.glm_api_key.take().is_some() {
242
+ removed = true;
243
+ }
244
+
245
+ self.save()?;
246
+
247
+ Ok(removed)
248
+ }
249
+ }
@@ -0,0 +1,183 @@
1
+ use std::fs;
2
+ use std::path::{PathBuf};
3
+ use std::time::{SystemTime, UNIX_EPOCH};
4
+
5
+ use anyhow::{Context, Result};
6
+ use chrono::{DateTime, Utc};
7
+ use serde::{Deserialize, Serialize};
8
+
9
+ use crate::cli::Provider;
10
+ use crate::session::{Message, MessageRole, Session};
11
+ use crate::config::Config;
12
+
13
+ #[derive(Debug, Clone, Serialize, Deserialize)]
14
+ pub struct ConversationSnapshot {
15
+ pub id: String,
16
+ pub title: String,
17
+ pub created_at: DateTime<Utc>,
18
+ pub updated_at: DateTime<Utc>,
19
+ pub provider: String,
20
+ pub model: String,
21
+ pub working_directory: PathBuf,
22
+ pub message_count: usize,
23
+ pub messages: Vec<Message>,
24
+ }
25
+
26
+ #[derive(Debug, Clone)]
27
+ pub struct ConversationSummary {
28
+ pub id: String,
29
+ pub title: String,
30
+ pub updated_at: DateTime<Utc>,
31
+ pub provider: String,
32
+ pub model: String,
33
+ pub message_count: usize,
34
+ }
35
+
36
+ pub struct ConversationStore;
37
+
38
+ impl ConversationStore {
39
+ fn storage_dir() -> Result<PathBuf> {
40
+ let config_path = Config::config_path()?;
41
+ let dir = config_path
42
+ .parent()
43
+ .map(|p| p.join("sessions"))
44
+ .unwrap_or_else(|| PathBuf::from(".zarz/sessions"));
45
+ fs::create_dir_all(&dir)
46
+ .with_context(|| format!("Failed to create session storage at {}", dir.display()))?;
47
+ Ok(dir)
48
+ }
49
+
50
+ fn generate_id() -> String {
51
+ let now = Utc::now();
52
+ let nanos = now.timestamp_subsec_nanos();
53
+ let since_epoch = SystemTime::now()
54
+ .duration_since(UNIX_EPOCH)
55
+ .map(|d| d.as_nanos())
56
+ .unwrap_or(0);
57
+ format!("{}-{:09}-{:x}", now.format("%Y%m%d-%H%M%S"), nanos, since_epoch)
58
+ }
59
+
60
+ fn derive_title(messages: &[Message]) -> String {
61
+ const DEFAULT_TITLE: &str = "Untitled session";
62
+ let candidate = messages
63
+ .iter()
64
+ .find_map(|msg| match msg.role {
65
+ MessageRole::User => {
66
+ msg.content
67
+ .lines()
68
+ .find(|line| !line.trim().is_empty())
69
+ .map(|line| line.trim())
70
+ .map(str::to_string)
71
+ }
72
+ _ => None,
73
+ })
74
+ .unwrap_or_else(|| DEFAULT_TITLE.to_string());
75
+
76
+ let trimmed = candidate.trim();
77
+ let title = if trimmed.is_empty() {
78
+ DEFAULT_TITLE.to_string()
79
+ } else {
80
+ trimmed.to_string()
81
+ };
82
+
83
+ if title.len() > 80 {
84
+ format!("{}…", &title[..80])
85
+ } else {
86
+ title
87
+ }
88
+ }
89
+
90
+ pub fn save_session(session: &mut Session, provider: Provider, model: &str) -> Result<()> {
91
+ if session.conversation_history.is_empty() {
92
+ return Ok(());
93
+ }
94
+
95
+ let now = Utc::now();
96
+ let id = session
97
+ .storage_id
98
+ .clone()
99
+ .unwrap_or_else(Self::generate_id);
100
+ let created_at = session
101
+ .created_at
102
+ .unwrap_or_else(|| {
103
+ let ts = now;
104
+ session.created_at = Some(ts);
105
+ ts
106
+ });
107
+
108
+ let title = session
109
+ .title
110
+ .clone()
111
+ .filter(|t| !t.trim().is_empty())
112
+ .unwrap_or_else(|| Self::derive_title(&session.conversation_history));
113
+
114
+ session.storage_id = Some(id.clone());
115
+ session.title = Some(title.clone());
116
+ session.updated_at = Some(now);
117
+
118
+ let snapshot = ConversationSnapshot {
119
+ id: id.clone(),
120
+ title,
121
+ created_at,
122
+ updated_at: now,
123
+ provider: provider.as_str().to_string(),
124
+ model: model.to_string(),
125
+ working_directory: session.working_directory.clone(),
126
+ message_count: session.conversation_history.len(),
127
+ messages: session.conversation_history.clone(),
128
+ };
129
+
130
+ let dir = Self::storage_dir()?;
131
+ let path = dir.join(format!("{id}.json"));
132
+ let data = serde_json::to_string_pretty(&snapshot)
133
+ .context("Failed to serialize conversation snapshot")?;
134
+ fs::write(&path, data)
135
+ .with_context(|| format!("Failed to write conversation snapshot to {}", path.display()))?;
136
+
137
+ Ok(())
138
+ }
139
+
140
+ pub fn list_summaries() -> Result<Vec<ConversationSummary>> {
141
+ let dir = Self::storage_dir()?;
142
+ if !dir.exists() {
143
+ return Ok(Vec::new());
144
+ }
145
+
146
+ let mut summaries = Vec::new();
147
+ for entry in fs::read_dir(&dir).with_context(|| format!("Failed to read {}", dir.display()))? {
148
+ let entry = entry?;
149
+ if !entry.file_type()?.is_file() {
150
+ continue;
151
+ }
152
+ let content = fs::read_to_string(entry.path());
153
+ let Ok(content) = content else {
154
+ continue;
155
+ };
156
+ let snapshot: Result<ConversationSnapshot, _> = serde_json::from_str(&content);
157
+ let Ok(snapshot) = snapshot else {
158
+ continue;
159
+ };
160
+ summaries.push(ConversationSummary {
161
+ id: snapshot.id,
162
+ title: snapshot.title,
163
+ updated_at: snapshot.updated_at,
164
+ provider: snapshot.provider,
165
+ model: snapshot.model,
166
+ message_count: snapshot.message_count,
167
+ });
168
+ }
169
+
170
+ summaries.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
171
+ Ok(summaries)
172
+ }
173
+
174
+ pub fn load_snapshot(id: &str) -> Result<ConversationSnapshot> {
175
+ let dir = Self::storage_dir()?;
176
+ let path = dir.join(format!("{id}.json"));
177
+ let data = fs::read_to_string(&path)
178
+ .with_context(|| format!("Failed to read session file {}", path.display()))?;
179
+ let snapshot: ConversationSnapshot =
180
+ serde_json::from_str(&data).context("Failed to parse stored session data")?;
181
+ Ok(snapshot)
182
+ }
183
+ }