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