cellstate/entity/
artifact.rs

1//! `cellstate artifact` — Manage artifacts (structured data produced or consumed by agents).
2//!
3//! Subcommands: create, list, get, update, delete, search.
4
5use anyhow::{anyhow, Result};
6use clap::{Arg, ArgMatches, Command};
7
8use cellstate_core::api_types::{
9    ArtifactResponse, CreateArtifactRequest, ListArtifactsResponse, SearchRequest, SearchResponse,
10    UpdateArtifactRequest,
11};
12use cellstate_core::{ArtifactType, EntityType, ExtractionMethod, TTL};
13
14use super::client::ApiClient;
15use super::output::OutputConfig;
16use super::require_arg;
17
18const BASE: &str = "/api/v1/artifacts";
19
20pub fn build_command() -> Command {
21    Command::new("artifact")
22        .about("Manage artifacts (structured data produced or consumed by agents)")
23        .subcommand_required(true)
24        .subcommand(
25            Command::new("create")
26                .about("Create a new artifact")
27                .arg(Arg::new("name").required(true).help("Artifact name"))
28                .arg(
29                    Arg::new("content")
30                        .required(true)
31                        .help("Artifact content"),
32                )
33                .arg(
34                    Arg::new("trajectory-id")
35                        .long("trajectory-id")
36                        .help("Trajectory ID (defaults to session trajectory)"),
37                )
38                .arg(
39                    Arg::new("scope-id")
40                        .long("scope-id")
41                        .help("Scope ID (defaults to session scope)"),
42                )
43                .arg(
44                    Arg::new("artifact-type")
45                        .long("type")
46                        .default_value("fact")
47                        .help("Artifact type (fact, code, document, data, model, config, log, summary, decision, plan, error_log, code_patch, design_decision, user_preference, constraint, tool_result, intermediate_output, custom, audio, image, video, transcript, screenshot)"),
48                )
49                .arg(
50                    Arg::new("source-turn")
51                        .long("source-turn")
52                        .default_value("0")
53                        .help("Source turn number"),
54                )
55                .arg(
56                    Arg::new("extraction-method")
57                        .long("extraction-method")
58                        .default_value("explicit")
59                        .help("Extraction method (explicit, inferred, user_provided, llm_extraction, tool_extraction, memory_recall, external_api, unknown)"),
60                )
61                .arg(
62                    Arg::new("confidence")
63                        .long("confidence")
64                        .help("Confidence score (0.0-1.0)"),
65                )
66                .arg(
67                    Arg::new("ttl")
68                        .long("ttl")
69                        .default_value("persistent")
70                        .help("Time-to-live (persistent, session, scope, ephemeral, short_term, medium_term, long_term)"),
71                ),
72        )
73        .subcommand(
74            Command::new("list")
75                .about("List artifacts")
76                .arg(
77                    Arg::new("artifact-type")
78                        .long("type")
79                        .help("Filter by artifact type"),
80                )
81                .arg(
82                    Arg::new("trajectory-id")
83                        .long("trajectory-id")
84                        .help("Filter by trajectory"),
85                )
86                .arg(
87                    Arg::new("scope-id")
88                        .long("scope-id")
89                        .help("Filter by scope"),
90                )
91                .arg(
92                    Arg::new("limit")
93                        .long("limit")
94                        .default_value("20")
95                        .help("Max results"),
96                )
97                .arg(
98                    Arg::new("offset")
99                        .long("offset")
100                        .default_value("0")
101                        .help("Offset"),
102                ),
103        )
104        .subcommand(
105            Command::new("get")
106                .about("Get artifact details")
107                .arg(Arg::new("id").required(true).help("Artifact ID")),
108        )
109        .subcommand(
110            Command::new("update")
111                .about("Update an artifact")
112                .arg(Arg::new("id").required(true).help("Artifact ID"))
113                .arg(Arg::new("name").long("name").help("New name"))
114                .arg(
115                    Arg::new("content")
116                        .long("content")
117                        .help("New content"),
118                )
119                .arg(
120                    Arg::new("artifact-type")
121                        .long("type")
122                        .help("New artifact type"),
123                )
124                .arg(
125                    Arg::new("ttl")
126                        .long("ttl")
127                        .help("New TTL (persistent, session, scope, ephemeral, short_term, medium_term, long_term)"),
128                ),
129        )
130        .subcommand(
131            Command::new("delete")
132                .about("Delete an artifact")
133                .arg(Arg::new("id").required(true).help("Artifact ID")),
134        )
135        .subcommand(
136            Command::new("search")
137                .about("Search artifacts by content")
138                .arg(
139                    Arg::new("query")
140                        .required(true)
141                        .help("Search query text"),
142                )
143                .arg(
144                    Arg::new("limit")
145                        .long("limit")
146                        .default_value("10")
147                        .help("Max results"),
148                )
149                .arg(
150                    Arg::new("vector")
151                        .long("vector")
152                        .action(clap::ArgAction::SetTrue)
153                        .help("Use vector (semantic) search instead of keyword"),
154                ),
155        )
156}
157
158pub async fn dispatch(
159    matches: &ArgMatches,
160    client: &ApiClient,
161    output: &OutputConfig,
162    session: &crate::session::CliSession,
163) -> Result<()> {
164    match matches.subcommand() {
165        Some(("create", sub)) => {
166            let artifact_type: ArtifactType = sub
167                .get_one::<String>("artifact-type")
168                .unwrap()
169                .parse()
170                .map_err(|e: String| anyhow!(e))?;
171            let extraction_method: ExtractionMethod = sub
172                .get_one::<String>("extraction-method")
173                .unwrap()
174                .parse()
175                .map_err(|e: String| anyhow!(e))?;
176            let ttl = parse_ttl(sub.get_one::<String>("ttl").unwrap())?;
177            let source_turn: i32 = sub.get_one::<String>("source-turn").unwrap().parse()?;
178            let confidence: Option<f32> = sub
179                .get_one::<String>("confidence")
180                .map(|s| s.parse())
181                .transpose()?;
182
183            let req = CreateArtifactRequest {
184                trajectory_id: require_arg(
185                    sub,
186                    "trajectory-id",
187                    session.trajectory_id.as_deref(),
188                    "trajectory",
189                )?
190                .parse()?,
191                scope_id: require_arg(sub, "scope-id", session.scope_id.as_deref(), "scope")?
192                    .parse()?,
193                artifact_type,
194                name: sub.get_one::<String>("name").unwrap().clone(),
195                content: sub.get_one::<String>("content").unwrap().clone(),
196                source_turn,
197                extraction_method,
198                confidence,
199                ttl,
200                metadata: None,
201            };
202            let resp: ArtifactResponse = client.post(BASE, &req).await?;
203            output.print(&resp);
204        }
205        Some(("list", sub)) => {
206            let limit: i64 = sub.get_one::<String>("limit").unwrap().parse()?;
207            let offset: i64 = sub.get_one::<String>("offset").unwrap().parse()?;
208            let mut path = format!("{BASE}?limit={limit}&offset={offset}");
209            if let Some(artifact_type) = sub.get_one::<String>("artifact-type") {
210                path.push_str(&format!("&artifact_type={artifact_type}"));
211            }
212            if let Some(tid) =
213                super::resolve_arg(sub, "trajectory-id", session.trajectory_id.as_deref())
214            {
215                path.push_str(&format!("&trajectory_id={tid}"));
216            }
217            if let Some(sid) = super::resolve_arg(sub, "scope-id", session.scope_id.as_deref()) {
218                path.push_str(&format!("&scope_id={sid}"));
219            }
220            let resp: ListArtifactsResponse = client.get(&path).await?;
221            output.print_list(&resp.artifacts, resp.total);
222        }
223        Some(("get", sub)) => {
224            let id = sub.get_one::<String>("id").unwrap();
225            let resp: ArtifactResponse = client.get(&format!("{BASE}/{id}")).await?;
226            output.print(&resp);
227        }
228        Some(("update", sub)) => {
229            let id = sub.get_one::<String>("id").unwrap();
230            let req = UpdateArtifactRequest {
231                name: sub.get_one::<String>("name").cloned(),
232                content: sub.get_one::<String>("content").cloned(),
233                artifact_type: sub
234                    .get_one::<String>("artifact-type")
235                    .map(|s| s.parse().map_err(|e: String| anyhow!(e)))
236                    .transpose()?,
237                ttl: sub
238                    .get_one::<String>("ttl")
239                    .map(|s| parse_ttl(s))
240                    .transpose()?,
241                metadata: None,
242            };
243            let resp: ArtifactResponse = client.patch(&format!("{BASE}/{id}"), &req).await?;
244            output.print(&resp);
245        }
246        Some(("delete", sub)) => {
247            let id = sub.get_one::<String>("id").unwrap();
248            client.delete(&format!("{BASE}/{id}")).await?;
249            if output.json {
250                println!(r#"{{"deleted": "{id}"}}"#);
251            } else {
252                println!("Deleted artifact {id}");
253            }
254        }
255        Some(("search", sub)) => {
256            let query = sub.get_one::<String>("query").unwrap().clone();
257            let limit: i64 = sub.get_one::<String>("limit").unwrap().parse()?;
258            let use_vector = sub.get_flag("vector");
259            let req = SearchRequest {
260                query,
261                entity_types: vec![EntityType::Artifact],
262                filters: vec![],
263                limit: Some(limit),
264                use_vector_search: if use_vector { Some(true) } else { None },
265            };
266            let resp: SearchResponse = client.post(&format!("{BASE}/search"), &req).await?;
267            output.print_list(&resp.results, resp.total);
268        }
269        _ => unreachable!("subcommand_required(true) prevents this"),
270    }
271    Ok(())
272}
273
274/// Parse a TTL string into the TTL enum.
275///
276/// Handles the named variants; Duration and Max are not exposed via CLI
277/// (use the API directly for those).
278fn parse_ttl(s: &str) -> Result<TTL> {
279    match s.to_lowercase().as_str() {
280        "persistent" | "permanent" => Ok(TTL::Persistent),
281        "session" => Ok(TTL::Session),
282        "scope" => Ok(TTL::Scope),
283        "ephemeral" => Ok(TTL::Ephemeral),
284        "short_term" | "short-term" => Ok(TTL::ShortTerm),
285        "medium_term" | "medium-term" => Ok(TTL::MediumTerm),
286        "long_term" | "long-term" => Ok(TTL::LongTerm),
287        _ => anyhow::bail!(
288            "invalid TTL: '{s}' (try: persistent, session, scope, ephemeral, short_term, medium_term, long_term)"
289        ),
290    }
291}