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 )
388
389 .subcommand(
390 Command::new("task")
391 .about(" work with a JACS Agent task")
392 .subcommand(
393 Command::new("create")
394 .about(" create a new JACS Task file, either by embedding or parsing a document")
395 .arg(
396 Arg::new("agent-file")
397 .short('a')
398 .help("Path to the agent file. Otherwise use config jacs_agent_id_and_version")
399 .value_parser(value_parser!(String)),
400 )
401 .arg(
402 Arg::new("filename")
403 .short('f')
404 .help("Path to input file. Must be JSON")
405 .value_parser(value_parser!(String)),
406 )
407 .arg(
408 Arg::new("name")
409 .short('n')
410 .required(true)
411 .help("name of task")
412 .value_parser(value_parser!(String)),
413 )
414 .arg(
415 Arg::new("description")
416 .short('d')
417 .required(true)
418 .help("description of task")
419 .value_parser(value_parser!(String)),
420 )
421 )
422 .subcommand(
423 Command::new("update")
424 .about("update an existing task document")
425 .arg(
426 Arg::new("filename")
427 .short('f')
428 .required(true)
429 .help("Path to the updated task JSON file")
430 .value_parser(value_parser!(String)),
431 )
432 .arg(
433 Arg::new("task-key")
434 .short('k')
435 .required(true)
436 .help("Task document key (id:version)")
437 .value_parser(value_parser!(String)),
438 )
439 )
440 )
441
442 .subcommand(
443 Command::new("document")
444 .about(" work with a general JACS document")
445 .subcommand(
446 Command::new("create")
447 .about(" create a new JACS file, either by embedding or parsing a document")
448 .arg(
449 Arg::new("agent-file")
450 .short('a')
451 .help("Path to the agent file. Otherwise use config jacs_agent_id_and_version")
452 .value_parser(value_parser!(String)),
453 )
454 .arg(
455 Arg::new("filename")
456 .short('f')
457 .help("Path to input file. Must be JSON")
458 .value_parser(value_parser!(String)),
459 )
460 .arg(
461 Arg::new("output")
462 .short('o')
463 .help("Output filename. ")
464 .value_parser(value_parser!(String)),
465 )
466 .arg(
467 Arg::new("directory")
468 .short('d')
469 .help("Path to directory of files. Files should end with .json")
470 .value_parser(value_parser!(String)),
471 )
472 .arg(
473 Arg::new("verbose")
474 .short('v')
475 .long("verbose")
476 .action(ArgAction::SetTrue),
477 )
478 .arg(
479 Arg::new("no-save")
480 .long("no-save")
481 .short('n')
482 .help("Instead of saving files, print to stdout")
483 .action(ArgAction::SetTrue),
484 )
485 .arg(
486 Arg::new("schema")
487 .short('s')
488 .help("Path to JSON schema file to use to create")
489 .long("schema")
490 .value_parser(value_parser!(String)),
491 )
492 .arg(
493 Arg::new("attach")
494 .help("Path to file or directory for file attachments")
495 .long("attach")
496 .value_parser(value_parser!(String)),
497 )
498 .arg(
499 Arg::new("embed")
500 .short('e')
501 .help("Embed documents or keep the documents external")
502 .long("embed")
503 .value_parser(value_parser!(bool)),
504 ),
505 )
506 .subcommand(
507 Command::new("update")
508 .about("create a new version of document. requires both the original JACS file and the modified jacs metadata")
509 .arg(
510 Arg::new("agent-file")
511 .short('a')
512 .help("Path to the agent file. Otherwise use config jacs_agent_id_and_version")
513 .value_parser(value_parser!(String)),
514 )
515 .arg(
516 Arg::new("new")
517 .short('n')
518 .required(true)
519 .help("Path to new version of document.")
520 .value_parser(value_parser!(String)),
521 )
522 .arg(
523 Arg::new("filename")
524 .short('f')
525 .required(true)
526 .help("Path to original document.")
527 .value_parser(value_parser!(String)),
528 )
529 .arg(
530 Arg::new("output")
531 .short('o')
532 .help("Output filename. Filenames will always end with \"json\"")
533 .value_parser(value_parser!(String)),
534 )
535 .arg(
536 Arg::new("verbose")
537 .short('v')
538 .long("verbose")
539 .action(ArgAction::SetTrue),
540 )
541 .arg(
542 Arg::new("no-save")
543 .long("no-save")
544 .short('n')
545 .help("Instead of saving files, print to stdout")
546 .action(ArgAction::SetTrue),
547 )
548 .arg(
549 Arg::new("schema")
550 .short('s')
551 .help("Path to JSON schema file to use to create")
552 .long("schema")
553 .value_parser(value_parser!(String)),
554 )
555 .arg(
556 Arg::new("attach")
557 .help("Path to file or directory for file attachments")
558 .long("attach")
559 .value_parser(value_parser!(String)),
560 )
561 .arg(
562 Arg::new("embed")
563 .short('e')
564 .help("Embed documents or keep the documents external")
565 .long("embed")
566 .value_parser(value_parser!(bool)),
567 )
568 ,
569 )
570 .subcommand(
571 Command::new("check-agreement")
572 .about("given a document, provide alist of agents that should sign document")
573 .arg(
574 Arg::new("agent-file")
575 .short('a')
576 .help("Path to the agent file. Otherwise use config jacs_agent_id_and_version")
577 .value_parser(value_parser!(String)),
578 )
579 .arg(
580 Arg::new("filename")
581 .short('f')
582 .required(true)
583 .help("Path to original document.")
584 .value_parser(value_parser!(String)),
585 )
586 .arg(
587 Arg::new("directory")
588 .short('d')
589 .help("Path to directory of files. Files should end with .json")
590 .value_parser(value_parser!(String)),
591 )
592 .arg(
593 Arg::new("schema")
594 .short('s')
595 .help("Path to JSON schema file to use to create")
596 .long("schema")
597 .value_parser(value_parser!(String)),
598 )
599
600 )
601 .subcommand(
602 Command::new("create-agreement")
603 .about("given a document, provide alist of agents that should sign document")
604 .arg(
605 Arg::new("agent-file")
606 .short('a')
607 .help("Path to the agent file. Otherwise use config jacs_agent_id_and_version")
608 .value_parser(value_parser!(String)),
609 )
610 .arg(
611 Arg::new("filename")
612 .short('f')
613 .required(true)
614 .help("Path to original document.")
615 .value_parser(value_parser!(String)),
616 )
617 .arg(
618 Arg::new("directory")
619 .short('d')
620 .help("Path to directory of files. Files should end with .json")
621 .value_parser(value_parser!(String)),
622 )
623 .arg(
624 Arg::new("agentids")
625 .short('i')
626 .long("agentids")
627 .value_name("VALUES")
628 .help("Comma-separated list of agent ids")
629 .value_delimiter(',')
630 .required(true)
631 .action(clap::ArgAction::Set),
632 )
633 .arg(
634 Arg::new("output")
635 .short('o')
636 .help("Output filename. Filenames will always end with \"json\"")
637 .value_parser(value_parser!(String)),
638 )
639 .arg(
640 Arg::new("verbose")
641 .short('v')
642 .long("verbose")
643 .action(ArgAction::SetTrue),
644 )
645 .arg(
646 Arg::new("no-save")
647 .long("no-save")
648 .short('n')
649 .help("Instead of saving files, print to stdout")
650 .action(ArgAction::SetTrue),
651 )
652 .arg(
653 Arg::new("schema")
654 .short('s')
655 .help("Path to JSON schema file to use to create")
656 .long("schema")
657 .value_parser(value_parser!(String)),
658 )
659
660 ).subcommand(
661 Command::new("sign-agreement")
662 .about("given a document, sign the agreement section")
663 .arg(
664 Arg::new("agent-file")
665 .short('a')
666 .help("Path to the agent file. Otherwise use config jacs_agent_id_and_version")
667 .value_parser(value_parser!(String)),
668 )
669 .arg(
670 Arg::new("filename")
671 .short('f')
672 .required(true)
673 .help("Path to original document.")
674 .value_parser(value_parser!(String)),
675 )
676 .arg(
677 Arg::new("directory")
678 .short('d')
679 .help("Path to directory of files. Files should end with .json")
680 .value_parser(value_parser!(String)),
681 )
682 .arg(
683 Arg::new("output")
684 .short('o')
685 .help("Output filename. Filenames will always end with \"json\"")
686 .value_parser(value_parser!(String)),
687 )
688 .arg(
689 Arg::new("verbose")
690 .short('v')
691 .long("verbose")
692 .action(ArgAction::SetTrue),
693 )
694 .arg(
695 Arg::new("no-save")
696 .long("no-save")
697 .short('n')
698 .help("Instead of saving files, print to stdout")
699 .action(ArgAction::SetTrue),
700 )
701 .arg(
702 Arg::new("schema")
703 .short('s')
704 .help("Path to JSON schema file to use to create")
705 .long("schema")
706 .value_parser(value_parser!(String)),
707 )
708
709 )
710 .subcommand(
711 Command::new("verify")
712 .about(" verify a documents hash, siginatures, and schema")
713 .arg(
714 Arg::new("agent-file")
715 .short('a')
716 .help("Path to the agent file. Otherwise use config jacs_agent_id_and_version")
717 .value_parser(value_parser!(String)),
718 )
719 .arg(
720 Arg::new("filename")
721 .short('f')
722 .help("Path to input file. Must be JSON")
723 .value_parser(value_parser!(String)),
724 )
725 .arg(
726 Arg::new("directory")
727 .short('d')
728 .help("Path to directory of files. Files should end with .json")
729 .value_parser(value_parser!(String)),
730 )
731 .arg(
732 Arg::new("verbose")
733 .short('v')
734 .long("verbose")
735 .action(ArgAction::SetTrue),
736 )
737 .arg(
738 Arg::new("schema")
739 .short('s')
740 .help("Path to JSON schema file to use to validate")
741 .long("schema")
742 .value_parser(value_parser!(String)),
743 ),
744 )
745 .subcommand(
746 Command::new("extract")
747 .about(" given documents, extract embedded contents if any")
748 .arg(
749 Arg::new("agent-file")
750 .short('a')
751 .help("Path to the agent file. Otherwise use config jacs_agent_id_and_version")
752 .value_parser(value_parser!(String)),
753 )
754 .arg(
755 Arg::new("filename")
756 .short('f')
757 .help("Path to input file. Must be JSON")
758 .value_parser(value_parser!(String)),
759 )
760 .arg(
761 Arg::new("directory")
762 .short('d')
763 .help("Path to directory of files. Files should end with .json")
764 .value_parser(value_parser!(String)),
765 )
766 .arg(
767 Arg::new("verbose")
768 .short('v')
769 .long("verbose")
770 .action(ArgAction::SetTrue),
771 )
772 .arg(
773 Arg::new("schema")
774 .short('s')
775 .help("Path to JSON schema file to use to validate")
776 .long("schema")
777 .value_parser(value_parser!(String)),
778 ),
779 )
780 )
781 .subcommand(
782 Command::new("key")
783 .about("Work with JACS cryptographic keys")
784 .subcommand(
785 Command::new("reencrypt")
786 .about("Re-encrypt the private key with a new password")
787 )
788 )
789 .subcommand(
790 Command::new("mcp")
791 .about("Start the built-in JACS MCP server (stdio transport)")
792 .arg(
793 Arg::new("profile")
794 .long("profile")
795 .default_value("core")
796 .help("Tool profile: 'core' (default, core tools) or 'full' (all tools)"),
797 )
798 .subcommand(
799 Command::new("install")
800 .about("Deprecated: MCP is now built into the jacs binary")
801 .hide(true)
802 )
803 .subcommand(
804 Command::new("run")
805 .about("Deprecated: use `jacs mcp` directly")
806 .hide(true)
807 ),
808 )
809 .subcommand(
810 Command::new("a2a")
811 .about("A2A (Agent-to-Agent) trust and discovery commands")
812 .subcommand(
813 Command::new("assess")
814 .about("Assess trust level of a remote A2A Agent Card")
815 .arg(
816 Arg::new("source")
817 .required(true)
818 .help("Path to Agent Card JSON file or URL"),
819 )
820 .arg(
821 Arg::new("policy")
822 .long("policy")
823 .short('p')
824 .value_parser(["open", "verified", "strict"])
825 .default_value("verified")
826 .help("Trust policy to apply (default: verified)"),
827 )
828 .arg(
829 Arg::new("json")
830 .long("json")
831 .action(ArgAction::SetTrue)
832 .help("Output result as JSON"),
833 ),
834 )
835 .subcommand(
836 Command::new("trust")
837 .about("Add a remote A2A agent to the local trust store")
838 .arg(
839 Arg::new("source")
840 .required(true)
841 .help("Path to Agent Card JSON file or URL"),
842 ),
843 )
844 .subcommand(
845 Command::new("discover")
846 .about("Discover a remote A2A agent via its well-known Agent Card")
847 .arg(
848 Arg::new("url")
849 .required(true)
850 .help("Base URL of the agent (e.g. https://agent.example.com)"),
851 )
852 .arg(
853 Arg::new("json")
854 .long("json")
855 .action(ArgAction::SetTrue)
856 .help("Output the full Agent Card as JSON"),
857 )
858 .arg(
859 Arg::new("policy")
860 .long("policy")
861 .short('p')
862 .value_parser(["open", "verified", "strict"])
863 .default_value("verified")
864 .help("Trust policy to apply against the discovered card"),
865 ),
866 )
867 .subcommand(
868 Command::new("serve")
869 .about("Serve this agent's .well-known endpoints for A2A discovery")
870 .arg(
871 Arg::new("port")
872 .long("port")
873 .value_parser(value_parser!(u16))
874 .default_value("8080")
875 .help("Port to listen on (default: 8080)"),
876 )
877 .arg(
878 Arg::new("host")
879 .long("host")
880 .default_value("127.0.0.1")
881 .help("Host to bind to (default: 127.0.0.1)"),
882 ),
883 )
884 .subcommand(
885 Command::new("quickstart")
886 .about("Create/load an agent and start serving A2A endpoints (password required)")
887 .after_help(quickstart_password_bootstrap_help())
888 .arg(
889 Arg::new("name")
890 .long("name")
891 .value_parser(value_parser!(String))
892 .required(true)
893 .help("Agent name used for first-time quickstart creation"),
894 )
895 .arg(
896 Arg::new("domain")
897 .long("domain")
898 .value_parser(value_parser!(String))
899 .required(true)
900 .help("Agent domain used for DNS/public-key verification workflows"),
901 )
902 .arg(
903 Arg::new("description")
904 .long("description")
905 .value_parser(value_parser!(String))
906 .help("Optional human-readable agent description"),
907 )
908 .arg(
909 Arg::new("port")
910 .long("port")
911 .value_parser(value_parser!(u16))
912 .default_value("8080")
913 .help("Port to listen on (default: 8080)"),
914 )
915 .arg(
916 Arg::new("host")
917 .long("host")
918 .default_value("127.0.0.1")
919 .help("Host to bind to (default: 127.0.0.1)"),
920 )
921 .arg(
922 Arg::new("algorithm")
923 .long("algorithm")
924 .short('a')
925 .value_parser(["pq2025", "ring-Ed25519", "RSA-PSS"])
926 .help("Signing algorithm (default: pq2025)"),
927 ),
928 ),
929 )
930 .subcommand(
931 Command::new("quickstart")
932 .about("Create or load a persistent agent for instant sign/verify (password required)")
933 .after_help(quickstart_password_bootstrap_help())
934 .arg(
935 Arg::new("name")
936 .long("name")
937 .value_parser(value_parser!(String))
938 .required(true)
939 .help("Agent name used for first-time quickstart creation"),
940 )
941 .arg(
942 Arg::new("domain")
943 .long("domain")
944 .value_parser(value_parser!(String))
945 .required(true)
946 .help("Agent domain used for DNS/public-key verification workflows"),
947 )
948 .arg(
949 Arg::new("description")
950 .long("description")
951 .value_parser(value_parser!(String))
952 .help("Optional human-readable agent description"),
953 )
954 .arg(
955 Arg::new("algorithm")
956 .long("algorithm")
957 .short('a')
958 .value_parser(["ed25519", "rsa-pss", "pq2025"])
959 .default_value("pq2025")
960 .help("Signing algorithm (default: pq2025)"),
961 )
962 .arg(
963 Arg::new("sign")
964 .long("sign")
965 .help("Sign JSON from stdin and print signed document to stdout")
966 .action(ArgAction::SetTrue),
967 )
968 .arg(
969 Arg::new("file")
970 .short('f')
971 .long("file")
972 .value_parser(value_parser!(String))
973 .help("Sign a JSON file instead of reading from stdin (used with --sign)"),
974 )
975 )
976 .subcommand(
977 Command::new("init")
978 .about("Initialize JACS by creating both config and agent (with keys)")
979 .arg(
980 Arg::new("yes")
981 .long("yes")
982 .short('y')
983 .action(ArgAction::SetTrue)
984 .help("Automatically set the new agent ID in jacs.config.json without prompting"),
985 )
986 )
987 .subcommand(
988 Command::new("attest")
989 .about("Create and verify attestation documents")
990 .subcommand(
991 Command::new("create")
992 .about("Create a signed attestation")
993 .arg(
994 Arg::new("subject-type")
995 .long("subject-type")
996 .value_parser(["agent", "artifact", "workflow", "identity"])
997 .help("Type of subject being attested"),
998 )
999 .arg(
1000 Arg::new("subject-id")
1001 .long("subject-id")
1002 .value_parser(value_parser!(String))
1003 .help("Identifier of the subject"),
1004 )
1005 .arg(
1006 Arg::new("subject-digest")
1007 .long("subject-digest")
1008 .value_parser(value_parser!(String))
1009 .help("SHA-256 digest of the subject"),
1010 )
1011 .arg(
1012 Arg::new("claims")
1013 .long("claims")
1014 .value_parser(value_parser!(String))
1015 .required(true)
1016 .help("JSON array of claims, e.g. '[{\"name\":\"reviewed\",\"value\":true}]'"),
1017 )
1018 .arg(
1019 Arg::new("evidence")
1020 .long("evidence")
1021 .value_parser(value_parser!(String))
1022 .help("JSON array of evidence references"),
1023 )
1024 .arg(
1025 Arg::new("from-document")
1026 .long("from-document")
1027 .value_parser(value_parser!(String))
1028 .help("Lift attestation from an existing signed document file"),
1029 )
1030 .arg(
1031 Arg::new("output")
1032 .short('o')
1033 .long("output")
1034 .value_parser(value_parser!(String))
1035 .help("Write attestation to file instead of stdout"),
1036 ),
1037 )
1038 .subcommand(
1039 Command::new("verify")
1040 .about("Verify an attestation document")
1041 .arg(
1042 Arg::new("file")
1043 .help("Path to the attestation JSON file")
1044 .required(true)
1045 .value_parser(value_parser!(String)),
1046 )
1047 .arg(
1048 Arg::new("full")
1049 .long("full")
1050 .action(ArgAction::SetTrue)
1051 .help("Use full verification (evidence + derivation chain)"),
1052 )
1053 .arg(
1054 Arg::new("json")
1055 .long("json")
1056 .action(ArgAction::SetTrue)
1057 .help("Output result as JSON"),
1058 )
1059 .arg(
1060 Arg::new("key-dir")
1061 .long("key-dir")
1062 .value_parser(value_parser!(String))
1063 .help("Directory containing public keys for verification"),
1064 )
1065 .arg(
1066 Arg::new("max-depth")
1067 .long("max-depth")
1068 .value_parser(value_parser!(u32))
1069 .help("Maximum derivation chain depth"),
1070 ),
1071 )
1072 .subcommand(
1073 Command::new("export-dsse")
1074 .about("Export an attestation as a DSSE envelope for in-toto/SLSA")
1075 .arg(
1076 Arg::new("file")
1077 .help("Path to the signed attestation JSON file")
1078 .required(true)
1079 .value_parser(value_parser!(String)),
1080 )
1081 .arg(
1082 Arg::new("output")
1083 .short('o')
1084 .long("output")
1085 .value_parser(value_parser!(String))
1086 .help("Write DSSE envelope to file instead of stdout"),
1087 ),
1088 )
1089 .subcommand_required(true)
1090 .arg_required_else_help(true),
1091 )
1092 .subcommand(
1093 Command::new("verify")
1094 .about("Verify a signed JACS document (no agent required)")
1095 .arg(
1096 Arg::new("file")
1097 .help("Path to the signed JACS JSON file")
1098 .required_unless_present("remote")
1099 .value_parser(value_parser!(String)),
1100 )
1101 .arg(
1102 Arg::new("remote")
1103 .long("remote")
1104 .value_parser(value_parser!(String))
1105 .help("Fetch document from URL before verifying"),
1106 )
1107 .arg(
1108 Arg::new("json")
1109 .long("json")
1110 .action(ArgAction::SetTrue)
1111 .help("Output result as JSON"),
1112 )
1113 .arg(
1114 Arg::new("key-dir")
1115 .long("key-dir")
1116 .value_parser(value_parser!(String))
1117 .help("Directory containing public keys for verification"),
1118 )
1119 );
1120
1121 #[cfg(feature = "keychain")]
1123 let cmd = cmd.subcommand(
1124 Command::new("keychain")
1125 .about("Manage private key passwords in the OS keychain (per-agent)")
1126 .subcommand(
1127 Command::new("set")
1128 .about("Store a password in the OS keychain for an agent")
1129 .arg(
1130 Arg::new("agent-id")
1131 .long("agent-id")
1132 .help("Agent ID to associate the password with")
1133 .value_name("AGENT_ID")
1134 .required(true),
1135 )
1136 .arg(
1137 Arg::new("password")
1138 .long("password")
1139 .help("Password to store (if omitted, prompts interactively)")
1140 .value_name("PASSWORD"),
1141 ),
1142 )
1143 .subcommand(
1144 Command::new("get")
1145 .about("Retrieve the stored password for an agent (prints to stdout)")
1146 .arg(
1147 Arg::new("agent-id")
1148 .long("agent-id")
1149 .help("Agent ID to look up")
1150 .value_name("AGENT_ID")
1151 .required(true),
1152 ),
1153 )
1154 .subcommand(
1155 Command::new("delete")
1156 .about("Remove the stored password for an agent from the OS keychain")
1157 .arg(
1158 Arg::new("agent-id")
1159 .long("agent-id")
1160 .help("Agent ID whose password to delete")
1161 .value_name("AGENT_ID")
1162 .required(true),
1163 ),
1164 )
1165 .subcommand(
1166 Command::new("status")
1167 .about("Check if a password is stored for an agent in the OS keychain")
1168 .arg(
1169 Arg::new("agent-id")
1170 .long("agent-id")
1171 .help("Agent ID to check")
1172 .value_name("AGENT_ID")
1173 .required(true),
1174 ),
1175 )
1176 .arg_required_else_help(true),
1177 );
1178
1179 let cmd = cmd.subcommand(
1180 Command::new("convert")
1181 .about(
1182 "Convert JACS documents between JSON, YAML, and HTML formats (no agent required)",
1183 )
1184 .arg(
1185 Arg::new("to")
1186 .long("to")
1187 .required(true)
1188 .value_parser(["json", "yaml", "html"])
1189 .help("Target format: json, yaml, or html"),
1190 )
1191 .arg(
1192 Arg::new("from")
1193 .long("from")
1194 .value_parser(["json", "yaml", "html"])
1195 .help("Source format (auto-detected from extension if omitted)"),
1196 )
1197 .arg(
1198 Arg::new("file")
1199 .short('f')
1200 .long("file")
1201 .required(true)
1202 .value_parser(value_parser!(String))
1203 .help("Input file path (use '-' for stdin)"),
1204 )
1205 .arg(
1206 Arg::new("output")
1207 .short('o')
1208 .long("output")
1209 .value_parser(value_parser!(String))
1210 .help("Output file path (defaults to stdout)"),
1211 ),
1212 );
1213
1214 cmd
1215}
1216
1217pub fn main() -> Result<(), Box<dyn Error>> {
1218 install_signal_handler();
1220
1221 let _shutdown_guard = ShutdownGuard::new();
1223 let matches = build_cli().arg_required_else_help(true).get_matches();
1224
1225 match matches.subcommand() {
1226 Some(("version", _sub_matches)) => {
1227 println!("{}", env!("CARGO_PKG_DESCRIPTION"));
1228 println!(
1229 "{} version: {}",
1230 env!("CARGO_PKG_NAME"),
1231 env!("CARGO_PKG_VERSION")
1232 );
1233 return Ok(());
1234 }
1235 Some(("config", config_matches)) => match config_matches.subcommand() {
1236 Some(("create", _create_matches)) => {
1237 handle_config_create()?;
1239 }
1240 Some(("read", _read_matches)) => {
1241 let config_path = "./jacs.config.json";
1242 match jacs::config::Config::from_file(config_path) {
1243 Ok(mut config) => {
1244 config.apply_env_overrides();
1245 println!("{}", config);
1246 }
1247 Err(e) => {
1248 eprintln!("Could not load config from '{}': {}", config_path, e);
1249 process::exit(1);
1250 }
1251 }
1252 }
1253 _ => println!("please enter subcommand see jacs config --help"),
1254 },
1255 Some(("agent", agent_matches)) => match agent_matches.subcommand() {
1256 Some(("dns", sub_m)) => {
1257 let domain = sub_m.get_one::<String>("domain").cloned();
1258 let agent_id_arg = sub_m.get_one::<String>("agent-id").cloned();
1259 let ttl = *sub_m.get_one::<u32>("ttl").unwrap();
1260 let enc = sub_m
1261 .get_one::<String>("encoding")
1262 .map(|s| s.as_str())
1263 .unwrap_or("base64");
1264 let provider = sub_m
1265 .get_one::<String>("provider")
1266 .map(|s| s.as_str())
1267 .unwrap_or("plain");
1268
1269 let _agent_file = sub_m.get_one::<String>("agent-file").cloned();
1271 let non_strict = *sub_m.get_one::<bool>("no-dns").unwrap_or(&false);
1272 let ignore_dns = *sub_m.get_one::<bool>("ignore-dns").unwrap_or(&false);
1273 let require_strict = *sub_m
1274 .get_one::<bool>("require-strict-dns")
1275 .unwrap_or(&false);
1276 let require_dns = *sub_m.get_one::<bool>("require-dns").unwrap_or(&false);
1277 let agent: Agent = load_agent_with_cli_dns_policy(
1278 ignore_dns,
1279 require_strict,
1280 require_dns,
1281 non_strict,
1282 )
1283 .expect("Failed to load agent from config");
1284 let agent_id = agent_id_arg.unwrap_or_else(|| agent.get_id().unwrap_or_default());
1285 let pk = agent.get_public_key().expect("public key");
1286 let digest = match enc {
1287 "hex" => dns_bootstrap::pubkey_digest_hex(&pk),
1288 _ => dns_bootstrap::pubkey_digest_b64(&pk),
1289 };
1290 let domain_final = domain
1291 .or_else(|| {
1292 agent
1293 .config
1294 .as_ref()
1295 .and_then(|c| c.jacs_agent_domain().clone())
1296 })
1297 .expect("domain required via --domain or jacs_agent_domain in config");
1298
1299 let rr = dns_bootstrap::build_dns_record(
1300 &domain_final,
1301 ttl,
1302 &agent_id,
1303 &digest,
1304 if enc == "hex" {
1305 dns_bootstrap::DigestEncoding::Hex
1306 } else {
1307 dns_bootstrap::DigestEncoding::Base64
1308 },
1309 );
1310
1311 println!("Plain/BIND:\n{}", dns_bootstrap::emit_plain_bind(&rr));
1312 match provider {
1313 "aws" => println!(
1314 "\nRoute53 change-batch JSON:\n{}",
1315 dns_bootstrap::emit_route53_change_batch(&rr)
1316 ),
1317 "azure" => println!(
1318 "\nAzure CLI:\n{}",
1319 dns_bootstrap::emit_azure_cli(
1320 &rr,
1321 "$RESOURCE_GROUP",
1322 &domain_final,
1323 "_v1.agent.jacs"
1324 )
1325 ),
1326 "cloudflare" => println!(
1327 "\nCloudflare curl:\n{}",
1328 dns_bootstrap::emit_cloudflare_curl(&rr, "$ZONE_ID")
1329 ),
1330 _ => {}
1331 }
1332 println!(
1333 "\nChecklist: Ensure DNSSEC is enabled for {domain} and DS is published at registrar.",
1334 domain = domain_final
1335 );
1336 }
1337 Some(("create", create_matches)) => {
1338 let filename = create_matches.get_one::<String>("filename");
1340 let create_keys = *create_matches.get_one::<bool>("create-keys").unwrap();
1341
1342 handle_agent_create(filename, create_keys)?;
1344 }
1345 Some(("verify", verify_matches)) => {
1346 let _agentfile = verify_matches.get_one::<String>("agent-file");
1347 let non_strict = *verify_matches.get_one::<bool>("no-dns").unwrap_or(&false);
1348 let require_dns = *verify_matches
1349 .get_one::<bool>("require-dns")
1350 .unwrap_or(&false);
1351 let require_strict = *verify_matches
1352 .get_one::<bool>("require-strict-dns")
1353 .unwrap_or(&false);
1354 let ignore_dns = *verify_matches
1355 .get_one::<bool>("ignore-dns")
1356 .unwrap_or(&false);
1357 let mut agent: Agent = load_agent_with_cli_dns_policy(
1358 ignore_dns,
1359 require_strict,
1360 require_dns,
1361 non_strict,
1362 )
1363 .expect("Failed to load agent from config");
1364 agent
1365 .verify_self_signature()
1366 .expect("signature verification");
1367 println!(
1368 "Agent {} signature verified OK.",
1369 agent.get_lookup_id().expect("jacsId")
1370 );
1371 }
1372 Some(("lookup", lookup_matches)) => {
1373 let domain = lookup_matches
1374 .get_one::<String>("domain")
1375 .expect("domain required");
1376 let skip_dns = *lookup_matches.get_one::<bool>("no-dns").unwrap_or(&false);
1377 let strict_dns = *lookup_matches.get_one::<bool>("strict").unwrap_or(&false);
1378
1379 println!("Agent Lookup: {}\n", domain);
1380
1381 println!("Public Key (/.well-known/jacs-pubkey.json):");
1383 let url = format!("https://{}/.well-known/jacs-pubkey.json", domain);
1384 let client = reqwest::blocking::Client::builder()
1385 .timeout(std::time::Duration::from_secs(10))
1386 .build()
1387 .expect("HTTP client");
1388 match client.get(&url).send() {
1389 Ok(response) => {
1390 if response.status().is_success() {
1391 match response.json::<serde_json::Value>() {
1392 Ok(json) => {
1393 println!(
1394 " Agent ID: {}",
1395 json.get("agentId")
1396 .and_then(|v| v.as_str())
1397 .unwrap_or("Not specified")
1398 );
1399 println!(
1400 " Algorithm: {}",
1401 json.get("algorithm")
1402 .and_then(|v| v.as_str())
1403 .unwrap_or("Not specified")
1404 );
1405 println!(
1406 " Public Key Hash: {}",
1407 json.get("publicKeyHash")
1408 .and_then(|v| v.as_str())
1409 .unwrap_or("Not specified")
1410 );
1411 if let Some(pk) = json.get("publicKey").and_then(|v| v.as_str())
1412 {
1413 let preview = if pk.len() > 60 {
1414 format!("{}...", &pk[..60])
1415 } else {
1416 pk.to_string()
1417 };
1418 println!(" Public Key: {}", preview);
1419 }
1420 }
1421 Err(e) => println!(" Error parsing response: {}", e),
1422 }
1423 } else {
1424 println!(" HTTP error: {}", response.status());
1425 }
1426 }
1427 Err(e) => println!(" Error fetching: {}", e),
1428 }
1429
1430 println!();
1431
1432 if !skip_dns {
1434 println!("DNS TXT Record (_v1.agent.jacs.{}):", domain);
1435 let owner = format!("_v1.agent.jacs.{}", domain.trim_end_matches('.'));
1436 let lookup_result = if strict_dns {
1437 dns_bootstrap::resolve_txt_dnssec(&owner)
1438 } else {
1439 dns_bootstrap::resolve_txt_insecure(&owner)
1440 };
1441 match lookup_result {
1442 Ok(txt) => {
1443 match dns_bootstrap::parse_agent_txt(&txt) {
1445 Ok(parsed) => {
1446 println!(" Version: {}", parsed.v);
1447 println!(" Agent ID: {}", parsed.jacs_agent_id);
1448 println!(" Algorithm: {:?}", parsed.alg);
1449 println!(" Encoding: {:?}", parsed.enc);
1450 println!(" Public Key Hash: {}", parsed.digest);
1451 }
1452 Err(e) => println!(" Error parsing TXT: {}", e),
1453 }
1454 println!(" Raw TXT: {}", txt);
1455 }
1456 Err(e) => {
1457 println!(" No DNS TXT record found: {}", e);
1458 if strict_dns {
1459 println!(" (Strict DNSSEC validation was required)");
1460 }
1461 }
1462 }
1463 } else {
1464 println!("DNS TXT Record: Skipped (--no-dns)");
1465 }
1466 }
1467 _ => println!("please enter subcommand see jacs agent --help"),
1468 },
1469
1470 Some(("task", task_matches)) => match task_matches.subcommand() {
1471 Some(("create", create_matches)) => {
1472 let _agentfile = create_matches.get_one::<String>("agent-file");
1473 let mut agent: Agent = load_agent().expect("failed to load agent for task create");
1474 let name = create_matches
1475 .get_one::<String>("name")
1476 .expect("task name is required");
1477 let description = create_matches
1478 .get_one::<String>("description")
1479 .expect("task description is required");
1480 println!(
1481 "{}",
1482 create_task(&mut agent, name.to_string(), description.to_string()).unwrap()
1483 );
1484 }
1485 Some(("update", update_matches)) => {
1486 let mut agent: Agent = load_agent().expect("failed to load agent for task update");
1487 let task_key = update_matches
1488 .get_one::<String>("task-key")
1489 .expect("task key is required");
1490 let filename = update_matches
1491 .get_one::<String>("filename")
1492 .expect("filename is required");
1493 let updated_json = std::fs::read_to_string(filename)
1494 .unwrap_or_else(|e| panic!("Failed to read '{}': {}", filename, e));
1495 println!(
1496 "{}",
1497 jacs::update_task(&mut agent, task_key, &updated_json).unwrap()
1498 );
1499 }
1500 _ => println!("please enter subcommand see jacs task --help"),
1501 },
1502 Some(("document", document_matches)) => match document_matches.subcommand() {
1503 Some(("create", create_matches)) => {
1504 let filename = create_matches.get_one::<String>("filename");
1505 let outputfilename = create_matches.get_one::<String>("output");
1506 let directory = create_matches.get_one::<String>("directory");
1507 let _verbose = *create_matches.get_one::<bool>("verbose").unwrap_or(&false);
1508 let no_save = *create_matches.get_one::<bool>("no-save").unwrap_or(&false);
1509 let _agentfile = create_matches.get_one::<String>("agent-file");
1510 let schema = create_matches.get_one::<String>("schema");
1511 let attachments = create_matches
1512 .get_one::<String>("attach")
1513 .map(|s| s.as_str());
1514 let embed: Option<bool> = create_matches.get_one::<bool>("embed").copied();
1515
1516 let mut agent: Agent = load_agent().expect("REASON");
1517
1518 let _attachment_links = agent.parse_attachement_arg(attachments);
1519 let _ = create_documents(
1520 &mut agent,
1521 filename,
1522 directory,
1523 outputfilename,
1524 attachments,
1525 embed,
1526 no_save,
1527 schema,
1528 );
1529 }
1530 Some(("update", create_matches)) => {
1533 let new_filename = create_matches.get_one::<String>("new").unwrap();
1534 let original_filename = create_matches.get_one::<String>("filename").unwrap();
1535 let outputfilename = create_matches.get_one::<String>("output");
1536 let _verbose = *create_matches.get_one::<bool>("verbose").unwrap_or(&false);
1537 let no_save = *create_matches.get_one::<bool>("no-save").unwrap_or(&false);
1538 let _agentfile = create_matches.get_one::<String>("agent-file");
1539 let schema = create_matches.get_one::<String>("schema");
1540 let attachments = create_matches
1541 .get_one::<String>("attach")
1542 .map(|s| s.as_str());
1543 let embed: Option<bool> = create_matches.get_one::<bool>("embed").copied();
1544
1545 let mut agent: Agent = load_agent().expect("REASON");
1546
1547 let attachment_links = agent.parse_attachement_arg(attachments);
1548 update_documents(
1549 &mut agent,
1550 new_filename,
1551 original_filename,
1552 outputfilename,
1553 attachment_links,
1554 embed,
1555 no_save,
1556 schema,
1557 )?;
1558 }
1559 Some(("sign-agreement", create_matches)) => {
1560 let filename = create_matches.get_one::<String>("filename");
1561 let directory = create_matches.get_one::<String>("directory");
1562 let _verbose = *create_matches.get_one::<bool>("verbose").unwrap_or(&false);
1563 let _agentfile = create_matches.get_one::<String>("agent-file");
1564 let mut agent: Agent = load_agent().expect("REASON");
1565 let schema = create_matches.get_one::<String>("schema");
1566 let _no_save = *create_matches.get_one::<bool>("no-save").unwrap_or(&false);
1567
1568 sign_documents(&mut agent, schema, filename, directory)?;
1570 }
1571 Some(("check-agreement", create_matches)) => {
1572 let filename = create_matches.get_one::<String>("filename");
1573 let directory = create_matches.get_one::<String>("directory");
1574 let _agentfile = create_matches.get_one::<String>("agent-file");
1575 let mut agent: Agent = load_agent().expect("REASON");
1576 let schema = create_matches.get_one::<String>("schema");
1577
1578 let _files: Vec<String> = default_set_file_list(filename, directory, None)
1580 .expect("Failed to determine file list");
1581 check_agreement(&mut agent, schema, filename, directory)?;
1582 }
1583 Some(("create-agreement", create_matches)) => {
1584 let filename = create_matches.get_one::<String>("filename");
1585 let directory = create_matches.get_one::<String>("directory");
1586 let _verbose = *create_matches.get_one::<bool>("verbose").unwrap_or(&false);
1587 let _agentfile = create_matches.get_one::<String>("agent-file");
1588
1589 let schema = create_matches.get_one::<String>("schema");
1590 let no_save = *create_matches.get_one::<bool>("no-save").unwrap_or(&false);
1591 let agentids: Vec<String> = create_matches .get_many::<String>("agentids")
1593 .unwrap_or_default()
1594 .map(|s| s.to_string())
1595 .collect();
1596
1597 let mut agent: Agent = load_agent().expect("REASON");
1598 let _ =
1600 create_agreement(&mut agent, agentids, filename, schema, no_save, directory);
1601 }
1602
1603 Some(("verify", verify_matches)) => {
1604 let filename = verify_matches.get_one::<String>("filename");
1605 let directory = verify_matches.get_one::<String>("directory");
1606 let _verbose = *verify_matches.get_one::<bool>("verbose").unwrap_or(&false);
1607 let _agentfile = verify_matches.get_one::<String>("agent-file");
1608 let mut agent: Agent = load_agent().expect("REASON");
1609 let schema = verify_matches.get_one::<String>("schema");
1610 verify_documents(&mut agent, schema, filename, directory)?;
1612 }
1613
1614 Some(("extract", extract_matches)) => {
1615 let filename = extract_matches.get_one::<String>("filename");
1616 let directory = extract_matches.get_one::<String>("directory");
1617 let _verbose = *extract_matches.get_one::<bool>("verbose").unwrap_or(&false);
1618 let _agentfile = extract_matches.get_one::<String>("agent-file");
1619 let mut agent: Agent = load_agent().expect("REASON");
1620 let schema = extract_matches.get_one::<String>("schema");
1621 let _files: Vec<String> = default_set_file_list(filename, directory, None)
1623 .expect("Failed to determine file list");
1624 extract_documents(&mut agent, schema, filename, directory)?;
1626 }
1627
1628 _ => println!("please enter subcommand see jacs document --help"),
1629 },
1630 Some(("key", key_matches)) => match key_matches.subcommand() {
1631 Some(("reencrypt", _reencrypt_matches)) => {
1632 use jacs::crypt::aes_encrypt::password_requirements;
1633 use jacs::simple::SimpleAgent;
1634
1635 let agent = SimpleAgent::load(None, None).map_err(|e| -> Box<dyn Error> {
1637 Box::new(std::io::Error::new(
1638 std::io::ErrorKind::Other,
1639 format!("Failed to load agent: {}", e),
1640 ))
1641 })?;
1642
1643 println!("Re-encrypting private key.\n");
1644
1645 println!("Enter current password:");
1647 let old_password = read_password().map_err(|e| -> Box<dyn Error> {
1648 Box::new(std::io::Error::new(
1649 std::io::ErrorKind::Other,
1650 format!("Error reading password: {}", e),
1651 ))
1652 })?;
1653
1654 if old_password.is_empty() {
1655 eprintln!("Error: current password cannot be empty.");
1656 process::exit(1);
1657 }
1658
1659 println!("\n{}", password_requirements());
1661 println!("\nEnter new password:");
1662 let new_password = read_password().map_err(|e| -> Box<dyn Error> {
1663 Box::new(std::io::Error::new(
1664 std::io::ErrorKind::Other,
1665 format!("Error reading password: {}", e),
1666 ))
1667 })?;
1668
1669 println!("Confirm new password:");
1670 let new_password_confirm = read_password().map_err(|e| -> Box<dyn Error> {
1671 Box::new(std::io::Error::new(
1672 std::io::ErrorKind::Other,
1673 format!("Error reading password: {}", e),
1674 ))
1675 })?;
1676
1677 if new_password != new_password_confirm {
1678 eprintln!("Error: new passwords do not match.");
1679 process::exit(1);
1680 }
1681
1682 jacs::simple::advanced::reencrypt_key(&agent, &old_password, &new_password)
1683 .map_err(|e| -> Box<dyn Error> {
1684 Box::new(std::io::Error::new(
1685 std::io::ErrorKind::Other,
1686 format!("Re-encryption failed: {}", e),
1687 ))
1688 })?;
1689
1690 println!("Private key re-encrypted successfully.");
1691 }
1692 _ => println!("please enter subcommand see jacs key --help"),
1693 },
1694 #[cfg(feature = "mcp")]
1695 Some(("mcp", mcp_matches)) => match mcp_matches.subcommand() {
1696 Some(("install", _)) => {
1697 eprintln!("`jacs mcp install` is no longer needed.");
1698 eprintln!("MCP is built into the jacs binary. Use `jacs mcp` to serve.");
1699 process::exit(0);
1700 }
1701 Some(("run", _)) => {
1702 eprintln!("`jacs mcp run` is no longer needed.");
1703 eprintln!("Use `jacs mcp` directly to start the MCP server.");
1704 process::exit(0);
1705 }
1706 _ => {
1707 let profile_str = mcp_matches.get_one::<String>("profile").map(|s| s.as_str());
1708 let profile = jacs_mcp::Profile::resolve(profile_str);
1709 let (agent, info) = jacs_mcp::load_agent_from_config_env_with_info()?;
1710 let state_roots = info["data_directory"]
1711 .as_str()
1712 .map(std::path::PathBuf::from)
1713 .into_iter()
1714 .collect();
1715 let server = jacs_mcp::JacsMcpServer::with_profile_and_state_roots(
1716 agent,
1717 profile,
1718 state_roots,
1719 );
1720 let rt = tokio::runtime::Runtime::new()?;
1721 rt.block_on(jacs_mcp::serve_stdio(server))?;
1722 }
1723 },
1724 #[cfg(not(feature = "mcp"))]
1725 Some(("mcp", _)) => {
1726 eprintln!(
1727 "MCP support not compiled. Install with default features: cargo install jacs-cli"
1728 );
1729 process::exit(1);
1730 }
1731 Some(("a2a", a2a_matches)) => match a2a_matches.subcommand() {
1732 Some(("assess", assess_matches)) => {
1733 use jacs::a2a::AgentCard;
1734 use jacs::a2a::trust::{A2ATrustPolicy, assess_a2a_agent};
1735
1736 let source = assess_matches.get_one::<String>("source").unwrap();
1737 let policy_str = assess_matches
1738 .get_one::<String>("policy")
1739 .map(|s| s.as_str())
1740 .unwrap_or("verified");
1741 let json_output = *assess_matches.get_one::<bool>("json").unwrap_or(&false);
1742
1743 let policy = A2ATrustPolicy::from_str_loose(policy_str)
1744 .map_err(|e| Box::<dyn Error>::from(format!("Invalid policy: {}", e)))?;
1745
1746 let card_json = if source.starts_with("http://") || source.starts_with("https://") {
1748 let client = reqwest::blocking::Client::builder()
1749 .timeout(std::time::Duration::from_secs(10))
1750 .build()
1751 .map_err(|e| format!("HTTP client error: {}", e))?;
1752 client
1753 .get(source.as_str())
1754 .send()
1755 .map_err(|e| format!("Fetch failed: {}", e))?
1756 .text()
1757 .map_err(|e| format!("Read body failed: {}", e))?
1758 } else {
1759 std::fs::read_to_string(source)
1760 .map_err(|e| format!("Read file failed: {}", e))?
1761 };
1762
1763 let card: AgentCard = serde_json::from_str(&card_json)
1764 .map_err(|e| format!("Invalid Agent Card JSON: {}", e))?;
1765
1766 let agent = jacs::get_empty_agent();
1768 let assessment = assess_a2a_agent(&agent, &card, policy);
1769
1770 if json_output {
1771 println!(
1772 "{}",
1773 serde_json::to_string_pretty(&assessment)
1774 .expect("assessment serialization")
1775 );
1776 } else {
1777 println!("Agent: {}", card.name);
1778 println!(
1779 "Agent ID: {}",
1780 assessment.agent_id.as_deref().unwrap_or("(not specified)")
1781 );
1782 println!("Policy: {}", assessment.policy);
1783 println!("Trust Level: {}", assessment.trust_level);
1784 println!(
1785 "Allowed: {}",
1786 if assessment.allowed { "YES" } else { "NO" }
1787 );
1788 println!("JACS Ext: {}", assessment.jacs_registered);
1789 println!("Reason: {}", assessment.reason);
1790 if !assessment.allowed {
1791 process::exit(1);
1792 }
1793 }
1794 }
1795 Some(("trust", trust_matches)) => {
1796 use jacs::a2a::AgentCard;
1797 use jacs::trust;
1798
1799 let source = trust_matches.get_one::<String>("source").unwrap();
1800
1801 let card_json = if source.starts_with("http://") || source.starts_with("https://") {
1803 let client = reqwest::blocking::Client::builder()
1804 .timeout(std::time::Duration::from_secs(10))
1805 .build()
1806 .map_err(|e| format!("HTTP client error: {}", e))?;
1807 client
1808 .get(source.as_str())
1809 .send()
1810 .map_err(|e| format!("Fetch failed: {}", e))?
1811 .text()
1812 .map_err(|e| format!("Read body failed: {}", e))?
1813 } else {
1814 std::fs::read_to_string(source)
1815 .map_err(|e| format!("Read file failed: {}", e))?
1816 };
1817
1818 let card: AgentCard = serde_json::from_str(&card_json)
1819 .map_err(|e| format!("Invalid Agent Card JSON: {}", e))?;
1820
1821 let agent_id = card
1823 .metadata
1824 .as_ref()
1825 .and_then(|m| m.get("jacsId"))
1826 .and_then(|v| v.as_str())
1827 .ok_or("Agent Card has no jacsId in metadata")?;
1828 let agent_version = card
1829 .metadata
1830 .as_ref()
1831 .and_then(|m| m.get("jacsVersion"))
1832 .and_then(|v| v.as_str())
1833 .unwrap_or("unknown");
1834
1835 let key = format!("{}:{}", agent_id, agent_version);
1836
1837 trust::trust_a2a_card(&key, &card_json)?;
1841
1842 println!(
1843 "Saved unverified A2A Agent Card bookmark '{}' ({})",
1844 card.name, agent_id
1845 );
1846 println!(" Version: {}", agent_version);
1847 println!(" Bookmark key: {}", key);
1848 println!(
1849 " This entry is not cryptographically trusted until verified JACS identity material is added."
1850 );
1851 }
1852 Some(("discover", discover_matches)) => {
1853 use jacs::a2a::AgentCard;
1854 use jacs::a2a::trust::{A2ATrustPolicy, assess_a2a_agent};
1855
1856 let base_url = discover_matches.get_one::<String>("url").unwrap();
1857 let json_output = *discover_matches.get_one::<bool>("json").unwrap_or(&false);
1858 let policy_str = discover_matches
1859 .get_one::<String>("policy")
1860 .map(|s| s.as_str())
1861 .unwrap_or("verified");
1862
1863 let policy = A2ATrustPolicy::from_str_loose(policy_str)
1864 .map_err(|e| Box::<dyn Error>::from(format!("Invalid policy: {}", e)))?;
1865
1866 let trimmed = base_url.trim_end_matches('/');
1868 let card_url = format!("{}/.well-known/agent-card.json", trimmed);
1869
1870 let client = reqwest::blocking::Client::builder()
1871 .timeout(std::time::Duration::from_secs(10))
1872 .build()
1873 .map_err(|e| format!("HTTP client error: {}", e))?;
1874
1875 let response = client
1876 .get(&card_url)
1877 .send()
1878 .map_err(|e| format!("Failed to fetch {}: {}", card_url, e))?;
1879
1880 if !response.status().is_success() {
1881 eprintln!(
1882 "Failed to discover agent at {}: HTTP {}",
1883 card_url,
1884 response.status()
1885 );
1886 process::exit(1);
1887 }
1888
1889 let card_json = response
1890 .text()
1891 .map_err(|e| format!("Read body failed: {}", e))?;
1892
1893 let card: AgentCard = serde_json::from_str(&card_json)
1894 .map_err(|e| format!("Invalid Agent Card JSON at {}: {}", card_url, e))?;
1895
1896 if json_output {
1897 println!(
1899 "{}",
1900 serde_json::to_string_pretty(&card).expect("card serialization")
1901 );
1902 } else {
1903 println!("Discovered A2A Agent: {}", card.name);
1905 println!(" Description: {}", card.description);
1906 println!(" Version: {}", card.version);
1907 println!(" Protocol: {}", card.protocol_versions.join(", "));
1908
1909 for iface in &card.supported_interfaces {
1911 println!(" Endpoint: {} ({})", iface.url, iface.protocol_binding);
1912 }
1913
1914 if !card.skills.is_empty() {
1916 println!(" Skills:");
1917 for skill in &card.skills {
1918 println!(" - {} ({})", skill.name, skill.id);
1919 }
1920 }
1921
1922 let has_jacs = card
1924 .capabilities
1925 .extensions
1926 .as_ref()
1927 .map(|exts| exts.iter().any(|e| e.uri == jacs::a2a::JACS_EXTENSION_URI))
1928 .unwrap_or(false);
1929 println!(" JACS: {}", if has_jacs { "YES" } else { "NO" });
1930
1931 let agent = jacs::get_empty_agent();
1933 let assessment = assess_a2a_agent(&agent, &card, policy);
1934 println!(
1935 " Trust: {} ({})",
1936 assessment.trust_level, assessment.reason
1937 );
1938 if !assessment.allowed {
1939 println!(
1940 " WARNING: Agent not allowed under '{}' policy",
1941 policy_str
1942 );
1943 }
1944 }
1945 }
1946 Some(("serve", serve_matches)) => {
1947 let port = *serve_matches.get_one::<u16>("port").unwrap();
1948 let host = serve_matches
1949 .get_one::<String>("host")
1950 .map(|s| s.as_str())
1951 .unwrap_or("127.0.0.1");
1952
1953 ensure_cli_private_key_password().map_err(|e| -> Box<dyn Error> {
1955 Box::new(std::io::Error::other(format!(
1956 "Password bootstrap failed: {}\n\n{}",
1957 e,
1958 quickstart_password_bootstrap_help()
1959 )))
1960 })?;
1961 let (agent, info) = jacs::simple::advanced::quickstart(
1962 "jacs-agent",
1963 "localhost",
1964 Some("JACS A2A agent"),
1965 None,
1966 None,
1967 )
1968 .map_err(|e| wrap_quickstart_error_with_password_help("Failed to load agent", e))?;
1969
1970 let agent_card = jacs::a2a::simple::export_agent_card(&agent).map_err(
1972 |e| -> Box<dyn Error> {
1973 Box::new(std::io::Error::new(
1974 std::io::ErrorKind::Other,
1975 format!("Failed to export Agent Card: {}", e),
1976 ))
1977 },
1978 )?;
1979
1980 let documents = jacs::a2a::simple::generate_well_known_documents(&agent, None)
1982 .map_err(|e| -> Box<dyn Error> {
1983 Box::new(std::io::Error::new(
1984 std::io::ErrorKind::Other,
1985 format!("Failed to generate well-known documents: {}", e),
1986 ))
1987 })?;
1988
1989 let mut routes: std::collections::HashMap<String, String> =
1991 std::collections::HashMap::new();
1992 for (path, value) in &documents {
1993 routes.insert(
1994 path.clone(),
1995 serde_json::to_string_pretty(value).unwrap_or_default(),
1996 );
1997 }
1998
1999 let addr = format!("{}:{}", host, port);
2000 let server = tiny_http::Server::http(&addr)
2001 .map_err(|e| format!("Failed to start server on {}: {}", addr, e))?;
2002
2003 println!("Serving A2A well-known endpoints at http://{}", addr);
2004 println!(" Agent: {} ({})", agent_card.name, info.agent_id);
2005 println!(" Endpoints:");
2006 for path in routes.keys() {
2007 println!(" http://{}{}", addr, path);
2008 }
2009 println!("\nPress Ctrl+C to stop.");
2010
2011 for request in server.incoming_requests() {
2012 let url = request.url().to_string();
2013 if let Some(body) = routes.get(&url) {
2014 let response = tiny_http::Response::from_string(body.clone()).with_header(
2015 tiny_http::Header::from_bytes(
2016 &b"Content-Type"[..],
2017 &b"application/json"[..],
2018 )
2019 .unwrap(),
2020 );
2021 let _ = request.respond(response);
2022 } else {
2023 let response =
2024 tiny_http::Response::from_string("{\"error\": \"not found\"}")
2025 .with_status_code(404)
2026 .with_header(
2027 tiny_http::Header::from_bytes(
2028 &b"Content-Type"[..],
2029 &b"application/json"[..],
2030 )
2031 .unwrap(),
2032 );
2033 let _ = request.respond(response);
2034 }
2035 }
2036 }
2037 Some(("quickstart", qs_matches)) => {
2038 let port = *qs_matches.get_one::<u16>("port").unwrap();
2039 let host = qs_matches
2040 .get_one::<String>("host")
2041 .map(|s| s.as_str())
2042 .unwrap_or("127.0.0.1");
2043 let algorithm = qs_matches
2044 .get_one::<String>("algorithm")
2045 .map(|s| s.as_str());
2046 let name = qs_matches
2047 .get_one::<String>("name")
2048 .map(|s| s.as_str())
2049 .unwrap_or("jacs-agent");
2050 let domain = qs_matches
2051 .get_one::<String>("domain")
2052 .map(|s| s.as_str())
2053 .unwrap_or("localhost");
2054 let description = qs_matches
2055 .get_one::<String>("description")
2056 .map(|s| s.as_str());
2057
2058 ensure_cli_private_key_password().map_err(|e| -> Box<dyn Error> {
2060 Box::new(std::io::Error::other(format!(
2061 "Password bootstrap failed: {}\n\n{}",
2062 e,
2063 quickstart_password_bootstrap_help()
2064 )))
2065 })?;
2066 let (agent, info) =
2067 jacs::simple::advanced::quickstart(name, domain, description, algorithm, None)
2068 .map_err(|e| {
2069 wrap_quickstart_error_with_password_help(
2070 "Failed to quickstart agent",
2071 e,
2072 )
2073 })?;
2074
2075 let agent_card = jacs::a2a::simple::export_agent_card(&agent).map_err(
2077 |e| -> Box<dyn Error> {
2078 Box::new(std::io::Error::new(
2079 std::io::ErrorKind::Other,
2080 format!("Failed to export Agent Card: {}", e),
2081 ))
2082 },
2083 )?;
2084
2085 let documents = jacs::a2a::simple::generate_well_known_documents(&agent, None)
2087 .map_err(|e| -> Box<dyn Error> {
2088 Box::new(std::io::Error::new(
2089 std::io::ErrorKind::Other,
2090 format!("Failed to generate well-known documents: {}", e),
2091 ))
2092 })?;
2093
2094 let mut routes: std::collections::HashMap<String, String> =
2096 std::collections::HashMap::new();
2097 for (path, value) in &documents {
2098 routes.insert(
2099 path.clone(),
2100 serde_json::to_string_pretty(value).unwrap_or_default(),
2101 );
2102 }
2103
2104 let addr = format!("{}:{}", host, port);
2105 let server = tiny_http::Server::http(&addr)
2106 .map_err(|e| format!("Failed to start server on {}: {}", addr, e))?;
2107
2108 println!("A2A Quickstart");
2109 println!("==============");
2110 println!("Agent: {} ({})", agent_card.name, info.agent_id);
2111 println!("Algorithm: {}", algorithm.unwrap_or("pq2025"));
2112 println!();
2113 println!("Discovery URL: http://{}/.well-known/agent-card.json", addr);
2114 println!();
2115 println!("Endpoints:");
2116 for path in routes.keys() {
2117 println!(" http://{}{}", addr, path);
2118 }
2119 println!();
2120 println!("Press Ctrl+C to stop.");
2121
2122 for request in server.incoming_requests() {
2123 let url = request.url().to_string();
2124 if let Some(body) = routes.get(&url) {
2125 let response = tiny_http::Response::from_string(body.clone()).with_header(
2126 tiny_http::Header::from_bytes(
2127 &b"Content-Type"[..],
2128 &b"application/json"[..],
2129 )
2130 .unwrap(),
2131 );
2132 let _ = request.respond(response);
2133 } else {
2134 let response =
2135 tiny_http::Response::from_string("{\"error\": \"not found\"}")
2136 .with_status_code(404)
2137 .with_header(
2138 tiny_http::Header::from_bytes(
2139 &b"Content-Type"[..],
2140 &b"application/json"[..],
2141 )
2142 .unwrap(),
2143 );
2144 let _ = request.respond(response);
2145 }
2146 }
2147 }
2148 _ => println!("please enter subcommand see jacs a2a --help"),
2149 },
2150 Some(("quickstart", qs_matches)) => {
2151 let algorithm = qs_matches
2152 .get_one::<String>("algorithm")
2153 .map(|s| s.as_str());
2154 let name = qs_matches
2155 .get_one::<String>("name")
2156 .map(|s| s.as_str())
2157 .unwrap_or("jacs-agent");
2158 let domain = qs_matches
2159 .get_one::<String>("domain")
2160 .map(|s| s.as_str())
2161 .unwrap_or("localhost");
2162 let description = qs_matches
2163 .get_one::<String>("description")
2164 .map(|s| s.as_str());
2165 let do_sign = *qs_matches.get_one::<bool>("sign").unwrap_or(&false);
2166 let sign_file = qs_matches.get_one::<String>("file");
2167
2168 if let Err(e) = ensure_cli_private_key_password() {
2171 eprintln!("Note: {}", e);
2172 }
2173
2174 if env::var("JACS_PRIVATE_KEY_PASSWORD")
2176 .unwrap_or_default()
2177 .trim()
2178 .is_empty()
2179 {
2180 eprintln!("{}", jacs::crypt::aes_encrypt::password_requirements());
2181 let password = loop {
2182 eprintln!("Enter a password for your JACS private key:");
2183 let pw =
2184 read_password().map_err(|e| format!("Failed to read password: {}", e))?;
2185 if pw.trim().is_empty() {
2186 eprintln!("Password cannot be empty. Please try again.");
2187 continue;
2188 }
2189 eprintln!("Confirm password:");
2190 let pw2 =
2191 read_password().map_err(|e| format!("Failed to read password: {}", e))?;
2192 if pw != pw2 {
2193 eprintln!("Passwords do not match. Please try again.");
2194 continue;
2195 }
2196 break pw;
2197 };
2198
2199 unsafe {
2201 env::set_var("JACS_PRIVATE_KEY_PASSWORD", &password);
2202 }
2203
2204 }
2207
2208 let (agent, info) =
2209 jacs::simple::advanced::quickstart(name, domain, description, algorithm, None)
2210 .map_err(|e| {
2211 wrap_quickstart_error_with_password_help("Quickstart failed", e)
2212 })?;
2213
2214 if do_sign {
2215 let input = if let Some(file_path) = sign_file {
2217 std::fs::read_to_string(file_path)?
2218 } else {
2219 use std::io::Read;
2220 let mut buf = String::new();
2221 std::io::stdin().read_to_string(&mut buf)?;
2222 buf
2223 };
2224
2225 let value: serde_json::Value = serde_json::from_str(&input)
2226 .map_err(|e| format!("Invalid JSON input: {}", e))?;
2227
2228 let signed = agent.sign_message(&value).map_err(|e| -> Box<dyn Error> {
2229 Box::new(std::io::Error::new(
2230 std::io::ErrorKind::Other,
2231 format!("Signing failed: {}", e),
2232 ))
2233 })?;
2234
2235 println!("{}", signed.raw);
2236 } else {
2237 println!("JACS agent ready ({})", info.algorithm);
2239 println!(" Agent ID: {}", info.agent_id);
2240 println!(" Version: {}", info.version);
2241 println!(" Config: {}", info.config_path);
2242 println!(" Keys: {}", info.key_directory);
2243 println!();
2244 println!("Sign something:");
2245 println!(" echo '{{\"hello\":\"world\"}}' | jacs quickstart --sign");
2246 }
2247 }
2248 #[cfg(feature = "attestation")]
2249 Some(("attest", attest_matches)) => {
2250 use jacs::attestation::types::*;
2251 use jacs::simple::SimpleAgent;
2252
2253 match attest_matches.subcommand() {
2254 Some(("create", create_matches)) => {
2255 ensure_cli_private_key_password()?;
2257
2258 let agent = match SimpleAgent::load(None, None) {
2260 Ok(a) => a,
2261 Err(e) => {
2262 eprintln!("Failed to load agent: {}", e);
2263 eprintln!("Run `jacs quickstart` first to create an agent.");
2264 process::exit(1);
2265 }
2266 };
2267
2268 let claims_str = create_matches
2270 .get_one::<String>("claims")
2271 .expect("claims is required");
2272 let claims: Vec<Claim> = serde_json::from_str(claims_str).map_err(|e| {
2273 format!(
2274 "Invalid claims JSON: {}. \
2275 Provide a JSON array like '[{{\"name\":\"reviewed\",\"value\":true}}]'",
2276 e
2277 )
2278 })?;
2279
2280 let evidence: Vec<EvidenceRef> =
2282 if let Some(ev_str) = create_matches.get_one::<String>("evidence") {
2283 serde_json::from_str(ev_str)
2284 .map_err(|e| format!("Invalid evidence JSON: {}", e))?
2285 } else {
2286 vec![]
2287 };
2288
2289 let att_json = if let Some(doc_path) =
2290 create_matches.get_one::<String>("from-document")
2291 {
2292 let doc_content = std::fs::read_to_string(doc_path).map_err(|e| {
2294 format!("Failed to read document '{}': {}", doc_path, e)
2295 })?;
2296 let result = jacs::attestation::simple::lift(&agent, &doc_content, &claims)
2297 .map_err(|e| {
2298 format!("Failed to lift document to attestation: {}", e)
2299 })?;
2300 result.raw
2301 } else {
2302 let subject_type_str = create_matches
2304 .get_one::<String>("subject-type")
2305 .ok_or("--subject-type is required when not using --from-document")?;
2306 let subject_id = create_matches
2307 .get_one::<String>("subject-id")
2308 .ok_or("--subject-id is required when not using --from-document")?;
2309 let subject_digest = create_matches
2310 .get_one::<String>("subject-digest")
2311 .ok_or("--subject-digest is required when not using --from-document")?;
2312
2313 let subject_type = match subject_type_str.as_str() {
2314 "agent" => SubjectType::Agent,
2315 "artifact" => SubjectType::Artifact,
2316 "workflow" => SubjectType::Workflow,
2317 "identity" => SubjectType::Identity,
2318 other => {
2319 return Err(format!("Unknown subject type: '{}'", other).into());
2320 }
2321 };
2322
2323 let subject = AttestationSubject {
2324 subject_type,
2325 id: subject_id.clone(),
2326 digests: DigestSet {
2327 sha256: subject_digest.clone(),
2328 sha512: None,
2329 additional: std::collections::HashMap::new(),
2330 },
2331 };
2332
2333 let result = jacs::attestation::simple::create(
2334 &agent, &subject, &claims, &evidence, None, None,
2335 )
2336 .map_err(|e| format!("Failed to create attestation: {}", e))?;
2337 result.raw
2338 };
2339
2340 if let Some(output_path) = create_matches.get_one::<String>("output") {
2342 std::fs::write(output_path, &att_json).map_err(|e| {
2343 format!("Failed to write output file '{}': {}", output_path, e)
2344 })?;
2345 eprintln!("Attestation written to {}", output_path);
2346 } else {
2347 println!("{}", att_json);
2348 }
2349 }
2350 Some(("verify", verify_matches)) => {
2351 let file_path = verify_matches
2352 .get_one::<String>("file")
2353 .expect("file is required");
2354 let full = *verify_matches.get_one::<bool>("full").unwrap_or(&false);
2355 let json_output = *verify_matches.get_one::<bool>("json").unwrap_or(&false);
2356 let key_dir = verify_matches.get_one::<String>("key-dir");
2357 let max_depth = verify_matches.get_one::<u32>("max-depth");
2358
2359 if let Some(kd) = key_dir {
2361 unsafe { std::env::set_var("JACS_KEY_DIRECTORY", kd) };
2363 }
2364
2365 if let Some(depth) = max_depth {
2367 unsafe {
2369 std::env::set_var("JACS_MAX_DERIVATION_DEPTH", depth.to_string())
2370 };
2371 }
2372
2373 let att_content = std::fs::read_to_string(file_path).map_err(|e| {
2375 format!("Failed to read attestation file '{}': {}", file_path, e)
2376 })?;
2377
2378 ensure_cli_private_key_password().ok();
2380 let agent = match SimpleAgent::load(None, None) {
2381 Ok(a) => a,
2382 Err(_) => {
2383 let (a, _) = SimpleAgent::ephemeral(Some("ed25519"))
2384 .map_err(|e| format!("Failed to create verifier: {}", e))?;
2385 a
2386 }
2387 };
2388
2389 let att_value: serde_json::Value = serde_json::from_str(&att_content)
2391 .map_err(|e| format!("Invalid attestation JSON: {}", e))?;
2392 let doc_key = format!(
2393 "{}:{}",
2394 att_value["jacsId"].as_str().unwrap_or("unknown"),
2395 att_value["jacsVersion"].as_str().unwrap_or("unknown")
2396 );
2397
2398 let verify_result = agent.verify(&att_content);
2401 if let Err(e) = &verify_result {
2402 if json_output {
2403 let out = serde_json::json!({
2404 "valid": false,
2405 "error": e.to_string(),
2406 });
2407 println!("{}", serde_json::to_string_pretty(&out).unwrap());
2408 } else {
2409 eprintln!("Verification error: {}", e);
2410 }
2411 process::exit(1);
2412 }
2413
2414 let att_result = if full {
2416 jacs::attestation::simple::verify_full(&agent, &doc_key)
2417 } else {
2418 jacs::attestation::simple::verify(&agent, &doc_key)
2419 };
2420
2421 match att_result {
2422 Ok(r) => {
2423 if json_output {
2424 println!("{}", serde_json::to_string_pretty(&r).unwrap());
2425 } else {
2426 println!(
2427 "Status: {}",
2428 if r.valid { "VALID" } else { "INVALID" }
2429 );
2430 println!(
2431 "Signature: {}",
2432 if r.crypto.signature_valid {
2433 "valid"
2434 } else {
2435 "INVALID"
2436 }
2437 );
2438 println!(
2439 "Hash: {}",
2440 if r.crypto.hash_valid {
2441 "valid"
2442 } else {
2443 "INVALID"
2444 }
2445 );
2446 if !r.crypto.signer_id.is_empty() {
2447 println!("Signer: {}", r.crypto.signer_id);
2448 }
2449 if !r.evidence.is_empty() {
2450 println!("Evidence: {} items checked", r.evidence.len());
2451 }
2452 if !r.errors.is_empty() {
2453 for err in &r.errors {
2454 eprintln!(" Error: {}", err);
2455 }
2456 }
2457 }
2458 if !r.valid {
2459 process::exit(1);
2460 }
2461 }
2462 Err(e) => {
2463 if json_output {
2464 let out = serde_json::json!({
2465 "valid": false,
2466 "error": e.to_string(),
2467 });
2468 println!("{}", serde_json::to_string_pretty(&out).unwrap());
2469 } else {
2470 eprintln!("Attestation verification error: {}", e);
2471 }
2472 process::exit(1);
2473 }
2474 }
2475 }
2476 Some(("export-dsse", export_matches)) => {
2477 let file_path = export_matches
2478 .get_one::<String>("file")
2479 .expect("file argument required");
2480 let output_path = export_matches.get_one::<String>("output");
2481
2482 let attestation_json = std::fs::read_to_string(file_path).unwrap_or_else(|e| {
2483 eprintln!("Cannot read {}: {}", file_path, e);
2484 process::exit(1);
2485 });
2486
2487 let (_agent, _info) = SimpleAgent::ephemeral(Some("ring-Ed25519"))
2488 .unwrap_or_else(|e| {
2489 eprintln!("Failed to create agent: {}", e);
2490 process::exit(1);
2491 });
2492
2493 match jacs::attestation::simple::export_dsse(&attestation_json) {
2494 Ok(envelope_json) => {
2495 if let Some(out_path) = output_path {
2496 std::fs::write(out_path, &envelope_json).unwrap_or_else(|e| {
2497 eprintln!("Cannot write to {}: {}", out_path, e);
2498 process::exit(1);
2499 });
2500 println!("DSSE envelope written to {}", out_path);
2501 } else {
2502 println!("{}", envelope_json);
2503 }
2504 }
2505 Err(e) => {
2506 eprintln!("Failed to export DSSE envelope: {}", e);
2507 process::exit(1);
2508 }
2509 }
2510 }
2511 _ => {
2512 eprintln!(
2513 "Use 'jacs attest create', 'jacs attest verify', or 'jacs attest export-dsse'. See --help."
2514 );
2515 process::exit(1);
2516 }
2517 }
2518 }
2519 Some(("verify", verify_matches)) => {
2520 use jacs::simple::SimpleAgent;
2521 use serde_json::json;
2522
2523 let file_path = verify_matches.get_one::<String>("file");
2524 let remote_url = verify_matches.get_one::<String>("remote");
2525 let json_output = *verify_matches.get_one::<bool>("json").unwrap_or(&false);
2526 let key_dir = verify_matches.get_one::<String>("key-dir");
2527
2528 if let Some(kd) = key_dir {
2530 unsafe { std::env::set_var("JACS_KEY_DIRECTORY", kd) };
2532 }
2533
2534 let document = if let Some(url) = remote_url {
2536 let client = reqwest::blocking::Client::builder()
2537 .timeout(std::time::Duration::from_secs(30))
2538 .build()
2539 .map_err(|e| format!("HTTP client error: {}", e))?;
2540 let resp = client
2541 .get(url)
2542 .send()
2543 .map_err(|e| format!("Fetch failed: {}", e))?;
2544 if !resp.status().is_success() {
2545 eprintln!("HTTP error: {}", resp.status());
2546 process::exit(1);
2547 }
2548 resp.text()
2549 .map_err(|e| format!("Read body failed: {}", e))?
2550 } else if let Some(path) = file_path {
2551 std::fs::read_to_string(path).map_err(|e| format!("Read file failed: {}", e))?
2552 } else {
2553 eprintln!("Provide a file path or --remote <url>");
2554 process::exit(1);
2555 };
2556
2557 let agent = if std::path::Path::new("./jacs.config.json").exists() {
2561 if let Err(e) = ensure_cli_private_key_password() {
2562 eprintln!("Warning: Password bootstrap failed: {}", e);
2563 eprintln!("{}", quickstart_password_bootstrap_help());
2564 }
2565 match SimpleAgent::load(None, None) {
2566 Ok(a) => a,
2567 Err(e) => {
2568 let lower = e.to_string().to_lowercase();
2569 if lower.contains("password")
2570 || lower.contains("decrypt")
2571 || lower.contains("private key")
2572 {
2573 eprintln!(
2574 "Warning: Could not load local agent from ./jacs.config.json: {}",
2575 e
2576 );
2577 eprintln!("{}", quickstart_password_bootstrap_help());
2578 }
2579 let (a, _) = SimpleAgent::ephemeral(Some("ed25519"))
2580 .map_err(|e| format!("Failed to create verifier: {}", e))?;
2581 a
2582 }
2583 }
2584 } else {
2585 let (a, _) = SimpleAgent::ephemeral(Some("ed25519"))
2586 .map_err(|e| format!("Failed to create verifier: {}", e))?;
2587 a
2588 };
2589
2590 match agent.verify(&document) {
2591 Ok(r) => {
2592 if json_output {
2593 let out = json!({
2594 "valid": r.valid,
2595 "signerId": r.signer_id,
2596 "timestamp": r.timestamp,
2597 });
2598 println!("{}", serde_json::to_string_pretty(&out).unwrap());
2599 } else {
2600 println!("Status: {}", if r.valid { "VALID" } else { "INVALID" });
2601 println!(
2602 "Signer: {}",
2603 if r.signer_id.is_empty() {
2604 "(unknown)"
2605 } else {
2606 &r.signer_id
2607 }
2608 );
2609 if !r.timestamp.is_empty() {
2610 println!("Signed at: {}", r.timestamp);
2611 }
2612 }
2613 if !r.valid {
2614 process::exit(1);
2615 }
2616 }
2617 Err(e) => {
2618 if json_output {
2619 let out = json!({
2620 "valid": false,
2621 "error": e.to_string(),
2622 });
2623 println!("{}", serde_json::to_string_pretty(&out).unwrap());
2624 } else {
2625 eprintln!("Verification error: {}", e);
2626 }
2627 process::exit(1);
2628 }
2629 }
2630 }
2631 Some(("convert", convert_matches)) => {
2632 use jacs::convert::{html_to_jacs, jacs_to_html, jacs_to_yaml, yaml_to_jacs};
2633
2634 let target_format = convert_matches.get_one::<String>("to").unwrap();
2635 let source_format = convert_matches.get_one::<String>("from");
2636 let file_path = convert_matches.get_one::<String>("file").unwrap();
2637 let output_path = convert_matches.get_one::<String>("output");
2638
2639 let is_stdin = file_path == "-";
2641 let detected_format = if let Some(fmt) = source_format {
2642 fmt.clone()
2643 } else if is_stdin {
2644 eprintln!(
2645 "When reading from stdin (-f -), --from is required to specify the source format."
2646 );
2647 process::exit(1);
2648 } else {
2649 let ext = std::path::Path::new(file_path)
2650 .extension()
2651 .and_then(|e| e.to_str())
2652 .unwrap_or("");
2653 match ext {
2654 "json" => "json".to_string(),
2655 "yaml" | "yml" => "yaml".to_string(),
2656 "html" | "htm" => "html".to_string(),
2657 _ => {
2658 eprintln!(
2659 "Cannot auto-detect format for extension '{}'. Use --from to specify.",
2660 ext
2661 );
2662 process::exit(1);
2663 }
2664 }
2665 };
2666
2667 let input = if is_stdin {
2669 let mut buf = String::new();
2670 std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)
2671 .map_err(|e| format!("Failed to read from stdin: {}", e))?;
2672 buf
2673 } else {
2674 std::fs::read_to_string(file_path)
2675 .map_err(|e| format!("Failed to read '{}': {}", file_path, e))?
2676 };
2677
2678 let output = match (detected_format.as_str(), target_format.as_str()) {
2680 ("json", "yaml") => jacs_to_yaml(&input).map_err(|e| format!("{}", e))?,
2681 ("yaml", "json") => yaml_to_jacs(&input).map_err(|e| format!("{}", e))?,
2682 ("json", "html") => jacs_to_html(&input).map_err(|e| format!("{}", e))?,
2683 ("html", "json") => html_to_jacs(&input).map_err(|e| format!("{}", e))?,
2684 ("yaml", "html") => {
2685 let json = yaml_to_jacs(&input).map_err(|e| format!("{}", e))?;
2686 jacs_to_html(&json).map_err(|e| format!("{}", e))?
2687 }
2688 ("html", "yaml") => {
2689 let json = html_to_jacs(&input).map_err(|e| format!("{}", e))?;
2690 jacs_to_yaml(&json).map_err(|e| format!("{}", e))?
2691 }
2692 (src, dst) if src == dst => {
2693 input
2695 }
2696 (src, dst) => {
2697 eprintln!("Unsupported conversion: {} -> {}", src, dst);
2698 process::exit(1);
2699 }
2700 };
2701
2702 if let Some(out_path) = output_path {
2704 std::fs::write(out_path, &output)
2705 .map_err(|e| format!("Failed to write '{}': {}", out_path, e))?;
2706 eprintln!("Written to {}", out_path);
2707 } else {
2708 print!("{}", output);
2709 }
2710 }
2711 Some(("init", init_matches)) => {
2712 let auto_yes = *init_matches.get_one::<bool>("yes").unwrap_or(&false);
2713 println!("--- Running Config Creation ---");
2714 handle_config_create()?;
2715 println!("\n--- Running Agent Creation (with keys) ---");
2716 handle_agent_create_auto(None, true, auto_yes)?;
2717 println!("\n--- JACS Initialization Complete ---");
2718 }
2719 #[cfg(feature = "keychain")]
2720 Some(("keychain", keychain_matches)) => {
2721 use jacs::keystore::keychain;
2722
2723 match keychain_matches.subcommand() {
2724 Some(("set", sub)) => {
2725 let agent_id = sub.get_one::<String>("agent-id").unwrap();
2726 let password = if let Some(pw) = sub.get_one::<String>("password") {
2727 pw.clone()
2728 } else {
2729 eprintln!("Enter password to store in keychain:");
2730 read_password().map_err(|e| format!("Failed to read password: {}", e))?
2731 };
2732 if password.trim().is_empty() {
2733 eprintln!("Error: password cannot be empty.");
2734 process::exit(1);
2735 }
2736 if let Err(e) = jacs::crypt::aes_encrypt::check_password_strength(&password) {
2738 eprintln!("Error: {}", e);
2739 process::exit(1);
2740 }
2741 keychain::store_password(agent_id, &password)?;
2742 eprintln!("Password stored in OS keychain for agent {}.", agent_id);
2743 }
2744 Some(("get", sub)) => {
2745 let agent_id = sub.get_one::<String>("agent-id").unwrap();
2746 match keychain::get_password(agent_id)? {
2747 Some(pw) => println!("{}", pw),
2748 None => {
2749 eprintln!("No password found in OS keychain for agent {}.", agent_id);
2750 process::exit(1);
2751 }
2752 }
2753 }
2754 Some(("delete", sub)) => {
2755 let agent_id = sub.get_one::<String>("agent-id").unwrap();
2756 keychain::delete_password(agent_id)?;
2757 eprintln!("Password removed from OS keychain for agent {}.", agent_id);
2758 }
2759 Some(("status", sub)) => {
2760 let agent_id = sub.get_one::<String>("agent-id").unwrap();
2761 if keychain::is_available() {
2762 match keychain::get_password(agent_id) {
2763 Ok(Some(_)) => {
2764 eprintln!("Keychain backend: available");
2765 eprintln!("Agent: {}", agent_id);
2766 eprintln!("Password: stored");
2767 }
2768 Ok(None) => {
2769 eprintln!("Keychain backend: available");
2770 eprintln!("Agent: {}", agent_id);
2771 eprintln!("Password: not stored");
2772 }
2773 Err(e) => {
2774 eprintln!("Keychain backend: error ({})", e);
2775 }
2776 }
2777 } else {
2778 eprintln!("Keychain backend: not available (feature disabled)");
2779 }
2780 }
2781 _ => {
2782 eprintln!("Unknown keychain subcommand. Use: set, get, delete, status");
2783 process::exit(1);
2784 }
2785 }
2786 }
2787 _ => {
2788 eprintln!("Invalid command or no subcommand provided. Use --help for usage.");
2790 process::exit(1); }
2792 }
2793
2794 Ok(())
2795}
2796
2797#[cfg(test)]
2798mod tests {
2799 use super::*;
2800 use serial_test::serial;
2801 use std::ffi::OsString;
2802 use tempfile::tempdir;
2803
2804 struct EnvGuard {
2805 saved: Vec<(&'static str, Option<OsString>)>,
2806 }
2807
2808 impl EnvGuard {
2809 fn capture(keys: &[&'static str]) -> Self {
2810 Self {
2811 saved: keys
2812 .iter()
2813 .map(|key| (*key, std::env::var_os(key)))
2814 .collect(),
2815 }
2816 }
2817 }
2818
2819 impl Drop for EnvGuard {
2820 fn drop(&mut self) {
2821 for (key, value) in self.saved.drain(..) {
2822 match value {
2823 Some(value) => {
2824 unsafe {
2826 std::env::set_var(key, value);
2827 }
2828 }
2829 None => {
2830 unsafe {
2832 std::env::remove_var(key);
2833 }
2834 }
2835 }
2836 }
2837 }
2838 }
2839
2840 #[test]
2841 fn quickstart_help_mentions_env_precedence_warning() {
2842 let help = quickstart_password_bootstrap_help();
2843 assert!(help.contains("prefer exactly one explicit source"));
2844 assert!(help.contains("CLI warns and uses JACS_PRIVATE_KEY_PASSWORD"));
2845 }
2846
2847 #[test]
2848 #[serial]
2849 fn ensure_cli_private_key_password_reads_password_file_when_env_absent() {
2850 let _guard = EnvGuard::capture(&["JACS_PRIVATE_KEY_PASSWORD", CLI_PASSWORD_FILE_ENV]);
2851 let temp = tempdir().expect("tempdir");
2852 let password_file = temp.path().join("password.txt");
2853 std::fs::write(&password_file, "TestP@ss123!#\n").expect("write password file");
2854 #[cfg(unix)]
2855 {
2856 use std::os::unix::fs::PermissionsExt;
2857 std::fs::set_permissions(&password_file, std::fs::Permissions::from_mode(0o600))
2858 .expect("chmod password file");
2859 }
2860
2861 unsafe {
2863 std::env::remove_var("JACS_PRIVATE_KEY_PASSWORD");
2864 std::env::set_var(CLI_PASSWORD_FILE_ENV, &password_file);
2865 }
2866
2867 let resolved =
2868 ensure_cli_private_key_password().expect("password bootstrap should succeed");
2869
2870 assert_eq!(
2871 resolved.as_deref(),
2872 Some("TestP@ss123!#"),
2873 "resolved password should match password file content"
2874 );
2875 assert_eq!(
2876 std::env::var("JACS_PRIVATE_KEY_PASSWORD").expect("env password"),
2877 "TestP@ss123!#"
2878 );
2879 }
2880
2881 #[test]
2882 #[serial]
2883 fn ensure_cli_private_key_password_prefers_env_when_sources_are_ambiguous() {
2884 let _guard = EnvGuard::capture(&["JACS_PRIVATE_KEY_PASSWORD", CLI_PASSWORD_FILE_ENV]);
2885 let temp = tempdir().expect("tempdir");
2886 let password_file = temp.path().join("password.txt");
2887 std::fs::write(&password_file, "DifferentP@ss456$\n").expect("write password file");
2888 #[cfg(unix)]
2889 {
2890 use std::os::unix::fs::PermissionsExt;
2891 std::fs::set_permissions(&password_file, std::fs::Permissions::from_mode(0o600))
2892 .expect("chmod password file");
2893 }
2894
2895 unsafe {
2897 std::env::set_var("JACS_PRIVATE_KEY_PASSWORD", "TestP@ss123!#");
2898 std::env::set_var(CLI_PASSWORD_FILE_ENV, &password_file);
2899 }
2900
2901 let resolved =
2902 ensure_cli_private_key_password().expect("password bootstrap should succeed");
2903
2904 assert_eq!(
2905 resolved.as_deref(),
2906 Some("TestP@ss123!#"),
2907 "env var should win over password file"
2908 );
2909 assert_eq!(
2910 std::env::var("JACS_PRIVATE_KEY_PASSWORD").expect("env password"),
2911 "TestP@ss123!#"
2912 );
2913 }
2914}