anveesa 0.1.0

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