silicon-extend-cli 1.1.1

The extend command: find and use the devices a Silicon has access to
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
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
//! The documentation tree bundled in the CLI: every node answers `--help` with what it's for, how
//! it's usually used with other commands, its arguments and flags, and the nodes under it.

use extend_protocol::capability::{COMMANDS, CommandSpec, Origin};

pub struct Node {
    pub path: &'static str,
    pub usage: &'static str,
    pub purpose: &'static str,
    pub used_with: &'static str,
    pub flags: &'static [(&'static str, &'static str)],
    pub examples: &'static [&'static str],
    pub who: &'static str,
}

pub const REPO: &str = "https://github.com/teamofsilicons/silicon-extend";
pub const DOCS: &str = "https://extend.teamofsilicons.com/docs";
pub const CRATE: &str = "https://crates.io/crates/silicon-extend-client";
pub const WEBSITE: &str = "https://extend.teamofsilicons.com";

pub const NODES: &[Node] = &[
    Node {
        path: "login",
        usage: "extend login <slt>",
        who: "Carbon or Silicon",
        purpose: "Sign in with a short-lived token (SLT) from Silicon IAM. Extend never asks for a password.",
        used_with: "Get the SLT with the IAM CLI (`iam login --app-id extend ...`) or the IAM consent screen, then run this. Check it worked with `extend login status`. In a test environment (`extend --test <test_id> login si:chef`) a test member id works instead of an SLT.",
        flags: &[],
        examples: &["extend login oac_...", "extend --test 9b3e0c1a-... login si:chef"],
    },
    Node {
        path: "login status",
        usage: "extend login status [--json]",
        who: "anyone",
        purpose: "Show who is signed in, checked live with Silicon IAM.",
        used_with: "Scripts check `authenticated` in the --json output before running other commands. It exits 0 whether or not you are signed in (the check itself worked); other commands exit 3 when you are not.",
        flags: &[(
            "--json",
            "{\"authenticated\": true, \"member\": {...}, \"teams\": [...], \"team\": ...} or {\"authenticated\": false, \"reason\": \"...\"}",
        )],
        examples: &["extend login status --json"],
    },
    Node {
        path: "logout",
        usage: "extend logout",
        who: "Carbon or Silicon",
        purpose: "Sign out and delete the saved tokens. A Silicon signing out ends its running sessions. A Carbon signing out ends the running sessions of the Silicons they gave access to (on their own pairs only, never another Carbon's).",
        used_with: "Sign in again later with `extend login <slt>`.",
        flags: &[],
        examples: &[],
    },
    Node {
        path: "iam",
        usage: "extend iam [--json]",
        who: "anyone, no login",
        purpose: "Show Extend's public Silicon IAM details: app_id, IAM URL, API URL.",
        used_with: "Use app_id when generating an SLT for Extend with the IAM CLI.",
        flags: &[(
            "--json",
            "{\"app_id\": \"extend\", \"iam_base_url\", \"api_base_url\", \"website_url\", \"docs_url\", \"repository_url\", ...}",
        )],
        examples: &["extend iam --json"],
    },
    Node {
        path: "team",
        usage: "extend team ls | extend team silicons [--all-teams] | extend team use <handle>",
        who: "Carbon or Silicon",
        purpose: "List the teams this login reaches, the Silicons in the team (to grant access), or choose the default team.",
        used_with: "Any command also takes --team <handle> for one run. A Silicon works in one Team at a time: the Team it was given access in. A Carbon's devices show in every Team, and a grant goes into the Team you pick with --team (default: the default team). \
`extend team silicons --all-teams` lists the Silicons of every Team your login reaches, with a TEAM column, and says which Teams couldn't be read and why. A Team you don't see isn't one your Extend login reaches: sign in to Extend again and select it.",
        flags: &[(
            "--all-teams",
            "silicons: every Team your login reaches, not only --team's",
        )],
        examples: &["extend team use acme", "extend team silicons --all-teams"],
    },
    Node {
        path: "config",
        usage: "extend config ls | get <key> | set <key> <value> | unset <key> | home <dir> [--use-existing] | test add|ls|rm",
        who: "anyone",
        purpose: "Read and change CLI settings, where state is kept, and saved test environments.",
        used_with: "Settings (each value is checked): api_url (https, or http for a local address), telemetry on|off, output text|json, team <handle>, screenshot_scale 0.01–1, self_destruct 1m–30d, download_dir <existing directory>, color auto|always|never. \
`extend config home <dir>` moves the saved login, settings, test environments and sessions to <dir>/.extend (default $SILICON_HOME/.extend or ~/.extend); if <dir> already holds Extend state it refuses, unless --use-existing switches to that state and leaves this one where it is. \
`extend config test add <test_id>` reads a test app secret from stdin, checks with Extend that it belongs to <test_id>, and saves it so `--test <test_id>` works. Scripts can skip that step: EXTEND_TEST_SECRET=<secret> extend --test <test_id> <command>.",
        flags: &[(
            "--use-existing",
            "config home: switch to the state already in <dir> instead of moving this one there",
        )],
        examples: &[
            "extend config set telemetry off",
            "extend config home /data/si-chef",
            "printf %s \"$SECRET\" | extend config test add 9b3e0c1a-...",
        ],
    },
    Node {
        path: "device",
        usage: "extend device <ls|show|pair|attach|setup|setup-code|rename|banner|ttl|stop|rm|access|activity|requests|wake|wake-requests>",
        who: "Carbon or Silicon",
        purpose: "Find devices (Silicons: the ones you can use) and manage them (Carbons: the ones you paired).",
        used_with: "A Silicon runs `extend device ls`, then `extend device show <device_id>` to see what it can do there, then `extend session new <device_id>`. A device that isn't awake still works for the terminal and Android debugging; for its screen, ask its Carbon with `extend device wake <device_id> --reason \"...\"`. \
A device belongs to the Carbons who paired it: a Carbon sees every device they paired, whichever Team is selected, and nobody else sees it. Several Carbons can pair one device (\"Pair with another Carbon\" in its Extend app); each pair is separate.",
        flags: &[],
        examples: &["extend device ls", "extend device show 7c1e09ab"],
    },
    Node {
        path: "device ls",
        usage: "extend device ls [--online] [--os <os>] [--removed] [--json]",
        who: "Carbon or Silicon",
        purpose: "Silicon: every device you have access to in your Team. Carbon: every device you paired, in every Team.",
        used_with: "Pick a device id from here for `extend device show` or `extend session new`. Every page is read, so the list is complete (it says so if it had to stop early). \
Columns for a Carbon: ID NAME OS ONLINE AWAKE IN USE ACCESS LAST USED DAYS LEFT; NAME says (shared) when another Carbon paired the device too, and IN USE shows your Silicon and its Team, \"yes (another Carbon's Silicon)\", or \"a carried device (stop it at the computer)\". \
Columns for a Silicon: ID NAME OS ONLINE AWAKE IN USE; IN USE names the Silicon when it is in your Team and has your Carbon, else says \"in use\", then \"asked\" while your wake request is open, and (same device as <id>) when another Carbon's pair of the same device is yours to use too. \
AWAKE is yes, no (screen off, locked, asleep, standby, another account), or — when Extend can't tell (offline, an app older than 1.1, an iPhone or iPad). A Silicon sees only the devices it was given access to in its Team: pass --team <handle> for another of its Teams.",
        flags: &[
            ("--online", "only online devices"),
            (
                "--os <os>",
                "android, android_tv, macos, windows, linux, ios, ipados, tvos, samsung_tv, lg_tv",
            ),
            (
                "--removed",
                "Carbons: also your removed devices, whose activity stays readable",
            ),
            (
                "--team-visible",
                "deprecated: devices are only visible to the Carbons who paired them, so it lists nothing",
            ),
        ],
        examples: &["extend device ls --online"],
    },
    Node {
        path: "device show",
        usage: "extend device show <device_id> [--json]",
        who: "Carbon or Silicon",
        purpose: "One device, and exactly what a Silicon can do on it right now given its OS and permissions, plus what's missing and why.",
        used_with: "Read before starting a session so you know which commands will work. It says whether the device is awake, and your open wake request. A Carbon also sees who has access, by Team, whether another Carbon paired it too, whether wake requests are on, and every open wake request. On a computer several Carbons paired, only Silicons given access by the Carbon who installed Silicon Extend on it get the terminal; the others see it under Missing, with why.",
        flags: &[],
        examples: &["extend device show 7c1e09ab"],
    },
    Node {
        path: "device pair",
        usage: "extend device pair <pairing_code> --name <name> [--ttl-days <1-30>] [--access <silicon_id>]...",
        who: "Carbon",
        purpose: "Pair the device showing this code to your account. The code is 6 hexadecimal characters, rotates every 5 minutes and works once.",
        used_with: "Then follow the device's own setup with `extend device setup <device_id> --watch`. A device another Carbon already paired shows a code under \"Pair with another Carbon\" in its Extend app (1.1 or later); your pair is separate, with its own name, access and lifetime. --access grants in --team's Team (default: the default team).",
        flags: &[
            ("--name <name>", "1–64 characters, required"),
            (
                "--visibility",
                "deprecated and ignored: a device is only visible to the Carbons who paired it",
            ),
            (
                "--ttl-days <n>",
                "days without activity before the pair ends, 1–30, default 14",
            ),
            ("--access <silicon_id>", "give a Silicon access now; repeatable"),
        ],
        examples: &["extend device pair 4f9c2a --name \"Saket's Pixel\" --access si:chef"],
    },
    Node {
        path: "device attach",
        usage: "extend device attach <host_device_id> --os ios|ipados|tvos|samsung_tv|lg_tv --name <name> [--address <ip>]",
        who: "Carbon",
        purpose: "Pair an iPhone, iPad, Apple TV or Samsung/LG TV through a paired Mac or computer on the same network.",
        used_with: "Then `extend device setup <device_id> --watch`; an Apple TV also needs `extend device setup-code <device_id> <code>`.",
        flags: &[
            ("--os <os>", "ios, ipados, tvos, samsung_tv or lg_tv; required"),
            ("--name <name>", "1–64 characters, required"),
            (
                "--address <ip>",
                "a TV's address on the network, when it isn't found on its own",
            ),
        ],
        examples: &["extend device attach 2e7f00d1 --os ios --name \"Saket's iPhone\""],
    },
    Node {
        path: "device setup",
        usage: "extend device setup <device_id> [--watch] [--retry [--step <key>]]",
        who: "Carbon (owner)",
        purpose: "The device's own setup steps (debugging, permissions) and which are left. --watch follows until complete; --retry runs failed steps again.",
        used_with: "Run after `extend device pair` or `extend device attach`. A failed step says what is wrong and what to do; once that is done, `--retry` runs it again at once and follows it until it finishes or fails. The device (or the computer it pairs through) must be online and run Silicon Extend 1.1 or later; an older app retries from its own screen.",
        flags: &[
            ("--watch", "refresh every 2 s until setup completes"),
            (
                "--retry",
                "run every failed step again now, then follow them (at most once every 5 s)",
            ),
            (
                "--step <key>",
                "with --retry: only this step (its key is in --json, like wireless_debugging)",
            ),
        ],
        examples: &[
            "extend device setup 7c1e09ab --retry",
            "extend device setup 7c1e09ab --retry --step wireless_debugging",
        ],
    },
    Node {
        path: "device setup-code",
        usage: "extend device setup-code <device_id> <code>",
        who: "Carbon (owner)",
        purpose: "Enter the 4-digit code an Apple TV shows during setup.",
        used_with: "`extend device setup <device_id>` says when the Apple TV is showing a code.",
        flags: &[],
        examples: &[],
    },
    Node {
        path: "device rename",
        usage: "extend device rename <device_id> <name>",
        who: "Carbon (owner)",
        purpose: "Rename a device.",
        used_with: "",
        flags: &[],
        examples: &[],
    },
    Node {
        path: "device visibility",
        usage: "extend device visibility <device_id> team|personal",
        who: "Carbon (owner)",
        purpose: "Gone in Silicon Extend 1.1: a device is only visible to the Carbons who paired it. It exits 2 and changes nothing.",
        used_with: "Choose who can use a device with `extend device access grant <device_id> <silicon_id>`.",
        flags: &[],
        examples: &[],
    },
    Node {
        path: "device banner",
        usage: "extend device banner <device_id> on|off",
        who: "Carbon (owner)",
        purpose: "Show or hide the device's in-use banner, notification and icon change. The choice applies to every Carbon's pair of this device.",
        used_with: "On: the banner shows for 10 seconds per session. Off: the app and website still show who is using the device, with Stop. Requests waiting for a Carbon still appear; Android's quiet running notification and Apple's Automation Running banner remain.",
        flags: &[],
        examples: &["extend device banner 7c1e09ab off"],
    },
    Node {
        path: "device ttl",
        usage: "extend device ttl <device_id> <days>",
        who: "Carbon (owner)",
        purpose: "Set how long the device stays paired without activity: 1–30 days.",
        used_with: "",
        flags: &[],
        examples: &["extend device ttl 7c1e09ab 30"],
    },
    Node {
        path: "device stop",
        usage: "extend device stop <device_id>",
        who: "Carbon (owner)",
        purpose: "Stop the Silicon using the device right now, whichever Carbon gave it access: every Carbon who paired a device can stop it.",
        used_with: "See who is using it with `extend device show <device_id>`. A Silicon another Carbon gave access to isn't named. On a computer it also stops the devices it carries that you paired; a carried device only another Carbon paired is stopped from the computer's Silicon Extend app (exit 6 says so).",
        flags: &[],
        examples: &[],
    },
    Node {
        path: "device rm",
        usage: "extend device rm <device_id> --yes",
        who: "Carbon (owner)",
        purpose: "Remove the device: ends any session and every Silicon's access, and unpairs it. Devices paired through it go too.",
        used_with: "Without --yes it only says what would happen. The activity log stays readable: `extend device ls --removed`, then `extend device activity <device_id>`.",
        flags: &[("--yes", "confirm")],
        examples: &[],
    },
    Node {
        path: "device access",
        usage: "extend device access ls <device_id> | grant <device_id> <silicon_id>... | revoke <device_id> <silicon_id>...",
        who: "Carbon (owner)",
        purpose: "See, give and take away which Silicons can use a device. Taking access away ends that Silicon's running session at once.",
        used_with: "A grant is for one Team: the Silicon's, given with --team (default: the default team). Your Extend login must reach that Team; if it doesn't, sign in to Extend again and select it. Silicons from any of your Teams can use the same device. `extend team silicons --all-teams` lists the Silicons you can grant. revoke with --team takes access away in that Team only; without it, in every Team, and it lists them. After a grant it warns when Ting doesn't know Extend's notification types in that Team (`extend ting status`).",
        flags: &[],
        examples: &[
            "extend --team labs device access grant 7c1e09ab si:chef",
            "extend device access revoke 7c1e09ab si:chef",
        ],
    },
    Node {
        path: "device activity",
        usage: "extend device activity <device_id> [--silicon <id>] [--session <id>] [--since <time>] [--until <time>] [--limit <n>]",
        who: "Carbon (owner)",
        purpose: "Every action on the device, which Silicon did it, in which Team, and when. Typed text is redacted.",
        used_with: "Only your own side: your Silicons' actions on your pair of the device. What other Carbons' Silicons do there is in their logs.",
        flags: &[
            ("--silicon <id>", "only this Silicon's actions"),
            ("--session <id>", "only this session's actions"),
            ("--since/--until", "RFC 3339, or relative like 30m, 2h, 3d"),
            ("--limit <n>", "at most n entries, 1–100 (default 50)"),
        ],
        examples: &["extend device activity 7c1e09ab --since 2h"],
    },
    Node {
        path: "device requests",
        usage: "extend device requests <device_id>",
        who: "Carbon (owner)",
        purpose: "Requests Silicons sent for this device, with their reasons and Teams.",
        used_with: "It also lists requests sent to you (TO says you): a Silicon asked for the device while a Silicon you gave access to was using it, and the two aren't in the same Team with the same Carbon. FROM names the Silicon that asked. Stop your Silicon with `extend device stop <device_id>` to let it in.",
        flags: &[],
        examples: &[],
    },
    Node {
        path: "device wake",
        usage: "extend device wake <device_id> --reason \"<text>\" | extend device wake <device_id> --cancel",
        who: "Silicon",
        purpose: "Ask the Carbon who gave you access to wake a device that isn't awake. Extend never wakes a device itself.",
        used_with: "`extend device ls` and `extend device show` say whether a device is awake. The device shows your name and reason where it can (a phone's lock screen, a computer's notifications), and your Carbon gets it through Ting. When the device wakes you get a Ting (or the Carbon says so), then run `extend session new <device_id>`. \
Asking again after 5 minutes refreshes the request (sooner: exit 12); a request expires 30 minutes after the last ask. --cancel withdraws it. Exit 6 when the device is already awake, another Silicon is using it, or its Carbon turned wake requests off; 4 without access; 5 for an unknown device. The terminal and Android debugging keep working while a device isn't awake: you don't need to wake it for them.",
        flags: &[
            (
                "--reason <text>",
                "1–300 characters, shown to the Carbon exactly as written",
            ),
            ("--cancel", "withdraw your open request instead"),
        ],
        examples: &[
            "extend --team labs device wake 0d44e1f2 --reason \"Need the TV on to check the new menu\"",
            "extend device wake 0d44e1f2 --cancel",
        ],
    },
    Node {
        path: "device wake-requests",
        usage: "extend device wake-requests ls <device_id> [--open] | answer <device_id> woken|declined [--wake-id <id>]... | mute <device_id> [--silicon <id> [--only-team <team>]] | unmute <device_id> [--silicon <id> [--only-team <team>]]",
        who: "Carbon (owner)",
        purpose: "See and answer requests from Silicons to wake your device, or turn them off.",
        used_with: "`answer woken` says the device is awake now: it ends every open request on it, whichever Carbon's Silicons asked and in whichever Team, and each Silicon that asked gets a Ting. Use it where Extend can't tell when a device wakes (iPhone, iPad, an app older than 1.1). `answer declined` ends the requests on your pair only (or the --wake-id ones), and each Silicon gets a Ting. \
`mute` turns wake requests off for the device, or for one Silicon (in every Team, or only --only-team), and withdraws the open ones; `unmute` turns them on again. A device sounds at most once every 15 minutes, and you get at most one wake Ting per device and Team every 15 minutes and 6 an hour; later asks wait for the hour's window.",
        flags: &[
            ("--open", "ls: only open requests"),
            ("--wake-id <id>", "answer declined: only this request; repeatable"),
            ("--silicon <id>", "mute/unmute: only this Silicon's requests"),
            (
                "--only-team <team>",
                "mute/unmute with --silicon: only its grant in this Team",
            ),
        ],
        examples: &[
            "extend device wake-requests ls 0d44e1f2 --open",
            "extend device wake-requests answer 0d44e1f2 woken",
            "extend device wake-requests mute 0d44e1f2 --silicon si:chef",
        ],
    },
    Node {
        path: "session",
        usage: "extend session <new|connect|disconnect|status|ls|end>",
        who: "Silicon",
        purpose: "Use a device. Only one Silicon at a time; a session ends on its own after 5 minutes without a command.",
        used_with: "`extend session new <device_id> --connect`, then device commands like `extend snapshot -i` and `extend click @e2`, then `extend session end`.",
        flags: &[],
        examples: &["extend session new 7c1e09ab --connect", "extend session end"],
    },
    Node {
        path: "session new",
        usage: "extend session new <device_id> [--connect]",
        who: "Silicon",
        purpose: "Start using a device. Prints the session id (3+ hexadecimal characters). If another Silicon is using it you get exit 6 and the command to ask for it.",
        used_with: "Add --connect to connect at once. The session runs in --team's Team (the one you were given access in); later commands in it use that Team on their own. On a device that isn't awake the session still starts, with a note: the terminal and Android debugging work, and commands that need its screen fail until its Carbon wakes it (`extend device wake`).",
        flags: &[("--connect", "also connect to the new session")],
        examples: &[],
    },
    Node {
        path: "session connect",
        usage: "extend session connect <session_id>",
        who: "Silicon",
        purpose: "Make this the session later commands run in, and load its device's commands into --help.",
        used_with: "The command list is refreshed on every device command and `extend session status`, and forgotten when the session ends.",
        flags: &[],
        examples: &["extend session connect a3f"],
    },
    Node {
        path: "session disconnect",
        usage: "extend session disconnect",
        who: "Silicon",
        purpose: "Forget the connected session locally. It keeps running until ended or idle.",
        used_with: "",
        flags: &[],
        examples: &[],
    },
    Node {
        path: "session status",
        usage: "extend session status [<session_id>]",
        who: "Silicon",
        purpose: "State, device, commands run, and when it ends for inactivity.",
        used_with: "Also refreshes what `extend --help` lists for the connected device.",
        flags: &[],
        examples: &[],
    },
    Node {
        path: "session ls",
        usage: "extend session ls [--device <device_id>] [--state active|paused|ended]",
        who: "Carbon or Silicon",
        purpose: "Silicon: your sessions in your Team. Carbon: sessions on your devices, in every Team (with a TEAM column).",
        used_with: "",
        flags: &[
            ("--device <device_id>", "only sessions on this device"),
            ("--state <state>", "active, paused or ended"),
        ],
        examples: &[],
    },
    Node {
        path: "session end",
        usage: "extend session end [<session_id>]",
        who: "Silicon",
        purpose: "Finish and free the device for other Silicons. Defaults to the connected session.",
        used_with: "",
        flags: &[],
        examples: &[],
    },
    Node {
        path: "takeover",
        usage: "extend takeover --reason \"<text>\" | extend takeover status | extend takeover release",
        who: "Silicon",
        purpose: "Hand the device to its Carbon (Face ID, a payment, an admin prompt). Commands are refused until they tap Done or you release. Up to 30 minutes.",
        used_with: "",
        flags: &[("--reason", "1–300 characters, shown on the device and website")],
        examples: &["extend takeover --reason \"Please approve the Face ID prompt\""],
    },
    Node {
        path: "request",
        usage: "extend request send <device_id> --reason \"<text>\" | extend request ls [--sent|--received] [--device <id>]",
        who: "Silicon",
        purpose: "Ask for a device another Silicon is using. Delivered through Ting with your reason exactly as written (1–300 characters).",
        used_with: "Use after `extend session new` says the device is in use. When the Silicon using it is in your Team and was given access by your Carbon, the request goes to it, and you see which Silicon it is. Otherwise it goes to the Carbon who gave that Silicon access, who sees your id and reason and can stop the session; you only see that it is in use.",
        flags: &[
            ("--reason <text>", "send: 1–300 characters, required"),
            ("--sent / --received", "ls: only requests you sent, or received"),
            ("--device <id>", "ls: only requests for this device"),
        ],
        examples: &["extend request send 7c1e09ab --reason \"Need 2 minutes to read an OTP\""],
    },
    Node {
        path: "ting",
        usage: "extend ting status [--all-teams] | extend ting on [--all-teams]",
        who: "Carbon or Silicon",
        purpose: "Whether Extend's notifications reach you through Ting, per Team, and which of Extend's Ting types a Team is missing.",
        used_with: "Ting delivers wake requests, answers to them, and requests for devices in use. It keeps notification types per Team, so a Ting manager of each Team registers Extend's four types once; `ting status` shows the exact command for any that are missing. Status is on, off (you turned Extend's Tings off in Ting) or pending (not registered yet). `ting on` registers you again in --team's Team (or every Team with --all-teams) and sends the Tings waiting for you there.",
        flags: &[("--all-teams", "every Team your login reaches, not only --team's")],
        examples: &["extend ting status --all-teams", "extend --team labs ting on"],
    },
    Node {
        path: "ting status",
        usage: "extend ting status [--all-teams] [--json]",
        who: "Carbon or Silicon",
        purpose: "Whether Extend's Tings reach you in --team's Team (or every Team), and the Ting types Ting doesn't know there, with the command a Ting manager runs to register them.",
        used_with: "",
        flags: &[("--all-teams", "every Team your login reaches")],
        examples: &["extend ting status --all-teams"],
    },
    Node {
        path: "ting on",
        usage: "extend ting on [--all-teams]",
        who: "Carbon or Silicon",
        purpose: "Turn Extend's Tings on again: registers you with Ting in --team's Team (or every Team), with your own login.",
        used_with: "Needs a login that reaches the Team. Run it after turning Extend's Tings off in Ting, or when `extend ting status` says pending.",
        flags: &[("--all-teams", "every Team your login reaches")],
        examples: &["extend --team labs ting on"],
    },
    Node {
        path: "file",
        usage: "extend file ls [--session <id>] [--device <id>] [--kind <kind>] | show <file_id> | get <file_id> [--out <path>] | keep <file_id>",
        who: "Carbon or Silicon",
        purpose: "Files made in sessions (screenshots, recordings, logs, scripts), stored in Briefcase. They self-destruct after 1 day unless made with --ttl or kept.",
        used_with: "Device commands that make files also take --ttl <1m–30d>, --keep and --out <path>. `extend file get` downloads through Extend, which reads Briefcase for you, so it works for the Silicon that made the file and the Carbon whose device made it.",
        flags: &[
            ("--session <id>", "ls: only files from this session"),
            ("--device <id>", "ls: only files from this device"),
            ("--kind <kind>", "ls: screenshot, recording, log, replay_script or diff"),
            (
                "--out <path>",
                "get: a file or directory to save to (default: download_dir, else here)",
            ),
        ],
        examples: &[
            "extend file get 6e1f2a9c-... --out ./shot.png",
            "extend file keep 6e1f2a9c-...",
        ],
    },
    Node {
        path: "report",
        usage: "extend report \"<message>\" [--pr <url>]",
        who: "Carbon or Silicon",
        purpose: "Report a bug to the Extend team. If you already fixed it, attach the pull request.",
        used_with: "Extend is open source: reproduce, patch and open a PR at https://github.com/teamofsilicons/silicon-extend, then report with --pr.",
        flags: &[("--pr <url>", "link to your pull request")],
        examples: &[],
    },
    Node {
        path: "env",
        usage: "extend --test <test_id> env show",
        who: "anyone (test environments only)",
        purpose: "The test environment's name, state, your test identity, and paired devices out of 5.",
        used_with: "",
        flags: &[],
        examples: &[],
    },
    Node {
        path: "version",
        usage: "extend version [--json]",
        who: "anyone",
        purpose: "CLI, client crate and negotiated API versions, and whether this CLI is current, deprecated or sunset.",
        used_with: "Reads Extend's compatibility matrix (GET /api/v1/contracts). Status is current, deprecated (update before the API is retired), sunset (update now), unsupported (this CLI is outside the versions the API works with) or unknown (Extend couldn't be asked). Update with `honeycomb install 'extend'`.",
        flags: &[(
            "--json",
            "{\"cli\", \"client_crate\", \"api_version\", \"api_url\", \"service_version\", \"status\", \"api_state\", \"cli_range\", \"message\", ...}",
        )],
        examples: &[],
    },
    Node {
        path: "docs",
        usage: "extend docs",
        who: "anyone",
        purpose: "Links: repository, docs, website, Rust crate; and where state is kept.",
        used_with: "",
        flags: &[],
        examples: &[],
    },
    Node {
        path: "help",
        usage: "extend help [<command>...]",
        who: "anyone",
        purpose: "The same as `extend [<command>...] --help`.",
        used_with: "",
        flags: &[],
        examples: &["extend help device ls"],
    },
];

