Skip to main content

opencode_ctl/
skill_asset.rs

1//! The two opencode-ctl skills, embedded as templates and rendered per host.
2//!
3//! The canonical templates live INSIDE this crate (`skill/`), so `include_str!` needs no `..` traversal.
4//! `render` strips the maintainer header, substitutes the host placeholders and returns the exact bytes
5//! `emit-skill <name> --host <h>` prints; the committed per-plugin copies are pinned against this output.
6
7use crate::error::{ErrorCode, ToolError};
8use crate::host::Host;
9
10/// The driver skill template, embedded at compile time (version-locked to this binary).
11pub const DRIVER_SKILL: &str = include_str!("../skill/opencode-ctl-driver.md");
12
13/// The doctor skill template, embedded at compile time.
14pub const DOCTOR_SKILL: &str = include_str!("../skill/opencode-ctl-doctor.md");
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum SkillName {
18    Driver,
19    Doctor,
20}
21
22impl SkillName {
23    pub fn as_str(self) -> &'static str {
24        match self {
25            SkillName::Driver => "opencode-ctl-driver",
26            SkillName::Doctor => "opencode-ctl-doctor",
27        }
28    }
29
30    pub fn parse(s: &str) -> Option<SkillName> {
31        match s {
32            "opencode-ctl-driver" => Some(SkillName::Driver),
33            "opencode-ctl-doctor" => Some(SkillName::Doctor),
34            _ => None,
35        }
36    }
37
38    fn template(self) -> &'static str {
39        match self {
40            SkillName::Driver => DRIVER_SKILL,
41            SkillName::Doctor => DOCTOR_SKILL,
42        }
43    }
44}
45
46/// Remove the leading HTML-comment header (the same rule as `shared/ghidra-mcp/src/skill_asset.rs:20-40`:
47/// only when the template starts with `<!--`, resuming after whatever line ending follows the `-->` line).
48fn strip_header(t: &str) -> &str {
49    if !t.starts_with("<!--") {
50        return t;
51    }
52    match t.find("-->") {
53        Some(i) => match t[i..].find('\n') {
54            Some(j) => &t[i + j + 1..],
55            None => "",
56        },
57        None => t,
58    }
59}
60
61fn host_name(host: Host) -> &'static str {
62    match host {
63        Host::Claude | Host::Unknown => "Claude Code",
64        Host::Qwen => "Qwen Code",
65        Host::Agy => "Antigravity",
66        Host::Opencode => "OpenCode",
67    }
68}
69
70fn binary(host: Host) -> &'static str {
71    match host {
72        Host::Claude | Host::Unknown => "${CLAUDE_PLUGIN_ROOT}/bin/opencode-ctl-cc-mcp",
73        Host::Qwen => "~/.qwen/extensions/opencode-ctl-mcp-qwen/bin/opencode-ctl-qwen-mcp",
74        Host::Agy => "opencode-ctl-agy-mcp",
75        Host::Opencode => {
76            "the first element of mcp.servers.opencode-ctl.command in your opencode.jsonc (an absolute path to opencode-ctl-cc-mcp)"
77        }
78    }
79}
80
81fn argument_hint(host: Host) -> &'static str {
82    match host {
83        Host::Claude | Host::Agy | Host::Unknown => "[install] [verbose]",
84        Host::Opencode => "[verbose]",
85        Host::Qwen => "",
86    }
87}
88
89fn install_clause(host: Host) -> &'static str {
90    match host {
91        Host::Claude | Host::Agy | Host::Unknown => {
92            ", or when the user wants opencode-pair installed (argument install)"
93        }
94        Host::Opencode | Host::Qwen => "",
95    }
96}
97
98/// The whole "Installing opencode-pair" section, without a trailing newline; it still carries `{{BINARY}}`.
99fn install_section(host: Host) -> &'static str {
100    match host {
101        Host::Claude | Host::Agy | Host::Unknown => {
102            "# Installing opencode-pair\n\nWhen invoked with `install`, run `{{BINARY}} install-launcher` and print its PATH hint."
103        }
104        Host::Qwen => {
105            "# Installing opencode-pair\n\nWhen invoked with no argument and `{{BINARY}} doctor-json` reports `launcher_link: missing`, run `{{BINARY}} install-launcher`."
106        }
107        Host::Opencode => "",
108    }
109}
110
111/// The bytes `emit-skill` prints for `(name, host)`: header stripped, every token substituted, exactly one
112/// final newline. A leftover `{{` is an error rather than silently shipped.
113pub fn render(name: SkillName, host: Host) -> Result<String, ToolError> {
114    if host == Host::Unknown {
115        return Err(ToolError::new(
116            ErrorCode::InvalidArgument,
117            "cannot render a skill for an unknown host",
118        ));
119    }
120    let doctor = host.slash_form(SkillName::Doctor.as_str());
121    let hint = argument_hint(host);
122    let host_flag = if host == Host::Opencode {
123        " --host opencode"
124    } else {
125        ""
126    };
127    // INSTALL_SECTION first: its text contains {{BINARY}}.
128    let mut out =
129        strip_header(name.template()).replace("{{INSTALL_SECTION}}", install_section(host));
130    for (token, value) in [
131        ("{{INVOCATION}}", host.slash_form(name.as_str())),
132        ("{{DOCTOR_INVOCATION}}", doctor),
133        ("{{BINARY}}", binary(host).to_string()),
134        ("{{ARGUMENT_HINT}}", hint.to_string()),
135        ("{{ALLOWED_TOOLS}}", "Bash, Read, Write".to_string()),
136        ("{{INSTALL_CLAUSE}}", install_clause(host).to_string()),
137        ("{{HOST_FLAG}}", host_flag.to_string()),
138        ("{{HOST_NAME}}", host_name(host).to_string()),
139    ] {
140        out = out.replace(token, &value);
141    }
142    if out.contains("{{") {
143        return Err(ToolError::new(
144            ErrorCode::CliError,
145            format!(
146                "skill template {} has an unsubstituted token",
147                name.as_str()
148            ),
149        ));
150    }
151    // An empty install section leaves trailing blank lines; every copy ends in exactly one newline.
152    let mut out = out.trim_end().to_string();
153    out.push('\n');
154    Ok(out)
155}