1use 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
29const PROJECT_ROOT_SENTINEL: &str = "/__AGENT_CONFIG_PROJECT_ROOT__";
33
34const HOME_PLACEHOLDER: &str = "~";
36
37const PROJECT_PLACEHOLDER: &str = "<project>";
39
40const 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
48pub 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 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
297fn 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 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 let render = |p: &Path| -> Option<String> { render_path(scope, p).ok() };
324
325 for change in &plan.changes {
326 match change {
327 PlannedChange::CreateFile { path } | PlannedChange::PatchFile { path } => {
328 if let Some(s) = render(path) {
329 config_files.insert(s);
330 }
331 }
332 PlannedChange::CreateDir { path } => {
333 if let Some(s) = render(path) {
334 directories.insert(s);
335 }
336 }
337 PlannedChange::WriteLedger { path, .. } => {
338 if let Some(s) = render(path) {
339 ledger_files.insert(s);
340 }
341 }
342 PlannedChange::Refuse { reason, path } => {
343 refusals.push(json!({
344 "reason": refusal_label(*reason),
345 "path": path.as_ref().and_then(|p| render(p)),
346 }));
347 }
348 _ => {}
354 }
355 }
356
357 json!({
358 "config_files": config_files.into_iter().collect::<Vec<_>>(),
359 "directories": directories.into_iter().collect::<Vec<_>>(),
360 "ledger_files": ledger_files.into_iter().collect::<Vec<_>>(),
361 "refusals": refusals,
362 })
363}
364
365fn refusal_label(reason: RefusalReason) -> &'static str {
366 match reason {
367 RefusalReason::OwnerMismatch => "owner_mismatch",
368 RefusalReason::UserInstalledEntry => "user_installed_entry",
369 RefusalReason::InvalidConfig => "invalid_config",
370 RefusalReason::BackupAlreadyExists => "backup_already_exists",
371 RefusalReason::UnsupportedScope => "unsupported_scope",
372 RefusalReason::MissingRequiredSpecField => "missing_required_spec_field",
373 RefusalReason::InlineSecretInLocalScope => "inline_secret_in_local_scope",
374 RefusalReason::UnsupportedPlatform => "unsupported_platform",
375 }
376}
377
378fn render_path(scope: &Scope, p: &Path) -> Result<String, AgentConfigError> {
383 let s = p.to_string_lossy().to_string();
384
385 if let Scope::Local(_) = scope {
386 if let Some(rest) = s.strip_prefix(PROJECT_ROOT_SENTINEL) {
387 let trimmed = rest.trim_start_matches('/');
389 return Ok(if trimmed.is_empty() {
390 PROJECT_PLACEHOLDER.to_string()
391 } else {
392 format!("{PROJECT_PLACEHOLDER}/{trimmed}")
393 });
394 }
395 }
396
397 if let Ok(home) = paths::home_dir() {
398 let home_s = home.to_string_lossy().to_string();
399 if let Some(rest) = s.strip_prefix(&home_s) {
400 let trimmed = rest.trim_start_matches('/');
401 return Ok(if trimmed.is_empty() {
402 HOME_PLACEHOLDER.to_string()
403 } else {
404 format!("{HOME_PLACEHOLDER}/{trimmed}")
405 });
406 }
407 }
408
409 Ok(s)
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415
416 #[test]
417 fn build_emits_warning_and_agents() {
418 let v = build();
419 assert!(v.get("_warning").and_then(|w| w.as_str()).is_some());
420 assert!(v
421 .get("crate_version")
422 .and_then(|v| v.as_str())
423 .filter(|s| !s.is_empty())
424 .is_some());
425 let agents = v.get("agents").and_then(|a| a.as_array()).unwrap();
426 assert!(!agents.is_empty(), "schema must have at least one agent");
427 }
428
429 #[test]
430 fn every_registered_id_present() {
431 let v = build();
432 let agents = v.get("agents").and_then(|a| a.as_array()).unwrap();
433 let ids: Vec<&str> = agents
434 .iter()
435 .filter_map(|a| a.get("id").and_then(|s| s.as_str()))
436 .collect();
437 for integ in all() {
438 assert!(
439 ids.contains(&integ.id()),
440 "missing agent {} in schema",
441 integ.id()
442 );
443 }
444 }
445
446 #[test]
447 fn render_path_substitutes_project_root() {
448 let scope = Scope::Local(PathBuf::from(PROJECT_ROOT_SENTINEL));
449 let p = PathBuf::from(format!("{PROJECT_ROOT_SENTINEL}/.claude/settings.json"));
450 let s = render_path(&scope, &p).unwrap();
451 assert_eq!(s, "<project>/.claude/settings.json");
452 }
453
454 #[test]
455 fn claude_local_hook_path_is_settings_json() {
456 if !cfg!(target_os = "linux") {
461 return;
462 }
463 let v = build();
464 let agents = v.get("agents").and_then(|a| a.as_array()).unwrap();
465 let claude = agents
466 .iter()
467 .find(|a| a.get("id").and_then(|s| s.as_str()) == Some("claude"))
468 .expect("claude in schema");
469 let local = claude
470 .pointer("/surfaces/hook/scopes/local/config_files")
471 .and_then(|v| v.as_array())
472 .expect("claude hook local config files");
473 let strs: Vec<&str> = local.iter().filter_map(|v| v.as_str()).collect();
474 assert!(
475 strs.iter().any(|s| s.contains(".claude/settings.json")),
476 "expected .claude/settings.json in {strs:?}"
477 );
478 }
479}