1#![forbid(unsafe_code)]
2
3pub mod agent_skill;
4pub mod archive;
5mod archive_broker;
6mod archive_client;
7mod archive_protocol;
8pub mod archive_query;
9mod archive_runtime;
10pub mod config;
11mod config_cli;
12pub mod reducers;
13mod result_reducer;
14pub mod rewrite;
15pub mod rule_pack;
16pub mod rules;
17mod rules_cli;
18pub mod runner;
19pub mod shell;
20
21use rewrite::ArchiveCommandRef;
22use std::io;
23use std::io::Write as _;
24use time::OffsetDateTime;
25use time::format_description::well_known::Rfc3339;
26
27const HELP: &str = "YARP prunes developer command output and archives Pi tool calls.\n\nUsage:\n yarp plan --json [--rule-pack <path>]... <shell-command>\n yarp rewrite [--rule-pack <path>]... <shell-command>\n yarp run [--rule-pack <path>]... -- <command> [arguments...]\n yarp config <path|init|show|get|set|unset|check>\n yarp rules check <source-pack>\n yarp rules compile <source-pack> --output <compiled-pack>\n yarp rules verify <compiled-pack>\n yarp rules list [--rule-pack <path>]... [--json]\n yarp rules explain [--rule-pack <path>]... [--json] -- <command> [arguments...]\n yarp search REF PATTERN [options]\n yarp read REF [SOURCE] START:END\n yarp read REF SOURCE --bytes START:END\n yarp archive stats\n yarp archive verify\n yarp archive prune --before <UTC timestamp>\n yarp archive ingest\n yarp archive broker\n yarp --skill list\n yarp --skill show yarp\n yarp --skill export yarp\n yarp --help\n yarp --version\n";
28
29#[must_use]
31pub fn run_cli(arguments: &[String]) -> i32 {
32 match arguments {
33 [] => {
34 print!("{HELP}");
35 0
36 }
37 [argument] if argument == "--help" || argument == "-h" => {
38 print!("{HELP}");
39 0
40 }
41 [argument] if argument == "--version" || argument == "-V" => {
42 println!("yarp {}", env!("CARGO_PKG_VERSION"));
43 0
44 }
45 [command, format, rest @ ..] if command == "plan" && format == "--json" => {
46 match parse_rewrite(rest) {
47 Ok((packs, reference, shell_command)) => plan(shell_command, reference, &packs),
48 Err(error) => usage_error(&error),
49 }
50 }
51 [command, rest @ ..] if command == "rewrite" => match parse_rewrite(rest) {
52 Ok((packs, reference, shell_command)) => rewrite(shell_command, reference, &packs),
53 Err(error) => usage_error(&error),
54 },
55 [command, rest @ ..] if command == "run" => match parse_run(rest) {
56 Ok((key, child, packs, expected)) => {
57 match runner::run_with_rules(child, key.as_ref(), &packs, expected.as_ref()) {
58 Ok(code) => code,
59 Err(error) => usage_error(&error),
60 }
61 }
62 Err(error) => usage_error(&error),
63 },
64 [command, rest @ ..] if command == "config" => config_cli::run(rest),
65 [command, rest @ ..] if command == "rules" => rules_cli::run(rest),
66 [command, rest @ ..] if command == "search" => archive_search(rest),
67 [command, rest @ ..] if command == "read" => archive_read(rest),
68 [command] if command == "result-reduce" => {
69 match result_reducer::run(io::stdin().lock(), io::stdout().lock()) {
70 Ok(()) => 0,
71 Err(error) => {
72 eprintln!("yarp: {error}");
73 65
74 }
75 }
76 }
77 [command, subcommand] if command == "archive" && subcommand == "stats" => archive_stats(),
78 [command, subcommand] if command == "archive" && subcommand == "verify" => archive_verify(),
79 [command, subcommand] if command == "archive" && subcommand == "ingest" => {
80 match archive::run_ingest(io::stdin().lock(), io::stdout().lock()) {
81 Ok(()) => 0,
82 Err(error) => {
83 eprintln!("yarp: {error}");
84 74
85 }
86 }
87 }
88 [command, subcommand] if command == "archive" && subcommand == "broker" => {
89 match archive_broker::run() {
90 Ok(()) => 0,
91 Err(error) => {
92 eprintln!("yarp: {error}");
93 74
94 }
95 }
96 }
97 [command, subcommand, rest @ ..] if command == "archive" && subcommand == "restore" => {
98 archive_restore(rest)
99 }
100 [command, subcommand, option, timestamp]
101 if command == "archive" && subcommand == "prune" && option == "--before" =>
102 {
103 archive_prune(timestamp)
104 }
105 _ => usage_error("invalid arguments"),
106 }
107}
108
109fn plan(
110 shell_command: &str,
111 reference: Option<ArchiveCommandRef<'_>>,
112 packs: &[rules::PackRequest],
113) -> i32 {
114 match rewrite::plan_with_options(shell_command, reference, packs)
115 .and_then(|plan| serde_json::to_string(&plan).map_err(|error| error.to_string()))
116 {
117 Ok(plan) => {
118 println!("{plan}");
119 0
120 }
121 Err(error) => {
122 eprintln!("yarp: {error}");
123 3
124 }
125 }
126}
127
128fn rewrite(
129 shell_command: &str,
130 reference: Option<ArchiveCommandRef<'_>>,
131 packs: &[rules::PackRequest],
132) -> i32 {
133 match rewrite::rewrite_with_options(shell_command, reference, packs) {
134 Ok(Some(rewritten)) => {
135 print!("{rewritten}");
136 0
137 }
138 Ok(None) => 3,
139 Err(error) => {
140 eprintln!("yarp: {error}");
141 3
142 }
143 }
144}
145
146fn archive_search(arguments: &[String]) -> i32 {
147 match archive_query::search(arguments) {
148 Ok(archive_query::SearchOutcome::Matches(output)) => {
149 if let Err(error) = io::stdout().write_all(&output) {
150 write_recovery_error(&format!("could not write search output: {error}"));
151 return 74;
152 }
153 0
154 }
155 Ok(archive_query::SearchOutcome::NoMatches(output)) => {
156 if let Err(error) = io::stdout().write_all(&output) {
157 write_recovery_error(&format!("could not write search output: {error}"));
158 return 74;
159 }
160 1
161 }
162 Err(error) => {
163 write_recovery_error(&error);
164 65
165 }
166 }
167}
168
169fn archive_read(arguments: &[String]) -> i32 {
170 match archive_query::read(arguments) {
171 Ok(output) => match io::stdout().write_all(&output) {
172 Ok(()) => 0,
173 Err(error) => {
174 write_recovery_error(&format!("could not write exact archive range: {error}"));
175 74
176 }
177 },
178 Err(error) => {
179 write_recovery_error(&error);
180 65
181 }
182 }
183}
184
185fn write_recovery_error(error: &str) {
186 const PREFIX_BYTES: usize = "yarp: ".len();
187 const NEWLINE_BYTES: usize = 1;
188 const ELLIPSIS: &str = "...";
189
190 let max_bytes = config::load().map_or(config::MIN_RECOVERY_CAP_BYTES, |resolved| {
191 resolved.output.recovery_cap_bytes
192 });
193 let body_budget = max_bytes.saturating_sub(PREFIX_BYTES + NEWLINE_BYTES);
194 let mut body = String::with_capacity(body_budget.min(error.len()));
195 let mut truncated = false;
196 for character in error.chars() {
197 let character = if character.is_control() {
198 ' '
199 } else {
200 character
201 };
202 if body.len().saturating_add(character.len_utf8()) > body_budget {
203 truncated = true;
204 break;
205 }
206 body.push(character);
207 }
208 if truncated && body_budget >= ELLIPSIS.len() {
209 while body.len().saturating_add(ELLIPSIS.len()) > body_budget {
210 body.pop();
211 }
212 body.push_str(ELLIPSIS);
213 }
214 eprintln!("yarp: {body}");
215}
216
217fn archive_stats() -> i32 {
218 match archive::Archive::open_read_only().and_then(|archive| archive.stats()) {
219 Ok(stats) => {
220 println!("sessions: {}", stats.sessions);
221 println!("calls: {}", stats.calls);
222 println!("incomplete_calls: {}", stats.incomplete_calls);
223 println!("logical_payload_bytes: {}", stats.logical_payload_bytes);
224 println!("stored_payload_bytes: {}", stats.stored_payload_bytes);
225 println!("database_bytes: {}", stats.database_bytes);
226 if let Some(oldest) = stats.oldest_call_ms {
227 println!("oldest_call_ms: {oldest}");
228 }
229 if let Some(newest) = stats.newest_call_ms {
230 println!("newest_call_ms: {newest}");
231 }
232 0
233 }
234 Err(error) => {
235 eprintln!("yarp: {error}");
236 74
237 }
238 }
239}
240
241fn archive_verify() -> i32 {
242 match archive::Archive::open_read_only().and_then(|archive| archive.verify()) {
243 Ok(report) => {
244 println!("incomplete_calls: {}", report.incomplete_calls);
245 if report.errors.is_empty() {
246 println!("archive: ok");
247 0
248 } else {
249 for error in report.errors {
250 eprintln!("yarp: archive verification failed: {error}");
251 }
252 65
253 }
254 }
255 Err(error) => {
256 eprintln!("yarp: {error}");
257 65
258 }
259 }
260}
261
262fn archive_restore(arguments: &[String]) -> i32 {
263 let key = match parse_archive_key(arguments) {
264 Ok(value) => value,
265 Err(error) => return usage_error(&error),
266 };
267 match archive::Archive::open_read_only()
268 .and_then(|archive| archive.restore_streams(&key, io::stdout().lock(), io::stderr().lock()))
269 {
270 Ok(()) => 0,
271 Err(error) => {
272 eprintln!("yarp: {error}");
273 74
274 }
275 }
276}
277
278fn archive_prune(timestamp: &str) -> i32 {
279 let parsed = match OffsetDateTime::parse(timestamp, &Rfc3339) {
280 Ok(value) => value,
281 Err(error) => return usage_error(&format!("invalid UTC timestamp: {error}")),
282 };
283 if parsed.offset() != time::UtcOffset::UTC {
284 return usage_error("prune timestamp must use UTC with a Z suffix");
285 }
286 let Ok(milliseconds) = i64::try_from(parsed.unix_timestamp_nanos() / 1_000_000) else {
287 return usage_error("prune timestamp is outside the supported range");
288 };
289 let operation = archive_protocol::ArchiveOperation::PruneBefore {
290 request_id: 1,
291 schema_version: archive_protocol::INGEST_SCHEMA_VERSION,
292 timestamp_ms: milliseconds,
293 };
294 match archive_client::execute(operation) {
295 Ok(Some(deleted)) => {
296 println!("pruned_calls: {deleted}");
297 0
298 }
299 Ok(None) => {
300 eprintln!("yarp: archive broker returned no prune count");
301 74
302 }
303 Err(error) => {
304 eprintln!("yarp: {error}");
305 74
306 }
307 }
308}
309
310fn parse_rewrite(
311 arguments: &[String],
312) -> Result<(Vec<rules::PackRequest>, Option<ArchiveCommandRef<'_>>, &str), String> {
313 let mut packs = rules::requests_from_config()?;
314 let mut project_root = None;
315 let mut project_pack_seen = false;
316 let mut agent = None;
317 let mut account = None;
318 let mut session = None;
319 let mut call = None;
320 let mut index = 0;
321 while index + 1 < arguments.len() {
322 let value = arguments
323 .get(index + 1)
324 .ok_or_else(|| format!("{} requires a value", arguments[index]))?;
325 match arguments[index].as_str() {
326 "--project-root" if project_root.is_none() => {
327 project_root = Some(std::path::PathBuf::from(value));
328 }
329 "--rule-pack" => {
330 let path = if let Some(root) = &project_root {
331 project_pack_seen = true;
332 rules::canonical_project_pack(root, std::path::Path::new(value))?
333 } else {
334 value.into()
335 };
336 packs.push(rules::PackRequest {
337 path,
338 expected_digest: None,
339 expected_compiled_digest: None,
340 });
341 }
342 "--archive-agent" if agent.is_none() => agent = Some(value.as_str()),
343 "--archive-account" if account.is_none() => account = Some(value.as_str()),
344 "--archive-session" if session.is_none() => session = Some(value.as_str()),
345 "--archive-call" if call.is_none() => call = Some(value.as_str()),
346 value if value.starts_with("--") => {
347 return Err(format!("unknown or duplicate rewrite option: {value}"));
348 }
349 _ => break,
350 }
351 index += 2;
352 }
353 let [shell_command] = &arguments[index..] else {
354 return Err("rewrite requires one shell command".to_owned());
355 };
356 if project_root.is_some() && !project_pack_seen {
357 return Err("--project-root requires a following --rule-pack".to_owned());
358 }
359 let reference = match (agent, account, session, call) {
360 (None, None, None, None) => None,
361 (Some(agent), Some(account), Some(session_id), Some(call_id)) => Some(ArchiveCommandRef {
362 agent,
363 account,
364 session_id,
365 call_id,
366 }),
367 _ => return Err("archived rewrite requires every archive identifier".to_owned()),
368 };
369 Ok((packs, reference, shell_command))
370}
371
372fn parse_archive_key(arguments: &[String]) -> Result<archive::ArchiveKey, String> {
373 let [
374 agent_flag,
375 agent,
376 account_flag,
377 account,
378 session_flag,
379 session,
380 call_flag,
381 call,
382 ] = arguments
383 else {
384 return Err("invalid archive key arguments".to_owned());
385 };
386 if agent_flag != "--archive-agent"
387 || account_flag != "--archive-account"
388 || session_flag != "--archive-session"
389 || call_flag != "--archive-call"
390 {
391 return Err("invalid archive key options".to_owned());
392 }
393 Ok(archive::ArchiveKey {
394 session: archive::SessionIdentity {
395 agent: agent.clone(),
396 account: account.clone(),
397 source_session_id: session.clone(),
398 started_at_ms: None,
399 },
400 source_call_id: call.clone(),
401 })
402}
403
404type ParsedRun<'a> = (
405 Option<archive::ArchiveKey>,
406 &'a [String],
407 Vec<rules::PackRequest>,
408 Option<runner::ExpectedSelection>,
409);
410
411fn parse_run(arguments: &[String]) -> Result<ParsedRun<'_>, String> {
412 let mut packs = Vec::new();
413 let mut selected_pack = None;
414 let mut selected_rule = None;
415 let mut selected_digest = None;
416 let mut agent = None;
417 let mut account = None;
418 let mut session = None;
419 let mut call = None;
420 let mut index = 0;
421 while index < arguments.len() && arguments[index] != "--" {
422 let option = arguments[index].as_str();
423 let value = arguments
424 .get(index + 1)
425 .ok_or_else(|| format!("{option} requires a value"))?;
426 match option {
427 "--selected-pack" if selected_pack.is_none() => selected_pack = Some(value.clone()),
428 "--selected-rule" if selected_rule.is_none() => selected_rule = Some(value.clone()),
429 "--selected-digest" if selected_digest.is_none() => {
430 selected_digest = Some(rules::parse_digest(value)?);
431 }
432 "--archive-agent" if agent.is_none() => agent = Some(value.clone()),
433 "--archive-account" if account.is_none() => account = Some(value.clone()),
434 "--archive-session" if session.is_none() => session = Some(value.clone()),
435 "--archive-call" if call.is_none() => call = Some(value.clone()),
436 "--rule-pack" => {
437 let (request, next_index) = parse_run_pack(arguments, index, value)?;
438 packs.push(request);
439 index = next_index;
440 continue;
441 }
442 value if value.starts_with("--") => {
443 return Err(format!("unknown or duplicate run option: {value}"));
444 }
445 _ => return Err(format!("invalid run option: {option}")),
446 }
447 index += 2;
448 }
449 let Some(separator) = arguments.get(index) else {
450 return Err("run requires -- and a child command".to_owned());
451 };
452 if separator != "--" || index + 1 >= arguments.len() {
453 return Err("run requires -- and a child command".to_owned());
454 }
455 let child = &arguments[index + 1..];
456 let expected = match (selected_pack, selected_rule, selected_digest) {
457 (None, None, None) => None,
458 (Some(pack_id), Some(rule_id), Some(source_digest)) => Some(runner::ExpectedSelection {
459 pack_id,
460 rule_id,
461 source_digest,
462 }),
463 _ => {
464 return Err(
465 "run requires selected pack, selected rule, and selected digest together"
466 .to_owned(),
467 );
468 }
469 };
470 let archive_key = match (agent, account, session, call) {
471 (None, None, None, None) => None,
472 (Some(agent), Some(account), Some(session), Some(call)) => Some(archive::ArchiveKey {
473 session: archive::SessionIdentity {
474 agent,
475 account,
476 source_session_id: session,
477 started_at_ms: None,
478 },
479 source_call_id: call,
480 }),
481 _ => return Err("archived run requires every archive identifier".to_owned()),
482 };
483 if expected.is_none() {
484 let mut configured = rules::requests_from_config()?;
485 configured.extend(packs);
486 packs = configured;
487 }
488 Ok((archive_key, child, packs, expected))
489}
490
491fn parse_run_pack(
492 arguments: &[String],
493 mut index: usize,
494 path: &str,
495) -> Result<(rules::PackRequest, usize), String> {
496 let mut expected_digest = None;
497 let mut expected_compiled_digest = None;
498 index += 2;
499 if arguments
500 .get(index)
501 .is_some_and(|value| value == "--rule-pack-digest")
502 {
503 let digest = arguments
504 .get(index + 1)
505 .ok_or_else(|| "--rule-pack-digest requires a value".to_owned())?;
506 expected_digest = Some(rules::parse_digest(digest)?);
507 index += 2;
508 }
509 if arguments
510 .get(index)
511 .is_some_and(|value| value == "--rule-pack-compiled-digest")
512 {
513 let digest = arguments
514 .get(index + 1)
515 .ok_or_else(|| "--rule-pack-compiled-digest requires a value".to_owned())?;
516 expected_compiled_digest = Some(rules::parse_digest(digest)?);
517 index += 2;
518 }
519 if expected_digest.is_some() != expected_compiled_digest.is_some() {
520 return Err("rule pack source and compiled digests must be supplied together".to_owned());
521 }
522 Ok((
523 rules::PackRequest {
524 path: path.into(),
525 expected_digest,
526 expected_compiled_digest,
527 },
528 index,
529 ))
530}
531
532fn usage_error(error: &str) -> i32 {
533 eprintln!("yarp: {error}\n\n{HELP}");
534 64
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540
541 #[test]
542 fn unsupported_rewrite_fails_open_with_distinct_status() {
543 assert_eq!(run_cli(&["rewrite".into(), "cat .env".into()]), 3);
544 }
545
546 #[test]
547 fn invalid_arguments_return_usage_error() {
548 assert_eq!(run_cli(&["rewrite".into()]), 64);
549 assert_eq!(run_cli(&["unknown".into()]), 64);
550 assert_eq!(run_cli(&["run".into(), "--".into()]), 64);
551 }
552
553 #[test]
554 fn parses_archived_run_metadata() {
555 let arguments = vec![
556 "--archive-agent".into(),
557 "pi".into(),
558 "--archive-account".into(),
559 "onur".into(),
560 "--archive-session".into(),
561 "session".into(),
562 "--archive-call".into(),
563 "call".into(),
564 "--".into(),
565 "git".into(),
566 "status".into(),
567 ];
568 let (key, child, packs, expected) = parse_run(&arguments).expect("parse");
569 let key = key.expect("archive key");
570 assert_eq!(key.session.agent, "pi");
571 assert_eq!(key.source_call_id, "call");
572 assert_eq!(child, &["git".to_owned(), "status".to_owned()]);
573 assert!(packs.is_empty());
574 assert!(expected.is_none());
575 }
576}