Skip to main content

warble_claude_code/emit/
mod.rs

1//! claude-code target — emits Claude Code agent runtime files from a resolved Warble IR.
2//!
3//! Dispatch is keyed on IR enum values (`realization_kind`, `trigger.kind`, `effect.outcome.kind`),
4//! never on a component's id/verb. Enum values not yet supported by this target fail loudly
5//! ("wall-hit") rather than silently emitting something wrong — see [`support::unsupported`].
6//!
7//! This module is split by responsibility: [`types`] (public flavor/realization enums), [`support`]
8//! (constants + enum-support predicates + guardrail helpers), [`gate`] (render gate + tool grants),
9//! [`sections`] (agent-body prompt sections), [`agent`] (single-agent markdown), [`settings`]
10//! (`settings.json` + wren config), [`run_md`] (RUN.md), [`split`] (per-step-tier split realization),
11//! [`resolution`] (capability resolution + summary), [`fs_util`] (file writers), and [`hybrid`]
12//! (hybrid local+cloud realization). The public `emit_claude_code*` entry points live here.
13
14mod agent;
15mod fs_util;
16mod gate;
17mod hybrid;
18mod resolution;
19mod run_md;
20mod sections;
21mod settings;
22mod split;
23mod support;
24mod types;
25
26pub use resolution::resolve_node_capabilities;
27pub use types::{HybridRealization, RenderFlavor, DEFAULT_RENDER_FLAVOR};
28
29use crate::error::DispatchError;
30use crate::ir::{validate_ir_version, WarbleIr};
31use crate::models::{ModelConfig, ANTHROPIC_PROVIDER};
32use crate::resolve::ResolutionReport;
33use crate::targets::{CapabilityOutcome, TargetId};
34use std::path::Path;
35
36use agent::build_agent_markdown;
37use fs_util::{mkdir_all, write_file, write_json};
38use hybrid::{any_local_provider, emit_hybrid_file_target, emit_hybrid_file_target_mcp};
39use resolution::{print_resolution_summary, resolve_node_with_shared_binding};
40use run_md::build_run_md;
41use settings::{build_settings, wren_config};
42use split::{
43    build_driver_markdown, build_split_run_md, build_split_settings, build_subagent_markdown,
44    should_split_per_step_tier, subagent_name,
45};
46use support::{outcome_supported, realization_supported, trigger_supported, unsupported};
47
48pub fn emit_claude_code(
49    ir: &WarbleIr,
50    out_dir: &Path,
51    target_id: &str,
52    render_flavor: RenderFlavor,
53) -> Result<(), DispatchError> {
54    emit_claude_code_with_models(
55        ir,
56        out_dir,
57        target_id,
58        render_flavor,
59        &ModelConfig::default(),
60    )
61}
62
63/// Loud-fail if the binding routes any step to a non-Anthropic provider but the target's profile does
64/// not realize `llm:per_step_provider` (hybrid). Anthropic-provider bindings (incl. the shorthand
65/// string form, whose provider defaults to Anthropic) always pass — a local model name that reaches a
66/// whole-session proxy is the caller's choice, not per-step provider routing.
67fn require_per_step_provider_support(
68    ir: &WarbleIr,
69    target_id: &str,
70    models: &ModelConfig,
71) -> Result<(), DispatchError> {
72    let supported = TargetId::parse(target_id)
73        .map(|t| t.profile())
74        .and_then(|p| p.get("llm:per_step_provider").map(|e| e.outcome))
75        .is_some_and(|outcome| outcome != CapabilityOutcome::Fail);
76    if supported {
77        return Ok(());
78    }
79    for node in &ir.components {
80        for call in &node.llm_calls {
81            let binding = models.binding(&call.tier)?;
82            if binding.provider != ANTHROPIC_PROVIDER {
83                return Err(DispatchError(format!(
84                    "llm:per_step_provider: fail on {target_id} — the binding routes step '{}.{}' \
85(tier '{}') to provider '{}', but this target is whole-session single-provider and does not support \
86per-step provider routing (hybrid). Use an all-cloud binding for this target, or dispatch to a target \
87that realizes llm:per_step_provider.",
88                    node.verb,
89                    call.name,
90                    call.tier,
91                    binding.provider.as_str()
92                )));
93            }
94        }
95    }
96    Ok(())
97}
98
99/// Emit Claude Code agent runtime files for a resolved IR into `out_dir`. Errors on any unsupported
100/// enum value rather than emitting a silently-wrong file. Runs the capability resolution pass first;
101/// on abort it errors and writes nothing. `models` resolves each step's tier to a concrete model.
102/// A hybrid binding uses the default [`HybridRealization::BashScript`]; call
103/// [`emit_claude_code_with_realization`] to choose.
104pub fn emit_claude_code_with_models(
105    ir: &WarbleIr,
106    out_dir: &Path,
107    target_id: &str,
108    render_flavor: RenderFlavor,
109    models: &ModelConfig,
110) -> Result<(), DispatchError> {
111    emit_claude_code_with_realization(
112        ir,
113        out_dir,
114        target_id,
115        render_flavor,
116        models,
117        HybridRealization::default(),
118    )
119}
120
121/// As [`emit_claude_code_with_models`], choosing how a hybrid binding's LOCAL step is realized on the
122/// file target (bash-script script vs an MCP server). Only affects the hybrid path.
123pub fn emit_claude_code_with_realization(
124    ir: &WarbleIr,
125    out_dir: &Path,
126    target_id: &str,
127    render_flavor: RenderFlavor,
128    models: &ModelConfig,
129    hybrid: HybridRealization,
130) -> Result<(), DispatchError> {
131    validate_ir_version(ir)?;
132    // Every step tier must map to a model — abort before writing anything if one is undefined.
133    models.validate(ir)?;
134    // A per-step-tier split needs the reserved `orchestrator` tier; require it up front so the
135    // split builders can resolve it infallibly.
136    if ir.components.iter().any(should_split_per_step_tier) {
137        models.orchestrator()?;
138    }
139    // Binding-time hybrid gate (llm:per_step_provider). Whether hybrid is needed is a property of the
140    // runtime BINDING (a step bound to a non-Anthropic provider), not the IR — so it is checked here,
141    // once the models config is known, rather than as an IR-static required capability. If the target's
142    // profile does not realize `llm:per_step_provider`, a non-Anthropic binding is a loud-fail (never a
143    // silent emit of a model name that would depend on a whole-session proxy).
144    require_per_step_provider_support(ir, target_id, models)?;
145
146    // Hybrid binding (a step routed to a local provider): the gate above confirmed the target realizes
147    // `llm:per_step_provider`, so take the chosen realization instead of the all-cloud emit below.
148    if any_local_provider(ir, models)? {
149        return match hybrid {
150            HybridRealization::BashScript => {
151                emit_hybrid_file_target(ir, out_dir, target_id, models)
152            }
153            HybridRealization::McpServer => {
154                emit_hybrid_file_target_mcp(ir, out_dir, target_id, models)
155            }
156        };
157    }
158
159    // Resolve every node first — abort before writing anything if any capability fails.
160    let mut reports: Vec<(String, ResolutionReport)> = Vec::with_capacity(ir.components.len());
161    for node in &ir.components {
162        reports.push((
163            node.id.clone(),
164            resolve_node_with_shared_binding(node, &ir.context_binding, target_id)?,
165        ));
166    }
167    let report_for = |id: &str| -> &ResolutionReport {
168        &reports
169            .iter()
170            .find(|(nid, _)| nid == id)
171            .expect("report exists")
172            .1
173    };
174
175    for node in &ir.components {
176        if !realization_supported(node.realization_kind) {
177            return Err(unsupported(
178                "realization_kind",
179                node.realization_kind.as_str(),
180            ));
181        }
182        if !trigger_supported(node.trigger.kind) {
183            return Err(unsupported("trigger.kind", node.trigger.kind.as_str()));
184        }
185        if !outcome_supported(node.effect.outcome.kind) {
186            return Err(unsupported(
187                "outcome.kind",
188                node.effect.outcome.kind.as_str(),
189            ));
190        }
191
192        let claude_dir = out_dir.join(".claude");
193        let agents_dir = claude_dir.join("agents");
194        let wren_dir = out_dir.join(".wren");
195        mkdir_all(&agents_dir)?;
196        mkdir_all(&wren_dir)?;
197
198        let report = report_for(&node.id);
199
200        if should_split_per_step_tier(node) {
201            write_file(
202                &agents_dir.join(format!("{}.md", node.verb)),
203                &build_driver_markdown(node, report, render_flavor, models),
204            )?;
205            for call in &node.llm_calls {
206                write_file(
207                    &agents_dir.join(format!("{}.md", subagent_name(&node.verb, call))),
208                    &build_subagent_markdown(node, call, models),
209                )?;
210            }
211            write_json(
212                &claude_dir.join("settings.json"),
213                &build_split_settings(node, report, render_flavor),
214            )?;
215            write_json(&wren_dir.join("config.json"), &wren_config())?;
216            write_file(
217                &out_dir.join("RUN.md"),
218                &build_split_run_md(node, report, render_flavor, models),
219            )?;
220        } else {
221            write_file(
222                &agents_dir.join(format!("{}.md", node.verb)),
223                &build_agent_markdown(node, report, render_flavor, models)?,
224            )?;
225            // P1: the single-agent path now also writes
226            // `.claude/settings.json` — same location as the split path — so Claude Code
227            // auto-loads the allowlist without a manual `--settings` flag or a copy step.
228            write_json(
229                &claude_dir.join("settings.json"),
230                &build_settings(node, report, render_flavor),
231            )?;
232            write_json(&wren_dir.join("config.json"), &wren_config())?;
233            write_file(
234                &out_dir.join("RUN.md"),
235                &build_run_md(node, report, render_flavor, models)?,
236            )?;
237        }
238    }
239
240    let capability_report = serde_json::json!({
241        "target": target_id,
242        "components": ir.components.iter().map(|node| serde_json::json!({
243            "id": node.id,
244            "capabilities": report_for(&node.id),
245        })).collect::<Vec<_>>(),
246    });
247    write_json(&out_dir.join("capability-report.json"), &capability_report)?;
248
249    for node in &ir.components {
250        print_resolution_summary(
251            &format!("{target_id} (component '{}')", node.id),
252            report_for(&node.id),
253        );
254    }
255
256    Ok(())
257}