Skip to main content

bern_target_api/
skill.rs

1//! The agent skill a build ships beside its command line: a `SKILL.md`
2//! that tells an agent how to reach the running application and lists
3//! every command, query, state field and UI action of this build, from
4//! the same catalog the command line and the MCP server derive theirs from.
5//!
6//! It goes into `skills/<program>/SKILL.md`, beside `bin/`, so the folder
7//! can be copied as it is into an agent's skills directory. Targets write
8//! it only when the application listens on the control channel; without
9//! one the programs cannot reach it.
10
11use crate::BuildStep;
12use crate::glue::program;
13use bern_ir::Application;
14use bern_media::media_catalog;
15use bern_protocol::{CommandSpec, Retry, Type, TypeTable, session_catalog};
16use std::fmt::Write as _;
17use std::path::{Path, PathBuf};
18
19/// Folder holding the skill below the root of a build or package.
20pub const DIR: &str = "skills";
21
22/// The longest description agents read from a skill's front matter.
23const DESCRIPTION_LIMIT: usize = 1024;
24
25/// Where the skill goes below the root of a build or package:
26/// `skills/<program>/SKILL.md`.
27pub fn path(app: &Application) -> PathBuf {
28    PathBuf::from(DIR).join(program(app)).join("SKILL.md")
29}
30
31/// How one copy of the skill finds the programs, and what reaching the
32/// application takes on its target.
33#[derive(Clone, Debug, PartialEq, Eq)]
34pub struct Place {
35    /// The command line, relative to the skill's folder.
36    pub cli: String,
37    /// The MCP server, relative to the skill's folder.
38    pub mcp: String,
39    /// Where an installed copy of the application keeps the command line,
40    /// when its packages install to a known place.
41    pub installed: Option<String>,
42    /// What the programs need before they reach the application, besides
43    /// it running, as Markdown.
44    pub connect: Option<String>,
45}
46
47impl Place {
48    /// The programs in `bin/` beside `skills/`, as every build lays them
49    /// out; `windows` spells the paths as Windows does, with `.exe`.
50    pub fn beside_bin(app: &Application, windows: bool) -> Place {
51        let program = program(app);
52        let (separator, suffix) = if windows { ("\\", ".exe") } else { ("/", "") };
53        let path = |name: &str| ["..", "..", "bin", &format!("{name}{suffix}")].join(separator);
54        Place {
55            cli: path(&program),
56            mcp: path(&format!("{program}-mcp")),
57            installed: None,
58            connect: None,
59        }
60    }
61
62    pub fn installed(mut self, path: impl Into<String>) -> Place {
63        self.installed = Some(path.into());
64        self
65    }
66
67    pub fn connect(mut self, text: impl Into<String>) -> Place {
68        self.connect = Some(text.into());
69        self
70    }
71}
72
73/// The steps that write the skill below `root`, or, when the target does
74/// not listen (`place` is `None`), remove the one an earlier build left.
75/// The folder is written afresh, so a renamed program leaves no old skill.
76pub fn steps(app: &Application, root: &Path, place: Option<&Place>) -> Vec<BuildStep> {
77    let mut steps = vec![BuildStep::RemoveIfExists {
78        path: root.join(DIR),
79    }];
80    if let Some(place) = place {
81        steps.push(BuildStep::Write {
82            path: root.join(path(app)),
83            contents: markdown(app, place).into_bytes(),
84        });
85    }
86    steps
87}
88
89/// Whether the target's application listens on the control channel.
90pub fn listens(channels: &[String]) -> bool {
91    channels.iter().any(|channel| channel == "ipc")
92}
93
94/// What an Android app needs: the programs run on this computer and reach
95/// the device through `adb forward`.
96pub fn android_connect(app: &Application) -> String {
97    let id = &app.app.bundle_id;
98    let dir = format!(
99        "bern-{:016x}",
100        bern_protocol::fingerprint_bytes(id.as_bytes())
101    );
102    format!(
103        "The app runs on the Android device and the programs run on this computer, which reaches the device through `adb`. `bern run --target android` forwards the control channel; when the app was started otherwise, forward it first:\n\n\
104         ```bash\n\
105         dir=\"${{BERN_IPC_DIR:-${{TMPDIR:-/tmp}}}}/{dir}\"\n\
106         mkdir -p \"$dir\" && chmod 700 \"$dir\" && rm -f \"$dir/android.sock\"\n\
107         adb forward localfilesystem:\"$dir/android.sock\" localabstract:bern.{id}\n\
108         ```\n\n\
109         On Windows the folder is `%TEMP%\\{dir}`."
110    )
111}
112
113/// What an app in the iOS or tvOS simulator needs: it listens only when
114/// started with the directory the programs use. `system` is the one it
115/// runs on, `ios` or `tvos`.
116pub fn simulator_connect(app: &Application, system: &str) -> String {
117    let id = &app.app.bundle_id;
118    format!(
119        "The app runs in the simulator and listens only when it was started with `BERN_IPC_DIR` naming the directory the programs use. `bern run` starts it so; otherwise launch it with:\n\n\
120         ```bash\n\
121         SIMCTL_CHILD_BERN_IPC_DIR=\"${{BERN_IPC_DIR:-${{TMPDIR:-/tmp}}}}\" xcrun simctl launch booted {id}\n\
122         ```\n\n\
123         When this Mac runs the macOS build of the same app too, both answer: choose the simulator's with `--engine {system}`."
124    )
125}
126
127/// One line of text: a doc comment's lines joined with spaces.
128fn one_line(text: &str) -> String {
129    text.split_whitespace().collect::<Vec<_>>().join(" ")
130}
131
132/// One line of text inside a Markdown table cell.
133fn cell(text: &str) -> String {
134    one_line(text).replace('|', "\\|")
135}
136
137/// A string in YAML's double-quoted style, which JSON strings are.
138fn yaml_string(text: &str) -> String {
139    serde_json::to_string(text).expect("strings serialize")
140}
141
142/// The type of a parameter or field as the command line takes it: the
143/// variants of an enum without data, else the catalog's spelling.
144fn type_text(ty: &Type, types: &TypeTable) -> String {
145    match ty {
146        Type::Enum { name } => match types.enum_def(name) {
147            Some(def) if def.is_unit_only() => def
148                .variants
149                .iter()
150                .map(|variant| variant.name.as_str())
151                .collect::<Vec<_>>()
152                .join("|"),
153            _ => ty.display(),
154        },
155        Type::Optional { inner } => type_text(inner, types),
156        other => other.display(),
157    }
158}
159
160/// The application's name in every language it has, its default first.
161fn names(app: &Application) -> Vec<String> {
162    let name = &app.app.name;
163    let mut names: Vec<String> = Vec::new();
164    let languages = std::iter::once(&app.languages.default).chain(&app.languages.supported);
165    for text in languages
166        .filter_map(|language| name.get(language))
167        .chain(name.translations.values().map(String::as_str))
168    {
169        let text = one_line(text);
170        if !text.is_empty() && !names.contains(&text) {
171            names.push(text);
172        }
173    }
174    if names.is_empty() {
175        names.push(program(app));
176    }
177    names
178}
179
180/// The skill's front matter description: what it is for and when to use
181/// it, followed by as many of the commands as fit.
182fn description(app: &Application, names: &[String], cli: &str) -> String {
183    let mut text = format!(
184        "Operate {} ({}) while it runs, through its command line `{cli}` and its MCP server `{cli}-mcp`: every command, query, state field and UI action of this build, with their parameters. Use when asked to use, inspect, test or automate {}.",
185        names.join(" / "),
186        app.app.bundle_id,
187        names[0],
188    );
189    if text.chars().count() > DESCRIPTION_LIMIT {
190        text = text.chars().take(DESCRIPTION_LIMIT - 1).collect();
191        text.push('…');
192        return text;
193    }
194    // " Commands: a, b, …." within the limit.
195    let room = DESCRIPTION_LIMIT - text.chars().count();
196    let mut listed: Vec<String> = Vec::new();
197    let mut length = " Commands: .".chars().count();
198    let mut all = true;
199    for spec in &app.protocol.commands {
200        let command = spec.external.cli.join(" ");
201        let added = command.chars().count() + if listed.is_empty() { 0 } else { 2 };
202        // Leave room for ", …" after the last one that fits.
203        if length + added + 3 > room {
204            all = false;
205            break;
206        }
207        length += added;
208        listed.push(command);
209    }
210    if !listed.is_empty() {
211        text.push_str(" Commands: ");
212        text.push_str(&listed.join(", "));
213        if !all {
214            text.push_str(", …");
215        }
216        text.push('.');
217    }
218    text
219}
220
221/// Named types the listed entries use, each with the table defining it,
222/// in order of first use.
223struct Types<'a> {
224    seen: Vec<(String, &'a TypeTable)>,
225}
226
227impl<'a> Types<'a> {
228    fn add(&mut self, ty: &Type, types: &'a TypeTable) {
229        match ty {
230            Type::Optional { inner } | Type::List { inner } => self.add(inner, types),
231            Type::Enum { name } | Type::Record { name } => {
232                if self.seen.iter().any(|(seen, _)| seen == name) {
233                    return;
234                }
235                self.seen.push((name.clone(), types));
236                if let Some(def) = types.record_def(name) {
237                    for field in &def.fields {
238                        self.add(&field.ty, types);
239                    }
240                }
241                if let Some(def) = types.enum_def(name) {
242                    for variant in &def.variants {
243                        for field in &variant.fields {
244                            self.add(&field.ty, types);
245                        }
246                    }
247                }
248            }
249            _ => {}
250        }
251    }
252
253    fn add_spec(&mut self, spec: &CommandSpec, types: &'a TypeTable) {
254        for param in &spec.params {
255            self.add(&param.ty, types);
256        }
257        self.add(&spec.result, types);
258    }
259}
260
261/// Error codes every command of a group may return, said once for the
262/// group rather than with each command.
263fn common_errors(specs: &[CommandSpec]) -> Vec<String> {
264    let Some((first, rest)) = specs.split_first() else {
265        return Vec::new();
266    };
267    if rest.is_empty() {
268        return Vec::new();
269    }
270    first
271        .errors
272        .iter()
273        .filter(|code| rest.iter().all(|spec| spec.errors.contains(code)))
274        .cloned()
275        .collect()
276}
277
278fn codes(errors: &[String]) -> String {
279    errors
280        .iter()
281        .map(|code| format!("`{code}`"))
282        .collect::<Vec<_>>()
283        .join(", ")
284}
285
286fn command(
287    out: &mut String,
288    cli: &str,
289    spec: &CommandSpec,
290    types: &TypeTable,
291    session: bool,
292    common: &[String],
293) {
294    let _ = writeln!(out, "### `{cli} {}`\n", spec.external.cli.join(" "));
295    let doc = spec.doc.trim();
296    if !doc.is_empty() {
297        let doc: Vec<&str> = doc.lines().map(str::trim).collect();
298        let _ = writeln!(out, "{}\n", doc.join("\n"));
299    }
300    for param in &spec.params {
301        let optional = matches!(param.ty, Type::Optional { .. });
302        let _ = write!(
303            out,
304            "- `--{} <{}>`",
305            param.name.replace('_', "-"),
306            type_text(&param.ty, types)
307        );
308        if optional {
309            out.push_str(" (optional; `none` for no value)");
310        }
311        let doc = one_line(&param.doc);
312        if !doc.is_empty() {
313            let _ = write!(out, ": {doc}");
314        }
315        out.push('\n');
316    }
317    if !spec.params.is_empty() {
318        out.push('\n');
319    }
320    let mut facts = Vec::new();
321    if spec.result != Type::Unit {
322        facts.push(format!("Result: `{}`.", spec.result.display()));
323    }
324    let errors: Vec<String> = spec
325        .errors
326        .iter()
327        .filter(|code| !common.contains(code))
328        .cloned()
329        .collect();
330    if !errors.is_empty() {
331        let also = if common.is_empty() { "" } else { "also " };
332        facts.push(format!("Errors: {also}{}.", codes(&errors)));
333    }
334    if let Some(event) = &spec.on {
335        facts.push(format!("Also runs by itself after the event `{event}`."));
336    }
337    if spec.policy.retry == Retry::Idempotent && !spec.is_query() {
338        facts.push("Repeating it leaves the same state.".into());
339    }
340    let mcp = if session {
341        format!(
342            "MCP tool: `{}` (with an optional `session_id`).",
343            spec.external.mcp
344        )
345    } else {
346        format!("MCP tool: `{}`.", spec.external.mcp)
347    };
348    facts.push(mcp);
349    let _ = writeln!(out, "{}\n", facts.join(" "));
350}
351
352fn type_section(out: &mut String, name: &str, types: &TypeTable) {
353    if let Some(def) = types.record_def(name) {
354        let _ = writeln!(
355            out,
356            "### `{name}`\n\nA JSON object with every one of these fields:\n\n| Field | Type | Meaning |\n| --- | --- | --- |"
357        );
358        for field in &def.fields {
359            let _ = writeln!(
360                out,
361                "| `{}` | `{}` | {} |",
362                field.name,
363                field.ty.display(),
364                cell(&field.doc)
365            );
366        }
367        out.push('\n');
368    } else if let Some(def) = types.enum_def(name) {
369        if def.is_unit_only() {
370            let variants: Vec<String> = def
371                .variants
372                .iter()
373                .map(|variant| format!("`\"{}\"`", variant.name))
374                .collect();
375            let _ = writeln!(out, "### `{name}`\n\nOne of {}.\n", variants.join(", "));
376        } else {
377            let _ = writeln!(
378                out,
379                "### `{name}`\n\nA variant name as a JSON string, or for a variant with fields an object `{{\"Variant\": {{fields}}}}`:\n"
380            );
381            for variant in &def.variants {
382                if variant.fields.is_empty() {
383                    let _ = writeln!(out, "- `\"{}\"`", variant.name);
384                } else {
385                    let fields: Vec<String> = variant
386                        .fields
387                        .iter()
388                        .map(|field| format!("`{}`: `{}`", field.name, field.ty.display()))
389                        .collect();
390                    let _ = writeln!(out, "- `{}` with {}", variant.name, fields.join(", "));
391                }
392            }
393            out.push('\n');
394        }
395    }
396}
397
398/// The skill's `SKILL.md`.
399pub fn markdown(app: &Application, place: &Place) -> String {
400    let cli = program(app);
401    let names = names(app);
402    let protocol = &app.protocol;
403    let (media_types, media_specs) = media_catalog();
404    let (session_types, session_specs) = session_catalog();
405    let media = !app.players.is_empty();
406    let commands: Vec<&CommandSpec> = protocol
407        .commands
408        .iter()
409        .filter(|spec| !spec.is_query())
410        .collect();
411    let queries: Vec<&CommandSpec> = protocol
412        .commands
413        .iter()
414        .filter(|spec| spec.is_query())
415        .collect();
416
417    let mut out = String::new();
418    let _ = writeln!(
419        out,
420        "---\nname: {cli}\ndescription: {}\n---\n",
421        yaml_string(&description(app, &names, &cli))
422    );
423    let _ = writeln!(out, "# {}\n", names[0]);
424    let _ = writeln!(
425        out,
426        "{} (`{}`, version {}) is built with Bern: everything it does is a command of one catalog, and its command line and MCP server reach the running app through the same catalog its own controls use. This file lists every command of this build and was generated with it; a new build brings a new copy.\n",
427        names.join(" / "),
428        app.app.bundle_id,
429        one_line(&app.app.version)
430    );
431
432    out.push_str(
433        "## Programs\n\n| Program | Relative to this file | What it is |\n| --- | --- | --- |\n",
434    );
435    let _ = writeln!(
436        out,
437        "| `{cli}` | `{}` | Command line: one command per run |",
438        place.cli
439    );
440    let _ = writeln!(
441        out,
442        "| `{cli}-mcp` | `{}` | MCP server on standard input and output |\n",
443        place.mcp
444    );
445    if let Some(installed) = &place.installed {
446        let _ = writeln!(
447            out,
448            "An installed copy of the app keeps the command line at `{installed}`, the MCP server beside it.\n"
449        );
450    }
451    let _ = writeln!(
452        out,
453        "Below, `{cli}` stands for the command line's path. Both programs talk to the app that is already running and never start it or a second copy: start the app first. `{cli} state` shows whether they reach it.\n"
454    );
455    if let Some(connect) = &place.connect {
456        let _ = writeln!(out, "{}\n", connect.trim_end());
457    }
458
459    let _ = writeln!(
460        out,
461        "## Calling commands\n\n```\n{cli} [--engine ID|SYSTEM] [--session ID] <command> [--parameter value]...\n```\n"
462    );
463    let _ = writeln!(
464        out,
465        "- Write parameters as `--name value` or `--name=value`. Text goes as it is; `int` and `float` as numbers; `bool` as `true` or `false`; an optional parameter takes `none` for no value; a choice takes its variant name. Lists, records and variants with fields go as JSON (see Types below).\n\
466         - Each run waits for the result and prints it as JSON. Exit status: 0 done, 1 the command failed (`error[code]: message` on standard error), 2 wrong usage, 3 the app is not running or the connection was lost.\n\
467         - `--engine` picks the app when several copies run: by the system it runs on (`macos`, `ios`, `tvos`, `windows`, `linux`, `android`), or by its id, which is new each time it starts; the error lists them. `--session ID` picks the window session for `ui` and `window` commands when several are open.\n\
468         - `{cli} state` prints the state below, `{cli} sessions` the open UI sessions and windows, `{cli} catalog` the whole catalog as JSON, `{cli} --help` the commands.\n"
469    );
470
471    let mcp_file = place.mcp.rsplit(['/', '\\']).next().unwrap_or(&place.mcp);
472    let _ = writeln!(
473        out,
474        "## MCP server\n\n`{cli}-mcp` speaks MCP over standard input and output. Register it with its absolute path, for example:\n\n```json\n{{ \"mcpServers\": {{ \"{cli}\": {{ \"command\": \"/absolute/path/to/{mcp_file}\" }} }} }}\n```\n"
475    );
476    let _ = writeln!(
477        out,
478        "When several copies of the app run, its tools fail with an error listing them: start it with `--engine` and the system or id, as the command line takes it, to bind it to one. Name the system in a registration, for example `\"args\": [\"--engine\", \"macos\"]`; the id is new each time the app starts.\n"
479    );
480    let _ = writeln!(
481        out,
482        "Its tools are the commands below under their **MCP tool** names, taking the parameters as a JSON object with the names written with `_`. `bern_state` reads the state and `bern_sessions` lists the UI sessions.\n"
483    );
484
485    let mut types = Types { seen: Vec::new() };
486    for field in &protocol.state {
487        types.add(&field.ty, &protocol.types);
488    }
489    for spec in &protocol.commands {
490        types.add_spec(spec, &protocol.types);
491    }
492    if media {
493        for spec in &media_specs {
494            types.add_spec(spec, &media_types);
495        }
496    }
497    for spec in &session_specs {
498        types.add_spec(spec, &session_types);
499    }
500
501    if !protocol.state.is_empty() {
502        let _ = writeln!(
503            out,
504            "## State\n\n`{cli} state` prints `{{\"revision\": n, \"values\": {{…}}}}` with these fields:\n\n| Field | Type | Meaning |\n| --- | --- | --- |"
505        );
506        for field in &protocol.state {
507            let _ = writeln!(
508                out,
509                "| `{}` | `{}` | {} |",
510                field.name,
511                field.ty.display(),
512                cell(&field.doc)
513            );
514        }
515        out.push('\n');
516    }
517
518    if !commands.is_empty() {
519        out.push_str(
520            "## Commands\n\nThese change the app's state, with or without a window open.\n\n",
521        );
522        for spec in &commands {
523            command(&mut out, &cli, spec, &protocol.types, false, &[]);
524        }
525    }
526    if !queries.is_empty() {
527        out.push_str("## Queries\n\nThese only read and change nothing.\n\n");
528        for spec in &queries {
529            command(&mut out, &cli, spec, &protocol.types, false, &[]);
530        }
531    }
532
533    if media {
534        out.push_str(
535            "## Media\n\nThe app's players, which these commands act on by `player_id`:\n\n",
536        );
537        for player in &app.players {
538            let doc = one_line(&player.doc);
539            if doc.is_empty() {
540                let _ = writeln!(out, "- `{}`", player.id);
541            } else {
542                let _ = writeln!(out, "- `{}`: {doc}", player.id);
543            }
544        }
545        out.push('\n');
546        let common = common_errors(&media_specs);
547        if !common.is_empty() {
548            let _ = writeln!(out, "Each of them may fail with {}.\n", codes(&common));
549        }
550        for spec in &media_specs {
551            command(&mut out, &cli, spec, &media_types, false, &common);
552        }
553    }
554
555    out.push_str(
556        "## Driving the UI\n\nThese act on an open window through its real controls, as a person would: a hidden or disabled control refuses, and without an open window they fail with `requires_ui_session`. Start with `ui inspect`, which lists the mounted controls with the `target` id each other command takes. A control written in a component is `component.control`; one written directly in a window takes the window's id, such as `main.add`; a control repeated for each item adds the item keys, outermost first, such as `card.open[3]`.\n\n",
557    );
558    if !app.windows.windows.is_empty() {
559        out.push_str("Window kinds, for `window open --kind`:\n\n");
560        for window in &app.windows.windows {
561            let title = window
562                .title
563                .get(&app.languages.default)
564                .map(one_line)
565                .unwrap_or_default();
566            let primary = if window.id == app.windows.primary {
567                " (the main window)"
568            } else {
569                ""
570            };
571            if title.is_empty() {
572                let _ = writeln!(out, "- `{}`{primary}", window.id);
573            } else {
574                let _ = writeln!(out, "- `{}`{primary}: {title}", window.id);
575            }
576        }
577        out.push('\n');
578    }
579    let languages: Vec<String> = app
580        .languages
581        .supported
582        .iter()
583        .map(|language| format!("`{language}`"))
584        .collect();
585    let _ = writeln!(
586        out,
587        "Interface languages, for `ui set-language`: {}.\n",
588        languages.join(", ")
589    );
590    let common = common_errors(&session_specs);
591    if !common.is_empty() {
592        let _ = writeln!(out, "Each of them may fail with {}.\n", codes(&common));
593    }
594    for spec in &session_specs {
595        command(&mut out, &cli, spec, &session_types, true, &common);
596    }
597
598    if !types.seen.is_empty() {
599        out.push_str("## Types\n\nThe JSON forms of the values the commands take and give. `int` may also be written as a string of digits; `T?` is a `T` or `null`; `[T]` is a list.\n\n");
600        for (name, table) in &types.seen {
601            type_section(&mut out, name, table);
602        }
603    }
604    while out.ends_with("\n\n") {
605        out.pop();
606    }
607    out
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613    use bern_ir::{AppInfo, Languages, PlayerDef, Theme, ThemeMode, WindowCatalog};
614    use bern_protocol::{
615        CommandPolicy, EnumDef, ExternalNames, FieldDef, LocalizedText, OperationKind, ParamSpec,
616        ProtocolDescriptor, RecordDef, Scope, StateField, VariantDef,
617    };
618    use std::collections::BTreeMap;
619
620    fn spec(name: &str, kind: OperationKind, params: Vec<ParamSpec>, result: Type) -> CommandSpec {
621        CommandSpec {
622            id: format!("planner.{name}"),
623            name: name.into(),
624            doc: format!("Doc of {name}.\nSecond line."),
625            kind,
626            scope: Scope::Engine,
627            params,
628            result,
629            errors: vec![],
630            policy: CommandPolicy::default(),
631            external: ExternalNames::derive("planner", name),
632            reads: vec![],
633            on: None,
634        }
635    }
636
637    fn param(name: &str, ty: Type) -> ParamSpec {
638        ParamSpec {
639            name: name.into(),
640            ty,
641            doc: format!("The {name}"),
642        }
643    }
644
645    fn app() -> Application {
646        let priority = EnumDef {
647            name: "Priority".into(),
648            variants: ["Low", "High"]
649                .iter()
650                .map(|name| VariantDef {
651                    name: (*name).into(),
652                    fields: vec![],
653                    label: None,
654                })
655                .collect(),
656        };
657        let note = RecordDef {
658            name: "Note".into(),
659            fields: vec![FieldDef {
660                name: "title".into(),
661                ty: Type::Text,
662                doc: "Title | shown".into(),
663                content: true,
664            }],
665        };
666        let mut rename = spec(
667            "rename",
668            OperationKind::Command,
669            vec![
670                param("new_title", Type::Text),
671                param(
672                    "priority",
673                    Type::Enum {
674                        name: "Priority".into(),
675                    },
676                ),
677            ],
678            Type::Unit,
679        );
680        rename.errors = vec!["empty_title".into()];
681        rename.policy.retry = Retry::Idempotent;
682        let notes = spec(
683            "notes",
684            OperationKind::Query,
685            vec![param("limit", Type::optional(Type::Int))],
686            Type::list(Type::Record {
687                name: "Note".into(),
688            }),
689        );
690        Application {
691            ir_version: bern_ir::IR_VERSION,
692            app: AppInfo {
693                id: "my_planner".into(),
694                bundle_id: "dev.bern.planner".into(),
695                name: LocalizedText::new([("en", "Planner \"Q\""), ("zh-Hans", "计划")]),
696                version: "0.1.0".into(),
697                icon: None,
698                platform_icons: BTreeMap::new(),
699                alternate_icons: BTreeMap::new(),
700                publisher: None,
701                files_folder: false,
702                background_audio: false,
703                overlays: false,
704            },
705            languages: Languages {
706                supported: vec!["zh-Hans".into(), "en".into()],
707                default: "zh-Hans".into(),
708                strict: true,
709            },
710            theme: Theme {
711                mode: ThemeMode::System,
712                brand_seed: None,
713            },
714            protocol: ProtocolDescriptor {
715                descriptor_version: 1,
716                namespace: "planner".into(),
717                version: 1,
718                types: TypeTable {
719                    enums: vec![priority],
720                    records: vec![note],
721                },
722                state: vec![StateField {
723                    name: "title".into(),
724                    ty: Type::Text,
725                    doc: "Title of the plan.".into(),
726                    content: true,
727                }],
728                commands: vec![rename, notes],
729                events: vec![],
730                modules: vec![],
731            },
732            types: TypeTable::default(),
733            sessions: vec![],
734            windows: WindowCatalog {
735                handle: "Windows".into(),
736                primary: "main".into(),
737                windows: vec![],
738                accent: None,
739                palette: None,
740                appearance: None,
741                icon: None,
742                menu_bar: None,
743                status_item: None,
744            },
745            players: vec![],
746            components: vec![],
747            texts: vec![],
748            resources: vec![],
749            layouts: None,
750            contracts: vec![],
751            style: Default::default(),
752        }
753    }
754
755    #[test]
756    fn the_skill_lists_every_command_with_its_cli_and_mcp_names() {
757        let app = app();
758        let text = markdown(&app, &Place::beside_bin(&app, false));
759        let front = text
760            .strip_prefix("---\n")
761            .unwrap()
762            .split_once("\n---\n")
763            .unwrap()
764            .0;
765        assert!(
766            front.starts_with("name: my-planner\ndescription: \""),
767            "{front}"
768        );
769        // The default language's name first; quotes escaped for YAML.
770        assert!(
771            front.contains("Operate 计划 / Planner \\\"Q\\\" (dev.bern.planner)"),
772            "{front}"
773        );
774        assert!(
775            front.contains("Commands: planner rename, planner notes."),
776            "{front}"
777        );
778
779        assert!(text.contains("| `my-planner` | `../../bin/my-planner` |"));
780        assert!(text.contains("| `my-planner-mcp` | `../../bin/my-planner-mcp` |"));
781        assert!(text.contains("`\"args\": [\"--engine\", \"macos\"]`"));
782        assert!(text.contains("### `my-planner planner rename`\n\nDoc of rename.\nSecond line.\n"));
783        assert!(text.contains("- `--new-title <text>`: The new_title\n"));
784        assert!(text.contains("- `--priority <Low|High>`: The priority\n"));
785        assert!(text.contains(
786            "Errors: `empty_title`. Repeating it leaves the same state. MCP tool: `planner_rename`."
787        ));
788        // Queries apart from commands, optional parameters marked.
789        let queries = text.find("## Queries").unwrap();
790        assert!(text.find("planner rename`").unwrap() < queries);
791        assert!(text.find("planner notes`").unwrap() > queries);
792        assert!(text.contains("- `--limit <int>` (optional; `none` for no value): The limit\n"));
793        assert!(text.contains("Result: `[Note]`."));
794        // State, the framework's UI commands, and the types they use.
795        assert!(text.contains("| `title` | `text` | Title of the plan. |"));
796        assert!(text.contains("### `my-planner ui inspect`"));
797        assert!(text.contains("MCP tool: `ui_activate` (with an optional `session_id`)."));
798        // The errors every UI command shares are said once.
799        assert!(
800            text.contains("Each of them may fail with `requires_ui_session`, `target_not_found`")
801        );
802        assert!(
803            text.contains(
804                "Errors: also `invalid_url`, `unsupported_file`. MCP tool: `ui_set_file`"
805            )
806        );
807        assert_eq!(text.matches("`requires_ui_session`").count(), 2, "{text}");
808        assert!(text.contains("Interface languages, for `ui set-language`: `zh-Hans`, `en`."));
809        assert!(text.contains("### `Note`\n\nA JSON object"));
810        assert!(text.contains("| `title` | `text` | Title \\| shown |"));
811        assert!(text.contains("### `Priority`\n\nOne of `\"Low\"`, `\"High\"`."));
812        assert!(text.contains("### `ControlInfo`"));
813        // Media only for an application with players.
814        assert!(!text.contains("## Media"));
815        assert!(text.ends_with(".\n") && !text.ends_with("\n\n"));
816    }
817
818    #[test]
819    fn media_and_places_follow_the_target() {
820        let mut app = app();
821        app.players.push(PlayerDef {
822            id: "main".into(),
823            doc: "The video".into(),
824        });
825        let place = Place::beside_bin(&app, true)
826            .installed("%LOCALAPPDATA%\\Programs\\Planner\\bin\\my-planner.exe")
827            .connect(android_connect(&app));
828        let text = markdown(&app, &place);
829        assert!(text.contains("| `my-planner` | `..\\..\\bin\\my-planner.exe` |"));
830        assert!(text.contains("\"command\": \"/absolute/path/to/my-planner-mcp.exe\""));
831        assert!(text.contains("at `%LOCALAPPDATA%\\Programs\\Planner\\bin\\my-planner.exe`"));
832        assert!(text.contains("localabstract:bern.dev.bern.planner"));
833        assert!(text.contains("## Media\n\nThe app's players, which these commands act on by `player_id`:\n\n- `main`: The video\n"));
834        assert!(text.contains("### `my-planner media play`"));
835    }
836
837    #[test]
838    fn the_description_stays_within_what_agents_read() {
839        let mut app = app();
840        let template = app.protocol.commands[0].clone();
841        for index in 0..200 {
842            let mut spec = template.clone();
843            spec.external = ExternalNames::derive("planner", &format!("command_number_{index}"));
844            app.protocol.commands.push(spec);
845        }
846        let names = names(&app);
847        let text = description(&app, &names, "my-planner");
848        assert!(text.chars().count() <= DESCRIPTION_LIMIT, "{}", text.len());
849        assert!(
850            text.contains(" Commands: planner rename, planner notes, "),
851            "{text}"
852        );
853        assert!(text.ends_with(", …."), "{text}");
854    }
855
856    #[test]
857    fn steps_write_the_skill_only_where_the_app_listens() {
858        let app = app();
859        let root = Path::new("/out");
860        let place = Place::beside_bin(&app, false);
861        let steps = steps(&app, root, Some(&place));
862        assert!(
863            matches!(&steps[0], BuildStep::RemoveIfExists { path } if path == &root.join("skills"))
864        );
865        assert!(
866            matches!(&steps[1], BuildStep::Write { path, .. } if path == &root.join("skills/my-planner/SKILL.md"))
867        );
868        let steps = super::steps(&app, root, None);
869        assert_eq!(steps.len(), 1);
870        assert!(listens(&["ipc".to_string()]) && !listens(&[]));
871    }
872}