1pub mod templates;
38use crate::error::NikaError;
42use std::fs;
43use std::path::PathBuf;
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum TemplateCategory {
48 Simple,
50 Pipeline,
52 Agent,
54 Mcp,
56 Advanced,
58}
59
60impl TemplateCategory {
61 pub fn display_name(&self) -> &'static str {
62 match self {
63 Self::Simple => "Simple",
64 Self::Pipeline => "Pipeline",
65 Self::Agent => "Agent",
66 Self::Mcp => "MCP Integration",
67 Self::Advanced => "Advanced",
68 }
69 }
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum Template {
75 SimpleInfer,
77 SimpleExec,
79 SimpleFetch,
81 ApiPipeline,
83 BlogGenerator,
85 CodeReview,
87 AgentResearch,
89 AgentBrowser,
91 McpIntegration,
93 MultiProvider,
95 DataPipeline,
97 MorningBriefing,
99 GitChangelog,
101 ParallelTranslation,
103 AgentQaTester,
105}
106
107impl Template {
108 pub const ALL: &'static [Template] = &[
110 Template::SimpleInfer,
111 Template::SimpleExec,
112 Template::SimpleFetch,
113 Template::ApiPipeline,
114 Template::BlogGenerator,
115 Template::CodeReview,
116 Template::AgentResearch,
117 Template::AgentBrowser,
118 Template::McpIntegration,
119 Template::MultiProvider,
120 Template::DataPipeline,
121 Template::MorningBriefing,
122 Template::GitChangelog,
123 Template::ParallelTranslation,
124 Template::AgentQaTester,
125 ];
126
127 pub fn name(&self) -> &'static str {
129 match self {
130 Self::SimpleInfer => "simple-infer",
131 Self::SimpleExec => "simple-exec",
132 Self::SimpleFetch => "simple-fetch",
133 Self::ApiPipeline => "api-pipeline",
134 Self::BlogGenerator => "blog-generator",
135 Self::CodeReview => "code-review",
136 Self::AgentResearch => "agent-research",
137 Self::AgentBrowser => "agent-browser",
138 Self::McpIntegration => "mcp-integration",
139 Self::MultiProvider => "multi-provider",
140 Self::DataPipeline => "data-pipeline",
141 Self::MorningBriefing => "morning-briefing",
142 Self::GitChangelog => "git-changelog",
143 Self::ParallelTranslation => "parallel-translation",
144 Self::AgentQaTester => "agent-qa-tester",
145 }
146 }
147
148 pub fn description(&self) -> &'static str {
150 match self {
151 Self::SimpleInfer => "Basic LLM text generation with infer verb",
152 Self::SimpleExec => "Shell command execution with exec verb",
153 Self::SimpleFetch => "HTTP request with fetch verb",
154 Self::ApiPipeline => "Multi-step API data processing pipeline",
155 Self::BlogGenerator => "Blog content generation with research and writing",
156 Self::CodeReview => "Code review assistant with file analysis",
157 Self::AgentResearch => "Research agent with MCP web search",
158 Self::AgentBrowser => "Browser automation agent with Playwright",
159 Self::McpIntegration => "MCP server integration example",
160 Self::MultiProvider => "Multi-provider workflow (Claude + OpenAI)",
161 Self::DataPipeline => "ETL data pipeline with fetch, transform, and load",
162 Self::MorningBriefing => "Daily digest with news, weather, and calendar",
163 Self::GitChangelog => "Git commit analysis and changelog generation",
164 Self::ParallelTranslation => "Multi-language translation with for_each",
165 Self::AgentQaTester => "QA testing agent with test generation",
166 }
167 }
168
169 pub fn category(&self) -> TemplateCategory {
171 match self {
172 Self::SimpleInfer | Self::SimpleExec | Self::SimpleFetch => TemplateCategory::Simple,
173 Self::ApiPipeline
174 | Self::BlogGenerator
175 | Self::CodeReview
176 | Self::DataPipeline
177 | Self::MorningBriefing
178 | Self::GitChangelog
179 | Self::ParallelTranslation => TemplateCategory::Pipeline,
180 Self::AgentResearch | Self::AgentBrowser | Self::AgentQaTester => {
181 TemplateCategory::Agent
182 }
183 Self::McpIntegration => TemplateCategory::Mcp,
184 Self::MultiProvider => TemplateCategory::Advanced,
185 }
186 }
187
188 pub fn from_name(name: &str) -> Option<Self> {
190 Self::ALL.iter().find(|t| t.name() == name).copied()
191 }
192
193 pub fn content(&self, workflow_name: &str) -> String {
195 templates::generate_template(*self, workflow_name)
196 }
197}
198
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
201pub enum Verb {
202 #[default]
204 Infer,
205 Exec,
207 Fetch,
209 Invoke,
211 Agent,
213}
214
215impl Verb {
216 pub fn name(&self) -> &'static str {
217 match self {
218 Self::Infer => "infer",
219 Self::Exec => "exec",
220 Self::Fetch => "fetch",
221 Self::Invoke => "invoke",
222 Self::Agent => "agent",
223 }
224 }
225
226 pub fn description(&self) -> &'static str {
227 match self {
228 Self::Infer => "LLM text generation (Claude, OpenAI, etc.)",
229 Self::Exec => "Shell command execution",
230 Self::Fetch => "HTTP requests (GET, POST, etc.)",
231 Self::Invoke => "MCP tool invocation",
232 Self::Agent => "Multi-turn agentic loop with tools",
233 }
234 }
235
236 pub fn from_name(name: &str) -> Option<Self> {
237 match name.to_lowercase().as_str() {
238 "infer" => Some(Self::Infer),
239 "exec" => Some(Self::Exec),
240 "fetch" => Some(Self::Fetch),
241 "invoke" => Some(Self::Invoke),
242 "agent" => Some(Self::Agent),
243 _ => None,
244 }
245 }
246}
247
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
250pub enum Provider {
251 #[default]
252 Claude,
253 OpenAI,
254 Mistral,
255 Groq,
256 DeepSeek,
257 Gemini,
258 Native,
259}
260
261impl Provider {
262 pub fn name(&self) -> &'static str {
263 match self {
264 Self::Claude => "claude",
265 Self::OpenAI => "openai",
266 Self::Mistral => "mistral",
267 Self::Groq => "groq",
268 Self::DeepSeek => "deepseek",
269 Self::Gemini => "gemini",
270 Self::Native => "native",
271 }
272 }
273
274 pub fn default_model(&self) -> &'static str {
275 match self {
276 Self::Claude => "claude-sonnet-4-6",
277 Self::OpenAI => "gpt-4o",
278 Self::Mistral => "mistral-large-latest",
279 Self::Groq => "llama-3.3-70b-versatile",
280 Self::DeepSeek => "deepseek-chat",
281 Self::Gemini => "gemini-2.0-flash",
282 Self::Native => "llama3.2-1b-q4",
283 }
284 }
285
286 pub fn env_var(&self) -> &'static str {
287 crate::core::find_provider(self.name())
289 .map(|p| p.env_var)
290 .unwrap_or("ANTHROPIC_API_KEY")
291 }
292
293 pub fn from_name(name: &str) -> Option<Self> {
294 let provider = crate::core::find_provider(name)?;
295 match provider.id {
296 "anthropic" => Some(Self::Claude),
297 "openai" => Some(Self::OpenAI),
298 "mistral" => Some(Self::Mistral),
299 "groq" => Some(Self::Groq),
300 "deepseek" => Some(Self::DeepSeek),
301 "gemini" => Some(Self::Gemini),
302 "native" => Some(Self::Native),
303 _ => None,
304 }
305 }
306}
307
308#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
310pub enum OutputFormat {
311 #[default]
312 Text,
313 Json,
314 Yaml,
315 File,
316}
317
318impl OutputFormat {
319 pub fn name(&self) -> &'static str {
320 match self {
321 Self::Text => "text",
322 Self::Json => "json",
323 Self::Yaml => "yaml",
324 Self::File => "file",
325 }
326 }
327
328 pub fn from_name(name: &str) -> Option<Self> {
329 match name.to_lowercase().as_str() {
330 "text" => Some(Self::Text),
331 "json" => Some(Self::Json),
332 "yaml" => Some(Self::Yaml),
333 "file" => Some(Self::File),
334 _ => None,
335 }
336 }
337}
338
339#[derive(Debug, Clone)]
341pub struct NewWorkflowConfig {
342 pub name: String,
344 pub description: Option<String>,
346 pub verb: Verb,
348 pub provider: Provider,
350 pub model: Option<String>,
352 pub output_format: OutputFormat,
354 pub with_mcp: bool,
356 pub with_include: bool,
358 pub with_artifacts: bool,
360 pub output_dir: PathBuf,
362}
363
364impl Default for NewWorkflowConfig {
365 fn default() -> Self {
366 Self {
367 name: "my-workflow".to_string(),
368 description: None,
369 verb: Verb::default(),
370 provider: Provider::default(),
371 model: None,
372 output_format: OutputFormat::default(),
373 with_mcp: false,
374 with_include: false,
375 with_artifacts: false,
376 output_dir: PathBuf::from("."),
377 }
378 }
379}
380
381impl NewWorkflowConfig {
382 pub fn generate(&self) -> String {
384 let model = self
385 .model
386 .as_deref()
387 .unwrap_or_else(|| self.provider.default_model());
388
389 let description = self
390 .description
391 .as_deref()
392 .unwrap_or("Generated by nika new");
393
394 let mut yaml = String::new();
395
396 yaml.push_str(&format!(
398 "# {}\n#\n# Generated by `nika new`\n#\n",
399 self.name
400 ));
401 yaml.push_str(&format!("# Usage:\n# nika {}.nika.yaml\n#\n", self.name));
402 yaml.push_str(&format!(
403 "# Requirements:\n# - {} environment variable\n\n",
404 self.provider.env_var()
405 ));
406
407 yaml.push_str("schema: \"nika/workflow@0.12\"\n");
409 yaml.push_str(&format!("workflow: {}\n", self.name));
410 yaml.push_str(&format!("description: \"{}\"\n\n", description));
411
412 yaml.push_str(&format!("provider: {}\n", self.provider.name()));
414 yaml.push_str(&format!("model: {}\n\n", model));
415
416 if self.with_artifacts {
418 yaml.push_str("artifacts:\n");
419 yaml.push_str(" dir: ./output/{{workflow_name}}\n");
420 yaml.push_str(" format: json\n");
421 yaml.push_str(" manifest: true\n\n");
422 }
423
424 if self.with_mcp {
426 yaml.push_str("mcp:\n");
427 yaml.push_str(" perplexity:\n");
428 yaml.push_str(" command: npx\n");
429 yaml.push_str(" args: [\"-y\", \"@perplexity-ai/mcp-server\"]\n\n");
430 }
431
432 if self.with_include {
434 yaml.push_str("# Uncomment to include a subworkflow\n");
435 yaml.push_str("# include:\n");
436 yaml.push_str("# - path: ./utils/common-tasks.nika.yaml\n");
437 yaml.push_str("# prefix: common\n\n");
438 }
439
440 yaml.push_str("tasks:\n");
442 yaml.push_str(&self.generate_primary_task());
443
444 yaml
445 }
446
447 fn generate_primary_task(&self) -> String {
449 match self.verb {
450 Verb::Infer => self.generate_infer_task(),
451 Verb::Exec => self.generate_exec_task(),
452 Verb::Fetch => self.generate_fetch_task(),
453 Verb::Invoke => self.generate_invoke_task(),
454 Verb::Agent => self.generate_agent_task(),
455 }
456 }
457
458 fn generate_infer_task(&self) -> String {
459 let mut task = String::new();
460 task.push_str(" - id: generate\n");
461 task.push_str(" description: \"Generate text with LLM\"\n");
462 task.push_str(" infer:\n");
463 task.push_str(" prompt: |\n");
464 task.push_str(" Write a brief summary about AI workflows.\n");
465 task.push_str(" Be concise and informative.\n");
466 task.push_str(" output:\n");
467 task.push_str(&format!(" format: {}\n", self.output_format.name()));
468
469 if self.output_format == OutputFormat::Json {
470 task.push_str(" schema:\n");
471 task.push_str(" type: object\n");
472 task.push_str(" required: [summary]\n");
473 task.push_str(" properties:\n");
474 task.push_str(" summary:\n");
475 task.push_str(" type: string\n");
476 }
477
478 task
479 }
480
481 fn generate_exec_task(&self) -> String {
482 let mut task = String::new();
483 task.push_str(" - id: run_command\n");
484 task.push_str(" description: \"Execute shell command\"\n");
485 task.push_str(" exec: |\n");
486 task.push_str(" echo \"Hello from Nika!\"\n");
487 task.push_str(" date\n");
488 task.push_str(" output:\n");
489 task.push_str(" format: text\n");
490 task
491 }
492
493 fn generate_fetch_task(&self) -> String {
494 let mut task = String::new();
495 task.push_str(" - id: fetch_data\n");
496 task.push_str(" description: \"Fetch data from API\"\n");
497 task.push_str(" fetch:\n");
498 task.push_str(" url: \"https://api.github.com/zen\"\n");
499 task.push_str(" method: GET\n");
500 task.push_str(" headers:\n");
501 task.push_str(" Accept: application/json\n");
502 task.push_str(" output:\n");
503 task.push_str(" format: text\n");
504 task
505 }
506
507 fn generate_invoke_task(&self) -> String {
508 let mut task = String::new();
509 task.push_str(" - id: invoke_tool\n");
510 task.push_str(" description: \"Invoke MCP tool\"\n");
511
512 if self.with_mcp {
513 task.push_str(" invoke:\n");
514 task.push_str(" server: perplexity\n");
515 task.push_str(" tool: perplexity_search\n");
516 task.push_str(" params:\n");
517 task.push_str(" query: \"latest AI news\"\n");
518 } else {
519 task.push_str(" # NOTE: Add MCP configuration above or use --with-mcp flag\n");
520 task.push_str(" invoke:\n");
521 task.push_str(" server: your-server\n");
522 task.push_str(" tool: your-tool\n");
523 task.push_str(" params:\n");
524 task.push_str(" key: value\n");
525 }
526
527 task.push_str(" output:\n");
528 task.push_str(" format: json\n");
529 task
530 }
531
532 fn generate_agent_task(&self) -> String {
533 let mut task = String::new();
534 task.push_str(" - id: agent_task\n");
535 task.push_str(" description: \"Multi-turn agent loop\"\n");
536 task.push_str(" agent:\n");
537 task.push_str(" prompt: |\n");
538 task.push_str(" You are a helpful assistant.\n");
539 task.push_str(" Answer the user's question concisely.\n\n");
540 task.push_str(" Question: What are the benefits of workflow automation?\n");
541 task.push_str(" max_turns: 5\n");
542
543 if self.with_mcp {
544 task.push_str(" mcp: [perplexity]\n");
545 }
546
547 task.push_str(" output:\n");
548 task.push_str(&format!(" format: {}\n", self.output_format.name()));
549
550 if self.output_format == OutputFormat::Json {
551 task.push_str(" schema:\n");
552 task.push_str(" type: object\n");
553 task.push_str(" required: [answer]\n");
554 task.push_str(" properties:\n");
555 task.push_str(" answer:\n");
556 task.push_str(" type: string\n");
557 }
558
559 task
560 }
561
562 pub fn write(&self) -> Result<PathBuf, NikaError> {
564 let filename = format!("{}.nika.yaml", self.name);
565 let path = self.output_dir.join(&filename);
566
567 if path.exists() {
569 return Err(NikaError::ValidationError {
570 reason: format!(
571 "File already exists: {}. Use a different name or delete the existing file.",
572 path.display()
573 ),
574 });
575 }
576
577 let content = self.generate();
579 fs::write(&path, content)?;
580
581 Ok(path)
582 }
583}
584
585pub fn create_from_template(
587 name: &str,
588 template: Template,
589 output_dir: &std::path::Path,
590) -> Result<PathBuf, NikaError> {
591 let filename = format!("{}.nika.yaml", name);
592 let path = output_dir.join(&filename);
593
594 if path.exists() {
596 return Err(NikaError::ValidationError {
597 reason: format!(
598 "File already exists: {}. Use a different name or delete the existing file.",
599 path.display()
600 ),
601 });
602 }
603
604 let content = template.content(name);
606 fs::write(&path, content)?;
607
608 Ok(path)
609}
610
611pub fn list_templates() -> Vec<(&'static str, &'static str, &'static str)> {
613 Template::ALL
614 .iter()
615 .map(|t| (t.name(), t.description(), t.category().display_name()))
616 .collect()
617}
618
619#[cfg(test)]
620mod tests {
621 use super::*;
622 use tempfile::TempDir;
623
624 #[test]
625 fn test_template_names() {
626 assert_eq!(Template::SimpleInfer.name(), "simple-infer");
627 assert_eq!(Template::BlogGenerator.name(), "blog-generator");
628 }
629
630 #[test]
631 fn test_template_from_name() {
632 assert_eq!(
633 Template::from_name("simple-infer"),
634 Some(Template::SimpleInfer)
635 );
636 assert_eq!(
637 Template::from_name("blog-generator"),
638 Some(Template::BlogGenerator)
639 );
640 assert_eq!(Template::from_name("invalid"), None);
641 }
642
643 #[test]
644 fn test_verb_from_name() {
645 assert_eq!(Verb::from_name("infer"), Some(Verb::Infer));
646 assert_eq!(Verb::from_name("EXEC"), Some(Verb::Exec));
647 assert_eq!(Verb::from_name("Agent"), Some(Verb::Agent));
648 assert_eq!(Verb::from_name("invalid"), None);
649 }
650
651 #[test]
652 fn test_provider_from_name() {
653 assert_eq!(Provider::from_name("claude"), Some(Provider::Claude));
654 assert_eq!(Provider::from_name("anthropic"), Some(Provider::Claude));
655 assert_eq!(Provider::from_name("GPT"), Some(Provider::OpenAI));
656 assert_eq!(Provider::from_name("invalid"), None);
657 }
658
659 #[test]
660 fn test_default_config() {
661 let config = NewWorkflowConfig::default();
662 assert_eq!(config.name, "my-workflow");
663 assert_eq!(config.verb, Verb::Infer);
664 assert_eq!(config.provider, Provider::Claude);
665 assert!(!config.with_mcp);
666 }
667
668 #[test]
669 fn test_generate_basic_workflow() {
670 let config = NewWorkflowConfig::default();
671 let yaml = config.generate();
672
673 assert!(yaml.contains("schema: \"nika/workflow@0.12\""));
674 assert!(yaml.contains("workflow: my-workflow"));
675 assert!(yaml.contains("provider: claude"));
676 assert!(yaml.contains("infer:"));
677 }
678
679 #[test]
680 fn test_generate_with_mcp() {
681 let config = NewWorkflowConfig {
682 with_mcp: true,
683 ..Default::default()
684 };
685 let yaml = config.generate();
686
687 assert!(yaml.contains("mcp:"));
688 assert!(yaml.contains("perplexity:"));
689 }
690
691 #[test]
692 fn test_generate_with_artifacts() {
693 let config = NewWorkflowConfig {
694 with_artifacts: true,
695 ..Default::default()
696 };
697 let yaml = config.generate();
698
699 assert!(yaml.contains("artifacts:"));
700 assert!(yaml.contains("dir:"));
701 }
702
703 #[test]
704 fn test_generate_exec_task() {
705 let config = NewWorkflowConfig {
706 verb: Verb::Exec,
707 ..Default::default()
708 };
709 let yaml = config.generate();
710
711 assert!(yaml.contains("exec: |"));
712 assert!(yaml.contains("echo \"Hello from Nika!\""));
713 }
714
715 #[test]
716 fn test_generate_fetch_task() {
717 let config = NewWorkflowConfig {
718 verb: Verb::Fetch,
719 ..Default::default()
720 };
721 let yaml = config.generate();
722
723 assert!(yaml.contains("fetch:"));
724 assert!(yaml.contains("url:"));
725 assert!(yaml.contains("method: GET"));
726 }
727
728 #[test]
729 fn test_generate_agent_task() {
730 let config = NewWorkflowConfig {
731 verb: Verb::Agent,
732 ..Default::default()
733 };
734 let yaml = config.generate();
735
736 assert!(yaml.contains("agent:"));
737 assert!(yaml.contains("max_turns:"));
738 }
739
740 #[test]
741 fn test_generate_json_output() {
742 let config = NewWorkflowConfig {
743 output_format: OutputFormat::Json,
744 ..Default::default()
745 };
746 let yaml = config.generate();
747
748 assert!(yaml.contains("format: json"));
749 assert!(yaml.contains("schema:"));
750 }
751
752 #[test]
753 fn test_write_workflow() {
754 let temp_dir = TempDir::new().unwrap();
755 let config = NewWorkflowConfig {
756 name: "test-workflow".to_string(),
757 output_dir: temp_dir.path().to_path_buf(),
758 ..Default::default()
759 };
760
761 let path = config.write().unwrap();
762 assert!(path.exists());
763 assert!(path.ends_with("test-workflow.nika.yaml"));
764
765 let content = std::fs::read_to_string(&path).unwrap();
767 assert!(content.contains("workflow: test-workflow"));
768 }
769
770 #[test]
771 fn test_write_workflow_already_exists() {
772 let temp_dir = TempDir::new().unwrap();
773 let config = NewWorkflowConfig {
774 name: "existing".to_string(),
775 output_dir: temp_dir.path().to_path_buf(),
776 ..Default::default()
777 };
778
779 let path = temp_dir.path().join("existing.nika.yaml");
781 std::fs::write(&path, "existing content").unwrap();
782
783 let result = config.write();
785 assert!(result.is_err());
786 }
787
788 #[test]
789 fn test_create_from_template() {
790 let temp_dir = TempDir::new().unwrap();
791 let path =
792 create_from_template("my-simple", Template::SimpleInfer, temp_dir.path()).unwrap();
793
794 assert!(path.exists());
795 let content = std::fs::read_to_string(&path).unwrap();
796 assert!(content.contains("workflow: my-simple"));
797 }
798
799 #[test]
800 fn test_list_templates() {
801 let templates = list_templates();
802 assert!(templates.len() >= 10);
803
804 for (name, description, category) in &templates {
806 assert!(!name.is_empty());
807 assert!(!description.is_empty());
808 assert!(!category.is_empty());
809 }
810 }
811
812 #[test]
813 fn test_all_templates_generate() {
814 for template in Template::ALL {
815 let content = template.content("test-workflow");
816 assert!(content.contains("schema: \"nika/workflow@0.12\""));
817 assert!(content.contains("workflow: test-workflow"));
818 }
819 }
820}