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