mahbot 0.9.0

An autonomous agentic engineering system that manages software development through role separation, subagents, and deterministic diagnostics.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
//! Single shared source of chrome action descriptions: one table rendered
//! into BOTH the interactive `chrome` tool's LLM-facing parameter schema and
//! the `mahbot chrome` CLI help, so the two consumers cannot drift. Each CLI
//! action declares its flags once ([`CliFlag`]), and both the help renderer and
//! the parser read that declaration.

use std::sync::LazyLock;

use serde_json::{Value, json};

use crate::chrome::CHROME_USE_DECLARED_BUDGET;
use crate::chrome::contract::OutKind;

/// Tool-schema parameter block for one action.
pub(crate) struct ToolParams {
    pub(crate) required: &'static [&'static str],
    pub(crate) properties: Value,
}

/// One CLI flag an action accepts — the single declaration the rendered help row
/// and the parser both read.
pub(crate) struct CliFlag {
    /// Printed spelling with its value placeholder (`--expect <sel>`), exactly as
    /// the help row shows it.
    pub(crate) spelling: &'static str,
    /// Whether the flag takes a value. Declared here, never derived from
    /// `spelling`: the placeholder and this field disagreeing is drift.
    pub(crate) takes_value: bool,
    /// The printed description of the row.
    pub(crate) description: String,
    /// The `session` subcommand the flag belongs to (`--force` → `Some("stop")`);
    /// `None` for a flag every invocation of the action accepts.
    pub(crate) scope: Option<&'static str>,
}

impl CliFlag {
    /// The machine name the parser matches (`--expect <sel>` → `expect`).
    #[must_use]
    pub(crate) fn name(&self) -> &str {
        self.spelling
            .trim_start_matches('-')
            .split_whitespace()
            .next()
            .unwrap_or_default()
    }
}

/// A flag that takes a value; `spelling` carries its printed placeholder.
fn value_flag(spelling: &'static str, description: impl Into<String>) -> CliFlag {
    CliFlag {
        spelling,
        takes_value: true,
        description: description.into(),
        scope: None,
    }
}

/// A switch (no value).
fn switch_flag(spelling: &'static str, description: impl Into<String>) -> CliFlag {
    CliFlag {
        spelling,
        takes_value: false,
        description: description.into(),
        scope: None,
    }
}

/// CLI-help data for one action.
pub(crate) struct CliHelp {
    /// Invocation syntax after the action word, e.g. `<url> [--expect <sel>]`.
    pub(crate) syntax: &'static str,
    /// This action's own flag rows, in printed order, each carrying the
    /// subcommand it belongs to (see [`CliFlag::scope`]). The session-wide pair
    /// is not here: [`CliHelp::session_refusal`] decides whether [`Self::flags`]
    /// appends it.
    pub(crate) declared: Vec<CliFlag>,
    /// `None` while the action takes the session-wide `--session`/`--keep` pair;
    /// `Some(message)` on the actions that refuse it, with the message the parser
    /// returns. This one field is what the help renders the pair's rows from and
    /// what the parser accepts them by, so the two cannot disagree.
    pub(crate) session_refusal: Option<&'static str>,
    /// Kinds this action's dispatch can emit (`Ok` first) — rendered into
    /// per-action CLI help and enforced by the CLI's `out_env` debug assertion.
    pub(crate) kinds: &'static [OutKind],
    /// Prose after the shared purpose line.
    pub(crate) details: &'static str,
    /// Shell invocation examples (1-2 per action).
    pub(crate) examples: &'static [&'static str],
}

impl CliHelp {
    /// Every flag this action accepts and its help prints, in row order: the
    /// declared rows, then the session-wide pair when the action takes them.
    pub(crate) fn flags(&self) -> impl Iterator<Item = &CliFlag> {
        let session: &[CliFlag] = if self.session_refusal.is_none() {
            &SESSION_FLAGS[..]
        } else {
            &[]
        };
        self.declared.iter().chain(session.iter())
    }
}

/// The session-wide flags: taken (and printed as rows) by every action whose
/// [`CliHelp::session_refusal`] is `None`, refused by the rest. The top-level
/// help words them its own way; these are the per-action rows.
static SESSION_FLAGS: LazyLock<[CliFlag; 2]> = LazyLock::new(|| {
    [
        value_flag(
            "--session <name>",
            "use/name a session — letters, digits, '-' or '_' only (not valid for status / session subcommands)",
        ),
        switch_flag(
            "--keep",
            "keep the session open until 'mahbot chrome session stop <name>' — only with --session, and only on the call that creates the session",
        ),
    ]
});