pub const GLOBAL_FLAGS: &[(&str, &str)] = &[
    (
        "--json",
        "one JSON document: on success the data itself on stdout, on failure {\"error\": {code, message, hint, request_id, docs_url, details, exit_code}} on stderr",
    ),
    (
        "--test <test_id>",
        "run in a test environment added with `extend config test add` or given by EXTEND_TEST_SECRET (named on stderr at the end, even on failure)",
    ),
    (
        "--session <session_id>",
        "use this session instead of the connected one (also EXTEND_SESSION)",
    ),
    ("--team <handle>", "use this team for one command"),
    ("--timeout <ms>", "device command deadline, 1000–300000 (default 30000)"),
    ("-h, --help", "help for this part of the tree"),
    ("-V, --version", "versions, and whether this CLI is current"),
    (
        "-v, --verbose",
        "on stderr: each call to Extend with its time and outcome, and the request id of a failed one",
    ),
];

pub fn find(path: &str) -> Option<&'static Node> {
    NODES.iter().find(|n| n.path == path)
}

/// The usage line for `extend <path>`, or its parent's.
pub fn usage_of(path: &str) -> &'static str {
    find(path)
        .or_else(|| path.rsplit_once(' ').and_then(|(parent, _)| find(parent)))
        .or_else(|| find(path.split(' ').next().unwrap_or(path)))
        .map_or("extend --help", |n| n.usage)
}

