Unify inventory provenance for generic parsers

Expose a stable source object on agent and skill inventory entries with the same id/label/detail_label keys while preserving skill origin for compatibility.\n\nConstraint: ROADMAP #702 scope only; keep existing skills origin field for compatibility.\nRejected: Rename agent source to origin | would break existing agents consumers and still require per-resource branching during migration.\nConfidence: high\nScope-risk: narrow\nDirective: Future inventory resources should expose provenance through source.id, source.label, and source.detail_label.\nTested: cargo fmt --manifest-path rust/Cargo.toml --all -- --check; cargo test --manifest-path rust/Cargo.toml -p commands renders_skills_reports_as_json -- --nocapture; cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli --test output_format_contract -- --nocapture; cargo build --manifest-path rust/Cargo.toml --workspace --locked; git diff --check\nNot-tested: full cargo test --manifest-path rust/Cargo.toml --workspace
This commit is contained in:
Yeachan-Heo
2026-05-25 12:05:50 +00:00
parent 21a986034e
commit 566992c331
2 changed files with 118 additions and 1 deletions

View File

@@ -4125,9 +4125,17 @@ fn definition_source_id(source: DefinitionSource) -> &'static str {
} }
fn definition_source_json(source: DefinitionSource) -> Value { fn definition_source_json(source: DefinitionSource) -> Value {
definition_source_json_with_detail(source, None)
}
fn definition_source_json_with_detail(
source: DefinitionSource,
detail_label: Option<&'static str>,
) -> Value {
json!({ json!({
"id": definition_source_id(source), "id": definition_source_id(source),
"label": source.label(), "label": source.label(),
"detail_label": detail_label,
}) })
} }
@@ -4161,7 +4169,7 @@ fn skill_summary_json(skill: &SkillSummary) -> Value {
json!({ json!({
"name": &skill.name, "name": &skill.name,
"description": &skill.description, "description": &skill.description,
"source": definition_source_json(skill.source), "source": definition_source_json_with_detail(skill.source, skill.origin.detail_label()),
"origin": skill_origin_json(skill.origin), "origin": skill_origin_json(skill.origin),
"active": skill.shadowed_by.is_none(), "active": skill.shadowed_by.is_none(),
"shadowed_by": skill.shadowed_by.map(definition_source_json), "shadowed_by": skill.shadowed_by.map(definition_source_json),
@@ -5464,7 +5472,18 @@ mod tests {
assert_eq!(report["summary"]["shadowed"], 1); assert_eq!(report["summary"]["shadowed"], 1);
assert_eq!(report["skills"][0]["name"], "plan"); assert_eq!(report["skills"][0]["name"], "plan");
assert_eq!(report["skills"][0]["source"]["id"], "project_claw"); assert_eq!(report["skills"][0]["source"]["id"], "project_claw");
assert_eq!(report["skills"][0]["source"]["label"], "Project roots");
assert_eq!(
report["skills"][0]["source"]["detail_label"],
serde_json::Value::Null
);
assert_eq!(report["skills"][1]["name"], "deploy"); assert_eq!(report["skills"][1]["name"], "deploy");
assert_eq!(report["skills"][1]["source"]["id"], "project_claw");
assert_eq!(report["skills"][1]["source"]["label"], "Project roots");
assert_eq!(
report["skills"][1]["source"]["detail_label"],
"legacy /commands"
);
assert_eq!(report["skills"][1]["origin"]["id"], "legacy_commands_dir"); assert_eq!(report["skills"][1]["origin"]["id"], "legacy_commands_dir");
assert_eq!(report["skills"][3]["shadowed_by"]["id"], "project_claw"); assert_eq!(report["skills"][3]["shadowed_by"]["id"], "project_claw");

View File

@@ -359,6 +359,8 @@ fn agents_command_emits_structured_agent_entries_when_requested() {
assert_eq!(parsed["summary"]["shadowed"], 1); assert_eq!(parsed["summary"]["shadowed"], 1);
assert_eq!(parsed["agents"][0]["name"], "planner"); assert_eq!(parsed["agents"][0]["name"], "planner");
assert_eq!(parsed["agents"][0]["source"]["id"], "project_claw"); assert_eq!(parsed["agents"][0]["source"]["id"], "project_claw");
assert_eq!(parsed["agents"][0]["source"]["label"], "Project roots");
assert_eq!(parsed["agents"][0]["source"]["detail_label"], Value::Null);
assert_eq!(parsed["agents"][0]["active"], true); assert_eq!(parsed["agents"][0]["active"], true);
assert_eq!(parsed["agents"][1]["name"], "verifier"); assert_eq!(parsed["agents"][1]["name"], "verifier");
assert_eq!(parsed["agents"][2]["name"], "planner"); assert_eq!(parsed["agents"][2]["name"], "planner");
@@ -366,6 +368,83 @@ fn agents_command_emits_structured_agent_entries_when_requested() {
assert_eq!(parsed["agents"][2]["shadowed_by"]["id"], "project_claw"); assert_eq!(parsed["agents"][2]["shadowed_by"]["id"], "project_claw");
} }
#[test]
fn agents_and_skills_inventory_share_source_schema_702() {
let root = unique_temp_dir("inventory-source-schema-702");
let workspace = root.join("workspace");
let project_agents = workspace.join(".codex").join("agents");
let project_skills = workspace.join(".codex").join("skills");
let legacy_commands = workspace.join(".claude").join("commands");
let home = root.join("home");
let isolated_config = root.join("config-home");
let isolated_codex = root.join("codex-home");
fs::create_dir_all(&workspace).expect("workspace should exist");
fs::create_dir_all(&home).expect("home should exist");
write_agent(
&project_agents,
"planner",
"Project planner",
"gpt-5.4",
"medium",
);
write_skill(&project_skills, "plan", "Project planning guidance");
write_legacy_command(&legacy_commands, "deploy", "Legacy deployment guidance");
let envs = [
("HOME", home.to_str().expect("utf8 home")),
(
"CLAW_CONFIG_HOME",
isolated_config.to_str().expect("utf8 config home"),
),
(
"CODEX_HOME",
isolated_codex.to_str().expect("utf8 codex home"),
),
];
let agents =
assert_json_command_with_env(&workspace, &["--output-format", "json", "agents"], &envs);
let skills =
assert_json_command_with_env(&workspace, &["--output-format", "json", "skills"], &envs);
let agent_source = &agents["agents"][0]["source"];
let skill_source = &skills["skills"][0]["source"];
for source in [agent_source, skill_source] {
assert!(
source.get("id").is_some(),
"inventory source must expose id: {source}"
);
assert!(
source.get("label").is_some(),
"inventory source must expose label: {source}"
);
assert!(
source.get("detail_label").is_some(),
"inventory source must expose detail_label for a stable cross-resource path: {source}"
);
}
assert_eq!(agent_source["id"], "project_claw");
assert_eq!(agent_source["label"], "Project roots");
assert_eq!(agent_source["detail_label"], Value::Null);
assert_eq!(skill_source["id"], "project_claw");
assert_eq!(skill_source["label"], "Project roots");
assert_eq!(skill_source["detail_label"], Value::Null);
let legacy_skill = skills["skills"]
.as_array()
.expect("skills array")
.iter()
.find(|skill| skill["name"] == "deploy")
.expect("legacy command skill should be listed");
assert_eq!(legacy_skill["source"]["id"], "project_claw");
assert_eq!(legacy_skill["source"]["label"], "Project roots");
assert_eq!(legacy_skill["source"]["detail_label"], "legacy /commands");
assert_eq!(
legacy_skill["origin"]["id"], "legacy_commands_dir",
"legacy origin stays for compatibility while generic parsers use source"
);
}
#[test] #[test]
fn bootstrap_and_system_prompt_emit_json_when_requested() { fn bootstrap_and_system_prompt_emit_json_when_requested() {
let root = unique_temp_dir("bootstrap-system-prompt-json"); let root = unique_temp_dir("bootstrap-system-prompt-json");
@@ -905,6 +984,25 @@ fn write_agent(root: &Path, name: &str, description: &str, model: &str, reasonin
.expect("agent fixture should write"); .expect("agent fixture should write");
} }
fn write_skill(root: &Path, name: &str, description: &str) {
let skill_root = root.join(name);
fs::create_dir_all(&skill_root).expect("skill root should exist");
fs::write(
skill_root.join("SKILL.md"),
format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"),
)
.expect("skill fixture should write");
}
fn write_legacy_command(root: &Path, name: &str, description: &str) {
fs::create_dir_all(root).expect("legacy command root should exist");
fs::write(
root.join(format!("{name}.md")),
format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"),
)
.expect("legacy command fixture should write");
}
fn unique_temp_dir(label: &str) -> PathBuf { fn unique_temp_dir(label: &str) -> PathBuf {
let millis = SystemTime::now() let millis = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)