1#[path = "cli_grammar.rs"]
4mod cli_grammar;
5#[path = "cli_render.rs"]
6mod cli_render;
7#[path = "cli_spec.rs"]
8mod cli_spec;
9#[path = "files_cli.rs"]
10mod files_cli;
11#[path = "knowledge_cli.rs"]
12mod knowledge_cli;
13#[path = "map_cli.rs"]
14pub(crate) mod map_cli;
15#[path = "ops_cli.rs"]
16mod ops_cli;
17#[path = "remote_cli.rs"]
18mod remote_cli;
19#[path = "repo_cli.rs"]
20mod repo_cli;
21#[path = "repo_cli_view.rs"]
22pub(crate) mod repo_cli_view;
23#[path = "repo_set_cli.rs"]
24mod repo_set_cli;
25#[path = "service_cli.rs"]
26mod service_cli;
27#[path = "setup_cli.rs"]
28mod setup_cli;
29#[path = "version_cli.rs"]
30mod version_cli;
31
32use std::{error::Error, fmt};
33
34use crate::{
35 api::{
36 ApiError, GraphInspectionRequest, HybridRetrievalRequest, IndexRefreshRequest,
37 IngestEvidence, IngestRequest, InterfaceKind, RequestContext, ServicePlanRequest,
38 },
39 application::RelayKnowledgeService,
40 domain::{FreshnessPolicy, IndexKind, ProposalState, WorkerKind},
41 env::{EnvironmentConfig, RemoteCliEnvironmentConfig},
42};
43
44use cli_render::{render_project_status, render_response, serialize_line};
45
46#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
48pub enum OutputFormat {
49 #[default]
50 Text,
51 Json,
52 Markdown,
53 StreamingJson,
54}
55
56impl OutputFormat {
57 fn as_str(self) -> &'static str {
58 match self {
59 Self::Text => "text",
60 Self::Json => "json",
61 Self::Markdown => "markdown",
62 Self::StreamingJson => "streaming-json",
63 }
64 }
65
66 fn is_machine_readable(self) -> bool {
67 matches!(self, Self::Json | Self::StreamingJson)
68 }
69
70 pub fn parse(value: &str) -> Result<Self, CliError> {
72 match value {
73 "text" => Ok(Self::Text),
74 "json" => Ok(Self::Json),
75 "markdown" => Ok(Self::Markdown),
76 "streaming-json" => Ok(Self::StreamingJson),
77 other => Err(CliError::invalid_format(other)),
78 }
79 }
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct CliCommand {
85 pub action: CliAction,
86 pub format: OutputFormat,
87 pub remote_base_url: Option<String>,
88 pub help: bool,
89}
90
91impl CliCommand {
92 pub fn parse<I, S>(args: I) -> Result<Self, CliError>
94 where
95 I: IntoIterator<Item = S>,
96 S: Into<String>,
97 {
98 let tokens = args.into_iter().map(Into::into).collect::<Vec<_>>();
99 let mut action_tokens = Vec::new();
100 let mut format = OutputFormat::default();
101 let mut remote_base_url = None;
102 let mut help = false;
103 let mut version = false;
104 let mut command_seen = false;
105 let mut delimiter_value = false;
106 let mut index = 0;
107
108 while index < tokens.len() {
109 let arg = &tokens[index];
110 if delimiter_value {
111 action_tokens.push(arg.clone());
112 delimiter_value = false;
113 index += 1;
114 } else if arg == "--format" {
115 let value = tokens
116 .get(index + 1)
117 .ok_or(CliError::MissingFormatValue)?
118 .clone();
119 format = OutputFormat::parse(&value)?;
120 index += 2;
121 } else if let Some(value) = arg.strip_prefix("--format=") {
122 format = OutputFormat::parse(value)?;
123 index += 1;
124 } else if arg == "--remote" {
125 remote_base_url = Some(
126 tokens
127 .get(index + 1)
128 .ok_or(CliError::MissingValue("--remote"))?
129 .clone(),
130 );
131 index += 2;
132 } else if let Some(value) = arg.strip_prefix("--remote=") {
133 if value.trim().is_empty() {
134 return Err(CliError::MissingValue("--remote"));
135 }
136 remote_base_url = Some(value.to_owned());
137 index += 1;
138 } else if arg == "--help" || arg == "-h" {
139 help = true;
140 index += 1;
141 } else if arg == "--version" && !command_seen {
142 version = true;
143 index += 1;
144 } else if arg == "--" {
145 action_tokens.push(arg.clone());
146 delimiter_value = true;
147 index += 1;
148 } else if option_consumes_value(arg) {
149 action_tokens.push(arg.clone());
150 if let Some(value) = tokens.get(index + 1) {
151 action_tokens.push(value.clone());
152 index += 2;
153 } else {
154 index += 1;
155 }
156 } else {
157 command_seen |= is_command_word(arg);
158 action_tokens.push(arg.clone());
159 index += 1;
160 }
161 }
162
163 let action = if help {
164 CliAction::Help {
165 path: help_path(action_tokens),
166 }
167 } else if version {
168 if let Some(token) = action_tokens.first() {
169 let error = CliError::UnexpectedArgument(token.clone());
170 return Err(cli_grammar::diagnose(&action_tokens, error, format));
171 }
172 CliAction::Version
173 } else {
174 match parse_action(action_tokens.clone()) {
175 Ok(action) => action,
176 Err(error) => return Err(cli_grammar::diagnose(&action_tokens, error, format)),
177 }
178 };
179
180 Ok(Self {
181 action,
182 format,
183 remote_base_url,
184 help,
185 })
186 }
187}
188
189fn option_consumes_value(option: &str) -> bool {
190 matches!(
191 option,
192 "--source"
193 | "--content"
194 | "--entity"
195 | "--limit"
196 | "--freshness"
197 | "--kind"
198 | "--alias"
199 | "--path"
200 | "--language"
201 | "--ref"
202 | "--base"
203 | "--head"
204 | "--changed-path"
205 | "--query"
206 | "--description"
207 | "--id"
208 | "--priority"
209 | "--mcp"
210 | "--state"
211 | "--by"
212 | "--reason"
213 | "--operation"
214 | "--task-id"
215 | "--input"
216 | "--root"
217 | "--scope"
218 | "--topic"
219 | "--uri"
220 | "--target-version"
221 | "--install-dir"
222 )
223}
224
225fn is_command_word(token: &str) -> bool {
226 matches!(
227 token,
228 "status"
229 | "ingest"
230 | "query"
231 | "repo"
232 | "repo-set"
233 | "files"
234 | "map"
235 | "graph"
236 | "index"
237 | "worker"
238 | "proposal"
239 | "audit"
240 | "provider"
241 | "health"
242 | "service"
243 | "setup"
244 | "version"
245 | "help"
246 )
247}
248
249#[derive(Debug, Clone, PartialEq, Eq)]
251pub enum CliAction {
252 Status,
253 Ingest {
254 source_scope: String,
255 content: String,
256 entity_labels: Vec<String>,
257 },
258 Query {
259 query: String,
260 source_scope: Option<String>,
261 limit: usize,
262 freshness: FreshnessPolicy,
263 },
264 FilesIndex {
265 source_scope: Option<String>,
266 roots: Vec<String>,
267 },
268 FilesQuery {
269 query: String,
270 source_scope: Option<String>,
271 root_id: Option<String>,
272 limit: usize,
273 freshness: FreshnessPolicy,
274 },
275 FilesContentQuery {
276 query: String,
277 source_scope: Option<String>,
278 root_id: Option<String>,
279 limit: usize,
280 freshness: FreshnessPolicy,
281 },
282 GraphInspect,
283 IndexRefresh {
284 kinds: Vec<IndexKind>,
285 },
286 Map(map_cli::MapCommand),
287 WorkerStatus {
288 kind: Option<WorkerKind>,
289 },
290 WorkerRunOnce {
291 kind: Option<WorkerKind>,
292 },
293 ProposalList {
294 state: Option<ProposalState>,
295 limit: usize,
296 },
297 ProposalShow {
298 proposal_id: String,
299 },
300 ProposalAccept {
301 proposal_id: String,
302 actor: String,
303 reason: Option<String>,
304 },
305 ProposalReject {
306 proposal_id: String,
307 actor: String,
308 reason: Option<String>,
309 },
310 ProposalSupersede {
311 proposal_id: String,
312 actor: String,
313 reason: Option<String>,
314 },
315 AuditQuery {
316 operation: Option<String>,
317 limit: usize,
318 },
319 ProviderProbe,
320 Repo(repo_cli::RepoCommand),
321 RepoSet(repo_set_cli::RepoSetCommand),
322 Health,
323 ServiceStatus,
324 ServicePlan(ServicePlanRequest),
325 ServiceDefinitionWrite,
326 ServiceOperatorStatus,
327 ServiceOperatorPause,
328 ServiceOperatorResume,
329 ServiceWorkerRun {
330 task_id: Option<String>,
331 },
332 ServiceRun {
333 mcp: ServiceMcpTransport,
334 web: bool,
335 },
336 SetupDoctor,
337 SetupProfile {
338 profile: setup_cli::SetupProfile,
339 },
340 Version,
341 VersionCheck,
342 Help {
343 path: Vec<String>,
344 },
345}
346
347#[derive(Debug, Clone, Copy, PartialEq, Eq)]
349pub enum ServiceMcpTransport {
350 Configured,
351 StreamableHttp,
352}
353
354#[derive(Debug, Clone, PartialEq, Eq)]
356pub enum CliError {
357 Diagnostic(Box<CliDiagnostic>),
358 InvalidFormat(String),
359 InvalidCodeQueryKind(String),
360 InvalidSoftwareKind(String),
361 InvalidFreshness(String),
362 InvalidIndexKind(String),
363 InvalidMapSourceKind(String),
364 InvalidWorkerKind(String),
365 InvalidProposalState(String),
366 InvalidServiceAction(String),
367 InvalidLimit(String),
368 MissingFormatValue,
369 MissingValue(&'static str),
370 UnsupportedVersionFormat(OutputFormat),
371 UnknownHelpTopic(String),
372 UnexpectedArgument(String),
373 RuntimeConfigFailed(String),
374 ApiFailed(String),
375 ApiError {
376 error: Box<ApiError>,
377 format: OutputFormat,
378 },
379 ServiceRunFailed(String),
380 RenderFailed(String),
381}
382
383impl CliError {
384 fn invalid_format(format: &str) -> Self {
385 Self::InvalidFormat(format.to_owned())
386 }
387
388 pub(crate) fn api_failed(error: ApiError, format: OutputFormat) -> Self {
389 Self::ApiError {
390 error: Box::new(error),
391 format,
392 }
393 }
394
395 pub(crate) fn invalid_api_argument(message: impl Into<String>, format: OutputFormat) -> Self {
396 Self::api_failed(ApiError::invalid_argument(message), format)
397 }
398
399 pub fn exit_code(&self) -> i32 {
401 match self {
402 Self::Diagnostic(_)
403 | Self::InvalidFormat(_)
404 | Self::InvalidCodeQueryKind(_)
405 | Self::InvalidSoftwareKind(_)
406 | Self::InvalidFreshness(_)
407 | Self::InvalidIndexKind(_)
408 | Self::InvalidMapSourceKind(_)
409 | Self::InvalidWorkerKind(_)
410 | Self::InvalidProposalState(_)
411 | Self::InvalidServiceAction(_)
412 | Self::InvalidLimit(_)
413 | Self::MissingFormatValue
414 | Self::MissingValue(_)
415 | Self::UnsupportedVersionFormat(_)
416 | Self::UnknownHelpTopic(_)
417 | Self::UnexpectedArgument(_) => 2,
418 Self::RuntimeConfigFailed(_)
419 | Self::ApiFailed(_)
420 | Self::ApiError { .. }
421 | Self::ServiceRunFailed(_)
422 | Self::RenderFailed(_) => 1,
423 }
424 }
425
426 pub fn render_stderr(&self) -> String {
428 match self {
429 Self::Diagnostic(diagnostic) => diagnostic.render_stderr(),
430 Self::ApiError { error, format } if format.is_machine_readable() => {
431 serde_json::to_string(error).unwrap_or_else(|_| error.message.clone())
432 }
433 _ => self.to_string(),
434 }
435 }
436}
437
438impl fmt::Display for CliError {
439 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
440 match self {
441 Self::Diagnostic(diagnostic) => write!(formatter, "{}", diagnostic.render_text()),
442 Self::InvalidFormat(format) => write!(
443 formatter,
444 "invalid --format value '{format}', expected text, json, markdown, or streaming-json"
445 ),
446 Self::InvalidCodeQueryKind(value) => write!(
447 formatter,
448 "invalid --kind value '{value}', expected hybrid, symbol, definition, references, callers, callees, imports, or sbom"
449 ),
450 Self::InvalidSoftwareKind(value) => write!(
451 formatter,
452 "invalid --kind value '{value}', expected dependencies, sdks, files, topics, relationships, build, iac, design, or all"
453 ),
454 Self::InvalidFreshness(value) => write!(
455 formatter,
456 "invalid --freshness value '{value}', expected allow-stale, wait-until-fresh, or graph-only"
457 ),
458 Self::InvalidIndexKind(value) => write!(
459 formatter,
460 "invalid --kind value '{value}', expected bm25, semantic, or vector"
461 ),
462 Self::InvalidMapSourceKind(value) => write!(
463 formatter,
464 "invalid --kind value '{value}', expected repo, file, doc, config, db, ci, runtime, wiki, or monitoring"
465 ),
466 Self::InvalidWorkerKind(value) => write!(
467 formatter,
468 "invalid worker kind '{value}', expected embedding, ocr, vision, or extractor"
469 ),
470 Self::InvalidProposalState(value) => write!(
471 formatter,
472 "invalid proposal state '{value}', expected proposed, accepted, rejected, or superseded"
473 ),
474 Self::InvalidServiceAction(value) => write!(
475 formatter,
476 "invalid service action '{value}', expected install, upgrade, rollback, or uninstall"
477 ),
478 Self::InvalidLimit(value) => write!(formatter, "invalid --limit value '{value}'"),
479 Self::MissingFormatValue => write!(formatter, "missing value for --format"),
480 Self::MissingValue(flag) => write!(formatter, "missing value for {flag}"),
481 Self::UnsupportedVersionFormat(format) => {
482 write!(
483 formatter,
484 "version does not support --format {}",
485 format.as_str()
486 )
487 }
488 Self::UnknownHelpTopic(topic) => write!(formatter, "unknown help topic '{topic}'"),
489 Self::UnexpectedArgument(argument) => {
490 write!(formatter, "unexpected argument '{argument}'")
491 }
492 Self::RuntimeConfigFailed(message) => {
493 write!(formatter, "failed to load runtime configuration: {message}")
494 }
495 Self::ApiFailed(message) => write!(formatter, "{message}"),
496 Self::ApiError { error, .. } => write!(formatter, "{}", error.message),
497 Self::ServiceRunFailed(message) => write!(formatter, "{message}"),
498 Self::RenderFailed(message) => write!(formatter, "failed to render output: {message}"),
499 }
500 }
501}
502
503impl Error for CliError {}
504
505#[derive(Debug, Clone, PartialEq, Eq)]
507pub struct CliDiagnostic {
508 message: String,
509 usage: Option<String>,
510 suggestion: Option<String>,
511 matched_path: Vec<String>,
512 unexpected_token: Option<String>,
513 expected: Vec<String>,
514 format: OutputFormat,
515}
516
517impl CliDiagnostic {
518 fn new(
519 message: String,
520 usage: Option<String>,
521 suggestion: Option<String>,
522 matched_path: Vec<String>,
523 unexpected_token: Option<String>,
524 expected: Vec<String>,
525 format: OutputFormat,
526 ) -> Self {
527 Self {
528 message,
529 usage,
530 suggestion,
531 matched_path,
532 unexpected_token,
533 expected,
534 format,
535 }
536 }
537
538 fn render_text(&self) -> String {
539 let mut output = self.message.clone();
540 if let Some(suggestion) = &self.suggestion {
541 output.push_str("\nTry: ");
542 output.push_str(suggestion);
543 }
544 if let Some(usage) = &self.usage {
545 output.push_str("\nUsage: ");
546 output.push_str(usage);
547 }
548
549 output
550 }
551
552 fn render_stderr(&self) -> String {
553 if self.format.is_machine_readable() {
554 return serde_json::json!({
555 "error": self.message,
556 "usage": self.usage,
557 "suggestion": self.suggestion,
558 "matched_path": self.matched_path,
559 "unexpected_token": self.unexpected_token,
560 "expected": self.expected,
561 })
562 .to_string();
563 }
564
565 self.render_text()
566 }
567}
568
569pub async fn run<I, S>(args: I) -> Result<String, CliError>
571where
572 I: IntoIterator<Item = S>,
573 S: Into<String>,
574{
575 let output = crate::bootstrap::cli::run_process(args, false).await?;
576 Ok(output.stdout)
577}
578
579#[derive(Debug, Clone, PartialEq, Eq)]
581pub struct CliProcessOutput {
582 pub stdout: String,
583 pub stderr: String,
584}
585
586pub async fn run_process<I, S>(
588 args: I,
589 interactive_text_output: bool,
590) -> Result<CliProcessOutput, CliError>
591where
592 I: IntoIterator<Item = S>,
593 S: Into<String>,
594{
595 crate::bootstrap::cli::run_process(args, interactive_text_output).await
596}
597
598pub async fn process_update_notice<I, S>(args: I, interactive_text_output: bool) -> Option<String>
600where
601 I: IntoIterator<Item = S>,
602 S: Into<String>,
603{
604 let command = CliCommand::parse(args).ok()?;
605 version_cli::update_notice_for_process(&command, interactive_text_output).await
606}
607
608pub(crate) async fn run_command(command: CliCommand) -> Result<String, CliError> {
609 if let CliAction::Help { path } = &command.action {
610 return cli_spec::render_help(path, command.format);
611 }
612 if command.action == CliAction::Version {
613 return version_cli::render_version(command.format);
614 }
615 if let CliAction::ServiceRun { mcp, web } = command.action.clone() {
616 return service_cli::run_service(mcp, web).await;
617 }
618 if let CliAction::Map(map_command) = command.action.clone() {
619 let context = RequestContext::for_interface(InterfaceKind::Cli);
620 return map_cli::run_map(map_command, None, context, command.format).await;
621 }
622
623 let context = RequestContext::for_interface(InterfaceKind::Cli);
624 if remote_environment_needed(&command) {
625 let remote_environment = RemoteCliEnvironmentConfig::from_process()
626 .map_err(|error| CliError::RuntimeConfigFailed(error.to_string()))?;
627 if let Some(remote) = remote_selection(&command, remote_environment.remote_cli.base_url) {
628 let remote_output = remote_cli::run_remote(
629 &remote_environment.network,
630 &remote.base_url,
631 &command.action,
632 context.clone(),
633 command.format,
634 )
635 .await?;
636 if let Some(output) = remote_output {
637 return Ok(output);
638 }
639 return Err(remote_unsupported_error());
640 }
641 }
642
643 let environment = EnvironmentConfig::from_process()
644 .map_err(|error| CliError::RuntimeConfigFailed(error.to_string()))?;
645 if let Some(remote) = remote_selection(&command, environment.remote_cli.base_url.clone()) {
646 let remote_output = remote_cli::run_remote(
647 &environment.network,
648 &remote.base_url,
649 &command.action,
650 context.clone(),
651 command.format,
652 )
653 .await?;
654 if let Some(output) = remote_output {
655 return Ok(output);
656 }
657 return Err(remote_unsupported_error());
658 }
659
660 let service = RelayKnowledgeService::from_environment(&environment)
661 .await
662 .map_err(|error| CliError::RuntimeConfigFailed(error.to_string()))?;
663
664 run_with_service(&service, command, context).await
665}
666
667fn remote_unsupported_error() -> CliError {
668 CliError::ApiFailed(
669 "remote CLI mode supports repo index, repo scope preview, repo status, repo query, repo context, repo feature-flags, repo impact, repo report, and repo software"
670 .to_owned(),
671 )
672}
673
674#[derive(Debug, Clone, PartialEq, Eq)]
675struct RemoteSelection {
676 base_url: String,
677 explicit: bool,
678}
679
680fn remote_selection(command: &CliCommand, env_base_url: Option<String>) -> Option<RemoteSelection> {
681 if let Some(base_url) = command.remote_base_url.clone() {
682 return Some(RemoteSelection {
683 base_url,
684 explicit: true,
685 });
686 }
687 if remote_cli::supports(&command.action) || remote_cli::blocks_local_fallback(&command.action) {
688 return env_base_url.map(|base_url| RemoteSelection {
689 base_url,
690 explicit: false,
691 });
692 }
693
694 None
695}
696
697fn remote_environment_needed(command: &CliCommand) -> bool {
698 command.remote_base_url.is_some()
699 || remote_cli::supports(&command.action)
700 || remote_cli::blocks_local_fallback(&command.action)
701}
702
703pub async fn run_with_service(
705 service: &RelayKnowledgeService,
706 command: CliCommand,
707 context: RequestContext,
708) -> Result<String, CliError> {
709 let format = command.format;
710 if let Some(output) =
711 ops_cli::run_operational_action(service, &command.action, context.clone(), format).await?
712 {
713 return Ok(output);
714 }
715 if let Some(output) =
716 setup_cli::run_setup_action(service, &command.action, context.clone(), format)?
717 {
718 return Ok(output);
719 }
720 if let Some(output) =
721 files_cli::run_files(service, &command.action, context.clone(), format).await?
722 {
723 return Ok(output);
724 }
725 match command.action {
726 CliAction::Status => {
727 let response = service
728 .project_status(context)
729 .await
730 .map_err(|error| CliError::api_failed(error, format))?;
731
732 render_project_status(&response, format)
733 }
734 CliAction::Ingest {
735 source_scope,
736 content,
737 entity_labels,
738 } => {
739 let response = service
740 .ingest(
741 IngestRequest {
742 source_scope,
743 evidence: vec![IngestEvidence {
744 id: None,
745 source_path: None,
746 span: None,
747 confidence: None,
748 status: None,
749 content,
750 entity_labels,
751 extraction: None,
752 }],
753 relations: Vec::new(),
754 claims: Vec::new(),
755 events: Vec::new(),
756 },
757 context,
758 )
759 .await
760 .map_err(|error| CliError::api_failed(error, format))?;
761
762 render_response(
763 "knowledge.ingest",
764 response.metadata.clone(),
765 &response,
766 format,
767 )
768 }
769 CliAction::Query {
770 query,
771 source_scope,
772 limit,
773 freshness,
774 } => {
775 let response = service
776 .retrieve_context(
777 HybridRetrievalRequest {
778 query,
779 source_scope,
780 limit,
781 freshness,
782 },
783 context,
784 )
785 .await
786 .map_err(|error| CliError::api_failed(error, format))?;
787
788 render_response(
789 "knowledge.retrieve_context",
790 response.metadata.clone(),
791 &response,
792 format,
793 )
794 }
795 CliAction::GraphInspect => {
796 let response = service
797 .inspect_graph(GraphInspectionRequest { source_scope: None }, context)
798 .await
799 .map_err(|error| CliError::api_failed(error, format))?;
800
801 render_response(
802 "graph.inspect",
803 response.metadata.clone(),
804 &response,
805 format,
806 )
807 }
808 CliAction::IndexRefresh { kinds } => {
809 let response = service
810 .refresh_indexes(IndexRefreshRequest { kinds }, context)
811 .await
812 .map_err(|error| CliError::api_failed(error, format))?;
813
814 render_response(
815 "index.refresh",
816 response.metadata.clone(),
817 &response,
818 format,
819 )
820 }
821 CliAction::Map(command) => map_cli::run_map(command, None, context, format).await,
822 CliAction::Repo(command) => repo_cli::run_repo(service, command, context, format).await,
823 CliAction::RepoSet(command) => {
824 repo_set_cli::run_repo_set(service, command, context, format).await
825 }
826 CliAction::Health => {
827 let response = service
828 .health(context)
829 .await
830 .map_err(|error| CliError::api_failed(error, format))?;
831
832 render_response(
833 "service.health",
834 response.metadata.clone(),
835 &response,
836 format,
837 )
838 }
839 CliAction::ProviderProbe => {
840 let response = service
841 .probe_embedding_provider(context)
842 .await
843 .map_err(|error| CliError::api_failed(error, format))?;
844
845 render_response(
846 "provider.embedding.probe",
847 response.metadata.clone(),
848 &response,
849 format,
850 )
851 }
852 CliAction::VersionCheck => version_cli::run_version_check(service, format).await,
853 CliAction::ServiceRun { .. } => Err(CliError::ServiceRunFailed(
854 "service run requires process runtime".to_owned(),
855 )),
856 CliAction::Help { path } => cli_spec::render_help(&path, format),
857 CliAction::WorkerStatus { .. }
858 | CliAction::FilesIndex { .. }
859 | CliAction::FilesQuery { .. }
860 | CliAction::FilesContentQuery { .. }
861 | CliAction::WorkerRunOnce { .. }
862 | CliAction::ProposalList { .. }
863 | CliAction::ProposalShow { .. }
864 | CliAction::ProposalAccept { .. }
865 | CliAction::ProposalReject { .. }
866 | CliAction::ProposalSupersede { .. }
867 | CliAction::AuditQuery { .. }
868 | CliAction::ServiceStatus
869 | CliAction::ServicePlan(_)
870 | CliAction::ServiceDefinitionWrite
871 | CliAction::ServiceOperatorStatus
872 | CliAction::ServiceOperatorPause
873 | CliAction::ServiceOperatorResume
874 | CliAction::ServiceWorkerRun { .. }
875 | CliAction::SetupDoctor
876 | CliAction::SetupProfile { .. } => Err(CliError::ApiFailed(
877 "operational command was not handled by the service adapter".to_owned(),
878 )),
879 CliAction::Version => version_cli::render_version(command.format),
880 }
881}
882
883fn parse_action(tokens: Vec<String>) -> Result<CliAction, CliError> {
884 if tokens.is_empty() || tokens == ["status"] {
885 return Ok(CliAction::Status);
886 }
887
888 match tokens[0].as_str() {
889 "status" => Err(CliError::UnexpectedArgument(
890 tokens
891 .get(1)
892 .cloned()
893 .unwrap_or_else(|| "status".to_owned()),
894 )),
895 "ingest" => knowledge_cli::parse_ingest(&tokens[1..]),
896 "query" => knowledge_cli::parse_query(&tokens[1..]),
897 "files" => files_cli::parse_files(&tokens[1..]),
898 "map" => map_cli::parse_map(&tokens[1..]),
899 "repo" => repo_cli::parse_repo(&tokens[1..]).map(CliAction::Repo),
900 "repo-set" => repo_set_cli::parse_repo_set(&tokens[1..]).map(CliAction::RepoSet),
901 "graph" => knowledge_cli::parse_graph(&tokens[1..]),
902 "index" => knowledge_cli::parse_index(&tokens[1..]),
903 "worker" => ops_cli::parse_worker(&tokens[1..]),
904 "proposal" => ops_cli::parse_proposal(&tokens[1..]),
905 "audit" => ops_cli::parse_audit(&tokens[1..]),
906 "provider" => parse_provider(&tokens[1..]),
907 "health" if tokens.len() == 1 => Ok(CliAction::Health),
908 "service" => ops_cli::parse_service(&tokens[1..]),
909 "setup" => setup_cli::parse_setup(&tokens[1..]),
910 "version" if tokens.len() == 1 => Ok(CliAction::Version),
911 "version" if tokens == ["version", "check"] => Ok(CliAction::VersionCheck),
912 "help" => Ok(CliAction::Help {
913 path: help_path(tokens[1..].to_vec()),
914 }),
915 other => Err(CliError::UnexpectedArgument(other.to_owned())),
916 }
917}
918
919fn help_path(tokens: Vec<String>) -> Vec<String> {
920 tokens
921 .into_iter()
922 .filter(|token| token != "--")
923 .filter(|token| !token.starts_with('-'))
924 .collect()
925}
926
927fn parse_provider(tokens: &[String]) -> Result<CliAction, CliError> {
928 if tokens == ["probe"] {
929 return Ok(CliAction::ProviderProbe);
930 }
931
932 Err(CliError::UnexpectedArgument(
933 tokens
934 .first()
935 .cloned()
936 .unwrap_or_else(|| "provider".to_owned()),
937 ))
938}
939
940pub(super) fn value_after(
941 tokens: &[String],
942 index: usize,
943 flag: &'static str,
944) -> Result<String, CliError> {
945 tokens
946 .get(index + 1)
947 .cloned()
948 .ok_or(CliError::MissingValue(flag))
949}
950
951pub(super) fn parse_freshness(value: &str) -> Result<FreshnessPolicy, CliError> {
952 match value {
953 "allow-stale" => Ok(FreshnessPolicy::AllowStale),
954 "wait-until-fresh" => Ok(FreshnessPolicy::WaitUntilFresh),
955 "graph-only" => Ok(FreshnessPolicy::GraphOnly),
956 other => Err(CliError::InvalidFreshness(other.to_owned())),
957 }
958}
959
960#[cfg(test)]
961use service_cli::ensure_web_remote_bind_allowed;
962
963#[cfg(test)]
964#[path = "cli_naming_tests.rs"]
965mod cli_naming_tests;
966
967#[cfg(test)]
968#[path = "cli_tests.rs"]
969mod cli_tests;
970
971#[cfg(test)]
972#[path = "cli_parse_tests.rs"]
973mod cli_parse_tests;
974
975#[cfg(test)]
976#[path = "remote_cli_tests.rs"]
977mod remote_cli_tests;
978
979#[cfg(test)]
980#[path = "cli_map_tests.rs"]
981mod cli_map_tests;
982
983#[cfg(test)]
984#[path = "cli_service_tests.rs"]
985mod cli_service_tests;
986
987#[cfg(test)]
988#[path = "cli_version_tests.rs"]
989mod cli_version_tests;