pub fn render_node(n: &Node) -> String {
    let mut s = format!(
        "extend {}\n\n{}\n\nUsage:\n  {}\n\nWho: {}\n",
        n.path, n.purpose, n.usage, n.who
    );
    if !n.used_with.is_empty() {
        s.push_str(&format!("\nHow it's used:\n  {}\n", n.used_with));
    }
    if !n.flags.is_empty() {
        s.push_str("\nFlags:\n");
        for (f, d) in n.flags {
            s.push_str(&format!("  {f:<24} {d}\n"));
        }
    }
    let children: Vec<&Node> = NODES
        .iter()
        .filter(|c| c.path.starts_with(&format!("{} ", n.path)))
        .collect();
    if !children.is_empty() {
        s.push_str("\nUnder this:\n");
        for c in children {
            s.push_str(&format!("  extend {:<22} {}\n", c.path, first_sentence(c.purpose)));
        }
        s.push_str(&format!("\nGo deeper: extend {} <command> --help\n", n.path));
    }
    if !n.examples.is_empty() {
        s.push_str("\nExamples:\n");
        for e in n.examples {
            s.push_str(&format!("  {e}\n"));
        }
    }
    s.push_str("\nGlobal flags work here too; see `extend --help`.\n");
    s
}

fn first_sentence(s: &str) -> &str {
    s.split(". ").next().unwrap_or(s).trim_end_matches('.')
}

