1use 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
31pub 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(×tamp, &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 ("ed[..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}