acp/commands/
primer.rs

1//! @acp:module "Primer Command"
2//! @acp:summary "Generate AI bootstrap primers with value-based section selection"
3//! @acp:domain cli
4//! @acp:layer handler
5//!
6//! RFC-0004: Tiered Interface Primers
7//! RFC-0015: Foundation prompt for standalone/raw API usage
8//! Generates token-efficient bootstrap text for AI agents.
9
10use std::path::PathBuf;
11
12/// RFC-0015 Section 4.2: Foundation prompt for raw API usage (~576 tokens)
13/// This provides baseline coding agent behaviors for AI models operating
14/// without an IDE's built-in system prompt (e.g., raw Claude/GPT API, local LLMs).
15pub const FOUNDATION_PROMPT: &str = r#"# System Instruction:
16You are an AI coding assistant. Your primary objective is to help the user produce correct, maintainable, secure software. Prefer quality, testability, and clear reasoning over speed or verbosity.
17
18## Operating principles
19- Clarify intent: If requirements are ambiguous or conflicting, ask the minimum number of targeted questions. If you can proceed with reasonable assumptions, state them explicitly and continue.
20- Plan before code: Briefly outline the approach, constraints, and tradeoffs, then implement.
21- Correctness first: Favor simple, reliable solutions. Avoid cleverness that reduces readability or increases risk.
22- Verification mindset: Provide ways to validate (tests, edge cases, invariants, quick checks, sample inputs/outputs). If uncertain, say so and propose a validation path.
23- Security and safety: Avoid insecure defaults. Highlight risky patterns (injection, authz/authn, secrets, SSRF, deserialization, unsafe file ops). Use least privilege and safe parsing.
24- Action over documentation: Code change requests (fix, update, migrate, implement) require code changes, not documentation.
25
26## Interaction contract
27- Start by confirming: language, runtime/versions, target environment, constraints (performance, memory, latency), and any style/architecture preferences. Only ask when missing details materially affect the solution.
28- Before modifying code: Read the file first to understand existing patterns, then make minimal, coherent changes that preserve conventions.
29- When proposing dependencies: keep them minimal; justify each; offer a standard-library alternative when feasible.
30- When giving commands or scripts: make them copy/paste-ready and note OS assumptions.
31- Never fabricate: If you don't know a detail (API, library behavior, version), say so and offer how to check.
32
33## Output format
34- Prefer structured responses:
35  1) Understanding (what you think the user wants + assumptions)
36  2) Approach (short plan + key tradeoffs)
37  3) Implementation (code)
38  4) Validation (tests/checks + edge cases)
39  5) Next steps (optional improvements)
40- Keep explanations concise, but include enough rationale for review and maintenance.
41
42## Code quality rules
43- Write idiomatic code for the requested language.
44- Include error handling, input validation, and clear naming.
45- Avoid premature optimization; note where optimization would be justified.
46- Add tests (unit/integration) when applicable and show how to run them.
47- For performance-sensitive tasks, analyze complexity and propose benchmarks.
48
49## Context handling
50- Use only the information provided in the conversation. If critical context is missing, ask. If a file or snippet is referenced but not included, request it.
51- Remember user-stated preferences (style, tools, constraints) within the session and apply them consistently.
52- ACP context usage: Use provided ACP metadata to navigate to relevant files quickly. Before modifying any file, read it first to verify your understanding matches reality. The metadata helps you find files faster—but you must still read what you'll change.
53
54You are a collaborative partner: be direct, careful, and review-oriented."#;
55
56/// Approximate token count for foundation prompt (validated per RFC-0015)
57/// Updated: Added ACP context usage directive (~44 additional tokens)
58pub const FOUNDATION_TOKENS: u32 = 620;
59
60use anyhow::Result;
61use serde::Serialize;
62
63use crate::cache::Cache;
64use crate::primer::{
65    self, load_primer_config, render_primer_with_tier, select_sections, CliOverrides,
66    IdeEnvironment, OutputFormat, PrimerTier, ProjectState,
67};
68
69/// Options for the primer command
70#[derive(Debug, Clone)]
71pub struct PrimerOptions {
72    /// Token budget for the primer
73    pub budget: u32,
74    /// Required capabilities (e.g., "shell", "mcp")
75    pub capabilities: Vec<String>,
76    /// Cache file path (for project state)
77    pub cache: Option<PathBuf>,
78    /// Custom primer config file
79    pub primer_config: Option<PathBuf>,
80    /// Output format
81    pub format: OutputFormat,
82    /// Output as JSON metadata
83    pub json: bool,
84    /// Weight preset (safe, efficient, accurate, balanced)
85    pub preset: Option<String>,
86    /// Force include section IDs
87    pub include: Vec<String>,
88    /// Exclude section IDs
89    pub exclude: Vec<String>,
90    /// Filter by category IDs
91    pub categories: Vec<String>,
92    /// Disable dynamic value modifiers
93    pub no_dynamic: bool,
94    /// Show selection reasoning
95    pub explain: bool,
96    /// List available sections
97    pub list_sections: bool,
98    /// List available presets
99    pub list_presets: bool,
100    /// Preview selection without rendering
101    pub preview: bool,
102    /// RFC-0015: Standalone mode (include foundation prompt for raw API usage)
103    pub standalone: bool,
104    /// RFC-0015: Output only the foundation prompt (~576 tokens)
105    pub foundation_only: bool,
106    /// RFC-0015: MCP mode - use tool references instead of CLI commands (20-29% token savings)
107    pub mcp: bool,
108}
109
110impl Default for PrimerOptions {
111    fn default() -> Self {
112        Self {
113            budget: 200,
114            capabilities: vec![],
115            cache: None,
116            primer_config: None,
117            format: OutputFormat::Markdown,
118            json: false,
119            preset: None,
120            include: vec![],
121            exclude: vec![],
122            categories: vec![],
123            no_dynamic: false,
124            explain: false,
125            list_sections: false,
126            list_presets: false,
127            preview: false,
128            standalone: false,
129            foundation_only: false,
130            mcp: false,
131        }
132    }
133}
134
135/// Generated primer output
136#[derive(Debug, Clone, Serialize)]
137pub struct PrimerOutput {
138    pub total_tokens: u32,
139    /// RFC-0015 tier (micro, minimal, standard, full)
140    pub tier: PrimerTier,
141    pub sections_included: usize,
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub selection_reasoning: Option<Vec<SelectionReason>>,
144    pub content: String,
145}
146
147#[derive(Debug, Clone, Serialize)]
148pub struct SelectionReason {
149    pub section_id: String,
150    pub phase: String,
151    pub value: f64,
152    pub tokens: u32,
153}
154
155/// Execute the primer command
156pub fn execute_primer(options: PrimerOptions) -> Result<()> {
157    // Handle --foundation-only first (RFC-0015)
158    if options.foundation_only {
159        if options.json {
160            let output = serde_json::json!({
161                "foundation": FOUNDATION_PROMPT,
162                "tokens": FOUNDATION_TOKENS
163            });
164            println!("{}", serde_json::to_string_pretty(&output)?);
165        } else {
166            println!("{}", FOUNDATION_PROMPT);
167        }
168        return Ok(());
169    }
170
171    // Handle list modes
172    if options.list_presets {
173        println!("Available presets:\n");
174        for (name, description, weights) in primer::scoring::list_presets() {
175            println!("  {} - {}", console::style(name).bold(), description);
176            println!(
177                "    safety={:.1} efficiency={:.1} accuracy={:.1} base={:.1}\n",
178                weights.safety, weights.efficiency, weights.accuracy, weights.base
179            );
180        }
181        return Ok(());
182    }
183
184    if options.list_sections {
185        let cli_overrides = CliOverrides::default();
186        let config = load_primer_config(options.primer_config.as_deref(), &cli_overrides)?;
187
188        println!("Available sections ({}):\n", config.sections.len());
189        for section in primer::selector::list_sections(&config) {
190            let required = if section.required { " [required]" } else { "" };
191            let caps = if section.capabilities.is_empty() {
192                String::new()
193            } else {
194                format!(" ({})", section.capabilities.join(","))
195            };
196            println!(
197                "  {:30} {:15} ~{} tokens{}{}",
198                section.id, section.category, section.tokens, required, caps
199            );
200        }
201        return Ok(());
202    }
203
204    // RFC-0015: Warn when using --standalone in an IDE context
205    if options.standalone {
206        let ide = IdeEnvironment::detect_with_override();
207        if ide.is_ide() && !matches!(ide, IdeEnvironment::ClaudeCode) {
208            eprintln!(
209                "{}: Using --standalone in {} context. \
210                 IDE integrations typically provide their own system prompts. \
211                 Consider removing --standalone or set ACP_NO_IDE_DETECT=1 to suppress.",
212                console::style("warning").yellow().bold(),
213                ide.name()
214            );
215        }
216    }
217
218    // Generate primer
219    let primer = generate_primer(&options)?;
220
221    if options.json {
222        // Include foundation in JSON if standalone
223        if options.standalone {
224            let output = serde_json::json!({
225                "foundation": FOUNDATION_PROMPT,
226                "foundation_tokens": FOUNDATION_TOKENS,
227                "primer": primer
228            });
229            println!("{}", serde_json::to_string_pretty(&output)?);
230        } else {
231            println!("{}", serde_json::to_string_pretty(&primer)?);
232        }
233    } else if options.preview {
234        // Include foundation token count in preview if standalone
235        if options.standalone {
236            println!(
237                "Preview: {} tokens (foundation) + {} tokens (primer), {} sections",
238                FOUNDATION_TOKENS, primer.total_tokens, primer.sections_included
239            );
240        } else {
241            println!(
242                "Preview: {} tokens, {} sections",
243                primer.total_tokens, primer.sections_included
244            );
245        }
246        if let Some(reasons) = &primer.selection_reasoning {
247            println!("\nSelection:");
248            for reason in reasons {
249                println!(
250                    "  [{:12}] {:30} value={:.1} tokens={}",
251                    reason.phase, reason.section_id, reason.value, reason.tokens
252                );
253            }
254        }
255    } else {
256        // RFC-0015: Prepend foundation prompt when standalone
257        if options.standalone {
258            println!("{}\n\n---\n\n{}", FOUNDATION_PROMPT, primer.content);
259        } else {
260            println!("{}", primer.content);
261        }
262    }
263
264    Ok(())
265}
266
267/// Generate primer content based on budget and capabilities
268pub fn generate_primer(options: &PrimerOptions) -> Result<PrimerOutput> {
269    // Build CLI overrides
270    let cli_overrides = CliOverrides {
271        include: options.include.clone(),
272        exclude: options.exclude.clone(),
273        preset: options.preset.clone(),
274        categories: options.categories.clone(),
275        no_dynamic: options.no_dynamic,
276    };
277
278    // Load primer config with 3-layer merge
279    let config = load_primer_config(options.primer_config.as_deref(), &cli_overrides)?;
280
281    // Load project state from cache
282    let project_state = if let Some(ref cache_path) = options.cache {
283        if cache_path.exists() {
284            let cache = Cache::from_json(cache_path)?;
285            ProjectState::from_cache(&cache)
286        } else {
287            ProjectState::default()
288        }
289    } else {
290        ProjectState::default()
291    };
292
293    // RFC-0015: Capability mode selection
294    // --mcp: Add "mcp" capability for MCP-specific sections (acp_* tool references)
295    // Default: Add "shell" capability for CLI-specific sections (acp <command> references)
296    let mut capabilities = options.capabilities.clone();
297    if options.mcp {
298        if !capabilities.contains(&"mcp".to_string()) {
299            capabilities.push("mcp".to_string());
300        }
301    } else if capabilities.is_empty() {
302        // Default to "shell" capability for CLI mode
303        capabilities.push("shell".to_string());
304    }
305
306    // Select sections based on budget and capabilities
307    let selected = select_sections(&config, options.budget, &capabilities, &project_state);
308
309    // Calculate totals
310    let total_tokens: u32 = selected.iter().map(|s| s.tokens).sum();
311
312    // Build selection reasoning if explain mode
313    let selection_reasoning = if options.explain || options.preview {
314        Some(
315            selected
316                .iter()
317                .map(|s| SelectionReason {
318                    section_id: s.id.clone(),
319                    phase: determine_phase(&s.section),
320                    value: s.value,
321                    tokens: s.tokens,
322                })
323                .collect(),
324        )
325    } else {
326        None
327    };
328
329    // Determine tier based on budget (RFC-0015)
330    let tier = PrimerTier::from_budget(options.budget);
331
332    // Render output with tier information
333    let content = render_primer_with_tier(&selected, options.format, &project_state, Some(tier))?;
334
335    Ok(PrimerOutput {
336        total_tokens,
337        tier,
338        sections_included: selected.len(),
339        selection_reasoning,
340        content,
341    })
342}
343
344fn determine_phase(section: &primer::types::Section) -> String {
345    if section.required {
346        "required".to_string()
347    } else if section.required_if.is_some() {
348        "conditional".to_string()
349    } else if section.value.safety >= 80 {
350        "safety".to_string()
351    } else {
352        "value".to_string()
353    }
354}
355
356// ============================================================================
357// Legacy types for backward compatibility with existing tests
358// ============================================================================
359
360/// Output format for primer (legacy)
361#[derive(Debug, Clone, Copy, Default)]
362pub enum PrimerFormat {
363    #[default]
364    Text,
365    Json,
366}
367
368/// Tier level for content selection (legacy - use PrimerTier from primer::types)
369#[derive(Debug, Clone, Copy, PartialEq, Eq)]
370pub enum Tier {
371    Minimal,
372    Standard,
373    Full,
374}
375
376impl Tier {
377    /// Determine tier based on remaining budget (legacy mapping)
378    /// Note: For RFC-0015 tier selection, use PrimerTier::from_budget instead
379    pub fn from_budget(remaining: u32) -> Self {
380        // Legacy mapping for backward compatibility
381        if remaining < 300 {
382            Tier::Minimal
383        } else if remaining < 700 {
384            Tier::Standard
385        } else {
386            Tier::Full
387        }
388    }
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    #[test]
396    fn test_primer_tier_from_budget() {
397        // RFC-0015 tier thresholds
398        assert_eq!(PrimerTier::from_budget(50), PrimerTier::Micro);
399        assert_eq!(PrimerTier::from_budget(299), PrimerTier::Micro);
400        assert_eq!(PrimerTier::from_budget(300), PrimerTier::Minimal);
401        assert_eq!(PrimerTier::from_budget(449), PrimerTier::Minimal);
402        assert_eq!(PrimerTier::from_budget(450), PrimerTier::Standard);
403        assert_eq!(PrimerTier::from_budget(699), PrimerTier::Standard);
404        assert_eq!(PrimerTier::from_budget(700), PrimerTier::Full);
405        assert_eq!(PrimerTier::from_budget(1000), PrimerTier::Full);
406    }
407
408    #[test]
409    fn test_legacy_tier_from_budget() {
410        // Legacy mapping (for backward compatibility)
411        assert_eq!(Tier::from_budget(50), Tier::Minimal);
412        assert_eq!(Tier::from_budget(299), Tier::Minimal);
413        assert_eq!(Tier::from_budget(300), Tier::Standard);
414        assert_eq!(Tier::from_budget(699), Tier::Standard);
415        assert_eq!(Tier::from_budget(700), Tier::Full);
416    }
417
418    #[test]
419    fn test_generate_micro_primer() {
420        let options = PrimerOptions {
421            budget: 60,
422            ..Default::default()
423        };
424
425        let result = generate_primer(&options).unwrap();
426        assert!(result.total_tokens <= 300 || result.sections_included >= 1);
427        assert_eq!(result.tier, PrimerTier::Micro);
428    }
429
430    #[test]
431    fn test_generate_minimal_primer() {
432        let options = PrimerOptions {
433            budget: 350,
434            ..Default::default()
435        };
436
437        let result = generate_primer(&options).unwrap();
438        assert_eq!(result.tier, PrimerTier::Minimal);
439        assert!(result.sections_included >= 1);
440    }
441
442    #[test]
443    fn test_generate_standard_primer() {
444        let options = PrimerOptions {
445            budget: 500,
446            ..Default::default()
447        };
448
449        let result = generate_primer(&options).unwrap();
450        assert_eq!(result.tier, PrimerTier::Standard);
451        assert!(result.sections_included >= 1);
452    }
453
454    #[test]
455    fn test_generate_full_primer() {
456        let options = PrimerOptions {
457            budget: 800,
458            ..Default::default()
459        };
460
461        let result = generate_primer(&options).unwrap();
462        assert_eq!(result.tier, PrimerTier::Full);
463        assert!(result.sections_included >= 1);
464    }
465
466    #[test]
467    fn test_critical_commands_always_included() {
468        let options = PrimerOptions {
469            budget: 30,
470            ..Default::default()
471        };
472
473        let result = generate_primer(&options).unwrap();
474        // Required sections should still be included
475        assert!(result.sections_included >= 1);
476    }
477
478    #[test]
479    fn test_capability_filtering() {
480        let options = PrimerOptions {
481            budget: 500,
482            capabilities: vec!["mcp".to_string()],
483            ..Default::default()
484        };
485
486        let result = generate_primer(&options).unwrap();
487        // With MCP capability, should get MCP sections only
488        assert!(result.sections_included >= 1);
489    }
490
491    #[test]
492    fn test_primer_tier_names() {
493        assert_eq!(PrimerTier::Micro.name(), "micro");
494        assert_eq!(PrimerTier::Minimal.name(), "minimal");
495        assert_eq!(PrimerTier::Standard.name(), "standard");
496        assert_eq!(PrimerTier::Full.name(), "full");
497    }
498
499    #[test]
500    fn test_primer_tier_tokens() {
501        assert_eq!(PrimerTier::Micro.cli_tokens(), 250);
502        assert_eq!(PrimerTier::Micro.mcp_tokens(), 178);
503        assert_eq!(PrimerTier::Standard.cli_tokens(), 600);
504        assert_eq!(PrimerTier::Full.cli_tokens(), 1400);
505    }
506
507    // ========================================================================
508    // RFC-0015: Foundation Prompt Tests
509    // ========================================================================
510
511    #[test]
512    fn test_foundation_prompt_content() {
513        // Verify foundation prompt starts with expected header
514        assert!(FOUNDATION_PROMPT.starts_with("# System Instruction:"));
515        // Verify it contains key sections
516        assert!(FOUNDATION_PROMPT.contains("## Operating principles"));
517        assert!(FOUNDATION_PROMPT.contains("## Interaction contract"));
518        assert!(FOUNDATION_PROMPT.contains("## Output format"));
519        assert!(FOUNDATION_PROMPT.contains("## Code quality rules"));
520        assert!(FOUNDATION_PROMPT.contains("## Context handling"));
521        // Verify it ends with the expected closing line
522        assert!(FOUNDATION_PROMPT.contains("collaborative partner"));
523    }
524
525    #[test]
526    fn test_foundation_prompt_token_count() {
527        // RFC-0015 base ~576 tokens + ACP context directive ~44 tokens = 620 tokens
528        assert_eq!(FOUNDATION_TOKENS, 620);
529        // Sanity check: content should be at least 2000 chars (~620 tokens)
530        assert!(FOUNDATION_PROMPT.len() > 2000);
531    }
532
533    #[test]
534    fn test_foundation_only_flag_default() {
535        let options = PrimerOptions::default();
536        assert!(!options.foundation_only);
537        assert!(!options.standalone);
538    }
539
540    #[test]
541    fn test_foundation_only_option() {
542        let options = PrimerOptions {
543            foundation_only: true,
544            ..Default::default()
545        };
546        assert!(options.foundation_only);
547    }
548
549    #[test]
550    fn test_standalone_option() {
551        let options = PrimerOptions {
552            standalone: true,
553            ..Default::default()
554        };
555        assert!(options.standalone);
556    }
557
558    // ========================================================================
559    // RFC-0015: MCP Mode Tests
560    // ========================================================================
561
562    #[test]
563    fn test_mcp_flag_default() {
564        let options = PrimerOptions::default();
565        assert!(!options.mcp);
566    }
567
568    #[test]
569    fn test_mcp_option() {
570        let options = PrimerOptions {
571            mcp: true,
572            budget: 300,
573            ..Default::default()
574        };
575        assert!(options.mcp);
576        // Should be able to generate primer with MCP mode
577        let result = generate_primer(&options).unwrap();
578        assert!(result.sections_included >= 1);
579    }
580
581    #[test]
582    fn test_mcp_mode_adds_capability() {
583        // When --mcp is set, "mcp" capability should be added
584        let mcp_options = PrimerOptions {
585            mcp: true,
586            budget: 500,
587            ..Default::default()
588        };
589        let mcp_result = generate_primer(&mcp_options).unwrap();
590
591        // When --mcp is not set, "shell" capability should be used by default
592        let shell_options = PrimerOptions {
593            budget: 500,
594            ..Default::default()
595        };
596        let shell_result = generate_primer(&shell_options).unwrap();
597
598        // Both should produce valid results
599        assert!(mcp_result.sections_included >= 1);
600        assert!(shell_result.sections_included >= 1);
601    }
602
603    #[test]
604    fn test_default_shell_capability() {
605        // Default mode should use "shell" capability
606        let options = PrimerOptions {
607            budget: 500,
608            ..Default::default()
609        };
610        let result = generate_primer(&options).unwrap();
611        // Output should include CLI-style content
612        assert!(result.content.contains("acp") || result.sections_included >= 1);
613    }
614}