Skip to main content

hematite/
lib.rs

1pub mod agent;
2
3/// Serializes tests that call `std::env::set_current_dir` to prevent race conditions
4/// in parallel test runs. Any test that mutates the process-wide cwd must hold this
5/// lock for the duration of the directory change.
6#[cfg(test)]
7pub(crate) static TEST_CWD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
8
9pub mod memory;
10pub mod runtime;
11pub mod telemetry;
12pub mod tools;
13pub mod ui;
14
15pub const HEMATITE_VERSION: &str = env!("CARGO_PKG_VERSION");
16pub const HEMATITE_AUTHOR: &str = "Ocean Bennett";
17pub const HEMATITE_REPOSITORY_URL: &str = "https://github.com/undergroundrap/hematite-cli";
18pub const HEMATITE_SHORT_DESCRIPTION: &str =
19    "Local-first AI coding harness — Senior SysAdmin, Network Admin, Data Analyst, and Software Engineer in your terminal.";
20const HEMATITE_GIT_COMMIT_SHORT_RAW: &str = env!("HEMATITE_GIT_COMMIT_SHORT");
21const HEMATITE_GIT_EXACT_TAG_RAW: &str = env!("HEMATITE_GIT_EXACT_TAG");
22const HEMATITE_GIT_DIRTY_RAW: &str = env!("HEMATITE_GIT_DIRTY");
23
24pub fn hematite_git_commit_short() -> Option<&'static str> {
25    #[allow(clippy::const_is_empty)]
26    (!HEMATITE_GIT_COMMIT_SHORT_RAW.is_empty()).then_some(HEMATITE_GIT_COMMIT_SHORT_RAW)
27}
28
29pub fn hematite_git_exact_tag() -> Option<&'static str> {
30    #[allow(clippy::const_is_empty)]
31    (!HEMATITE_GIT_EXACT_TAG_RAW.is_empty()).then_some(HEMATITE_GIT_EXACT_TAG_RAW)
32}
33
34pub fn hematite_git_dirty() -> bool {
35    HEMATITE_GIT_DIRTY_RAW.eq_ignore_ascii_case("true")
36}
37
38pub fn hematite_build_descriptor() -> String {
39    let release_tag = format!("v{}", HEMATITE_VERSION);
40    let exact_release = matches!(hematite_git_exact_tag(), Some(tag) if tag == release_tag);
41
42    if exact_release && !hematite_git_dirty() {
43        "release".to_string()
44    } else {
45        match (hematite_git_commit_short(), hematite_git_dirty()) {
46            (Some(commit), true) => format!("dev+{}-dirty", commit),
47            (Some(commit), false) => format!("dev+{}", commit),
48            (None, true) => "dev-dirty".to_string(),
49            (None, false) => "dev".to_string(),
50        }
51    }
52}
53
54pub fn hematite_version() -> String {
55    format!("v{}", HEMATITE_VERSION)
56}
57
58pub fn hematite_version_display() -> String {
59    format!("v{} [{}]", HEMATITE_VERSION, hematite_build_descriptor())
60}
61
62pub fn hematite_version_report() -> String {
63    let mut lines = vec![
64        format!("Hematite v{}", HEMATITE_VERSION),
65        format!("Build: {}", hematite_build_descriptor()),
66    ];
67    if let Some(commit) = hematite_git_commit_short() {
68        lines.push(format!("Commit: {}", commit));
69    }
70    lines.push(format!(
71        "Built from a dirty worktree: {}",
72        if hematite_git_dirty() { "yes" } else { "no" }
73    ));
74    lines.push(format!(
75        "Exact release tag at build time: {}",
76        hematite_git_exact_tag().unwrap_or("none")
77    ));
78    lines.join("\n")
79}
80
81pub fn hematite_about_report() -> String {
82    [
83        format!("Hematite v{}", HEMATITE_VERSION),
84        format!("Build: {}", hematite_build_descriptor()),
85        format!("Created and maintained by {}", HEMATITE_AUTHOR),
86        HEMATITE_SHORT_DESCRIPTION.to_string(),
87        format!("Repo: {}", HEMATITE_REPOSITORY_URL),
88    ]
89    .join("\n")
90}
91
92pub fn hematite_identity_answer() -> String {
93    format!(
94        "Hematite was created and is maintained by {}.\n\n{}\n\nThe running assistant uses a local model runtime, but Hematite itself is the local harness: the TUI, tool use, file editing, workflow control, host inspection, data analysis sandbox, voice integration, and workstation-assistant architecture.\n\nRepo: {}",
95        HEMATITE_AUTHOR, HEMATITE_SHORT_DESCRIPTION, HEMATITE_REPOSITORY_URL
96    )
97}
98
99// Standard imports for library users
100pub use agent::config::HematiteConfig;
101pub use agent::conversation::ConversationManager;
102pub use agent::inference::InferenceEngine;
103
104use clap::Parser;
105
106#[derive(Parser, Debug, Clone)]
107#[command(
108    author,
109    version,
110    about = "Hematite CLI - SysAdmin, Network Admin, Data Analyst, and Software Engineer in your terminal",
111    long_about = None
112)]
113pub struct CliCockpit {
114    #[arg(long, help = "Bypasses the high-risk modal (Danger mode)")]
115    pub yolo: bool,
116
117    #[arg(
118        long,
119        default_value_t = 3,
120        help = "Sets max parallel workers (default 3)"
121    )]
122    pub swarm_size: usize,
123
124    #[arg(
125        long,
126        help = "Forces the Vigil Brief Mode for concise, high-speed output"
127    )]
128    pub brief: bool,
129
130    #[arg(
131        long,
132        help = "Pass a custom salt to reroll the deterministic species hash"
133    )]
134    pub reroll: Option<String>,
135
136    #[arg(
137        long,
138        help = "Rusty Mode: Enables the Rusty personality system, snark, and companion features"
139    )]
140    pub rusty: bool,
141
142    #[arg(long, help = "Show Rusty stats and exit")]
143    pub stats: bool,
144
145    #[arg(
146        long,
147        help = "Skip the blocking splash screen and enter the TUI immediately"
148    )]
149    pub no_splash: bool,
150
151    #[arg(
152        long,
153        help = "Optional model ID for simple tasks (overrides auto-detect)"
154    )]
155    pub fast_model: Option<String>,
156
157    #[arg(
158        long,
159        help = "Optional model ID for complex tasks (overrides auto-detect)"
160    )]
161    pub think_model: Option<String>,
162
163    #[arg(
164        long,
165        default_value = "http://localhost:1234/v1",
166        help = "The base URL for the OpenAI-compatible API"
167    )]
168    pub url: String,
169
170    // ── MCP Server ────────────────────────────────────────────────────────────
171    #[arg(
172        long,
173        help_heading = "MCP Server",
174        help = "Run as an MCP stdio server — exposes inspect_host to Claude Desktop, OpenClaw, Cursor, and any MCP-capable agent"
175    )]
176    pub mcp_server: bool,
177
178    #[arg(
179        long,
180        help_heading = "MCP Server",
181        help = "Enable edge redaction in MCP server mode — strips usernames, MACs, serial numbers, hostnames, and credentials before responses leave the machine"
182    )]
183    pub edge_redact: bool,
184
185    #[arg(
186        long,
187        help_heading = "MCP Server",
188        help = "Enable semantic edge redaction — routes inspect_host output through the local model for privacy-safe summarization before any data leaves the machine. Implies --edge-redact."
189    )]
190    pub semantic_redact: bool,
191
192    #[arg(
193        long,
194        help_heading = "MCP Server",
195        help = "Endpoint for --semantic-redact (default: same as --url). Point at a dedicated compact model on a different port."
196    )]
197    pub semantic_url: Option<String>,
198
199    #[arg(
200        long,
201        help_heading = "MCP Server",
202        help = "Model ID for --semantic-redact (e.g. bonsai-8b). Required when multiple models are loaded."
203    )]
204    pub semantic_model: Option<String>,
205
206    // ── Headless Reports ──────────────────────────────────────────────────────
207    #[arg(
208        long,
209        help_heading = "Headless Reports",
210        help = "Run a headless diagnostic report and print to stdout — no TUI launched. Pipe to a file: hematite --report > health.md"
211    )]
212    pub report: bool,
213
214    #[arg(
215        long,
216        help_heading = "Headless Reports",
217        default_value = "md",
218        help = "Output format: md (default), json, or html (self-contained, double-clickable)"
219    )]
220    pub report_format: String,
221
222    #[arg(
223        long,
224        help_heading = "Headless Reports",
225        help = "Staged triage — health_report then targeted follow-up inspections. Saves to .hematite/reports/. Add --open to launch."
226    )]
227    pub diagnose: bool,
228
229    #[arg(
230        long,
231        help_heading = "Headless Reports",
232        default_missing_value = "default",
233        num_args = 0..=1,
234        value_name = "PRESET",
235        help = "IT-first-look triage. Optional preset: network, security, performance, storage, apps. Plain --triage runs health+security+connectivity+identity+updates."
236    )]
237    pub triage: Option<String>,
238
239    #[arg(
240        long,
241        help_heading = "Headless Reports",
242        value_name = "ISSUE",
243        help = "Targeted fix plan — keyword-matches your issue to the right inspect_host topics and saves a step-by-step plan. Example: hematite --fix \"PC running slow\""
244    )]
245    pub fix: Option<String>,
246
247    #[arg(
248        long,
249        help_heading = "Headless Reports",
250        help = "Open the saved report file immediately after writing (browser for HTML, editor for Markdown)"
251    )]
252    pub open: bool,
253
254    #[arg(
255        long,
256        help_heading = "Headless Reports",
257        help = "With --fix: preview which topics would be inspected without running any checks"
258    )]
259    pub dry_run: bool,
260
261    #[arg(
262        long,
263        help_heading = "Headless Reports",
264        help = "With --fix: offer to run safe auto-fixes after generating the plan (DNS flush, service restarts, clock sync, etc.)"
265    )]
266    pub execute: bool,
267
268    #[arg(
269        long,
270        help_heading = "Headless Reports",
271        help = "With --fix --execute: skip the Y/n prompt and apply auto-fixes immediately. Use in scripts and scheduled tasks."
272    )]
273    pub yes: bool,
274
275    #[arg(
276        long,
277        help_heading = "Headless Reports",
278        help = "Suppress output when the result is healthy (exit 0). Only prints when issues are found (exit 1). Use in scheduled tasks and scripts."
279    )]
280    pub quiet: bool,
281
282    #[arg(
283        long,
284        help_heading = "Headless Reports",
285        help = "Maintenance sweep — checks every safe auto-fix topic, skips what is healthy, runs what needs fixing, and verifies each fix resolved. No model required."
286    )]
287    pub fix_all: bool,
288
289    #[arg(
290        long,
291        help_heading = "Headless Reports",
292        value_name = "LABEL",
293        help = "With --fix-all: run only the named fix from the sweep. Example: hematite --fix-all --only \"Flush DNS Cache\". Use --fix-all --list to see all fix labels."
294    )]
295    pub only: Option<String>,
296
297    #[arg(
298        long,
299        help_heading = "Headless Reports",
300        help = "Copy output to clipboard after the command completes. Works with --triage, --diagnose, --fix, --fix-all, --inspect, and --query."
301    )]
302    pub clipboard: bool,
303
304    #[arg(
305        long,
306        help_heading = "Headless Reports",
307        help = "Show a native desktop notification when the command finishes. On alert pattern match with --watch, fires a notification instead of only ringing the bell. Windows 10/11 only."
308    )]
309    pub notify: bool,
310
311    #[arg(
312        long,
313        help_heading = "Headless Reports",
314        value_name = "PATH",
315        help = "Save report output to an explicit file path instead of the auto-dated .hematite/reports/ directory. Works with --triage, --diagnose, --fix, --fix-all, and --inspect."
316    )]
317    pub output: Option<String>,
318
319    #[arg(
320        long,
321        help_heading = "Headless Reports",
322        default_missing_value = "weekly",
323        num_args = 0..=1,
324        value_name = "CADENCE",
325        help = "Register a Windows scheduled task for --triage. CADENCE: weekly (default), daily, remove, status. Combine with --fix-all to schedule the maintenance sweep instead."
326    )]
327    pub schedule: Option<String>,
328
329    // ── Modelless Inspection ──────────────────────────────────────────────────
330    #[arg(
331        long,
332        help_heading = "Modelless Inspection",
333        help = "List all 128 available inspect_host topics by category. No model or TUI required."
334    )]
335    pub inventory: bool,
336
337    #[arg(
338        long,
339        help_heading = "Modelless Inspection",
340        value_name = "TOPIC[,TOPIC2,...]",
341        help = "Run any inspect_host topic directly to stdout. Comma-separate for multiple topics. Example: hematite --inspect wifi,latency,dns_cache"
342    )]
343    pub inspect: Option<String>,
344
345    #[arg(
346        long,
347        help_heading = "Modelless Inspection",
348        value_name = "QUERY",
349        help = "Natural-language query routed to the right inspect_host topics. Example: hematite --query \"why is my PC slow\""
350    )]
351    pub query: Option<String>,
352
353    #[arg(
354        long,
355        help_heading = "Modelless Inspection",
356        value_name = "TOPIC[,TOPIC2,...]",
357        help = "Continuously poll topic(s) every N seconds (see --watch-interval). Press Ctrl+C to stop. Example: hematite --watch resource_load,thermal"
358    )]
359    pub watch: Option<String>,
360
361    #[arg(
362        long,
363        help_heading = "Modelless Inspection",
364        value_name = "SECONDS",
365        default_value = "5",
366        help = "Polling interval in seconds for --watch (default: 5)"
367    )]
368    pub watch_interval: u64,
369
370    #[arg(
371        long,
372        help_heading = "Modelless Inspection",
373        value_name = "N",
374        help = "With --watch: stop after N poll cycles instead of running until Ctrl+C. Example: hematite --watch resource_load --count 5"
375    )]
376    pub count: Option<u64>,
377
378    #[arg(
379        long,
380        help_heading = "Modelless Inspection",
381        value_name = "TOPIC[,TOPIC2,...]",
382        help = "Take two snapshots separated by --diff-after seconds and show a colored diff. Example: hematite --diff processes --diff-after 60"
383    )]
384    pub diff: Option<String>,
385
386    #[arg(
387        long,
388        help_heading = "Modelless Inspection",
389        value_name = "SECONDS",
390        default_value = "30",
391        help = "Seconds between snapshots for --diff (default: 30)"
392    )]
393    pub diff_after: u64,
394
395    #[arg(
396        long,
397        help_heading = "Modelless Inspection",
398        value_name = "PATTERN",
399        help = "With --watch: silent heartbeat when pattern is absent, bell + full output on match. Example: hematite --watch thermal --alert throttl"
400    )]
401    pub alert: Option<String>,
402
403    #[arg(
404        long,
405        help_heading = "Modelless Inspection",
406        value_name = "PATTERN",
407        help = "With --watch or --inspect: filter output to only lines containing PATTERN. Case-insensitive. Example: hematite --watch resource_load --field cpu"
408    )]
409    pub field: Option<String>,
410
411    #[arg(
412        long,
413        help_heading = "Modelless Inspection",
414        value_name = "NAME",
415        help = "With --inspect: save output to .hematite/snapshots/<name>.txt instead of printing. Example: hematite --inspect thermal --snapshot before-update"
416    )]
417    pub snapshot: Option<String>,
418
419    #[arg(
420        long,
421        help_heading = "Modelless Inspection",
422        value_name = "NAME",
423        help = "With --diff: load snapshot A from .hematite/snapshots/<name>.txt instead of running a live capture. Example: hematite --diff thermal --from before-update"
424    )]
425    pub from: Option<String>,
426
427    #[arg(
428        long,
429        help_heading = "Modelless Inspection",
430        help = "List saved snapshots in .hematite/snapshots/ with timestamps and sizes"
431    )]
432    pub snapshots: bool,
433
434    #[arg(
435        long,
436        help_heading = "Modelless Inspection",
437        value_name = "NAME1,NAME2",
438        help = "Diff two saved snapshots against each other without a live run. Example: hematite --compare before-update,after-update"
439    )]
440    pub compare: Option<String>,
441
442    #[arg(
443        long,
444        help_heading = "Modelless Inspection",
445        value_name = "NAME",
446        help = "Start a change audit session — takes a baseline snapshot of key system topics. Example: hematite --audit-start pre-patch"
447    )]
448    pub audit_start: Option<String>,
449
450    #[arg(
451        long,
452        help_heading = "Modelless Inspection",
453        value_name = "NAME",
454        help = "End a change audit session — re-runs the baseline topics and generates a diff report. Example: hematite --audit-end pre-patch"
455    )]
456    pub audit_end: Option<String>,
457
458    #[arg(
459        long,
460        help_heading = "Modelless Inspection",
461        value_name = "TOPIC[,TOPIC2,...]",
462        help = "Topics to capture for --audit-start (default: services,startup_items,ports,scheduled_tasks,shares,firewall_rules,processes,connections)"
463    )]
464    pub audit_topics: Option<String>,
465
466    #[arg(
467        long,
468        help_heading = "Modelless Inspection",
469        value_name = "TOPIC:PATTERN",
470        help = "Add a persistent alert rule. Format: TOPIC:PATTERN (e.g. thermal:throttl). Add --alert-rule-label to name it. Add --alert-rule-negate to fire when pattern is absent."
471    )]
472    pub alert_rule_add: Option<String>,
473
474    #[arg(
475        long,
476        help_heading = "Modelless Inspection",
477        value_name = "NAME",
478        help = "Label for the alert rule being added with --alert-rule-add."
479    )]
480    pub alert_rule_label: Option<String>,
481
482    #[arg(
483        long,
484        help_heading = "Modelless Inspection",
485        help = "With --alert-rule-add: fire when pattern is ABSENT (e.g. alert if antivirus is not running)."
486    )]
487    pub alert_rule_negate: bool,
488
489    #[arg(
490        long,
491        help_heading = "Modelless Inspection",
492        help = "List all saved alert rules."
493    )]
494    pub alert_rules: bool,
495
496    #[arg(
497        long,
498        help_heading = "Modelless Inspection",
499        value_name = "ID",
500        help = "Remove alert rule by ID (see --alert-rules for IDs)."
501    )]
502    pub alert_rule_remove: Option<u64>,
503
504    #[arg(
505        long,
506        help_heading = "Modelless Inspection",
507        help = "Evaluate all saved alert rules against live machine data and fire toast notifications for matches. Add --schedule hourly|daily to automate."
508    )]
509    pub alert_rule_run: bool,
510
511    #[arg(
512        long,
513        help_heading = "Modelless Inspection",
514        help = "Take today's timeline snapshot (health_report, startup_items, ports, services). Skips if already captured today. Add --schedule daily to register a Task Scheduler task."
515    )]
516    pub timeline_capture: bool,
517
518    #[arg(
519        long,
520        help_heading = "Modelless Inspection",
521        help = "Show the machine state timeline — all captured daily entries with date, health grade, and summary."
522    )]
523    pub timeline: bool,
524
525    #[arg(
526        long,
527        help_heading = "Modelless Inspection",
528        value_name = "DATE or DATE1,DATE2",
529        help = "Diff timeline entries. Single date diffs against the previous entry; two dates diff each other. Example: hematite --timeline-diff 2025-05-10"
530    )]
531    pub timeline_diff: Option<String>,
532
533    #[arg(
534        long,
535        help_heading = "Modelless Inspection",
536        help = "Show an ASCII health grade trend chart from all captured timeline entries. Renders a bar chart, sparkline, and trajectory summary."
537    )]
538    pub timeline_trend: bool,
539
540    #[arg(
541        long,
542        help_heading = "Modelless Inspection",
543        value_name = "SYMPTOM",
544        help = "Symptom-driven root-cause diagnosis — describe the problem in plain English. Runs all relevant topics and returns ranked probable causes with evidence. No model required. Example: hematite --diagnose-why \"PC is slow and freezing\""
545    )]
546    pub diagnose_why: Option<String>,
547
548    #[arg(long, hide = true)]
549    pub pdf_extract_helper: Option<String>,
550
551    #[arg(long, hide = true)]
552    pub teleported_from: Option<String>,
553}
554
555#[cfg(test)]
556mod tests {
557    #[test]
558    fn version_report_contains_release_version() {
559        let report = crate::hematite_version_report();
560        assert!(report.contains(crate::HEMATITE_VERSION));
561        assert!(report.contains("Build:"));
562    }
563
564    #[test]
565    fn about_report_contains_author_and_repo() {
566        let report = crate::hematite_about_report();
567        assert!(report.contains(crate::HEMATITE_AUTHOR));
568        assert!(report.contains(crate::HEMATITE_REPOSITORY_URL));
569    }
570}