From bea5e8eccc6284bb8706d1a297522801a6e1e376 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 7 Nov 2026 10:43:36 +0100 Subject: [PATCH] huginn CLI: persona get and persona put persona get prints the stored gamecult.persona_state.v0 document, then its updatedAt and receipt (absent, exit 1, until the first put). persona put reads the whole document from a file or stdin and prints Committed , or the refusal (a stale put names the stored updatedAt), exit 1; a document that is not one JSON object is a usage error and sends nothing. Provenance tool is huginn. The eureka-state MCP's last unique capability is now in the CLI. Co-Authored-By: Claude Sonnet 5.5 --- crates/eureka-state/src/bin/huginn/main.rs | 78 +++++++++++++++++- crates/eureka-state/tests/cli.rs | 92 +++++++++++++++++++++- 2 files changed, 167 insertions(+), 3 deletions(-) diff --git a/crates/eureka-state/src/bin/huginn/main.rs b/crates/eureka-state/src/bin/huginn/main.rs index 635d76c..a8696ae 100644 --- a/crates/eureka-state/src/bin/huginn/main.rs +++ b/crates/eureka-state/src/bin/huginn/main.rs @@ -35,7 +35,7 @@ use huginn_mind::eureka_pipeline::{ }; use huginn_mind::{ BATCH_MAX, Faculty, HuginnMindRequest, HuginnMindResponse, MindRefusal, PipelineAdmissionBatch, PipelineAdmissionOutcome, - PipelinePageItems, PipelineProvenance, PipelineSelectionPage, SemanticQuery, SourceQuery, + PersonaPutOutcome, PipelinePageItems, PipelineProvenance, PipelineSelectionPage, SemanticQuery, SourceQuery, }; use serde_json::Value; @@ -55,7 +55,7 @@ Every key the schema does not know is refused (exit 2) with its path; nothing is After Committed or AlreadyAdmitted the batch is read back and compared with what was sent: `read back equal`, or exit 5 with the ids and field paths that differ."; #[derive(Parser, Debug)] -#[command(name = "huginn", about = "Huginn's mind: whoami, view, query, recipes, admit, schema", arg_required_else_help = true)] +#[command(name = "huginn", about = "Huginn's mind: whoami, view, query, recipes, persona, admit, schema", arg_required_else_help = true)] struct Cli { #[command(subcommand)] command: Command, @@ -119,6 +119,33 @@ enum Command { }, /// The JSON Schema of a document kind or of a selection; no name lists them. Schema { name: Option }, + /// The mind's persona document (gamecult.persona_state.v0). + Persona { + #[command(subcommand)] + command: PersonaCommand, + }, +} + +#[derive(Subcommand, Debug)] +enum PersonaCommand { + /// The stored document with its updatedAt and receipt; `absent` (exit 1) until the first put. + Get, + /// Replace the persona document whole. + Put { + #[arg(long, value_parser = faculty)] + faculty: Faculty, + /// The writing agent's name. + #[arg(long)] + agent: String, + /// The writing session. + #[arg(long)] + session: String, + /// The stored updatedAt this put replaces; omit for the first put. + #[arg(long)] + expected_updated_at: Option, + /// The whole document file, or - for stdin. + file: String, + }, } fn faculty(text: &str) -> Result { @@ -200,6 +227,10 @@ fn run(settings: &Settings, command: Command) -> Result { query(settings, door::selection_value(&ask.selection)?, ask.semantic, ask.page.cursor, ask.page.documents) } Command::Admit { faculty, agent, session, file } => admit(settings, faculty, agent, session, &file), + Command::Persona { command: PersonaCommand::Get } => persona_get(settings), + Command::Persona { command: PersonaCommand::Put { faculty, agent, session, expected_updated_at, file } } => { + persona_put(settings, faculty, agent, session, expected_updated_at, &file) + } Command::Schema { name } => { let text = schema::text(name.as_deref())?; let _ = write!(std::io::stdout(), "{text}"); @@ -499,6 +530,49 @@ fn refused(refusal: &MindRefusal) -> u8 { 1 } +fn persona_get(settings: &Settings) -> Result { + match settings.client()?.persona_get().map_err(Trouble::from)? { + HuginnMindResponse::Persona(Some(view)) => { + say(serde_json::to_string_pretty(&view.value).map_err(|error| Trouble::Internal(error.to_string()))?); + say(format_args!("updatedAt {} receipt {}", view.updated_at, view.receipt_id)); + Ok(0) + } + HuginnMindResponse::Persona(None) => { + say("absent"); + Ok(1) + } + HuginnMindResponse::Refused(refusal) => Ok(refused(&refusal)), + other => Err(unexpected(&other)), + } +} + +fn persona_put( + settings: &Settings, + faculty: Faculty, + agent: String, + session: String, + expected_updated_at: Option, + file: &str, +) -> Result { + let text = door::file_or_stdin(file)?; + let state: Value = serde_json::from_str(&text) + .map_err(|error| Trouble::invalid(format!("the persona document is not JSON: {error}")))?; + if !state.is_object() { + return Err(Trouble::invalid("the persona document is one JSON object, the whole gamecult.persona_state.v0")); + } + let provenance = PipelineProvenance { faculty, agent: Short(agent), session: Short(session), tool: Short(TOOL.to_string()) }; + match settings.client()?.persona_put(provenance, state, expected_updated_at).map_err(Trouble::from)? { + HuginnMindResponse::PersonaPut(PersonaPutOutcome::Committed { receipt_id }) => { + say(format_args!("Committed {receipt_id}")); + Ok(0) + } + HuginnMindResponse::PersonaPut(PersonaPutOutcome::Refused(refusal)) | HuginnMindResponse::Refused(refusal) => { + Ok(refused(&refusal)) + } + other => Err(unexpected(&other)), + } +} + fn unexpected(answer: &HuginnMindResponse) -> Trouble { let name = match answer { HuginnMindResponse::Whoami(_) => "whoami", diff --git a/crates/eureka-state/tests/cli.rs b/crates/eureka-state/tests/cli.rs index 2166621..ac2c737 100644 --- a/crates/eureka-state/tests/cli.rs +++ b/crates/eureka-state/tests/cli.rs @@ -19,7 +19,7 @@ use huginn_mind::eureka_pipeline::{ }; use huginn_mind::{ AdmissionFacts, HuginnMindRequest, HuginnMindResponse, IndexStatus, MindRefusal, MindStatus, PersonaStatus, PipelineAdmissionOutcome, PipelineDocumentView, - PipelinePageItems, PipelineProvenance, PipelineSelectionPage, PipelineStatus, + PersonaPutOutcome, PipelinePageItems, PipelineProvenance, PipelineSelectionPage, PipelineStatus, }; use huginn_daemon::{Daemon, Hits, IndexSink, SearchTicket}; use huginn_mind::MindStore; @@ -1375,3 +1375,93 @@ fn sources_refuses_naming_both_models_when_the_daemon_embeds_under_another() { assert_eq!(refused.code, 1, "{}", refused.err); assert!(refused.out.contains("d1") && refused.out.contains("d2"), "{}", refused.out); } + +/// A scripted daemon that records each request and answers every one with `response`. +fn replying(operation: &'static str, response: HuginnMindResponse) -> (Server, Arc>>) { + let seen = Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&seen); + let server = scripted(move |message| { + let (_, request) = decode_request(message).expect("a mind request"); + sink.lock().unwrap().push(request); + answer(operation, &response) + }); + (server, seen) +} + +fn persona_put_args<'a>(file: &'a str, expected: Option<&'a str>) -> Vec<&'a str> { + let mut args = vec!["persona", "put", "--faculty", "SelfFaculty", "--agent", "claude", "--session", "session-7"]; + if let Some(expected) = expected { + args.extend(["--expected-updated-at", expected]); + } + args.push(file); + args +} + +#[test] +fn the_persona_commands_round_trip_against_a_live_daemon() { + const T1: &str = "2026-10-10T10:00:00Z"; + const T2: &str = "2026-10-10T10:00:01Z"; + let (root, daemon) = mind(vec![]); + let server = serve(root, daemon); + let dir = tempfile::tempdir().unwrap(); + let first = write(&dir, "first.json", &persona(INSTANCE, T1)); + let second = write(&dir, "second.json", &persona(INSTANCE, T2)); + + let ran = at(server.addr, &["persona", "get"], None); + assert_eq!((ran.code, ran.out.as_str(), ran.err.as_str()), (1, "absent\n", "")); + + let ran = at(server.addr, &persona_put_args(&first, None), None); + assert_eq!(ran.code, 0, "{}", ran.err); + let receipt = ran.out.strip_prefix("Committed ").and_then(|rest| rest.strip_suffix('\n')).expect(&ran.out).to_string(); + + let ran = at(server.addr, &["persona", "get"], None); + assert_eq!(ran.code, 0, "{}", ran.err); + assert!(ran.out.ends_with(&format!("\nupdatedAt {T1} receipt {receipt}\n")), "{}", ran.out); + let (document, _) = ran.out.rsplit_once("\nupdatedAt").unwrap(); + assert_eq!(serde_json::from_str::(document).unwrap(), persona(INSTANCE, T1)); + + // A put that did not read the stored document is refused, names the stored updatedAt, and writes nothing. + let stale = at(server.addr, &persona_put_args(&second, None), None); + assert_eq!(stale.code, 1, "{}", stale.err); + assert!(stale.out.starts_with("Refused PersonaStale") && stale.out.contains(T1), "{}", stale.out); + let ran = at(server.addr, &["persona", "get"], None); + assert!(ran.out.contains(&format!("updatedAt {T1} receipt {receipt}")), "{}", ran.out); + + // The same document read from stdin, with the stored updatedAt, commits. + let ran = at(server.addr, &persona_put_args("-", Some(T1)), Some(&persona(INSTANCE, T2).to_string())); + assert_eq!(ran.code, 0, "{} {}", ran.out, ran.err); + let ran = at(server.addr, &["persona", "get"], None); + assert!(ran.out.contains(&format!("updatedAt {T2} receipt ")), "{}", ran.out); +} + +#[test] +fn persona_put_sends_the_whole_document_under_the_huginn_tool() { + let (server, seen) = replying("persona_put", HuginnMindResponse::PersonaPut(PersonaPutOutcome::Committed { receipt_id: "mind-commit-p".into() })); + let dir = tempfile::tempdir().unwrap(); + let file = write(&dir, "persona.json", &persona(INSTANCE, "2026-10-10T10:00:00Z")); + let ran = at(server.addr, &persona_put_args(&file, Some("2026-10-09T00:00:00Z")), None); + assert_eq!((ran.code, ran.out.as_str()), (0, "Committed mind-commit-p\n"), "{}", ran.err); + let seen = seen.lock().unwrap(); + let [HuginnMindRequest::PersonaPut { instance, provenance, state, expected_updated_at }] = &seen[..] else { panic!("{seen:?}") }; + assert_eq!(instance.0, INSTANCE); + assert_eq!( + (provenance.faculty, provenance.agent.0.as_str(), provenance.session.0.as_str(), provenance.tool.0.as_str()), + (huginn_mind::Faculty::SelfFaculty, "claude", "session-7", "huginn") + ); + assert_eq!(state, &persona(INSTANCE, "2026-10-10T10:00:00Z")); + assert_eq!(expected_updated_at.as_deref(), Some("2026-10-09T00:00:00Z")); +} + +#[test] +fn a_persona_that_is_not_one_object_is_a_usage_error_and_sends_nothing() { + let (server, seen) = replying("persona_put", HuginnMindResponse::PersonaPut(PersonaPutOutcome::Committed { receipt_id: "x".into() })); + let dir = tempfile::tempdir().unwrap(); + let array = write(&dir, "array.json", &json!([persona(INSTANCE, "2026-10-10T10:00:00Z")])); + let ran = at(server.addr, &persona_put_args(&array, None), None); + assert_eq!(ran.code, 2, "{}", ran.out); + assert!(ran.err.starts_with("InvalidInput: "), "{}", ran.err); + let ran = at(server.addr, &persona_put_args("-", None), Some("not json")); + assert_eq!(ran.code, 2, "{}", ran.out); + assert!(ran.err.starts_with("InvalidInput: "), "{}", ran.err); + assert!(seen.lock().unwrap().is_empty()); +}