| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | | use super::identity::owner_home; |
| 5 | | use super::{call, ui, Ctx}; |
| 6 | | use anyhow::{bail, Context, Result}; |
| 7 | | use spectra_core::keys::KeyPair; |
| 8 | | use spectra_core::ledger::Ledger; |
| 9 | | use spectra_core::principals::signed_event; |
| 10 | | use std::path::PathBuf; |
| 11 | |
|
| 12 | 6× | fn label(project: Option<&str>) -> String { |
| 13 | 6× | project.map(|p| format!("{p}'s brief")).unwrap_or_else(|| "the overall brief".into()) |
| 14 | 6× | } |
| 15 | |
|
| 16 | |
|
| 17 | 2× | async fn current(ctx: &Ctx, project: Option<&str>) -> Result<Option<String>> { |
| 18 | 2× | let home = owner_home(ctx, None); |
| 19 | 2× | if let Some(c) = ctx.owner_client(&home).await? { |
| 20 | 2× | let v = call(&c, "GET", "/api/v1/briefs", None).await?; |
| 21 | 2× | return Ok(match project { |
| 22 | 1× | None => v["overall"].as_str().map(String::from), |
| 23 | 1× | Some(p) => v["projects"] |
| 24 | 1× | .as_array() |
| 25 | 2× | .and_then(|a| a.iter().find(|x| x["project"] == p)) |
| 26 | 1× | .and_then(|x| x["text"].as_str()) |
| 27 | 1× | .map(String::from), |
| 28 | | }); |
| 29 | 0× | } |
| 30 | 0× | let ledger = Ledger::open_read_only(&ctx.db()?).await?; |
| 31 | 0× | spectra_core::rules::layer(ledger.pool(), project).await |
| 32 | 2× | } |
| 33 | |
|
| 34 | 2× | pub async fn show(ctx: &Ctx, project: Option<String>, as_json: bool) -> Result<i32> { |
| 35 | 2× | let text = current(ctx, project.as_deref()).await?; |
| 36 | 2× | if as_json { |
| 37 | 1× | println!("{}", serde_json::json!({ "project": project, "text": text })); |
| 38 | 1× | return Ok(0); |
| 39 | 1× | } |
| 40 | 1× | println!("{}", ui::section(label(project.as_deref()))); |
| 41 | 1× | match text { |
| 42 | 1× | Some(t) => println!("{}", t.trim_end()), |
| 43 | 0× | None => println!("{} {}", ui::note(), ui::dim("not set: spectra brief set [--project P] \"…\", or spectra brief edit")), |
| 44 | | } |
| 45 | 1× | Ok(0) |
| 46 | 2× | } |
| 47 | |
|
| 48 | 6× | pub async fn set(ctx: &Ctx, project: Option<String>, text: String, owner_home_flag: Option<PathBuf>) -> Result<i32> { |
| 49 | 6× | spectra_core::rules::check(&text)?; |
| 50 | 5× | let home = owner_home(ctx, owner_home_flag); |
| 51 | 5× | let w = ctx.owner_writer(&home).await?; |
| 52 | 5× | if let Some(p) = &project { |
| 53 | 3× | let known = match (w.client(), w.ledger()) { |
| 54 | 3× | (Some(c), _) => { |
| 55 | 3× | call(c, "GET", "/api/v1/projects", None).await?.as_array().is_some_and(|l| l.iter().any(|x| x["name"] == p.as_str())) |
| 56 | | } |
| 57 | 0× | (None, Some(l)) => sqlx::query_scalar::<_, String>("SELECT name FROM projects WHERE name = ?") |
| 58 | 0× | .bind(p) |
| 59 | 0× | .fetch_optional(l.pool()) |
| 60 | 0× | .await? |
| 61 | 0× | .is_some(), |
| 62 | 0× | (None, None) => false, |
| 63 | | }; |
| 64 | 3× | if !known { |
| 65 | 0× | bail!("no project named {p} (spectra project list)"); |
| 66 | 3× | } |
| 67 | 2× | } |
| 68 | 5× | let mut events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect(); |
| 69 | 5× | let owner = KeyPair::load_home(&home)?; |
| 70 | 5× | let mut e = signed_event(&owner, "human", "brief.set", serde_json::json!({ "project": project, "text": text })); |
| 71 | 5× | e.project = project.clone(); |
| 72 | 5× | events.push(e); |
| 73 | 5× | w.append(events).await?; |
| 74 | 5× | let verb = if text.trim().is_empty() { "Cleared" } else { "Set" }; |
| 75 | 5× | println!("{} {}", ui::done(verb), ui::subject(label(project.as_deref()))); |
| 76 | 5× | println!(" {} {}", ui::note(), ui::dim("agents are told on their next call that their brief changed")); |
| 77 | 5× | Ok(0) |
| 78 | 6× | } |
| 79 | |
|
| 80 | |
|
| 81 | 6× | pub fn text_of(text: Option<String>, file: Option<PathBuf>) -> Result<String> { |
| 82 | 6× | match (text, file) { |
| 83 | 6× | (Some(t), None) => Ok(t), |
| 84 | 0× | (None, Some(f)) if f.as_os_str() == "-" => { |
| 85 | 0× | let mut s = String::new(); |
| 86 | 0× | std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?; |
| 87 | 0× | Ok(s) |
| 88 | | } |
| 89 | 0× | (None, Some(f)) => std::fs::read_to_string(&f).with_context(|| format!("reading {}", f.display())), |
| 90 | 0× | (Some(_), Some(_)) => bail!("give the text or --file, not both"), |
| 91 | 0× | (None, None) => bail!("give the text, or --file FILE (- for standard input), or use spectra brief edit"), |
| 92 | | } |
| 93 | 6× | } |
| 94 | |
|
| 95 | |
|
| 96 | 0× | pub async fn edit(ctx: &Ctx, project: Option<String>, owner_home_flag: Option<PathBuf>) -> Result<i32> { |
| 97 | 0× | let before = current(ctx, project.as_deref()).await?.unwrap_or_default(); |
| 98 | 0× | let dir = tempfile::tempdir()?; |
| 99 | 0× | let path = dir.path().join(format!("{}.md", project.as_deref().unwrap_or("overall"))); |
| 100 | 0× | std::fs::write(&path, &before)?; |
| 101 | 0× | let editor = std::env::var("VISUAL").or_else(|_| std::env::var("EDITOR")).unwrap_or_else(|_| "nano".into()); |
| 102 | 0× | let mut parts = editor.split_whitespace(); |
| 103 | 0× | let program = parts.next().context("no editor ($VISUAL or $EDITOR)")?; |
| 104 | 0× | let status = std::process::Command::new(program).args(parts).arg(&path).status().with_context(|| format!("running {editor}"))?; |
| 105 | 0× | if !status.success() { |
| 106 | 0× | bail!("{editor} did not finish cleanly: nothing was changed"); |
| 107 | 0× | } |
| 108 | 0× | let after = std::fs::read_to_string(&path)?; |
| 109 | 0× | if after == before { |
| 110 | 0× | println!("{} {}", ui::note(), ui::dim("unchanged")); |
| 111 | 0× | return Ok(0); |
| 112 | 0× | } |
| 113 | 0× | set(ctx, project, after, owner_home_flag).await |
| 114 | 0× | } |