Skip to main content

squigit/
cli.rs

1// Copyright 2026 a7mddra
2// SPDX-License-Identifier: Apache-2.0
3
4//! Native terminal-facing workflows built from the same services as the GUI.
5
6use crate::brain::{
7    AttachmentPreparationStatus, PrepareAttachmentRequest, PrepareSubmissionAttachmentsRequest,
8};
9use crate::storage::{
10    self, AttachmentFileType, OcrAnnotationEntry, OcrRegion, Profile, GOOGLE_ISSUER,
11};
12use crate::{explorer, profile, services, settings};
13use chrono::{SecondsFormat, Utc};
14use serde_json::json;
15use std::collections::{HashMap, HashSet};
16use std::path::{Path, PathBuf};
17use std::sync::atomic::{AtomicU64, Ordering};
18
19static CLI_OPERATION_SEQUENCE: AtomicU64 = AtomicU64::new(1);
20
21pub const SUPPORTED_FILE_EXTENSIONS: &[&str] = &[
22    "png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "pdf", "docx", "xlsx", "pptx", "txt", "md",
23    "csv", "json", "xml", "yaml", "yml", "toml", "ini", "cfg", "conf", "html", "css", "js", "ts",
24    "jsx", "tsx", "sh", "bash", "zsh", "fish", "py", "rs", "go", "java", "c", "cpp", "h", "hpp",
25    "sql", "log",
26];
27
28const CONTRIBUTOR_EMAIL: &str = "contributor@squigit.app";
29const CONTRIBUTOR_SUBJECT: &str = "squigit-cli-contributor";
30
31/// Configure an isolated contributor session without persisting API keys.
32///
33/// When at least one key is present, the deterministic contributor profile is
34/// created and activated. With no keys, the profile store enters guest mode so
35/// local and OCR-only workflows remain available.
36pub fn initialize_contributor_mode(
37    gemini_api_key: Option<&str>,
38    imgbb_api_key: Option<&str>,
39) -> Result<bool, String> {
40    let gemini_api_key = nonempty_secret(gemini_api_key);
41    let imgbb_api_key = nonempty_secret(imgbb_api_key);
42    crate::auth::set_session_api_keys(gemini_api_key, imgbb_api_key)
43        .map_err(|error| error.to_string())?;
44
45    let store = storage::profile_store().map_err(|error| error.to_string())?;
46    if gemini_api_key.is_none() && imgbb_api_key.is_none() {
47        store
48            .clear_active_profile_id()
49            .map_err(|error| error.to_string())?;
50        return Ok(false);
51    }
52
53    let profile = Profile::new_google(
54        GOOGLE_ISSUER,
55        CONTRIBUTOR_SUBJECT,
56        CONTRIBUTOR_EMAIL,
57        "Contributor",
58        None,
59        None,
60    );
61    store
62        .upsert_profile(&profile)
63        .map_err(|error| error.to_string())?;
64    store
65        .set_active_profile_id(&profile.id)
66        .map_err(|error| error.to_string())?;
67    Ok(true)
68}
69
70fn nonempty_secret(value: Option<&str>) -> Option<&str> {
71    value.map(str::trim).filter(|value| !value.is_empty())
72}
73
74#[derive(Clone, Debug)]
75pub struct CliThreadEntry {
76    pub id: String,
77    pub title: String,
78    pub updated_at: String,
79    pub kind: CliThreadKind,
80}
81
82#[derive(Clone, Copy, Debug, Eq, PartialEq)]
83pub enum CliThreadKind {
84    Image,
85    SideChat,
86}
87
88#[derive(Clone, Debug)]
89pub struct CliResumeSection {
90    pub title: String,
91    pub threads: Vec<CliThreadEntry>,
92}
93
94#[derive(Clone, Debug)]
95pub struct CliSubmissionRequest {
96    pub message: String,
97    pub attachment_paths: Vec<PathBuf>,
98    pub thread_id: Option<String>,
99    pub model: String,
100    pub effort: String,
101}
102
103#[derive(Clone, Debug)]
104pub struct CliSubmissionResult {
105    pub log_path: Option<PathBuf>,
106    pub canonical_message: String,
107    pub brain_message: String,
108    pub attachment_hashes: Vec<String>,
109}
110
111#[derive(Clone, Debug)]
112pub struct CliComposerMention {
113    pub start: usize,
114    pub end: usize,
115    pub path: PathBuf,
116}
117
118#[derive(Clone, Debug)]
119pub struct CliComposerResolution {
120    pub markdown: String,
121    pub attachment_paths: Vec<PathBuf>,
122    pub mentions: Vec<CliComposerMention>,
123}
124
125#[derive(Clone, Debug)]
126pub struct CliOcrRun {
127    pub model_id: String,
128    pub model_name: String,
129    pub scanned_at: String,
130    pub text: String,
131}
132
133struct PreparedAttachment {
134    source_path: PathBuf,
135    cas_path: String,
136    hash: String,
137    file_type: AttachmentFileType,
138}
139
140pub fn attachment_mention(path: &Path) -> Result<String, String> {
141    let path = std::fs::canonicalize(path)
142        .map_err(|error| format!("Could not resolve attachment {}: {error}", path.display()))?;
143    let label = path
144        .file_name()
145        .and_then(|value| value.to_str())
146        .unwrap_or("attachment")
147        .replace(['[', ']', '\n', '\r'], " ");
148    Ok(format!("[{label}](<file://{}>)", normalized_path(&path)))
149}
150
151pub fn resolve_composer_mentions(input: &str, directory: &Path) -> CliComposerResolution {
152    let mentions = find_composer_mentions(input, directory);
153    let mut markdown = String::with_capacity(input.len());
154    let mut cursor = 0;
155    let mut attachment_paths = Vec::with_capacity(mentions.len());
156    for mention in &mentions {
157        markdown.push_str(&input[cursor..mention.start]);
158        markdown.push_str(
159            &attachment_mention(&mention.path)
160                .unwrap_or_else(|_| input[mention.start..mention.end].to_string()),
161        );
162        cursor = mention.end;
163        if !attachment_paths.contains(&mention.path) {
164            attachment_paths.push(mention.path.clone());
165        }
166    }
167    markdown.push_str(&input[cursor..]);
168    CliComposerResolution {
169        markdown,
170        attachment_paths,
171        mentions,
172    }
173}
174
175pub fn resume_sections_for_directory(directory: &Path) -> Result<Vec<CliResumeSection>, String> {
176    let directory = std::fs::canonicalize(directory).unwrap_or_else(|_| directory.to_path_buf());
177    let workspaces = explorer::list_workspaces("updated".to_string(), "updated".to_string())?;
178    let mut seen = HashSet::new();
179    let mut sections = Vec::new();
180
181    for workspace in workspaces {
182        let matches_directory = workspace.directories.iter().any(|candidate| {
183            let candidate = PathBuf::from(candidate);
184            std::fs::canonicalize(&candidate).unwrap_or(candidate) == directory
185        });
186        if !matches_directory {
187            continue;
188        }
189        let threads = workspace
190            .threads
191            .into_iter()
192            .filter(|thread| seen.insert(thread.id.clone()))
193            .map(|thread| CliThreadEntry {
194                id: thread.id,
195                title: thread.title,
196                updated_at: thread.updated_at,
197                kind: CliThreadKind::Image,
198            })
199            .collect::<Vec<_>>();
200        if !threads.is_empty() {
201            sections.push(CliResumeSection {
202                title: workspace.name,
203                threads,
204            });
205        }
206    }
207
208    let recents = explorer::list_unassigned_threads(0, 500, "updated".to_string())?
209        .threads
210        .into_iter()
211        .filter(|thread| seen.insert(thread.id.clone()))
212        .map(|thread| CliThreadEntry {
213            id: thread.id,
214            title: thread.title,
215            updated_at: thread.updated_at,
216            kind: CliThreadKind::Image,
217        })
218        .collect::<Vec<_>>();
219    if !recents.is_empty() {
220        sections.push(CliResumeSection {
221            title: "Recents".to_string(),
222            threads: recents,
223        });
224    }
225
226    let sidechats = explorer::list_sidechat_threads()?
227        .into_iter()
228        .map(|thread| CliThreadEntry {
229            id: thread.id,
230            title: thread.title,
231            updated_at: thread.updated_at,
232            kind: CliThreadKind::SideChat,
233        })
234        .collect::<Vec<_>>();
235    if !sidechats.is_empty() {
236        sections.push(CliResumeSection {
237            title: "Chats".to_string(),
238            threads: sidechats,
239        });
240    }
241
242    Ok(sections)
243}
244
245pub async fn submit_message(request: CliSubmissionRequest) -> Result<CliSubmissionResult, String> {
246    let settings_snapshot = settings::load_settings()?;
247    let contributor_mode = crate::auth::session_api_keys_active();
248    let profile_id = settings_snapshot.active_profile_id.ok_or_else(|| {
249        if contributor_mode {
250            "Gemini is unavailable. Set GEMINI_API_KEY in the shell or repo .env.".to_string()
251        } else {
252            "Login is required before sending a message. Run /login.".to_string()
253        }
254    })?;
255    if !settings_snapshot.google_ai_studio.configured {
256        return Err(if contributor_mode {
257            "Gemini is unavailable. Set GEMINI_API_KEY in the shell or repo .env.".to_string()
258        } else {
259            "API key missing. Run /configure to add a Gemini API key.".to_string()
260        });
261    }
262    if request.message.trim().is_empty() && request.attachment_paths.is_empty() {
263        return Err("The composer is empty.".to_string());
264    }
265
266    let sequence = CLI_OPERATION_SEQUENCE.fetch_add(1, Ordering::Relaxed);
267    let mut prepared = Vec::with_capacity(request.attachment_paths.len());
268    for (index, source_path) in request.attachment_paths.iter().enumerate() {
269        let result = services::brain()
270            .prepare_attachment(PrepareAttachmentRequest {
271                job_id: format!("cli-prepare-{sequence}-{index}"),
272                source_path: source_path.to_string_lossy().into_owned(),
273            })
274            .await;
275        if result.status != AttachmentPreparationStatus::Ready {
276            return Err(result
277                .error_message
278                .unwrap_or_else(|| format!("Could not prepare {}", source_path.display())));
279        }
280        prepared.push(PreparedAttachment {
281            source_path: source_path.clone(),
282            cas_path: result
283                .cas_path
284                .ok_or_else(|| "Attachment preparation returned no CAS path".to_string())?,
285            hash: result
286                .attachment_hash
287                .ok_or_else(|| "Attachment preparation returned no object hash".to_string())?,
288            file_type: result
289                .file_type
290                .ok_or_else(|| "Attachment preparation returned no file type".to_string())?,
291        });
292    }
293
294    let mut canonical_message = request.message.trim().to_string();
295    for attachment in &prepared {
296        canonical_message = canonical_message.replace(
297            &normalized_path(&attachment.source_path),
298            &normalized_path(Path::new(&attachment.cas_path)),
299        );
300    }
301
302    let attachment_hashes = prepared
303        .iter()
304        .map(|attachment| attachment.hash.clone())
305        .collect::<Vec<_>>();
306    let boundary_id = request
307        .thread_id
308        .clone()
309        .unwrap_or_else(|| "cli-session".to_string());
310    let user_message_id = format!("message-{sequence}");
311    let preflight_id = format!("preflight-{sequence}");
312    let preflight = services::brain()
313        .prepare_submission_attachments(PrepareSubmissionAttachmentsRequest {
314            preflight_id: preflight_id.clone(),
315            thread_id: boundary_id.clone(),
316            user_message_id: user_message_id.clone(),
317            attachment_hashes: attachment_hashes.clone(),
318        })
319        .await;
320    if let Some(failed) = preflight
321        .results
322        .iter()
323        .find(|result| result.status != AttachmentPreparationStatus::Ready)
324    {
325        return Err(failed
326            .error_message
327            .clone()
328            .unwrap_or_else(|| format!("Attachment {} failed preflight", failed.attachment_hash)));
329    }
330    if !attachment_hashes.is_empty() && preflight.preflight_token.is_none() {
331        return Err("Attachment preflight completed without a token.".to_string());
332    }
333
334    let text_paths = prepared
335        .iter()
336        .filter(|attachment| attachment.file_type == AttachmentFileType::TextLocal)
337        .map(|attachment| attachment.cas_path.clone())
338        .collect::<Vec<_>>();
339    let resolved_text_paths = text_paths
340        .iter()
341        .map(|path| (path.clone(), path.clone()))
342        .collect::<HashMap<_, _>>();
343    let harness =
344        crate::harness::prepare_text_first_message(crate::harness::PrepareTextFirstMessageInput {
345            message_text: canonical_message.clone(),
346            text_attachment_paths: text_paths,
347            resolved_text_attachment_paths: resolved_text_paths,
348        })?;
349
350    let profile = profile::get_profile_snapshot()
351        .map_err(|error| error.to_string())?
352        .profiles
353        .into_iter()
354        .find(|profile| profile.id == profile_id);
355    let timestamp = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
356    let attachments_json = prepared
357        .iter()
358        .map(|attachment| {
359            json!({
360                "name": attachment.source_path.file_name().and_then(|value| value.to_str()),
361                "fileType": attachment.file_type,
362                "sourcePath": attachment.source_path,
363                "casPath": attachment.cas_path,
364                "attachmentHash": attachment.hash,
365                "status": "ready",
366            })
367        })
368        .collect::<Vec<_>>();
369    let harness_json = harness
370        .attachments
371        .iter()
372        .map(|attachment| {
373            json!({
374                "path": attachment.path,
375                "displayName": attachment.display_name,
376                "extension": attachment.extension,
377                "charCount": attachment.char_count,
378                "ok": attachment.ok,
379                "errorCode": attachment.error_code,
380                "errorMessage": attachment.error_message,
381            })
382        })
383        .collect::<Vec<_>>();
384    let envelope = json!({
385        "timestamp": timestamp,
386        "profile": {
387            "id": profile_id,
388            "email": profile.as_ref().map(|profile| profile.email.as_str()),
389        },
390        "destination": {
391            "kind": if request.thread_id.is_some() { "thread" } else { "cli-session" },
392            "threadId": request.thread_id,
393        },
394        "ids": {
395            "threadId": boundary_id,
396            "userMessageId": user_message_id,
397            "preflightId": preflight_id,
398        },
399        "composer": {
400            "messageMarkdown": canonical_message,
401            "modelId": request.model,
402            "effort": request.effort,
403            "forceWebSearch": false,
404        },
405        "brainInput": {
406            "userMessage": harness.message_text,
407            "attachmentPreflightToken": preflight.preflight_token,
408        },
409        "attachments": attachments_json,
410        "preflightResults": preflight.results,
411        "harnessResults": harness_json,
412    });
413    let log_path = write_boundary_log(&timestamp, &envelope)?;
414
415    Ok(CliSubmissionResult {
416        log_path,
417        canonical_message,
418        brain_message: harness.message_text,
419        attachment_hashes,
420    })
421}
422
423pub fn load_ocr_text(thread_id: &str, model_id: Option<&str>) -> Result<String, String> {
424    let runs = list_ocr_runs(thread_id)?;
425    Ok(model_id
426        .and_then(|model_id| runs.iter().find(|run| run.model_id == model_id))
427        .or_else(|| runs.first())
428        .map(|run| run.text.clone())
429        .unwrap_or_default())
430}
431
432pub fn list_ocr_runs(thread_id: &str) -> Result<Vec<CliOcrRun>, String> {
433    let snapshot = crate::thread::ocr::load_ocr_thread(thread_id)?;
434    let mut runs = snapshot
435        .ocr_data
436        .into_iter()
437        .filter_map(|(model_id, entry)| match entry {
438            OcrAnnotationEntry::Model(model) => Some(CliOcrRun {
439                model_name: squigit_ocr::models::OCR_MODELS
440                    .iter()
441                    .find(|candidate| candidate.id == model_id)
442                    .map(|candidate| candidate.name.to_string())
443                    .unwrap_or_else(|| model_id.clone()),
444                model_id,
445                scanned_at: model
446                    .scanned_at
447                    .map(|value| value.to_rfc3339())
448                    .unwrap_or_else(|| "unknown time".to_string()),
449                text: format_ocr_regions(&model.ocr_data),
450            }),
451            OcrAnnotationEntry::EmptyState(_) => None,
452        })
453        .collect::<Vec<_>>();
454    runs.sort_by(|left, right| right.scanned_at.cmp(&left.scanned_at));
455    Ok(runs)
456}
457
458pub fn persona_path() -> Result<PathBuf, String> {
459    storage::rules_path().ok_or_else(|| "Could not locate Squigit's RULES.md path".to_string())
460}
461
462pub fn open_external(value: &str) -> Result<(), String> {
463    let parsed = url::Url::parse(value).map_err(|error| error.to_string())?;
464    if !matches!(parsed.scheme(), "http" | "https" | "mailto") {
465        return Err(format!(
466            "External URL protocol is not allowed: {}",
467            parsed.scheme()
468        ));
469    }
470    webbrowser::open(parsed.as_str()).map_err(|error| error.to_string())
471}
472
473fn normalized_path(path: &Path) -> String {
474    path.to_string_lossy().replace('\\', "/")
475}
476
477fn find_composer_mentions(input: &str, directory: &Path) -> Vec<CliComposerMention> {
478    let mut mentions = Vec::new();
479    let mut cursor = 0;
480    while let Some(relative_start) = input[cursor..].find('@') {
481        let start = cursor + relative_start;
482        let starts_at_boundary = start == 0
483            || input[..start]
484                .chars()
485                .next_back()
486                .is_some_and(char::is_whitespace);
487        if !starts_at_boundary {
488            cursor = start + 1;
489            continue;
490        }
491
492        let tail = &input[start + 1..];
493        let (value, end) = if let Some(quoted) = tail.strip_prefix('<') {
494            let Some(close) = quoted.find('>') else {
495                cursor = start + 1;
496                continue;
497            };
498            (&quoted[..close], start + 1 + close + 2)
499        } else {
500            let length = tail.find(char::is_whitespace).unwrap_or(tail.len());
501            (&tail[..length], start + 1 + length)
502        };
503        if value.is_empty() {
504            cursor = start + 1;
505            continue;
506        }
507        let candidate = Path::new(value);
508        let candidate = if candidate.is_absolute() {
509            candidate.to_path_buf()
510        } else {
511            directory.join(candidate)
512        };
513        let Ok(path) = std::fs::canonicalize(candidate) else {
514            cursor = end;
515            continue;
516        };
517        let supported = path.is_file()
518            && path
519                .extension()
520                .and_then(|extension| extension.to_str())
521                .is_some_and(|extension| {
522                    SUPPORTED_FILE_EXTENSIONS
523                        .iter()
524                        .any(|supported| extension.eq_ignore_ascii_case(supported))
525                });
526        if supported {
527            mentions.push(CliComposerMention { start, end, path });
528        }
529        cursor = end;
530    }
531    mentions
532}
533
534fn write_boundary_log(
535    timestamp: &str,
536    envelope: &serde_json::Value,
537) -> Result<Option<PathBuf>, String> {
538    let Some(logs_dir) = std::env::var_os("SQUIGIT_LOG_DIR").map(PathBuf::from) else {
539        return Ok(None);
540    };
541    std::fs::create_dir_all(&logs_dir).map_err(|error| error.to_string())?;
542    let file_name = timestamp
543        .chars()
544        .map(|character| {
545            if character.is_ascii_digit() || matches!(character, 'T' | 'Z' | '-') {
546                character
547            } else {
548                '-'
549            }
550        })
551        .collect::<String>();
552    let path = logs_dir.join(format!("{file_name}.log"));
553    let rendered = serde_json::to_string_pretty(envelope).map_err(|error| error.to_string())?;
554    std::fs::write(&path, format!("{rendered}\n")).map_err(|error| error.to_string())?;
555    Ok(Some(path))
556}
557
558fn format_ocr_regions(regions: &[OcrRegion]) -> String {
559    #[derive(Clone)]
560    struct PositionedRegion {
561        text: String,
562        x: i32,
563        center_y: i32,
564        height: i32,
565    }
566
567    let mut positioned = regions
568        .iter()
569        .enumerate()
570        .filter_map(|(index, region)| {
571            let text = region.text.trim();
572            if text.is_empty() {
573                return None;
574            }
575            let points = region
576                .bbox
577                .iter()
578                .filter(|point| point.len() >= 2)
579                .collect::<Vec<_>>();
580            let (x, center_y, height) = if points.is_empty() {
581                (0, i32::MAX / 2 + index as i32, 1)
582            } else {
583                let min_x = points.iter().map(|point| point[0]).min().unwrap_or(0);
584                let min_y = points.iter().map(|point| point[1]).min().unwrap_or(0);
585                let max_y = points.iter().map(|point| point[1]).max().unwrap_or(min_y);
586                (min_x, min_y + (max_y - min_y) / 2, (max_y - min_y).max(1))
587            };
588            Some(PositionedRegion {
589                text: text.to_string(),
590                x,
591                center_y,
592                height,
593            })
594        })
595        .collect::<Vec<_>>();
596    positioned.sort_by_key(|region| (region.center_y, region.x));
597
598    let mut lines: Vec<Vec<PositionedRegion>> = Vec::new();
599    for region in positioned {
600        let belongs_to_last = lines.last().is_some_and(|line| {
601            let center = line.iter().map(|item| item.center_y).sum::<i32>() / line.len() as i32;
602            let height = line.iter().map(|item| item.height).max().unwrap_or(1);
603            (region.center_y - center).abs() <= height.max(region.height) / 2 + 2
604        });
605        if belongs_to_last {
606            if let Some(line) = lines.last_mut() {
607                line.push(region);
608            }
609        } else {
610            lines.push(vec![region]);
611        }
612    }
613
614    lines
615        .into_iter()
616        .map(|mut line| {
617            line.sort_by_key(|region| region.x);
618            line.into_iter()
619                .map(|region| region.text)
620                .collect::<Vec<_>>()
621                .join(" ")
622        })
623        .collect::<Vec<_>>()
624        .join("\n")
625}