1use 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
274fn 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}