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, resolve_plugin_relative_exe};
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 = resolve_plugin_relative_exe(&d.plugin_dir, &runner.bin)
147 .context("resolve runner binary path")?;
148 if !bin_path.exists() {
149 println!("FAILED (runner binary)");
150 println!(" Error: runner binary not found: {}", bin_path.display());
151 errors += 1;
152 continue;
153 }
154 }
155
156 if let Some(proc) = &d.manifest.processors {
158 let bin_path = resolve_plugin_relative_exe(&d.plugin_dir, &proc.bin)
159 .context("resolve processor binary path")?;
160 if !bin_path.exists() {
161 println!("FAILED (processor binary)");
162 println!(
163 " Error: processor binary not found: {}",
164 bin_path.display()
165 );
166 errors += 1;
167 continue;
168 }
169 }
170
171 println!("OK");
172 validated += 1;
173 }
174
175 if let Some(filter) = filter_id
176 && validated == 0
177 && errors == 0
178 {
179 println!("Plugin '{}' not found.", filter);
180 }
181
182 if errors > 0 {
183 anyhow::bail!("{} validation error(s) found", errors);
184 }
185
186 println!("{} plugin(s) validated successfully.", validated);
187 Ok(())
188}
189
190fn scope_root(repo_root: &Path, scope: PluginScopeArg) -> Result<PathBuf> {
191 Ok(match scope {
192 PluginScopeArg::Project => repo_root.join(".ralph/plugins"),
193 PluginScopeArg::Global => {
194 let home = std::env::var_os("HOME")
195 .ok_or_else(|| anyhow::anyhow!("HOME environment variable not set"))?;
196 PathBuf::from(home).join(".config/ralph/plugins")
197 }
198 })
199}
200
201fn cmd_install(resolved: &Resolved, source: &str, scope: PluginScopeArg) -> Result<()> {
202 let source_path = Path::new(source);
203 if !source_path.exists() {
204 anyhow::bail!("Source path does not exist: {}", source);
205 }
206 if !source_path.is_dir() {
207 anyhow::bail!("Source path is not a directory: {}", source);
208 }
209
210 let manifest_path = source_path.join("plugin.json");
212 if !manifest_path.exists() {
213 anyhow::bail!("Source directory does not contain plugin.json: {}", source);
214 }
215
216 let manifest: PluginManifest = {
217 let raw = fs::read_to_string(&manifest_path)
218 .with_context(|| format!("read {}", manifest_path.display()))?;
219 serde_json::from_str(&raw).context("parse plugin.json")?
220 };
221 manifest.validate().context("validate plugin manifest")?;
222
223 let plugin_id = &manifest.id;
224
225 let target_root = scope_root(&resolved.repo_root, scope)?;
227 let target_dir = target_root.join(plugin_id);
228
229 if target_dir.exists() {
231 anyhow::bail!(
232 "Plugin {} is already installed at {}. Use uninstall first.",
233 plugin_id,
234 target_dir.display()
235 );
236 }
237
238 fs::create_dir_all(&target_root)
240 .with_context(|| format!("create plugin directory {}", target_root.display()))?;
241
242 copy_dir_all(source_path, &target_dir)
244 .with_context(|| format!("copy plugin to {}", target_dir.display()))?;
245
246 println!("Installed plugin {} to {}", plugin_id, target_dir.display());
247 println!();
248 println!("NOTE: The plugin is NOT automatically enabled.");
249 println!("To enable it, add to your config:");
250 println!(
251 r#" {{ "plugins": {{ "plugins": {{ "{}": {{ "enabled": true }} }} }} }}"#,
252 plugin_id
253 );
254
255 Ok(())
256}
257
258fn cmd_uninstall(resolved: &Resolved, plugin_id: &str, scope: PluginScopeArg) -> Result<()> {
259 let target_root = scope_root(&resolved.repo_root, scope)?;
261 let target_dir = target_root.join(plugin_id);
262
263 if !target_dir.exists() {
264 anyhow::bail!(
265 "Plugin {} is not installed at {}.",
266 plugin_id,
267 target_dir.display()
268 );
269 }
270
271 let manifest_path = target_dir.join("plugin.json");
273 if !manifest_path.exists() {
274 anyhow::bail!(
275 "Directory {} does not appear to be a plugin (no plugin.json).",
276 target_dir.display()
277 );
278 }
279
280 fs::remove_dir_all(&target_dir)
282 .with_context(|| format!("remove plugin directory {}", target_dir.display()))?;
283
284 println!(
285 "Uninstalled plugin {} from {}",
286 plugin_id,
287 target_dir.display()
288 );
289
290 Ok(())
291}
292
293fn default_name_from_id(id: &str) -> String {
294 id.replace(['.', '-', '_'], " ")
296}
297
298fn cmd_init(resolved: &Resolved, args: &PluginInitArgs) -> Result<()> {
299 if args.id.contains('/') || args.id.contains('\\') {
301 anyhow::bail!("plugin id must not contain path separators");
302 }
303 if args.id.trim().is_empty() {
304 anyhow::bail!("plugin id must be non-empty");
305 }
306
307 let default_both = !args.with_runner && !args.with_processor;
309 let with_runner = args.with_runner || default_both;
310 let with_processor = args.with_processor || default_both;
311
312 let target_dir = if let Some(path) = &args.path {
314 if path.is_absolute() {
315 path.clone()
316 } else {
317 resolved.repo_root.join(path)
318 }
319 } else {
320 scope_root(&resolved.repo_root, args.scope)?.join(&args.id)
321 };
322
323 if target_dir.exists() && !args.force {
325 anyhow::bail!(
326 "Plugin directory already exists: {}. Use --force to overwrite.",
327 target_dir.display()
328 );
329 }
330
331 let name = args
333 .name
334 .clone()
335 .unwrap_or_else(|| default_name_from_id(&args.id));
336
337 let runner = if with_runner {
338 Some(RunnerPlugin {
339 bin: "runner.sh".to_string(),
340 supports_resume: Some(false),
341 default_model: None,
342 })
343 } else {
344 None
345 };
346
347 let processors = if with_processor {
348 Some(ProcessorPlugin {
349 bin: "processor.sh".to_string(),
350 hooks: vec![
351 "validate_task".to_string(),
352 "pre_prompt".to_string(),
353 "post_run".to_string(),
354 ],
355 })
356 } else {
357 None
358 };
359
360 let manifest = PluginManifest {
361 api_version: PLUGIN_API_VERSION,
362 id: args.id.clone(),
363 version: args.version.clone(),
364 name,
365 description: args.description.clone(),
366 runner,
367 processors,
368 };
369
370 manifest.validate().context("validate generated manifest")?;
372
373 let manifest_json = serde_json::to_string_pretty(&manifest)?;
375
376 let runner_script = if with_runner {
377 Some(RUNNER_SCRIPT_TEMPLATE.replace("{plugin_id}", &args.id))
378 } else {
379 None
380 };
381
382 let processor_script = if with_processor {
383 Some(PROCESSOR_SCRIPT_TEMPLATE.replace("{plugin_id}", &args.id))
384 } else {
385 None
386 };
387
388 if args.dry_run {
389 println!("Would create plugin directory: {}", target_dir.display());
390 println!("Would write: {}", target_dir.join("plugin.json").display());
391 if with_runner {
392 println!("Would write: {}", target_dir.join("runner.sh").display());
393 }
394 if with_processor {
395 println!("Would write: {}", target_dir.join("processor.sh").display());
396 }
397 return Ok(());
398 }
399
400 fs::create_dir_all(&target_dir)
402 .with_context(|| format!("create plugin directory {}", target_dir.display()))?;
403
404 crate::fsutil::write_atomic(&target_dir.join("plugin.json"), manifest_json.as_bytes())
406 .context("write plugin.json")?;
407
408 if let Some(script) = runner_script {
409 let runner_path = target_dir.join("runner.sh");
410 crate::fsutil::write_atomic(&runner_path, script.as_bytes()).context("write runner.sh")?;
411 #[cfg(unix)]
412 {
413 use std::os::unix::fs::PermissionsExt;
414 let mut perms = fs::metadata(&runner_path)?.permissions();
415 perms.set_mode(0o755);
416 fs::set_permissions(&runner_path, perms)?;
417 }
418 }
419
420 if let Some(script) = processor_script {
421 let processor_path = target_dir.join("processor.sh");
422 crate::fsutil::write_atomic(&processor_path, script.as_bytes())
423 .context("write processor.sh")?;
424 #[cfg(unix)]
425 {
426 use std::os::unix::fs::PermissionsExt;
427 let mut perms = fs::metadata(&processor_path)?.permissions();
428 perms.set_mode(0o755);
429 fs::set_permissions(&processor_path, perms)?;
430 }
431 }
432
433 println!("Created plugin {} at {}", args.id, target_dir.display());
434 println!();
435 println!("Files created:");
436 println!(" plugin.json");
437 if with_runner {
438 println!(" runner.sh");
439 }
440 if with_processor {
441 println!(" processor.sh");
442 }
443 println!();
444 println!("NOTE: The plugin is NOT automatically enabled.");
445 println!("To enable it, add to your config:");
446 println!(
447 r#" {{ "plugins": {{ "plugins": {{ "{}": {{ "enabled": true }} }} }} }}"#,
448 args.id
449 );
450 println!();
451 println!("Validate the plugin:");
452 println!(" ralph plugin validate --id {}", args.id);
453
454 Ok(())
455}
456
457const RUNNER_SCRIPT_TEMPLATE: &str = r#"#!/bin/bash
458# Runner stub for {plugin_id}
459#
460# Responsibilities:
461# - Execute AI agent runs and resumes with prompt input from stdin.
462# - Output newline-delimited JSON with text, tool_call, and finish types.
463#
464# Not handled here:
465# - Task planning (handled by Ralph before invocation).
466# - File operations outside the working directory.
467#
468# Assumptions:
469# - stdin contains the compiled prompt.
470# - Environment RALPH_PLUGIN_CONFIG_JSON contains plugin config.
471# - Environment RALPH_RUNNER_CLI_JSON contains CLI options.
472
473set -euo pipefail
474
475PLUGIN_ID="{plugin_id}"
476
477show_help() {
478 cat << 'EOF'
479Usage: runner.sh <COMMAND> [OPTIONS]
480
481Commands:
482 run Execute a new run
483 resume Resume an existing session
484 help Show this help message
485
486Run Options:
487 --model <MODEL> Model identifier
488 --output-format <FORMAT> Output format (must be stream-json)
489 --session <ID> Session identifier
490
491Resume Options:
492 --session <ID> Session to resume (required)
493 --model <MODEL> Model identifier
494 --output-format <FORMAT> Output format (must be stream-json)
495 <MESSAGE> Additional message argument
496
497Examples:
498 runner.sh run --model gpt-4 --output-format stream-json
499 runner.sh resume --session abc123 --model gpt-4 --output-format stream-json "continue"
500 runner.sh help
501
502Protocol:
503 Input: Prompt text via stdin
504 Output: Newline-delimited JSON objects:
505 {"type": "text", "content": "Hello"}
506 {"type": "tool_call", "name": "write", "arguments": {"path": "file.txt"}}
507 {"type": "finish", "session_id": "..."}
508EOF
509}
510
511COMMAND="${1:-}"
512
513case "$COMMAND" in
514 run)
515 # Stub: replace with your runner's execution logic.
516 # Input prompt is provided via stdin; output must be NDJSON on stdout.
517 _PROMPT=$(cat || true)
518 echo "{\"type\": \"text\", \"content\": \"Stub runner: run not implemented\"}"
519 echo "{\"type\": \"finish\", \"session_id\": \"stub-session\"}"
520 echo "Stub runner ($PLUGIN_ID): run not implemented" >&2
521 exit 1
522 ;;
523 resume)
524 # Stub: replace with your runner's resume logic.
525 echo "{\"type\": \"text\", \"content\": \"Stub runner: resume not implemented\"}"
526 echo "{\"type\": \"finish\", \"session_id\": \"stub-session\"}"
527 echo "Stub runner ($PLUGIN_ID): resume not implemented" >&2
528 exit 1
529 ;;
530 help|--help|-h)
531 show_help
532 exit 0
533 ;;
534 "")
535 echo "Error: No command specified" >&2
536 show_help >&2
537 exit 1
538 ;;
539 *)
540 echo "Error: Unknown command: $COMMAND" >&2
541 show_help >&2
542 exit 1
543 ;;
544esac
545"#;
546
547const PROCESSOR_SCRIPT_TEMPLATE: &str = r#"#!/bin/bash
548# Processor stub for {plugin_id}
549#
550# Responsibilities:
551# - Process task lifecycle hooks: validate_task, pre_prompt, post_run.
552# - Called by Ralph with hook name and task ID as arguments.
553#
554# Not handled here:
555# - Direct task execution (handled by runners).
556# - Queue modification (handled by Ralph).
557#
558# Assumptions:
559# - First argument is the hook name.
560# - Second argument is the task ID.
561# - Additional arguments may follow depending on hook.
562
563set -euo pipefail
564
565PLUGIN_ID="{plugin_id}"
566
567show_help() {
568 cat << 'EOF'
569Usage: processor.sh <HOOK> <TASK_ID> [ARGS...]
570
571Hooks:
572 validate_task Validate task structure before execution
573 Args: <TASK_ID> <TASK_JSON_FILE>
574 pre_prompt Called before prompt is sent to runner
575 Args: <TASK_ID> <PROMPT_FILE>
576 post_run Called after runner execution completes
577 Args: <TASK_ID> <OUTPUT_FILE>
578
579Examples:
580 processor.sh validate_task RQ-0001 /tmp/task.json
581 processor.sh pre_prompt RQ-0001 /tmp/prompt.txt
582 processor.sh post_run RQ-0001 /tmp/output.ndjson
583 processor.sh help
584
585Exit Codes:
586 0 Success
587 1 Validation/processing error
588
589Environment:
590 RALPH_PLUGIN_CONFIG_JSON Plugin configuration as JSON string
591EOF
592}
593
594HOOK="${1:-}"
595TASK_ID="${2:-}"
596
597# Shift to leave remaining args for hook processing
598shift 2 || true
599
600case "$HOOK" in
601 validate_task)
602 # Stub: implement validate_task logic.
603 # TASK_JSON_FILE="${1:-}"
604 # Validate task JSON structure
605 exit 0
606 ;;
607 pre_prompt)
608 # Stub: implement pre_prompt logic.
609 # PROMPT_FILE="${1:-}"
610 # Can modify prompt file in place
611 exit 0
612 ;;
613 post_run)
614 # Stub: implement post_run logic.
615 # OUTPUT_FILE="${1:-}"
616 # Process runner output
617 exit 0
618 ;;
619 help|--help|-h)
620 show_help
621 exit 0
622 ;;
623 "")
624 echo "Error: No hook specified" >&2
625 show_help >&2
626 exit 1
627 ;;
628 *)
629 echo "Error: Unknown hook: $HOOK" >&2
630 show_help >&2
631 exit 1
632 ;;
633esac
634"#;
635
636fn copy_dir_all(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> Result<()> {
637 fs::create_dir_all(&dst)?;
638 for entry in fs::read_dir(src)? {
639 let entry = entry?;
640 let ty = entry.file_type()?;
641 if ty.is_dir() {
642 copy_dir_all(entry.path(), dst.as_ref().join(entry.file_name()))?;
643 } else {
644 fs::copy(entry.path(), dst.as_ref().join(entry.file_name()))?;
645 }
646 }
647 Ok(())
648}