pub(crate) struct ActionDesc {
    pub(crate) name: &'static str,
    /// One-line purpose — the tool's schema description AND the CLI's
    /// action-list line, from one source.
    pub(crate) purpose: &'static str,
    pub(crate) tool: Option<ToolParams>,
    pub(crate) cli: Option<CliHelp>,
}

/// The `--timeout` row for the eight verbs chrome-use takes no per-call deadline
/// for (click, count, eval, extract, fill, type, press, upload). Their call runs
/// to the clock mahbot declares to chrome-use and the product kills only above
/// it, so a bound below that clock cannot be honoured — it is refused (rc 3)
/// rather than silently discarded — and a larger one widens only mahbot's own
/// kill. The printed floor is [`CHROME_USE_DECLARED_BUDGET`], the same number
/// [`parse_bound_timeout`](crate::chrome::cli) enforces.
fn own_bound_flag() -> String {
    let floor = CHROME_USE_DECLARED_BUDGET.as_secs();
    format!(
        "your own bound on the step (default: none — the clock mahbot declares to chrome-use is the \
         call's clock and the product never cuts the call off before it; a value above that clock \
         raises only the bound mahbot itself kills at, by the relay-recovery window and slack, and \
         anything below it is refused as a usage error because chrome-use takes no per-call \
         deadline for this verb — {floor} is the smallest accepted value; a --timeout is honoured \
         in full by wait/expect only)"
    )
}

/// The `--timeout` description for the verbs chrome-use honours a `--timeout`
/// on (wait, expect): the declared value IS the chrome-use-side deadline, so
/// chrome-use's own timeout surfaces at it and the product only kills above it
/// plus the relay-recovery window. A value at or above chrome-use's own client
/// tolerance is refused as a usage error. `open` derives the wait's declaration
/// from what REMAINS of the flag when the wait starts, so a larger `--timeout`
/// there never pushes the declaration past the tolerance this parse layer
/// refuses.
const FORWARDED_DEADLINE_FLAG: &str = "condition deadline in seconds (default 8) — chrome-use \
     honours it in full and gives up first, so its own 'condition was not met' verdict is what \
     you see; the product's kill rides above it plus chrome-use's relay-recovery window. Must \
     stay under 45, chrome-use's own client tolerance — a value at or above it is refused, \
     because the tool would run out of tolerance instead of answering";

/// The kinds an action that works through a chrome-use session can end as,
/// `Ok` first as `CliHelp::kinds` requires: `Timeout` (its own deadline ran
/// out), `Network`, `NotFound`, `Error` (the step failed otherwise),
/// `Environment` (which rules it out), `Usage` (a malformed call). A row with
/// a different vocabulary spells its own list out (`open` alone can emit
/// `Redesign`).
const STEP_KINDS: &[OutKind] = &[
    OutKind::Ok,
    OutKind::Timeout,
    OutKind::Network,
    OutKind::NotFound,
    OutKind::Error,
    OutKind::Environment,
    OutKind::Usage,
];

/// [`STEP_KINDS`] plus `Empty`, kept second so `Ok` stays first, for the three
/// actions that can answer with nothing found instead of a failure: a region
/// no element matches, an extraction with no rows, and a `session status`
/// whose session is not running.
const STEP_KINDS_WITH_EMPTY: &[OutKind] = &[
    OutKind::Ok,
    OutKind::Empty,
    OutKind::Timeout,
    OutKind::Network,
    OutKind::NotFound,
    OutKind::Error,
    OutKind::Environment,
    OutKind::Usage,
];

pub(crate) static ACTIONS: LazyLock<Vec<ActionDesc>> = LazyLock::new(build_actions);

