|
| 1 | +//! Outputs commands: write, list, show. |
| 2 | +//! |
| 3 | +//! Thin CLI wrapper over `flowctl_service::outputs::OutputsStore`. Provides |
| 4 | +//! a lightweight narrative handoff layer at `.flow/outputs/<task-id>.md` that |
| 5 | +//! workers populate in Phase 5c and read during Phase 1 re-anchor. |
| 6 | +
|
| 7 | +use std::fs; |
| 8 | +use std::io::Read; |
| 9 | + |
| 10 | +use clap::Subcommand; |
| 11 | +use serde_json::json; |
| 12 | + |
| 13 | +use flowctl_core::id::is_task_id; |
| 14 | +use flowctl_service::outputs::OutputsStore; |
| 15 | + |
| 16 | +use crate::output::{error_exit, json_output}; |
| 17 | + |
| 18 | +use super::helpers::get_flow_dir; |
| 19 | + |
| 20 | +#[derive(Subcommand, Debug)] |
| 21 | +pub enum OutputsCmd { |
| 22 | + /// Write output markdown for a task (from file or stdin via '-'). |
| 23 | + Write { |
| 24 | + /// Task ID (e.g. fn-1.3). |
| 25 | + task_id: String, |
| 26 | + /// Path to content file, or '-' for stdin. |
| 27 | + #[arg(long)] |
| 28 | + file: String, |
| 29 | + }, |
| 30 | + /// List prior outputs for an epic, newest-first. |
| 31 | + List { |
| 32 | + /// Epic ID. |
| 33 | + #[arg(long)] |
| 34 | + epic: String, |
| 35 | + /// Max entries to return. |
| 36 | + #[arg(long)] |
| 37 | + limit: Option<usize>, |
| 38 | + }, |
| 39 | + /// Print the full markdown content for a task's output file. |
| 40 | + Show { |
| 41 | + /// Task ID (e.g. fn-1.3). |
| 42 | + task_id: String, |
| 43 | + }, |
| 44 | +} |
| 45 | + |
| 46 | +pub fn dispatch(cmd: &OutputsCmd, json: bool) { |
| 47 | + match cmd { |
| 48 | + OutputsCmd::Write { task_id, file } => cmd_outputs_write(json, task_id, file), |
| 49 | + OutputsCmd::List { epic, limit } => cmd_outputs_list(json, epic, *limit), |
| 50 | + OutputsCmd::Show { task_id } => cmd_outputs_show(json, task_id), |
| 51 | + } |
| 52 | +} |
| 53 | + |
| 54 | +/// Open the store, exiting cleanly if `.flow/` is missing. |
| 55 | +fn open_store(json: bool) -> OutputsStore { |
| 56 | + let flow_dir = get_flow_dir(); |
| 57 | + if !flow_dir.exists() { |
| 58 | + if json { |
| 59 | + json_output(json!({"error": ".flow/ does not exist. Run 'flowctl init' first."})); |
| 60 | + std::process::exit(1); |
| 61 | + } else { |
| 62 | + error_exit(".flow/ does not exist. Run 'flowctl init' first."); |
| 63 | + } |
| 64 | + } |
| 65 | + match OutputsStore::new(&flow_dir) { |
| 66 | + Ok(store) => store, |
| 67 | + Err(e) => { |
| 68 | + if json { |
| 69 | + json_output(json!({"error": format!("failed to open outputs store: {e}")})); |
| 70 | + std::process::exit(1); |
| 71 | + } else { |
| 72 | + error_exit(&format!("failed to open outputs store: {e}")); |
| 73 | + } |
| 74 | + } |
| 75 | + } |
| 76 | +} |
| 77 | + |
| 78 | +fn cmd_outputs_write(json: bool, task_id: &str, file: &str) { |
| 79 | + if !is_task_id(task_id) { |
| 80 | + if json { |
| 81 | + json_output(json!({"error": format!("Invalid task ID: {task_id}")})); |
| 82 | + std::process::exit(1); |
| 83 | + } else { |
| 84 | + error_exit(&format!( |
| 85 | + "Invalid task ID: {task_id}. Expected format: fn-N.M or fn-N-slug.M" |
| 86 | + )); |
| 87 | + } |
| 88 | + } |
| 89 | + |
| 90 | + // Read content from file or stdin. |
| 91 | + let content = if file == "-" { |
| 92 | + let mut buf = String::new(); |
| 93 | + if let Err(e) = std::io::stdin().read_to_string(&mut buf) { |
| 94 | + error_exit(&format!("failed to read stdin: {e}")); |
| 95 | + } |
| 96 | + buf |
| 97 | + } else { |
| 98 | + match fs::read_to_string(file) { |
| 99 | + Ok(c) => c, |
| 100 | + Err(e) => error_exit(&format!("failed to read {file}: {e}")), |
| 101 | + } |
| 102 | + }; |
| 103 | + |
| 104 | + let store = open_store(json); |
| 105 | + match store.write(task_id, &content) { |
| 106 | + Ok(path) => { |
| 107 | + if json { |
| 108 | + json_output(json!({ |
| 109 | + "task_id": task_id, |
| 110 | + "path": path.to_string_lossy(), |
| 111 | + "bytes": content.len(), |
| 112 | + })); |
| 113 | + } else { |
| 114 | + println!("Wrote {} ({} bytes)", path.display(), content.len()); |
| 115 | + } |
| 116 | + } |
| 117 | + Err(e) => { |
| 118 | + if json { |
| 119 | + json_output(json!({"error": format!("write failed: {e}")})); |
| 120 | + std::process::exit(1); |
| 121 | + } else { |
| 122 | + error_exit(&format!("write failed: {e}")); |
| 123 | + } |
| 124 | + } |
| 125 | + } |
| 126 | +} |
| 127 | + |
| 128 | +fn cmd_outputs_list(json: bool, epic_id: &str, limit: Option<usize>) { |
| 129 | + let store = open_store(json); |
| 130 | + match store.list_for_epic(epic_id, limit) { |
| 131 | + Ok(entries) => { |
| 132 | + if json { |
| 133 | + let arr: Vec<_> = entries |
| 134 | + .iter() |
| 135 | + .map(|e| { |
| 136 | + json!({ |
| 137 | + "task_id": e.task_id, |
| 138 | + "path": e.path.to_string_lossy(), |
| 139 | + "mtime": e.mtime, |
| 140 | + }) |
| 141 | + }) |
| 142 | + .collect(); |
| 143 | + json_output(json!({"entries": arr, "count": entries.len()})); |
| 144 | + } else if entries.is_empty() { |
| 145 | + println!("(no outputs for epic {epic_id})"); |
| 146 | + } else { |
| 147 | + for e in &entries { |
| 148 | + println!("{}\t{}", e.task_id, e.path.display()); |
| 149 | + } |
| 150 | + } |
| 151 | + } |
| 152 | + Err(e) => { |
| 153 | + if json { |
| 154 | + json_output(json!({"error": format!("list failed: {e}")})); |
| 155 | + std::process::exit(1); |
| 156 | + } else { |
| 157 | + error_exit(&format!("list failed: {e}")); |
| 158 | + } |
| 159 | + } |
| 160 | + } |
| 161 | +} |
| 162 | + |
| 163 | +fn cmd_outputs_show(json: bool, task_id: &str) { |
| 164 | + if !is_task_id(task_id) { |
| 165 | + if json { |
| 166 | + json_output(json!({"error": format!("Invalid task ID: {task_id}")})); |
| 167 | + std::process::exit(1); |
| 168 | + } else { |
| 169 | + error_exit(&format!( |
| 170 | + "Invalid task ID: {task_id}. Expected format: fn-N.M or fn-N-slug.M" |
| 171 | + )); |
| 172 | + } |
| 173 | + } |
| 174 | + |
| 175 | + let store = open_store(json); |
| 176 | + match store.read(task_id) { |
| 177 | + Ok(content) => { |
| 178 | + if json { |
| 179 | + json_output(json!({ |
| 180 | + "task_id": task_id, |
| 181 | + "content": content, |
| 182 | + })); |
| 183 | + } else { |
| 184 | + print!("{content}"); |
| 185 | + } |
| 186 | + } |
| 187 | + Err(e) => { |
| 188 | + if json { |
| 189 | + json_output(json!({"error": format!("read failed: {e}")})); |
| 190 | + std::process::exit(1); |
| 191 | + } else { |
| 192 | + error_exit(&format!("read failed: {e}")); |
| 193 | + } |
| 194 | + } |
| 195 | + } |
| 196 | +} |
0 commit comments