Skip to main content

agent_config/
schema.rs

1//! Live JSON manifest of every registered agent's file layout, surface
2//! coverage, and marker conventions.
3//!
4//! [`build`] walks [`crate::registry::all`] and the per-surface
5//! `*_capable` lists, runs probe specs through each agent's `plan_install_*`
6//! method, and renders the resulting paths as literal strings keyed by agent
7//! id, surface, and scope. Paths under the user's home are rendered with a
8//! leading `~/`; project-local paths are rendered with a leading `<project>/`.
9//!
10//! The output is intentionally machine-readable so external tooling can
11//! discover what this crate would touch without depending on the Rust API.
12//! `examples/gen_schema.rs` renders [`build`] to `schema/agents.json`.
13
14use std::collections::BTreeSet;
15use std::path::{Path, PathBuf};
16
17use serde_json::{json, Map, Value};
18
19use crate::error::AgentConfigError;
20use crate::integration::{InstructionSurface, Integration, McpSurface, SkillSurface};
21use crate::paths;
22use crate::plan::{PlannedChange, RefusalReason};
23use crate::registry::{all, instruction_capable, mcp_capable, skill_capable};
24use crate::scope::{Scope, ScopeKind};
25use crate::spec::{
26    Event, HookSpec, InstructionPlacement, InstructionSpec, Matcher, McpSpec, SkillSpec,
27};
28
29/// Sentinel project root used when probing Local-scope plans. Chosen so it
30/// will not clash with any real path, and so prefix substitution is
31/// unambiguous on output.
32const PROJECT_ROOT_SENTINEL: &str = "/__AGENT_CONFIG_PROJECT_ROOT__";
33
34/// Placeholder rendered into the final JSON in place of the user's home dir.
35const HOME_PLACEHOLDER: &str = "~";
36
37/// Placeholder rendered into the final JSON in place of the local project root.
38const PROJECT_PLACEHOLDER: &str = "<project>";
39
40/// Probe spec names. These leak into rendered paths for skill and instruction
41/// surfaces, so they read as placeholders rather than realistic identifiers.
42const SKILL_NAME_PROBE: &str = "placeholder";
43const INSTRUCTION_NAME_PROBE: &str = "PLACEHOLDER";
44const MCP_NAME_PROBE: &str = "placeholder";
45const HOOK_TAG_PROBE: &str = "placeholder";
46const OWNER_TAG_PROBE: &str = "placeholder";
47
48/// Build the full agent schema as a JSON value.
49///
50/// The shape is documented at the crate root and stabilised by the golden
51/// fixture at `schema/agents.json`. Use the
52/// [`crate::registry`] module if a programmatic Rust API is preferable.
53///
54/// **Platform note.** Cline and Roo's MCP global paths flow through
55/// `paths::config_dir()`, which differs across macOS, Linux, and Windows.
56/// The committed `schema/agents.json` is the Linux view; regenerate on
57/// Linux for canonical output. The companion test only byte-compares on
58/// Linux for the same reason.
59pub fn build() -> Value {
60    let mut root = Map::new();
61    root.insert(
62        "_warning".into(),
63        json!(
64            "AUTO-GENERATED. Do not edit by hand. \
65             Regenerate on Linux via `cargo run --example gen_schema` \
66             or `AGENT_SCHEMA_UPDATE=1 cargo test --test schema_golden`. \
67             A few VS Code globalStorage paths are OS-specific; the canonical schema is the Linux view."
68        ),
69    );
70    root.insert("crate_version".into(), json!(env!("CARGO_PKG_VERSION")));
71    root.insert("placeholders".into(), placeholders_block());
72    root.insert("marker_conventions".into(), marker_conventions_block());
73    root.insert("agents".into(), Value::Array(agents_array()));
74    Value::Object(root)
75}
76
77fn placeholders_block() -> Value {
78    json!({
79        "home": HOME_PLACEHOLDER,
80        "project_root": PROJECT_PLACEHOLDER,
81        "skill_name": SKILL_NAME_PROBE,
82        "instruction_name": INSTRUCTION_NAME_PROBE,
83        "mcp_name": MCP_NAME_PROBE,
84        "hook_tag": HOOK_TAG_PROBE,
85        "owner_tag": OWNER_TAG_PROBE,
86    })
87}
88
89fn marker_conventions_block() -> Value {
90    json!({
91        "json_tag_field": "_agent_config_tag",
92        "markdown_fence": {
93            "begin": "<!-- BEGIN AGENT-CONFIG:<NAME> -->",
94            "end": "<!-- END AGENT-CONFIG:<NAME> -->",
95        },
96        "instruction_markdown_fence": {
97            "begin": "<!-- BEGIN AGENT-CONFIG-INSTR:<NAME> -->",
98            "end": "<!-- END AGENT-CONFIG-INSTR:<NAME> -->",
99        },
100        "ledger_files": {
101            "mcp": ".agent-config-mcp.json",
102            "skill": ".agent-config-skills.json",
103            "instruction": ".agent-config-instructions.json",
104        },
105        "backup_suffix": ".bak",
106    })
107}
108
109fn agents_array() -> Vec<Value> {
110    let integrations = all();
111    let mcp_agents = mcp_capable();
112    let skill_agents = skill_capable();
113    let instruction_agents = instruction_capable();
114
115    let mut out = Vec::with_capacity(integrations.len());
116    for hook_agent in &integrations {
117        let id = hook_agent.id();
118        let mut entry = Map::new();
119        entry.insert("id".into(), json!(id));
120        entry.insert("display_name".into(), json!(hook_agent.display_name()));
121        entry.insert(
122            "supported_scopes".into(),
123            scope_list(hook_agent.supported_scopes()),
124        );
125
126        let mut surfaces = Map::new();
127        if let Some(value) = hook_surface(hook_agent.as_ref()) {
128            surfaces.insert("hook".into(), value);
129        }
130        if let Some(mcp) = mcp_agents.iter().find(|a| a.id() == id) {
131            if let Some(value) = mcp_surface(mcp.as_ref()) {
132                surfaces.insert("mcp".into(), value);
133            }
134        }
135        if let Some(skill) = skill_agents.iter().find(|a| a.id() == id) {
136            if let Some(value) = skill_surface(skill.as_ref()) {
137                surfaces.insert("skill".into(), value);
138            }
139        }
140        if let Some(instr) = instruction_agents.iter().find(|a| a.id() == id) {
141            if let Some(value) = instruction_surface(instr.as_ref()) {
142                surfaces.insert("instruction".into(), value);
143            }
144        }
145        entry.insert("surfaces".into(), Value::Object(surfaces));
146        out.push(Value::Object(entry));
147    }
148    out
149}
150
151fn scope_list(scopes: &[ScopeKind]) -> Value {
152    let mut v = Vec::new();
153    for s in scopes {
154        v.push(match s {
155            ScopeKind::Global => json!("global"),
156            ScopeKind::Local => json!("local"),
157        });
158    }
159    Value::Array(v)
160}
161
162fn hook_surface(agent: &dyn Integration) -> Option<Value> {
163    let scopes = agent.supported_scopes();
164    if scopes.is_empty() {
165        return None;
166    }
167    let mut by_scope = Map::new();
168    for kind in scopes {
169        let scope = scope_for(*kind);
170        let spec = HookSpec::builder(HOOK_TAG_PROBE)
171            .command_program("noop", [] as [&str; 0])
172            .matcher(Matcher::Bash)
173            .event(Event::PreToolUse)
174            .rules("placeholder")
175            .build();
176        let plan_result = agent.plan_install(&scope, &spec);
177        by_scope.insert(
178            scope_key(*kind).into(),
179            changes_to_value(&scope, plan_result),
180        );
181    }
182    Some(json!({
183        "supported_scopes": scope_list(scopes),
184        "scopes": by_scope,
185    }))
186}
187
188fn mcp_surface(agent: &dyn McpSurface) -> Option<Value> {
189    let scopes = agent.supported_mcp_scopes();
190    if scopes.is_empty() {
191        return None;
192    }
193    let mut by_scope = Map::new();
194    for kind in scopes {
195        let scope = scope_for(*kind);
196        // No env to avoid the inline-secret refusal in Local scope.
197        let spec = McpSpec::builder(MCP_NAME_PROBE)
198            .owner(OWNER_TAG_PROBE)
199            .stdio("noop", [] as [&str; 0])
200            .build();
201        let plan_result = agent.plan_install_mcp(&scope, &spec);
202        by_scope.insert(
203            scope_key(*kind).into(),
204            changes_to_value(&scope, plan_result),
205        );
206    }
207    Some(json!({
208        "supported_scopes": scope_list(scopes),
209        "scopes": by_scope,
210    }))
211}
212
213fn skill_surface(agent: &dyn SkillSurface) -> Option<Value> {
214    let scopes = agent.supported_skill_scopes();
215    if scopes.is_empty() {
216        return None;
217    }
218    let mut by_scope = Map::new();
219    for kind in scopes {
220        let scope = scope_for(*kind);
221        let spec = SkillSpec::builder(SKILL_NAME_PROBE)
222            .owner(OWNER_TAG_PROBE)
223            .description("placeholder skill for schema generation")
224            .body("placeholder")
225            .build();
226        let plan_result = agent.plan_install_skill(&scope, &spec);
227        by_scope.insert(
228            scope_key(*kind).into(),
229            changes_to_value(&scope, plan_result),
230        );
231    }
232    Some(json!({
233        "supported_scopes": scope_list(scopes),
234        "scopes": by_scope,
235    }))
236}
237
238fn instruction_surface(agent: &dyn InstructionSurface) -> Option<Value> {
239    let scopes = agent.supported_instruction_scopes();
240    if scopes.is_empty() {
241        return None;
242    }
243    let placement = instruction_placement_for(agent.id());
244    let mut by_scope = Map::new();
245    for kind in scopes {
246        let scope = scope_for(*kind);
247        let spec = InstructionSpec::builder(INSTRUCTION_NAME_PROBE)
248            .owner(OWNER_TAG_PROBE)
249            .placement(placement)
250            .body("placeholder")
251            .build();
252        let plan_result = agent.plan_install_instruction(&scope, &spec);
253        by_scope.insert(
254            scope_key(*kind).into(),
255            changes_to_value(&scope, plan_result),
256        );
257    }
258    Some(json!({
259        "supported_scopes": scope_list(scopes),
260        "placement": placement_label(placement),
261        "scopes": by_scope,
262    }))
263}
264
265fn instruction_placement_for(id: &str) -> InstructionPlacement {
266    match id {
267        "claude" => InstructionPlacement::ReferencedFile,
268        "cline" | "roo" | "kilocode" | "windsurf" | "antigravity" => {
269            InstructionPlacement::StandaloneFile
270        }
271        _ => InstructionPlacement::InlineBlock,
272    }
273}
274
275fn placement_label(p: InstructionPlacement) -> &'static str {
276    match p {
277        InstructionPlacement::InlineBlock => "inline_block",
278        InstructionPlacement::ReferencedFile => "referenced_file",
279        InstructionPlacement::StandaloneFile => "standalone_file",
280    }
281}
282
283fn scope_for(kind: ScopeKind) -> Scope {
284    match kind {
285        ScopeKind::Global => Scope::Global,
286        ScopeKind::Local => Scope::Local(PathBuf::from(PROJECT_ROOT_SENTINEL)),
287    }
288}
289
290fn scope_key(kind: ScopeKind) -> &'static str {
291    match kind {
292        ScopeKind::Global => "global",
293        ScopeKind::Local => "local",
294    }
295}
296
297/// Turn one plan into the per-scope JSON block. On error (e.g. unsupported
298/// scope, missing `$HOME`), we record the error string instead of dropping the
299/// scope so consumers can see why a surface is unavailable.
300fn changes_to_value(
301    scope: &Scope,
302    plan: Result<crate::plan::InstallPlan, AgentConfigError>,
303) -> Value {
304    let plan = match plan {
305        Ok(p) => p,
306        Err(e) => {
307            return json!({
308                "error": e.to_string(),
309                "config_files": [],
310                "directories": [],
311                "ledger_files": [],
312                "refusals": [],
313            });
314        }
315    };
316
317    // Use BTreeSet for deterministic, deduplicated output.
318    let mut config_files: BTreeSet<String> = BTreeSet::new();
319    let mut directories: BTreeSet<String> = BTreeSet::new();
320    let mut ledger_files: BTreeSet<String> = BTreeSet::new();
321    let mut refusals: Vec<Value> = Vec::new();
322
323    for change in &plan.changes {
324        match change {
325            PlannedChange::CreateFile { path } | PlannedChange::PatchFile { path } => {
326                record_file_path(scope, path, &mut config_files, &mut directories);
327            }
328            PlannedChange::CreateDir { path } => {
329                if let Ok(s) = render_path(scope, path) {
330                    directories.insert(s);
331                }
332            }
333            PlannedChange::WriteLedger { path, .. } => {
334                record_file_path(scope, path, &mut ledger_files, &mut directories);
335            }
336            PlannedChange::Refuse { reason, path } => {
337                refusals.push(json!({
338                    "reason": refusal_label(*reason),
339                    "path": path.as_ref().and_then(|p| render_path(scope, p).ok()),
340                }));
341            }
342            PlannedChange::NoOp { path, .. } => {
343                record_file_path(scope, path, &mut config_files, &mut directories);
344            }
345            // Other variants (RemoveFile, RestoreBackup, CreateBackup,
346            // RemoveDir, RemoveLedgerEntry, SetPermissions) only show up in
347            // uninstall/repair plans or for paths we already captured via
348            // Create/Patch. They are not load-bearing for a layout manifest,
349            // so skip.
350            _ => {}
351        }
352    }
353
354    json!({
355        "config_files": config_files.into_iter().collect::<Vec<_>>(),
356        "directories": directories.into_iter().collect::<Vec<_>>(),
357        "ledger_files": ledger_files.into_iter().collect::<Vec<_>>(),
358        "refusals": refusals,
359    })
360}
361
362fn record_file_path(
363    scope: &Scope,
364    path: &Path,
365    files: &mut BTreeSet<String>,
366    directories: &mut BTreeSet<String>,
367) {
368    if let Ok(s) = render_path(scope, path) {
369        files.insert(s);
370    }
371    record_parent_dirs(scope, path, directories);
372}
373
374fn record_parent_dirs(scope: &Scope, path: &Path, directories: &mut BTreeSet<String>) {
375    let Some(mut cur) = path.parent().filter(|p| !p.as_os_str().is_empty()) else {
376        return;
377    };
378
379    match scope {
380        Scope::Local(root) => loop {
381            if !cur.starts_with(root) {
382                break;
383            }
384            if let Ok(s) = render_path(scope, cur) {
385                directories.insert(s);
386            }
387            if cur == root {
388                break;
389            }
390            let Some(next) = cur.parent() else {
391                break;
392            };
393            cur = next;
394        },
395        Scope::Global => {
396            let Ok(home) = paths::home_dir() else {
397                return;
398            };
399            while cur.starts_with(&home) && cur != home {
400                if let Ok(s) = render_path(scope, cur) {
401                    directories.insert(s);
402                }
403                let Some(next) = cur.parent() else {
404                    break;
405                };
406                cur = next;
407            }
408        }
409    }
410}
411
412fn refusal_label(reason: RefusalReason) -> &'static str {
413    match reason {
414        RefusalReason::OwnerMismatch => "owner_mismatch",
415        RefusalReason::UserInstalledEntry => "user_installed_entry",
416        RefusalReason::InvalidConfig => "invalid_config",
417        RefusalReason::BackupAlreadyExists => "backup_already_exists",
418        RefusalReason::UnsupportedScope => "unsupported_scope",
419        RefusalReason::MissingRequiredSpecField => "missing_required_spec_field",
420        RefusalReason::InlineSecretInLocalScope => "inline_secret_in_local_scope",
421        RefusalReason::UnsupportedTransport => "unsupported_transport",
422        RefusalReason::UnsupportedPlatform => "unsupported_platform",
423        RefusalReason::UnsupportedSpecField => "unsupported_spec_field",
424    }
425}
426
427/// Render a planner-produced path with the home or project-root prefix
428/// replaced by the corresponding placeholder string. Returns the raw path on
429/// platforms where the home dir is unresolvable (so the schema still has data
430/// to look at).
431fn render_path(scope: &Scope, p: &Path) -> Result<String, AgentConfigError> {
432    let s = p.to_string_lossy().to_string();
433
434    if let Scope::Local(_) = scope {
435        if let Some(rest) = s.strip_prefix(PROJECT_ROOT_SENTINEL) {
436            // strip leading separator if any so the placeholder stays clean
437            let trimmed = rest.trim_start_matches('/');
438            return Ok(if trimmed.is_empty() {
439                PROJECT_PLACEHOLDER.to_string()
440            } else {
441                format!("{PROJECT_PLACEHOLDER}/{trimmed}")
442            });
443        }
444    }
445
446    if let Ok(home) = paths::home_dir() {
447        let home_s = home.to_string_lossy().to_string();
448        if let Some(rest) = s.strip_prefix(&home_s) {
449            let trimmed = rest.trim_start_matches('/');
450            return Ok(if trimmed.is_empty() {
451                HOME_PLACEHOLDER.to_string()
452            } else {
453                format!("{HOME_PLACEHOLDER}/{trimmed}")
454            });
455        }
456    }
457
458    Ok(s)
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464
465    #[test]
466    fn build_emits_warning_and_agents() {
467        let v = build();
468        assert!(v.get("_warning").and_then(|w| w.as_str()).is_some());
469        assert!(v
470            .get("crate_version")
471            .and_then(|v| v.as_str())
472            .filter(|s| !s.is_empty())
473            .is_some());
474        let agents = v.get("agents").and_then(|a| a.as_array()).unwrap();
475        assert!(!agents.is_empty(), "schema must have at least one agent");
476    }
477
478    #[test]
479    fn every_registered_id_present() {
480        let v = build();
481        let agents = v.get("agents").and_then(|a| a.as_array()).unwrap();
482        let ids: Vec<&str> = agents
483            .iter()
484            .filter_map(|a| a.get("id").and_then(|s| s.as_str()))
485            .collect();
486        for integ in all() {
487            assert!(
488                ids.contains(&integ.id()),
489                "missing agent {} in schema",
490                integ.id()
491            );
492        }
493    }
494
495    #[test]
496    fn render_path_substitutes_project_root() {
497        let scope = Scope::Local(PathBuf::from(PROJECT_ROOT_SENTINEL));
498        let p = PathBuf::from(format!("{PROJECT_ROOT_SENTINEL}/.claude/settings.json"));
499        let s = render_path(&scope, &p).unwrap();
500        assert_eq!(s, "<project>/.claude/settings.json");
501    }
502
503    #[test]
504    fn claude_local_hook_path_is_settings_json() {
505        // Schema is the canonical Linux view (see `build` doc); on Windows the
506        // PathBuf separator is `\` and the assertion against forward-slash
507        // path strings is meaningless. Match the convention in
508        // `tests/schema_golden.rs` and skip the path-shape check off-Linux.
509        if !cfg!(target_os = "linux") {
510            return;
511        }
512        let v = build();
513        let agents = v.get("agents").and_then(|a| a.as_array()).unwrap();
514        let claude = agents
515            .iter()
516            .find(|a| a.get("id").and_then(|s| s.as_str()) == Some("claude"))
517            .expect("claude in schema");
518        let local = claude
519            .pointer("/surfaces/hook/scopes/local/config_files")
520            .and_then(|v| v.as_array())
521            .expect("claude hook local config files");
522        let strs: Vec<&str> = local.iter().filter_map(|v| v.as_str()).collect();
523        assert!(
524            strs.iter().any(|s| s.contains(".claude/settings.json")),
525            "expected .claude/settings.json in {strs:?}"
526        );
527    }
528}