1use anyhow::{Context, Result};
11use std::collections::BTreeMap;
12use std::fs;
13use std::path::{Path, PathBuf};
14
15use crate::cli::plugin::{PluginArgs, PluginCommand, PluginInitArgs, PluginScopeArg};
16use crate::config::Resolved;
17use crate::plugins::PLUGIN_API_VERSION;
18use crate::plugins::discovery::{PluginScope, discover_plugins, plugin_roots};
19use crate::plugins::manifest::{PluginManifest, ProcessorPlugin, RunnerPlugin};
20use crate::plugins::registry::PluginRegistry;
21
22pub fn run(args: &PluginArgs, resolved: &Resolved) -> Result<()> {
23 match &args.command {
24 PluginCommand::List { json } => cmd_list(resolved, *json),
25 PluginCommand::Validate { id } => cmd_validate(resolved, id.as_deref()),
26 PluginCommand::Install { source, scope } => cmd_install(resolved, source, *scope),
27 PluginCommand::Uninstall { id, scope } => cmd_uninstall(resolved, id, *scope),
28 PluginCommand::Init(init_args) => cmd_init(resolved, init_args),
29 }
30}
31
32fn cmd_list(resolved: &Resolved, json_output: bool) -> Result<()> {
33 let registry = PluginRegistry::load(&resolved.repo_root, &resolved.config)?;
34 let discovered = registry.discovered();
35
36 if json_output {
37 let output: BTreeMap<String, serde_json::Value> = discovered
38 .iter()
39 .map(|(id, d)| {
40 let enabled = registry.is_enabled(id);
41 let info = serde_json::json!({
42 "id": id,
43 "name": d.manifest.name,
44 "version": d.manifest.version,
45 "scope": match d.scope {
46 PluginScope::Global => "global",
47 PluginScope::Project => "project",
48 },
49 "enabled": enabled,
50 "has_runner": d.manifest.runner.is_some(),
51 "has_processors": d.manifest.processors.is_some(),
52 });
53 (id.clone(), info)
54 })
55 .collect();
56 println!("{}", serde_json::to_string_pretty(&output)?);
57 } else {
58 if discovered.is_empty() {
59 println!("No plugins discovered.");
60 println!();
61 println!("Plugin directories checked:");
62 for (scope, root) in plugin_roots(&resolved.repo_root) {
63 let scope_str = match scope {
64 PluginScope::Global => "global",
65 PluginScope::Project => "project",
66 };
67 println!(" [{}] {}", scope_str, root.display());
68 }
69 return Ok(());
70 }
71
72 println!("Discovered plugins:");
73 println!();
74 for (id, d) in discovered.iter() {
75 let enabled = registry.is_enabled(id);
76 let scope_str = match d.scope {
77 PluginScope::Global => "global",
78 PluginScope::Project => "project",
79 };
80 let status = if enabled { "enabled" } else { "disabled" };
81 let capabilities = {
82 let mut caps = Vec::new();
83 if d.manifest.runner.is_some() {
84 caps.push("runner");
85 }
86 if d.manifest.processors.is_some() {
87 caps.push("processors");
88 }
89 if caps.is_empty() {
90 "none".to_string()
91 } else {
92 caps.join(", ")
93 }
94 };
95
96 println!(" {} ({})", id, d.manifest.version);
97 println!(" Name: {}", d.manifest.name);
98 println!(" Scope: {}", scope_str);
99 println!(" Status: {}", status);
100 println!(" Capabilities: {}", capabilities);
101 if let Some(desc) = &d.manifest.description {
102 println!(" Description: {}", desc);
103 }
104 println!();
105 }
106
107 println!("To enable a plugin, add to your config:");
108 println!(
109 r#" {{ "plugins": {{ "plugins": {{ "<plugin-id>": {{ "enabled": true }} }} }} }}"#
110 );
111 }
112
113 Ok(())
114}
115
116fn cmd_validate(resolved: &Resolved, filter_id: Option<&str>) -> Result<()> {
117 let discovered = discover_plugins(&resolved.repo_root)?;
118
119 if discovered.is_empty() {
120 println!("No plugins to validate.");
121 return Ok(());
122 }
123
124 let mut validated = 0;
125 let mut errors = 0;
126
127 for (id, d) in discovered.iter() {
128 if let Some(filter) = filter_id
129 && id != filter
130 {
131 continue;
132 }
133
134 print!("Validating {}... ", id);
135
136 if let Err(e) = d.manifest.validate() {
138 println!("FAILED (manifest)");
139 println!(" Error: {}", e);
140 errors += 1;
141 continue;
142 }
143
144 if let Some(runner) = &d.manifest.runner {
146 let bin_path = d.plugin_dir.join(&runner.bin);
147 if !bin_path.exists() {
148 println!("FAILED (runner binary)");
149 println!(" Error: runner binary not found: {}", bin_path.display());
150 errors += 1;
151 continue;
152 }
153 }
154
155 if let Some(proc) = &d.manifest.processors {
157 let bin_path = d.plugin_dir.join(&proc.bin);
158 if !bin_path.exists() {
159 println!("FAILED (processor binary)");
160 println!(
161 " Error: processor binary not found: {}",
162 bin_path.display()
163 );
164 errors += 1;
165 continue;
166 }
167 }
168
169 println!("OK");
170 validated += 1;
171 }
172
173 if let Some(filter) = filter_id
174 && validated == 0
175 && errors == 0
176 {
177 println!("Plugin '{}' not found.", filter);
178 }
179
180 if errors > 0 {
181 anyhow::bail!("{} validation error(s) found", errors);
182 }
183
184 println!("{} plugin(s) validated successfully.", validated);
185 Ok(())
186}
187
188fn scope_root(repo_root: &Path, scope: PluginScopeArg) -> Result<PathBuf> {
189 Ok(match scope {
190 PluginScopeArg::Project => repo_root.join(".ralph/plugins"),
191 PluginScopeArg::Global => {
192 let home = std::env::var_os("HOME")
193 .ok_or_else(|| anyhow::anyhow!("HOME environment variable not set"))?;
194 PathBuf::from(home).join(".config/ralph/plugins")
195 }
196 })
197}
198
199fn cmd_install(resolved: &Resolved, source: &str, scope: PluginScopeArg) -> Result<()> {
200 let source_path = Path::new(source);
201 if !source_path.exists() {
202 anyhow::bail!("Source path does not exist: {}", source);
203 }
204 if !source_path.is_dir() {
205 anyhow::bail!("Source path is not a directory: {}", source);
206 }
207
208 let manifest_path = source_path.join("plugin.json");
210 if !manifest_path.exists() {
211 anyhow::bail!("Source directory does not contain plugin.json: {}", source);
212 }
213
214 let manifest: PluginManifest = {
215 let raw = fs::read_to_string(&manifest_path)
216 .with_context(|| format!("read {}", manifest_path.display()))?;
217 serde_json::from_str(&raw).context("parse plugin.json")?
218 };
219 manifest.validate().context("validate plugin manifest")?;
220
221 let plugin_id = &manifest.id;
222
223 let target_root = scope_root(&resolved.repo_root, scope)?;
225 let target_dir = target_root.join(plugin_id);
226
227 if target_dir.exists() {
229 anyhow::bail!(
230 "Plugin {} is already installed at {}. Use uninstall first.",
231 plugin_id,
232 target_dir.display()
233 );
234 }
235
236 fs::create_dir_all(&target_root)
238 .with_context(|| format!("create plugin directory {}", target_root.display()))?;
239
240 copy_dir_all(source_path, &target_dir)
242 .with_context(|| format!("copy plugin to {}", target_dir.display()))?;
243
244 println!("Installed plugin {} to {}", plugin_id, target_dir.display());
245 println!();
246 println!("NOTE: The plugin is NOT automatically enabled.");
247 println!("To enable it, add to your config:");
248 println!(
249 r#" {{ "plugins": {{ "plugins": {{ "{}": {{ "enabled": true }} }} }} }}"#,
250 plugin_id
251 );
252
253 Ok(())
254}
255
256fn cmd_uninstall(resolved: &Resolved, plugin_id: &str, scope: PluginScopeArg) -> Result<()> {
257 let target_root = scope_root(&resolved.repo_root, scope)?;
259 let target_dir = target_root.join(plugin_id);
260
261 if !target_dir.exists() {
262 anyhow::bail!(
263 "Plugin {} is not installed at {}.",
264 plugin_id,
265 target_dir.display()
266 );
267 }
268
269 let manifest_path = target_dir.join("plugin.json");
271 if !manifest_path.exists() {
272 anyhow::bail!(
273 "Directory {} does not appear to be a plugin (no plugin.json).",
274 target_dir.display()
275 );
276 }
277
278 fs::remove_dir_all(&target_dir)
280 .with_context(|| format!("remove plugin directory {}", target_dir.display()))?;
281
282 println!(
283 "Uninstalled plugin {} from {}",
284 plugin_id,
285 target_dir.display()
286 );
287
288 Ok(())
289}
290
291fn default_name_from_id(id: &str) -> String {
292 id.replace(['.', '-', '_'], " ")
294}
295
296fn cmd_init(resolved: &Resolved, args: &PluginInitArgs) -> Result<()> {
297 if args.id.contains('/') || args.id.contains('\\') {
299 anyhow::bail!("plugin id must not contain path separators");
300 }
301 if args.id.trim().is_empty() {
302 anyhow::bail!("plugin id must be non-empty");
303 }
304
305 let default_both = !args.with_runner && !args.with_processor;
307 let with_runner = args.with_runner || default_both;
308 let with_processor = args.with_processor || default_both;
309
310 let target_dir = if let Some(path) = &args.path {
312 if path.is_absolute() {
313 path.clone()
314 } else {
315 resolved.repo_root.join(path)
316 }
317 } else {
318 scope_root(&resolved.repo_root, args.scope)?.join(&args.id)
319 };
320
321 if target_dir.exists() && !args.force {
323 anyhow::bail!(
324 "Plugin directory already exists: {}. Use --force to overwrite.",
325 target_dir.display()
326 );
327 }
328
329 let name = args
331 .name
332 .clone()
333 .unwrap_or_else(|| default_name_from_id(&args.id));
334
335 let runner = if with_runner {
336 Some(RunnerPlugin {
337 bin: "runner.sh".to_string(),
338 supports_resume: Some(false),
339 default_model: None,
340 })
341 } else {
342 None
343 };
344
345 let processors = if with_processor {
346 Some(ProcessorPlugin {
347 bin: "processor.sh".to_string(),
348 hooks: vec![
349 "validate_task".to_string(),
350 "pre_prompt".to_string(),
351 "post_run".to_string(),
352 ],
353 })
354 } else {
355 None
356 };
357
358 let manifest = PluginManifest {
359 api_version: PLUGIN_API_VERSION,
360 id: args.id.clone(),
361 version: args.version.clone(),
362 name,
363 description: args.description.clone(),
364 runner,
365 processors,
366 };
367
368 manifest.validate().context("validate generated manifest")?;
370
371 let manifest_json = serde_json::to_string_pretty(&manifest)?;
373
374 let runner_script = if with_runner {
375 Some(RUNNER_SCRIPT_TEMPLATE.replace("{plugin_id}", &args.id))
376 } else {
377 None
378 };
379
380 let processor_script = if with_processor {
381 Some(PROCESSOR_SCRIPT_TEMPLATE.replace("{plugin_id}", &args.id))
382 } else {
383 None
384 };
385
386 if args.dry_run {
387 println!("Would create plugin directory: {}", target_dir.display());
388 println!("Would write: {}", target_dir.join("plugin.json").display());
389 if with_runner {
390 println!("Would write: {}", target_dir.join("runner.sh").display());
391 }
392 if with_processor {
393 println!("Would write: {}", target_dir.join("processor.sh").display());
394 }
395 return Ok(());
396 }
397
398 fs::create_dir_all(&target_dir)
400 .with_context(|| format!("create plugin directory {}", target_dir.display()))?;
401
402 crate::fsutil::write_atomic(&target_dir.join("plugin.json"), manifest_json.as_bytes())
404 .context("write plugin.json")?;
405
406 if let Some(script) = runner_script {
407 let runner_path = target_dir.join("runner.sh");
408 crate::fsutil::write_atomic(&runner_path, script.as_bytes()).context("write runner.sh")?;
409 #[cfg(unix)]
410 {
411 use std::os::unix::fs::PermissionsExt;
412 let mut perms = fs::metadata(&runner_path)?.permissions();
413 perms.set_mode(0o755);
414 fs::set_permissions(&runner_path, perms)?;
415 }
416 }
417
418 if let Some(script) = processor_script {
419 let processor_path = target_dir.join("processor.sh");
420 crate::fsutil::write_atomic(&processor_path, script.as_bytes())
421 .context("write processor.sh")?;
422 #[cfg(unix)]
423 {
424 use std::os::unix::fs::PermissionsExt;
425 let mut perms = fs::metadata(&processor_path)?.permissions();
426 perms.set_mode(0o755);
427 fs::set_permissions(&processor_path, perms)?;
428 }
429 }
430
431 println!("Created plugin {} at {}", args.id, target_dir.display());
432 println!();
433 println!("Files created:");
434 println!(" plugin.json");
435 if with_runner {
436 println!(" runner.sh");
437 }
438 if with_processor {
439 println!(" processor.sh");
440 }
441 println!();
442 println!("NOTE: The plugin is NOT automatically enabled.");
443 println!("To enable it, add to your config:");
444 println!(
445 r#" {{ "plugins": {{ "plugins": {{ "{}": {{ "enabled": true }} }} }} }}"#,
446 args.id
447 );
448 println!();
449 println!("Validate the plugin:");
450 println!(" ralph plugin validate --id {}", args.id);
451
452 Ok(())
453}
454
455const RUNNER_SCRIPT_TEMPLATE: &str = r#"#!/bin/bash
456# Runner stub for {plugin_id}
457#
458# Responsibilities:
459# - Execute AI agent runs and resumes with prompt input from stdin.
460# - Output newline-delimited JSON with text, tool_call, and finish types.
461#
462# Not handled here:
463# - Task planning (handled by Ralph before invocation).
464# - File operations outside the working directory.
465#
466# Assumptions:
467# - stdin contains the compiled prompt.
468# - Environment RALPH_PLUGIN_CONFIG_JSON contains plugin config.
469# - Environment RALPH_RUNNER_CLI_JSON contains CLI options.
470
471set -euo pipefail
472
473PLUGIN_ID="{plugin_id}"
474
475show_help() {
476 cat << 'EOF'
477Usage: runner.sh <COMMAND> [OPTIONS]
478
479Commands:
480 run Execute a new run
481 resume Resume an existing session
482 help Show this help message
483
484Run Options:
485 --model <MODEL> Model identifier
486 --output-format <FORMAT> Output format (must be stream-json)
487 --session <ID> Session identifier
488
489Resume Options:
490 --session <ID> Session to resume (required)
491 --model <MODEL> Model identifier
492 --output-format <FORMAT> Output format (must be stream-json)
493 <MESSAGE> Additional message argument
494
495Examples:
496 runner.sh run --model gpt-4 --output-format stream-json
497 runner.sh resume --session abc123 --model gpt-4 --output-format stream-json "continue"
498 runner.sh help
499
500Protocol:
501 Input: Prompt text via stdin
502 Output: Newline-delimited JSON objects:
503 {"type": "text", "content": "Hello"}
504 {"type": "tool_call", "name": "write", "arguments": {"path": "file.txt"}}
505 {"type": "finish", "session_id": "..."}
506EOF
507}
508
509COMMAND="${1:-}"
510
511case "$COMMAND" in
512 run)
513 # Stub: replace with your runner's execution logic.
514 # Input prompt is provided via stdin; output must be NDJSON on stdout.
515 _PROMPT=$(cat || true)
516 echo "{\"type\": \"text\", \"content\": \"Stub runner: run not implemented\"}"
517 echo "{\"type\": \"finish\", \"session_id\": \"stub-session\"}"
518 echo "Stub runner ($PLUGIN_ID): run not implemented" >&2
519 exit 1
520 ;;
521 resume)
522 # Stub: replace with your runner's resume logic.
523 echo "{\"type\": \"text\", \"content\": \"Stub runner: resume not implemented\"}"
524 echo "{\"type\": \"finish\", \"session_id\": \"stub-session\"}"
525 echo "Stub runner ($PLUGIN_ID): resume not implemented" >&2
526 exit 1
527 ;;
528 help|--help|-h)
529 show_help
530 exit 0
531 ;;
532 "")
533 echo "Error: No command specified" >&2
534 show_help >&2
535 exit 1
536 ;;
537 *)
538 echo "Error: Unknown command: $COMMAND" >&2
539 show_help >&2
540 exit 1
541 ;;
542esac
543"#;
544
545const PROCESSOR_SCRIPT_TEMPLATE: &str = r#"#!/bin/bash
546# Processor stub for {plugin_id}
547#
548# Responsibilities:
549# - Process task lifecycle hooks: validate_task, pre_prompt, post_run.
550# - Called by Ralph with hook name and task ID as arguments.
551#
552# Not handled here:
553# - Direct task execution (handled by runners).
554# - Queue modification (handled by Ralph).
555#
556# Assumptions:
557# - First argument is the hook name.
558# - Second argument is the task ID.
559# - Additional arguments may follow depending on hook.
560
561set -euo pipefail
562
563PLUGIN_ID="{plugin_id}"
564
565show_help() {
566 cat << 'EOF'
567Usage: processor.sh <HOOK> <TASK_ID> [ARGS...]
568
569Hooks:
570 validate_task Validate task structure before execution
571 Args: <TASK_ID> <TASK_JSON_FILE>
572 pre_prompt Called before prompt is sent to runner
573 Args: <TASK_ID> <PROMPT_FILE>
574 post_run Called after runner execution completes
575 Args: <TASK_ID> <OUTPUT_FILE>
576
577Examples:
578 processor.sh validate_task RQ-0001 /tmp/task.json
579 processor.sh pre_prompt RQ-0001 /tmp/prompt.txt
580 processor.sh post_run RQ-0001 /tmp/output.ndjson
581 processor.sh help
582
583Exit Codes:
584 0 Success
585 1 Validation/processing error
586
587Environment:
588 RALPH_PLUGIN_CONFIG_JSON Plugin configuration as JSON string
589EOF
590}
591
592HOOK="${1:-}"
593TASK_ID="${2:-}"
594
595# Shift to leave remaining args for hook processing
596shift 2 || true
597
598case "$HOOK" in
599 validate_task)
600 # Stub: implement validate_task logic.
601 # TASK_JSON_FILE="${1:-}"
602 # Validate task JSON structure
603 exit 0
604 ;;
605 pre_prompt)
606 # Stub: implement pre_prompt logic.
607 # PROMPT_FILE="${1:-}"
608 # Can modify prompt file in place
609 exit 0
610 ;;
611 post_run)
612 # Stub: implement post_run logic.
613 # OUTPUT_FILE="${1:-}"
614 # Process runner output
615 exit 0
616 ;;
617 help|--help|-h)
618 show_help
619 exit 0
620 ;;
621 "")
622 echo "Error: No hook specified" >&2
623 show_help >&2
624 exit 1
625 ;;
626 *)
627 echo "Error: Unknown hook: $HOOK" >&2
628 show_help >&2
629 exit 1
630 ;;
631esac
632"#;
633
634fn copy_dir_all(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> Result<()> {
635 fs::create_dir_all(&dst)?;
636 for entry in fs::read_dir(src)? {
637 let entry = entry?;
638 let ty = entry.file_type()?;
639 if ty.is_dir() {
640 copy_dir_all(entry.path(), dst.as_ref().join(entry.file_name()))?;
641 } else {
642 fs::copy(entry.path(), dst.as_ref().join(entry.file_name()))?;
643 }
644 }
645 Ok(())
646}