/
codenik
/
codenik-tools
Обзор
Документация
Войти
/
codenik
/
codenik-tools
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
main
crates/codenik-cli/src/main.rs
562 строки
18 KB
Codenik Wizard
feat(mcp): wire provider tools (repos/issues/mrs) and codenik mcp command
09 май 2026, 01:06
09 май 2026, 01:06
94c6654
Код
Авторство
О чём код?
//! Бинарь `codenik` — CLI поверх codenik-* крейтов. mod auth; mod doctor; mod factory; mod mcp; mod output; use anyhow::Result; use clap::{Parser, Subcommand}; use codenik_core::{ Config, ContextConfig, IssueFilter, IssueKey, IssueState, MergeRequestState, MrFilter, Pagination, RepoFilter, }; #[derive(Parser, Debug)] #[command( name = "codenik", version, about = "Унифицированный CLI для российских dev-сервисов", long_about = "codenik — единый интерфейс к GitVerse, SourceCraft и Yandex Tracker.\n\ Поддерживает контексты проектов, OAuth-авторизацию и MCP-сервер для AI-агентов." )] struct Cli { /// Подробный вывод (повышает уровень логирования). #[arg(short, long, global = true)] verbose: bool, #[command(subcommand)] command: Commands, } #[derive(Subcommand, Debug)] enum Commands { /// Первичная настройка codenik (конфиг, контексты, авторизация). Init, /// Управление авторизацией провайдеров. Auth { #[command(subcommand)] action: AuthAction, }, /// Управление контекстами (профили проекта). Context { #[command(subcommand)] action: ContextAction, }, /// Работа с репозиториями. Repos { #[command(subcommand)] action: ReposAction, }, /// Работа с задачами / issue'ами. Issues { #[command(subcommand)] action: IssuesAction, }, /// Работа с merge / pull request'ами. Mrs { #[command(subcommand)] action: MrsAction, }, /// Запустить MCP-сервер на stdio (для подключения к AI-агентам). Mcp, /// Диагностика конфигурации, авторизации и доступности провайдеров. Doctor, /// Просмотр и управление конфигурацией. Config { #[command(subcommand)] action: ConfigAction, }, } #[derive(Subcommand, Debug)] enum AuthAction { Login { provider: String }, Logout { provider: String }, Status, } #[derive(Subcommand, Debug)] enum ContextAction { List, Use { name: String }, Add { name: String }, Remove { name: String }, Show, } #[derive(Subcommand, Debug)] enum ReposAction { /// Список репозиториев. List { /// Имя провайдера: gitverse | sourcecraft. #[arg(long)] provider: String, /// Владелец (organization). Обязателен для sourcecraft. #[arg(long)] owner: Option<String>, /// Максимум элементов на странице. #[arg(long, default_value_t = 30)] limit: u32, }, /// Информация о репозитории. Show { repo: String }, } #[derive(Subcommand, Debug)] enum IssuesAction { /// Список issue / задач. List { /// Имя провайдера: gitverse | sourcecraft | yandex-tracker. #[arg(long)] provider: String, /// Scope: `owner/repo` для git-провайдеров, `QUEUE_KEY` для Yandex Tracker. #[arg(long)] scope: Option<String>, /// Фильтр по состоянию: open | closed. #[arg(long)] state: Option<String>, /// Максимум элементов. #[arg(long, default_value_t = 30)] limit: u32, }, /// Подробности задачи. Формат key: `<provider>:<scope>:<id>`. /// Примеры: `gitverse:octo/repo:42`, `yandex-tracker:TEST:TEST-1`. Show { key: String }, } #[derive(Subcommand, Debug)] enum MrsAction { /// Список merge / pull request'ов. List { /// gitverse | sourcecraft. #[arg(long)] provider: String, /// `<owner>/<repo>`. #[arg(long)] scope: String, /// open | closed | merged. #[arg(long)] state: Option<String>, #[arg(long, default_value_t = 30)] limit: u32, }, /// Показать MR / PR. Формат key: `<provider>:<owner>/<repo>:<number>`. Show { key: String }, } #[derive(Subcommand, Debug)] enum ConfigAction { Show, Path, } fn not_implemented(what: &str) -> Result<()> { eprintln!("codenik: команда `{what}` ещё не реализована"); std::process::exit(64); } #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); let _ = cli.verbose; match cli.command { Commands::Repos { action: ReposAction::List { provider, owner, limit, }, } => repos_list(&provider, owner, limit).await, Commands::Issues { action: IssuesAction::List { provider, scope, state, limit, }, } => issues_list(&provider, scope, state, limit).await, Commands::Issues { action: IssuesAction::Show { key }, } => issues_show(&key).await, Commands::Mrs { action: MrsAction::List { provider, scope, state, limit, }, } => mrs_list(&provider, scope, state, limit).await, Commands::Mrs { action: MrsAction::Show { key }, } => mrs_show(&key).await, Commands::Init => not_implemented("init"), Commands::Auth { action } => match action { AuthAction::Login { provider } => auth::login(&provider).await, AuthAction::Logout { provider } => auth::logout(&provider), AuthAction::Status => auth::status(), }, Commands::Context { action } => match action { ContextAction::List => context_list(), ContextAction::Use { name } => context_use(&name), ContextAction::Add { name } => context_add(&name), ContextAction::Remove { name } => context_remove(&name), ContextAction::Show => context_show(), }, Commands::Repos { action: ReposAction::Show { repo }, } => not_implemented(&format!("repos show {repo}")), Commands::Mcp => mcp::run().await, Commands::Doctor => doctor::run().await, Commands::Config { action } => match action { ConfigAction::Show => config_show(), ConfigAction::Path => config_path(), }, } } async fn repos_list(provider: &str, owner: Option<String>, limit: u32) -> Result<()> { let store = factory::default_credential_store(); let client = factory::make_repo_provider(provider, &store)?; let filter = RepoFilter { owner, pagination: Pagination { limit: Some(limit), cursor: None, }, ..Default::default() }; let page = client.list_repos(filter).await?; output::print_repos(&page.items); Ok(()) } fn parse_state(s: Option<String>) -> Result<Option<IssueState>> { match s.as_deref() { None | Some("") => Ok(None), Some("open") => Ok(Some(IssueState::Open)), Some("closed") => Ok(Some(IssueState::Closed)), Some("all") => Ok(None), Some(other) => Err(anyhow::anyhow!( "неизвестное значение --state `{other}` (доступны: open, closed, all)" )), } } async fn issues_list( provider: &str, scope: Option<String>, state: Option<String>, limit: u32, ) -> Result<()> { let store = factory::default_credential_store(); let client = factory::make_issue_provider(provider, &store)?; let filter = IssueFilter { scope, state: parse_state(state)?, pagination: Pagination { limit: Some(limit), cursor: None, }, ..Default::default() }; let page = client.list_issues(filter).await?; output::print_issues(&page.items); Ok(()) } /// Парсит `<provider>:<scope>:<id>`. Scope может содержать `:` только если /// его нет внутри (split на 3 части). fn parse_issue_show_key(s: &str) -> Result<(&str, &str, &str)> { let mut parts = s.splitn(3, ':'); let provider = parts .next() .ok_or_else(|| anyhow::anyhow!("ключ должен быть `<provider>:<scope>:<id>`"))?; let scope = parts .next() .ok_or_else(|| anyhow::anyhow!("ключ должен быть `<provider>:<scope>:<id>`"))?; let id = parts .next() .ok_or_else(|| anyhow::anyhow!("ключ должен быть `<provider>:<scope>:<id>`"))?; if provider.is_empty() || scope.is_empty() || id.is_empty() { return Err(anyhow::anyhow!("ключ должен быть `<provider>:<scope>:<id>`")); } Ok((provider, scope, id)) } async fn issues_show(raw_key: &str) -> Result<()> { let (provider, scope, id) = parse_issue_show_key(raw_key)?; let store = factory::default_credential_store(); let client = factory::make_issue_provider(provider, &store)?; let key = IssueKey::new(scope, id); let issue = client.get_issue(&key).await?; output::print_issue_details(&issue); Ok(()) } fn parse_mr_state(s: Option<String>) -> Result<Option<MergeRequestState>> { match s.as_deref() { None | Some("") => Ok(None), Some("open") => Ok(Some(MergeRequestState::Open)), Some("closed") => Ok(Some(MergeRequestState::Closed)), Some("merged") => Ok(Some(MergeRequestState::Merged)), Some("all") => Ok(None), Some(other) => Err(anyhow::anyhow!( "неизвестное значение --state `{other}` (доступны: open, closed, merged, all)" )), } } async fn mrs_list( provider: &str, scope: String, state: Option<String>, limit: u32, ) -> Result<()> { let store = factory::default_credential_store(); let client = factory::make_merge_request_provider(provider, &store)?; let filter = MrFilter { scope: Some(scope), state: parse_mr_state(state)?, pagination: Pagination { limit: Some(limit), cursor: None, }, ..Default::default() }; let page = client.list_merge_requests(filter).await?; output::print_merge_requests(&page.items); Ok(()) } /// Парсит `<provider>:<owner>/<repo>:<number>`. fn parse_mr_show_key(s: &str) -> Result<(&str, &str, &str, u64)> { let mut parts = s.splitn(3, ':'); let provider = parts .next() .ok_or_else(|| anyhow::anyhow!("ключ должен быть `<provider>:<owner>/<repo>:<number>`"))?; let scope = parts .next() .ok_or_else(|| anyhow::anyhow!("ключ должен быть `<provider>:<owner>/<repo>:<number>`"))?; let number_str = parts .next() .ok_or_else(|| anyhow::anyhow!("ключ должен быть `<provider>:<owner>/<repo>:<number>`"))?; if provider.is_empty() || scope.is_empty() || number_str.is_empty() { return Err(anyhow::anyhow!( "ключ должен быть `<provider>:<owner>/<repo>:<number>`" )); } let mut scope_parts = scope.splitn(2, '/'); let owner = scope_parts .next() .filter(|s| !s.is_empty()) .ok_or_else(|| anyhow::anyhow!("scope должен быть owner/repo"))?; let repo = scope_parts .next() .filter(|s| !s.is_empty()) .ok_or_else(|| anyhow::anyhow!("scope должен быть owner/repo"))?; let number: u64 = number_str .parse() .map_err(|_| anyhow::anyhow!("номер MR должен быть числом, получено `{number_str}`"))?; Ok((provider, owner, repo, number)) } async fn mrs_show(raw_key: &str) -> Result<()> { let (provider, owner, repo, number) = parse_mr_show_key(raw_key)?; let store = factory::default_credential_store(); let client = factory::make_merge_request_provider(provider, &store)?; let mr = client.get_merge_request(owner, repo, number).await?; output::print_merge_request_details(&mr); Ok(()) } fn config_path() -> Result<()> { let path = Config::default_path()?; println!("{}", path.display()); Ok(()) } fn config_show() -> Result<()> { let path = Config::default_path()?; let cfg = Config::load_from(&path)?; if !path.exists() { eprintln!( "(файл `{}` отсутствует — показан конфиг по умолчанию)", path.display() ); } let s = toml::to_string_pretty(&cfg) .map_err(|e| anyhow::anyhow!("сериализация конфига: {e}"))?; if s.trim().is_empty() { println!("# (пустой конфиг)"); } else { print!("{s}"); } Ok(()) } fn context_list() -> Result<()> { let cfg = Config::load()?; if cfg.contexts.is_empty() { println!("(контекстов не задано)"); return Ok(()); } let active = cfg.active_context.as_deref(); for name in cfg.contexts.keys() { let marker = if Some(name.as_str()) == active { "*" } else { " " }; println!("{marker} {name}"); } Ok(()) } fn context_show() -> Result<()> { let cfg = Config::load()?; match cfg.active_context.as_deref() { Some(name) => { println!("активный контекст: {name}"); if let Some(ctx) = cfg.contexts.get(name) { let s = toml::to_string_pretty(ctx) .map_err(|e| anyhow::anyhow!("сериализация контекста: {e}"))?; if !s.trim().is_empty() { print!("{s}"); } } else { eprintln!("(контекст `{name}` не найден в [contexts])"); } } None => println!("(активный контекст не задан)"), } Ok(()) } fn context_use(name: &str) -> Result<()> { let mut cfg = Config::load()?; if !cfg.contexts.contains_key(name) { return Err(anyhow::anyhow!( "контекст `{name}` не существует. Создайте его: `codenik context add {name}`" )); } cfg.active_context = Some(name.to_string()); cfg.save()?; println!("активный контекст: {name}"); Ok(()) } fn context_add(name: &str) -> Result<()> { if name.is_empty() { return Err(anyhow::anyhow!("имя контекста не может быть пустым")); } let mut cfg = Config::load()?; if cfg.contexts.contains_key(name) { return Err(anyhow::anyhow!("контекст `{name}` уже существует")); } cfg.contexts.insert(name.to_string(), ContextConfig::default()); cfg.save()?; println!("контекст `{name}` создан"); Ok(()) } fn context_remove(name: &str) -> Result<()> { let mut cfg = Config::load()?; if cfg.contexts.remove(name).is_none() { return Err(anyhow::anyhow!("контекст `{name}` не найден")); } if cfg.active_context.as_deref() == Some(name) { cfg.active_context = None; } cfg.save()?; println!("контекст `{name}` удалён"); Ok(()) } #[cfg(test)] mod cli_tests { use super::*; #[test] fn parse_show_key_three_parts() { let (p, s, i) = parse_issue_show_key("gitverse:octo/repo:42").unwrap(); assert_eq!(p, "gitverse"); assert_eq!(s, "octo/repo"); assert_eq!(i, "42"); } #[test] fn parse_show_key_yandex_tracker() { let (p, s, i) = parse_issue_show_key("yandex-tracker:TEST:TEST-1").unwrap(); assert_eq!(p, "yandex-tracker"); assert_eq!(s, "TEST"); assert_eq!(i, "TEST-1"); } #[test] fn parse_show_key_rejects_two_parts() { let err = parse_issue_show_key("gitverse:octo").unwrap_err(); assert!(err.to_string().contains("provider")); } #[test] fn parse_state_known_values() { assert!(parse_state(None).unwrap().is_none()); assert_eq!(parse_state(Some("open".into())).unwrap(), Some(IssueState::Open)); assert_eq!( parse_state(Some("closed".into())).unwrap(), Some(IssueState::Closed) ); assert!(parse_state(Some("all".into())).unwrap().is_none()); } #[test] fn parse_state_unknown_value_errors() { assert!(parse_state(Some("nope".into())).is_err()); } #[test] fn parse_mr_show_key_full() { let (p, o, r, n) = parse_mr_show_key("gitverse:octo/repo:42").unwrap(); assert_eq!((p, o, r, n), ("gitverse", "octo", "repo", 42)); } #[test] fn parse_mr_show_key_rejects_non_numeric() { let err = parse_mr_show_key("gitverse:octo/repo:abc").unwrap_err(); assert!(err.to_string().contains("числом")); } #[test] fn parse_mr_show_key_rejects_bad_scope() { let err = parse_mr_show_key("gitverse:singlepart:1").unwrap_err(); assert!(err.to_string().contains("owner/repo")); } #[test] fn parse_mr_state_known_values() { assert_eq!( parse_mr_state(Some("open".into())).unwrap(), Some(MergeRequestState::Open) ); assert_eq!( parse_mr_state(Some("merged".into())).unwrap(), Some(MergeRequestState::Merged) ); assert!(parse_mr_state(Some("all".into())).unwrap().is_none()); assert!(parse_mr_state(Some("nope".into())).is_err()); } }