anveesa 0.7.2 → 0.7.3

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.
@@ -0,0 +1,310 @@
1
+ use std::{
2
+ env,
3
+ ffi::OsString,
4
+ path::{Path, PathBuf},
5
+ process::Stdio,
6
+ };
7
+
8
+ use anyhow::{Context, Result, bail};
9
+ use tokio::{io::AsyncWriteExt, process::Command, sync::mpsc::UnboundedSender};
10
+
11
+ use crate::{
12
+ config::CommandProviderConfig,
13
+ provider::{ChatRole, PromptRequest, StreamEvent, TurnResult},
14
+ };
15
+
16
+ pub async fn ask(
17
+ config: &CommandProviderConfig,
18
+ request: PromptRequest,
19
+ events: &UnboundedSender<StreamEvent>,
20
+ ) -> Result<TurnResult> {
21
+ let command_prompt = command_prompt(config, &request);
22
+ let prompt_in_args = config.args.iter().any(|arg| arg.contains("{prompt}"));
23
+ let args = build_args(config, &command_prompt, &request);
24
+
25
+ let executable = resolve_command(&config.command)?;
26
+ let mut command = Command::new(&executable);
27
+ command
28
+ .args(args)
29
+ .stdout(Stdio::piped())
30
+ .stderr(Stdio::piped());
31
+
32
+ for (key, value) in &config.env {
33
+ command.env(key, expand_arg(value, &command_prompt, &request));
34
+ }
35
+
36
+ if !prompt_in_args {
37
+ command.stdin(Stdio::piped());
38
+ }
39
+
40
+ let _ = events.send(StreamEvent::Status {
41
+ message: format!("Running command provider `{}`", config.command),
42
+ });
43
+
44
+ let mut child = command.spawn().with_context(|| {
45
+ format!(
46
+ "failed to spawn command provider '{}' at {}",
47
+ config.command,
48
+ executable.display()
49
+ )
50
+ })?;
51
+
52
+ if !prompt_in_args {
53
+ let mut stdin = child.stdin.take().context("failed to open command stdin")?;
54
+ stdin
55
+ .write_all(command_prompt.as_bytes())
56
+ .await
57
+ .context("failed to write prompt to command stdin")?;
58
+ drop(stdin);
59
+ }
60
+
61
+ let output = child
62
+ .wait_with_output()
63
+ .await
64
+ .context("failed to wait for command provider")?;
65
+
66
+ if !output.status.success() {
67
+ let stderr = String::from_utf8_lossy(&output.stderr);
68
+ bail!(
69
+ "command provider '{}' exited with {}: {}",
70
+ config.command,
71
+ output.status,
72
+ stderr.trim()
73
+ );
74
+ }
75
+
76
+ let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
77
+ let _ = events.send(StreamEvent::Token(text.clone()));
78
+ Ok(TurnResult {
79
+ text,
80
+ usage: None,
81
+ model_used: None,
82
+ })
83
+ }
84
+
85
+ fn command_prompt(config: &CommandProviderConfig, request: &PromptRequest) -> String {
86
+ let system_is_native = request.system.is_some() && !config.system_args.is_empty();
87
+ if request.history.is_empty()
88
+ && request.workspace_context.is_none()
89
+ && (request.system.is_none() || system_is_native)
90
+ {
91
+ return request.prompt.clone();
92
+ }
93
+
94
+ let mut prompt = String::new();
95
+
96
+ if let (Some(system), false) = (&request.system, system_is_native) {
97
+ prompt.push_str("System:\n");
98
+ prompt.push_str(system);
99
+ prompt.push_str("\n\n");
100
+ }
101
+
102
+ if let Some(workspace_context) = &request.workspace_context {
103
+ prompt.push_str("System:\n");
104
+ prompt.push_str(workspace_context);
105
+ prompt.push_str("\n\n");
106
+ }
107
+
108
+ for message in &request.history {
109
+ match message.role {
110
+ ChatRole::User => prompt.push_str("User:\n"),
111
+ ChatRole::Assistant => prompt.push_str("Assistant:\n"),
112
+ }
113
+ prompt.push_str(&message.content);
114
+ prompt.push_str("\n\n");
115
+ }
116
+
117
+ prompt.push_str("User:\n");
118
+ prompt.push_str(&request.prompt);
119
+ prompt
120
+ }
121
+
122
+ fn build_args(
123
+ config: &CommandProviderConfig,
124
+ command_prompt: &str,
125
+ request: &PromptRequest,
126
+ ) -> Vec<String> {
127
+ let mut args = Vec::new();
128
+ let mut expanded_model_args = false;
129
+ let mut expanded_system_args = false;
130
+
131
+ for arg in &config.args {
132
+ match arg.as_str() {
133
+ "{model_args}" => {
134
+ expanded_model_args = true;
135
+ append_optional_args(
136
+ &mut args,
137
+ &config.model_args,
138
+ request.model.is_some(),
139
+ command_prompt,
140
+ request,
141
+ );
142
+ }
143
+ "{system_args}" => {
144
+ expanded_system_args = true;
145
+ append_optional_args(
146
+ &mut args,
147
+ &config.system_args,
148
+ request.system.is_some(),
149
+ command_prompt,
150
+ request,
151
+ );
152
+ }
153
+ _ => args.push(expand_arg(arg, command_prompt, request)),
154
+ }
155
+ }
156
+
157
+ if !expanded_model_args {
158
+ append_optional_args(
159
+ &mut args,
160
+ &config.model_args,
161
+ request.model.is_some(),
162
+ command_prompt,
163
+ request,
164
+ );
165
+ }
166
+
167
+ if !expanded_system_args {
168
+ append_optional_args(
169
+ &mut args,
170
+ &config.system_args,
171
+ request.system.is_some(),
172
+ command_prompt,
173
+ request,
174
+ );
175
+ }
176
+
177
+ args
178
+ }
179
+
180
+ fn append_optional_args(
181
+ args: &mut Vec<String>,
182
+ templates: &[String],
183
+ include: bool,
184
+ command_prompt: &str,
185
+ request: &PromptRequest,
186
+ ) {
187
+ if !include {
188
+ return;
189
+ }
190
+
191
+ args.extend(
192
+ templates
193
+ .iter()
194
+ .map(|arg| expand_arg(arg, command_prompt, request)),
195
+ );
196
+ }
197
+
198
+ fn expand_arg(value: &str, prompt: &str, request: &PromptRequest) -> String {
199
+ value
200
+ .replace("{prompt}", prompt)
201
+ .replace("{model}", request.model.as_deref().unwrap_or_default())
202
+ .replace("{system}", request.system.as_deref().unwrap_or_default())
203
+ }
204
+
205
+ #[cfg(test)]
206
+ mod tests {
207
+ use super::*;
208
+ use crate::provider::ChatMessage;
209
+
210
+ fn config() -> CommandProviderConfig {
211
+ CommandProviderConfig {
212
+ command: "codex".into(),
213
+ default_model: None,
214
+ args: vec!["exec".into(), "{model_args}".into(), "{prompt}".into()],
215
+ model_args: vec!["--model".into(), "{model}".into()],
216
+ system_args: vec![],
217
+ env: Default::default(),
218
+ }
219
+ }
220
+
221
+ fn request(prompt: &str) -> PromptRequest {
222
+ PromptRequest {
223
+ prompt: prompt.into(),
224
+ model: None,
225
+ system: None,
226
+ workspace_context: None,
227
+ history: vec![],
228
+ images: vec![],
229
+ mcp: None,
230
+ }
231
+ }
232
+
233
+ #[test]
234
+ fn plain_prompt_when_no_context() {
235
+ let req = request("hello");
236
+ assert_eq!(command_prompt(&config(), &req), "hello");
237
+ }
238
+
239
+ #[test]
240
+ fn prepends_history_and_context() {
241
+ let mut req = request("now what?");
242
+ req.workspace_context = Some("cwd: /tmp".into());
243
+ req.history = vec![
244
+ ChatMessage::user("first".into()),
245
+ ChatMessage::assistant("answer".into()),
246
+ ];
247
+ let prompt = command_prompt(&config(), &req);
248
+ assert!(prompt.contains("System:\ncwd: /tmp"));
249
+ assert!(prompt.contains("User:\nfirst"));
250
+ assert!(prompt.contains("Assistant:\nanswer"));
251
+ assert!(prompt.trim_end().ends_with("User:\nnow what?"));
252
+ }
253
+
254
+ #[test]
255
+ fn omits_model_args_without_model() {
256
+ let args = build_args(&config(), "hi", &request("hi"));
257
+ assert_eq!(args, vec!["exec", "hi"]);
258
+ }
259
+
260
+ #[test]
261
+ fn expands_model_args_with_model() {
262
+ let mut req = request("hi");
263
+ req.model = Some("gpt-5.1-codex".into());
264
+ let args = build_args(&config(), "hi", &req);
265
+ assert_eq!(args, vec!["exec", "--model", "gpt-5.1-codex", "hi"]);
266
+ }
267
+ }
268
+
269
+ fn resolve_command(command: &str) -> Result<PathBuf> {
270
+ let command_path = Path::new(command);
271
+ if command_path.components().count() > 1 {
272
+ return Ok(command_path.to_path_buf());
273
+ }
274
+
275
+ let Some(current_name) = env::current_exe()
276
+ .ok()
277
+ .and_then(|path| path.file_name().map(OsString::from))
278
+ else {
279
+ return Ok(command_path.to_path_buf());
280
+ };
281
+
282
+ let command_name = OsString::from(command);
283
+ if current_name != command_name {
284
+ return Ok(command_path.to_path_buf());
285
+ }
286
+
287
+ let current_exe = env::current_exe()
288
+ .ok()
289
+ .and_then(|path| path.canonicalize().ok());
290
+
291
+ for dir in env::split_paths(&env::var_os("PATH").unwrap_or_default()) {
292
+ let candidate = dir.join(command);
293
+ if !candidate.is_file() {
294
+ continue;
295
+ }
296
+
297
+ let canonical_candidate = candidate.canonicalize().ok();
298
+ if current_exe.is_some() && canonical_candidate == current_exe {
299
+ continue;
300
+ }
301
+
302
+ return Ok(candidate);
303
+ }
304
+
305
+ bail!(
306
+ "command provider '{}' resolves to this Anveesa alias; set providers.{}.command to the real executable path",
307
+ command,
308
+ command
309
+ )
310
+ }
@@ -0,0 +1,210 @@
1
+ mod command;
2
+ pub mod openai_compatible;
3
+
4
+ use anyhow::{Result, anyhow};
5
+ use serde::{Deserialize, Serialize};
6
+ use tokio::sync::{mpsc::UnboundedSender, oneshot};
7
+
8
+ use crate::config::{AppConfig, ProviderConfig};
9
+
10
+ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11
+ pub struct ChatMessage {
12
+ pub role: ChatRole,
13
+ pub content: String,
14
+ }
15
+
16
+ impl ChatMessage {
17
+ pub fn user(content: String) -> Self {
18
+ Self {
19
+ role: ChatRole::User,
20
+ content,
21
+ }
22
+ }
23
+
24
+ pub fn assistant(content: String) -> Self {
25
+ Self {
26
+ role: ChatRole::Assistant,
27
+ content,
28
+ }
29
+ }
30
+ }
31
+
32
+ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
33
+ pub enum ChatRole {
34
+ User,
35
+ Assistant,
36
+ }
37
+
38
+ /// A base64-encoded image attached to the current user turn.
39
+ #[derive(Debug, Clone)]
40
+ pub struct ImageAttachment {
41
+ pub mime: String, // e.g. "image/png"
42
+ pub data: String, // base64-encoded bytes
43
+ }
44
+
45
+ #[derive(Debug, Clone)]
46
+ pub struct PromptRequest {
47
+ pub prompt: String,
48
+ pub model: Option<String>,
49
+ pub system: Option<String>,
50
+ pub workspace_context: Option<String>,
51
+ pub history: Vec<ChatMessage>,
52
+ /// Images attached to the current turn (clipboard paste or explicit attach).
53
+ pub images: Vec<ImageAttachment>,
54
+ /// Connected MCP servers (runtime only, not part of session history).
55
+ pub mcp: Option<std::sync::Arc<crate::mcp::McpManager>>,
56
+ }
57
+
58
+ /// How tool calls that modify the system (write/edit/run) should be handled.
59
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
60
+ pub enum ApprovalPolicy {
61
+ /// Never run write/run tools (and do not advertise them).
62
+ Deny,
63
+ /// Ask the user on the terminal before each write/run tool call.
64
+ Prompt,
65
+ /// Run write/run tools without asking.
66
+ Allow,
67
+ }
68
+
69
+ /// User decision for a write/run tool confirmation.
70
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
71
+ pub enum ApprovalDecision {
72
+ Deny,
73
+ AllowOnce,
74
+ AllowForTurn,
75
+ }
76
+
77
+ impl ApprovalPolicy {
78
+ /// Whether write/run tools should be advertised to the model at all.
79
+ pub fn allows_write_tools(self) -> bool {
80
+ !matches!(self, ApprovalPolicy::Deny)
81
+ }
82
+ }
83
+
84
+ /// Token accounting reported by a provider, when available.
85
+ #[derive(Debug, Clone, Copy, Default)]
86
+ pub struct Usage {
87
+ pub prompt_tokens: u64,
88
+ pub completion_tokens: u64,
89
+ pub total_tokens: u64,
90
+ /// Tokens served from the prompt cache (Anthropic: cache_read_input_tokens; OpenAI: cached_tokens).
91
+ pub cache_read_tokens: u64,
92
+ /// Tokens written into the prompt cache this turn (Anthropic: cache_creation_input_tokens).
93
+ pub cache_write_tokens: u64,
94
+ }
95
+
96
+ /// What to show in the approval dialog before running a write/run tool.
97
+ #[derive(Debug)]
98
+ pub enum ToolConfirmPreview {
99
+ /// A file write or edit — diff lines already computed from the arguments.
100
+ FileOp {
101
+ verb: String,
102
+ path: String,
103
+ added: usize,
104
+ removed: usize,
105
+ diff: Vec<DiffLine>,
106
+ truncated: bool,
107
+ },
108
+ /// A directory that will be created.
109
+ CreateDir { path: String },
110
+ /// Any other write/run tool — show a plain-text description.
111
+ Generic { summary: String },
112
+ }
113
+
114
+ /// Events streamed from a provider back to the renderer, which owns the terminal.
115
+ #[derive(Debug)]
116
+ pub enum StreamEvent {
117
+ /// Durable progress/status message for long waits between model/tool phases.
118
+ Status { message: String },
119
+ /// A chunk of assistant text to display as it arrives.
120
+ Token(String),
121
+ /// A chunk of extended thinking text (Anthropic thinking blocks).
122
+ Thinking(String),
123
+ /// Final token accounting for the turn.
124
+ Usage(Usage),
125
+ /// A read-only tool is running. Used to make multi-round inspection visible.
126
+ ToolCall { summary: String },
127
+ /// A tool finished running. Used to show explicit success/failure after approval.
128
+ ToolResult {
129
+ summary: String,
130
+ ok: bool,
131
+ elapsed_ms: u128,
132
+ error: Option<String>,
133
+ },
134
+ /// A write/run tool needs the user's approval. The renderer shows the
135
+ /// preview, prompts for a decision, and sends it back through the reply channel.
136
+ Confirm {
137
+ preview: ToolConfirmPreview,
138
+ reply: oneshot::Sender<ApprovalDecision>,
139
+ },
140
+ /// A file was created or edited — show a diff-style summary.
141
+ FileOp {
142
+ verb: String,
143
+ path: String,
144
+ added: usize,
145
+ removed: usize,
146
+ preview: Vec<DiffLine>,
147
+ truncated: bool,
148
+ },
149
+ /// The model announced a multi-step plan.
150
+ PlanSet { tasks: Vec<String> },
151
+ /// The model marked one plan step as complete.
152
+ PlanTaskDone { index: usize },
153
+ }
154
+
155
+ #[derive(Debug)]
156
+ pub enum DiffKind {
157
+ Add,
158
+ Remove,
159
+ }
160
+
161
+ #[derive(Debug)]
162
+ pub struct DiffLine {
163
+ pub kind: DiffKind,
164
+ pub line_no: usize,
165
+ pub text: String,
166
+ }
167
+
168
+ /// What the provider produced for a single turn.
169
+ #[derive(Debug, Clone, Default)]
170
+ pub struct TurnResult {
171
+ pub text: String,
172
+ pub model_used: Option<String>,
173
+ pub usage: Option<Usage>,
174
+ }
175
+
176
+ pub async fn ask(
177
+ config: &AppConfig,
178
+ provider_name: &str,
179
+ mut request: PromptRequest,
180
+ policy: ApprovalPolicy,
181
+ events: &UnboundedSender<StreamEvent>,
182
+ ) -> Result<TurnResult> {
183
+ let provider = config
184
+ .providers
185
+ .get(provider_name)
186
+ .ok_or_else(|| unknown_provider_error(config, provider_name))?;
187
+
188
+ if request.model.is_none() {
189
+ request.model = provider.default_model().map(str::to_string);
190
+ }
191
+
192
+ match provider {
193
+ ProviderConfig::OpenAiCompatible(provider_config) => {
194
+ openai_compatible::ask(provider_name, provider_config, request, policy, events).await
195
+ }
196
+ ProviderConfig::Command(provider_config) => {
197
+ command::ask(provider_config, request, events).await
198
+ }
199
+ }
200
+ }
201
+
202
+ fn unknown_provider_error(config: &AppConfig, provider_name: &str) -> anyhow::Error {
203
+ let mut names = config.providers.keys().cloned().collect::<Vec<_>>();
204
+ names.sort();
205
+ anyhow!(
206
+ "unknown provider '{}'; available providers: {}",
207
+ provider_name,
208
+ names.join(", ")
209
+ )
210
+ }