/// The session this CLI is connected to, as last read from Extend.
pub struct Connected<'a> {
    pub session_id: &'a str,
    pub device: &'a str,
    pub os: &'a str,
    pub commands: &'a [String],
    pub missing: &'a [crate::store::MissingNote],
    /// Unix seconds.
    pub refreshed_at: i64,
}

impl Connected<'_> {
    fn when(&self) -> String {
        if self.refreshed_at <= 0 {
            return "when you connected".into();
        }
        time::OffsetDateTime::from_unix_timestamp(self.refreshed_at)
            .ok()
            .and_then(|t| {
                t.format(&time::macros::format_description!("[hour]:[minute]:[second]Z"))
                    .ok()
            })
            .map_or_else(|| "when you connected".into(), |t| format!("at {t}"))
    }
}

/// Why the connected device can't run `c`, or `None` when it can.
fn not_available(c: &CommandSpec, on: &Connected) -> Option<String> {
    if on.commands.iter().any(|x| x == c.name) {
        return None;
    }
    let caps = help_capabilities(c, Some(on));
    let reasons: Vec<String> = on
        .missing
        .iter()
        .filter(|m| caps.contains(&m.capability.as_str()))
        .map(|m| format!("{}: {}", m.capability, m.reason))
        .collect();
    Some(format!(
        "Not available on {} ({}) in session {}: it needs {}, which the device {} ({}).{}",
        on.device,
        on.os,
        on.session_id,
        caps.join(" or "),
        if on.commands.is_empty() {
            "didn't report"
        } else {
            "doesn't have"
        },
        on.when(),
        if reasons.is_empty() {
            String::new()
        } else {
            format!(" {}.", reasons.join("; ").trim_end_matches('.'))
        }
    ))
}

