use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use thiserror::Error;
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ElicitError {
#[error("client does not support elicitation")]
NotSupported,
#[error("user declined: {0}")]
Declined(String),
#[error("user cancelled the operation")]
Cancelled,
#[error("elicitation response missing field '{0}'")]
MissingField(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ElicitParams {
pub message: String,
#[serde(rename = "requestedSchema")]
pub requested_schema: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ElicitRequest {
pub params: ElicitParams,
}
impl ElicitRequest {
#[must_use]
pub fn new(message: impl Into<String>, schema: Value) -> Self {
Self {
params: ElicitParams {
message: message.into(),
requested_schema: schema,
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ElicitAction {
Accept,
Decline,
Cancel,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ElicitResponse {
pub action: ElicitAction,
#[serde(default)]
pub content: Option<Value>,
}
impl ElicitResponse {
pub fn into_accepted(self) -> Result<Value, ElicitError> {
match self.action {
ElicitAction::Accept => Ok(self
.content
.unwrap_or(Value::Object(serde_json::Map::default()))),
ElicitAction::Decline => Err(ElicitError::Declined("user declined".into())),
ElicitAction::Cancel => Err(ElicitError::Cancelled),
}
}
}
#[must_use]
pub fn elicit_ambiguous_app(query: &str, candidates: &[(String, String)]) -> ElicitRequest {
let choices: Vec<Value> = candidates
.iter()
.map(|(name, bundle)| json!({ "const": bundle, "title": name }))
.collect();
let schema = json!({
"type": "object",
"properties": {
"app": {
"type": "string",
"title": "Select application",
"oneOf": choices
}
},
"required": ["app"]
});
ElicitRequest::new(
format!("Multiple apps match '{query}'. Which one do you want to connect to?"),
schema,
)
}
pub fn parse_ambiguous_app(resp: ElicitResponse) -> Result<String, ElicitError> {
let content = resp.into_accepted()?;
content["app"]
.as_str()
.map(str::to_owned)
.ok_or_else(|| ElicitError::MissingField("app".into()))
}
#[must_use]
pub fn elicit_element_not_found(
query: &str,
app: &str,
closest: &[impl AsRef<str>],
) -> ElicitRequest {
let top: Vec<&str> = closest.iter().take(3).map(AsRef::as_ref).collect();
let message = build_not_found_message(query, app, &top);
let schema = build_not_found_schema(&top);
ElicitRequest::new(message, schema)
}
fn build_not_found_message(query: &str, app: &str, top: &[&str]) -> String {
if top.is_empty() {
return format!("Could not find '{query}' in {app}. Please describe it differently.");
}
let list = top
.iter()
.enumerate()
.map(|(i, s)| format!(" {}. {}", i + 1, s))
.collect::<Vec<_>>()
.join("\n");
format!(
"Could not find '{query}' in {app}. Closest matches:\n{list}\n\nPick one or supply a new query."
)
}
fn build_not_found_schema(top: &[&str]) -> Value {
if top.is_empty() {
return json!({
"type": "object",
"properties": {
"description": { "type": "string", "title": "Alternative description" },
"use_visual": { "type": "boolean", "title": "Try AI vision search?", "default": true }
},
"required": ["description"]
});
}
let mut choices: Vec<Value> = top
.iter()
.map(|s| json!({ "const": s, "title": s }))
.collect();
choices.push(json!({ "const": "__custom__", "title": "Enter a different query" }));
json!({
"type": "object",
"properties": {
"choice": {
"type": "string",
"title": "Select element",
"oneOf": choices
},
"custom_query": {
"type": "string",
"title": "Custom query (if you selected 'Enter a different query')"
},
"use_visual": {
"type": "boolean",
"title": "Try AI vision search as fallback?",
"default": false
}
},
"required": ["choice"]
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ElementChoice {
Candidate(String),
Custom(String),
UseVisual,
}
pub fn parse_element_not_found(resp: ElicitResponse) -> Result<ElementChoice, ElicitError> {
let content = resp.into_accepted()?;
if let Some(choice) = content.get("choice").and_then(Value::as_str) {
if choice == "__custom__" {
let custom = content["custom_query"]
.as_str()
.ok_or_else(|| ElicitError::MissingField("custom_query".into()))?
.to_owned();
return Ok(ElementChoice::Custom(custom));
}
let use_visual = content
.get("use_visual")
.and_then(Value::as_bool)
.unwrap_or(false);
if use_visual {
return Ok(ElementChoice::UseVisual);
}
return Ok(ElementChoice::Candidate(choice.to_owned()));
}
if content
.get("use_visual")
.and_then(Value::as_bool)
.unwrap_or(false)
{
return Ok(ElementChoice::UseVisual);
}
let desc = content["description"]
.as_str()
.ok_or_else(|| ElicitError::MissingField("description".into()))?
.to_owned();
Ok(ElementChoice::Custom(desc))
}
const DESTRUCTIVE_KEYWORDS: &[&str] = &[
"delete",
"remove",
"erase",
"quit",
"close",
"format",
"reset",
"clear",
"wipe",
"destroy",
"terminate",
"uninstall",
"revoke",
];
#[must_use]
pub fn is_destructive_element(element_text: &str) -> bool {
let lower = element_text.to_lowercase();
DESTRUCTIVE_KEYWORDS.iter().any(|kw| lower.contains(kw))
}
#[must_use]
pub fn elicit_destructive_action(element_text: &str, app: &str) -> ElicitRequest {
let schema = json!({
"type": "object",
"properties": {
"confirm": {
"type": "boolean",
"title": "Confirm destructive action",
"description": "This action may be irreversible.",
"default": false
}
},
"required": ["confirm"]
});
ElicitRequest::new(
format!(
"About to click '{element_text}' in {app}. \
This action may be irreversible. Proceed?"
),
schema,
)
}
pub fn parse_destructive_action(resp: ElicitResponse) -> Result<(), ElicitError> {
let content = resp.into_accepted()?;
match content.get("confirm").and_then(Value::as_bool) {
Some(true) => Ok(()),
Some(false) => Err(ElicitError::Declined("user did not confirm".into())),
None => Err(ElicitError::MissingField("confirm".into())),
}
}
#[must_use]
pub fn elicit_permissions_missing() -> ElicitRequest {
let schema = json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"title": "Action",
"oneOf": [
{
"const": "open_settings",
"title": "Open System Settings for me (requires brief focus)"
},
{
"const": "show_instructions",
"title": "Show me how to enable it manually"
},
{
"const": "cancel",
"title": "Cancel — I will handle this myself"
}
]
}
},
"required": ["action"]
});
ElicitRequest::new(
"Accessibility permissions are not enabled for this process. \
Without them, no tools can interact with macOS applications. \
How would you like to proceed?",
schema,
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PermissionAction {
OpenSettings,
ShowInstructions,
Cancel,
}
pub const ACCESSIBILITY_SETTINGS_URL: &str =
"x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility";
pub fn parse_permissions_missing(resp: ElicitResponse) -> Result<PermissionAction, ElicitError> {
let content = match resp.action {
ElicitAction::Accept => resp
.content
.unwrap_or(Value::Object(serde_json::Map::default())),
ElicitAction::Decline | ElicitAction::Cancel => return Err(ElicitError::Cancelled),
};
match content["action"].as_str() {
Some("open_settings") => Ok(PermissionAction::OpenSettings),
Some("show_instructions") => Ok(PermissionAction::ShowInstructions),
Some("cancel") | None => Err(ElicitError::Cancelled),
Some(other) => Err(ElicitError::MissingField(format!(
"unknown action '{other}'"
))),
}
}
pub const MANUAL_ACCESSIBILITY_INSTRUCTIONS: &str = "\
To enable accessibility permissions:\n\
\n\
1. Open System Settings (Apple menu > System Settings)\n\
2. Navigate to Privacy & Security > Accessibility\n\
3. Click the '+' button\n\
4. Add your terminal application (Terminal, iTerm2, Alacritty, etc.)\n\
5. Enable the toggle next to your terminal\n\
6. Restart the terminal and retry\n\
\n\
Alternatively, run: open '";
#[cfg(test)]
#[path = "elicitation_tests.rs"]
mod tests;