Skip to main content

packc/
setup_gen.rs

1//! Auto-derive the credential setup form (`assets/setup.yaml`) and
2//! `assets/secret-requirements.json` for an application pack from its
3//! `agents[].llm` and the secret requirements of the tool extensions the
4//! agents use. Pure logic; all I/O (resolving `describe.json`) lives in the
5//! caller (`cli::ext_resolver`).
6
7use std::collections::BTreeMap;
8
9use anyhow::Context;
10use serde::{Deserialize, Serialize};
11use tracing::warn;
12
13/// One credential question. Field set/names match greentic-setup's
14/// `SetupQuestion` reader; `None` optionals are omitted (the reader defaults
15/// them), so output stays close to a hand-authored file.
16#[derive(Debug, Clone, PartialEq, Serialize)]
17pub struct SetupQuestionOut {
18    pub name: String,
19    pub title: String,
20    pub kind: String, // always "string" for credentials
21    pub required: bool,
22    pub secret: bool, // always true for credentials
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub help: Option<String>,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub group: Option<String>,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub docs_url: Option<String>,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub placeholder: Option<String>,
31}
32
33#[derive(Debug, Clone, PartialEq, Serialize)]
34pub struct SetupSpecOut {
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub title: Option<String>,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub description: Option<String>,
39    pub questions: Vec<SetupQuestionOut>,
40}
41
42impl SetupSpecOut {
43    pub fn to_yaml(&self) -> anyhow::Result<String> {
44        serde_yaml_bw::to_string(self).map_err(Into::into)
45    }
46}
47
48/// One entry of `secret-requirements.json`. `required` is omitted when `true`
49/// (the reader defaults it to true), matching hand-authored files.
50#[derive(Debug, Clone, PartialEq, Serialize)]
51pub struct SecretRequirementOut {
52    pub key: String,
53    #[serde(skip_serializing_if = "is_true")]
54    pub required: bool,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub description: Option<String>,
57}
58
59fn is_true(b: &bool) -> bool {
60    *b
61}
62
63/// Display metadata for an LLM provider's API-key question. Keyed by the
64/// provider id used in `pack.yaml agents[].llm.provider` (matches
65/// `greentic_llm::ProviderKind::as_str()`).
66#[derive(Debug, Clone, PartialEq)]
67pub struct ProviderOverlay {
68    pub label: String,
69    pub docs_url: String,
70    pub placeholder: String,
71}
72
73fn overlay(label: &str, docs_url: &str, placeholder: &str) -> ProviderOverlay {
74    ProviderOverlay {
75        label: label.to_string(),
76        docs_url: docs_url.to_string(),
77        placeholder: placeholder.to_string(),
78    }
79}
80
81/// Polished display metadata for the popular providers. Returns `None` for
82/// providers without a curated entry (the caller emits a minimal question).
83/// Task 5's drift-test asserts every `greentic_llm::ProviderKind` is either
84/// covered here or in that test's explicit minimal allow-list.
85pub fn llm_overlay(provider: &str) -> Option<ProviderOverlay> {
86    Some(match provider {
87        "openai" => overlay("OpenAI", "https://platform.openai.com/api-keys", "sk-..."),
88        "anthropic" => overlay(
89            "Anthropic",
90            "https://console.anthropic.com/settings/keys",
91            "sk-ant-...",
92        ),
93        "deepseek" => overlay("DeepSeek", "https://platform.deepseek.com", "sk-..."),
94        "gemini" => overlay(
95            "Google Gemini",
96            "https://aistudio.google.com/app/apikey",
97            "AIza...",
98        ),
99        "cohere" => overlay("Cohere", "https://dashboard.cohere.com/api-keys", "..."),
100        "groq" => overlay("Groq", "https://console.groq.com/keys", "gsk_..."),
101        "perplexity" => overlay(
102            "Perplexity",
103            "https://www.perplexity.ai/settings/api",
104            "pplx-...",
105        ),
106        "xai" => overlay("xAI", "https://console.x.ai", "xai-..."),
107        "mistral" => overlay("Mistral", "https://console.mistral.ai/api-keys", "..."),
108        "openrouter" => overlay("OpenRouter", "https://openrouter.ai/keys", "sk-or-..."),
109        _ => return None,
110    })
111}
112
113/// The two generated asset bodies, ready to write into the pack.
114#[derive(Debug, Clone, PartialEq)]
115pub struct GeneratedSetup {
116    pub setup_yaml: String,
117    pub secret_requirements_json: String,
118}
119
120/// One secret a tool needs, as declared in the extension's `describe.json`
121/// `contributions.tools[].secret_requirements`.
122#[derive(Debug, Clone, PartialEq, Deserialize)]
123pub struct ToolSecretReq {
124    pub key: String,
125    #[serde(default = "default_required")]
126    pub required: bool,
127    #[serde(default)]
128    pub description: Option<String>,
129    #[serde(default)]
130    pub format: Option<String>,
131}
132
133fn default_required() -> bool {
134    true
135}
136
137// Minimal view of describe.json — only the fields we consume.
138#[derive(Deserialize, Default)]
139struct DescribeMinimal {
140    #[serde(default)]
141    contributions: DescribeContributions,
142}
143
144#[derive(Deserialize, Default)]
145struct DescribeContributions {
146    #[serde(default)]
147    tools: Vec<DescribeTool>,
148}
149
150#[derive(Deserialize)]
151struct DescribeTool {
152    #[serde(default)]
153    name: String,
154    #[serde(default)]
155    secret_requirements: Vec<ToolSecretReq>,
156}
157
158/// Collect the secret requirements of the named tools from a `describe.json`
159/// body, deduped by `key` (first occurrence wins), preserving discovery order.
160pub fn extract_tool_secret_requirements(
161    describe_json: &[u8],
162    used_tool_names: &[String],
163) -> anyhow::Result<Vec<ToolSecretReq>> {
164    let describe: DescribeMinimal =
165        serde_json::from_slice(describe_json).context("parse extension describe.json")?;
166    let mut seen = std::collections::BTreeSet::new();
167    let mut out = Vec::new();
168    for tool in &describe.contributions.tools {
169        if !used_tool_names.iter().any(|t| t == &tool.name) {
170            continue;
171        }
172        for req in &tool.secret_requirements {
173            if seen.insert(req.key.clone()) {
174                out.push(req.clone());
175            }
176        }
177    }
178    Ok(out)
179}
180
181/// A pending question keyed by its secret key, before collision resolution.
182struct Pending {
183    secret_key: String, // canonical secret key (e.g. "llm/deepseek", "tavily/api_key")
184    provider: String,   // "llm" provider id, or the tool secret's first segment
185    last_segment: String, // default question name (segment after the last "/")
186    question: SetupQuestionOut,
187    requirement: SecretRequirementOut,
188}
189
190fn last_segment(key: &str) -> &str {
191    key.rsplit('/').next().unwrap_or(key)
192}
193
194fn llm_question(provider: &str, credential_ref: &str) -> Pending {
195    let secret_key = format!("llm/{credential_ref}");
196    let (title, help, docs_url, placeholder) = match llm_overlay(provider) {
197        Some(o) => (
198            format!("{} API key", o.label),
199            Some("LLM API key for the agentic worker's reasoning loop.".to_string()),
200            Some(o.docs_url),
201            Some(o.placeholder),
202        ),
203        None => {
204            warn!(
205                provider,
206                "no LLM overlay; emitting a minimal credential question"
207            );
208            (
209                format!("{provider} API key"),
210                Some("LLM API key for the agentic worker's reasoning loop.".to_string()),
211                None,
212                None,
213            )
214        }
215    };
216    Pending {
217        secret_key: secret_key.clone(),
218        provider: "llm".to_string(),
219        last_segment: credential_ref.to_string(),
220        question: SetupQuestionOut {
221            name: credential_ref.to_string(),
222            title,
223            kind: "string".to_string(),
224            required: true,
225            secret: true,
226            help,
227            group: Some("LLM".to_string()),
228            docs_url,
229            placeholder,
230        },
231        requirement: SecretRequirementOut {
232            key: secret_key,
233            required: true,
234            description: None,
235        },
236    }
237}
238
239fn tool_question(req: &ToolSecretReq) -> Pending {
240    let provider = req.key.split('/').next().unwrap_or("").to_string();
241    let seg = last_segment(&req.key).to_string();
242    Pending {
243        secret_key: req.key.clone(),
244        provider,
245        last_segment: seg.clone(),
246        question: SetupQuestionOut {
247            name: seg.clone(),
248            title: title_for_secret_segment(&seg),
249            kind: "string".to_string(),
250            required: req.required,
251            secret: true,
252            help: req.description.clone(),
253            group: Some("Tools".to_string()),
254            docs_url: None,
255            placeholder: None,
256        },
257        requirement: SecretRequirementOut {
258            key: req.key.clone(),
259            required: req.required,
260            description: req.description.clone(),
261        },
262    }
263}
264
265fn titleize(s: &str) -> String {
266    s.split(['_', '-', ' '])
267        .filter(|w| !w.is_empty())
268        .map(|w| {
269            let mut c = w.chars();
270            match c.next() {
271                Some(f) => f.to_uppercase().chain(c).collect::<String>(),
272                None => String::new(),
273            }
274        })
275        .collect::<Vec<_>>()
276        .join(" ")
277}
278
279/// Human-readable title for a credential question derived from a secret key's
280/// last segment. Appends " key" so a bare segment like `token` reads as
281/// "Token key", but never doubles it when the segment already ends in "key"
282/// (e.g. `api_key` → "Api Key", not "Api Key key").
283fn title_for_secret_segment(segment: &str) -> String {
284    let titled = titleize(segment);
285    if titled.to_ascii_lowercase().ends_with("key") {
286        titled
287    } else {
288        format!("{titled} key")
289    }
290}
291
292/// Build setup.yaml + secret-requirements.json from a pack's agents and the
293/// resolved tool secret requirements. Returns `None` when there is nothing to
294/// emit. Component requirements are merged into secret-requirements.json.
295pub fn generate(
296    pack_id: &str,
297    agents: &BTreeMap<String, serde_json::Value>,
298    tool_reqs_by_ext: &BTreeMap<String, Vec<ToolSecretReq>>,
299    component_reqs: &[SecretRequirementOut],
300) -> anyhow::Result<Option<GeneratedSetup>> {
301    let mut pending: Vec<Pending> = Vec::new();
302    let mut seen_keys = std::collections::BTreeSet::new();
303
304    for agent in agents.values() {
305        // LLM question
306        if let Some(cred) = agent
307            .get("llm")
308            .and_then(|l| l.get("credential_ref"))
309            .and_then(|c| c.as_str())
310        {
311            let provider = agent["llm"]
312                .get("provider")
313                .and_then(|p| p.as_str())
314                .unwrap_or("");
315            let p = llm_question(provider, cred);
316            if seen_keys.insert(p.secret_key.clone()) {
317                pending.push(p);
318            }
319        }
320        // Tool questions
321        if let Some(tools) = agent.get("tools").and_then(|t| t.as_array()) {
322            for tool in tools {
323                let ext_id = tool
324                    .get("extension_id")
325                    .and_then(|e| e.as_str())
326                    .unwrap_or("");
327                let Some(reqs) = tool_reqs_by_ext.get(ext_id) else {
328                    continue;
329                };
330                for req in reqs {
331                    // The resolver already filtered to used tools; key-dedupe here.
332                    if seen_keys.insert(req.key.clone()) {
333                        pending.push(tool_question(req));
334                    }
335                }
336            }
337        }
338    }
339
340    if pending.is_empty() && component_reqs.is_empty() {
341        return Ok(None);
342    }
343
344    // Resolve question-name collisions: if two pending share a question name,
345    // disambiguate both to "<provider>_<segment>".
346    let mut name_counts: BTreeMap<String, usize> = BTreeMap::new();
347    for p in &pending {
348        *name_counts.entry(p.question.name.clone()).or_default() += 1;
349    }
350    for p in &mut pending {
351        if name_counts.get(&p.question.name).copied().unwrap_or(0) > 1 {
352            p.question.name = format!("{}_{}", p.provider, p.last_segment);
353        }
354    }
355
356    let questions: Vec<SetupQuestionOut> = pending.iter().map(|p| p.question.clone()).collect();
357    let mut requirements: Vec<SecretRequirementOut> =
358        pending.iter().map(|p| p.requirement.clone()).collect();
359    // Merge component-derived requirements (dedupe by key).
360    for cr in component_reqs {
361        if !requirements.iter().any(|r| r.key == cr.key) {
362            requirements.push(cr.clone());
363        }
364    }
365
366    let spec = SetupSpecOut {
367        title: Some(format!("{pack_id} — credentials")),
368        description: Some("API keys for the agentic worker and its tools.".to_string()),
369        questions,
370    };
371    Ok(Some(GeneratedSetup {
372        setup_yaml: spec.to_yaml()?,
373        secret_requirements_json: serde_json::to_string_pretty(&requirements)?,
374    }))
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use std::collections::BTreeMap;
381
382    fn tavily_agents() -> BTreeMap<String, serde_json::Value> {
383        let agent = serde_json::json!({
384            "agent_id": "demo_assistant",
385            "llm": {"provider": "deepseek", "model": "deepseek-chat", "credential_ref": "deepseek"},
386            "tools": [
387                {"extension_id": "greentic.tavily", "tool_name": "tavily_search"},
388                {"extension_id": "greentic.tavily", "tool_name": "tavily_extract"}
389            ]
390        });
391        BTreeMap::from([("demo_assistant".to_string(), agent)])
392    }
393
394    fn tavily_tool_reqs() -> BTreeMap<String, Vec<ToolSecretReq>> {
395        BTreeMap::from([(
396            "greentic.tavily".to_string(),
397            vec![ToolSecretReq {
398                key: "tavily/api_key".to_string(),
399                required: true,
400                description: Some("Tavily web-search API key.".to_string()),
401                format: Some("text".to_string()),
402            }],
403        )])
404    }
405
406    #[test]
407    fn generate_produces_llm_and_tool_questions() {
408        let output = generate(
409            "agentic-research-tavily-demo",
410            &tavily_agents(),
411            &tavily_tool_reqs(),
412            &[],
413        )
414        .unwrap()
415        .expect("some output");
416
417        let spec: serde_json::Value = serde_yaml_bw::from_str(&output.setup_yaml).unwrap();
418        let q = spec["questions"].as_array().unwrap();
419        assert_eq!(q.len(), 2, "one LLM + one tool (deduped)");
420
421        let llm = q.iter().find(|x| x["name"] == "deepseek").unwrap();
422        assert_eq!(llm["group"], "LLM");
423        assert_eq!(llm["title"], "DeepSeek API key");
424        assert_eq!(llm["docs_url"], "https://platform.deepseek.com");
425        assert_eq!(llm["secret"], true);
426
427        let tool = q.iter().find(|x| x["name"] == "api_key").unwrap();
428        assert_eq!(tool["group"], "Tools");
429        assert_eq!(tool["help"], "Tavily web-search API key.");
430        // A segment that already ends in "key" must not be doubled into
431        // "Api Key key" — the derived title reads as a single "key".
432        assert_eq!(tool["title"], "Api Key");
433
434        let reqs: Vec<serde_json::Value> =
435            serde_json::from_str(&output.secret_requirements_json).unwrap();
436        let keys: Vec<&str> = reqs.iter().map(|r| r["key"].as_str().unwrap()).collect();
437        assert!(keys.contains(&"llm/deepseek"));
438        assert!(keys.contains(&"tavily/api_key"));
439    }
440
441    #[test]
442    fn tool_title_appends_key_only_when_segment_lacks_it() {
443        // Already ends in "key": no doubling.
444        assert_eq!(title_for_secret_segment("api_key"), "Api Key");
445        // Bare segment: append " key" so it reads as a credential.
446        assert_eq!(title_for_secret_segment("token"), "Token key");
447    }
448
449    #[test]
450    fn generate_disambiguates_colliding_tool_names() {
451        let mut tool_reqs = tavily_tool_reqs();
452        tool_reqs.insert(
453            "other.search".to_string(),
454            vec![ToolSecretReq {
455                key: "other/api_key".to_string(),
456                required: true,
457                description: None,
458                format: None,
459            }],
460        );
461        let mut agents = tavily_agents();
462        // add a second agent using other.search so both api_key secrets surface
463        agents.insert(
464            "a2".to_string(),
465            serde_json::json!({
466                "agent_id": "a2",
467                "llm": {"provider": "openai", "credential_ref": "openai"},
468                "tools": [{"extension_id": "other.search", "tool_name": "search"}]
469            }),
470        );
471        let output = generate("p", &agents, &tool_reqs, &[]).unwrap().unwrap();
472        let spec: serde_json::Value = serde_yaml_bw::from_str(&output.setup_yaml).unwrap();
473        let names: Vec<&str> = spec["questions"]
474            .as_array()
475            .unwrap()
476            .iter()
477            .map(|q| q["name"].as_str().unwrap())
478            .collect();
479        assert!(names.contains(&"tavily_api_key"));
480        assert!(names.contains(&"other_api_key"));
481    }
482
483    #[test]
484    fn llm_overlay_known_and_unknown() {
485        let d = llm_overlay("deepseek").expect("deepseek known");
486        assert_eq!(d.label, "DeepSeek");
487        assert!(d.docs_url.starts_with("https://"));
488        assert!(d.placeholder.starts_with("sk-"));
489        assert!(llm_overlay("totally-unknown-provider").is_none());
490    }
491
492    const TAVILY_DESCRIBE: &str = r#"{
493      "contributions": {
494        "tools": [
495          {"name": "tavily_search",  "secret_requirements": [
496            {"key": "tavily/api_key", "required": true, "description": "Search key", "format": "text"}]},
497          {"name": "tavily_extract", "secret_requirements": [
498            {"key": "tavily/api_key", "required": true, "description": "Extract key", "format": "text"}]}
499        ]
500      }
501    }"#;
502
503    #[test]
504    fn extracts_and_dedupes_tool_secrets_for_used_tools() {
505        let used = vec!["tavily_search".to_string(), "tavily_extract".to_string()];
506        let reqs = extract_tool_secret_requirements(TAVILY_DESCRIBE.as_bytes(), &used).unwrap();
507        assert_eq!(reqs.len(), 1, "same key on two tools dedupes to one");
508        assert_eq!(reqs[0].key, "tavily/api_key");
509        assert_eq!(reqs[0].description.as_deref(), Some("Search key"));
510    }
511
512    #[test]
513    fn ignores_secrets_of_unused_tools() {
514        let used = vec!["tavily_extract".to_string()];
515        let reqs = extract_tool_secret_requirements(TAVILY_DESCRIBE.as_bytes(), &used).unwrap();
516        assert_eq!(reqs.len(), 1);
517        assert_eq!(reqs[0].description.as_deref(), Some("Extract key"));
518    }
519
520    #[test]
521    fn setup_spec_serializes_and_round_trips_with_optional_fields_omitted() {
522        let spec = SetupSpecOut {
523            title: Some("Demo — credentials".to_string()),
524            description: None,
525            questions: vec![SetupQuestionOut {
526                name: "deepseek".to_string(),
527                title: "DeepSeek API key".to_string(),
528                kind: "string".to_string(),
529                required: true,
530                secret: true,
531                help: Some("LLM key".to_string()),
532                group: Some("LLM".to_string()),
533                docs_url: Some("https://platform.deepseek.com".to_string()),
534                placeholder: Some("sk-...".to_string()),
535            }],
536        };
537        let yaml = spec.to_yaml().expect("serialize");
538        // description (None) must not appear; title (None on question is N/A here)
539        assert!(!yaml.contains("description"));
540        assert!(yaml.contains("name: deepseek"));
541        // Round-trips through a serde_json::Value with the same field names.
542        let v: serde_json::Value = serde_yaml_bw::from_str(&yaml).expect("parse");
543        assert_eq!(v["questions"][0]["group"], "LLM");
544        assert!(v["questions"][0].get("default").is_none());
545    }
546}