fn help_capabilities(c: &CommandSpec, connected: Option<&Connected>) -> Vec<&'static str> {
    let required = if connected.is_some_and(|on| on.os == "android_tv") {
        c.any_of_for(extend_protocol::DeviceOs::AndroidTv)
    } else {
        c.any_of
    };
    required.iter().map(|x| x.as_str()).collect()
}

pub fn render_device_command(name: &str, connected: Option<&Connected>) -> Option<String> {
    let c = COMMANDS.iter().find(|c| c.name == name)?;
    let caps = help_capabilities(c, connected);
    let origin = match c.origin {
        Origin::AgentDevice => "a command of the device engine, run on the session's device through Extend",
        Origin::Extend => "a command Extend adds around the device engine",
    };
    let placement = match name {
        "adb" => ADB_ARGUMENTS,
        "install" | "reinstall" => INSTALL_ARGUMENTS,
        _ => {
            "They may come before or after the command's arguments. `--` ends Extend's flags: everything after it goes to the device as written, even `-h` or `--json`.\n"
        }
    };
    let more = match (name, c.origin) {
        ("click", _) => format!("\nOn Android TV, element clicks need the Extend app 1.1 or newer and Accessibility. They use the element's click action, then remote selection or Android debugging where available; they do not require a mouse or touch screen. Older TV apps can use `find <text> click`. Coordinate, repeated and held clicks still depend on Android accepting gesture injection.\nArgument details: {DOCS}/cli\n"),
        ("display", _) => "\nFor --image and --video, pass a local file, a public media URL, or file:<file_id> (the bare UUID and the stored Extend/Briefcase link also work). Extend reads stored files as you: only your unexpired files in this Team are allowed. Stored and local files share the command's 8-file, 8-MiB attachment limit.\nExample: extend display show --image file:<file_id>\n".into(),
        (_, Origin::AgentDevice) => format!("\nArgument details: {DOCS}/cli\n"),
        (_, Origin::Extend) => String::new(),
    };
    let note = connected
        .and_then(|on| not_available(c, on))
        .map(|n| {
            format!("\n{n}\n`extend --help` lists what works there; `extend session status` refreshes that list.\n")
        })
        .unwrap_or_default();
    Some(format!(
        "extend {name}\n\n{}.\n{note}\nUsage:\n  extend {}\n\nThis is {origin}. It needs a session (`extend session new <device_id> --connect`) and a device with: {}.\n\nFlags for any device command:\n  --session <id>          which session (default: the connected one)\n  --timeout <ms>          deadline, 1000–300000 (default 30000)\n  --json                  full structured result\n  --ttl <1m–30d>          self-destruct for files this command makes (default 1d)\n  --keep                  keep files this command makes permanently\n  --out <path>            also save the files locally (their Briefcase links are printed first)\n\n{placement}{more}",
        c.summary,
        usage(c),
        caps.join(" or "),
    ))
}