/// Build the shared action registry (registry order = CLI help order).
#[expect(clippy::too_many_lines)]
fn build_actions() -> Vec<ActionDesc> {
    vec![
        ActionDesc {
            name: "status",
            purpose: "report what was established about the owner's real browser",
            tool: None,
            cli: Some(CliHelp {
                syntax: "",
                declared: vec![],
                session_refusal: Some("--session is not valid for status"),
                kinds: &[OutKind::Ok, OutKind::Environment, OutKind::Usage],
                details: "Pure preflight — reports what was established (chrome-use, the native host, the extension relay, the real profile being driven, a real browser reachable through the relay, a running Chrome, a usable display), never a file/process health check. Exits 2 (environment) only for a chrome-use that cannot state its version or a fact that rules the connection out; a not-proven report exits 0 with `verdict: not-proven` and an action is still attempted. Never launches Chrome, never recovers, never mutates the environment. Rejects --session and --keep.",
                examples: &["mahbot chrome status"],
            }),
        },
        ActionDesc {
            name: "open",
            purpose: "navigate to a URL (returns the snapshot)",
            tool: Some(ToolParams {
                required: &["url"],
                properties: json!({
                    "url": {
                        "type": "string",
                        "description": "http(s) URL to open"
                    }
                }),
            }),
            cli: Some(CliHelp {
                syntax: "<url> [--expect <sel>] [--structural] [--timeout <secs>]",
                declared: vec![
                    value_flag("--expect <sel>", "wait for this selector after navigation"),
                    switch_flag(
                        "--structural",
                        "with --expect: a wait timeout is classified as redesign (suspected DOM redesign) instead of timeout",
                    ),
                    value_flag(
                        "--timeout <secs>",
                        "the condition deadline for the --expect wait, in seconds (default 20) — \
                         the wait declares what REMAINS of it when it starts, and chrome-use \
                         honours that in full and gives up first, so its own verdict is the \
                         open's; the error-page probe and the settle/content capture are \
                         best-effort and product-bounded; the navigation runs to that same declared \
                         clock, which no value here shortens, so the open may \
                         outlive it; a value at or above chrome-use's own client tolerance (45) \
                         is refused as a usage error, because the tool would then run out of \
                         tolerance instead of answering",
                    ),
                ],
                session_refusal: None,
                kinds: &[
                    OutKind::Ok,
                    OutKind::Network,
                    OutKind::Timeout,
                    OutKind::Redesign,
                    OutKind::NotFound,
                    OutKind::Error,
                    OutKind::Environment,
                    OutKind::Usage,
                ],
                details: "The URL must be http(s). Reports the committed final URL plus the page content — a best-effort compact accessibility snapshot, truncated at ~5 KB and absent when the capture fails, the step budget is exhausted, or the page is content-free. An uncommitted navigation (tab still on about:blank) or a Chrome error page is kind network with the requested url; a rendered net error code (e.g. DNS_PROBE_FINISHED_NXDOMAIN, ERR_CONNECTION_REFUSED) surfaces as error_code plus a specific cause in error. An invalid URL is kind usage (rc 3). After navigation (plain path, no --expect) a best-effort network settle runs — capped at ~10s and skipped when the remaining budget cannot also cover content capture — so heavy SPAs (Gmail, YouTube) have largely settled before the next step; a settle timeout never fails the open. The settle reduces, not eliminates, first-step lag: the first count/eval after open can still be slow under contention, but such a step now runs to the clock mahbot declares to chrome-use and reports chrome-use's own reason — pass --expect to have the open itself wait for the page instead. `--expect` is a wait-for-selector convenience after navigation, not a general assertion — use the expect action for condition checks. The `--timeout` deadline bounds the `--expect` wait, which declares to chrome-use what REMAINS of it at the moment it starts — a declaration chrome-use honours in full and gives up first on, so its verdict is the open's — and bounds the product's own best-effort work (the error-page probe, the settle, content capture). A `--timeout` at or above chrome-use's own client tolerance (45s) is refused (rc 3). It does NOT bound the navigation, which runs to the clock mahbot declares to chrome-use like every verb chrome-use accepts no `--timeout` for, so the open may outlive the declared deadline. A tab this opens in a named session is taken out of chrome-use's tab group before the result is printed — an ordinary tab a person can work in beside the automation — while the session keeps driving and closing it as before; only a tab this very command opened is moved. Telling that tab apart costs the call one extra passive tab listing before the step (a second or two on a cold helper); when the browser will not answer that listing the tab stays in the group, the open still succeeds, and the miss is filed as an issue.",
                examples: &[
                    "mahbot chrome open https://example.com",
                    "mahbot chrome open https://example.com --expect \"#main\" --structural --timeout 15",
                ],
            }),
        },
        ActionDesc {
            name: "count",
            purpose: "count elements matching a CSS selector (eval shim over querySelectorAll)",
            tool: None,
            cli: Some(CliHelp {
                syntax: "<selector> [--timeout <secs>]",
                declared: vec![value_flag("--timeout <secs>", own_bound_flag())],
                session_refusal: None,
                kinds: STEP_KINDS_WITH_EMPTY,
                details: "A count of 0 reports kind empty (rc 0) — a legitimately empty region, not a failure.",
                examples: &[
                    "mahbot chrome count \".card\"",
                    "mahbot chrome count \"a[href]\" --session docs",
                ],
            }),
        },
        ActionDesc {
            name: "wait",
            purpose: "wait for exactly one of selector/url/text",
            tool: Some(ToolParams {
                required: &[],
                properties: json!({
                    "selector": {
                        "type": "string",
                        "description": "CSS selector"
                    },
                    "url": {
                        "type": "string",
                        "description": "URL pattern"
                    },
                    "text": {
                        "type": "string",
                        "description": "Text to appear"
                    }
                }),
            }),
            cli: Some(CliHelp {
                syntax: "(<selector> | --url <pattern> | --text <text>) [--timeout <secs>]",
                declared: vec![
                    value_flag("--url <pattern>", "wait until the URL matches this pattern"),
                    value_flag("--text <text>", "wait until this text appears in the page"),
                    value_flag("--timeout <secs>", FORWARDED_DEADLINE_FLAG),
                ],
                session_refusal: None,
                kinds: STEP_KINDS,
                details: "Exactly one target: a selector positional, --url, or --text. A numeric first token (chrome-use's silent-sleep form) is rejected as usage. --timeout IS chrome-use's deadline, honoured in full — chrome-use's own timeout error surfaces at it, and the product kills only above it plus the relay-recovery window. --fn/--load are not exposed (the one internal --load use is open's post-navigation settle).",
                examples: &[
                    "mahbot chrome wait \"#results\" --timeout 15",
                    "mahbot chrome wait --text \"Loaded\" --timeout 10",
                ],
            }),
        },
        ActionDesc {
            name: "expect",
            purpose: "assert a condition, PASS/FAIL",
            tool: Some(ToolParams {
                required: &["condition"],
                properties: json!({
                    "condition": {
                        "type": "string",
                        "description": "visible|hidden|present|count|text|value|attr|url"
                    },
                    "selector": {
                        "type": "string",
                        "description": "CSS selector (except url)"
                    },
                    "count": {
                        "type": "integer",
                        "description": "element count"
                    },
                    "op": {
                        "type": "string",
                        "description": "== != > < >= <= (default ==)"
                    },
                    "predicate": {
                        "type": "string",
                        "description": "equals | contains | matches (default equals)"
                    },
                    "name": {
                        "type": "string",
                        "description": "attribute name"
                    },
                    "expected": {
                        "type": "string",
                        "description": "expected value; pattern for url"
                    }
                }),
            }),
            cli: Some(CliHelp {
                syntax: "(<selector> <visible|hidden|present> | count <sel> <op> <n> | text|value <sel> <equals|contains|matches> <value> | attr <sel> <name> <pred> <value> | url <pred> <pattern>) [--timeout <secs>]",
                declared: vec![value_flag("--timeout <secs>", FORWARDED_DEADLINE_FLAG)],
                session_refusal: None,
                kinds: STEP_KINDS,
                details: "rc 0 when the condition holds. rc 1 when it does not: kind timeout when the condition never held within the deadline (chrome-use reports every failed expect as a deadline expiration), kind error for other step failures. The allowlist is the safe subset of chrome-use's grammar — gone/request/no-errors and the --not/--regex/--no-wait flags are not exposed.",
                examples: &[
                    "mahbot chrome expect \"#main\" visible",
                    "mahbot chrome expect count \".card\" \"==\" 0",
                    "mahbot chrome expect url contains \"dashboard\"",
                ],
            }),
        },
        ActionDesc {
            name: "eval",
            purpose: "run JavaScript",
            tool: Some(ToolParams {
                required: &["js"],
                properties: json!({
                    "js": {
                        "type": "string",
                        "description": "JavaScript, no top-level return"
                    }
                }),
            }),
            cli: Some(CliHelp {
                syntax: "<js> [--timeout <secs>]",
                declared: vec![value_flag("--timeout <secs>", own_bound_flag())],
                session_refusal: None,
                kinds: STEP_KINDS,
                details: "The result is emitted as a JSON value under the result key.",
                examples: &["mahbot chrome eval 'document.title'"],
            }),
        },
        ActionDesc {
            name: "extract",
            purpose: "extract rows with a schema",
            tool: Some(ToolParams {
                required: &["schema"],
                properties: json!({
                    "schema": {
                        "type": "object",
                        "description": "{\"rows\": \"<css>\", \"fields\": {\"<name>\": \"<css>\" | {\"sel\": \"<css>\", \"get\": \"text|html|value|@attr\", \"all\": true}}}; \"all\" returns an array of every match; attributes need the \"@\""
                    },
                    "limit": {
                        "type": "integer",
                        "description": "trim rows; total keeps the count"
                    }
                }),
            }),
            cli: Some(CliHelp {
                syntax: "--schema-file <path> [--limit <n>] [--timeout <secs>]",
                declared: vec![
                    value_flag(
                        "--schema-file <path>",
                        "JSON schema file for the extraction (required)",
                    ),
                    value_flag(
                        "--limit <n>",
                        "trim rows mahbot-side; total still reports the honest count",
                    ),
                    value_flag("--timeout <secs>", own_bound_flag()),
                ],
                session_refusal: None,
                kinds: STEP_KINDS_WITH_EMPTY,
                details: "The schema follows chrome-use's grammar: an optional \"rows\" selector plus a \"fields\" object (field name → CSS selector or {sel, get, all}); a flat schema with no \"fields\" is normalized instead of refused. Field getters: \"text\" (default), \"html\", \"value\", or \"@<attribute>\" (e.g. \"@href\"); attributes require the \"@\" prefix, and unknown getters are rejected kind usage (rc 3) instead of chrome-use's silent textContent fallback. When the schema's rows selector matches 0 elements, the empty region is reported honestly (kind empty, rc 0) without invoking chrome-use's phantom-row extract. An unreadable or invalid schema file is kind usage (rc 3). If the count eval fails (e.g. an invalid CSS rows selector), the action errors explicitly — chrome-use's dominant-container auto-detect fallback is deliberately NOT used, because the gate exists to avoid phantom rows.",
                examples: &["mahbot chrome extract --schema-file products.json --limit 20"],
            }),
        },
        ActionDesc {
            name: "click",
            purpose: "click an element",
            tool: Some(ToolParams {
                required: &["selector"],
                properties: json!({
                    "selector": {
                        "type": "string",
                        "description": "Ref (@e1) or CSS selector"
                    }
                }),
            }),
            cli: Some(CliHelp {
                syntax: "<selector> [--if-present] [--timeout <secs>]",
                declared: vec![
                    switch_flag(
                        "--if-present",
                        "a missed click is a no-op success (chrome-use semantics)",
                    ),
                    value_flag("--timeout <secs>", own_bound_flag()),
                ],
                session_refusal: None,
                kinds: STEP_KINDS,
                details: "The CLI has no snapshot refs — the selector is always CSS. A tab the page opens is taken out of chrome-use's tab group before the result is printed where chrome-use reports it as this session's own; a popup it reports as the owner's tab is left to the browser's own placement.",
                examples: &[
                    "mahbot chrome click \"#submit\"",
                    "mahbot chrome click \".next\" --if-present",
                ],
            }),
        },
        ActionDesc {
            name: "fill",
            purpose: "fill a field (read back)",
            tool: Some(ToolParams {
                required: &["selector", "text"],
                properties: json!({
                    "selector": {
                        "type": "string",
                        "description": "CSS selector or ref (@e1)"
                    },
                    "text": {
                        "type": "string",
                        "description": "text to write, replacing content"
                    }
                }),
            }),
            cli: Some(CliHelp {
                syntax: "<selector> <text> [--file <path>] [--stdin] [--timeout <secs>]",
                declared: vec![
                    value_flag(
                        "--file <path>",
                        "read the fill value from a UTF-8 file (large/multiline text — chrome-use reads the file)",
                    ),
                    switch_flag("--stdin", "read the fill value from this process's stdin"),
                    value_flag("--timeout <secs>", own_bound_flag()),
                ],
                session_refusal: None,
                kinds: STEP_KINDS,
                details: "Clears the field and fills it, replacing existing content; the written value is read back and verified before success — rich editors (CodeMirror, Monaco, ProseMirror, contenteditable) and framework inputs (React/Vue/Angular) are handled natively. Exactly one text source: the inline text (multi-word text is joined with spaces), --file <path>, or --stdin. A missing --file is kind usage; a target that does not exist is kind not-found; a read-back verification failure is kind error. A text beginning with '-' is taken as literal text (fill has no single-dash flags), so `fill \"#q\" -tail` fills `-tail`; `--` shields a leading-dash text (or use --file/--stdin), but an unshielded `-h`/`--help` in the tail is still this help page, not text — and chrome-use answers a help-looking value with its own help, so such a fill fails instead of writing it.",
                examples: &[
                    "mahbot chrome fill \"#email\" \"user@example.com\"",
                    "mahbot chrome fill \".editor\" --file ./post.md",
                ],
            }),
        },
        ActionDesc {
            name: "type",
            purpose: "type, appending (no clear)",
            tool: Some(ToolParams {
                required: &["selector", "text"],
                properties: json!({
                    "selector": {
                        "type": "string",
                        "description": "CSS selector or ref (@e1) of the field"
                    },
                    "text": {
                        "type": "string",
                        "description": "text to append (a newline presses Enter)"
                    },
                    "key_events": {
                        "type": "boolean",
                        "description": "send real key events (autocomplete fields)"
                    }
                }),
            }),
            cli: Some(CliHelp {
                syntax: "<selector> <text> [--key-events] [--timeout <secs>]",
                declared: vec![
                    switch_flag(
                        "--key-events",
                        "send real per-character keyDown/keyUp — for autocomplete/combobox fields",
                    ),
                    value_flag("--timeout <secs>", own_bound_flag()),
                ],
                session_refusal: None,
                kinds: STEP_KINDS,
                details: "Types character-by-character without clearing (appends to existing content). --key-events sends real per-character keyDown/keyUp for autocomplete/combobox fields. Embedded newlines press Enter — they can submit a form; use fill for multiline text. When the page rewrites or filters the typed text, chrome-use's warning makes the action kind error (rc 1) — never a silent success. A text beginning with '-' is taken as literal text (type has no single-dash flags); `--` shields one, but an unshielded `-h`/`--help` in the tail is still this help page, not text — and chrome-use answers a help-looking value with its own help, so such a type fails instead of typing it.",
                examples: &[
                    "mahbot chrome type \"#search\" \"hello\"",
                    "mahbot chrome type \"#zip\" \"201-0001\" --key-events",
                ],
            }),
        },
        ActionDesc {
            name: "press",
            purpose: "press a key",
            tool: Some(ToolParams {
                required: &["key"],
                properties: json!({
                    "key": {
                        "type": "string",
                        "description": "key to press (e.g. Enter, Escape)"
                    },
                    "selector": {
                        "type": "string",
                        "description": "focus it first (non-focusable: the key lands where focus is)"
                    }
                }),
            }),
            cli: Some(CliHelp {
                syntax: "<key> [--selector <sel>] [--hold <ms>] [--timeout <secs>]",
                declared: vec![
                    value_flag(
                        "--selector <sel>",
                        "try to focus this element before pressing (no-op if not focusable)",
                    ),
                    value_flag(
                        "--hold <ms>",
                        "hold the key down for this long before releasing",
                    ),
                    value_flag("--timeout <secs>", own_bound_flag()),
                ],
                session_refusal: None,
                kinds: STEP_KINDS,
                details: "Sends the key to the focused element. --selector tries to focus its target first (making kind not-found reachable), but focus moves only if the element is focusable (input, textarea, select, button, ...) — for a non-focusable selector (e.g. body) focus does not move and the key lands wherever focus currently is. data.target reports the element where the key actually landed (may differ from --selector, may be absent). For listener-dependent keys (Enter on a bare input, Escape, arrows/PageUp/PageDown in text fields) chrome-use probes for key listeners; with none found, chrome-use's warning makes the action kind error (rc 1) — never a silent success. Keys with browser defaults (e.g. Enter on a button) and command chords are never probed, so they can succeed even if the page ignores them; separately, an Enter that lands where nothing is focused (or on an iframe) also warns and makes the action kind error (rc 1).",
                examples: &[
                    "mahbot chrome press Enter --selector \"textarea[name=q]\"",
                    "mahbot chrome press Escape",
                ],
            }),
        },
        ActionDesc {
            name: "upload",
            purpose: "attach local files",
            tool: Some(ToolParams {
                required: &["selector", "files"],
                properties: json!({
                    "selector": {
                        "type": "string",
                        "description": "file input or its label"
                    },
                    "files": {
                        "type": "array",
                        "items": { "type": "string" },
                        "description": "absolute or workspace-relative paths"
                    }
                }),
            }),
            cli: Some(CliHelp {
                syntax: "<selector> <path...> [--timeout <secs>]",
                declared: vec![value_flag("--timeout <secs>", own_bound_flag())],
                session_refusal: None,
                kinds: STEP_KINDS,
                details: "Hands each path to the page's file input — the input itself, a hidden one behind a styled control, the label that pairs with it, or the container holding it — the way a real selection reaches it. The CLI has no snapshot refs, so the selector is always CSS. Relative paths resolve against the directory this command was run in, and every path is reported as the absolute file that was used; any file you can read may be attached. Several files need a target that accepts several: a one-file input — named directly, through the label that pairs with it, or through the container holding it — refuses more than one. A success means the files reached the page. The browser side's own warning — it could not confirm the input still holds them (a React-style control consumed or replaced them) — is still a success and is repeated verbatim; it never means the page's own validation accepted them, which is invisible here. A failure means the attach did not complete, and its reason says how far it got. Where the target's shape cannot be judged from the page, the files are sent and the result's `note` says the one-file check was skipped; a target that only accepts drops, with no file input behind it, is refused by the browser side and reported as not delivered.",
                examples: &[
                    "mahbot chrome upload \"#file\" ./report.pdf",
                    "mahbot chrome upload \"input[type=file]\" ./page-1.png ./page-2.png",
                ],
            }),
        },
        ActionDesc {
            name: "session",
            purpose: "manage named CLI sessions (session stop / session status / session list)",
            tool: None,
            cli: Some(CliHelp {
                syntax: "stop <name> [--force] | status <name> | list",
                declared: vec![CliFlag {
                    scope: Some("stop"),
                    ..switch_flag(
                        "--force",
                        "also stop protected agent-tab-* / link-enricher-* sessions — 'session stop' only",
                    )
                }],
                session_refusal: Some("--session is not valid for session stop/status/list"),
                kinds: STEP_KINDS_WITH_EMPTY,
                details: "Manages named CLI sessions — a `<name>` takes letters, digits, '-' or '_' only (chrome-use's own alphabet: a '.' is refused, since chrome-use refuses to stop such a session). stop refuses protected sessions (agent-tab-* / link-enricher-*) unless --force; names are prefixed mahbot-chrome- unless already prefixed. list shows the sessions this CLI created — their names, keep marks, and seconds since last use. A session this CLI creates lives 10 minutes after its last use and is then closed; --keep (only with --session, and only on the call that creates the session) keeps it until session stop, which is also the only thing that ends a kept session. Stopping, restarting or updating mahbot closes no browser tabs. status is an opt-in liveness probe (worst case ~30s: session-list preflight + a real bounded get-url against the session): a not-running session reports empty (rc 0); a wedged one reports environment (rc 2). The probe closes nothing on its own, and for a session this CLI recorded it is still a later call with the same name: it restarts the 10-minute clock, so a diagnosis keeps a session's tabs open rather than closing them; the recovery itself is automatic everywhere the session is actually driven: every on-demand action verb recovers a session that stopped answering and says so in its error (only when mahbot's own bound ended the step, or chrome-use classified the session unresponsive — a step chrome-use timed out on its own, e.g. an expect whose condition was not met, is not a wedge and costs no session), `session stop <name>` clears it, and the background watchdog recovers one too. After a recovery, re-run the action with `--session <name>` to re-create the session — cookies persist in the profile, open tabs/tab-group identity do not. The global `status` action is session-unaware: it can report the connection ready while a named session is wedged.",
                examples: &[
                    "mahbot chrome session stop docs",
                    "mahbot chrome session status docs",
                    "mahbot chrome session list",
                ],
            }),
        },
        ActionDesc {
            name: "snapshot",
            purpose: "accessibility tree with refs",
            tool: Some(ToolParams {
                required: &[],
                properties: json!({
                    "interactive_only": {
                        "type": "boolean",
                        "description": "Only interactive elements"
                    },
                    "compact": {
                        "type": "boolean",
                        "description": "drop empty structure (default true)"
                    },
                    "depth": {
                        "type": "integer",
                        "description": "Limit tree depth"
                    }
                }),
            }),
            cli: None,
        },
        ActionDesc {
            name: "get_text",
            purpose: "get rendered text",
            tool: Some(ToolParams {
                required: &["selector"],
                properties: json!({
                    "selector": {
                        "type": "string",
                        "description": "CSS selector or ref (@e1)"
                    }
                }),
            }),
            cli: None,
        },
        ActionDesc {
            name: "get_innertext",
            purpose: "innerText eval; prefer get_text",
            tool: Some(ToolParams {
                required: &["selector"],
                properties: json!({
                    "selector": {
                        "type": "string",
                        "description": "CSS selector or ref (@e1)"
                    }
                }),
            }),
            cli: None,
        },
        ActionDesc {
            name: "get_url",
            purpose: "get the current URL",
            tool: Some(ToolParams {
                required: &[],
                properties: json!({}),
            }),
            cli: None,
        },
        ActionDesc {
            name: "find",
            purpose: "find an element and act",
            tool: Some(ToolParams {
                required: &["by", "value", "action"],
                properties: json!({
                    "by": {
                        "type": "string",
                        "description": "text | role | label (accessible label) | placeholder | alt | title | testid | first | last | nth"
                    },
                    "value": {
                        "type": "string",
                        "description": "what to locate (CSS for first/last/nth)"
                    },
                    "action": {
                        "type": "string",
                        "description": "click | fill | hover | check | text (reads the element)"
                    },
                    "text": {
                        "type": "string",
                        "description": "Text for action fill"
                    },
                    "name": {
                        "type": "string",
                        "description": "name filter for role"
                    },
                    "exact": {
                        "type": "boolean",
                        "description": "Whole-string match"
                    },
                    "index": {
                        "type": "integer",
                        "description": "Zero-based, for nth"
                    }
                }),
            }),
            cli: None,
        },
        ActionDesc {
            name: "screenshot",
            purpose: "capture the page as an image",
            tool: Some(ToolParams {
                required: &[],
                properties: json!({}),
            }),
            cli: Some(CliHelp {
                syntax: "[--output-dir <dir>]",
                declared: vec![value_flag(
                    "--output-dir <dir>",
                    "directory the picture is written into (default: the directory this command \
                     was run in) — it must already exist and be your own; the file is named \
                     screenshot.png, or screenshot_2.png … when that name is taken, so nothing is \
                     overwritten",
                )],
                session_refusal: None,
                kinds: STEP_KINDS,
                details: "Captures the visible page exactly as the agent-facing capture does — the \
                          same viewport capture, framed and sized by chrome-use the same way. \
                          `path` in the result is the absolute file that was written, in the \
                          platform's own spelling, so it can be opened as it is; `origin` names the \
                          page those pixels came from. The session rule in `mahbot chrome --help` \
                          decides which tab is captured.",
                examples: &[
                    "mahbot chrome screenshot",
                    "mahbot chrome screenshot --output-dir ~/Pictures --session shot",
                ],
            }),
        },
    ]
}

