Skip to main content

ralph/commands/plugin/
mod.rs

1//! Plugin command implementations.
2//!
3//! Responsibilities:
4//! - Implement plugin list, validate, install, uninstall, and init commands.
5//!
6//! Not handled here:
7//! - CLI argument parsing (see `crate::cli::plugin`).
8//! - Plugin discovery/registry (see `crate::plugins`).
9
10use 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        // Manifest was already validated during discovery, but re-validate for thoroughness
137        if let Err(e) = d.manifest.validate() {
138            println!("FAILED (manifest)");
139            println!("  Error: {}", e);
140            errors += 1;
141            continue;
142        }
143
144        // Check runner binary exists if specified
145        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        // Check processor binary exists if specified
156        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    // Validate manifest exists and is valid
209    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    // Determine target directory
224    let target_root = scope_root(&resolved.repo_root, scope)?;
225    let target_dir = target_root.join(plugin_id);
226
227    // Check if already exists
228    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    // Create target directory and copy plugin
237    fs::create_dir_all(&target_root)
238        .with_context(|| format!("create plugin directory {}", target_root.display()))?;
239
240    // Copy directory recursively
241    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    // Determine target directory
258    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    // Verify it's actually a plugin directory
270    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    // Remove the directory
279    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    // "acme.super_runner" -> "acme super runner"
293    id.replace(['.', '-', '_'], " ")
294}
295
296fn cmd_init(resolved: &Resolved, args: &PluginInitArgs) -> Result<()> {
297    // Validate plugin ID format early
298    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    // Determine with_runner and with_processor based on flags
306    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    // Determine target directory
311    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    // Check if target exists (unless --force)
322    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    // Build manifest
330    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    // Validate the manifest before writing
369    manifest.validate().context("validate generated manifest")?;
370
371    // Prepare file contents
372    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    // Create directory
399    fs::create_dir_all(&target_dir)
400        .with_context(|| format!("create plugin directory {}", target_dir.display()))?;
401
402    // Write files
403    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}