/// Usage lines the CLI prints instead of the shared command table's (`COMMANDS` in
/// extend-protocol), where that table is behind understanding/cli.yaml: it says
/// `install <app> <file_id|path>`, but only a local .apk works, and it doesn't show where
/// Extend's flags go for `adb`.
const USAGE_OVERRIDES: &[(&str, &str)] = &[
    ("install", "install <package> <path.apk>"),
    ("reinstall", "reinstall <package> <path.apk>"),
    ("adb", "adb [<extend flags>] [--] <args>..."),
];

fn usage(c: &CommandSpec) -> &'static str {
    USAGE_OVERRIDES
        .iter()
        .find(|(n, _)| *n == c.name)
        .map_or(c.usage, |(_, u)| u)
}

/// What `extend install` / `extend reinstall` take.
const INSTALL_ARGUMENTS: &str = "\
Extend's flags may come before or after the command's arguments.

<path.apk> is an .apk file on this computer; it is sent with the command, so it can be at most
8 MiB (8388608 bytes). A missing path, a directory, a link or a Briefcase file id is refused before
anything is sent: download a Briefcase file first with extend file get <file_id> --out ./app.apk.
An .aab bundle is refused too: build a universal APK from it with bundletool first.
For a larger APK, the error explains how to push it in parts and install it with
extend adb shell pm install -r /data/local/tmp/<file>.
";