/// Linear scan for one action's full descriptor.
#[must_use]
pub(crate) fn desc(name: &str) -> Option<&'static ActionDesc> {
    ACTIONS.iter().find(|a| a.name == name)
}

/// Whether `word` is a CLI-dispatchable action (has a `cli` block).
#[must_use]
pub(crate) fn is_cli_action(word: &str) -> bool {
    desc(word).is_some_and(|d| d.cli.is_some())
}

#[cfg(test)]
mod tests {
    use super::*;

    fn assert_action_set(actual: Vec<&str>, expected: &[&str]) {
        let mut a = actual;
        a.sort_unstable();
        let mut e = expected.to_vec();
        e.sort_unstable();
        assert_eq!(a, e);
    }

    #[test]
    fn names_and_purposes_are_valid() {
        let mut seen = std::collections::HashSet::new();
        for a in ACTIONS.iter() {
            assert!(!a.name.is_empty(), "action has an empty name");
            assert!(seen.insert(a.name), "duplicate action name: {}", a.name);
            assert!(
                !a.purpose.is_empty(),
                "{}: purpose must be non-empty",
                a.name
            );
        }
    }

    #[test]
    fn kind_lists_are_wellformed() {
        for a in ACTIONS.iter() {
            let Some(c) = a.cli.as_ref() else {
                continue;
            };
            assert!(!c.kinds.is_empty(), "{}: kinds must be non-empty", a.name);
            assert_eq!(
                c.kinds[0],
                OutKind::Ok,
                "{}: kinds must start with Ok",
                a.name
            );
            for (i, k) in c.kinds.iter().enumerate() {
                assert!(
                    !c.kinds[..i].contains(k),
                    "{}: duplicate kind {}",
                    a.name,
                    k.as_str()
                );
                assert!(!k.as_str().is_empty(), "{}: as_str rendered empty", a.name);
            }
        }
    }

    #[test]
    fn cli_action_names_match_the_cli_dispatch_set() {
        let names: Vec<&str> = ACTIONS
            .iter()
            .filter(|a| a.cli.is_some())
            .map(|a| a.name)
            .collect();
        assert_action_set(
            names,
            &[
                "status",
                "open",
                "count",
                "wait",
                "expect",
                "eval",
                "extract",
                "click",
                "fill",
                "type",
                "press",
                "upload",
                "screenshot",
                "session",
            ],
        );
    }

    #[test]
    fn cli_help_entries_are_wellformed() {
        for a in ACTIONS.iter() {
            let Some(c) = a.cli.as_ref() else {
                continue;
            };
            assert!(
                !c.examples.is_empty(),
                "{}: examples must be non-empty",
                a.name
            );
        }
    }
}