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 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 _ => {}
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
427fn 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 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 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}