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"]
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/// Supported CLI output formats.
47#[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    /// Parses a CLI output format value.
71    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/// Parsed CLI command.
83#[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    /// Parses the CLI arguments after the binary name.
93    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/// CLI action after global options are removed.
250#[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/// MCP transport option for foreground service mode.
348#[derive(Debug, Clone, Copy, PartialEq, Eq)]
349pub enum ServiceMcpTransport {
350    Configured,
351    StreamableHttp,
352}
353
354/// CLI adapter error.
355#[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    /// Returns the process exit code for the error.
400    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    /// Renders the process stderr payload for this error.
427    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/// Structured parse diagnostic produced from the CLI grammar.
506#[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
569/// Runs the CLI command and renders its response.
570pub 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/// Rendered stdout/stderr for the process entry point.
580#[derive(Debug, Clone, PartialEq, Eq)]
581pub struct CliProcessOutput {
582    pub stdout: String,
583    pub stderr: String,
584}
585
586/// Runs the CLI command and renders only the command result.
587pub 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
598/// Renders best-effort process-only notices after primary command output is emitted.
599pub 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
703/// Runs a parsed CLI command with an already composed service.
704pub 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;