/// How `extend adb` reads its arguments (see `VERBATIM_COMMANDS` in args.rs).
const ADB_ARGUMENTS: &str = "\
Everything after the first adb argument goes to the device exactly as typed, including -h, -v,
--json and --out (only pull's local path is kept here; see below). Put Extend's flags before it:
  extend --json adb shell df -h
  extend adb --timeout 60000 shell sleep 40
A `--` right after `adb` ends Extend's flags and is not sent: extend adb -- shell grep -v error /sdcard/app.log

Local files stay on this computer's side:
  push <local file> <device path>     sends the local file with the command
  install [-r] <local .apk>           sends the local APK with the command
  pull <device path> [<local path>]   saves the pulled file locally; --out <local path> before or
                                      after the device path does the same
push and install take a file on this computer only: a missing path, a directory, a link or a
Briefcase file id is refused before anything is sent (download a Briefcase file first with
extend file get <file_id> --out <local path>). A command carries at most 8 MiB of local files. For a
larger file, split it into parts of 8 MiB or less (split -b 8m), push each part to its own path under
/data/local/tmp, and join them with extend adb shell 'cat <parts> > <file>'.
";

/// Top-level help. `connected` narrows the device commands to what the session's device can do.
pub fn render_top(connected: Option<&Connected>) -> String {
    let mut s = String::from(
        "extend — let a Silicon use the devices a Carbon has paired, and let the Carbon manage them.\n\n\
Getting started\n\
  honeycomb install 'extend'          install the CLI\n\
  extend login <slt>                  sign in with a short-lived token from Silicon IAM\n\
  extend device ls                    devices you can use (Silicon) or have paired (Carbon)\n\
  extend device show <device_id>      what a Silicon can do on a device\n\
  extend session new <device_id> --connect\n\
  extend snapshot -i                  read the screen; then act: extend click @e2\n\
  extend session end                  free the device for other Silicons\n\n\
Commands (extend <command> --help goes deeper)\n",
    );
    for n in NODES.iter().filter(|n| !n.path.contains(' ')) {
        s.push_str(&format!("  {:<12} {}\n", n.path, first_sentence(n.purpose)));
    }
    match connected {
        Some(on) => {
            s.push_str(&format!(
                "\nDevice commands — connected to session {} on {} ({}); only commands that work there are shown (as the device reported {}; `extend session status` refreshes this)\n",
                on.session_id,
                on.device,
                on.os,
                on.when()
            ));
            let mut any = false;
            for c in COMMANDS.iter().filter(|c| on.commands.iter().any(|x| x == c.name)) {
                any = true;
                s.push_str(&format!("  {:<14} {}\n", c.name, c.summary));
            }
            if !any {
                s.push_str("  (none right now: the device reported nothing it can do, usually because it is offline or still being set up. `extend session status` checks again.)\n");
            }
        }
        None => {
            s.push_str("\nDevice commands (need a session; `extend --help` while connected shows only what that device can do)\n");
            for c in COMMANDS {
                s.push_str(&format!("  {:<14} {}\n", c.name, c.summary));
            }
        }
    }
    s.push_str("\nGlobal flags (anywhere before a `--`; for `extend adb`, only before its first argument)\n");
    for (f, d) in GLOBAL_FLAGS {
        s.push_str(&format!("  {f:<24} {d}\n"));
    }
    s.push_str(&format!(
        "\nState lives in {} (move it with `extend config home <dir>`).\nDocs {DOCS} · Source {REPO} · Rust crate {CRATE}\nFound a bug? `extend report \"...\" --pr <link>` — Extend is open source, patches welcome.\n",
        crate::store::root().display()
    ));
    s
}

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

    #[test]
    fn tv_click_help_uses_element_requirements_without_offering_gestures() {
        let commands = vec!["snapshot".to_owned(), "click".to_owned()];
        let on = Connected {
            session_id: "a3f",
            device: "TV",
            os: "android_tv",
            commands: &commands,
            missing: &[],
            refreshed_at: 0,
        };
        let help = render_device_command("click", Some(&on)).unwrap();
        assert!(help.contains("a device with: screen.read."), "{help}");
        assert!(!help.contains("Not available"));
        assert!(help.contains("app 1.1 or newer"));
        assert!(help.contains("Older TV apps can use `find <text> click`"));
        assert!(
            render_device_command("hover", Some(&on))
                .unwrap()
                .contains("Not available")
        );
        assert!(
            render_device_command("click", None)
                .unwrap()
                .contains("input.pointer or input.touch")
        );
    }

    #[test]
    fn install_help_matches_the_contract() {
        let contract = std::fs::read_to_string(
            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../understanding/cli.yaml"),
        )
        .unwrap();
        for name in ["install", "reinstall"] {
            let help = render_device_command(name, None).unwrap();
            let usage_line = help.lines().skip_while(|l| *l != "Usage:").nth(1).unwrap();
            assert!(
                !usage_line.contains("file_id"),
                "`extend {name} --help` offers a file id: {usage_line}"
            );
            let line = format!("extend {}", usage(COMMANDS.iter().find(|c| c.name == name).unwrap()));
            assert!(help.contains(&format!("Usage:\n  {line}\n")), "{help}");
            assert!(contract.contains(&line), "understanding/cli.yaml has no `{line}`");
            assert!(
                help.contains("at most\n8 MiB") && help.contains("extend file get <file_id> --out ./app.apk"),
                "{help}"
            );
        }
    }

    /// Every flag an Extend command accepts is documented in its help node.
    #[test]
    fn every_accepted_flag_is_in_help() {
        for spec in crate::args::SPECS {
            let node = find(spec.path)
                .or_else(|| spec.path.rsplit_once(' ').and_then(|(p, _)| find(p)))
                .or_else(|| find(spec.path.split(' ').next().unwrap()))
                .unwrap_or_else(|| panic!("no help for `extend {}`", spec.path));
            for (flag, _) in spec.flags {
                let documented = node.usage.contains(flag) || node.flags.iter().any(|(f, _)| f.contains(flag));
                assert!(
                    documented,
                    "`extend {}` accepts {flag}, but `extend {} --help` doesn't mention it",
                    spec.path, node.path
                );
            }
        }
    }

    #[test]
    fn help_marks_commands_the_connected_device_cannot_run() {
        let commands = vec!["snapshot".to_owned(), "terminal".to_owned()];
        let missing = vec![crate::store::MissingNote {
            capability: "input.remote".into(),
            reason: "Only TVs have a remote.".into(),
        }];
        let on = Connected {
            session_id: "a3f",
            device: "CLI box",
            os: "linux",
            commands: &commands,
            missing: &missing,
            refreshed_at: 0,
        };
        let tv = render_device_command("tv-remote", Some(&on)).unwrap();
        assert!(
            tv.contains(
                "Not available on CLI box (linux) in session a3f: it needs input.remote, which the device doesn't have (when you connected). input.remote: Only TVs have a remote."
            ),
            "{tv}"
        );
        let snap = render_device_command("snapshot", Some(&on)).unwrap();
        assert!(!snap.contains("Not available"), "{snap}");
        assert!(
            !render_device_command("tv-remote", None)
                .unwrap()
                .contains("Not available")
        );

        let empty: Vec<String> = vec![];
        let offline = Connected { commands: &empty, ..on };
        assert!(render_top(Some(&offline)).contains("(none right now"));
    }
}