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, 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        // 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 = 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        // Check processor binary exists if specified
157        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    // Validate manifest exists and is valid
211    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    // Determine target directory
226    let target_root = scope_root(&resolved.repo_root, scope)?;
227    let target_dir = target_root.join(plugin_id);
228
229    // Check if already exists
230    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    // Create target directory and copy plugin
239    fs::create_dir_all(&target_root)
240        .with_context(|| format!("create plugin directory {}", target_root.display()))?;
241
242    // Copy directory recursively
243    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    // Determine target directory
260    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    // Verify it's actually a plugin directory
272    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    // Remove the directory
281    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    // "acme.super_runner" -> "acme super runner"
295    id.replace(['.', '-', '_'], " ")
296}
297
298fn cmd_init(resolved: &Resolved, args: &PluginInitArgs) -> Result<()> {
299    // Validate plugin ID format early
300    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    // Determine with_runner and with_processor based on flags
308    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    // Determine target directory
313    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    // Check if target exists (unless --force)
324    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    // Build manifest
332    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    // Validate the manifest before writing
371    manifest.validate().context("validate generated manifest")?;
372
373    // Prepare file contents
374    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    // Create directory
401    fs::create_dir_all(&target_dir)
402        .with_context(|| format!("create plugin directory {}", target_dir.display()))?;
403
404    // Write files
405    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}