From 507976cc0e09b67b40ea099691c902b97f9ac2be Mon Sep 17 00:00:00 2001 From: Jacob Magar Date: Sat, 23 May 2026 07:55:47 -0400 Subject: [PATCH 1/4] fix(cli): align labby surfaces with implemented behavior --- crates/lab/Cargo.toml | 2 +- crates/lab/src/cli.rs | 64 +- crates/lab/src/cli/extract.rs | 120 +-- crates/lab/src/cli/install.rs | 47 -- crates/lab/src/config/env_merge.rs | 130 +++- crates/lab/src/dispatch/extract.rs | 273 ++++++- crates/lab/src/docs/render.rs | 3 + docs/README.md | 2 +- docs/generated/action-catalog.json | 276 ++++--- docs/generated/action-catalog.md | 14 +- docs/generated/api-routes.json | 16 - docs/generated/api-routes.md | 1 - docs/generated/cli-help.md | 110 +-- docs/generated/mcp-help.json | 84 ++- docs/generated/mcp-help.md | 11 +- docs/generated/openapi.json | 134 ++-- docs/generated/service-catalog.json | 21 - docs/generated/service-catalog.md | 1 - .../2026-05-23-lab-cli-surface-completion.md | 688 ++++++++++++++++++ docs/surfaces/CLI.md | 47 +- plugins/lab/skills/using-lab-cli/SKILL.md | 186 ++--- .../references/config-reference.md | 7 +- 22 files changed, 1510 insertions(+), 727 deletions(-) delete mode 100644 crates/lab/src/cli/install.rs create mode 100644 docs/superpowers/plans/2026-05-23-lab-cli-surface-completion.md diff --git a/crates/lab/Cargo.toml b/crates/lab/Cargo.toml index 9f379cae6..76131e371 100644 --- a/crates/lab/Cargo.toml +++ b/crates/lab/Cargo.toml @@ -47,7 +47,7 @@ tracing-subscriber.workspace = true tracing-appender = "0.2" clap.workspace = true -clap_complete = { workspace = true, optional = true } +clap_complete.workspace = true owo-colors.workspace = true is-terminal.workspace = true indicatif.workspace = true diff --git a/crates/lab/src/cli.rs b/crates/lab/src/cli.rs index e6e4f8909..e4d0bb8da 100644 --- a/crates/lab/src/cli.rs +++ b/crates/lab/src/cli.rs @@ -4,6 +4,7 @@ //! `lab-apis` client (or a lab-local subsystem), and formats output. //! See `crates/lab/src/cli/CLAUDE.md` for the rulebook. +pub mod completions; pub mod docs; pub mod doctor; pub mod extract; @@ -11,7 +12,6 @@ pub mod gateway; pub mod health; pub mod help; pub mod helpers; -pub mod install; pub mod logs; pub mod marketplace; #[cfg(feature = "mcpregistry")] @@ -76,17 +76,12 @@ pub enum Command { Nodes(nodes::NodesArgs), /// Quick reachability check for configured services. Health, - /// Install one or more services into `.mcp.json`. - Install(install::InstallArgs), - /// Uninstall services from `.mcp.json`. - Uninstall(install::UninstallArgs), - /// First-time setup wizard. - Init, /// Open the web-based first-run wizard (or settings) — lab-bg3e.3. Setup(setup::SetupArgs), /// Print the service + action catalog. Help(help::HelpArgs), /// Generate shell completions. + Completions(completions::CompletionsArgs), /// Scan a local or SSH appdata path and extract service credentials. Extract(extract::ExtractCmd), /// Manage proxied upstream MCP gateways. @@ -102,29 +97,6 @@ pub enum Command { Registry(mcpregistry::RegistryArgs), /// Component versioning and deployment. Stash(stash::StashArgs), - /// Radarr movie collection manager. - /// Sonarr TV series manager. - /// Prowlarr indexer manager. - /// Plex media server. - /// Tautulli Plex analytics. - /// `SABnzbd` download client. - /// qBittorrent download client. - /// Tailscale VPN network. - /// Linkding bookmark manager. - /// Memos note-taking service. - /// Beads issue tracker. - /// Bytestash snippet manager. - /// Arcane Docker management UI. - /// Unraid server management. - /// `UniFi` network management. - /// Overseerr media request manager. - /// Gotify push notifications. - /// `OpenAI` API client. - /// Upstream OpenACP daemon. - /// Google NotebookLM client. - /// Qdrant vector database. - /// HF Text Embeddings Inference. - /// Apprise notification dispatcher. /// Deploy the local lab release binary to SSH targets. #[cfg(feature = "deploy")] Deploy(deploy::DeployArgs), @@ -142,11 +114,9 @@ pub async fn dispatch(cli: Cli, config: LabConfig) -> Result { Command::Docs(args) => docs::run(args), Command::Nodes(args) => nodes::run(args, format, &config).await, Command::Health => health::run(format).await, - Command::Install(args) => install::run_install(&args).map(|()| ExitCode::SUCCESS), - Command::Uninstall(args) => install::run_uninstall(&args).map(|()| ExitCode::SUCCESS), - Command::Init => install::run_init().map(|()| ExitCode::SUCCESS), Command::Setup(args) => setup::run(args, format).await, Command::Help(args) => help::run(args, format), + Command::Completions(args) => completions::run(&args), Command::Extract(cmd) => cmd.run(color).await.map(|()| ExitCode::SUCCESS), Command::Gateway(args) => gateway::run(args, format, &config).await, Command::Oauth(args) => oauth::run(args, &config).await, @@ -213,6 +183,34 @@ mod tests { }) )); } + + #[test] + fn cli_rejects_legacy_install_uninstall_init_stubs() { + for command in ["install", "uninstall", "init"] { + let err = + Cli::try_parse_from(["labby", command]).expect_err("legacy stub must be gone"); + assert!( + err.to_string().contains("unrecognized subcommand"), + "{command}: {err}" + ); + } + } + + #[test] + fn replacement_setup_commands_parse() { + let cli = Cli::try_parse_from(["labby", "setup"]).expect("setup parses"); + assert!(matches!(cli.command, Command::Setup(_))); + + let cli = Cli::try_parse_from(["labby", "setup", "install-plugin", "gateway", "-y"]) + .expect("setup install-plugin parses"); + assert!(matches!(cli.command, Command::Setup(_))); + } + + #[test] + fn cli_parses_completions_subcommand() { + let cli = Cli::parse_from(["labby", "completions", "bash"]); + assert!(matches!(cli.command, Command::Completions(_))); + } } /// Deploy dispatch extracted to a helper so the match arm stays a single expression, diff --git a/crates/lab/src/cli/extract.rs b/crates/lab/src/cli/extract.rs index f28a7bcd4..bb7deacdf 100644 --- a/crates/lab/src/cli/extract.rs +++ b/crates/lab/src/cli/extract.rs @@ -14,7 +14,7 @@ use anyhow::{Context, Result}; use clap::Args; use owo_colors::{OwoColorize, XtermColors}; -use crate::config::{backup_env, env_is_up_to_date, write_env}; +use crate::config::{env_merge, write_service_creds}; use crate::output::{ColorPolicy, OutputFormat, RenderEnv, print}; use lab_apis::extract::{ExtractClient, ExtractReport, ScanTarget, Uri}; @@ -81,8 +81,12 @@ impl ExtractCmd { fn apply_report(&self, report: &ExtractReport, color_policy: ColorPolicy) -> Result<()> { let target = self.resolve_env_path()?; + let merge_request = merge_request_from_creds(&report.creds, self.force); + let preview = env_merge::preview(&target, &merge_request) + .with_context(|| format!("preview {}", target.display()))?; + // Rule 8: idempotence check — skip backup and write if nothing would change. - if env_is_up_to_date(&target, &report.creds) { + if preview.written == 0 && preview.skipped.is_empty() { eprintln!("{}", "Already up to date — nothing to write.".dimmed()); return Ok(()); } @@ -104,9 +108,10 @@ impl ExtractCmd { return Ok(()); } - // Rule 1: backup before any write. - let backup = backup_env(&target).with_context(|| format!("backup {}", target.display()))?; - if backup.exists() { + // Canonical merge owns backup, atomic write, permissions, and retention. + let outcome = write_service_creds(&target, &report.creds, self.force) + .with_context(|| format!("write {}", target.display()))?; + if let Some(backup) = &outcome.backup_path { eprintln!( " {} {}", "backup →".dimmed(), @@ -114,11 +119,7 @@ impl ExtractCmd { ); } - // Rules 2–7: atomic merge write. - let warnings = write_env(&target, &report.creds, self.force) - .with_context(|| format!("write {}", target.display()))?; - - for w in &warnings { + for w in &outcome.skipped { eprintln!(" {} {}", "⚠".color(XtermColors::FlushOrange), w); } eprintln!( @@ -131,39 +132,40 @@ impl ExtractCmd { fn diff_report(&self, report: &ExtractReport) -> Result<()> { let target = self.resolve_env_path()?; + let preview = env_merge::preview( + &target, + &merge_request_from_creds(&report.creds, self.force), + ) + .with_context(|| format!("preview {}", target.display()))?; - let existing_raw = if target.exists() { - std::fs::read_to_string(&target) - .with_context(|| format!("read {}", target.display()))? - } else { - String::new() - }; - let existing: std::collections::HashMap = existing_raw - .lines() - .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#')) - .filter_map(|l| { - l.split_once('=') - .map(|(k, v)| (k.trim().to_owned(), v.trim().to_owned())) - }) - .collect(); - - let mut any = false; - for cred in &report.creds { - let svc_upper = cred.service.to_uppercase(); - if let Some(url) = &cred.url { - let key = format!("{svc_upper}_URL"); - print_diff_line(&key, url, &existing); - any = true; - } - if let Some(secret) = &cred.secret { - print_diff_line(&cred.env_field, secret, &existing); - any = true; - } - } - - if !any { + if preview.changes.is_empty() { eprintln!("{}", "No credentials found — nothing to diff.".dimmed()); } + for change in preview.changes { + match change.status { + env_merge::PreviewStatus::Add => eprintln!( + " {} {}", + "+".color(XtermColors::BrightGreen).bold(), + change.key + ), + env_merge::PreviewStatus::Update => eprintln!( + " {} {}", + "~".color(XtermColors::FlushOrange).bold(), + change.key + ), + env_merge::PreviewStatus::Unchanged => { + eprintln!(" {} {}", "=".dimmed(), change.key) + } + env_merge::PreviewStatus::Conflict => eprintln!( + " {} {}", + "!".color(XtermColors::FlushOrange).bold(), + change.key + ), + }; + } + for warning in preview.skipped { + eprintln!(" {} {}", "⚠".color(XtermColors::FlushOrange), warning); + } Ok(()) } @@ -194,22 +196,30 @@ impl ExtractCmd { } } -fn print_diff_line(key: &str, value: &str, existing: &std::collections::HashMap) { - match existing.get(key) { - None => eprintln!( - " {} {}={}", - "+".color(XtermColors::BrightGreen).bold(), - key.color(XtermColors::BrightGreen), - value - ), - Some(ev) if ev == value => { - // Same value already present — silent in diff output. +fn merge_request_from_creds( + creds: &[lab_apis::extract::ServiceCreds], + force: bool, +) -> env_merge::MergeRequest { + let mut entries = Vec::new(); + for cred in creds { + let svc_upper = cred.service.to_uppercase(); + if let Some(url) = &cred.url { + entries.push(env_merge::EnvEntry::new( + format!("{svc_upper}_URL"), + url.clone(), + )); + } + if let Some(secret) = &cred.secret { + entries.push(env_merge::EnvEntry::new( + cred.env_field.clone(), + secret.clone(), + )); } - Some(ev) => eprintln!( - " {} {} (was {ev:?})", - "~".color(XtermColors::FlushOrange).bold(), - format!("{key}={value}").color(XtermColors::FlushOrange) - ), + } + env_merge::MergeRequest { + entries, + force, + expected_mtime: None, } } diff --git a/crates/lab/src/cli/install.rs b/crates/lab/src/cli/install.rs deleted file mode 100644 index 26b142e3d..000000000 --- a/crates/lab/src/cli/install.rs +++ /dev/null @@ -1,47 +0,0 @@ -//! `labby install` / `labby uninstall` / `labby init`. -//! -//! These subcommands mutate the user's `.mcp.json` and/or `~/.lab/.env`. -//! Real logic lives in later plans — stubs return a clear not-implemented error. - -use anyhow::Result; -use clap::Args; - -/// `labby install` arguments. -#[derive(Debug, Args)] -pub struct InstallArgs { - /// Services to install. - #[arg(required = true)] - pub services: Vec, -} - -/// `labby uninstall` arguments. -#[derive(Debug, Args)] -pub struct UninstallArgs { - /// Services to uninstall. - #[arg(required = true)] - pub services: Vec, -} - -/// Run `labby install`. Stub. -/// -/// # Errors -/// Always returns a not-yet-implemented error. -pub fn run_install(_args: &InstallArgs) -> Result<()> { - anyhow::bail!("labby install: not yet implemented") -} - -/// Run `labby uninstall`. Stub. -/// -/// # Errors -/// Always returns a not-yet-implemented error. -pub fn run_uninstall(_args: &UninstallArgs) -> Result<()> { - anyhow::bail!("labby uninstall: not yet implemented") -} - -/// Run `labby init` setup wizard. Stub. -/// -/// # Errors -/// Always returns a not-yet-implemented error. -pub fn run_init() -> Result<()> { - anyhow::bail!("labby init: setup wizard not yet implemented") -} diff --git a/crates/lab/src/config/env_merge.rs b/crates/lab/src/config/env_merge.rs index 1195e4e24..cef880f1b 100644 --- a/crates/lab/src/config/env_merge.rs +++ b/crates/lab/src/config/env_merge.rs @@ -81,6 +81,30 @@ pub struct MergeOutcome { pub pruned: PruneStats, } +/// Dry-run classification for a merge request. +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct MergePreview { + pub changes: Vec, + pub skipped: Vec, + pub written: usize, +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub struct PreviewChange { + pub key: String, + pub status: PreviewStatus, +} + +#[derive(Debug, Clone, Copy, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PreviewStatus { + Add, + Update, + Unchanged, + Conflict, +} + #[derive(Debug, Clone, Copy, Default)] #[allow(dead_code)] pub struct PruneStats { @@ -251,15 +275,12 @@ pub fn merge(path: &Path, req: MergeRequest) -> Result Some(existing_val) if existing_val == &entry.value => { // Idempotent — no change. } - Some(existing_val) => { + Some(_) => { if req.force { overrides.insert(entry.key.clone(), entry.value.clone()); written_count += 1; } else { - skipped.push(format!( - "CONFLICT: {} already set to {:?}; skipping (set force=true to overwrite)", - entry.key, existing_val - )); + skipped.push(conflict_warning(&entry.key)); } } } @@ -322,6 +343,81 @@ pub fn merge(path: &Path, req: MergeRequest) -> Result }) } +/// Classify a merge without writing, backing up, or pruning files. +pub fn preview(path: &Path, req: &MergeRequest) -> Result { + let existing_raw = match fs::read_to_string(path) { + Ok(s) => s, + Err(e) if e.kind() == ErrorKind::NotFound => String::new(), + Err(e) => { + return Err(MergeError::WriteFailed { + path: path.to_path_buf(), + reason: WriteFailReason::from_io(&e), + }); + } + }; + + let mut existing_map: HashMap = HashMap::new(); + for line in existing_raw.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + if let Some((k, v)) = trimmed.split_once('=') { + existing_map.insert(k.trim().to_owned(), strip_quotes(v.trim()).to_owned()); + } + } + + let mut request_entries: Vec = Vec::new(); + for entry in &req.entries { + if let Some(slot) = request_entries + .iter_mut() + .find(|existing| existing.key == entry.key) + { + slot.value = entry.value.clone(); + } else { + request_entries.push(entry.clone()); + } + } + + let mut preview = MergePreview::default(); + for entry in request_entries { + match existing_map.get(&entry.key) { + None => { + preview.written += 1; + preview.changes.push(PreviewChange { + key: entry.key, + status: PreviewStatus::Add, + }); + } + Some(existing_val) if existing_val == &entry.value => { + preview.changes.push(PreviewChange { + key: entry.key, + status: PreviewStatus::Unchanged, + }); + } + Some(_) if req.force => { + preview.written += 1; + preview.changes.push(PreviewChange { + key: entry.key, + status: PreviewStatus::Update, + }); + } + Some(_) => { + preview.skipped.push(conflict_warning(&entry.key)); + preview.changes.push(PreviewChange { + key: entry.key, + status: PreviewStatus::Conflict, + }); + } + } + } + Ok(preview) +} + +fn conflict_warning(key: &str) -> String { + format!("CONFLICT: {key} already set; skipping (set force=true to overwrite)") +} + fn write_atomically(path: &Path, lines: &[String], parent: &Path) -> Result<(), MergeError> { let mut tmp = NamedTempFile::new_in(parent).map_err(|e| MergeError::TempCreate { parent: parent.to_path_buf(), @@ -568,9 +664,33 @@ mod tests { .unwrap(); assert_eq!(outcome.written, 0); assert_eq!(outcome.skipped.len(), 1); + assert!(!outcome.skipped[0].contains("bar")); assert!(fs::read_to_string(&path).unwrap().contains("FOO=bar")); } + #[test] + fn preview_classifies_without_writing_or_leaking_existing_value() { + let dir = tempfile::tempdir().unwrap(); + let path = write_initial(dir.path(), ".env", "FOO=secret\nBAR=same\n"); + let outcome = preview( + &path, + &MergeRequest { + entries: vec![ + EnvEntry::new("FOO", "new-secret"), + EnvEntry::new("BAR", "same"), + EnvEntry::new("BAZ", "new"), + ], + ..Default::default() + }, + ) + .unwrap(); + + assert_eq!(outcome.written, 1); + assert_eq!(outcome.skipped.len(), 1); + assert!(!outcome.skipped[0].contains("secret")); + assert!(fs::read_to_string(&path).unwrap().contains("FOO=secret")); + } + #[test] fn force_overwrite_replaces_value() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/lab/src/dispatch/extract.rs b/crates/lab/src/dispatch/extract.rs index 5a9cd7432..69cbda9c2 100644 --- a/crates/lab/src/dispatch/extract.rs +++ b/crates/lab/src/dispatch/extract.rs @@ -3,10 +3,14 @@ //! This is the always-on service; no feature flag needed. //! All real work is delegated to `lab_apis::extract::ExtractClient`. +use std::path::PathBuf; + use lab_apis::core::action::{ActionSpec, ParamSpec}; -use lab_apis::extract::{ExtractClient, RedactedExtractReport, ScanTarget, Uri}; +use lab_apis::extract::{ExtractClient, RedactedExtractReport, ScanTarget, ServiceCreds, Uri}; +use serde::Serialize; use serde_json::Value; +use crate::config::{env_merge, write_service_creds}; use crate::dispatch::error::ToolError; use crate::dispatch::helpers::{action_schema, help_payload, require_str, to_json}; @@ -88,6 +92,12 @@ pub const ACTIONS: &[ActionSpec] = &[ required: false, description: "Override target env file path", }, + ParamSpec { + name: "force", + ty: "bool", + required: false, + description: "Overwrite conflicting env keys instead of skipping them", + }, ], returns: "WritePlan", }, @@ -95,12 +105,32 @@ pub const ACTIONS: &[ActionSpec] = &[ name: "diff", description: "Show what 'apply' would change vs the current env file (no writes)", destructive: false, - params: &[ParamSpec { - name: "uri", - ty: "string", - required: true, - description: "Local path or 'host:/abs/path' for SSH — same format as scan", - }], + params: &[ + ParamSpec { + name: "uri", + ty: "string", + required: true, + description: "Local path or 'host:/abs/path' for SSH — same format as scan", + }, + ParamSpec { + name: "services", + ty: "string[]", + required: false, + description: "Optional filter; defaults to everything found", + }, + ParamSpec { + name: "env_path", + ty: "string", + required: false, + description: "Override target env file path", + }, + ParamSpec { + name: "force", + ty: "bool", + required: false, + description: "Show overwrite changes instead of skipped conflicts", + }, + ], returns: "WritePlan", }, ]; @@ -146,15 +176,42 @@ pub async fn dispatch(action: &str, params: Value) -> Result { "apply" => { // Destructive — the registry has already invoked elicitation // before we get here, otherwise dispatch would have short-circuited. - Err(ToolError::Sdk { - sdk_kind: "internal_error".into(), - message: "apply not yet implemented".into(), + let uri = parse_uri(¶ms)?; + let force = parse_bool_param(¶ms, "force")?.unwrap_or(false); + let env_path = parse_env_path(¶ms)?; + let report = scan_targeted(uri).await?; + let creds = filter_creds(report.creds, parse_services_filter(¶ms)?)?; + let merge_request = merge_request_from_creds(&creds, force); + let preview = env_merge::preview(&env_path, &merge_request).map_err(map_merge_err)?; + let outcome = write_service_creds(&env_path, &creds, force).map_err(map_merge_err)?; + to_json(WritePlan { + env_path, + credentials: creds.len(), + preview, + applied: true, + written: outcome.written, + skipped: outcome.skipped, + backup_path: outcome.backup_path, + }) + } + "diff" => { + let uri = parse_uri(¶ms)?; + let force = parse_bool_param(¶ms, "force")?.unwrap_or(false); + let env_path = parse_env_path(¶ms)?; + let report = scan_targeted(uri).await?; + let creds = filter_creds(report.creds, parse_services_filter(¶ms)?)?; + let merge_request = merge_request_from_creds(&creds, force); + let preview = env_merge::preview(&env_path, &merge_request).map_err(map_merge_err)?; + to_json(WritePlan { + env_path, + credentials: creds.len(), + written: preview.written, + skipped: preview.skipped.clone(), + preview, + applied: false, + backup_path: None, }) } - "diff" => Err(ToolError::Sdk { - sdk_kind: "internal_error".into(), - message: "diff not yet implemented".into(), - }), unknown => Err(ToolError::UnknownAction { message: format!("unknown action 'extract.{unknown}'"), valid: ACTIONS.iter().map(|a| a.name.to_string()).collect(), @@ -163,13 +220,32 @@ pub async fn dispatch(action: &str, params: Value) -> Result { } } +#[derive(Debug, Serialize)] +struct WritePlan { + env_path: PathBuf, + credentials: usize, + preview: env_merge::MergePreview, + applied: bool, + written: usize, + skipped: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + backup_path: Option, +} + fn parse_redact_secrets(params: &Value) -> Result { - match params.get("redact_secrets") { - None => Ok(false), - Some(value) => value.as_bool().ok_or_else(|| ToolError::InvalidParam { - message: "parameter `redact_secrets` must be a bool".into(), - param: "redact_secrets".into(), - }), + Ok(parse_bool_param(params, "redact_secrets")?.unwrap_or(false)) +} + +fn parse_bool_param(params: &Value, param: &'static str) -> Result, ToolError> { + match params.get(param) { + None => Ok(None), + Some(value) => value + .as_bool() + .map(Some) + .ok_or_else(|| ToolError::InvalidParam { + message: format!("parameter `{param}` must be a bool"), + param: param.into(), + }), } } @@ -220,6 +296,122 @@ fn parse_hosts_filter(params: &Value) -> Result>, ToolError> Ok(if hosts.is_empty() { None } else { Some(hosts) }) } +fn parse_services_filter(params: &Value) -> Result>, ToolError> { + let Some(value) = params.get("services") else { + return Ok(None); + }; + let arr = value.as_array().ok_or_else(|| ToolError::InvalidParam { + message: "parameter `services` must be an array of strings".into(), + param: "services".into(), + })?; + let services = arr + .iter() + .map(|v| { + v.as_str() + .map(|s| s.to_ascii_lowercase()) + .ok_or_else(|| ToolError::InvalidParam { + message: "each element of `services` must be a string".into(), + param: "services".into(), + }) + }) + .collect::, _>>()?; + Ok(if services.is_empty() { + None + } else { + Some(services) + }) +} + +fn parse_env_path(params: &Value) -> Result { + match params.get("env_path") { + None => default_env_path(), + Some(Value::String(path)) if env_path_override_allowed() => Ok(PathBuf::from(path)), + Some(Value::String(_)) => Err(ToolError::InvalidParam { + message: + "parameter `env_path` is only accepted when LAB_ALLOW_EXTRACT_ENV_PATH_OVERRIDE=1" + .into(), + param: "env_path".into(), + }), + Some(_) => Err(ToolError::InvalidParam { + message: "parameter `env_path` must be a string".into(), + param: "env_path".into(), + }), + } +} + +fn default_env_path() -> Result { + let home = std::env::var("HOME").map_err(|_| ToolError::Sdk { + sdk_kind: "internal_error".into(), + message: "$HOME not set".into(), + })?; + Ok(PathBuf::from(home).join(".lab/.env")) +} + +fn env_path_override_allowed() -> bool { + std::env::var("LAB_ALLOW_EXTRACT_ENV_PATH_OVERRIDE").is_ok_and(|value| value == "1") +} + +async fn scan_targeted(uri: Uri) -> Result { + ExtractClient::new() + .scan(ScanTarget::Targeted(uri)) + .await + .map_err(|e| ToolError::Sdk { + sdk_kind: "internal_error".into(), + message: e.to_string(), + }) +} + +fn filter_creds( + creds: Vec, + services: Option>, +) -> Result, ToolError> { + let Some(services) = services else { + return Ok(creds); + }; + let filtered: Vec = creds + .into_iter() + .filter(|cred| services.iter().any(|service| service == &cred.service)) + .collect(); + if filtered.is_empty() { + return Err(ToolError::InvalidParam { + message: "parameter `services` matched no discovered credentials".into(), + param: "services".into(), + }); + } + Ok(filtered) +} + +fn merge_request_from_creds(creds: &[ServiceCreds], force: bool) -> env_merge::MergeRequest { + let mut entries = Vec::new(); + for cred in creds { + let svc_upper = cred.service.to_uppercase(); + if let Some(url) = &cred.url { + entries.push(env_merge::EnvEntry::new( + format!("{svc_upper}_URL"), + url.clone(), + )); + } + if let Some(secret) = &cred.secret { + entries.push(env_merge::EnvEntry::new( + cred.env_field.clone(), + secret.clone(), + )); + } + } + env_merge::MergeRequest { + entries, + force, + expected_mtime: None, + } +} + +fn map_merge_err(err: env_merge::MergeError) -> ToolError { + ToolError::Sdk { + sdk_kind: err.kind().into(), + message: err.to_string(), + } +} + fn parse_scan_target(params: &Value) -> Result { match params.get("uri") { Some(Value::String(_)) => Ok(ScanTarget::Targeted(parse_uri(params)?)), @@ -277,13 +469,52 @@ mod tests { )); } + #[test] + fn env_path_override_is_rejected_by_default() { + let error = parse_env_path(&json!({"env_path": "/tmp/custom.env"})) + .expect_err("api env_path override should be gated"); + assert!(matches!( + error, + ToolError::InvalidParam { param, .. } if param == "env_path" + )); + } + + #[test] + fn services_filter_is_case_insensitive_and_rejects_misses() { + let creds = vec![ServiceCreds { + service: "radarr".to_owned(), + url: Some("http://localhost:7878".to_owned()), + secret: Some("secret-key".to_owned()), + env_field: "RADARR_API_KEY".to_owned(), + source_host: None, + probe_host: None, + runtime: None, + url_verified: false, + }]; + + let filtered = filter_creds( + creds.clone(), + parse_services_filter(&json!({"services": ["RADARR"]})).expect("services"), + ) + .expect("filter"); + assert_eq!(filtered.len(), 1); + + assert!( + filter_creds( + creds, + parse_services_filter(&json!({"services": ["sonarr"]})).expect("services"), + ) + .is_err() + ); + } + #[test] fn redacted_scan_serialization_omits_secret_values() { let report = lab_apis::extract::ExtractReport { target: ScanTarget::Fleet, uri: None, found: vec!["radarr".to_owned()], - creds: vec![lab_apis::extract::ServiceCreds { + creds: vec![ServiceCreds { service: "radarr".to_owned(), url: Some("http://100.64.0.12:7878".to_owned()), secret: Some("secret-key".to_owned()), diff --git a/crates/lab/src/docs/render.rs b/crates/lab/src/docs/render.rs index b71458594..38448ee35 100644 --- a/crates/lab/src/docs/render.rs +++ b/crates/lab/src/docs/render.rs @@ -44,6 +44,9 @@ pub fn cli_help() -> String { ); let mut command = crate::cli::Cli::command(); write_cli_command(&mut out, &mut command, "labby"); + while out.ends_with("\n\n") { + out.pop(); + } out } diff --git a/docs/README.md b/docs/README.md index 035316d86..aa43d0107 100644 --- a/docs/README.md +++ b/docs/README.md @@ -148,7 +148,7 @@ The docs are split by topic so contributors do not have to recover architecture, - [SCAFFOLD_AND_AUDIT.md](./dev/SCAFFOLD_AND_AUDIT.md) `labby scaffold service` and `labby audit onboarding` contract. - [CLI.md](./surfaces/CLI.md) - Command structure, output rules, confirmation rules, install/uninstall, operator commands, and `labby oauth relay-local`. + Command structure, output rules, confirmation rules, setup/install surfaces, operator commands, and `labby oauth relay-local`. - [design/CLI_DESIGN_SYSTEM.md](./design/CLI_DESIGN_SYSTEM.md) Human-readable CLI output language, semantic tokens, status hierarchy, and pipe-safe color policy. - [design/CLI_OUTPUT_THEME_API.md](./design/CLI_OUTPUT_THEME_API.md) diff --git a/docs/generated/action-catalog.json b/docs/generated/action-catalog.json index ed739c118..5888b941b 100644 --- a/docs/generated/action-catalog.json +++ b/docs/generated/action-catalog.json @@ -945,6 +945,12 @@ "ty": "string", "required": false, "description": "Override target env file path" + }, + { + "name": "force", + "ty": "bool", + "required": false, + "description": "Overwrite conflicting env keys instead of skipping them" } ], "returns": "WritePlan", @@ -970,6 +976,24 @@ "ty": "string", "required": true, "description": "Local path or 'host:/abs/path' for SSH — same format as scan" + }, + { + "name": "services", + "ty": "string[]", + "required": false, + "description": "Optional filter; defaults to everything found" + }, + { + "name": "env_path", + "ty": "string", + "required": false, + "description": "Override target env file path" + }, + { + "name": "force", + "ty": "bool", + "required": false, + "description": "Show overwrite changes instead of skipped conflicts" } ], "returns": "WritePlan", @@ -1082,105 +1106,6 @@ "inventory_scope": "global_inventory_not_active_runtime_exposure", "builtin": false }, - { - "service": "fs", - "action": "fs.list", - "description": "List immediate entries of a directory inside the configured workspace root", - "destructive": false, - "params": [ - { - "name": "path", - "ty": "string", - "required": false, - "description": "Workspace-relative path to list; empty or omitted means the workspace root" - } - ], - "returns": "{entries: [{name, path, kind, size, modified, accessible}], truncated: bool}", - "surface_availability": { - "cli": false, - "mcp": true, - "api": true, - "web_ui": true - }, - "requires_http_subject": false, - "auth_posture": "uses the selected transport auth and gateway visibility policy", - "inventory_scope": "global_inventory_not_active_runtime_exposure", - "builtin": false - }, - { - "service": "fs", - "action": "fs.preview", - "description": "Stream a capped byte window from a workspace file (HTTP-only, admin-session gated)", - "destructive": false, - "params": [ - { - "name": "path", - "ty": "string", - "required": true, - "description": "Workspace-relative path of the file to preview" - }, - { - "name": "max_bytes", - "ty": "integer", - "required": false, - "description": "Upper bound on bytes returned; server cap of 2 MiB always wins" - } - ], - "returns": "binary (streamed); mime from safe-MIME whitelist or application/octet-stream", - "surface_availability": { - "cli": false, - "mcp": false, - "api": true, - "web_ui": true - }, - "requires_http_subject": true, - "auth_posture": "HTTP-only admin/browser session path; intentionally unavailable on MCP", - "inventory_scope": "global_inventory_not_active_runtime_exposure", - "builtin": false - }, - { - "service": "fs", - "action": "help", - "description": "Show service actions", - "destructive": false, - "params": [], - "returns": "HelpPayload", - "surface_availability": { - "cli": false, - "mcp": true, - "api": true, - "web_ui": true - }, - "requires_http_subject": false, - "auth_posture": "uses the selected transport auth and gateway visibility policy", - "inventory_scope": "global_inventory_not_active_runtime_exposure", - "builtin": true - }, - { - "service": "fs", - "action": "schema", - "description": "Show the schema for a specific action", - "destructive": false, - "params": [ - { - "name": "action", - "ty": "string", - "required": true, - "description": "Action name to describe" - } - ], - "returns": "ActionSpec", - "surface_availability": { - "cli": false, - "mcp": true, - "api": true, - "web_ui": true - }, - "requires_http_subject": false, - "auth_posture": "uses the selected transport auth and gateway visibility policy", - "inventory_scope": "global_inventory_not_active_runtime_exposure", - "builtin": true - }, { "service": "gateway", "action": "gateway.add", @@ -2058,6 +1983,86 @@ "inventory_scope": "global_inventory_not_active_runtime_exposure", "builtin": false }, + { + "service": "gateway", + "action": "gateway.schema", + "description": "Return the cached tool schemas (input_schema + meta) for one upstream MCP server, filtered by its exposure policy.", + "destructive": false, + "params": [ + { + "name": "name", + "ty": "string", + "required": true, + "description": "Upstream server name (as listed by gateway.servers)." + } + ], + "returns": "GatewayServerSchema", + "surface_availability": { + "cli": true, + "mcp": true, + "api": true, + "web_ui": true + }, + "requires_http_subject": false, + "auth_posture": "uses the selected transport auth and gateway visibility policy", + "inventory_scope": "global_inventory_not_active_runtime_exposure", + "builtin": false + }, + { + "service": "gateway", + "action": "gateway.scout.get", + "description": "Read the gateway-wide scout (tool discovery) settings", + "destructive": false, + "params": [], + "returns": "ScoutConfig", + "surface_availability": { + "cli": true, + "mcp": true, + "api": true, + "web_ui": true + }, + "requires_http_subject": false, + "auth_posture": "uses the selected transport auth and gateway visibility policy", + "inventory_scope": "global_inventory_not_active_runtime_exposure", + "builtin": false + }, + { + "service": "gateway", + "action": "gateway.scout.set", + "description": "Enable or disable gateway-wide scout/invoke mode for all exposed upstream tools", + "destructive": true, + "params": [ + { + "name": "enabled", + "ty": "boolean", + "required": true, + "description": "Whether scout/invoke mode is enabled for the gateway" + }, + { + "name": "top_k_default", + "ty": "integer", + "required": false, + "description": "Default result count for scout when top_k is omitted" + }, + { + "name": "max_tools", + "ty": "integer", + "required": false, + "description": "Maximum number of tools to index per rebuild" + } + ], + "returns": "ScoutConfig", + "surface_availability": { + "cli": true, + "mcp": true, + "api": true, + "web_ui": true + }, + "requires_http_subject": false, + "auth_posture": "uses the selected transport auth and gateway visibility policy", + "inventory_scope": "global_inventory_not_active_runtime_exposure", + "builtin": false + }, { "service": "gateway", "action": "gateway.server.get", @@ -2083,6 +2088,24 @@ "inventory_scope": "global_inventory_not_active_runtime_exposure", "builtin": false }, + { + "service": "gateway", + "action": "gateway.servers", + "description": "List upstream MCP servers connected to the gateway, with cached tool/prompt/resource counts and tools-capability health.", + "destructive": false, + "params": [], + "returns": "GatewayServersDoc", + "surface_availability": { + "cli": true, + "mcp": true, + "api": true, + "web_ui": true + }, + "requires_http_subject": false, + "auth_posture": "uses the selected transport auth and gateway visibility policy", + "inventory_scope": "global_inventory_not_active_runtime_exposure", + "builtin": false + }, { "service": "gateway", "action": "gateway.service_actions", @@ -2244,61 +2267,6 @@ "inventory_scope": "global_inventory_not_active_runtime_exposure", "builtin": false }, - { - "service": "gateway", - "action": "gateway.tool_search.get", - "description": "Read the gateway-wide tool-search settings", - "destructive": false, - "params": [], - "returns": "ToolSearchConfig", - "surface_availability": { - "cli": true, - "mcp": true, - "api": true, - "web_ui": true - }, - "requires_http_subject": false, - "auth_posture": "uses the selected transport auth and gateway visibility policy", - "inventory_scope": "global_inventory_not_active_runtime_exposure", - "builtin": false - }, - { - "service": "gateway", - "action": "gateway.tool_search.set", - "description": "Enable or disable gateway-wide tool-search mode for all exposed upstream tools", - "destructive": true, - "params": [ - { - "name": "enabled", - "ty": "boolean", - "required": true, - "description": "Whether tool_search/tool_execute mode is enabled for the gateway" - }, - { - "name": "top_k_default", - "ty": "integer", - "required": false, - "description": "Default result count for tool_search when top_k is omitted" - }, - { - "name": "max_tools", - "ty": "integer", - "required": false, - "description": "Maximum number of tools to index per rebuild" - } - ], - "returns": "ToolSearchConfig", - "surface_availability": { - "cli": true, - "mcp": true, - "api": true, - "web_ui": true - }, - "requires_http_subject": false, - "auth_posture": "uses the selected transport auth and gateway visibility policy", - "inventory_scope": "global_inventory_not_active_runtime_exposure", - "builtin": false - }, { "service": "gateway", "action": "gateway.update", diff --git a/docs/generated/action-catalog.md b/docs/generated/action-catalog.md index b6899a2ca..a167af97c 100644 --- a/docs/generated/action-catalog.md +++ b/docs/generated/action-catalog.md @@ -39,16 +39,12 @@ This is a global inventory, not the active runtime exposure or authorization pol | `doctor` | `schema` | false | false | `action*: string` | `Schema` | cli, mcp, api | | `doctor` | `service.probe` | false | false | `service*: string`
`instance: string` | `Finding` | cli, mcp, api | | `doctor` | `system.checks` | false | false | | `DoctorReport` | cli, mcp, api | -| `extract` | `apply` | false | true | `uri*: string`
`services: string[]`
`env_path: string` | `WritePlan` | cli, mcp, api | -| `extract` | `diff` | false | false | `uri*: string` | `WritePlan` | cli, mcp, api | +| `extract` | `apply` | false | true | `uri*: string`
`services: string[]`
`env_path: string`
`force: bool` | `WritePlan` | cli, mcp, api | +| `extract` | `diff` | false | false | `uri*: string`
`services: string[]`
`env_path: string`
`force: bool` | `WritePlan` | cli, mcp, api | | `extract` | `help` | false | false | | `Catalog` | cli, mcp, api | | `extract` | `list_hosts` | false | false | | `string[]` | cli, mcp, api | | `extract` | `scan` | false | false | `uri: string`
`hosts: string[]`
`redact_secrets: bool` | `DiscoveredService[]` | cli, mcp, api | | `extract` | `schema` | false | false | `action*: string` | `Schema` | cli, mcp, api | -| `fs` | `fs.list` | false | false | `path: string` | `{entries: [{name, path, kind, size, modified, accessible}], truncated: bool}` | mcp, api, web | -| `fs` | `fs.preview` | false | false | `path*: string`
`max_bytes: integer` | `binary (streamed); mime from safe-MIME whitelist or application/octet-stream` | api, web | -| `fs` | `help` | true | false | | `HelpPayload` | mcp, api, web | -| `fs` | `schema` | true | false | `action*: string` | `ActionSpec` | mcp, api, web | | `gateway` | `gateway.add` | false | true | `spec*: json`
`bearer_token_value: string`
`allow_stdio: boolean` | `GatewayView` | cli, mcp, api, web | | `gateway` | `gateway.client_config.get` | false | false | `name*: string` | `McpClientConfigView` | cli, mcp, api, web | | `gateway` | `gateway.discover` | false | false | `clients: string[]`
`include_existing: boolean` | `DiscoveredServerView[]` | cli, mcp, api, web | @@ -81,15 +77,17 @@ This is a global inventory, not the active runtime exposure or authorization pol | `gateway` | `gateway.public_urls.get` | false | false | | `{app: string?, mcp_gateway: string?, effective_mcp_gateway: string?}` | cli, mcp, api, web | | `gateway` | `gateway.reload` | false | true | | `GatewayCatalogDiff` | cli, mcp, api, web | | `gateway` | `gateway.remove` | false | true | `name*: string` | `GatewayView` | cli, mcp, api, web | +| `gateway` | `gateway.schema` | false | false | `name*: string` | `GatewayServerSchema` | cli, mcp, api, web | +| `gateway` | `gateway.scout.get` | false | false | | `ScoutConfig` | cli, mcp, api, web | +| `gateway` | `gateway.scout.set` | false | true | `enabled*: boolean`
`top_k_default: integer`
`max_tools: integer` | `ScoutConfig` | cli, mcp, api, web | | `gateway` | `gateway.server.get` | false | false | `id*: string` | `ServerView` | cli, mcp, api, web | +| `gateway` | `gateway.servers` | false | false | | `GatewayServersDoc` | cli, mcp, api, web | | `gateway` | `gateway.service_actions` | false | false | `service*: string` | `ServiceActionView[]` | cli, mcp, api, web | | `gateway` | `gateway.service_config.get` | false | false | `service*: string` | `ServiceConfigView` | cli, mcp, api, web | | `gateway` | `gateway.service_config.set` | false | true | `service*: string`
`values*: json` | `ServiceConfigView` | cli, mcp, api, web | | `gateway` | `gateway.status` | false | false | `name: string` | `GatewayRuntimeView[]` | cli, mcp, api, web | | `gateway` | `gateway.supported_services` | false | false | | `SupportedServiceView[]` | cli, mcp, api, web | | `gateway` | `gateway.test` | false | false | `name: string`
`spec: json`
`allow_stdio: boolean` | `GatewayTestResult` | cli, mcp, api, web | -| `gateway` | `gateway.tool_search.get` | false | false | | `ToolSearchConfig` | cli, mcp, api, web | -| `gateway` | `gateway.tool_search.set` | false | true | `enabled*: boolean`
`top_k_default: integer`
`max_tools: integer` | `ToolSearchConfig` | cli, mcp, api, web | | `gateway` | `gateway.update` | false | true | `name*: string`
`patch*: json`
`bearer_token_value: string`
`allow_stdio: boolean` | `GatewayView` | cli, mcp, api, web | | `gateway` | `gateway.virtual_server.disable` | false | true | `id*: string` | `ServerView` | cli, mcp, api, web | | `gateway` | `gateway.virtual_server.enable` | false | true | `id*: string` | `ServerView` | cli, mcp, api, web | diff --git a/docs/generated/api-routes.json b/docs/generated/api-routes.json index edc2e1eb2..3e9330e4b 100644 --- a/docs/generated/api-routes.json +++ b/docs/generated/api-routes.json @@ -511,22 +511,6 @@ "cache_posture": "upgrade, not cacheable", "notes": "legacy websocket alias" }, - { - "method": "POST", - "path": "/v1/fs", - "surface": "api", - "handler_group": "services", - "feature": "fs", - "runtime_condition": "mounted only when fs is enabled and /v1 auth is configured if LAB_WEB_UI_AUTH_DISABLED=true", - "auth_required": true, - "bearer_only": false, - "session_cookie_allowed": true, - "csrf_required": true, - "host_validation": false, - "master_only": true, - "cache_posture": "not cacheable", - "notes": "service action dispatch" - }, { "method": "POST", "path": "/v1/gateway", diff --git a/docs/generated/api-routes.md b/docs/generated/api-routes.md index 0e6df28eb..e58f61510 100644 --- a/docs/generated/api-routes.md +++ b/docs/generated/api-routes.md @@ -36,7 +36,6 @@ Generated by `labby docs generate`. Do not edit by hand. | POST | `/v1/extract` | true | true | true | true | true | extract | extract action dispatch | | POST | `/v1/fleet/hello` | false | false | false | false | false | nodes | legacy node self-registration alias | | GET | `/v1/fleet/ws` | false | false | false | false | false | nodes | legacy websocket alias | -| POST | `/v1/fs` | true | true | true | false | true | services | service action dispatch | | POST | `/v1/gateway` | true | true | true | false | true | gateway | gateway action dispatch | | POST | `/v1/gateway` | true | true | true | false | true | services | service action dispatch | | POST | `/v1/gateway/oauth/cancel` | true | true | true | false | true | upstream_oauth | cancel upstream OAuth flow | diff --git a/docs/generated/cli-help.md b/docs/generated/cli-help.md index 8c93a8d1b..34cce836b 100644 --- a/docs/generated/cli-help.md +++ b/docs/generated/cli-help.md @@ -16,19 +16,17 @@ Commands: docs Generate and verify code-owned documentation artifacts nodes Query nodes from the configured controller health Quick reachability check for configured services - install Install one or more services into `.mcp.json` - uninstall Uninstall services from `.mcp.json` - init First-time setup wizard setup Open the web-based first-run wizard (or settings) — lab-bg3e.3 help Print the service + action catalog - extract Generate shell completions. Scan a local or SSH appdata path and extract service credentials + completions Generate shell completions + extract Scan a local or SSH appdata path and extract service credentials gateway Manage proxied upstream MCP gateways oauth Run local OAuth callback relay helpers logs Search fleet logs on the configured master marketplace Claude plugin marketplace manager registry MCP Registry — look up and install servers from registry.modelcontextprotocol.io stash Component versioning and deployment - deploy Radarr movie collection manager. Sonarr TV series manager. Prowlarr indexer manager. Plex media server. Tautulli Plex analytics. `SABnzbd` download client. qBittorrent download client. Tailscale VPN network. Linkding bookmark manager. Memos note-taking service. Beads issue tracker. Bytestash snippet manager. Arcane Docker management UI. Unraid server management. `UniFi` network management. Overseerr media request manager. Gotify push notifications. `OpenAI` API client. Upstream OpenACP daemon. Google NotebookLM client. Qdrant vector database. HF Text Embeddings Inference. Apprise notification dispatcher. Deploy the local lab release binary to SSH targets + deploy Deploy the local lab release binary to SSH targets Options: --json @@ -580,77 +578,6 @@ Options: Print help ``` -## `labby install` - -```text -Install one or more services into `.mcp.json` - -Usage: install [OPTIONS] ... - -Arguments: - ... - Services to install - -Options: - --json - Emit JSON instead of human-readable tables - - --color - Control human-readable CLI styling - - [default: auto] - [possible values: auto, plain, color] - - -h, --help - Print help -``` - -## `labby uninstall` - -```text -Uninstall services from `.mcp.json` - -Usage: uninstall [OPTIONS] ... - -Arguments: - ... - Services to uninstall - -Options: - --json - Emit JSON instead of human-readable tables - - --color - Control human-readable CLI styling - - [default: auto] - [possible values: auto, plain, color] - - -h, --help - Print help -``` - -## `labby init` - -```text -First-time setup wizard - -Usage: init [OPTIONS] - -Options: - --json - Emit JSON instead of human-readable tables - - --color - Control human-readable CLI styling - - [default: auto] - [possible values: auto, plain, color] - - -h, --help - Print help -``` - ## `labby setup` ```text @@ -968,10 +895,37 @@ Options: Print help ``` +## `labby completions` + +```text +Generate shell completions + +Usage: completions [OPTIONS] + +Arguments: + + Target shell + + [possible values: bash, elvish, fish, powershell, zsh] + +Options: + --json + Emit JSON instead of human-readable tables + + --color + Control human-readable CLI styling + + [default: auto] + [possible values: auto, plain, color] + + -h, --help + Print help +``` + ## `labby extract` ```text -Generate shell completions. Scan a local or SSH appdata path and extract service credentials +Scan a local or SSH appdata path and extract service credentials Usage: extract [OPTIONS] [URI] @@ -2588,7 +2542,7 @@ Options: ## `labby deploy` ```text -Radarr movie collection manager. Sonarr TV series manager. Prowlarr indexer manager. Plex media server. Tautulli Plex analytics. `SABnzbd` download client. qBittorrent download client. Tailscale VPN network. Linkding bookmark manager. Memos note-taking service. Beads issue tracker. Bytestash snippet manager. Arcane Docker management UI. Unraid server management. `UniFi` network management. Overseerr media request manager. Gotify push notifications. `OpenAI` API client. Upstream OpenACP daemon. Google NotebookLM client. Qdrant vector database. HF Text Embeddings Inference. Apprise notification dispatcher. Deploy the local lab release binary to SSH targets +Deploy the local lab release binary to SSH targets Usage: deploy [OPTIONS] diff --git a/docs/generated/mcp-help.json b/docs/generated/mcp-help.json index d8eaf3844..e134befb6 100644 --- a/docs/generated/mcp-help.json +++ b/docs/generated/mcp-help.json @@ -83,6 +83,12 @@ "ty": "string", "required": false, "description": "Override target env file path" + }, + { + "name": "force", + "ty": "bool", + "required": false, + "description": "Overwrite conflicting env keys instead of skipping them" } ], "returns": "WritePlan" @@ -97,6 +103,24 @@ "ty": "string", "required": true, "description": "Local path or 'host:/abs/path' for SSH — same format as scan" + }, + { + "name": "services", + "ty": "string[]", + "required": false, + "description": "Optional filter; defaults to everything found" + }, + { + "name": "env_path", + "ty": "string", + "required": false, + "description": "Override target env file path" + }, + { + "name": "force", + "ty": "bool", + "required": false, + "description": "Show overwrite changes instead of skipped conflicts" } ], "returns": "WritePlan" @@ -139,28 +163,28 @@ "returns": "ServerView[]" }, { - "name": "gateway.tool_search.get", - "description": "Read the gateway-wide tool-search settings", + "name": "gateway.scout.get", + "description": "Read the gateway-wide scout (tool discovery) settings", "destructive": false, "params": [], - "returns": "ToolSearchConfig" + "returns": "ScoutConfig" }, { - "name": "gateway.tool_search.set", - "description": "Enable or disable gateway-wide tool-search mode for all exposed upstream tools", + "name": "gateway.scout.set", + "description": "Enable or disable gateway-wide scout/invoke mode for all exposed upstream tools", "destructive": true, "params": [ { "name": "enabled", "ty": "boolean", "required": true, - "description": "Whether tool_search/tool_execute mode is enabled for the gateway" + "description": "Whether scout/invoke mode is enabled for the gateway" }, { "name": "top_k_default", "ty": "integer", "required": false, - "description": "Default result count for tool_search when top_k is omitted" + "description": "Default result count for scout when top_k is omitted" }, { "name": "max_tools", @@ -169,7 +193,7 @@ "description": "Maximum number of tools to index per rebuild" } ], - "returns": "ToolSearchConfig" + "returns": "ScoutConfig" }, { "name": "gateway.server.get", @@ -799,6 +823,27 @@ ], "returns": "string[]" }, + { + "name": "gateway.servers", + "description": "List upstream MCP servers connected to the gateway, with cached tool/prompt/resource counts and tools-capability health.", + "destructive": false, + "params": [], + "returns": "GatewayServersDoc" + }, + { + "name": "gateway.schema", + "description": "Return the cached tool schemas (input_schema + meta) for one upstream MCP server, filtered by its exposure policy.", + "destructive": false, + "params": [ + { + "name": "name", + "ty": "string", + "required": true, + "description": "Upstream server name (as listed by gateway.servers)." + } + ], + "returns": "GatewayServerSchema" + }, { "name": "gateway.oauth.probe", "description": "Probe a URL for OAuth support via RFC 8414 AS metadata discovery. Rejects userinfo, query strings, and fragments. Registers a transient OAuth manager keyed by URL host, port, and path; it is persisted only after a successful callback updates gateway config.", @@ -3133,29 +3178,6 @@ "returns": "AuditReport" } ] - }, - { - "name": "fs", - "description": "Workspace filesystem browser (read-only, deny-listed)", - "category": "bootstrap", - "status": "available", - "requires_http_subject": false, - "actions": [ - { - "name": "fs.list", - "description": "List immediate entries of a directory inside the configured workspace root", - "destructive": false, - "params": [ - { - "name": "path", - "ty": "string", - "required": false, - "description": "Workspace-relative path to list; empty or omitted means the workspace root" - } - ], - "returns": "{entries: [{name, path, kind, size, modified, accessible}], truncated: bool}" - } - ] } ] } diff --git a/docs/generated/mcp-help.md b/docs/generated/mcp-help.md index ca99c28c8..bd5c872fc 100644 --- a/docs/generated/mcp-help.md +++ b/docs/generated/mcp-help.md @@ -8,13 +8,13 @@ Generated by `labby docs generate`. Do not edit by hand. | `extract` | bootstrap | available | `schema` | false | `action*: string` | `Schema` | | `extract` | bootstrap | available | `list_hosts` | false | | `string[]` | | `extract` | bootstrap | available | `scan` | false | `uri: string`
`hosts: string[]`
`redact_secrets: bool` | `DiscoveredService[]` | -| `extract` | bootstrap | available | `apply` | true | `uri*: string`
`services: string[]`
`env_path: string` | `WritePlan` | -| `extract` | bootstrap | available | `diff` | false | `uri*: string` | `WritePlan` | +| `extract` | bootstrap | available | `apply` | true | `uri*: string`
`services: string[]`
`env_path: string`
`force: bool` | `WritePlan` | +| `extract` | bootstrap | available | `diff` | false | `uri*: string`
`services: string[]`
`env_path: string`
`force: bool` | `WritePlan` | | `gateway` | bootstrap | available | `help` | false | | `Catalog` | | `gateway` | bootstrap | available | `schema` | false | `action*: string` | `Schema` | | `gateway` | bootstrap | available | `gateway.list` | false | | `ServerView[]` | -| `gateway` | bootstrap | available | `gateway.tool_search.get` | false | | `ToolSearchConfig` | -| `gateway` | bootstrap | available | `gateway.tool_search.set` | true | `enabled*: boolean`
`top_k_default: integer`
`max_tools: integer` | `ToolSearchConfig` | +| `gateway` | bootstrap | available | `gateway.scout.get` | false | | `ScoutConfig` | +| `gateway` | bootstrap | available | `gateway.scout.set` | true | `enabled*: boolean`
`top_k_default: integer`
`max_tools: integer` | `ScoutConfig` | | `gateway` | bootstrap | available | `gateway.server.get` | false | `id*: string` | `ServerView` | | `gateway` | bootstrap | available | `gateway.supported_services` | false | | `SupportedServiceView[]` | | `gateway` | bootstrap | available | `gateway.protected_route.list` | false | | `ProtectedMcpRouteConfig[]` | @@ -53,6 +53,8 @@ Generated by `labby docs generate`. Do not edit by hand. | `gateway` | bootstrap | available | `gateway.discovered_tools` | false | `name*: string` | `GatewayToolExposureRowView[]` | | `gateway` | bootstrap | available | `gateway.discovered_resources` | false | `name*: string` | `string[]` | | `gateway` | bootstrap | available | `gateway.discovered_prompts` | false | `name*: string` | `string[]` | +| `gateway` | bootstrap | available | `gateway.servers` | false | | `GatewayServersDoc` | +| `gateway` | bootstrap | available | `gateway.schema` | false | `name*: string` | `GatewayServerSchema` | | `gateway` | bootstrap | available | `gateway.oauth.probe` | true | `url*: string` | `ProbeResult` | | `gateway` | bootstrap | available | `gateway.oauth.start` | false | `upstream*: string`
`subject: string` | `BeginAuthorization` | | `gateway` | bootstrap | available | `gateway.oauth.status` | false | `upstream*: string`
`subject: string` | `UpstreamOauthStatusView` | @@ -182,4 +184,3 @@ Generated by `labby docs generate`. Do not edit by hand. | `lab_admin` | bootstrap | available | `help` | false | | `Catalog` | | `lab_admin` | bootstrap | available | `schema` | false | `action*: string` | `Schema` | | `lab_admin` | bootstrap | available | `onboarding.audit` | false | `services*: string[]` | `AuditReport` | -| `fs` | bootstrap | available | `fs.list` | false | `path: string` | `{entries: [{name, path, kind, size, modified, accessible}], truncated: bool}` | diff --git a/docs/generated/openapi.json b/docs/generated/openapi.json index 6406f7ef4..08535da01 100644 --- a/docs/generated/openapi.json +++ b/docs/generated/openapi.json @@ -388,72 +388,6 @@ ] } }, - "/v1/fs": { - "post": { - "tags": [ - "fs" - ], - "summary": "Dispatch action to fs", - "description": "Execute an action on the fs service. Use `action: \"help\"` to list available actions.", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ActionRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Successful action response", - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "400": { - "description": "Bad request (unknown action, confirmation required)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorUnknownAction" - } - } - } - }, - "401": { - "description": "Authentication failed", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorSdk" - } - } - } - }, - "422": { - "description": "Validation error (missing or invalid param)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorMissingParam" - } - } - } - } - }, - "security": [ - { - "bearer_auth": [] - } - ] - } - }, "/v1/gateway": { "post": { "tags": [ @@ -1366,6 +1300,9 @@ "env_path": { "type": "string" }, + "force": { + "type": "string" + }, "services": { "type": "array", "items": { @@ -1383,6 +1320,18 @@ "uri" ], "properties": { + "env_path": { + "type": "string" + }, + "force": { + "type": "string" + }, + "services": { + "type": "array", + "items": { + "type": "string" + } + }, "uri": { "type": "string" } @@ -1416,14 +1365,6 @@ } } }, - "FsFsListParams": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - }, "GatewayGatewayAddParams": { "type": "object", "required": [ @@ -1766,6 +1707,34 @@ } } }, + "GatewayGatewaySchemaParams": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + } + }, + "GatewayGatewayScoutSetParams": { + "type": "object", + "required": [ + "enabled" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "max_tools": { + "type": "integer" + }, + "top_k_default": { + "type": "integer" + } + } + }, "GatewayGatewayServerGetParams": { "type": "object", "required": [ @@ -1836,23 +1805,6 @@ } } }, - "GatewayGatewayTool_searchSetParams": { - "type": "object", - "required": [ - "enabled" - ], - "properties": { - "enabled": { - "type": "boolean" - }, - "max_tools": { - "type": "integer" - }, - "top_k_default": { - "type": "integer" - } - } - }, "GatewayGatewayUpdateParams": { "type": "object", "required": [ diff --git a/docs/generated/service-catalog.json b/docs/generated/service-catalog.json index 4a6f97815..a8674fd73 100644 --- a/docs/generated/service-catalog.json +++ b/docs/generated/service-catalog.json @@ -125,27 +125,6 @@ "supports_multi_instance": false, "metadata_source": "registry + PluginMeta" }, - { - "name": "fs", - "display_name": "fs", - "description": "Workspace filesystem browser (read-only, deny-listed)", - "category": "bootstrap", - "status": "available", - "feature": "fs", - "exposure": "feature_gated", - "surfaces": { - "cli": false, - "mcp": true, - "api": true, - "web_ui": true - }, - "default_port": null, - "docs_url": null, - "coverage_doc": null, - "upstream_doc": null, - "supports_multi_instance": false, - "metadata_source": "registry synthetic metadata" - }, { "name": "gateway", "display_name": "gateway", diff --git a/docs/generated/service-catalog.md b/docs/generated/service-catalog.md index bd93a9735..75b490029 100644 --- a/docs/generated/service-catalog.md +++ b/docs/generated/service-catalog.md @@ -10,7 +10,6 @@ Generated by `labby docs generate`. Do not edit by hand. | `device` | available | AlwaysOn | - | bootstrap | mcp, api, web | registry synthetic metadata | | `doctor` | available | AlwaysOn | - | bootstrap | cli, mcp, api | registry + PluginMeta | | `extract` | available | FeatureGated | extract | bootstrap | cli, mcp, api | registry + PluginMeta | -| `fs` | available | FeatureGated | fs | bootstrap | mcp, api, web | registry synthetic metadata | | `gateway` | available | AlwaysOn | - | bootstrap | cli, mcp, api, web | registry synthetic metadata | | `lab_admin` | available | RuntimeConditional | - | bootstrap | cli, mcp | registry synthetic metadata | | `logs` | available | AlwaysOn | - | bootstrap | cli, mcp, api, web | registry synthetic metadata | diff --git a/docs/superpowers/plans/2026-05-23-lab-cli-surface-completion.md b/docs/superpowers/plans/2026-05-23-lab-cli-surface-completion.md new file mode 100644 index 000000000..8c4607b34 --- /dev/null +++ b/docs/superpowers/plans/2026-05-23-lab-cli-surface-completion.md @@ -0,0 +1,688 @@ +# Lab CLI Surface Completion Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Finish or remove the misleading incomplete Lab CLI surfaces so `labby --help`, generated docs, MCP/API action catalogs, and the Lab plugin `using-lab-cli` skill describe behavior that actually works. + +**Architecture:** Keep filesystem and environment mutation in the `labby` binary crate, not `lab-apis`. Route `extract.apply` and `extract.diff` through the canonical `config::env_merge` preview/write path so diff and apply semantics cannot drift. Remove the legacy top-level install/init stubs because their real ownership now lives under `setup`, `marketplace`, and `registry`. + +**Tech Stack:** Rust 2024, clap/clap_complete, serde/serde_json, axum dispatch helpers, rmcp action catalog, cargo-nextest, generated docs, Lab plugin skills. + +--- + +## Research And Review Decisions + +- `labby install`, `labby uninstall`, and `labby init` are visible but stubbed. Remove them from clap instead of hiding them, so scripts fail clearly and users use the real surfaces: `labby setup`, `labby setup install-plugin`, `labby marketplace`, and `labby registry install`. +- `labby completions ` already has an implementation file but is not wired into `cli.rs`. Make `clap_complete` a normal dependency because completions are a normal CLI surface. +- `labby extract` remains flag-based: `labby extract [URI] --apply|--diff`. Do not introduce `labby extract scan/apply` subcommands. +- `extract.apply` and `extract.diff` are advertised through MCP/API today but return stubs. Implement them or remove the advertised actions. This plan implements them. +- Do not implement old homelab service stubs. The repo has pivoted to gateway/operator surfaces. +- `labby mcp` is stdio MCP. `labby serve` is HTTP/node runtime. +- Engineering review requirements applied here: canonical env preview, no duplicate env parser, redacted conflict output, constrained `env_path`, symlink/path-safety tests, `services` filtering, mtime conflict detection, and diff-first skill guidance. + +## Implementation Progress + +- Completed: removed top-level `install`/`uninstall`/`init` stubs and regenerated CLI docs. +- Completed: wired `labby completions ` and made `clap_complete` a normal dependency. +- Completed: added canonical `.env` preview classification with redacted conflict warnings. +- Completed: switched CLI `extract --apply/--diff` to the canonical merge path. +- Completed: implemented MCP/API `extract.apply` and `extract.diff`, including `services` filtering and gated `env_path` overrides. +- Completed: updated `plugins/lab/skills/using-lab-cli` and its config reference. +- Verification: `cargo check -p labby --lib`, `cargo run -p labby -- docs check`, `target/debug/labby completions bash`, and `target/debug/labby install` rejection passed. +- Blocked verification: `cargo test -p labby cli::tests:: --lib` still fails before running CLI tests because `crates/lab/src/mcp/server.rs` references missing `tool_search_schema_visible` in its test module. + +## File Map + +- Modify: `crates/lab/Cargo.toml` — make `clap_complete` non-optional. +- Modify: `crates/lab/src/cli.rs` — wire `completions`, remove stub install/init commands, remove stale service enum comments. +- Delete if unused: `crates/lab/src/cli/install.rs`. +- Modify: `crates/lab/src/cli/completions.rs` — update comments and add focused output tests. +- Modify: `crates/lab/src/cli/extract.rs` — keep human output stable; switch apply writes to canonical merge helper. +- Modify: `crates/lab/src/dispatch/extract.rs` — implement safe redacted `diff` and `apply`. +- Modify: `crates/lab/src/config.rs` — expose credential-to-env-entry conversion and mtime-aware `write_service_creds`. +- Modify: `crates/lab/src/config/env_merge.rs` — add redacted preview/classification and redact skipped conflict output. +- Modify: `docs/services/EXTRACT.md`, `docs/surfaces/CLI.md`, `docs/generated/*`. +- Modify: `plugins/lab/skills/using-lab-cli/SKILL.md` and references. + +## Task 1: Remove Misleading Top-Level Install/Init Stubs + +**Files:** +- Modify: `crates/lab/src/cli.rs` +- Delete if unused: `crates/lab/src/cli/install.rs` +- Modify: `docs/surfaces/CLI.md` +- Test: `crates/lab/src/cli.rs` + +- [ ] **Step 1: Add parser tests documenting the desired surface** + +Add tests in `crates/lab/src/cli.rs`: + +```rust +#[test] +fn cli_rejects_legacy_install_uninstall_init_stubs() { + for command in ["install", "uninstall", "init"] { + let err = Cli::try_parse_from(["labby", command]).expect_err("legacy stub must be gone"); + assert!(err.to_string().contains("unrecognized subcommand"), "{command}: {err}"); + } +} + +#[test] +fn replacement_setup_commands_parse() { + let cli = Cli::try_parse_from(["labby", "setup"]).expect("setup parses"); + assert!(matches!(cli.command, Command::Setup(_))); + + let cli = Cli::try_parse_from(["labby", "setup", "install-plugin", "gateway", "-y"]) + .expect("setup install-plugin parses"); + assert!(matches!(cli.command, Command::Setup(_))); +} +``` + +- [ ] **Step 2: Run focused tests and verify failure** + +Run: + +```bash +cargo test -p labby cli_rejects_legacy_install_uninstall_init_stubs replacement_setup_commands_parse --lib +``` + +Expected: first test fails because the legacy commands still parse. + +- [ ] **Step 3: Remove the legacy clap variants** + +In `crates/lab/src/cli.rs`, remove `pub mod install;`, the `Install`, `Uninstall`, and `Init` enum variants, and their dispatch arms. Delete `crates/lab/src/cli/install.rs` if no references remain. + +- [ ] **Step 4: Update CLI docs** + +In `docs/surfaces/CLI.md`, replace top-level install/init guidance with: + +```markdown +- First-run setup: `labby setup` +- Lab service plugin lifecycle: `labby setup install-plugin -y` and `labby setup uninstall-plugin -y` +- MCP Registry installation: `labby marketplace mcp.install --params '{...}' -y` or the `labby registry install` shim where available +``` + +- [ ] **Step 5: Run focused verification** + +Run: + +```bash +cargo test -p labby cli_rejects_legacy_install_uninstall_init_stubs replacement_setup_commands_parse --lib +``` + +Expected: PASS. + +## Task 2: Wire `labby completions ` + +**Files:** +- Modify: `crates/lab/Cargo.toml` +- Modify: `crates/lab/src/cli.rs` +- Modify: `crates/lab/src/cli/completions.rs` +- Test: `crates/lab/src/cli.rs`, `crates/lab/src/cli/completions.rs` + +- [ ] **Step 1: Add failing parser and output tests** + +In `crates/lab/src/cli.rs`, add: + +```rust +#[test] +fn cli_parses_completions_subcommand() { + let cli = Cli::try_parse_from(["labby", "completions", "bash"]).expect("completions parses"); + assert!(matches!(cli.command, Command::Completions(_))); +} +``` + +In `crates/lab/src/cli/completions.rs`, add: + +```rust +pub fn render_for_test(shell: Shell) -> String { + let mut cmd = Cli::command(); + let bin_name = cmd.get_name().to_string(); + let mut out = Vec::new(); + generate(shell, &mut cmd, bin_name, &mut out); + String::from_utf8(out).expect("completion output is utf8") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bash_completion_mentions_labby_and_current_commands() { + let rendered = render_for_test(Shell::Bash); + assert!(rendered.contains("labby")); + assert!(rendered.contains("completions")); + assert!(rendered.contains("extract")); + assert!(!rendered.contains(" install ")); + assert!(!rendered.contains(" init ")); + assert!(rendered.len() < 512_000, "completion output unexpectedly large"); + } +} +``` + +- [ ] **Step 2: Run focused tests and verify failure** + +Run: + +```bash +cargo test -p labby cli_parses_completions_subcommand bash_completion_mentions_labby_and_current_commands --lib +``` + +Expected: compile or test failure because `completions` is not wired and `clap_complete` is optional. + +- [ ] **Step 3: Make `clap_complete` non-optional** + +In `crates/lab/Cargo.toml`, change: + +```toml +clap_complete = { workspace = true, optional = true } +``` + +to: + +```toml +clap_complete.workspace = true +``` + +- [ ] **Step 4: Wire the command** + +In `crates/lab/src/cli.rs`, add `pub mod completions;`, add `Completions(completions::CompletionsArgs)` near `Help`, add `Command::Completions(args) => completions::run(&args)`, and move the stale `Generate shell completions` comment so it no longer describes `Extract`. + +- [ ] **Step 5: Run focused verification** + +Run: + +```bash +cargo test -p labby cli_parses_completions_subcommand bash_completion_mentions_labby_and_current_commands --lib --all-features +cargo check -p labby --no-default-features +``` + +Expected: PASS. The reduced-feature check proves completions wiring does not depend on the `all` feature. + +## Task 3: Add Canonical Env Merge Preview And Redaction + +**Files:** +- Modify: `crates/lab/src/config/env_merge.rs` +- Modify: `crates/lab/src/config.rs` +- Test: `crates/lab/src/config/env_merge.rs` + +- [ ] **Step 1: Add failing preview/redaction tests** + +In `crates/lab/src/config/env_merge.rs`, add: + +```rust +#[test] +fn preview_classifies_entries_with_canonical_quote_semantics() { + let dir = tempfile::tempdir().expect("tempdir"); + let env_path = dir.path().join(".env"); + std::fs::write( + &env_path, + "RADARR_URL=\"http://radarr.local\"\nRADARR_API_KEY=\"old secret\"\n", + ) + .expect("write env"); + + let preview = preview( + &env_path, + MergeRequest { + entries: vec![ + EnvEntry::new("RADARR_URL", "http://radarr.local"), + EnvEntry::new("RADARR_API_KEY", "new secret"), + EnvEntry::new("PROWLARR_URL", "http://prowlarr.local"), + ], + force: false, + expected_mtime: None, + }, + ) + .expect("preview"); + + assert!(preview.entries.iter().any(|entry| entry.key == "RADARR_URL" && entry.status == PreviewStatus::Same)); + assert!(preview.entries.iter().any(|entry| entry.key == "RADARR_API_KEY" && entry.status == PreviewStatus::Conflict)); + assert!(preview.entries.iter().any(|entry| entry.key == "PROWLARR_URL" && entry.status == PreviewStatus::New)); + let rendered = serde_json::to_string(&preview).expect("json"); + assert!(!rendered.contains("old secret")); + assert!(!rendered.contains("new secret")); +} + +#[test] +fn merge_conflict_warning_does_not_include_existing_or_new_value() { + let dir = tempfile::tempdir().expect("tempdir"); + let env_path = dir.path().join(".env"); + std::fs::write(&env_path, "RADARR_API_KEY=old-secret\n").expect("write env"); + + let outcome = merge( + &env_path, + MergeRequest { + entries: vec![EnvEntry::new("RADARR_API_KEY", "new-secret")], + force: false, + expected_mtime: None, + }, + ) + .expect("merge"); + + let rendered = format!("{:?}", outcome.skipped); + assert!(rendered.contains("RADARR_API_KEY")); + assert!(!rendered.contains("old-secret")); + assert!(!rendered.contains("new-secret")); +} +``` + +- [ ] **Step 2: Run tests and verify failure** + +Run: + +```bash +cargo test -p labby preview_classifies_entries_with_canonical_quote_semantics merge_conflict_warning_does_not_include_existing_or_new_value --lib --all-features +``` + +Expected: FAIL because `preview` and `PreviewStatus` do not exist and conflict warnings currently include the existing value. + +- [ ] **Step 3: Implement preview types and shared classification** + +In `env_merge.rs`, add: + +```rust +#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)] +pub struct MergePreview { + pub entries: Vec, + pub written: usize, + pub skipped: usize, + pub force: bool, + #[serde(skip)] + pub expected_mtime: Option, +} + +#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)] +pub struct PreviewEntry { + pub key: String, + pub status: PreviewStatus, +} + +#[derive(Debug, Clone, Copy, serde::Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PreviewStatus { + New, + Same, + Conflict, + Overwrite, +} +``` + +Add `pub fn preview(path: &Path, req: MergeRequest) -> Result`. Extract shared helpers from `merge` so both functions reuse the same file reading, `strip_quotes`, duplicate request-key collapse, and classification rules. + +- [ ] **Step 4: Redact merge conflict warnings** + +Change skipped conflict output in `merge` to: + +```rust +skipped.push(format!( + "CONFLICT: {} already set; skipping (set force=true to overwrite)", + entry.key +)); +``` + +- [ ] **Step 5: Add credential entry conversion and mtime-aware write helper** + +In `crates/lab/src/config.rs`, add: + +```rust +pub fn service_creds_to_env_entries(creds: &[ServiceCreds]) -> Vec { + let mut entries = Vec::new(); + for cred in creds { + let svc_upper = cred.service.to_uppercase(); + if let Some(url) = &cred.url { + entries.push(env_merge::EnvEntry::new(format!("{svc_upper}_URL"), url.clone())); + } + if let Some(secret) = &cred.secret { + entries.push(env_merge::EnvEntry::new(cred.env_field.clone(), secret.clone())); + } + } + entries +} +``` + +Update `write_service_creds` to call this helper and accept `expected_mtime: Option`. + +- [ ] **Step 6: Run focused verification** + +Run: + +```bash +cargo test -p labby preview_classifies_entries_with_canonical_quote_semantics merge_conflict_warning_does_not_include_existing_or_new_value --lib --all-features +``` + +Expected: PASS. + +## Task 4: Implement Safe `extract.diff` And `extract.apply` + +**Files:** +- Modify: `crates/lab/src/dispatch/extract.rs` +- Modify: `crates/lab/src/cli/extract.rs` +- Modify: `crates/lab/src/config.rs` +- Test: `crates/lab/src/dispatch/extract.rs`, `crates/lab/src/cli/extract.rs` + +- [ ] **Step 1: Add failing dispatch/helper tests** + +Use the current type shape from `crates/lab-apis/src/extract/types.rs`. Add: + +```rust +fn test_extract_report_with_radarr_and_sonarr() -> lab_apis::extract::ExtractReport { + lab_apis::extract::ExtractReport { + target: ScanTarget::Targeted("/tmp/appdata".parse().expect("uri")), + uri: Some("/tmp/appdata".parse().expect("uri")), + found: vec!["radarr".to_string(), "sonarr".to_string()], + creds: vec![ + lab_apis::extract::ServiceCreds { + service: "radarr".to_string(), + url: Some("http://radarr".to_string()), + secret: Some("radarr-secret".to_string()), + env_field: "RADARR_API_KEY".to_string(), + source_host: None, + probe_host: None, + runtime: None, + url_verified: false, + }, + lab_apis::extract::ServiceCreds { + service: "sonarr".to_string(), + url: Some("http://sonarr".to_string()), + secret: Some("sonarr-secret".to_string()), + env_field: "SONARR_API_KEY".to_string(), + source_host: None, + probe_host: None, + runtime: None, + url_verified: false, + }, + ], + warnings: vec![], + } +} + +#[tokio::test] +async fn extract_diff_requires_targeted_uri() { + let err = dispatch("diff", serde_json::json!({})).await.expect_err("missing uri"); + assert_eq!(err.kind(), "missing_param"); +} + +#[tokio::test] +async fn extract_apply_rejects_missing_targeted_uri() { + let err = dispatch("apply", serde_json::json!({})).await.expect_err("missing uri"); + assert_eq!(err.kind(), "missing_param"); +} + +#[test] +fn apply_report_uses_canonical_merge_and_redacted_plan() { + let dir = tempfile::tempdir().expect("tempdir"); + let env_path = dir.path().join(".env"); + let report = test_extract_report_with_radarr_and_sonarr(); + + let applied = apply_report_to_env(&report, &env_path, false, None).expect("apply"); + let written = std::fs::read_to_string(&env_path).expect("env written"); + assert!(written.contains("RADARR_URL=http://radarr")); + assert!(written.contains("SONARR_API_KEY=sonarr-secret")); + let json = serde_json::to_string(&applied).expect("json"); + assert!(!json.contains("radarr-secret")); + assert!(!json.contains("sonarr-secret")); +} + +#[test] +fn apply_report_respects_services_filter() { + let dir = tempfile::tempdir().expect("tempdir"); + let env_path = dir.path().join(".env"); + let filtered = filter_report_services( + test_extract_report_with_radarr_and_sonarr(), + &["sonarr".to_string()], + ) + .expect("filter"); + + apply_report_to_env(&filtered, &env_path, false, None).expect("apply"); + + let written = std::fs::read_to_string(&env_path).expect("env written"); + assert!(written.contains("SONARR_URL=")); + assert!(!written.contains("RADARR_URL=")); +} + +#[cfg(unix)] +#[test] +fn env_path_rejects_symlink() { + let dir = tempfile::tempdir().expect("tempdir"); + let target = dir.path().join("target.env"); + let link = dir.path().join(".env"); + std::fs::write(&target, "").expect("target"); + std::os::unix::fs::symlink(&target, &link).expect("symlink"); + assert!(validate_env_path_for_write(&link).is_err()); +} +``` + +- [ ] **Step 2: Run tests and verify failure** + +Run: + +```bash +cargo test -p labby extract_diff_requires_targeted_uri extract_apply_rejects_missing_targeted_uri apply_report_uses_canonical_merge_and_redacted_plan apply_report_respects_services_filter env_path_rejects_symlink --lib --all-features +``` + +Expected: helper tests fail because the helpers do not exist and apply/diff still hit stubs. + +- [ ] **Step 3: Add redacted extract plan/outcome types** + +In `dispatch/extract.rs`, add serializable output types that contain keys/status only, no values: + +```rust +#[derive(Debug, Clone, Serialize)] +pub struct ExtractWritePlan { + pub env_path: PathBuf, + pub entries: Vec, + pub written: usize, + pub skipped: usize, + pub force: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ExtractApplyOutcome { + pub plan: ExtractWritePlan, + pub backup_path: Option, + pub skipped: Vec, + pub pruned_backups: usize, +} +``` + +- [ ] **Step 4: Add safe env-path resolution** + +Keep arbitrary `--env-path` as local CLI behavior only. For dispatch/API/MCP, default to `$HOME/.lab/.env` and reject `env_path` unless `LAB_ALLOW_EXTRACT_ENV_PATH_OVERRIDE=1` is set for tests. + +Add `validate_env_path_for_write(path)`: + +- reject symlink final components using `std::fs::symlink_metadata` +- canonicalize an existing parent +- reject file names other than `.env` +- map invalid paths to `ToolError::InvalidParam` + +- [ ] **Step 5: Add force and services parsing** + +Add: + +```rust +fn parse_force(params: &Value) -> Result { /* bool only */ } +fn parse_services(params: &Value) -> Result>, ToolError> { /* array of strings only */ } +fn filter_report_services(report: ExtractReport, services: &[String]) -> Result { /* lowercase filter */ } +``` + +Do not leave `services` advertised and ignored. + +- [ ] **Step 6: Add preview/apply helpers** + +Use `env_merge::snapshot_mtime`, `env_merge::preview`, and mtime-aware `write_service_creds`. Do not parse `.env` in dispatch. + +```rust +pub(crate) fn build_write_plan( + report: &lab_apis::extract::ExtractReport, + env_path: &Path, + force: bool, +) -> Result<(ExtractWritePlan, Option), ToolError> { + validate_env_path_for_write(env_path)?; + let mtime = crate::config::env_merge::snapshot_mtime(env_path); + let preview = crate::config::env_merge::preview( + env_path, + crate::config::env_merge::MergeRequest { + entries: crate::config::service_creds_to_env_entries(&report.creds), + force, + expected_mtime: mtime, + }, + ) + .map_err(map_merge_error)?; + Ok(( + ExtractWritePlan { + env_path: env_path.to_path_buf(), + entries: preview.entries, + written: preview.written, + skipped: preview.skipped, + force, + }, + mtime, + )) +} +``` + +`apply_report_to_env(report, env_path, force, expected_mtime)` must call `write_service_creds(env_path, &report.creds, force, expected_mtime)` and return redacted `ExtractApplyOutcome`. + +- [ ] **Step 7: Wire dispatch actions** + +Replace the current `apply`/`diff` stubs. Both actions must require a targeted `uri`, parse `force`, parse and apply `services`, resolve the safe env path, scan the target, and return redacted plan/outcome JSON. `apply` remains destructive in `ACTIONS`. Add `force` to `apply` and `diff` params; keep `services` only if implemented. + +- [ ] **Step 8: Keep CLI output stable but use canonical merge for writes** + +Do not rewrite CLI human output wholesale. Preserve current `labby extract --diff` and `--apply --dry-run` behavior unless tests prove it must change. Replace the write path so `--apply` uses the mtime-aware canonical `write_service_creds` instead of `backup_env` + `write_env`. + +If local CLI diff continues to print raw values, document that it is a local-only terminal workflow and never used by API/MCP. Add a test or smoke check showing API/MCP JSON never includes raw secrets. + +- [ ] **Step 9: Run focused verification** + +Run: + +```bash +cargo test -p labby extract_ --lib --all-features +``` + +Expected: extract tests pass and `apply not yet implemented` no longer appears in dispatch tests. + +## Task 5: Align Docs, Generated Artifacts, And Skill References + +**Files:** +- Modify: `docs/services/EXTRACT.md` +- Modify: `docs/surfaces/CLI.md` +- Modify: `docs/generated/*` +- Modify: `plugins/lab/skills/using-lab-cli/SKILL.md` +- Modify: `plugins/lab/skills/using-lab-cli/references/service-catalog.md` +- Modify: `plugins/lab/skills/using-lab-cli/references/config-reference.md` + +- [ ] **Step 1: Update hand-written docs** + +Document: + +```markdown +- `labby mcp` is the stdio MCP entrypoint. +- `labby serve` is the HTTP/node runtime. +- `labby extract [URI] --apply|--diff` is the CLI shape. +- `extract.diff` and `extract.apply` are available through MCP/API after destructive confirmation and admin-capable auth. +- `extract.diff` and `extract.apply` machine outputs are redacted and never include discovered or existing secret values. +- `env_path` override is local/test-only; API/MCP use the canonical Lab env file. +- Old homelab service stubs are not product surfaces; Lab is gateway/operator focused. +``` + +- [ ] **Step 2: Update plugin skill examples** + +In `plugins/lab/skills/using-lab-cli/SKILL.md`, replace all `lab ...` examples with `labby ...`. Replace `labby serve # stdio` with: + +```bash +labby mcp # stdio MCP for local MCP clients +labby serve # HTTP/node runtime, including /mcp +``` + +Replace extract examples with diff-first guidance: + +```bash +labby extract +labby extract host:/path/to/appdata --diff +# Only after the user explicitly approves the diff: +labby extract host:/path/to/appdata --apply -y +``` + +Add prose: agents must run `--diff` first and must not pass `--env-path` unless the user names a Lab env file explicitly. + +- [ ] **Step 3: Regenerate docs** + +Run: + +```bash +cargo run -p labby --all-features -- docs generate +``` + +Expected: + +- generated CLI help includes `labby completions` +- generated help excludes top-level `install`, `uninstall`, and `init` +- generated extract action catalog includes implemented `extract.apply` and `extract.diff` +- generated docs contain no `not yet implemented` language for implemented actions +- generated docs do not contain concrete secret-looking values such as `API_KEY=`, `TOKEN=`, `PASSWORD=`, `SECRET=`, or bearer-looking sample tokens except obvious placeholders + +- [ ] **Step 4: Run docs check** + +Run: + +```bash +just docs-check +``` + +Expected: PASS. + +## Task 6: Final Verification + +**Files:** +- All files touched by prior tasks. + +- [ ] **Step 1: Focused Rust tests** + +Run: + +```bash +cargo test -p labby cli_ completions extract env_merge --lib --all-features +``` + +Expected: PASS. + +- [ ] **Step 2: Early workspace compile check** + +Run: + +```bash +cargo check --workspace --all-features +``` + +Expected: PASS. This is an early compile gate; `just check` below may repeat it as part of the repo-standard final gate. If this fails on the pre-existing `tool_search_schema_visible` compile issue, fix that compile break in the same worktree before continuing because work-it requires a green worktree. + +- [ ] **Step 3: Required repo gates** + +Run: + +```bash +just check +just lint +just test +just build +``` + +Expected: PASS. + +- [ ] **Step 4: Runtime smoke checks** + +Run: + +```bash +LAB_LOG_DIR=/tmp/lab-logs ./target/debug/labby --help +LAB_LOG_DIR=/tmp/lab-logs ./target/debug/labby completions bash | head -20 +LAB_LOG_DIR=/tmp/lab-logs ./target/debug/labby extract --help +``` + +Expected: + +- `--help` includes `completions` +- `--help` does not include top-level `install`, `uninstall`, or `init` +- completion output mentions `labby` +- extract help shows `[URI]` plus `--apply` and `--diff` diff --git a/docs/surfaces/CLI.md b/docs/surfaces/CLI.md index 1c2bc2333..3ee70c8c5 100644 --- a/docs/surfaces/CLI.md +++ b/docs/surfaces/CLI.md @@ -13,7 +13,6 @@ The CLI is the human-facing surface for `lab`. It must remain thin, predictable, The CLI includes: -- one subcommand per service - `mcp` - `nodes` - `logs` @@ -23,13 +22,8 @@ The CLI includes: - `stash` - `plugins` - `setup` -- `install` -- `uninstall` -- `init` - `health` - `doctor` -- `audit` -- `scaffold` - `extract` - `oauth` - `help` @@ -38,8 +32,7 @@ The CLI includes: Representative command tree: ```text -lab -├── ... +labby ├── mcp ├── nodes ├── logs @@ -49,34 +42,28 @@ lab ├── stash ├── plugins ├── setup -├── install -├── uninstall -├── init ├── health ├── doctor -├── audit -├── scaffold ├── extract ├── oauth ├── help └── completions ``` -## Per-Service Commands +## Service Actions -Each service subcommand must expose operations in a way that mirrors the service model cleanly. +Service actions are exposed through the shared catalog and the MCP/API dispatch +surfaces. Do not add per-service CLI command trees unless the service needs a +genuine human-operator workflow that cannot be represented by the shared action +model. Examples: -- `lab radarr movie-lookup --params '{"query":"The Matrix"}'` -- `lab sonarr series.list` -- `lab plex library.list` -- `lab unraid system.array` -- `labby openai models` -- `lab qdrant collections.list` +- `labby help` - `labby marketplace mcp.meta.set --params '{"name":"io.github.user/server","metadata":{"curation":{"featured":true},"trust":{"reviewed":true}}}'` +- MCP/API: `extract({ "action": "scan", "params": { "uri": "/mnt/appdata" } })` -The CLI must not invent a second semantic model that drifts from MCP or the SDK. +The CLI must not invent a second semantic model that drifts from MCP, HTTP, or the SDK. ## Output Formats @@ -147,7 +134,7 @@ The CLI reads the same destructive flag from `ActionSpec` that MCP uses for elic The CLI must support explicit instance selection where relevant: ```bash -lab unraid array status --instance shart +labby help --all ``` If there is a clear default instance, that can be used implicitly. Otherwise the command must fail loudly and ask for an instance. @@ -230,19 +217,21 @@ Rules: ## Install and Uninstall -`labby install` and `labby uninstall` handle: +Top-level `labby install`, `labby uninstall`, and `labby init` are not part of +the supported CLI surface. Use the owning surfaces instead: -- env validation and prompting -- `.mcp.json` patching -- service enablement changes +- `labby setup` for first-run and local environment repair flows +- `labby setup install-plugin ` for Lab plugin installation +- `labby marketplace ...` for marketplace-managed plugin operations +- `labby registry ...` for MCP Registry installs when the `mcpregistry` feature is enabled -These commands are operationally sensitive and must use atomic file writes and backup behavior. +These flows are operationally sensitive and must use atomic file writes and backup behavior. Expected `.mcp.json` behavior: 1. locate the file 2. parse or initialize it -3. compute the updated `--services` list +3. compute the updated server/plugin list 4. support dry-run diffing 5. back up before mutation 6. write atomically diff --git a/plugins/lab/skills/using-lab-cli/SKILL.md b/plugins/lab/skills/using-lab-cli/SKILL.md index be0081a64..483149474 100644 --- a/plugins/lab/skills/using-lab-cli/SKILL.md +++ b/plugins/lab/skills/using-lab-cli/SKILL.md @@ -1,165 +1,101 @@ --- name: using-lab-cli -description: This skill should be used when the user wants to run labby CLI commands, operate any homelab service through the labby binary (Radarr, Sonarr, UniFi, Unraid, Linkding, Gotify, SABnzbd, Qdrant, Prowlarr, Bytestash, Apprise, TEI), manage the labby MCP server (labby serve, labby install, labby uninstall), configure credentials in ~/.lab/.env, scan for credentials with labby extract, check service health with labby doctor, scaffold a new service with labby scaffold, or perform any action dispatch against a homelab service using the action + params pattern. +description: This skill should be used when the user wants to run labby CLI commands, manage the labby MCP stdio server or HTTP/API server, configure credentials in ~/.lab/.env, scan or apply credentials with labby extract, check Lab health with labby doctor, manage gateway or marketplace surfaces, install Lab plugins through labby setup, or perform action + params dispatch against Lab operator services. --- -# Using the `lab` CLI +# Using the `labby` CLI -`lab` is a pluggable homelab CLI + MCP server. One binary, 21 services, runtime MCP tool selection. +`labby` is the Lab binary. Treat generated help and `docs/` as source of truth when this skill and the repo disagree. ## Quick Start ```bash -lab help # Full service + action catalog -labby doctor # Audit all configured services (health, auth, reachability) -labby health # Quick reachability check -lab --help # Per-service subcommands -labby --json # Machine-readable output for any command +labby help # Service + action catalog +labby doctor # Full health/config audit +labby health # Quick availability check +labby --json doctor # Machine-readable output +labby completions bash # Generate shell completions ``` -## Top-Level Commands +Use `labby`, not the old `lab` command name. + +## Top-Level Surfaces | Command | Purpose | |---------|---------| -| `labby serve` | Start MCP server (stdio or HTTP transport) | -| `labby doctor` | Full health audit: env vars, reachability, auth, version | -| `labby health` | Quick reachability check for all configured services | -| `lab plugins` | Open plugin manager TUI | -| `labby audit onboarding` | Audit service onboarding against repo contract | -| `labby install ` | Install service into `.mcp.json` | -| `labby uninstall ` | Remove service from `.mcp.json` | -| `labby init` | First-time setup wizard | -| `lab help` | Service + action catalog | -| `labby scaffold service ` | Generate new service onboarding scaffold | -| `lab completions` | Generate shell completions | - -## Available Services - -For current service status, see [references/service-catalog.md](references/service-catalog.md). - -**Active services** (fully implemented): `extract`, `radarr`, `prowlarr`, `sabnzbd`, `linkding`, `bytestash`, `unraid`, `unifi`, `gotify`, `qdrant`, `tei`, `apprise` - -**Stub services** (not yet implemented): `sonarr`, `plex`, `tautulli`, `qbittorrent`, `tailscale`, `memos`, `arcane`, `overseerr`, `openai` - -If asked to use a stub service, inform the user it is not yet implemented and suggest `labby doctor` to see what is actually configured. - -## CLI vs MCP Naming - -**CLI subcommands use kebab-case**: `movie-list`, `movie-lookup`, `bookmark-list` - -**MCP action strings use `resource.verb` dot notation**: `movie.search`, `movie.add`, `bookmark.list` - -They map to the same underlying operations — the surface determines the form. - -## Common Patterns - -### Querying a service - -```bash -lab radarr movie-list -lab radarr movie-lookup --query "The Matrix" -lab radarr calendar-list --json - -lab unifi client-list -lab unraid system-status - -lab linkding bookmark-list --tag homelab -``` - -### Destructive operations require `--yes` - -```bash -lab radarr movie-delete --id 42 --yes -lab sabnzbd queue-purge --yes -labby extract apply --yes # writes to ~/.lab/.env (backs up first) -``` - -`extract apply` merges found credentials into `~/.lab/.env`. It backs up the file before writing. Use `--force` to overwrite on key conflicts instead of the default skip-and-warn. - -### Multi-instance services +| `labby mcp` | Start the MCP server over stdio | +| `labby serve` | Start the HTTP/API server | +| `labby doctor` | Audit config, auth, and runtime health | +| `labby health` | Quick availability check | +| `labby setup` | First-run/setup and plugin install flows | +| `labby setup install-plugin ` | Install a Lab plugin | +| `labby gateway ...` | Manage proxied upstream MCP gateways | +| `labby marketplace ...` | Manage marketplace/plugin metadata | +| `labby registry ...` | MCP Registry install/search when enabled | +| `labby extract [URI]` | Scan appdata for credentials | +| `labby extract [URI] --diff` | Preview `.env` changes | +| `labby extract [URI] --apply [-y]` | Merge discovered credentials into `.env` | +| `labby logs ...` | Search/tail Lab logs | +| `labby stash ...` | Component versioning/deployment metadata | + +Do not suggest top-level `labby install`, `labby uninstall`, or `labby init`; those legacy stubs are intentionally unsupported. Use `setup`, `marketplace`, or `registry` instead. + +## CLI vs MCP + +The MCP surface exposes one tool per runtime service with flat action strings: -Some services support multiple instances (e.g. multiple Unraid nodes). Select via `--instance`: - -```bash -lab unraid system-status --instance node2 -``` - -Instances are configured in `~/.lab/.env` with a label prefix: -``` -UNRAID_URL=http://tower.local -UNRAID_NODE2_URL=http://tower2.local +```json +{ "action": "help" } +{ "action": "schema", "params": { "action": "gateway.reload" } } +{ "action": "tool.search", "params": { "query": "radarr queue" } } ``` -### JSON output - -```bash -lab radarr movie-list --json | jq '.[].title' -labby doctor --json # CI-friendly audit -``` +For direct MCP stdio use, run `labby mcp`. For browser/API/admin workflows, run `labby serve`. -## Scaffolding a New Service +## Extract Credentials -When onboarding a new service, always scaffold first and audit second: +`extract` scans local or SSH appdata roots. `--apply` and `--diff` require a targeted URI. ```bash -labby scaffold service # generates module stubs in the correct locations -labby audit onboarding # checks all services against the repo contract +labby extract /mnt/user/appdata +labby extract squirts:/mnt/user/appdata --diff +labby extract squirts:/mnt/user/appdata --apply -y +labby extract squirts:/mnt/user/appdata --apply -y --force ``` -`scaffold` produces the required files (`client.rs`, `types.rs`, `error.rs`, module declaration, CLI shim, MCP dispatch) in the right crate locations. `audit onboarding` verifies the scaffold matches the contract before wiring it into the build. +`--apply` uses the canonical `.env` merge path: backup, atomic write, key dedupe, comment preservation, conflict warnings, and secure file permissions. Do not pass `-y` for a destructive operation unless the user explicitly approved it. ## Configuration -Config lives in `~/.lab/.env`. For full env-var reference, see [references/config-reference.md](references/config-reference.md). - -Each service uses: +Config lives in `~/.lab/.env` and `config.toml` using Lab's documented load order. Common env keys: -``` +```bash {SERVICE}_URL=http://... -{SERVICE}_API_KEY=... # API key auth -{SERVICE}_TOKEN=... # Bearer token auth -{SERVICE}_USERNAME=... # Basic auth +{SERVICE}_API_KEY=... +{SERVICE}_TOKEN=... +{SERVICE}_USERNAME=... {SERVICE}_PASSWORD=... ``` -**Bootstrap from existing configs:** +Multi-instance services add a label before the suffix, for example `UNRAID_NODE2_URL`. -```bash -labby extract scan # Find credentials in local config files -labby extract scan --ssh user@host # Scan remote host -labby extract apply --yes # Write found credentials to ~/.lab/.env -``` +## Dev Commands -## MCP Server Mode +Inside the Lab repo, default verification is all-features: ```bash -labby serve # stdio (for Claude Desktop, claude.ai) -labby serve --http # HTTP with bearer auth -labby install radarr # Add radarr tool to .mcp.json -labby install --all # Install all available services +just check +just test +just lint +just build +just run -- help ``` -Each service exposes one MCP tool with `action` + `params` dispatch: - -```json -{ "action": "movie.search", "params": { "query": "The Matrix" } } -{ "action": "help" } -{ "action": "schema", "params": { "action": "movie.add" } } -``` - -## Dev Commands (inside the lab repository) - -```bash -just build # cargo build --workspace --all-features -just test # cargo nextest run -just lint # clippy + fmt check -just check # cargo check --workspace -just run # cargo run --all-features -- -``` +If you run a narrow command for speed, treat the result as provisional until the all-features path is checked. ## Troubleshooting -- **Service not found**: set `{SERVICE}_URL` in `~/.lab/.env`, then run `labby doctor` -- **Auth errors**: set `{SERVICE}_API_KEY` or `{SERVICE}_TOKEN` for the service -- **Stub service**: not yet implemented — inform the user and run `labby doctor` to show what is configured -- **All services**: run `labby doctor` for a comprehensive health report; exit code reflects worst severity +- Check current commands with `labby --help` or `labby --help`. +- Use `labby doctor --json` when you need structured evidence. +- For MCP stdio problems, verify `labby mcp`; for HTTP/browser problems, verify `labby serve`. +- For stale docs, refresh generated docs before editing hand-written guidance. diff --git a/plugins/lab/skills/using-lab-cli/references/config-reference.md b/plugins/lab/skills/using-lab-cli/references/config-reference.md index 85f6693ec..835124cf9 100644 --- a/plugins/lab/skills/using-lab-cli/references/config-reference.md +++ b/plugins/lab/skills/using-lab-cli/references/config-reference.md @@ -28,8 +28,7 @@ UNRAID_NODE2_URL=http://tower2.local UNRAID_NODE2_API_KEY=... ``` -CLI: `lab unraid system-status --instance node2` -MCP: `{ "action": "system.status", "params": { "instance": "node2" } }` +MCP/API dispatch: `{ "action": "system.status", "params": { "instance": "node2" } }` Unknown instance labels return a structured error listing valid labels. @@ -56,9 +55,9 @@ LAB_LOG=labby=info,lab_apis=warn # tracing filter directive (default) LAB_LOG_FORMAT=json # emit newline-delimited JSON (for prod/CI) ``` -## extract.apply Behavior +## `extract --apply` Behavior -`labby extract apply --yes` writes credentials to `~/.lab/.env`: +`labby extract /path/to/appdata --apply -y` writes credentials to `~/.lab/.env`: - Backs up the file before writing - Deduplicates by key, preserves order and comments From 4e25ec043b16cc583dd6692c77d4bdff9db3ea55 Mon Sep 17 00:00:00 2001 From: Jacob Magar Date: Sat, 23 May 2026 07:58:03 -0400 Subject: [PATCH 2/4] style: apply rustfmt --- crates/lab/src/cli/gateway.rs | 7 +++---- crates/lab/src/dispatch/gateway/dispatch.rs | 8 +++++++- crates/lab/src/mcp/server.rs | 11 ++++++----- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/crates/lab/src/cli/gateway.rs b/crates/lab/src/cli/gateway.rs index a680b6a9d..05a03b924 100644 --- a/crates/lab/src/cli/gateway.rs +++ b/crates/lab/src/cli/gateway.rs @@ -614,10 +614,9 @@ pub async fn run(args: GatewayArgs, format: OutputFormat, config: &LabConfig) -> "max_tools": args.max_tools, }), ), - GatewayToolSearchCommand::Disable => ( - "gateway.scout.set".to_string(), - json!({ "enabled": false }), - ), + GatewayToolSearchCommand::Disable => { + ("gateway.scout.set".to_string(), json!({ "enabled": false })) + } }, GatewayCommand::Reload => ( "gateway.reload".to_string(), diff --git a/crates/lab/src/dispatch/gateway/dispatch.rs b/crates/lab/src/dispatch/gateway/dispatch.rs index 99ffdad91..42ddf0d77 100644 --- a/crates/lab/src/dispatch/gateway/dispatch.rs +++ b/crates/lab/src/dispatch/gateway/dispatch.rs @@ -894,7 +894,13 @@ mod tests { async fn gateway_dispatch_rejects_synthetic_tool_execution_actions() { let manager = test_manager(); - for action in ["tool_execute", "tool_invoke", "tool_search", "scout", "invoke"] { + for action in [ + "tool_execute", + "tool_invoke", + "tool_search", + "scout", + "invoke", + ] { let err = dispatch_with_manager(&manager, action, json!({})) .await .expect_err("synthetic top-level MCP tools are not gateway actions"); diff --git a/crates/lab/src/mcp/server.rs b/crates/lab/src/mcp/server.rs index c7f7d4ad2..b6d99f3fd 100644 --- a/crates/lab/src/mcp/server.rs +++ b/crates/lab/src/mcp/server.rs @@ -26,7 +26,7 @@ use crate::config::NodeRole; use crate::dispatch::gateway::manager::{GatewayManager, GatewayToolSearchResult}; use crate::mcp::catalog::{ LEGACY_TOOL_EXECUTE_TOOL_NAME, LEGACY_TOOL_INVOKE_TOOL_NAME, LEGACY_TOOL_SEARCH_TOOL_NAME, - TOOL_EXECUTE_TOOL_NAME, TOOL_SEARCH_TOOL_NAME, + TOOL_EXECUTE_TOOL_NAME, TOOL_SEARCH_TOOL_NAME, }; use crate::mcp::elicitation::{ElicitResult, elicit_confirm}; use crate::mcp::envelope::{build_error, build_error_extra, build_success}; @@ -1359,9 +1359,7 @@ impl ServerHandler for LabMcpServer { } if matches!( service.as_str(), - TOOL_EXECUTE_TOOL_NAME - | LEGACY_TOOL_EXECUTE_TOOL_NAME - | LEGACY_TOOL_INVOKE_TOOL_NAME + TOOL_EXECUTE_TOOL_NAME | LEGACY_TOOL_EXECUTE_TOOL_NAME | LEGACY_TOOL_INVOKE_TOOL_NAME ) { let started = Instant::now(); let tool_name = args @@ -3498,7 +3496,10 @@ mod tests { Some(&read_only), true )); - assert!(super::tool_search_include_schema_allowed(Some(&admin), true)); + assert!(super::tool_search_include_schema_allowed( + Some(&admin), + true + )); assert!(super::tool_search_include_schema_allowed(None, true)); assert!(!super::tool_search_include_schema_allowed( Some(&admin), From 14eaac2c7df3bac3b1060deef249a7de3398c097 Mon Sep 17 00:00:00 2001 From: Jacob Magar Date: Sat, 23 May 2026 08:05:30 -0400 Subject: [PATCH 3/4] test: restore tool search schema visibility helper --- crates/lab/src/mcp/server.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/lab/src/mcp/server.rs b/crates/lab/src/mcp/server.rs index b6d99f3fd..fb339e6bc 100644 --- a/crates/lab/src/mcp/server.rs +++ b/crates/lab/src/mcp/server.rs @@ -2595,6 +2595,11 @@ fn tool_search_include_schema_allowed( }) } +#[cfg(test)] +fn tool_search_schema_visible(auth: Option<&crate::api::oauth::AuthContext>) -> bool { + tool_search_include_schema_allowed(auth, true) +} + fn tool_execute_builtin_action_allowed( entry: &crate::registry::RegisteredService, action: &str, From 3ffc195f450132f11c1c46c70640787d33fb1a1b Mon Sep 17 00:00:00 2001 From: Jacob Magar Date: Sat, 23 May 2026 08:17:26 -0400 Subject: [PATCH 4/4] docs: refresh all-features generated artifacts --- docs/generated/action-catalog.json | 99 +++++++++++++++++++++++++++++ docs/generated/action-catalog.md | 4 ++ docs/generated/api-routes.json | 16 +++++ docs/generated/api-routes.md | 1 + docs/generated/mcp-help.json | 23 +++++++ docs/generated/mcp-help.md | 1 + docs/generated/openapi.json | 74 +++++++++++++++++++++ docs/generated/service-catalog.json | 21 ++++++ docs/generated/service-catalog.md | 1 + 9 files changed, 240 insertions(+) diff --git a/docs/generated/action-catalog.json b/docs/generated/action-catalog.json index 5888b941b..0177c25d9 100644 --- a/docs/generated/action-catalog.json +++ b/docs/generated/action-catalog.json @@ -1106,6 +1106,105 @@ "inventory_scope": "global_inventory_not_active_runtime_exposure", "builtin": false }, + { + "service": "fs", + "action": "fs.list", + "description": "List immediate entries of a directory inside the configured workspace root", + "destructive": false, + "params": [ + { + "name": "path", + "ty": "string", + "required": false, + "description": "Workspace-relative path to list; empty or omitted means the workspace root" + } + ], + "returns": "{entries: [{name, path, kind, size, modified, accessible}], truncated: bool}", + "surface_availability": { + "cli": false, + "mcp": true, + "api": true, + "web_ui": true + }, + "requires_http_subject": false, + "auth_posture": "uses the selected transport auth and gateway visibility policy", + "inventory_scope": "global_inventory_not_active_runtime_exposure", + "builtin": false + }, + { + "service": "fs", + "action": "fs.preview", + "description": "Stream a capped byte window from a workspace file (HTTP-only, admin-session gated)", + "destructive": false, + "params": [ + { + "name": "path", + "ty": "string", + "required": true, + "description": "Workspace-relative path of the file to preview" + }, + { + "name": "max_bytes", + "ty": "integer", + "required": false, + "description": "Upper bound on bytes returned; server cap of 2 MiB always wins" + } + ], + "returns": "binary (streamed); mime from safe-MIME whitelist or application/octet-stream", + "surface_availability": { + "cli": false, + "mcp": false, + "api": true, + "web_ui": true + }, + "requires_http_subject": true, + "auth_posture": "HTTP-only admin/browser session path; intentionally unavailable on MCP", + "inventory_scope": "global_inventory_not_active_runtime_exposure", + "builtin": false + }, + { + "service": "fs", + "action": "help", + "description": "Show service actions", + "destructive": false, + "params": [], + "returns": "HelpPayload", + "surface_availability": { + "cli": false, + "mcp": true, + "api": true, + "web_ui": true + }, + "requires_http_subject": false, + "auth_posture": "uses the selected transport auth and gateway visibility policy", + "inventory_scope": "global_inventory_not_active_runtime_exposure", + "builtin": true + }, + { + "service": "fs", + "action": "schema", + "description": "Show the schema for a specific action", + "destructive": false, + "params": [ + { + "name": "action", + "ty": "string", + "required": true, + "description": "Action name to describe" + } + ], + "returns": "ActionSpec", + "surface_availability": { + "cli": false, + "mcp": true, + "api": true, + "web_ui": true + }, + "requires_http_subject": false, + "auth_posture": "uses the selected transport auth and gateway visibility policy", + "inventory_scope": "global_inventory_not_active_runtime_exposure", + "builtin": true + }, { "service": "gateway", "action": "gateway.add", diff --git a/docs/generated/action-catalog.md b/docs/generated/action-catalog.md index a167af97c..d8769a64c 100644 --- a/docs/generated/action-catalog.md +++ b/docs/generated/action-catalog.md @@ -45,6 +45,10 @@ This is a global inventory, not the active runtime exposure or authorization pol | `extract` | `list_hosts` | false | false | | `string[]` | cli, mcp, api | | `extract` | `scan` | false | false | `uri: string`
`hosts: string[]`
`redact_secrets: bool` | `DiscoveredService[]` | cli, mcp, api | | `extract` | `schema` | false | false | `action*: string` | `Schema` | cli, mcp, api | +| `fs` | `fs.list` | false | false | `path: string` | `{entries: [{name, path, kind, size, modified, accessible}], truncated: bool}` | mcp, api, web | +| `fs` | `fs.preview` | false | false | `path*: string`
`max_bytes: integer` | `binary (streamed); mime from safe-MIME whitelist or application/octet-stream` | api, web | +| `fs` | `help` | true | false | | `HelpPayload` | mcp, api, web | +| `fs` | `schema` | true | false | `action*: string` | `ActionSpec` | mcp, api, web | | `gateway` | `gateway.add` | false | true | `spec*: json`
`bearer_token_value: string`
`allow_stdio: boolean` | `GatewayView` | cli, mcp, api, web | | `gateway` | `gateway.client_config.get` | false | false | `name*: string` | `McpClientConfigView` | cli, mcp, api, web | | `gateway` | `gateway.discover` | false | false | `clients: string[]`
`include_existing: boolean` | `DiscoveredServerView[]` | cli, mcp, api, web | diff --git a/docs/generated/api-routes.json b/docs/generated/api-routes.json index 3e9330e4b..edc2e1eb2 100644 --- a/docs/generated/api-routes.json +++ b/docs/generated/api-routes.json @@ -511,6 +511,22 @@ "cache_posture": "upgrade, not cacheable", "notes": "legacy websocket alias" }, + { + "method": "POST", + "path": "/v1/fs", + "surface": "api", + "handler_group": "services", + "feature": "fs", + "runtime_condition": "mounted only when fs is enabled and /v1 auth is configured if LAB_WEB_UI_AUTH_DISABLED=true", + "auth_required": true, + "bearer_only": false, + "session_cookie_allowed": true, + "csrf_required": true, + "host_validation": false, + "master_only": true, + "cache_posture": "not cacheable", + "notes": "service action dispatch" + }, { "method": "POST", "path": "/v1/gateway", diff --git a/docs/generated/api-routes.md b/docs/generated/api-routes.md index e58f61510..0e6df28eb 100644 --- a/docs/generated/api-routes.md +++ b/docs/generated/api-routes.md @@ -36,6 +36,7 @@ Generated by `labby docs generate`. Do not edit by hand. | POST | `/v1/extract` | true | true | true | true | true | extract | extract action dispatch | | POST | `/v1/fleet/hello` | false | false | false | false | false | nodes | legacy node self-registration alias | | GET | `/v1/fleet/ws` | false | false | false | false | false | nodes | legacy websocket alias | +| POST | `/v1/fs` | true | true | true | false | true | services | service action dispatch | | POST | `/v1/gateway` | true | true | true | false | true | gateway | gateway action dispatch | | POST | `/v1/gateway` | true | true | true | false | true | services | service action dispatch | | POST | `/v1/gateway/oauth/cancel` | true | true | true | false | true | upstream_oauth | cancel upstream OAuth flow | diff --git a/docs/generated/mcp-help.json b/docs/generated/mcp-help.json index e134befb6..fb5e27521 100644 --- a/docs/generated/mcp-help.json +++ b/docs/generated/mcp-help.json @@ -3178,6 +3178,29 @@ "returns": "AuditReport" } ] + }, + { + "name": "fs", + "description": "Workspace filesystem browser (read-only, deny-listed)", + "category": "bootstrap", + "status": "available", + "requires_http_subject": false, + "actions": [ + { + "name": "fs.list", + "description": "List immediate entries of a directory inside the configured workspace root", + "destructive": false, + "params": [ + { + "name": "path", + "ty": "string", + "required": false, + "description": "Workspace-relative path to list; empty or omitted means the workspace root" + } + ], + "returns": "{entries: [{name, path, kind, size, modified, accessible}], truncated: bool}" + } + ] } ] } diff --git a/docs/generated/mcp-help.md b/docs/generated/mcp-help.md index bd5c872fc..868c1180c 100644 --- a/docs/generated/mcp-help.md +++ b/docs/generated/mcp-help.md @@ -184,3 +184,4 @@ Generated by `labby docs generate`. Do not edit by hand. | `lab_admin` | bootstrap | available | `help` | false | | `Catalog` | | `lab_admin` | bootstrap | available | `schema` | false | `action*: string` | `Schema` | | `lab_admin` | bootstrap | available | `onboarding.audit` | false | `services*: string[]` | `AuditReport` | +| `fs` | bootstrap | available | `fs.list` | false | `path: string` | `{entries: [{name, path, kind, size, modified, accessible}], truncated: bool}` | diff --git a/docs/generated/openapi.json b/docs/generated/openapi.json index 08535da01..fe9cd337a 100644 --- a/docs/generated/openapi.json +++ b/docs/generated/openapi.json @@ -388,6 +388,72 @@ ] } }, + "/v1/fs": { + "post": { + "tags": [ + "fs" + ], + "summary": "Dispatch action to fs", + "description": "Execute an action on the fs service. Use `action: \"help\"` to list available actions.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful action response", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "400": { + "description": "Bad request (unknown action, confirmation required)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorUnknownAction" + } + } + } + }, + "401": { + "description": "Authentication failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorSdk" + } + } + } + }, + "422": { + "description": "Validation error (missing or invalid param)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorMissingParam" + } + } + } + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, "/v1/gateway": { "post": { "tags": [ @@ -1365,6 +1431,14 @@ } } }, + "FsFsListParams": { + "type": "object", + "properties": { + "path": { + "type": "string" + } + } + }, "GatewayGatewayAddParams": { "type": "object", "required": [ diff --git a/docs/generated/service-catalog.json b/docs/generated/service-catalog.json index a8674fd73..4a6f97815 100644 --- a/docs/generated/service-catalog.json +++ b/docs/generated/service-catalog.json @@ -125,6 +125,27 @@ "supports_multi_instance": false, "metadata_source": "registry + PluginMeta" }, + { + "name": "fs", + "display_name": "fs", + "description": "Workspace filesystem browser (read-only, deny-listed)", + "category": "bootstrap", + "status": "available", + "feature": "fs", + "exposure": "feature_gated", + "surfaces": { + "cli": false, + "mcp": true, + "api": true, + "web_ui": true + }, + "default_port": null, + "docs_url": null, + "coverage_doc": null, + "upstream_doc": null, + "supports_multi_instance": false, + "metadata_source": "registry synthetic metadata" + }, { "name": "gateway", "display_name": "gateway", diff --git a/docs/generated/service-catalog.md b/docs/generated/service-catalog.md index 75b490029..bd93a9735 100644 --- a/docs/generated/service-catalog.md +++ b/docs/generated/service-catalog.md @@ -10,6 +10,7 @@ Generated by `labby docs generate`. Do not edit by hand. | `device` | available | AlwaysOn | - | bootstrap | mcp, api, web | registry synthetic metadata | | `doctor` | available | AlwaysOn | - | bootstrap | cli, mcp, api | registry + PluginMeta | | `extract` | available | FeatureGated | extract | bootstrap | cli, mcp, api | registry + PluginMeta | +| `fs` | available | FeatureGated | fs | bootstrap | mcp, api, web | registry synthetic metadata | | `gateway` | available | AlwaysOn | - | bootstrap | cli, mcp, api, web | registry synthetic metadata | | `lab_admin` | available | RuntimeConditional | - | bootstrap | cli, mcp | registry synthetic metadata | | `logs` | available | AlwaysOn | - | bootstrap | cli, mcp, api, web | registry synthetic metadata |