Skip to main content

relay_knowledge/interfaces/
cli.rs

1//! CLI adapter for the shared application service.
2
3#[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, ServicePlanRequest,
36    },
37    application::RelayKnowledgeService,
38    domain::{FreshnessPolicy, IndexKind, ProposalState, WorkerKind},
39    env::{EnvironmentConfig, RemoteCliEnvironmentConfig},
40};
41
42use cli_render::{render_project_status, render_response, serialize_line};
43
44/// Supported CLI output formats.
45#[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    /// Parses a CLI output format value.
69    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/// Parsed CLI command.
81#[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    /// Parses the CLI arguments after the binary name.
91    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            | "--target-version"
218            | "--install-dir"
219    )
220}
221
222fn is_command_word(token: &str) -> bool {
223    matches!(
224        token,
225        "status"
226            | "ingest"
227            | "query"
228            | "repo"
229            | "repo-set"
230            | "files"
231            | "map"
232            | "graph"
233            | "index"
234            | "worker"
235            | "proposal"
236            | "audit"
237            | "provider"
238            | "health"
239            | "service"
240            | "setup"
241            | "version"
242            | "help"
243    )
244}
245
246/// CLI action after global options are removed.
247#[derive(Debug, Clone, PartialEq, Eq)]
248pub enum CliAction {
249    Status,
250    Ingest {
251        source_scope: String,
252        content: String,
253        entity_labels: Vec<String>,
254    },
255    Query {
256        query: String,
257        source_scope: Option<String>,
258        limit: usize,
259        freshness: FreshnessPolicy,
260    },
261    FilesIndex {
262        source_scope: Option<String>,
263        roots: Vec<String>,
264    },
265    FilesQuery {
266        query: String,
267        source_scope: Option<String>,
268        root_id: Option<String>,
269        limit: usize,
270        freshness: FreshnessPolicy,
271    },
272    GraphInspect,
273    IndexRefresh {
274        kinds: Vec<IndexKind>,
275    },
276    Map(map_cli::MapCommand),
277    WorkerStatus {
278        kind: Option<WorkerKind>,
279    },
280    WorkerRunOnce {
281        kind: Option<WorkerKind>,
282    },
283    ProposalList {
284        state: Option<ProposalState>,
285        limit: usize,
286    },
287    ProposalShow {
288        proposal_id: String,
289    },
290    ProposalAccept {
291        proposal_id: String,
292        actor: String,
293        reason: Option<String>,
294    },
295    ProposalReject {
296        proposal_id: String,
297        actor: String,
298        reason: Option<String>,
299    },
300    ProposalSupersede {
301        proposal_id: String,
302        actor: String,
303        reason: Option<String>,
304    },
305    AuditQuery {
306        operation: Option<String>,
307        limit: usize,
308    },
309    ProviderProbe,
310    Repo(repo_cli::RepoCommand),
311    RepoSet(repo_set_cli::RepoSetCommand),
312    Health,
313    ServiceStatus,
314    ServicePlan(ServicePlanRequest),
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/// MCP transport option for foreground service mode.
338#[derive(Debug, Clone, Copy, PartialEq, Eq)]
339pub enum ServiceMcpTransport {
340    Configured,
341    StreamableHttp,
342}
343
344/// CLI adapter error.
345#[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    /// Returns the process exit code for the error.
390    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    /// Renders the process stderr payload for this error.
417    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, upgrade, rollback, 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/// Structured parse diagnostic produced from the CLI grammar.
496#[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
559/// Runs the CLI command and renders its response.
560pub 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/// Rendered stdout/stderr for the process entry point.
570#[derive(Debug, Clone, PartialEq, Eq)]
571pub struct CliProcessOutput {
572    pub stdout: String,
573    pub stderr: String,
574}
575
576/// Runs the CLI command and renders only the command result.
577pub 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
594/// Renders best-effort process-only notices after primary command output is emitted.
595pub 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
699/// Runs a parsed CLI command with an already composed service.
700pub 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;