1use clap::{Arg, ArgAction, Command, crate_name, value_parser};
2
3use jacs::agent::Agent;
4use jacs::agent::boilerplate::BoilerPlate;
5use jacs::agent::document::DocumentTraits;
6use jacs::cli_utils::create::{
7 handle_agent_create, handle_agent_create_auto, handle_config_create,
8};
9use jacs::cli_utils::default_set_file_list;
10use jacs::cli_utils::document::{
11 check_agreement, create_agreement, create_documents, extract_documents, sign_documents,
12 update_documents, verify_documents,
13};
14use jacs::create_task; use jacs::dns::bootstrap as dns_bootstrap;
16use jacs::shutdown::{ShutdownGuard, install_signal_handler};
17
18use rpassword::read_password;
19use std::env;
20use std::error::Error;
21use std::path::Path;
22use std::process;
23
24const CLI_PASSWORD_FILE_ENV: &str = "JACS_PASSWORD_FILE";
25const DEFAULT_LEGACY_PASSWORD_FILE: &str = "./jacs_keys/.jacs_password";
26
27fn quickstart_password_bootstrap_help() -> &'static str {
28 "Password bootstrap options (prefer exactly one explicit source):
29 1) Direct env (recommended):
30 export JACS_PRIVATE_KEY_PASSWORD='your-strong-password'
31 2) Export from a secret file:
32 export JACS_PRIVATE_KEY_PASSWORD=\"$(cat /path/to/password)\"
33 3) CLI convenience (file path):
34 export JACS_PASSWORD_FILE=/path/to/password
35If both JACS_PRIVATE_KEY_PASSWORD and JACS_PASSWORD_FILE are set, CLI warns and uses JACS_PRIVATE_KEY_PASSWORD.
36If neither is set, CLI will try legacy ./jacs_keys/.jacs_password when present."
37}
38
39fn read_password_from_file(path: &Path, source_name: &str) -> Result<String, String> {
40 #[cfg(unix)]
42 {
43 use std::os::unix::fs::PermissionsExt;
44
45 let metadata = std::fs::metadata(path)
46 .map_err(|e| format!("Failed to read {} '{}': {}", source_name, path.display(), e))?;
47 let mode = metadata.permissions().mode() & 0o777;
48 if mode & 0o077 != 0 {
49 return Err(format!(
50 "{} '{}' has insecure permissions (mode {:04o}). \
51 File must not be group-readable or world-readable. \
52 Fix with: chmod 600 '{}'\n\n{}",
53 source_name,
54 path.display(),
55 mode,
56 path.display(),
57 quickstart_password_bootstrap_help()
58 ));
59 }
60 }
61
62 let raw = std::fs::read_to_string(path)
63 .map_err(|e| format!("Failed to read {} '{}': {}", source_name, path.display(), e))?;
64 let password = raw.trim_end_matches(|c| c == '\n' || c == '\r');
66 if password.is_empty() {
67 return Err(format!(
68 "{} '{}' is empty. {}",
69 source_name,
70 path.display(),
71 quickstart_password_bootstrap_help()
72 ));
73 }
74 Ok(password.to_string())
75}
76
77fn get_non_empty_env_var(key: &str) -> Result<Option<String>, String> {
78 match env::var(key) {
79 Ok(value) => {
80 if value.trim().is_empty() {
81 Err(format!(
82 "{} is set but empty. {}",
83 key,
84 quickstart_password_bootstrap_help()
85 ))
86 } else {
87 Ok(Some(value))
88 }
89 }
90 Err(std::env::VarError::NotPresent) => Ok(None),
91 Err(std::env::VarError::NotUnicode(_)) => Err(format!(
92 "{} contains non-UTF-8 data. {}",
93 key,
94 quickstart_password_bootstrap_help()
95 )),
96 }
97}
98
99fn ensure_cli_private_key_password() -> Result<Option<String>, String> {
108 let env_password = get_non_empty_env_var("JACS_PRIVATE_KEY_PASSWORD")?;
109 let password_file = get_non_empty_env_var(CLI_PASSWORD_FILE_ENV)?;
110
111 if let Some(password) = env_password {
116 if password_file.is_some() {
117 eprintln!(
118 "Warning: both JACS_PRIVATE_KEY_PASSWORD and {} are set. \
119 Using JACS_PRIVATE_KEY_PASSWORD (highest priority).",
120 CLI_PASSWORD_FILE_ENV
121 );
122 }
123 unsafe {
125 env::set_var("JACS_PRIVATE_KEY_PASSWORD", &password);
126 }
127 return Ok(Some(password));
128 }
129
130 if let Some(path) = password_file {
132 let password = read_password_from_file(Path::new(path.trim()), CLI_PASSWORD_FILE_ENV)?;
133 unsafe {
135 env::set_var("JACS_PRIVATE_KEY_PASSWORD", &password);
136 }
137 return Ok(Some(password));
138 }
139
140 let legacy_path = Path::new(DEFAULT_LEGACY_PASSWORD_FILE);
142 if legacy_path.exists() {
143 let password = read_password_from_file(legacy_path, "legacy password file")?;
144 unsafe {
146 env::set_var("JACS_PRIVATE_KEY_PASSWORD", &password);
147 }
148 eprintln!(
149 "Using legacy password source '{}'. Prefer JACS_PRIVATE_KEY_PASSWORD or {}.",
150 legacy_path.display(),
151 CLI_PASSWORD_FILE_ENV
152 );
153 #[cfg(feature = "keychain")]
155 {
156 if jacs::keystore::keychain::is_available() {
157 eprintln!(
158 "Warning: A plaintext password file '{}' was found. \
159 Consider migrating to the OS keychain with `jacs keychain set` \
160 and then deleting the password file.",
161 legacy_path.display()
162 );
163 }
164 }
165 return Ok(Some(password));
166 }
167
168 Ok(None)
171}
172
173fn resolve_dns_policy_overrides(
174 ignore_dns: bool,
175 require_strict: bool,
176 require_dns: bool,
177 non_strict: bool,
178) -> (Option<bool>, Option<bool>, Option<bool>) {
179 if ignore_dns {
180 (Some(false), Some(false), Some(false))
181 } else if require_strict {
182 (Some(true), Some(true), Some(true))
183 } else if require_dns {
184 (Some(true), Some(true), Some(false))
185 } else if non_strict {
186 (Some(true), Some(false), Some(false))
187 } else {
188 (None, None, None)
189 }
190}
191
192fn load_agent_with_cli_dns_policy(
193 ignore_dns: bool,
194 require_strict: bool,
195 require_dns: bool,
196 non_strict: bool,
197) -> Result<Agent, Box<dyn Error>> {
198 let (dns_validate, dns_required, dns_strict) =
199 resolve_dns_policy_overrides(ignore_dns, require_strict, require_dns, non_strict);
200 let mut agent = load_agent()?;
201 if let Some(v) = dns_validate {
202 agent.set_dns_validate(v);
203 }
204 if let Some(v) = dns_required {
205 agent.set_dns_required(v);
206 }
207 if let Some(v) = dns_strict {
208 agent.set_dns_strict(v);
209 }
210 Ok(agent)
211}
212
213fn resolve_config_path() -> String {
218 std::env::var("JACS_CONFIG")
219 .ok()
220 .filter(|v| !v.trim().is_empty())
221 .unwrap_or_else(|| "./jacs.config.json".to_string())
222}
223
224fn load_agent() -> Result<Agent, jacs::error::JacsError> {
227 let mut config = jacs::config::Config::from_file(&resolve_config_path())?;
228 config.apply_env_overrides();
229 let password = ensure_cli_private_key_password()
230 .map_err(|e| jacs::error::JacsError::Internal { message: e })?;
231 Agent::from_config(config, password.as_deref())
232}
233
234fn wrap_quickstart_error_with_password_help(
235 context: &str,
236 err: impl std::fmt::Display,
237) -> Box<dyn Error> {
238 Box::new(std::io::Error::other(format!(
239 "{}: {}\n\n{}",
240 context,
241 err,
242 quickstart_password_bootstrap_help()
243 )))
244}
245
246pub fn build_cli() -> Command {
253 let cmd = Command::new(crate_name!())
254 .version(env!("CARGO_PKG_VERSION"))
255 .about(env!("CARGO_PKG_DESCRIPTION"))
256 .subcommand(
257 Command::new("version")
258 .about("Prints version and build information")
259 )
260 .subcommand(
261 Command::new("config")
262 .about(" work with JACS configuration")
263 .subcommand(
264 Command::new("create")
265 .about(" create a config file")
266 )
267 .subcommand(
268 Command::new("read")
269 .about("read configuration and display to screen. This includes both the config file and the env variables.")
270 ),
271 )
272 .subcommand(
273 Command::new("agent")
274 .about(" work with a JACS agent")
275 .subcommand(
276 Command::new("dns")
277 .about("emit DNS TXT commands for publishing agent fingerprint")
278 .arg(
279 Arg::new("agent-file")
280 .short('a')
281 .long("agent-file")
282 .value_parser(value_parser!(String))
283 .help("Path to agent JSON (optional; defaults via config)"),
284 )
285 .arg(
286 Arg::new("no-dns")
287 .long("no-dns")
288 .help("Disable DNS validation; rely on embedded fingerprint")
289 .action(ArgAction::SetTrue),
290 )
291 .arg(
292 Arg::new("require-dns")
293 .long("require-dns")
294 .help("Require DNS validation; if domain missing, fail. Not strict (no DNSSEC required).")
295 .action(ArgAction::SetTrue),
296 )
297 .arg(
298 Arg::new("require-strict-dns")
299 .long("require-strict-dns")
300 .help("Require strict DNSSEC validation; if domain missing, fail.")
301 .action(ArgAction::SetTrue),
302 )
303 .arg(
304 Arg::new("ignore-dns")
305 .long("ignore-dns")
306 .help("Ignore DNS validation entirely.")
307 .action(ArgAction::SetTrue),
308 )
309 .arg(Arg::new("domain").long("domain").value_parser(value_parser!(String)))
310 .arg(Arg::new("agent-id").long("agent-id").value_parser(value_parser!(String)))
311 .arg(Arg::new("ttl").long("ttl").value_parser(value_parser!(u32)).default_value("3600"))
312 .arg(Arg::new("encoding").long("encoding").value_parser(["base64","hex"]).default_value("base64"))
313 .arg(Arg::new("provider").long("provider").value_parser(["plain","aws","azure","cloudflare"]).default_value("plain"))
314 )
315 .subcommand(
316 Command::new("create")
317 .about(" create an agent")
318 .arg(
319 Arg::new("filename")
320 .short('f')
321 .help("Name of the json file with agent schema and jacsAgentType")
322 .value_parser(value_parser!(String)),
323 )
324 .arg(
325 Arg::new("create-keys")
326 .long("create-keys")
327 .required(true)
328 .help("Create keys or not if they already exist. Configure key type in jacs.config.json")
329 .value_parser(value_parser!(bool)),
330 ),
331 )
332 .subcommand(
333 Command::new("verify")
334 .about(" verify an agent")
335 .arg(
336 Arg::new("agent-file")
337 .short('a')
338 .help("Path to the agent file. Otherwise use config jacs_agent_id_and_version")
339 .value_parser(value_parser!(String)),
340 )
341 .arg(
342 Arg::new("no-dns")
343 .long("no-dns")
344 .help("Disable DNS validation; rely on embedded fingerprint")
345 .action(ArgAction::SetTrue),
346 )
347 .arg(
348 Arg::new("require-dns")
349 .long("require-dns")
350 .help("Require DNS validation; if domain missing, fail. Not strict (no DNSSEC required).")
351 .action(ArgAction::SetTrue),
352 )
353 .arg(
354 Arg::new("require-strict-dns")
355 .long("require-strict-dns")
356 .help("Require strict DNSSEC validation; if domain missing, fail.")
357 .action(ArgAction::SetTrue),
358 )
359 .arg(
360 Arg::new("ignore-dns")
361 .long("ignore-dns")
362 .help("Ignore DNS validation entirely.")
363 .action(ArgAction::SetTrue),
364 ),
365 )
366 .subcommand(
367 Command::new("lookup")
368 .about("Look up another agent's public key and DNS info from their domain")
369 .arg(
370 Arg::new("domain")
371 .required(true)
372 .help("Domain to look up (e.g., agent.example.com)"),
373 )
374 .arg(
375 Arg::new("no-dns")
376 .long("no-dns")
377 .help("Skip DNS TXT record lookup")
378 .action(ArgAction::SetTrue),
379 )
380 .arg(
381 Arg::new("strict")
382 .long("strict")
383 .help("Require DNSSEC validation for DNS lookup")
384 .action(ArgAction::SetTrue),
385 ),
386 )
387 .subcommand(
388 Command::new("rotate-keys")
389 .about("Rotate the agent's cryptographic keys")
390 .arg(
391 Arg::new("algorithm")
392 .long("algorithm")
393 .value_parser(["ring-Ed25519", "RSA-PSS", "pq2025"])
394 .help("Signing algorithm for the new keys (defaults to current)"),
395 )
396 .arg(
397 Arg::new("config")
398 .long("config")
399 .value_parser(value_parser!(String))
400 .help("Path to jacs.config.json (default: ./jacs.config.json)"),
401 ),
402 )
403 .subcommand(
404 Command::new("keys-list")
405 .about("List active and archived key files")
406 .arg(
407 Arg::new("config")
408 .long("config")
409 .value_parser(value_parser!(String))
410 .help("Path to jacs.config.json (default: ./jacs.config.json)"),
411 ),
412 )
413 .subcommand(
414 Command::new("repair")
415 .about("Repair config after an interrupted key rotation")
416 .arg(
417 Arg::new("config")
418 .long("config")
419 .value_parser(value_parser!(String))
420 .help("Path to jacs.config.json (default: ./jacs.config.json)"),
421 ),
422 ),
423 )
424
425 .subcommand(
426 Command::new("task")
427 .about(" work with a JACS Agent task")
428 .subcommand(
429 Command::new("create")
430 .about(" create a new JACS Task file, either by embedding or parsing a document")
431 .arg(
432 Arg::new("agent-file")
433 .short('a')
434 .help("Path to the agent file. Otherwise use config jacs_agent_id_and_version")
435 .value_parser(value_parser!(String)),
436 )
437 .arg(
438 Arg::new("filename")
439 .short('f')
440 .help("Path to input file. Must be JSON")
441 .value_parser(value_parser!(String)),
442 )
443 .arg(
444 Arg::new("name")
445 .short('n')
446 .required(true)
447 .help("name of task")
448 .value_parser(value_parser!(String)),
449 )
450 .arg(
451 Arg::new("description")
452 .short('d')
453 .required(true)
454 .help("description of task")
455 .value_parser(value_parser!(String)),
456 )
457 )
458 .subcommand(
459 Command::new("update")
460 .about("update an existing task document")
461 .arg(
462 Arg::new("filename")
463 .short('f')
464 .required(true)
465 .help("Path to the updated task JSON file")
466 .value_parser(value_parser!(String)),
467 )
468 .arg(
469 Arg::new("task-key")
470 .short('k')
471 .required(true)
472 .help("Task document key (id:version)")
473 .value_parser(value_parser!(String)),
474 )
475 )
476 )
477
478 .subcommand(
479 Command::new("document")
480 .about(" work with a general JACS document")
481 .subcommand(
482 Command::new("create")
483 .about(" create a new JACS file, either by embedding or parsing a document")
484 .arg(
485 Arg::new("agent-file")
486 .short('a')
487 .help("Path to the agent file. Otherwise use config jacs_agent_id_and_version")
488 .value_parser(value_parser!(String)),
489 )
490 .arg(
491 Arg::new("filename")
492 .short('f')
493 .help("Path to input file. Must be JSON")
494 .value_parser(value_parser!(String)),
495 )
496 .arg(
497 Arg::new("output")
498 .short('o')
499 .help("Output filename. ")
500 .value_parser(value_parser!(String)),
501 )
502 .arg(
503 Arg::new("directory")
504 .short('d')
505 .help("Path to directory of files. Files should end with .json")
506 .value_parser(value_parser!(String)),
507 )
508 .arg(
509 Arg::new("verbose")
510 .short('v')
511 .long("verbose")
512 .action(ArgAction::SetTrue),
513 )
514 .arg(
515 Arg::new("no-save")
516 .long("no-save")
517 .short('n')
518 .help("Instead of saving files, print to stdout")
519 .action(ArgAction::SetTrue),
520 )
521 .arg(
522 Arg::new("schema")
523 .short('s')
524 .help("Path to JSON schema file to use to create")
525 .long("schema")
526 .value_parser(value_parser!(String)),
527 )
528 .arg(
529 Arg::new("attach")
530 .help("Path to file or directory for file attachments")
531 .long("attach")
532 .value_parser(value_parser!(String)),
533 )
534 .arg(
535 Arg::new("embed")
536 .short('e')
537 .help("Embed documents or keep the documents external")
538 .long("embed")
539 .value_parser(value_parser!(bool)),
540 ),
541 )
542 .subcommand(
543 Command::new("update")
544 .about("create a new version of document. requires both the original JACS file and the modified jacs metadata")
545 .arg(
546 Arg::new("agent-file")
547 .short('a')
548 .help("Path to the agent file. Otherwise use config jacs_agent_id_and_version")
549 .value_parser(value_parser!(String)),
550 )
551 .arg(
552 Arg::new("new")
553 .short('n')
554 .required(true)
555 .help("Path to new version of document.")
556 .value_parser(value_parser!(String)),
557 )
558 .arg(
559 Arg::new("filename")
560 .short('f')
561 .required(true)
562 .help("Path to original document.")
563 .value_parser(value_parser!(String)),
564 )
565 .arg(
566 Arg::new("output")
567 .short('o')
568 .help("Output filename. Filenames will always end with \"json\"")
569 .value_parser(value_parser!(String)),
570 )
571 .arg(
572 Arg::new("verbose")
573 .short('v')
574 .long("verbose")
575 .action(ArgAction::SetTrue),
576 )
577 .arg(
578 Arg::new("no-save")
579 .long("no-save")
580 .short('n')
581 .help("Instead of saving files, print to stdout")
582 .action(ArgAction::SetTrue),
583 )
584 .arg(
585 Arg::new("schema")
586 .short('s')
587 .help("Path to JSON schema file to use to create")
588 .long("schema")
589 .value_parser(value_parser!(String)),
590 )
591 .arg(
592 Arg::new("attach")
593 .help("Path to file or directory for file attachments")
594 .long("attach")
595 .value_parser(value_parser!(String)),
596 )
597 .arg(
598 Arg::new("embed")
599 .short('e')
600 .help("Embed documents or keep the documents external")
601 .long("embed")
602 .value_parser(value_parser!(bool)),
603 )
604 ,
605 )
606 .subcommand(
607 Command::new("check-agreement")
608 .about("given a document, provide alist of agents that should sign document")
609 .arg(
610 Arg::new("agent-file")
611 .short('a')
612 .help("Path to the agent file. Otherwise use config jacs_agent_id_and_version")
613 .value_parser(value_parser!(String)),
614 )
615 .arg(
616 Arg::new("filename")
617 .short('f')
618 .required(true)
619 .help("Path to original document.")
620 .value_parser(value_parser!(String)),
621 )
622 .arg(
623 Arg::new("directory")
624 .short('d')
625 .help("Path to directory of files. Files should end with .json")
626 .value_parser(value_parser!(String)),
627 )
628 .arg(
629 Arg::new("schema")
630 .short('s')
631 .help("Path to JSON schema file to use to create")
632 .long("schema")
633 .value_parser(value_parser!(String)),
634 )
635
636 )
637 .subcommand(
638 Command::new("create-agreement")
639 .about("given a document, provide alist of agents that should sign document")
640 .arg(
641 Arg::new("agent-file")
642 .short('a')
643 .help("Path to the agent file. Otherwise use config jacs_agent_id_and_version")
644 .value_parser(value_parser!(String)),
645 )
646 .arg(
647 Arg::new("filename")
648 .short('f')
649 .required(true)
650 .help("Path to original document.")
651 .value_parser(value_parser!(String)),
652 )
653 .arg(
654 Arg::new("directory")
655 .short('d')
656 .help("Path to directory of files. Files should end with .json")
657 .value_parser(value_parser!(String)),
658 )
659 .arg(
660 Arg::new("agentids")
661 .short('i')
662 .long("agentids")
663 .value_name("VALUES")
664 .help("Comma-separated list of agent ids")
665 .value_delimiter(',')
666 .required(true)
667 .action(clap::ArgAction::Set),
668 )
669 .arg(
670 Arg::new("output")
671 .short('o')
672 .help("Output filename. Filenames will always end with \"json\"")
673 .value_parser(value_parser!(String)),
674 )
675 .arg(
676 Arg::new("verbose")
677 .short('v')
678 .long("verbose")
679 .action(ArgAction::SetTrue),
680 )
681 .arg(
682 Arg::new("no-save")
683 .long("no-save")
684 .short('n')
685 .help("Instead of saving files, print to stdout")
686 .action(ArgAction::SetTrue),
687 )
688 .arg(
689 Arg::new("schema")
690 .short('s')
691 .help("Path to JSON schema file to use to create")
692 .long("schema")
693 .value_parser(value_parser!(String)),
694 )
695
696 ).subcommand(
697 Command::new("sign-agreement")
698 .about("given a document, sign the agreement section")
699 .arg(
700 Arg::new("agent-file")
701 .short('a')
702 .help("Path to the agent file. Otherwise use config jacs_agent_id_and_version")
703 .value_parser(value_parser!(String)),
704 )
705 .arg(
706 Arg::new("filename")
707 .short('f')
708 .required(true)
709 .help("Path to original document.")
710 .value_parser(value_parser!(String)),
711 )
712 .arg(
713 Arg::new("directory")
714 .short('d')
715 .help("Path to directory of files. Files should end with .json")
716 .value_parser(value_parser!(String)),
717 )
718 .arg(
719 Arg::new("output")
720 .short('o')
721 .help("Output filename. Filenames will always end with \"json\"")
722 .value_parser(value_parser!(String)),
723 )
724 .arg(
725 Arg::new("verbose")
726 .short('v')
727 .long("verbose")
728 .action(ArgAction::SetTrue),
729 )
730 .arg(
731 Arg::new("no-save")
732 .long("no-save")
733 .short('n')
734 .help("Instead of saving files, print to stdout")
735 .action(ArgAction::SetTrue),
736 )
737 .arg(
738 Arg::new("schema")
739 .short('s')
740 .help("Path to JSON schema file to use to create")
741 .long("schema")
742 .value_parser(value_parser!(String)),
743 )
744
745 )
746 .subcommand(
747 Command::new("verify")
748 .about(" verify a documents hash, siginatures, and schema")
749 .arg(
750 Arg::new("agent-file")
751 .short('a')
752 .help("Path to the agent file. Otherwise use config jacs_agent_id_and_version")
753 .value_parser(value_parser!(String)),
754 )
755 .arg(
756 Arg::new("filename")
757 .short('f')
758 .help("Path to input file. Must be JSON")
759 .value_parser(value_parser!(String)),
760 )
761 .arg(
762 Arg::new("directory")
763 .short('d')
764 .help("Path to directory of files. Files should end with .json")
765 .value_parser(value_parser!(String)),
766 )
767 .arg(
768 Arg::new("verbose")
769 .short('v')
770 .long("verbose")
771 .action(ArgAction::SetTrue),
772 )
773 .arg(
774 Arg::new("schema")
775 .short('s')
776 .help("Path to JSON schema file to use to validate")
777 .long("schema")
778 .value_parser(value_parser!(String)),
779 ),
780 )
781 .subcommand(
782 Command::new("extract")
783 .about(" given documents, extract embedded contents if any")
784 .arg(
785 Arg::new("agent-file")
786 .short('a')
787 .help("Path to the agent file. Otherwise use config jacs_agent_id_and_version")
788 .value_parser(value_parser!(String)),
789 )
790 .arg(
791 Arg::new("filename")
792 .short('f')
793 .help("Path to input file. Must be JSON")
794 .value_parser(value_parser!(String)),
795 )
796 .arg(
797 Arg::new("directory")
798 .short('d')
799 .help("Path to directory of files. Files should end with .json")
800 .value_parser(value_parser!(String)),
801 )
802 .arg(
803 Arg::new("verbose")
804 .short('v')
805 .long("verbose")
806 .action(ArgAction::SetTrue),
807 )
808 .arg(
809 Arg::new("schema")
810 .short('s')
811 .help("Path to JSON schema file to use to validate")
812 .long("schema")
813 .value_parser(value_parser!(String)),
814 ),
815 )
816 )
817 .subcommand(
818 Command::new("key")
819 .about("Work with JACS cryptographic keys")
820 .subcommand(
821 Command::new("reencrypt")
822 .about("Re-encrypt the private key with a new password")
823 )
824 )
825 .subcommand(
826 Command::new("mcp")
827 .about("Start the built-in JACS MCP server (stdio transport)")
828 .arg(
829 Arg::new("profile")
830 .long("profile")
831 .default_value("core")
832 .help("Tool profile: 'core' (default, core tools) or 'full' (all tools)"),
833 )
834 .subcommand(
835 Command::new("install")
836 .about("Deprecated: MCP is now built into the jacs binary")
837 .hide(true)
838 )
839 .subcommand(
840 Command::new("run")
841 .about("Deprecated: use `jacs mcp` directly")
842 .hide(true)
843 ),
844 )
845 .subcommand(
846 Command::new("a2a")
847 .about("A2A (Agent-to-Agent) trust and discovery commands")
848 .subcommand(
849 Command::new("assess")
850 .about("Assess trust level of a remote A2A Agent Card")
851 .arg(
852 Arg::new("source")
853 .required(true)
854 .help("Path to Agent Card JSON file or URL"),
855 )
856 .arg(
857 Arg::new("policy")
858 .long("policy")
859 .short('p')
860 .value_parser(["open", "verified", "strict"])
861 .default_value("verified")
862 .help("Trust policy to apply (default: verified)"),
863 )
864 .arg(
865 Arg::new("json")
866 .long("json")
867 .action(ArgAction::SetTrue)
868 .help("Output result as JSON"),
869 ),
870 )
871 .subcommand(
872 Command::new("trust")
873 .about("Add a remote A2A agent to the local trust store")
874 .arg(
875 Arg::new("source")
876 .required(true)
877 .help("Path to Agent Card JSON file or URL"),
878 ),
879 )
880 .subcommand(
881 Command::new("discover")
882 .about("Discover a remote A2A agent via its well-known Agent Card")
883 .arg(
884 Arg::new("url")
885 .required(true)
886 .help("Base URL of the agent (e.g. https://agent.example.com)"),
887 )
888 .arg(
889 Arg::new("json")
890 .long("json")
891 .action(ArgAction::SetTrue)
892 .help("Output the full Agent Card as JSON"),
893 )
894 .arg(
895 Arg::new("policy")
896 .long("policy")
897 .short('p')
898 .value_parser(["open", "verified", "strict"])
899 .default_value("verified")
900 .help("Trust policy to apply against the discovered card"),
901 ),
902 )
903 .subcommand(
904 Command::new("serve")
905 .about("Serve this agent's .well-known endpoints for A2A discovery")
906 .arg(
907 Arg::new("port")
908 .long("port")
909 .value_parser(value_parser!(u16))
910 .default_value("8080")
911 .help("Port to listen on (default: 8080)"),
912 )
913 .arg(
914 Arg::new("host")
915 .long("host")
916 .default_value("127.0.0.1")
917 .help("Host to bind to (default: 127.0.0.1)"),
918 ),
919 )
920 .subcommand(
921 Command::new("quickstart")
922 .about("Create/load an agent and start serving A2A endpoints (password required)")
923 .after_help(quickstart_password_bootstrap_help())
924 .arg(
925 Arg::new("name")
926 .long("name")
927 .value_parser(value_parser!(String))
928 .required(true)
929 .help("Agent name used for first-time quickstart creation"),
930 )
931 .arg(
932 Arg::new("domain")
933 .long("domain")
934 .value_parser(value_parser!(String))
935 .required(true)
936 .help("Agent domain used for DNS/public-key verification workflows"),
937 )
938 .arg(
939 Arg::new("description")
940 .long("description")
941 .value_parser(value_parser!(String))
942 .help("Optional human-readable agent description"),
943 )
944 .arg(
945 Arg::new("port")
946 .long("port")
947 .value_parser(value_parser!(u16))
948 .default_value("8080")
949 .help("Port to listen on (default: 8080)"),
950 )
951 .arg(
952 Arg::new("host")
953 .long("host")
954 .default_value("127.0.0.1")
955 .help("Host to bind to (default: 127.0.0.1)"),
956 )
957 .arg(
958 Arg::new("algorithm")
959 .long("algorithm")
960 .short('a')
961 .value_parser(["pq2025", "ring-Ed25519", "RSA-PSS"])
962 .help("Signing algorithm (default: pq2025)"),
963 ),
964 ),
965 )
966 .subcommand(
967 Command::new("quickstart")
968 .about("Create or load a persistent agent for instant sign/verify (password required)")
969 .after_help(quickstart_password_bootstrap_help())
970 .arg(
971 Arg::new("name")
972 .long("name")
973 .value_parser(value_parser!(String))
974 .required(true)
975 .help("Agent name used for first-time quickstart creation"),
976 )
977 .arg(
978 Arg::new("domain")
979 .long("domain")
980 .value_parser(value_parser!(String))
981 .required(true)
982 .help("Agent domain used for DNS/public-key verification workflows"),
983 )
984 .arg(
985 Arg::new("description")
986 .long("description")
987 .value_parser(value_parser!(String))
988 .help("Optional human-readable agent description"),
989 )
990 .arg(
991 Arg::new("algorithm")
992 .long("algorithm")
993 .short('a')
994 .value_parser(["ed25519", "rsa-pss", "pq2025"])
995 .default_value("pq2025")
996 .help("Signing algorithm (default: pq2025)"),
997 )
998 .arg(
999 Arg::new("sign")
1000 .long("sign")
1001 .help("Sign JSON from stdin and print signed document to stdout")
1002 .action(ArgAction::SetTrue),
1003 )
1004 .arg(
1005 Arg::new("file")
1006 .short('f')
1007 .long("file")
1008 .value_parser(value_parser!(String))
1009 .help("Sign a JSON file instead of reading from stdin (used with --sign)"),
1010 )
1011 )
1012 .subcommand(
1013 Command::new("init")
1014 .about("Initialize JACS by creating both config and agent (with keys)")
1015 .arg(
1016 Arg::new("yes")
1017 .long("yes")
1018 .short('y')
1019 .action(ArgAction::SetTrue)
1020 .help("Automatically set the new agent ID in jacs.config.json without prompting"),
1021 )
1022 )
1023 .subcommand(
1024 Command::new("attest")
1025 .about("Create and verify attestation documents")
1026 .subcommand(
1027 Command::new("create")
1028 .about("Create a signed attestation")
1029 .arg(
1030 Arg::new("subject-type")
1031 .long("subject-type")
1032 .value_parser(["agent", "artifact", "workflow", "identity"])
1033 .help("Type of subject being attested"),
1034 )
1035 .arg(
1036 Arg::new("subject-id")
1037 .long("subject-id")
1038 .value_parser(value_parser!(String))
1039 .help("Identifier of the subject"),
1040 )
1041 .arg(
1042 Arg::new("subject-digest")
1043 .long("subject-digest")
1044 .value_parser(value_parser!(String))
1045 .help("SHA-256 digest of the subject"),
1046 )
1047 .arg(
1048 Arg::new("claims")
1049 .long("claims")
1050 .value_parser(value_parser!(String))
1051 .required(true)
1052 .help("JSON array of claims, e.g. '[{\"name\":\"reviewed\",\"value\":true}]'"),
1053 )
1054 .arg(
1055 Arg::new("evidence")
1056 .long("evidence")
1057 .value_parser(value_parser!(String))
1058 .help("JSON array of evidence references"),
1059 )
1060 .arg(
1061 Arg::new("from-document")
1062 .long("from-document")
1063 .value_parser(value_parser!(String))
1064 .help("Lift attestation from an existing signed document file"),
1065 )
1066 .arg(
1067 Arg::new("output")
1068 .short('o')
1069 .long("output")
1070 .value_parser(value_parser!(String))
1071 .help("Write attestation to file instead of stdout"),
1072 ),
1073 )
1074 .subcommand(
1075 Command::new("verify")
1076 .about("Verify an attestation document")
1077 .arg(
1078 Arg::new("file")
1079 .help("Path to the attestation JSON file")
1080 .required(true)
1081 .value_parser(value_parser!(String)),
1082 )
1083 .arg(
1084 Arg::new("full")
1085 .long("full")
1086 .action(ArgAction::SetTrue)
1087 .help("Use full verification (evidence + derivation chain)"),
1088 )
1089 .arg(
1090 Arg::new("json")
1091 .long("json")
1092 .action(ArgAction::SetTrue)
1093 .help("Output result as JSON"),
1094 )
1095 .arg(
1096 Arg::new("key-dir")
1097 .long("key-dir")
1098 .value_parser(value_parser!(String))
1099 .help("Directory containing public keys for verification"),
1100 )
1101 .arg(
1102 Arg::new("max-depth")
1103 .long("max-depth")
1104 .value_parser(value_parser!(u32))
1105 .help("Maximum derivation chain depth"),
1106 ),
1107 )
1108 .subcommand(
1109 Command::new("export-dsse")
1110 .about("Export an attestation as a DSSE envelope for in-toto/SLSA")
1111 .arg(
1112 Arg::new("file")
1113 .help("Path to the signed attestation JSON file")
1114 .required(true)
1115 .value_parser(value_parser!(String)),
1116 )
1117 .arg(
1118 Arg::new("output")
1119 .short('o')
1120 .long("output")
1121 .value_parser(value_parser!(String))
1122 .help("Write DSSE envelope to file instead of stdout"),
1123 ),
1124 )
1125 .subcommand_required(true)
1126 .arg_required_else_help(true),
1127 )
1128 .subcommand(
1129 Command::new("verify")
1130 .about("Verify a signed JACS document (no agent required)")
1131 .arg(
1132 Arg::new("file")
1133 .help("Path to the signed JACS JSON file")
1134 .required_unless_present("remote")
1135 .value_parser(value_parser!(String)),
1136 )
1137 .arg(
1138 Arg::new("remote")
1139 .long("remote")
1140 .value_parser(value_parser!(String))
1141 .help("Fetch document from URL before verifying"),
1142 )
1143 .arg(
1144 Arg::new("json")
1145 .long("json")
1146 .action(ArgAction::SetTrue)
1147 .help("Output result as JSON"),
1148 )
1149 .arg(
1150 Arg::new("key-dir")
1151 .long("key-dir")
1152 .value_parser(value_parser!(String))
1153 .help("Directory containing public keys for verification"),
1154 )
1155 );
1156
1157 #[cfg(feature = "keychain")]
1159 let cmd = cmd.subcommand(
1160 Command::new("keychain")
1161 .about("Manage private key passwords in the OS keychain (per-agent)")
1162 .subcommand(
1163 Command::new("set")
1164 .about("Store a password in the OS keychain for an agent")
1165 .arg(
1166 Arg::new("agent-id")
1167 .long("agent-id")
1168 .help("Agent ID to associate the password with")
1169 .value_name("AGENT_ID")
1170 .required(true),
1171 )
1172 .arg(
1173 Arg::new("password")
1174 .long("password")
1175 .help("Password to store (if omitted, prompts interactively)")
1176 .value_name("PASSWORD"),
1177 ),
1178 )
1179 .subcommand(
1180 Command::new("get")
1181 .about("Retrieve the stored password for an agent (prints to stdout)")
1182 .arg(
1183 Arg::new("agent-id")
1184 .long("agent-id")
1185 .help("Agent ID to look up")
1186 .value_name("AGENT_ID")
1187 .required(true),
1188 ),
1189 )
1190 .subcommand(
1191 Command::new("delete")
1192 .about("Remove the stored password for an agent from the OS keychain")
1193 .arg(
1194 Arg::new("agent-id")
1195 .long("agent-id")
1196 .help("Agent ID whose password to delete")
1197 .value_name("AGENT_ID")
1198 .required(true),
1199 ),
1200 )
1201 .subcommand(
1202 Command::new("status")
1203 .about("Check if a password is stored for an agent in the OS keychain")
1204 .arg(
1205 Arg::new("agent-id")
1206 .long("agent-id")
1207 .help("Agent ID to check")
1208 .value_name("AGENT_ID")
1209 .required(true),
1210 ),
1211 )
1212 .arg_required_else_help(true),
1213 );
1214
1215 let cmd = cmd.subcommand(
1216 Command::new("convert")
1217 .about(
1218 "Convert JACS documents between JSON, YAML, and HTML formats (no agent required)",
1219 )
1220 .arg(
1221 Arg::new("to")
1222 .long("to")
1223 .required(true)
1224 .value_parser(["json", "yaml", "html"])
1225 .help("Target format: json, yaml, or html"),
1226 )
1227 .arg(
1228 Arg::new("from")
1229 .long("from")
1230 .value_parser(["json", "yaml", "html"])
1231 .help("Source format (auto-detected from extension if omitted)"),
1232 )
1233 .arg(
1234 Arg::new("file")
1235 .short('f')
1236 .long("file")
1237 .required(true)
1238 .value_parser(value_parser!(String))
1239 .help("Input file path (use '-' for stdin)"),
1240 )
1241 .arg(
1242 Arg::new("output")
1243 .short('o')
1244 .long("output")
1245 .value_parser(value_parser!(String))
1246 .help("Output file path (defaults to stdout)"),
1247 ),
1248 );
1249
1250 cmd
1251}
1252
1253pub fn main() -> Result<(), Box<dyn Error>> {
1254 install_signal_handler();
1256
1257 let _shutdown_guard = ShutdownGuard::new();
1259 let matches = build_cli().arg_required_else_help(true).get_matches();
1260
1261 match matches.subcommand() {
1262 Some(("version", _sub_matches)) => {
1263 println!("{}", env!("CARGO_PKG_DESCRIPTION"));
1264 println!(
1265 "{} version: {}",
1266 env!("CARGO_PKG_NAME"),
1267 env!("CARGO_PKG_VERSION")
1268 );
1269 return Ok(());
1270 }
1271 Some(("config", config_matches)) => match config_matches.subcommand() {
1272 Some(("create", _create_matches)) => {
1273 handle_config_create()?;
1275 }
1276 Some(("read", _read_matches)) => {
1277 let config_path = "./jacs.config.json";
1278 match jacs::config::Config::from_file(config_path) {
1279 Ok(mut config) => {
1280 config.apply_env_overrides();
1281 println!("{}", config);
1282 }
1283 Err(e) => {
1284 eprintln!("Could not load config from '{}': {}", config_path, e);
1285 process::exit(1);
1286 }
1287 }
1288 }
1289 _ => println!("please enter subcommand see jacs config --help"),
1290 },
1291 Some(("agent", agent_matches)) => match agent_matches.subcommand() {
1292 Some(("dns", sub_m)) => {
1293 let domain = sub_m.get_one::<String>("domain").cloned();
1294 let agent_id_arg = sub_m.get_one::<String>("agent-id").cloned();
1295 let ttl = *sub_m.get_one::<u32>("ttl").unwrap();
1296 let enc = sub_m
1297 .get_one::<String>("encoding")
1298 .map(|s| s.as_str())
1299 .unwrap_or("base64");
1300 let provider = sub_m
1301 .get_one::<String>("provider")
1302 .map(|s| s.as_str())
1303 .unwrap_or("plain");
1304
1305 let _agent_file = sub_m.get_one::<String>("agent-file").cloned();
1307 let non_strict = *sub_m.get_one::<bool>("no-dns").unwrap_or(&false);
1308 let ignore_dns = *sub_m.get_one::<bool>("ignore-dns").unwrap_or(&false);
1309 let require_strict = *sub_m
1310 .get_one::<bool>("require-strict-dns")
1311 .unwrap_or(&false);
1312 let require_dns = *sub_m.get_one::<bool>("require-dns").unwrap_or(&false);
1313 let agent: Agent = load_agent_with_cli_dns_policy(
1314 ignore_dns,
1315 require_strict,
1316 require_dns,
1317 non_strict,
1318 )
1319 .expect("Failed to load agent from config");
1320 let agent_id = agent_id_arg.unwrap_or_else(|| agent.get_id().unwrap_or_default());
1321 let pk = agent.get_public_key().expect("public key");
1322 let digest = match enc {
1323 "hex" => dns_bootstrap::pubkey_digest_hex(&pk),
1324 _ => dns_bootstrap::pubkey_digest_b64(&pk),
1325 };
1326 let domain_final = domain
1327 .or_else(|| {
1328 agent
1329 .config
1330 .as_ref()
1331 .and_then(|c| c.jacs_agent_domain().clone())
1332 })
1333 .expect("domain required via --domain or jacs_agent_domain in config");
1334
1335 let rr = dns_bootstrap::build_dns_record(
1336 &domain_final,
1337 ttl,
1338 &agent_id,
1339 &digest,
1340 if enc == "hex" {
1341 dns_bootstrap::DigestEncoding::Hex
1342 } else {
1343 dns_bootstrap::DigestEncoding::Base64
1344 },
1345 );
1346
1347 println!("Plain/BIND:\n{}", dns_bootstrap::emit_plain_bind(&rr));
1348 match provider {
1349 "aws" => println!(
1350 "\nRoute53 change-batch JSON:\n{}",
1351 dns_bootstrap::emit_route53_change_batch(&rr)
1352 ),
1353 "azure" => println!(
1354 "\nAzure CLI:\n{}",
1355 dns_bootstrap::emit_azure_cli(
1356 &rr,
1357 "$RESOURCE_GROUP",
1358 &domain_final,
1359 "_v1.agent.jacs"
1360 )
1361 ),
1362 "cloudflare" => println!(
1363 "\nCloudflare curl:\n{}",
1364 dns_bootstrap::emit_cloudflare_curl(&rr, "$ZONE_ID")
1365 ),
1366 _ => {}
1367 }
1368 println!(
1369 "\nChecklist: Ensure DNSSEC is enabled for {domain} and DS is published at registrar.",
1370 domain = domain_final
1371 );
1372 }
1373 Some(("create", create_matches)) => {
1374 let filename = create_matches.get_one::<String>("filename");
1376 let create_keys = *create_matches.get_one::<bool>("create-keys").unwrap();
1377
1378 handle_agent_create(filename, create_keys)?;
1380 }
1381 Some(("verify", verify_matches)) => {
1382 let _agentfile = verify_matches.get_one::<String>("agent-file");
1383 let non_strict = *verify_matches.get_one::<bool>("no-dns").unwrap_or(&false);
1384 let require_dns = *verify_matches
1385 .get_one::<bool>("require-dns")
1386 .unwrap_or(&false);
1387 let require_strict = *verify_matches
1388 .get_one::<bool>("require-strict-dns")
1389 .unwrap_or(&false);
1390 let ignore_dns = *verify_matches
1391 .get_one::<bool>("ignore-dns")
1392 .unwrap_or(&false);
1393 let mut agent: Agent = load_agent_with_cli_dns_policy(
1394 ignore_dns,
1395 require_strict,
1396 require_dns,
1397 non_strict,
1398 )
1399 .expect("Failed to load agent from config");
1400 agent
1401 .verify_self_signature()
1402 .expect("signature verification");
1403 println!(
1404 "Agent {} signature verified OK.",
1405 agent.get_lookup_id().expect("jacsId")
1406 );
1407 }
1408 Some(("lookup", lookup_matches)) => {
1409 let domain = lookup_matches
1410 .get_one::<String>("domain")
1411 .expect("domain required");
1412 let skip_dns = *lookup_matches.get_one::<bool>("no-dns").unwrap_or(&false);
1413 let strict_dns = *lookup_matches.get_one::<bool>("strict").unwrap_or(&false);
1414
1415 println!("Agent Lookup: {}\n", domain);
1416
1417 println!("Public Key (/.well-known/jacs-pubkey.json):");
1419 let url = format!("https://{}/.well-known/jacs-pubkey.json", domain);
1420 let client = reqwest::blocking::Client::builder()
1421 .timeout(std::time::Duration::from_secs(10))
1422 .build()
1423 .expect("HTTP client");
1424 match client.get(&url).send() {
1425 Ok(response) => {
1426 if response.status().is_success() {
1427 match response.json::<serde_json::Value>() {
1428 Ok(json) => {
1429 println!(
1430 " Agent ID: {}",
1431 json.get("agentId")
1432 .and_then(|v| v.as_str())
1433 .unwrap_or("Not specified")
1434 );
1435 println!(
1436 " Algorithm: {}",
1437 json.get("algorithm")
1438 .and_then(|v| v.as_str())
1439 .unwrap_or("Not specified")
1440 );
1441 println!(
1442 " Public Key Hash: {}",
1443 json.get("publicKeyHash")
1444 .and_then(|v| v.as_str())
1445 .unwrap_or("Not specified")
1446 );
1447 if let Some(pk) = json.get("publicKey").and_then(|v| v.as_str())
1448 {
1449 let preview = if pk.len() > 60 {
1450 format!("{}...", &pk[..60])
1451 } else {
1452 pk.to_string()
1453 };
1454 println!(" Public Key: {}", preview);
1455 }
1456 }
1457 Err(e) => println!(" Error parsing response: {}", e),
1458 }
1459 } else {
1460 println!(" HTTP error: {}", response.status());
1461 }
1462 }
1463 Err(e) => println!(" Error fetching: {}", e),
1464 }
1465
1466 println!();
1467
1468 if !skip_dns {
1470 println!("DNS TXT Record (_v1.agent.jacs.{}):", domain);
1471 let owner = format!("_v1.agent.jacs.{}", domain.trim_end_matches('.'));
1472 let lookup_result = if strict_dns {
1473 dns_bootstrap::resolve_txt_dnssec(&owner)
1474 } else {
1475 dns_bootstrap::resolve_txt_insecure(&owner)
1476 };
1477 match lookup_result {
1478 Ok(txt) => {
1479 match dns_bootstrap::parse_agent_txt(&txt) {
1481 Ok(parsed) => {
1482 println!(" Version: {}", parsed.v);
1483 println!(" Agent ID: {}", parsed.jacs_agent_id);
1484 println!(" Algorithm: {:?}", parsed.alg);
1485 println!(" Encoding: {:?}", parsed.enc);
1486 println!(" Public Key Hash: {}", parsed.digest);
1487 }
1488 Err(e) => println!(" Error parsing TXT: {}", e),
1489 }
1490 println!(" Raw TXT: {}", txt);
1491 }
1492 Err(e) => {
1493 println!(" No DNS TXT record found: {}", e);
1494 if strict_dns {
1495 println!(" (Strict DNSSEC validation was required)");
1496 }
1497 }
1498 }
1499 } else {
1500 println!("DNS TXT Record: Skipped (--no-dns)");
1501 }
1502 }
1503 Some(("rotate-keys", sub_m)) => {
1504 use jacs::simple::SimpleAgent;
1505
1506 let config_path = sub_m.get_one::<String>("config").map(|s| s.as_str());
1507 let algorithm = sub_m.get_one::<String>("algorithm").map(|s| s.as_str());
1508
1509 let agent =
1510 SimpleAgent::load(config_path, None).map_err(|e| -> Box<dyn Error> {
1511 Box::new(std::io::Error::new(
1512 std::io::ErrorKind::Other,
1513 format!("Failed to load agent: {}", e),
1514 ))
1515 })?;
1516
1517 let result = jacs::simple::advanced::rotate(&agent, algorithm).map_err(
1518 |e| -> Box<dyn Error> {
1519 Box::new(std::io::Error::new(
1520 std::io::ErrorKind::Other,
1521 format!("Key rotation failed: {}", e),
1522 ))
1523 },
1524 )?;
1525
1526 println!("Key rotation successful.");
1527 println!(" Agent ID: {}", result.jacs_id);
1528 println!(" Old version: {}", result.old_version);
1529 println!(" New version: {}", result.new_version);
1530 println!(" New key hash: {}", result.new_public_key_hash);
1531 if result.transition_proof.is_some() {
1532 println!(" Transition proof: present");
1533 }
1534 }
1535 Some(("keys-list", sub_m)) => {
1536 let config_path = sub_m.get_one::<String>("config");
1537 let config_p = config_path
1538 .map(|s| s.as_str())
1539 .unwrap_or("./jacs.config.json");
1540
1541 let config =
1542 jacs::config::Config::from_file(config_p).map_err(|e| -> Box<dyn Error> {
1543 Box::new(std::io::Error::new(
1544 std::io::ErrorKind::Other,
1545 format!("Failed to load config: {}", e),
1546 ))
1547 })?;
1548
1549 let key_dir = config
1550 .jacs_key_directory()
1551 .as_deref()
1552 .unwrap_or("./jacs_keys");
1553 let algo = config
1554 .jacs_agent_key_algorithm()
1555 .as_deref()
1556 .unwrap_or("unknown");
1557 let pub_name = config
1558 .jacs_agent_public_key_filename()
1559 .as_deref()
1560 .unwrap_or("jacs.public.pem");
1561
1562 let active_path = std::path::Path::new(key_dir).join(pub_name);
1564 if active_path.exists() {
1565 let meta = std::fs::metadata(&active_path).ok();
1566 let modified = meta
1567 .and_then(|m| m.modified().ok())
1568 .map(|t| {
1569 let duration =
1570 t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
1571 format!("{}s since epoch", duration.as_secs())
1572 })
1573 .unwrap_or_else(|| "unknown".to_string());
1574 println!(
1575 "Active: {} (algorithm: {}, modified: {})",
1576 active_path.display(),
1577 algo,
1578 modified
1579 );
1580 } else {
1581 println!(
1582 "Active: (no public key found at {})",
1583 active_path.display()
1584 );
1585 }
1586
1587 let mut archived: Vec<(String, String)> = Vec::new();
1589 if let Ok(entries) = std::fs::read_dir(key_dir) {
1590 for entry in entries.flatten() {
1591 let name = entry.file_name().to_string_lossy().to_string();
1592 if name.ends_with(".pem")
1594 && name.starts_with("jacs.public.")
1595 && name != pub_name
1596 {
1597 let modified = entry
1598 .metadata()
1599 .ok()
1600 .and_then(|m| m.modified().ok())
1601 .map(|t| {
1602 let duration =
1603 t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
1604 format!("{}s since epoch", duration.as_secs())
1605 })
1606 .unwrap_or_else(|| "unknown".to_string());
1607 archived.push((name, modified));
1608 }
1609 }
1610 }
1611
1612 if archived.is_empty() {
1613 println!("Archived: (none)");
1614 } else {
1615 archived.sort_by(|a, b| b.1.cmp(&a.1));
1616 for (name, modified) in &archived {
1617 println!("Archived: {}/{} (modified: {})", key_dir, name, modified);
1618 }
1619 }
1620 }
1621 Some(("repair", sub_m)) => {
1622 use jacs::keystore::RotationJournal;
1623 use jacs::simple::SimpleAgent;
1624
1625 let config_path = sub_m.get_one::<String>("config");
1626 let config_p = config_path
1627 .map(|s| s.as_str())
1628 .unwrap_or("./jacs.config.json");
1629
1630 let config =
1631 jacs::config::Config::from_file(config_p).map_err(|e| -> Box<dyn Error> {
1632 Box::new(std::io::Error::new(
1633 std::io::ErrorKind::Other,
1634 format!("Failed to load config: {}", e),
1635 ))
1636 })?;
1637
1638 let key_dir = config
1639 .jacs_key_directory()
1640 .as_deref()
1641 .unwrap_or("./jacs_keys");
1642
1643 let journal_path = RotationJournal::journal_path(key_dir);
1644 if RotationJournal::load(&journal_path).is_some() {
1645 println!(
1646 "Incomplete rotation detected (journal at {}). Loading agent to trigger auto-repair...",
1647 journal_path
1648 );
1649 let _agent =
1651 SimpleAgent::load(Some(config_p), None).map_err(|e| -> Box<dyn Error> {
1652 Box::new(std::io::Error::new(
1653 std::io::ErrorKind::Other,
1654 format!("Repair failed: {}", e),
1655 ))
1656 })?;
1657 if RotationJournal::load(&journal_path).is_none() {
1659 println!("Config repaired successfully. Journal cleaned up.");
1660 } else {
1661 println!(
1662 "Warning: Journal still present after load. Manual intervention may be needed."
1663 );
1664 }
1665 } else {
1666 println!("No incomplete rotation detected. Nothing to repair.");
1667 }
1668 }
1669 _ => println!("please enter subcommand see jacs agent --help"),
1670 },
1671
1672 Some(("task", task_matches)) => match task_matches.subcommand() {
1673 Some(("create", create_matches)) => {
1674 let _agentfile = create_matches.get_one::<String>("agent-file");
1675 let mut agent: Agent = load_agent().expect("failed to load agent for task create");
1676 let name = create_matches
1677 .get_one::<String>("name")
1678 .expect("task name is required");
1679 let description = create_matches
1680 .get_one::<String>("description")
1681 .expect("task description is required");
1682 println!(
1683 "{}",
1684 create_task(&mut agent, name.to_string(), description.to_string()).unwrap()
1685 );
1686 }
1687 Some(("update", update_matches)) => {
1688 let mut agent: Agent = load_agent().expect("failed to load agent for task update");
1689 let task_key = update_matches
1690 .get_one::<String>("task-key")
1691 .expect("task key is required");
1692 let filename = update_matches
1693 .get_one::<String>("filename")
1694 .expect("filename is required");
1695 let updated_json = std::fs::read_to_string(filename)
1696 .unwrap_or_else(|e| panic!("Failed to read '{}': {}", filename, e));
1697 println!(
1698 "{}",
1699 jacs::update_task(&mut agent, task_key, &updated_json).unwrap()
1700 );
1701 }
1702 _ => println!("please enter subcommand see jacs task --help"),
1703 },
1704 Some(("document", document_matches)) => match document_matches.subcommand() {
1705 Some(("create", create_matches)) => {
1706 let filename = create_matches.get_one::<String>("filename");
1707 let outputfilename = create_matches.get_one::<String>("output");
1708 let directory = create_matches.get_one::<String>("directory");
1709 let _verbose = *create_matches.get_one::<bool>("verbose").unwrap_or(&false);
1710 let no_save = *create_matches.get_one::<bool>("no-save").unwrap_or(&false);
1711 let _agentfile = create_matches.get_one::<String>("agent-file");
1712 let schema = create_matches.get_one::<String>("schema");
1713 let attachments = create_matches
1714 .get_one::<String>("attach")
1715 .map(|s| s.as_str());
1716 let embed: Option<bool> = create_matches.get_one::<bool>("embed").copied();
1717
1718 let mut agent: Agent = load_agent().expect("REASON");
1719
1720 let _attachment_links = agent.parse_attachement_arg(attachments);
1721 let _ = create_documents(
1722 &mut agent,
1723 filename,
1724 directory,
1725 outputfilename,
1726 attachments,
1727 embed,
1728 no_save,
1729 schema,
1730 );
1731 }
1732 Some(("update", create_matches)) => {
1735 let new_filename = create_matches.get_one::<String>("new").unwrap();
1736 let original_filename = create_matches.get_one::<String>("filename").unwrap();
1737 let outputfilename = create_matches.get_one::<String>("output");
1738 let _verbose = *create_matches.get_one::<bool>("verbose").unwrap_or(&false);
1739 let no_save = *create_matches.get_one::<bool>("no-save").unwrap_or(&false);
1740 let _agentfile = create_matches.get_one::<String>("agent-file");
1741 let schema = create_matches.get_one::<String>("schema");
1742 let attachments = create_matches
1743 .get_one::<String>("attach")
1744 .map(|s| s.as_str());
1745 let embed: Option<bool> = create_matches.get_one::<bool>("embed").copied();
1746
1747 let mut agent: Agent = load_agent().expect("REASON");
1748
1749 let attachment_links = agent.parse_attachement_arg(attachments);
1750 update_documents(
1751 &mut agent,
1752 new_filename,
1753 original_filename,
1754 outputfilename,
1755 attachment_links,
1756 embed,
1757 no_save,
1758 schema,
1759 )?;
1760 }
1761 Some(("sign-agreement", create_matches)) => {
1762 let filename = create_matches.get_one::<String>("filename");
1763 let directory = create_matches.get_one::<String>("directory");
1764 let _verbose = *create_matches.get_one::<bool>("verbose").unwrap_or(&false);
1765 let _agentfile = create_matches.get_one::<String>("agent-file");
1766 let mut agent: Agent = load_agent().expect("REASON");
1767 let schema = create_matches.get_one::<String>("schema");
1768 let _no_save = *create_matches.get_one::<bool>("no-save").unwrap_or(&false);
1769
1770 sign_documents(&mut agent, schema, filename, directory)?;
1772 }
1773 Some(("check-agreement", create_matches)) => {
1774 let filename = create_matches.get_one::<String>("filename");
1775 let directory = create_matches.get_one::<String>("directory");
1776 let _agentfile = create_matches.get_one::<String>("agent-file");
1777 let mut agent: Agent = load_agent().expect("REASON");
1778 let schema = create_matches.get_one::<String>("schema");
1779
1780 let _files: Vec<String> = default_set_file_list(filename, directory, None)
1782 .expect("Failed to determine file list");
1783 check_agreement(&mut agent, schema, filename, directory)?;
1784 }
1785 Some(("create-agreement", create_matches)) => {
1786 let filename = create_matches.get_one::<String>("filename");
1787 let directory = create_matches.get_one::<String>("directory");
1788 let _verbose = *create_matches.get_one::<bool>("verbose").unwrap_or(&false);
1789 let _agentfile = create_matches.get_one::<String>("agent-file");
1790
1791 let schema = create_matches.get_one::<String>("schema");
1792 let no_save = *create_matches.get_one::<bool>("no-save").unwrap_or(&false);
1793 let agentids: Vec<String> = create_matches .get_many::<String>("agentids")
1795 .unwrap_or_default()
1796 .map(|s| s.to_string())
1797 .collect();
1798
1799 let mut agent: Agent = load_agent().expect("REASON");
1800 let _ =
1802 create_agreement(&mut agent, agentids, filename, schema, no_save, directory);
1803 }
1804
1805 Some(("verify", verify_matches)) => {
1806 let filename = verify_matches.get_one::<String>("filename");
1807 let directory = verify_matches.get_one::<String>("directory");
1808 let _verbose = *verify_matches.get_one::<bool>("verbose").unwrap_or(&false);
1809 let _agentfile = verify_matches.get_one::<String>("agent-file");
1810 let mut agent: Agent = load_agent().expect("REASON");
1811 let schema = verify_matches.get_one::<String>("schema");
1812 verify_documents(&mut agent, schema, filename, directory)?;
1814 }
1815
1816 Some(("extract", extract_matches)) => {
1817 let filename = extract_matches.get_one::<String>("filename");
1818 let directory = extract_matches.get_one::<String>("directory");
1819 let _verbose = *extract_matches.get_one::<bool>("verbose").unwrap_or(&false);
1820 let _agentfile = extract_matches.get_one::<String>("agent-file");
1821 let mut agent: Agent = load_agent().expect("REASON");
1822 let schema = extract_matches.get_one::<String>("schema");
1823 let _files: Vec<String> = default_set_file_list(filename, directory, None)
1825 .expect("Failed to determine file list");
1826 extract_documents(&mut agent, schema, filename, directory)?;
1828 }
1829
1830 _ => println!("please enter subcommand see jacs document --help"),
1831 },
1832 Some(("key", key_matches)) => match key_matches.subcommand() {
1833 Some(("reencrypt", _reencrypt_matches)) => {
1834 use jacs::crypt::aes_encrypt::password_requirements;
1835 use jacs::simple::SimpleAgent;
1836
1837 let agent = SimpleAgent::load(None, None).map_err(|e| -> Box<dyn Error> {
1839 Box::new(std::io::Error::new(
1840 std::io::ErrorKind::Other,
1841 format!("Failed to load agent: {}", e),
1842 ))
1843 })?;
1844
1845 println!("Re-encrypting private key.\n");
1846
1847 println!("Enter current password:");
1849 let old_password = read_password().map_err(|e| -> Box<dyn Error> {
1850 Box::new(std::io::Error::new(
1851 std::io::ErrorKind::Other,
1852 format!("Error reading password: {}", e),
1853 ))
1854 })?;
1855
1856 if old_password.is_empty() {
1857 eprintln!("Error: current password cannot be empty.");
1858 process::exit(1);
1859 }
1860
1861 println!("\n{}", password_requirements());
1863 println!("\nEnter new password:");
1864 let new_password = read_password().map_err(|e| -> Box<dyn Error> {
1865 Box::new(std::io::Error::new(
1866 std::io::ErrorKind::Other,
1867 format!("Error reading password: {}", e),
1868 ))
1869 })?;
1870
1871 println!("Confirm new password:");
1872 let new_password_confirm = read_password().map_err(|e| -> Box<dyn Error> {
1873 Box::new(std::io::Error::new(
1874 std::io::ErrorKind::Other,
1875 format!("Error reading password: {}", e),
1876 ))
1877 })?;
1878
1879 if new_password != new_password_confirm {
1880 eprintln!("Error: new passwords do not match.");
1881 process::exit(1);
1882 }
1883
1884 jacs::simple::advanced::reencrypt_key(&agent, &old_password, &new_password)
1885 .map_err(|e| -> Box<dyn Error> {
1886 Box::new(std::io::Error::new(
1887 std::io::ErrorKind::Other,
1888 format!("Re-encryption failed: {}", e),
1889 ))
1890 })?;
1891
1892 println!("Private key re-encrypted successfully.");
1893 }
1894 _ => println!("please enter subcommand see jacs key --help"),
1895 },
1896 #[cfg(feature = "mcp")]
1897 Some(("mcp", mcp_matches)) => match mcp_matches.subcommand() {
1898 Some(("install", _)) => {
1899 eprintln!("`jacs mcp install` is no longer needed.");
1900 eprintln!("MCP is built into the jacs binary. Use `jacs mcp` to serve.");
1901 process::exit(0);
1902 }
1903 Some(("run", _)) => {
1904 eprintln!("`jacs mcp run` is no longer needed.");
1905 eprintln!("Use `jacs mcp` directly to start the MCP server.");
1906 process::exit(0);
1907 }
1908 _ => {
1909 let profile_str = mcp_matches.get_one::<String>("profile").map(|s| s.as_str());
1910 let profile = jacs_mcp::Profile::resolve(profile_str);
1911 let (agent, info) = jacs_mcp::load_agent_from_config_env_with_info()?;
1912 let state_roots = info["data_directory"]
1913 .as_str()
1914 .map(std::path::PathBuf::from)
1915 .into_iter()
1916 .collect();
1917 let server = jacs_mcp::JacsMcpServer::with_profile_and_state_roots(
1918 agent,
1919 profile,
1920 state_roots,
1921 );
1922 let rt = tokio::runtime::Runtime::new()?;
1923 rt.block_on(jacs_mcp::serve_stdio(server))?;
1924 }
1925 },
1926 #[cfg(not(feature = "mcp"))]
1927 Some(("mcp", _)) => {
1928 eprintln!(
1929 "MCP support not compiled. Install with default features: cargo install jacs-cli"
1930 );
1931 process::exit(1);
1932 }
1933 Some(("a2a", a2a_matches)) => match a2a_matches.subcommand() {
1934 Some(("assess", assess_matches)) => {
1935 use jacs::a2a::AgentCard;
1936 use jacs::a2a::trust::{A2ATrustPolicy, assess_a2a_agent};
1937
1938 let source = assess_matches.get_one::<String>("source").unwrap();
1939 let policy_str = assess_matches
1940 .get_one::<String>("policy")
1941 .map(|s| s.as_str())
1942 .unwrap_or("verified");
1943 let json_output = *assess_matches.get_one::<bool>("json").unwrap_or(&false);
1944
1945 let policy = A2ATrustPolicy::from_str_loose(policy_str)
1946 .map_err(|e| Box::<dyn Error>::from(format!("Invalid policy: {}", e)))?;
1947
1948 let card_json = if source.starts_with("http://") || source.starts_with("https://") {
1950 let client = reqwest::blocking::Client::builder()
1951 .timeout(std::time::Duration::from_secs(10))
1952 .build()
1953 .map_err(|e| format!("HTTP client error: {}", e))?;
1954 client
1955 .get(source.as_str())
1956 .send()
1957 .map_err(|e| format!("Fetch failed: {}", e))?
1958 .text()
1959 .map_err(|e| format!("Read body failed: {}", e))?
1960 } else {
1961 std::fs::read_to_string(source)
1962 .map_err(|e| format!("Read file failed: {}", e))?
1963 };
1964
1965 let card: AgentCard = serde_json::from_str(&card_json)
1966 .map_err(|e| format!("Invalid Agent Card JSON: {}", e))?;
1967
1968 let agent = jacs::get_empty_agent();
1970 let assessment = assess_a2a_agent(&agent, &card, policy);
1971
1972 if json_output {
1973 println!(
1974 "{}",
1975 serde_json::to_string_pretty(&assessment)
1976 .expect("assessment serialization")
1977 );
1978 } else {
1979 println!("Agent: {}", card.name);
1980 println!(
1981 "Agent ID: {}",
1982 assessment.agent_id.as_deref().unwrap_or("(not specified)")
1983 );
1984 println!("Policy: {}", assessment.policy);
1985 println!("Trust Level: {}", assessment.trust_level);
1986 println!(
1987 "Allowed: {}",
1988 if assessment.allowed { "YES" } else { "NO" }
1989 );
1990 println!("JACS Ext: {}", assessment.jacs_registered);
1991 println!("Reason: {}", assessment.reason);
1992 if !assessment.allowed {
1993 process::exit(1);
1994 }
1995 }
1996 }
1997 Some(("trust", trust_matches)) => {
1998 use jacs::a2a::AgentCard;
1999 use jacs::trust;
2000
2001 let source = trust_matches.get_one::<String>("source").unwrap();
2002
2003 let card_json = if source.starts_with("http://") || source.starts_with("https://") {
2005 let client = reqwest::blocking::Client::builder()
2006 .timeout(std::time::Duration::from_secs(10))
2007 .build()
2008 .map_err(|e| format!("HTTP client error: {}", e))?;
2009 client
2010 .get(source.as_str())
2011 .send()
2012 .map_err(|e| format!("Fetch failed: {}", e))?
2013 .text()
2014 .map_err(|e| format!("Read body failed: {}", e))?
2015 } else {
2016 std::fs::read_to_string(source)
2017 .map_err(|e| format!("Read file failed: {}", e))?
2018 };
2019
2020 let card: AgentCard = serde_json::from_str(&card_json)
2021 .map_err(|e| format!("Invalid Agent Card JSON: {}", e))?;
2022
2023 let agent_id = card
2025 .metadata
2026 .as_ref()
2027 .and_then(|m| m.get("jacsId"))
2028 .and_then(|v| v.as_str())
2029 .ok_or("Agent Card has no jacsId in metadata")?;
2030 let agent_version = card
2031 .metadata
2032 .as_ref()
2033 .and_then(|m| m.get("jacsVersion"))
2034 .and_then(|v| v.as_str())
2035 .unwrap_or("unknown");
2036
2037 let key = format!("{}:{}", agent_id, agent_version);
2038
2039 trust::trust_a2a_card(&key, &card_json)?;
2043
2044 println!(
2045 "Saved unverified A2A Agent Card bookmark '{}' ({})",
2046 card.name, agent_id
2047 );
2048 println!(" Version: {}", agent_version);
2049 println!(" Bookmark key: {}", key);
2050 println!(
2051 " This entry is not cryptographically trusted until verified JACS identity material is added."
2052 );
2053 }
2054 Some(("discover", discover_matches)) => {
2055 use jacs::a2a::AgentCard;
2056 use jacs::a2a::trust::{A2ATrustPolicy, assess_a2a_agent};
2057
2058 let base_url = discover_matches.get_one::<String>("url").unwrap();
2059 let json_output = *discover_matches.get_one::<bool>("json").unwrap_or(&false);
2060 let policy_str = discover_matches
2061 .get_one::<String>("policy")
2062 .map(|s| s.as_str())
2063 .unwrap_or("verified");
2064
2065 let policy = A2ATrustPolicy::from_str_loose(policy_str)
2066 .map_err(|e| Box::<dyn Error>::from(format!("Invalid policy: {}", e)))?;
2067
2068 let trimmed = base_url.trim_end_matches('/');
2070 let card_url = format!("{}/.well-known/agent-card.json", trimmed);
2071
2072 let client = reqwest::blocking::Client::builder()
2073 .timeout(std::time::Duration::from_secs(10))
2074 .build()
2075 .map_err(|e| format!("HTTP client error: {}", e))?;
2076
2077 let response = client
2078 .get(&card_url)
2079 .send()
2080 .map_err(|e| format!("Failed to fetch {}: {}", card_url, e))?;
2081
2082 if !response.status().is_success() {
2083 eprintln!(
2084 "Failed to discover agent at {}: HTTP {}",
2085 card_url,
2086 response.status()
2087 );
2088 process::exit(1);
2089 }
2090
2091 let card_json = response
2092 .text()
2093 .map_err(|e| format!("Read body failed: {}", e))?;
2094
2095 let card: AgentCard = serde_json::from_str(&card_json)
2096 .map_err(|e| format!("Invalid Agent Card JSON at {}: {}", card_url, e))?;
2097
2098 if json_output {
2099 println!(
2101 "{}",
2102 serde_json::to_string_pretty(&card).expect("card serialization")
2103 );
2104 } else {
2105 println!("Discovered A2A Agent: {}", card.name);
2107 println!(" Description: {}", card.description);
2108 println!(" Version: {}", card.version);
2109 println!(" Protocol: {}", card.protocol_versions.join(", "));
2110
2111 for iface in &card.supported_interfaces {
2113 println!(" Endpoint: {} ({})", iface.url, iface.protocol_binding);
2114 }
2115
2116 if !card.skills.is_empty() {
2118 println!(" Skills:");
2119 for skill in &card.skills {
2120 println!(" - {} ({})", skill.name, skill.id);
2121 }
2122 }
2123
2124 let has_jacs = card
2126 .capabilities
2127 .extensions
2128 .as_ref()
2129 .map(|exts| exts.iter().any(|e| e.uri == jacs::a2a::JACS_EXTENSION_URI))
2130 .unwrap_or(false);
2131 println!(" JACS: {}", if has_jacs { "YES" } else { "NO" });
2132
2133 let agent = jacs::get_empty_agent();
2135 let assessment = assess_a2a_agent(&agent, &card, policy);
2136 println!(
2137 " Trust: {} ({})",
2138 assessment.trust_level, assessment.reason
2139 );
2140 if !assessment.allowed {
2141 println!(
2142 " WARNING: Agent not allowed under '{}' policy",
2143 policy_str
2144 );
2145 }
2146 }
2147 }
2148 Some(("serve", serve_matches)) => {
2149 let port = *serve_matches.get_one::<u16>("port").unwrap();
2150 let host = serve_matches
2151 .get_one::<String>("host")
2152 .map(|s| s.as_str())
2153 .unwrap_or("127.0.0.1");
2154
2155 ensure_cli_private_key_password().map_err(|e| -> Box<dyn Error> {
2157 Box::new(std::io::Error::other(format!(
2158 "Password bootstrap failed: {}\n\n{}",
2159 e,
2160 quickstart_password_bootstrap_help()
2161 )))
2162 })?;
2163 let (agent, info) = jacs::simple::advanced::quickstart(
2164 "jacs-agent",
2165 "localhost",
2166 Some("JACS A2A agent"),
2167 None,
2168 None,
2169 )
2170 .map_err(|e| wrap_quickstart_error_with_password_help("Failed to load agent", e))?;
2171
2172 let agent_card = jacs::a2a::simple::export_agent_card(&agent).map_err(
2174 |e| -> Box<dyn Error> {
2175 Box::new(std::io::Error::new(
2176 std::io::ErrorKind::Other,
2177 format!("Failed to export Agent Card: {}", e),
2178 ))
2179 },
2180 )?;
2181
2182 let documents = jacs::a2a::simple::generate_well_known_documents(&agent, None)
2184 .map_err(|e| -> Box<dyn Error> {
2185 Box::new(std::io::Error::new(
2186 std::io::ErrorKind::Other,
2187 format!("Failed to generate well-known documents: {}", e),
2188 ))
2189 })?;
2190
2191 let mut routes: std::collections::HashMap<String, String> =
2193 std::collections::HashMap::new();
2194 for (path, value) in &documents {
2195 routes.insert(
2196 path.clone(),
2197 serde_json::to_string_pretty(value).unwrap_or_default(),
2198 );
2199 }
2200
2201 let addr = format!("{}:{}", host, port);
2202 let server = tiny_http::Server::http(&addr)
2203 .map_err(|e| format!("Failed to start server on {}: {}", addr, e))?;
2204
2205 println!("Serving A2A well-known endpoints at http://{}", addr);
2206 println!(" Agent: {} ({})", agent_card.name, info.agent_id);
2207 println!(" Endpoints:");
2208 for path in routes.keys() {
2209 println!(" http://{}{}", addr, path);
2210 }
2211 println!("\nPress Ctrl+C to stop.");
2212
2213 for request in server.incoming_requests() {
2214 let url = request.url().to_string();
2215 if let Some(body) = routes.get(&url) {
2216 let response = tiny_http::Response::from_string(body.clone()).with_header(
2217 tiny_http::Header::from_bytes(
2218 &b"Content-Type"[..],
2219 &b"application/json"[..],
2220 )
2221 .unwrap(),
2222 );
2223 let _ = request.respond(response);
2224 } else {
2225 let response =
2226 tiny_http::Response::from_string("{\"error\": \"not found\"}")
2227 .with_status_code(404)
2228 .with_header(
2229 tiny_http::Header::from_bytes(
2230 &b"Content-Type"[..],
2231 &b"application/json"[..],
2232 )
2233 .unwrap(),
2234 );
2235 let _ = request.respond(response);
2236 }
2237 }
2238 }
2239 Some(("quickstart", qs_matches)) => {
2240 let port = *qs_matches.get_one::<u16>("port").unwrap();
2241 let host = qs_matches
2242 .get_one::<String>("host")
2243 .map(|s| s.as_str())
2244 .unwrap_or("127.0.0.1");
2245 let algorithm = qs_matches
2246 .get_one::<String>("algorithm")
2247 .map(|s| s.as_str());
2248 let name = qs_matches
2249 .get_one::<String>("name")
2250 .map(|s| s.as_str())
2251 .unwrap_or("jacs-agent");
2252 let domain = qs_matches
2253 .get_one::<String>("domain")
2254 .map(|s| s.as_str())
2255 .unwrap_or("localhost");
2256 let description = qs_matches
2257 .get_one::<String>("description")
2258 .map(|s| s.as_str());
2259
2260 ensure_cli_private_key_password().map_err(|e| -> Box<dyn Error> {
2262 Box::new(std::io::Error::other(format!(
2263 "Password bootstrap failed: {}\n\n{}",
2264 e,
2265 quickstart_password_bootstrap_help()
2266 )))
2267 })?;
2268 let (agent, info) =
2269 jacs::simple::advanced::quickstart(name, domain, description, algorithm, None)
2270 .map_err(|e| {
2271 wrap_quickstart_error_with_password_help(
2272 "Failed to quickstart agent",
2273 e,
2274 )
2275 })?;
2276
2277 let agent_card = jacs::a2a::simple::export_agent_card(&agent).map_err(
2279 |e| -> Box<dyn Error> {
2280 Box::new(std::io::Error::new(
2281 std::io::ErrorKind::Other,
2282 format!("Failed to export Agent Card: {}", e),
2283 ))
2284 },
2285 )?;
2286
2287 let documents = jacs::a2a::simple::generate_well_known_documents(&agent, None)
2289 .map_err(|e| -> Box<dyn Error> {
2290 Box::new(std::io::Error::new(
2291 std::io::ErrorKind::Other,
2292 format!("Failed to generate well-known documents: {}", e),
2293 ))
2294 })?;
2295
2296 let mut routes: std::collections::HashMap<String, String> =
2298 std::collections::HashMap::new();
2299 for (path, value) in &documents {
2300 routes.insert(
2301 path.clone(),
2302 serde_json::to_string_pretty(value).unwrap_or_default(),
2303 );
2304 }
2305
2306 let addr = format!("{}:{}", host, port);
2307 let server = tiny_http::Server::http(&addr)
2308 .map_err(|e| format!("Failed to start server on {}: {}", addr, e))?;
2309
2310 println!("A2A Quickstart");
2311 println!("==============");
2312 println!("Agent: {} ({})", agent_card.name, info.agent_id);
2313 println!("Algorithm: {}", algorithm.unwrap_or("pq2025"));
2314 println!();
2315 println!("Discovery URL: http://{}/.well-known/agent-card.json", addr);
2316 println!();
2317 println!("Endpoints:");
2318 for path in routes.keys() {
2319 println!(" http://{}{}", addr, path);
2320 }
2321 println!();
2322 println!("Press Ctrl+C to stop.");
2323
2324 for request in server.incoming_requests() {
2325 let url = request.url().to_string();
2326 if let Some(body) = routes.get(&url) {
2327 let response = tiny_http::Response::from_string(body.clone()).with_header(
2328 tiny_http::Header::from_bytes(
2329 &b"Content-Type"[..],
2330 &b"application/json"[..],
2331 )
2332 .unwrap(),
2333 );
2334 let _ = request.respond(response);
2335 } else {
2336 let response =
2337 tiny_http::Response::from_string("{\"error\": \"not found\"}")
2338 .with_status_code(404)
2339 .with_header(
2340 tiny_http::Header::from_bytes(
2341 &b"Content-Type"[..],
2342 &b"application/json"[..],
2343 )
2344 .unwrap(),
2345 );
2346 let _ = request.respond(response);
2347 }
2348 }
2349 }
2350 _ => println!("please enter subcommand see jacs a2a --help"),
2351 },
2352 Some(("quickstart", qs_matches)) => {
2353 let algorithm = qs_matches
2354 .get_one::<String>("algorithm")
2355 .map(|s| s.as_str());
2356 let name = qs_matches
2357 .get_one::<String>("name")
2358 .map(|s| s.as_str())
2359 .unwrap_or("jacs-agent");
2360 let domain = qs_matches
2361 .get_one::<String>("domain")
2362 .map(|s| s.as_str())
2363 .unwrap_or("localhost");
2364 let description = qs_matches
2365 .get_one::<String>("description")
2366 .map(|s| s.as_str());
2367 let do_sign = *qs_matches.get_one::<bool>("sign").unwrap_or(&false);
2368 let sign_file = qs_matches.get_one::<String>("file");
2369
2370 if let Err(e) = ensure_cli_private_key_password() {
2373 eprintln!("Note: {}", e);
2374 }
2375
2376 if env::var("JACS_PRIVATE_KEY_PASSWORD")
2378 .unwrap_or_default()
2379 .trim()
2380 .is_empty()
2381 {
2382 eprintln!("{}", jacs::crypt::aes_encrypt::password_requirements());
2383 let password = loop {
2384 eprintln!("Enter a password for your JACS private key:");
2385 let pw =
2386 read_password().map_err(|e| format!("Failed to read password: {}", e))?;
2387 if pw.trim().is_empty() {
2388 eprintln!("Password cannot be empty. Please try again.");
2389 continue;
2390 }
2391 eprintln!("Confirm password:");
2392 let pw2 =
2393 read_password().map_err(|e| format!("Failed to read password: {}", e))?;
2394 if pw != pw2 {
2395 eprintln!("Passwords do not match. Please try again.");
2396 continue;
2397 }
2398 break pw;
2399 };
2400
2401 unsafe {
2403 env::set_var("JACS_PRIVATE_KEY_PASSWORD", &password);
2404 }
2405
2406 }
2409
2410 let (agent, info) =
2411 jacs::simple::advanced::quickstart(name, domain, description, algorithm, None)
2412 .map_err(|e| {
2413 wrap_quickstart_error_with_password_help("Quickstart failed", e)
2414 })?;
2415
2416 if do_sign {
2417 let input = if let Some(file_path) = sign_file {
2419 std::fs::read_to_string(file_path)?
2420 } else {
2421 use std::io::Read;
2422 let mut buf = String::new();
2423 std::io::stdin().read_to_string(&mut buf)?;
2424 buf
2425 };
2426
2427 let value: serde_json::Value = serde_json::from_str(&input)
2428 .map_err(|e| format!("Invalid JSON input: {}", e))?;
2429
2430 let signed = agent.sign_message(&value).map_err(|e| -> Box<dyn Error> {
2431 Box::new(std::io::Error::new(
2432 std::io::ErrorKind::Other,
2433 format!("Signing failed: {}", e),
2434 ))
2435 })?;
2436
2437 println!("{}", signed.raw);
2438 } else {
2439 println!("JACS agent ready ({})", info.algorithm);
2441 println!(" Agent ID: {}", info.agent_id);
2442 println!(" Version: {}", info.version);
2443 println!(" Config: {}", info.config_path);
2444 println!(" Keys: {}", info.key_directory);
2445 println!();
2446 println!("Sign something:");
2447 println!(" echo '{{\"hello\":\"world\"}}' | jacs quickstart --sign");
2448 }
2449 }
2450 #[cfg(feature = "attestation")]
2451 Some(("attest", attest_matches)) => {
2452 use jacs::attestation::types::*;
2453 use jacs::simple::SimpleAgent;
2454
2455 match attest_matches.subcommand() {
2456 Some(("create", create_matches)) => {
2457 ensure_cli_private_key_password()?;
2459
2460 let agent = match SimpleAgent::load(None, None) {
2462 Ok(a) => a,
2463 Err(e) => {
2464 eprintln!("Failed to load agent: {}", e);
2465 eprintln!("Run `jacs quickstart` first to create an agent.");
2466 process::exit(1);
2467 }
2468 };
2469
2470 let claims_str = create_matches
2472 .get_one::<String>("claims")
2473 .expect("claims is required");
2474 let claims: Vec<Claim> = serde_json::from_str(claims_str).map_err(|e| {
2475 format!(
2476 "Invalid claims JSON: {}. \
2477 Provide a JSON array like '[{{\"name\":\"reviewed\",\"value\":true}}]'",
2478 e
2479 )
2480 })?;
2481
2482 let evidence: Vec<EvidenceRef> =
2484 if let Some(ev_str) = create_matches.get_one::<String>("evidence") {
2485 serde_json::from_str(ev_str)
2486 .map_err(|e| format!("Invalid evidence JSON: {}", e))?
2487 } else {
2488 vec![]
2489 };
2490
2491 let att_json = if let Some(doc_path) =
2492 create_matches.get_one::<String>("from-document")
2493 {
2494 let doc_content = std::fs::read_to_string(doc_path).map_err(|e| {
2496 format!("Failed to read document '{}': {}", doc_path, e)
2497 })?;
2498 let result = jacs::attestation::simple::lift(&agent, &doc_content, &claims)
2499 .map_err(|e| {
2500 format!("Failed to lift document to attestation: {}", e)
2501 })?;
2502 result.raw
2503 } else {
2504 let subject_type_str = create_matches
2506 .get_one::<String>("subject-type")
2507 .ok_or("--subject-type is required when not using --from-document")?;
2508 let subject_id = create_matches
2509 .get_one::<String>("subject-id")
2510 .ok_or("--subject-id is required when not using --from-document")?;
2511 let subject_digest = create_matches
2512 .get_one::<String>("subject-digest")
2513 .ok_or("--subject-digest is required when not using --from-document")?;
2514
2515 let subject_type = match subject_type_str.as_str() {
2516 "agent" => SubjectType::Agent,
2517 "artifact" => SubjectType::Artifact,
2518 "workflow" => SubjectType::Workflow,
2519 "identity" => SubjectType::Identity,
2520 other => {
2521 return Err(format!("Unknown subject type: '{}'", other).into());
2522 }
2523 };
2524
2525 let subject = AttestationSubject {
2526 subject_type,
2527 id: subject_id.clone(),
2528 digests: DigestSet {
2529 sha256: subject_digest.clone(),
2530 sha512: None,
2531 additional: std::collections::HashMap::new(),
2532 },
2533 };
2534
2535 let result = jacs::attestation::simple::create(
2536 &agent, &subject, &claims, &evidence, None, None,
2537 )
2538 .map_err(|e| format!("Failed to create attestation: {}", e))?;
2539 result.raw
2540 };
2541
2542 if let Some(output_path) = create_matches.get_one::<String>("output") {
2544 std::fs::write(output_path, &att_json).map_err(|e| {
2545 format!("Failed to write output file '{}': {}", output_path, e)
2546 })?;
2547 eprintln!("Attestation written to {}", output_path);
2548 } else {
2549 println!("{}", att_json);
2550 }
2551 }
2552 Some(("verify", verify_matches)) => {
2553 let file_path = verify_matches
2554 .get_one::<String>("file")
2555 .expect("file is required");
2556 let full = *verify_matches.get_one::<bool>("full").unwrap_or(&false);
2557 let json_output = *verify_matches.get_one::<bool>("json").unwrap_or(&false);
2558 let key_dir = verify_matches.get_one::<String>("key-dir");
2559 let max_depth = verify_matches.get_one::<u32>("max-depth");
2560
2561 if let Some(kd) = key_dir {
2563 unsafe { std::env::set_var("JACS_KEY_DIRECTORY", kd) };
2565 }
2566
2567 if let Some(depth) = max_depth {
2569 unsafe {
2571 std::env::set_var("JACS_MAX_DERIVATION_DEPTH", depth.to_string())
2572 };
2573 }
2574
2575 let att_content = std::fs::read_to_string(file_path).map_err(|e| {
2577 format!("Failed to read attestation file '{}': {}", file_path, e)
2578 })?;
2579
2580 ensure_cli_private_key_password().ok();
2582 let agent = match SimpleAgent::load(None, None) {
2583 Ok(a) => a,
2584 Err(_) => {
2585 let (a, _) = SimpleAgent::ephemeral(Some("ed25519"))
2586 .map_err(|e| format!("Failed to create verifier: {}", e))?;
2587 a
2588 }
2589 };
2590
2591 let att_value: serde_json::Value = serde_json::from_str(&att_content)
2593 .map_err(|e| format!("Invalid attestation JSON: {}", e))?;
2594 let doc_key = format!(
2595 "{}:{}",
2596 att_value["jacsId"].as_str().unwrap_or("unknown"),
2597 att_value["jacsVersion"].as_str().unwrap_or("unknown")
2598 );
2599
2600 let verify_result = agent.verify(&att_content);
2603 if let Err(e) = &verify_result {
2604 if json_output {
2605 let out = serde_json::json!({
2606 "valid": false,
2607 "error": e.to_string(),
2608 });
2609 println!("{}", serde_json::to_string_pretty(&out).unwrap());
2610 } else {
2611 eprintln!("Verification error: {}", e);
2612 }
2613 process::exit(1);
2614 }
2615
2616 let att_result = if full {
2618 jacs::attestation::simple::verify_full(&agent, &doc_key)
2619 } else {
2620 jacs::attestation::simple::verify(&agent, &doc_key)
2621 };
2622
2623 match att_result {
2624 Ok(r) => {
2625 if json_output {
2626 println!("{}", serde_json::to_string_pretty(&r).unwrap());
2627 } else {
2628 println!(
2629 "Status: {}",
2630 if r.valid { "VALID" } else { "INVALID" }
2631 );
2632 println!(
2633 "Signature: {}",
2634 if r.crypto.signature_valid {
2635 "valid"
2636 } else {
2637 "INVALID"
2638 }
2639 );
2640 println!(
2641 "Hash: {}",
2642 if r.crypto.hash_valid {
2643 "valid"
2644 } else {
2645 "INVALID"
2646 }
2647 );
2648 if !r.crypto.signer_id.is_empty() {
2649 println!("Signer: {}", r.crypto.signer_id);
2650 }
2651 if !r.evidence.is_empty() {
2652 println!("Evidence: {} items checked", r.evidence.len());
2653 }
2654 if !r.errors.is_empty() {
2655 for err in &r.errors {
2656 eprintln!(" Error: {}", err);
2657 }
2658 }
2659 }
2660 if !r.valid {
2661 process::exit(1);
2662 }
2663 }
2664 Err(e) => {
2665 if json_output {
2666 let out = serde_json::json!({
2667 "valid": false,
2668 "error": e.to_string(),
2669 });
2670 println!("{}", serde_json::to_string_pretty(&out).unwrap());
2671 } else {
2672 eprintln!("Attestation verification error: {}", e);
2673 }
2674 process::exit(1);
2675 }
2676 }
2677 }
2678 Some(("export-dsse", export_matches)) => {
2679 let file_path = export_matches
2680 .get_one::<String>("file")
2681 .expect("file argument required");
2682 let output_path = export_matches.get_one::<String>("output");
2683
2684 let attestation_json = std::fs::read_to_string(file_path).unwrap_or_else(|e| {
2685 eprintln!("Cannot read {}: {}", file_path, e);
2686 process::exit(1);
2687 });
2688
2689 let (_agent, _info) = SimpleAgent::ephemeral(Some("ring-Ed25519"))
2690 .unwrap_or_else(|e| {
2691 eprintln!("Failed to create agent: {}", e);
2692 process::exit(1);
2693 });
2694
2695 match jacs::attestation::simple::export_dsse(&attestation_json) {
2696 Ok(envelope_json) => {
2697 if let Some(out_path) = output_path {
2698 std::fs::write(out_path, &envelope_json).unwrap_or_else(|e| {
2699 eprintln!("Cannot write to {}: {}", out_path, e);
2700 process::exit(1);
2701 });
2702 println!("DSSE envelope written to {}", out_path);
2703 } else {
2704 println!("{}", envelope_json);
2705 }
2706 }
2707 Err(e) => {
2708 eprintln!("Failed to export DSSE envelope: {}", e);
2709 process::exit(1);
2710 }
2711 }
2712 }
2713 _ => {
2714 eprintln!(
2715 "Use 'jacs attest create', 'jacs attest verify', or 'jacs attest export-dsse'. See --help."
2716 );
2717 process::exit(1);
2718 }
2719 }
2720 }
2721 Some(("verify", verify_matches)) => {
2722 use jacs::simple::SimpleAgent;
2723 use serde_json::json;
2724
2725 let file_path = verify_matches.get_one::<String>("file");
2726 let remote_url = verify_matches.get_one::<String>("remote");
2727 let json_output = *verify_matches.get_one::<bool>("json").unwrap_or(&false);
2728 let key_dir = verify_matches.get_one::<String>("key-dir");
2729
2730 if let Some(kd) = key_dir {
2732 unsafe { std::env::set_var("JACS_KEY_DIRECTORY", kd) };
2734 }
2735
2736 let document = if let Some(url) = remote_url {
2738 let client = reqwest::blocking::Client::builder()
2739 .timeout(std::time::Duration::from_secs(30))
2740 .build()
2741 .map_err(|e| format!("HTTP client error: {}", e))?;
2742 let resp = client
2743 .get(url)
2744 .send()
2745 .map_err(|e| format!("Fetch failed: {}", e))?;
2746 if !resp.status().is_success() {
2747 eprintln!("HTTP error: {}", resp.status());
2748 process::exit(1);
2749 }
2750 resp.text()
2751 .map_err(|e| format!("Read body failed: {}", e))?
2752 } else if let Some(path) = file_path {
2753 std::fs::read_to_string(path).map_err(|e| format!("Read file failed: {}", e))?
2754 } else {
2755 eprintln!("Provide a file path or --remote <url>");
2756 process::exit(1);
2757 };
2758
2759 let agent = if std::path::Path::new("./jacs.config.json").exists() {
2763 if let Err(e) = ensure_cli_private_key_password() {
2764 eprintln!("Warning: Password bootstrap failed: {}", e);
2765 eprintln!("{}", quickstart_password_bootstrap_help());
2766 }
2767 match SimpleAgent::load(None, None) {
2768 Ok(a) => a,
2769 Err(e) => {
2770 let lower = e.to_string().to_lowercase();
2771 if lower.contains("password")
2772 || lower.contains("decrypt")
2773 || lower.contains("private key")
2774 {
2775 eprintln!(
2776 "Warning: Could not load local agent from ./jacs.config.json: {}",
2777 e
2778 );
2779 eprintln!("{}", quickstart_password_bootstrap_help());
2780 }
2781 let (a, _) = SimpleAgent::ephemeral(Some("ed25519"))
2782 .map_err(|e| format!("Failed to create verifier: {}", e))?;
2783 a
2784 }
2785 }
2786 } else {
2787 let (a, _) = SimpleAgent::ephemeral(Some("ed25519"))
2788 .map_err(|e| format!("Failed to create verifier: {}", e))?;
2789 a
2790 };
2791
2792 match agent.verify(&document) {
2793 Ok(r) => {
2794 if json_output {
2795 let out = json!({
2796 "valid": r.valid,
2797 "signerId": r.signer_id,
2798 "timestamp": r.timestamp,
2799 });
2800 println!("{}", serde_json::to_string_pretty(&out).unwrap());
2801 } else {
2802 println!("Status: {}", if r.valid { "VALID" } else { "INVALID" });
2803 println!(
2804 "Signer: {}",
2805 if r.signer_id.is_empty() {
2806 "(unknown)"
2807 } else {
2808 &r.signer_id
2809 }
2810 );
2811 if !r.timestamp.is_empty() {
2812 println!("Signed at: {}", r.timestamp);
2813 }
2814 }
2815 if !r.valid {
2816 process::exit(1);
2817 }
2818 }
2819 Err(e) => {
2820 if json_output {
2821 let out = json!({
2822 "valid": false,
2823 "error": e.to_string(),
2824 });
2825 println!("{}", serde_json::to_string_pretty(&out).unwrap());
2826 } else {
2827 eprintln!("Verification error: {}", e);
2828 }
2829 process::exit(1);
2830 }
2831 }
2832 }
2833 Some(("convert", convert_matches)) => {
2834 use jacs::convert::{html_to_jacs, jacs_to_html, jacs_to_yaml, yaml_to_jacs};
2835
2836 let target_format = convert_matches.get_one::<String>("to").unwrap();
2837 let source_format = convert_matches.get_one::<String>("from");
2838 let file_path = convert_matches.get_one::<String>("file").unwrap();
2839 let output_path = convert_matches.get_one::<String>("output");
2840
2841 let is_stdin = file_path == "-";
2843 let detected_format = if let Some(fmt) = source_format {
2844 fmt.clone()
2845 } else if is_stdin {
2846 eprintln!(
2847 "When reading from stdin (-f -), --from is required to specify the source format."
2848 );
2849 process::exit(1);
2850 } else {
2851 let ext = std::path::Path::new(file_path)
2852 .extension()
2853 .and_then(|e| e.to_str())
2854 .unwrap_or("");
2855 match ext {
2856 "json" => "json".to_string(),
2857 "yaml" | "yml" => "yaml".to_string(),
2858 "html" | "htm" => "html".to_string(),
2859 _ => {
2860 eprintln!(
2861 "Cannot auto-detect format for extension '{}'. Use --from to specify.",
2862 ext
2863 );
2864 process::exit(1);
2865 }
2866 }
2867 };
2868
2869 let input = if is_stdin {
2871 let mut buf = String::new();
2872 std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)
2873 .map_err(|e| format!("Failed to read from stdin: {}", e))?;
2874 buf
2875 } else {
2876 std::fs::read_to_string(file_path)
2877 .map_err(|e| format!("Failed to read '{}': {}", file_path, e))?
2878 };
2879
2880 let output = match (detected_format.as_str(), target_format.as_str()) {
2882 ("json", "yaml") => jacs_to_yaml(&input).map_err(|e| format!("{}", e))?,
2883 ("yaml", "json") => yaml_to_jacs(&input).map_err(|e| format!("{}", e))?,
2884 ("json", "html") => jacs_to_html(&input).map_err(|e| format!("{}", e))?,
2885 ("html", "json") => html_to_jacs(&input).map_err(|e| format!("{}", e))?,
2886 ("yaml", "html") => {
2887 let json = yaml_to_jacs(&input).map_err(|e| format!("{}", e))?;
2888 jacs_to_html(&json).map_err(|e| format!("{}", e))?
2889 }
2890 ("html", "yaml") => {
2891 let json = html_to_jacs(&input).map_err(|e| format!("{}", e))?;
2892 jacs_to_yaml(&json).map_err(|e| format!("{}", e))?
2893 }
2894 (src, dst) if src == dst => {
2895 input
2897 }
2898 (src, dst) => {
2899 eprintln!("Unsupported conversion: {} -> {}", src, dst);
2900 process::exit(1);
2901 }
2902 };
2903
2904 if let Some(out_path) = output_path {
2906 std::fs::write(out_path, &output)
2907 .map_err(|e| format!("Failed to write '{}': {}", out_path, e))?;
2908 eprintln!("Written to {}", out_path);
2909 } else {
2910 print!("{}", output);
2911 }
2912 }
2913 Some(("init", init_matches)) => {
2914 let auto_yes = *init_matches.get_one::<bool>("yes").unwrap_or(&false);
2915 println!("--- Running Config Creation ---");
2916 handle_config_create()?;
2917 println!("\n--- Running Agent Creation (with keys) ---");
2918 handle_agent_create_auto(None, true, auto_yes)?;
2919 println!("\n--- JACS Initialization Complete ---");
2920 }
2921 #[cfg(feature = "keychain")]
2922 Some(("keychain", keychain_matches)) => {
2923 use jacs::keystore::keychain;
2924
2925 match keychain_matches.subcommand() {
2926 Some(("set", sub)) => {
2927 let agent_id = sub.get_one::<String>("agent-id").unwrap();
2928 let password = if let Some(pw) = sub.get_one::<String>("password") {
2929 pw.clone()
2930 } else {
2931 eprintln!("Enter password to store in keychain:");
2932 read_password().map_err(|e| format!("Failed to read password: {}", e))?
2933 };
2934 if password.trim().is_empty() {
2935 eprintln!("Error: password cannot be empty.");
2936 process::exit(1);
2937 }
2938 if let Err(e) = jacs::crypt::aes_encrypt::check_password_strength(&password) {
2940 eprintln!("Error: {}", e);
2941 process::exit(1);
2942 }
2943 keychain::store_password(agent_id, &password)?;
2944 eprintln!("Password stored in OS keychain for agent {}.", agent_id);
2945 }
2946 Some(("get", sub)) => {
2947 let agent_id = sub.get_one::<String>("agent-id").unwrap();
2948 match keychain::get_password(agent_id)? {
2949 Some(pw) => println!("{}", pw),
2950 None => {
2951 eprintln!("No password found in OS keychain for agent {}.", agent_id);
2952 process::exit(1);
2953 }
2954 }
2955 }
2956 Some(("delete", sub)) => {
2957 let agent_id = sub.get_one::<String>("agent-id").unwrap();
2958 keychain::delete_password(agent_id)?;
2959 eprintln!("Password removed from OS keychain for agent {}.", agent_id);
2960 }
2961 Some(("status", sub)) => {
2962 let agent_id = sub.get_one::<String>("agent-id").unwrap();
2963 if keychain::is_available() {
2964 match keychain::get_password(agent_id) {
2965 Ok(Some(_)) => {
2966 eprintln!("Keychain backend: available");
2967 eprintln!("Agent: {}", agent_id);
2968 eprintln!("Password: stored");
2969 }
2970 Ok(None) => {
2971 eprintln!("Keychain backend: available");
2972 eprintln!("Agent: {}", agent_id);
2973 eprintln!("Password: not stored");
2974 }
2975 Err(e) => {
2976 eprintln!("Keychain backend: error ({})", e);
2977 }
2978 }
2979 } else {
2980 eprintln!("Keychain backend: not available (feature disabled)");
2981 }
2982 }
2983 _ => {
2984 eprintln!("Unknown keychain subcommand. Use: set, get, delete, status");
2985 process::exit(1);
2986 }
2987 }
2988 }
2989 _ => {
2990 eprintln!("Invalid command or no subcommand provided. Use --help for usage.");
2992 process::exit(1); }
2994 }
2995
2996 Ok(())
2997}
2998
2999#[cfg(test)]
3000mod tests {
3001 use super::*;
3002 use serial_test::serial;
3003 use std::ffi::OsString;
3004 use tempfile::tempdir;
3005
3006 struct EnvGuard {
3007 saved: Vec<(&'static str, Option<OsString>)>,
3008 }
3009
3010 impl EnvGuard {
3011 fn capture(keys: &[&'static str]) -> Self {
3012 Self {
3013 saved: keys
3014 .iter()
3015 .map(|key| (*key, std::env::var_os(key)))
3016 .collect(),
3017 }
3018 }
3019 }
3020
3021 impl Drop for EnvGuard {
3022 fn drop(&mut self) {
3023 for (key, value) in self.saved.drain(..) {
3024 match value {
3025 Some(value) => {
3026 unsafe {
3028 std::env::set_var(key, value);
3029 }
3030 }
3031 None => {
3032 unsafe {
3034 std::env::remove_var(key);
3035 }
3036 }
3037 }
3038 }
3039 }
3040 }
3041
3042 #[test]
3043 fn quickstart_help_mentions_env_precedence_warning() {
3044 let help = quickstart_password_bootstrap_help();
3045 assert!(help.contains("prefer exactly one explicit source"));
3046 assert!(help.contains("CLI warns and uses JACS_PRIVATE_KEY_PASSWORD"));
3047 }
3048
3049 #[test]
3050 #[serial]
3051 fn ensure_cli_private_key_password_reads_password_file_when_env_absent() {
3052 let _guard = EnvGuard::capture(&["JACS_PRIVATE_KEY_PASSWORD", CLI_PASSWORD_FILE_ENV]);
3053 let temp = tempdir().expect("tempdir");
3054 let password_file = temp.path().join("password.txt");
3055 std::fs::write(&password_file, "TestP@ss123!#\n").expect("write password file");
3056 #[cfg(unix)]
3057 {
3058 use std::os::unix::fs::PermissionsExt;
3059 std::fs::set_permissions(&password_file, std::fs::Permissions::from_mode(0o600))
3060 .expect("chmod password file");
3061 }
3062
3063 unsafe {
3065 std::env::remove_var("JACS_PRIVATE_KEY_PASSWORD");
3066 std::env::set_var(CLI_PASSWORD_FILE_ENV, &password_file);
3067 }
3068
3069 let resolved =
3070 ensure_cli_private_key_password().expect("password bootstrap should succeed");
3071
3072 assert_eq!(
3073 resolved.as_deref(),
3074 Some("TestP@ss123!#"),
3075 "resolved password should match password file content"
3076 );
3077 assert_eq!(
3078 std::env::var("JACS_PRIVATE_KEY_PASSWORD").expect("env password"),
3079 "TestP@ss123!#"
3080 );
3081 }
3082
3083 #[test]
3084 #[serial]
3085 fn ensure_cli_private_key_password_prefers_env_when_sources_are_ambiguous() {
3086 let _guard = EnvGuard::capture(&["JACS_PRIVATE_KEY_PASSWORD", CLI_PASSWORD_FILE_ENV]);
3087 let temp = tempdir().expect("tempdir");
3088 let password_file = temp.path().join("password.txt");
3089 std::fs::write(&password_file, "DifferentP@ss456$\n").expect("write password file");
3090 #[cfg(unix)]
3091 {
3092 use std::os::unix::fs::PermissionsExt;
3093 std::fs::set_permissions(&password_file, std::fs::Permissions::from_mode(0o600))
3094 .expect("chmod password file");
3095 }
3096
3097 unsafe {
3099 std::env::set_var("JACS_PRIVATE_KEY_PASSWORD", "TestP@ss123!#");
3100 std::env::set_var(CLI_PASSWORD_FILE_ENV, &password_file);
3101 }
3102
3103 let resolved =
3104 ensure_cli_private_key_password().expect("password bootstrap should succeed");
3105
3106 assert_eq!(
3107 resolved.as_deref(),
3108 Some("TestP@ss123!#"),
3109 "env var should win over password file"
3110 );
3111 assert_eq!(
3112 std::env::var("JACS_PRIVATE_KEY_PASSWORD").expect("env password"),
3113 "TestP@ss123!#"
3114 );
3115 }
3116}