From c3c6912285f89f5f7d368c44cd5e63e34d93ee46 Mon Sep 17 00:00:00 2001 From: 111 Date: Tue, 18 Aug 2026 18:55:06 +0800 Subject: [PATCH] Add multi-agent WebFetch Skill support --- .cargo/config.toml | 2 + .github/workflows/ci.yml | 36 ++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 36 +- skills/lexmount-webfetch/SKILL.md | 41 +- .../references/authentication.md | 19 +- .../lexmount-webfetch/references/commands.md | 28 +- .../references/troubleshooting.md | 22 +- .../lexmount-webfetch/scripts/bootstrap.ps1 | 2 +- skills/lexmount-webfetch/scripts/bootstrap.sh | 2 +- src/auth.rs | 114 ++++- src/client.rs | 4 +- src/main.rs | 391 ++++++++++++---- src/skill.rs | 435 ++++++++++++++++++ tests/cli.rs | 125 +++++ 16 files changed, 1111 insertions(+), 150 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 src/skill.rs create mode 100644 tests/cli.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..ac2b23f --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.x86_64-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 906dca7..1741d7c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,6 +65,16 @@ jobs: unzip -Z1 dist/lexmount-webfetch.zip | grep -qx 'SKILL.md' ! unzip -Z1 dist/lexmount-webfetch.zip | grep -q '^lexmount-webfetch/' ! unzip -Z1 dist/lexmount-webfetch.zip | grep -q '^bin/' + expected_files="$(printf '%s\n' \ + 'SKILL.md' \ + 'references/authentication.md' \ + 'references/commands.md' \ + 'references/troubleshooting.md' \ + 'scripts/bootstrap.ps1' \ + 'scripts/bootstrap.sh' \ + 'scripts/doctor.ps1' \ + 'scripts/doctor.sh')" + test "$(unzip -Z1 dist/lexmount-webfetch.zip)" = "$expected_files" windows-bootstrap: runs-on: windows-latest @@ -82,3 +92,29 @@ jobs: $env:LEXMOUNT_WEBFETCH_CLI_INSTALL_DIR = Join-Path $env:RUNNER_TEMP "webfetch-cli-bootstrap" & .\skills\lexmount-webfetch\scripts\bootstrap.ps1 & (Join-Path $env:LEXMOUNT_WEBFETCH_CLI_INSTALL_DIR "webfetch-cli.exe") version + + windows-release: + runs-on: windows-latest + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-pc-windows-msvc + - run: cargo test --locked --target x86_64-pc-windows-msvc + - run: cargo build --release --locked --target x86_64-pc-windows-msvc + - name: Verify standalone Windows executable + shell: powershell + run: | + $binary = "target/x86_64-pc-windows-msvc/release/webfetch-cli.exe" + & $binary version + if ($LASTEXITCODE -ne 0) { throw "webfetch-cli version failed with exit code $LASTEXITCODE" } + + $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + $installationPath = & $vswhere -latest -property installationPath + Import-Module "$installationPath\Common7\Tools\Microsoft.VisualStudio.DevShell.dll" + Enter-VsDevShell -VsInstallPath $installationPath -SkipAutomaticLocation + $dependencies = & dumpbin.exe /dependents $binary + $dependencies | Write-Output + if ($dependencies -match '(?im)^\s+(?:VCRUNTIME|MSVCP)\d*[^\s]*\.dll\s*$') { + throw "Windows release dynamically depends on the Visual C++ Redistributable" + } diff --git a/Cargo.lock b/Cargo.lock index b429ea7..c4db1ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1202,7 +1202,7 @@ checksum = "db13adb97ab515a3691f56e4dbab09283d0b86cb45abd991d8634a9d6f501760" [[package]] name = "lexmount-webfetch" -version = "0.1.2" +version = "0.1.3" dependencies = [ "base64 0.22.1", "clap", diff --git a/Cargo.toml b/Cargo.toml index 6a09ab7..3217396 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lexmount-webfetch" -version = "0.1.2" +version = "0.1.3" edition = "2024" license = "MIT" description = "Native Rust SDK and CLI for Lexmount WebFetch" diff --git a/README.md b/README.md index 53e65a5..797b70e 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,11 @@ cargo build --release ``` Credentials come from `LEXMOUNT_API_KEY`, `LEXMOUNT_PROJECT_ID`, optional -`LEXMOUNT_WEBFETCH_BASE_URL`, or `webfetch-cli auth login`. PKCE login stores -credentials at `~/.config/lexmount/webfetch-cli/credentials.json` with mode -`0600` on Unix and never prints the API key. +`LEXMOUNT_WEBFETCH_BASE_URL`, or the Skill-local CLI's `auth login --open` flow. +PKCE login stores credentials at +`~/.config/lexmount/webfetch-cli/credentials.json` with mode `0600` on Unix and +never prints the API key. Pass `--client-name ""` to identify the calling +Agent on the approval page, or omit it to use `Agent`. ## Use @@ -25,21 +27,31 @@ Markdown is the default agent-readable output. `--format text` returns plain text, `--format json` returns a compact response with quality warnings, and `--format json-full` preserves the API response for debugging. -## WorkBuddy package +## Agent Skill package -The publishable Skill is in `skills/lexmount-webfetch`. Build a deterministic, -direct-upload SkillHub ZIP with: +The publishable Skill is in `skills/lexmount-webfetch`. Build a deterministic +release ZIP with: ```bash ./scripts/package-skill.sh ``` -The Skill ZIP contains `SKILL.md`, references, and platform bootstrap scripts; -native executables are published separately. On first use, the matching script -downloads the pinned release from Tencent Cloud COS and verifies its SHA-256 -digest. Tagged releases publish the Skill ZIP, `SHA256SUMS`, and exactly two -standalone binaries: signed and notarized macOS ARM64 plus Windows x64. Linux -and macOS Intel are not release platforms. +The Agent host installs the complete ZIP at its selected Skill root; the ZIP +root is the Skill root. Skill installation and status are host responsibilities, +so the Rust CLI does not provide `skill install` or `skill status`. Agents +resolve bundled scripts and binaries from the directory containing the loaded +`SKILL.md`: Codex uses the absolute source path supplied in Skill metadata, +Claude Code uses `${CLAUDE_SKILL_DIR}`, and WorkBuddy/CodeBuddy uses +`${CODEBUDDY_SKILL_DIR}`. Once started, the bootstrap and doctor scripts locate +the Skill directory from their own path. + +The ZIP contains exactly eight files: `SKILL.md`, three references, and the +bootstrap/doctor scripts for both platforms. Native executables are published +separately. On first use, the matching script downloads the pinned release from +Tencent Cloud COS and verifies its SHA-256 digest. Tagged releases publish the +Skill ZIP, `SHA256SUMS`, and exactly two standalone binaries: signed and +notarized macOS ARM64 plus Windows x64. Linux and macOS Intel are not release +platforms. The macOS signing job reads its certificate and notarization credentials from the `macos-release` GitHub environment. The publish job uploads both platform diff --git a/skills/lexmount-webfetch/SKILL.md b/skills/lexmount-webfetch/SKILL.md index 9373693..bb184d7 100644 --- a/skills/lexmount-webfetch/SKILL.md +++ b/skills/lexmount-webfetch/SKILL.md @@ -5,25 +5,52 @@ description: Use Lexmount WebFetch for lightweight public-page extraction and re # Lexmount WebFetch +Resolve `` to the directory containing this loaded `SKILL.md` with +the current Agent's Skill locator: + +- Codex: use the absolute `SKILL.md` source path supplied in the Skill metadata. +- Claude Code: use `${CLAUDE_SKILL_DIR}`. +- WorkBuddy/CodeBuddy: use `${CODEBUDDY_SKILL_DIR}`. + +Do not infer `` from the working directory. + Select the native Rust binary for the current platform: -- macOS arm64: run `${CODEBUDDY_SKILL_DIR}/scripts/bootstrap.sh` when `${CODEBUDDY_SKILL_DIR}/bin/webfetch-cli` is missing, then use that file. -- Windows x64: run `${CODEBUDDY_SKILL_DIR}/scripts/bootstrap.ps1` when `${CODEBUDDY_SKILL_DIR}/bin/webfetch-cli.exe` is missing, then use that file. +- macOS arm64: run `sh "/scripts/bootstrap.sh"` when `/bin/webfetch-cli` is missing, then invoke `"/bin/webfetch-cli"`. +- Windows x64: run `& "\scripts\bootstrap.ps1"` in PowerShell when `\bin\webfetch-cli.exe` is missing, then invoke `& "\bin\webfetch-cli.exe"`. + +Both bootstrap scripts download the fixed release version from Tencent Cloud COS +and verify its SHA-256 digest. The Agent-specific locator is needed to form the +initial absolute command. Once started, the bootstrap and doctor scripts locate +the Skill directory from their own file location. + +Do not run the binary for the other platform or assume `webfetch-cli` is on `PATH`. + +## Setup -Both bootstrap scripts download the fixed release version from Tencent Cloud COS and verify its SHA-256 digest. The examples abbreviate the selected path as `webfetch-cli`. +1. Resolve `` from this `SKILL.md` and select the matching platform paths above. +2. Run the Skill-local bootstrap script if the binary is missing. Then run `sh "/scripts/doctor.sh"` on macOS arm64 or `& "\scripts\doctor.ps1"` in Windows PowerShell. +3. If credentials are missing, run the Skill-local CLI's `auth login --open`. + Pass `--client-name ""` when the current Agent has a user-facing + name; otherwise omit it and the CLI uses `Agent`. Let the user approve in + their browser; never ask them to paste an API key into chat. +4. Run the platform doctor script again after login. Continue only when the + top-level `ok` value is `true` and both the `credentials` and `agent_skill` + checks pass. ## Fast path Call the target command directly when credentials are already configured: ```bash -webfetch-cli extract --url -webfetch-cli dump-dom --url +"/bin/webfetch-cli" extract --url +"/bin/webfetch-cli" dump-dom --url ``` -Do not run setup checks before every extraction. On first use, run the matching bootstrap script if the binary is missing, then run the platform doctor script. Run doctor again after an authentication or API error. +On Windows PowerShell, invoke `& "\bin\webfetch-cli.exe"` with the +same arguments. -If credentials are missing, run `webfetch-cli auth login --open --client-name WorkBuddy`. Let the user approve in their browser; never ask them to paste an API key into chat. +Do not run setup checks before every extraction. Run doctor again after an authentication or API error. ## Output selection diff --git a/skills/lexmount-webfetch/references/authentication.md b/skills/lexmount-webfetch/references/authentication.md index b44bd71..01fd371 100644 --- a/skills/lexmount-webfetch/references/authentication.md +++ b/skills/lexmount-webfetch/references/authentication.md @@ -1,12 +1,19 @@ # Authentication -Run: +Resolve `` from the loaded `SKILL.md`, then run the Skill-local CLI: -```bash -webfetch-cli auth login --open --client-name WorkBuddy +```text +"/bin/webfetch-cli" auth login --open [--client-name "NAME"] ``` -The CLI opens a PKCE approval flow, listens on a random loopback port, exchanges the returned code, and stores the credential locally. On Unix the file mode is `0600`. +On Windows PowerShell, invoke `& "\bin\webfetch-cli.exe"` with the +same arguments. Quote and pass the current Agent's user-facing name when +available. Omit `--client-name` when no name is available; the CLI then uses +`Agent`. + +The CLI opens a PKCE approval flow, listens on a random loopback port, exchanges +the returned code, and stores the credential locally. On Unix the file mode is +`0600`. Environment variables take precedence: @@ -16,4 +23,6 @@ Environment variables take precedence: - `LEXMOUNT_WEBFETCH_CONNECT_BASE_URL` - `LEXMOUNT_WEBFETCH_CREDENTIALS_FILE` -Use `webfetch-cli auth status` to inspect non-secret state. Use `auth clear-credentials` only when the user asks to disconnect or when a stored credential must be replaced. +Use the same Skill-local CLI with `auth status` to inspect non-secret state. Use +`auth clear-credentials` only when the user asks to disconnect or when a stored +credential must be replaced. diff --git a/skills/lexmount-webfetch/references/commands.md b/skills/lexmount-webfetch/references/commands.md index aca6c9e..3405c24 100644 --- a/skills/lexmount-webfetch/references/commands.md +++ b/skills/lexmount-webfetch/references/commands.md @@ -1,23 +1,33 @@ # Command reference +Resolve `` from the loaded `SKILL.md`. On macOS, `` +below means `"/bin/webfetch-cli"`; on Windows PowerShell it means +`& "\bin\webfetch-cli.exe"`. These are Skill-local absolute +invocations, not `PATH` lookups. + ```text -webfetch-cli version -webfetch-cli doctor --json -webfetch-cli capabilities --json + version + doctor --json + capabilities --json -webfetch-cli auth status -webfetch-cli auth login --open [--client-name WorkBuddy] + auth status + auth login --open [--client-name "NAME"] [--connect-base-url https://browser.lexmount.cn] [--timeout-seconds 300] -webfetch-cli auth clear-credentials + auth clear-credentials -webfetch-cli extract (--url URL | --dom-id ID) [--timeout-ms MS] + extract (--url URL | --dom-id ID) [--timeout-ms MS] [--format md|text|json|json-full] [--include-trace] [--include-raw-dom] -webfetch-cli dump-dom --url URL [--timeout-ms MS] + dump-dom --url URL [--timeout-ms MS] [--format md|text|json|json-full] [--engine auto|http|chrome|chrome_cdp|lightmount_lite|lightmount_dcl|lightmount_domstable] [--filter-scripts-styles] ``` -`extract --dom-id` reuses a prior DOM dump when the API returned a DOM ID. Default output is Markdown. Debug flags require `--format json-full` so heavy or sensitive diagnostic fields do not appear accidentally. +Quote and pass the current Agent's user-facing name when available. Omit +`--client-name` when no name is available; the CLI defaults to `Agent`. + +`extract --dom-id` reuses a prior DOM dump when the API returned a DOM ID. +Default output is Markdown. Debug flags require `--format json-full` so heavy or +sensitive diagnostic fields do not appear accidentally. diff --git a/skills/lexmount-webfetch/references/troubleshooting.md b/skills/lexmount-webfetch/references/troubleshooting.md index dc5de76..61fdb25 100644 --- a/skills/lexmount-webfetch/references/troubleshooting.md +++ b/skills/lexmount-webfetch/references/troubleshooting.md @@ -1,8 +1,18 @@ # Troubleshooting -1. Missing command: run the platform bootstrap script, then the doctor script. -2. Missing or expired credentials: run `webfetch-cli auth login --open --client-name WorkBuddy`. -3. Thin content or HTML warning: retry with `dump-dom`, try an explicit engine, or move to the browser Skill when interaction/rendering is required. -4. API timeout: increase `--timeout-ms` once; do not retry indefinitely. -5. Need trace or raw DOM: add `--format json-full` before the debug flag. -6. Unexpected API shape: use `--format json-full` for diagnosis, but redact secrets before sharing output. +1. Skill root unknown: resolve the directory containing the loaded `SKILL.md` + with the current host's locator: Codex supplies its absolute source path in + Skill metadata, Claude Code provides `${CLAUDE_SKILL_DIR}`, and + WorkBuddy/CodeBuddy provides `${CODEBUDDY_SKILL_DIR}`. Do not infer it from + the working directory or search the user's home directory. +2. Missing command: run `sh "/scripts/bootstrap.sh"` on macOS arm64 + or `& "\scripts\bootstrap.ps1"` on Windows x64, then run the + matching doctor script. Invoke only the Skill-local binary afterward; do not + rely on `PATH`. +3. Missing or expired credentials: run the Skill-local CLI's + `auth login --open`. Pass `--client-name ""` when the current + Agent has a user-facing name; otherwise omit it to use `Agent`. +4. Thin content or HTML warning: retry with `dump-dom`, try an explicit engine, or move to the browser Skill when interaction/rendering is required. +5. API timeout: increase `--timeout-ms` once; do not retry indefinitely. +6. Need trace or raw DOM: add `--format json-full` before the debug flag. +7. Unexpected API shape: use `--format json-full` for diagnosis, but redact secrets before sharing output. diff --git a/skills/lexmount-webfetch/scripts/bootstrap.ps1 b/skills/lexmount-webfetch/scripts/bootstrap.ps1 index 329f4ee..cb93f93 100644 --- a/skills/lexmount-webfetch/scripts/bootstrap.ps1 +++ b/skills/lexmount-webfetch/scripts/bootstrap.ps1 @@ -12,7 +12,7 @@ function Invoke-Tls12Download { } } -$version = if ($env:LEXMOUNT_WEBFETCH_CLI_VERSION) { $env:LEXMOUNT_WEBFETCH_CLI_VERSION } else { "0.1.2" } +$version = if ($env:LEXMOUNT_WEBFETCH_CLI_VERSION) { $env:LEXMOUNT_WEBFETCH_CLI_VERSION } else { "0.1.3" } $downloadBaseUrl = if ($env:LEXMOUNT_WEBFETCH_CLI_DOWNLOAD_BASE_URL) { $env:LEXMOUNT_WEBFETCH_CLI_DOWNLOAD_BASE_URL.TrimEnd('/') } else { "https://cli-bin-1377899528.cos.ap-nanjing.myqcloud.com/releases/webfetch-cli" } $architecture = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE } if ($architecture -ne "AMD64") { throw "Only Windows x64 is supported" } diff --git a/skills/lexmount-webfetch/scripts/bootstrap.sh b/skills/lexmount-webfetch/scripts/bootstrap.sh index 9c9c538..8ce3624 100755 --- a/skills/lexmount-webfetch/scripts/bootstrap.sh +++ b/skills/lexmount-webfetch/scripts/bootstrap.sh @@ -1,7 +1,7 @@ #!/bin/sh set -eu -version="${LEXMOUNT_WEBFETCH_CLI_VERSION:-0.1.2}" +version="${LEXMOUNT_WEBFETCH_CLI_VERSION:-0.1.3}" download_base_url="${LEXMOUNT_WEBFETCH_CLI_DOWNLOAD_BASE_URL:-https://cli-bin-1377899528.cos.ap-nanjing.myqcloud.com/releases/webfetch-cli}" repo="${download_base_url%/}/v${version}" case "$(uname -s)-$(uname -m)" in diff --git a/src/auth.rs b/src/auth.rs index 15a7b65..186fe4c 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -18,6 +18,7 @@ use url::Url; use crate::{Error, Result, client::DEFAULT_API_BASE_URL}; pub const DEFAULT_CONNECT_BASE_URL: &str = "https://browser.lexmount.cn"; +pub const DEFAULT_CLIENT_NAME: &str = "Agent"; pub const CONNECT_BASE_URL_ENV: &str = "LEXMOUNT_WEBFETCH_CONNECT_BASE_URL"; pub const CREDENTIALS_FILE_ENV: &str = "LEXMOUNT_WEBFETCH_CREDENTIALS_FILE"; pub const DEFAULT_SCOPES: &[&str] = &["browser:read"]; @@ -87,6 +88,32 @@ pub fn clear_credentials(path: Option<&Path>) -> Result { Ok(true) } +fn build_login_url( + connect_base_url: &str, + redirect_uri: &str, + state: &str, + challenge: &str, + client_name: &str, +) -> Result { + let connect_base_url = connect_base_url.trim_end_matches('/'); + let mut login_url = Url::parse(&format!("{connect_base_url}/connect/codex")) + .map_err(|e| Error::Config(format!("invalid connect base URL: {e}")))?; + let client_name = if client_name.trim().is_empty() { + DEFAULT_CLIENT_NAME + } else { + client_name + }; + login_url + .query_pairs_mut() + .append_pair("redirect_uri", redirect_uri) + .append_pair("state", state) + .append_pair("code_challenge", challenge) + .append_pair("code_challenge_method", "S256") + .append_pair("scope", &DEFAULT_SCOPES.join(" ")) + .append_pair("client_name", client_name); + Ok(login_url) +} + pub fn login( connect_base_url: &str, client_name: &str, @@ -104,16 +131,13 @@ pub fn login( let verifier = random_urlsafe(48); let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); let state = random_urlsafe(24); - let mut login_url = Url::parse(&format!("{connect_base_url}/connect/codex")) - .map_err(|e| Error::Config(format!("invalid connect base URL: {e}")))?; - login_url - .query_pairs_mut() - .append_pair("redirect_uri", &redirect_uri) - .append_pair("state", &state) - .append_pair("code_challenge", &challenge) - .append_pair("code_challenge_method", "S256") - .append_pair("scope", &DEFAULT_SCOPES.join(" ")) - .append_pair("client_name", client_name); + let login_url = build_login_url( + connect_base_url, + &redirect_uri, + &state, + &challenge, + client_name, + )?; if open_browser { open::that(login_url.as_str()).map_err(|e| Error::Io(std::io::Error::other(e)))?; } @@ -274,6 +298,76 @@ mod tests { assert!(!is_internal_api_base_url("https://api.lexmount.cn")); } + #[test] + fn login_url_encodes_and_round_trips_client_name() { + let client_name = "Claude Code 中文 &/?/#/%/+"; + let login_url = build_login_url( + "https://browser.lexmount.cn", + "http://127.0.0.1:12345/callback", + "state", + "challenge", + client_name, + ) + .unwrap(); + + assert!( + login_url + .as_str() + .contains("client_name=Claude+Code+%E4%B8%AD%E6%96%87+%26%2F%3F%2F%23%2F%25%2F%2B") + ); + assert_eq!( + login_url + .query_pairs() + .find(|(key, _)| key == "client_name") + .map(|(_, value)| value.into_owned()), + Some(client_name.to_owned()) + ); + } + + #[test] + fn login_url_accepts_trailing_slashes_and_rejects_invalid_base_url() { + let login_url = build_login_url( + "https://browser.lexmount.cn///", + "http://127.0.0.1:12345/callback", + "state", + "challenge", + DEFAULT_CLIENT_NAME, + ) + .unwrap(); + + assert_eq!(login_url.path(), "/connect/codex"); + assert!(matches!( + build_login_url( + "not a URL", + "http://127.0.0.1:12345/callback", + "state", + "challenge", + DEFAULT_CLIENT_NAME, + ), + Err(Error::Config(message)) if message.starts_with("invalid connect base URL:") + )); + } + + #[test] + fn login_url_uses_default_for_blank_client_name() { + let login_url = build_login_url( + DEFAULT_CONNECT_BASE_URL, + "http://127.0.0.1:12345/callback", + "state", + "challenge", + " ", + ) + .unwrap(); + + assert_eq!( + login_url + .query_pairs() + .find(|(key, _)| key == "client_name") + .map(|(_, value)| value.into_owned()), + Some(DEFAULT_CLIENT_NAME.to_owned()) + ); + } + #[test] fn credentials_round_trip_without_exposing_secret_in_status() { let dir = tempdir().unwrap(); diff --git a/src/client.rs b/src/client.rs index eaa87d1..5d373c3 100644 --- a/src/client.rs +++ b/src/client.rs @@ -45,7 +45,7 @@ impl ClientBuilder { .filter(|v| !v.is_empty()) .ok_or_else(|| { Error::Config(format!( - "Missing project id. Run webfetch-cli auth login or set {PROJECT_ID_ENV}." + "Missing project id. Run the Skill-local CLI with `auth login --open` or set {PROJECT_ID_ENV}." )) })?; let api_key = self @@ -55,7 +55,7 @@ impl ClientBuilder { .filter(|v| !v.is_empty()) .ok_or_else(|| { Error::Config(format!( - "Missing API key. Run webfetch-cli auth login or set {API_KEY_ENV}." + "Missing API key. Run the Skill-local CLI with `auth login --open` or set {API_KEY_ENV}." )) })?; let base_url = self diff --git a/src/main.rs b/src/main.rs index da70c08..e23d1c6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,6 @@ -use std::{fs, path::PathBuf, process::ExitCode, time::Duration}; +use std::{path::PathBuf, process::ExitCode, time::Duration}; + +mod skill; use clap::{Args, Parser, Subcommand, ValueEnum}; use lexmount_webfetch::{Client, Error, Result, auth, output}; @@ -32,10 +34,6 @@ enum Command { }, Extract(ExtractArgs), DumpDom(DumpDomArgs), - Skill { - #[command(subcommand)] - command: SkillCommand, - }, } #[derive(Subcommand)] @@ -45,7 +43,7 @@ enum AuthCommand { open: bool, #[arg(long)] connect_base_url: Option, - #[arg(long, default_value = "Agent")] + #[arg(long, default_value = auth::DEFAULT_CLIENT_NAME)] client_name: String, #[arg(long, default_value_t = 300)] timeout_seconds: u64, @@ -92,20 +90,6 @@ struct DumpDomArgs { filter_scripts_styles: bool, } -#[derive(Subcommand)] -enum SkillCommand { - Status { - #[arg(long)] - dest: Option, - }, - Install { - #[arg(long)] - dest: Option, - #[arg(long)] - force: bool, - }, -} - fn main() -> ExitCode { match run(Cli::parse()) { Ok(code) => ExitCode::from(code), @@ -193,7 +177,6 @@ fn run(cli: Cli) -> Result { )?; emit_formatted(&payload, args.format, false)?; } - Command::Skill { command } => run_skill(command)?, } Ok(0) } @@ -228,7 +211,10 @@ fn emit_formatted(payload: &Value, format: OutputFormat, extract: bool) -> Resul fn auth_status() -> Result { let path = auth::credentials_path(None)?; - let stored = auth::load_credentials(Some(&path))?; + let (stored, credentials_file_error) = match auth::load_credentials(Some(&path)) { + Ok(stored) => (stored, None), + Err(error) => (None, Some(error.to_string())), + }; let env_project = std::env::var_os("LEXMOUNT_PROJECT_ID").is_some(); let env_key = std::env::var_os("LEXMOUNT_API_KEY").is_some(); let env_base = std::env::var_os("LEXMOUNT_WEBFETCH_BASE_URL").is_some(); @@ -239,89 +225,304 @@ fn auth_status() -> Result { }); match Client::from_env() { Ok(client) => Ok( - json!({"authenticated":true,"credentials_file":path,"sources":sources,"project_id":client.project_id(),"api_base_url":client.base_url(),"has_api_key":true,"stored":stored.as_ref().map(|v|json!({"project_id":v.project_id,"api_base_url":v.api_base_url,"scope":v.scope,"has_api_key":!v.api_key.is_empty()}))}), - ), - Err(error) => Ok( - json!({"authenticated":false,"credentials_file":path,"sources":sources,"error":error.to_string(),"login_command":format!("webfetch-cli auth login --open --connect-base-url {} --client-name Agent",auth::DEFAULT_CONNECT_BASE_URL),"next_step":"Run login_command, then rerun webfetch-cli auth status."}), + json!({"authenticated":true,"credentials_file":path,"credentials_file_error":credentials_file_error,"sources":sources,"project_id":client.project_id(),"api_base_url":client.base_url(),"has_api_key":true,"stored":stored.as_ref().map(|v|json!({"project_id":v.project_id,"api_base_url":v.api_base_url,"scope":v.scope,"has_api_key":!v.api_key.is_empty()}))}), ), + Err(error) => { + let error = credentials_file_error + .as_ref() + .map(|file_error| { + format!( + "Failed to load credentials file {}: {file_error}", + path.display() + ) + }) + .unwrap_or_else(|| error.to_string()); + Ok(json!({ + "authenticated": false, + "credentials_file": path, + "credentials_file_error": credentials_file_error, + "sources": sources, + "error": error, + "login_command": auth_login_invocation( + std::env::current_exe().ok(), + &std::env::var(auth::CONNECT_BASE_URL_ENV) + .unwrap_or_else(|_| auth::DEFAULT_CONNECT_BASE_URL.to_owned()) + ), + "next_step": "Run login_command.executable with login_command.arguments. Add --client-name with the current Agent's name when available, then rerun the Skill-local CLI's auth status command." + })) + } } } -fn default_skill_destination() -> Result { - let root = std::env::var_os("CODEX_HOME") - .map(PathBuf::from) - .or_else(|| dirs::home_dir().map(|v| v.join(".codex"))) - .ok_or_else(|| Error::Config("home directory is unavailable".into()))?; - Ok(root.join("skills/lexmount-webfetch")) -} -fn skill_files() -> [(&'static str, &'static str); 4] { - [ - ( - "SKILL.md", - include_str!("../skills/lexmount-webfetch/SKILL.md"), - ), - ( - "references/authentication.md", - include_str!("../skills/lexmount-webfetch/references/authentication.md"), - ), - ( - "references/commands.md", - include_str!("../skills/lexmount-webfetch/references/commands.md"), - ), - ( - "references/troubleshooting.md", - include_str!("../skills/lexmount-webfetch/references/troubleshooting.md"), - ), - ] -} -fn run_skill(command: SkillCommand) -> Result<()> { - match command { - SkillCommand::Status { dest } => { - let dest = dest.map(Ok).unwrap_or_else(default_skill_destination)?; - emit_json( - &json!({"installed":dest.join("SKILL.md").exists(),"destination":dest,"skill_file":dest.join("SKILL.md")}), - ) - } - SkillCommand::Install { dest, force } => { - let dest = dest.map(Ok).unwrap_or_else(default_skill_destination)?; - if dest.exists() { - if !force { - return Err(Error::Config(format!( - "Skill destination already exists: {}. Use --force.", - dest.display() - ))); - } - fs::remove_dir_all(&dest)?; - } - for (rel, content) in skill_files() { - let path = dest.join(rel); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - fs::write(path, content)?; - } - emit_json(&json!({"ok":true,"installed":true,"destination":dest})) - } - } +fn auth_login_invocation(executable: Option, connect_base_url: &str) -> Value { + json!({ + "executable": executable, + "arguments": [ + "auth", + "login", + "--open", + "--connect-base-url", + connect_base_url + ] + }) } fn doctor() -> Result { - let status = auth_status()?; - let destination = default_skill_destination()?; + Ok(doctor_report( + &auth_status()?, + skill::discover_skill_roots_from_process(), + )) +} + +fn doctor_report(status: &Value, candidates: Vec) -> Value { let credentials_ok = status["authenticated"] == true; - let workbuddy_skill = std::env::var_os("CODEBUDDY_SKILL_DIR") - .map(PathBuf::from) - .is_some_and(|path| path.join("SKILL.md").exists()); - let skill_ok = destination.join("SKILL.md").exists() || workbuddy_skill; - Ok( - json!({"ok":credentials_ok&&skill_ok,"status":if credentials_ok&&skill_ok{"pass"}else{"fail"},"checks":[ - {"name":"cli_version","status":"pass","version":env!("CARGO_PKG_VERSION")}, - if credentials_ok {json!({"name":"credentials","status":"pass","project_id":status["project_id"],"api_base_url":status["api_base_url"],"has_api_key":true})} else {json!({"name":"credentials","status":"fail","message":status["error"],"repair_command":status["login_command"]})}, - {"name":"codex_skill","status":if skill_ok{"pass"}else{"warn"},"destination":destination,"repair_command":"webfetch-cli skill install --force"} - ]}), - ) + let active = candidates + .iter() + .find(|candidate| { + candidate + .sources + .contains(&skill::SkillRootSource::CurrentExecutable) + }) + .or_else(|| { + candidates.iter().find(|candidate| { + candidate.sources.iter().any(|source| { + matches!( + source, + skill::SkillRootSource::ClaudeSkillDir + | skill::SkillRootSource::WorkBuddySkillDir + ) + }) + }) + }); + let active_root = active.map(|candidate| candidate.root.clone()); + let selected_candidate = match active { + Some(candidate) => candidate.is_installed().then_some(candidate), + None => candidates.iter().find(|candidate| candidate.is_installed()), + }; + let selected = selected_candidate + .map(|candidate| json!({"root":candidate.root,"sources":candidate.sources})); + let skill_ok = selected.is_some(); + let candidate_reports = candidates + .iter() + .map(|candidate| { + json!({ + "root": candidate.root, + "sources": candidate.sources, + "complete": candidate.manifest.complete, + "missing_files": candidate.manifest.missing_files, + "active": active_root.as_ref() == Some(&candidate.root), + }) + }) + .collect::>(); + let ok = credentials_ok && skill_ok; + + json!({"ok":ok,"status":if ok{"pass"}else{"fail"},"checks":[ + {"name":"cli_version","status":"pass","version":env!("CARGO_PKG_VERSION")}, + if credentials_ok {json!({"name":"credentials","status":"pass","project_id":status["project_id"],"api_base_url":status["api_base_url"],"has_api_key":true})} else {json!({"name":"credentials","status":"fail","message":status["error"],"repair":status["login_command"]})}, + {"name":"agent_skill","status":if skill_ok{"pass"}else{"fail"},"installed":skill_ok,"active_root":active_root,"selected":selected,"candidates":candidate_reports,"required_files":skill::SKILL_ARCHIVE_FILES,"repair":"Install the complete official Skill ZIP at the current Agent's Skill root, then run the Skill-local bootstrap and doctor scripts."} + ]}) } fn capabilities() -> Value { json!({"name":"webfetch-cli","version":env!("CARGO_PKG_VERSION"),"default_format":"md","formats":["md","text","json","json-full"],"commands":{"extract":{"inputs":["url","dom_id"],"options":["timeout_ms","format","include_trace","include_raw_dom"],"default_output":"agent_readable_markdown","debug_output":"json-full"},"dump-dom":{"inputs":["url"],"options":["timeout_ms","format","engine","filter_scripts_styles"],"default_output":"agent_readable_markdown","debug_output":"json-full"}},"exit_codes":{"0":"success","1":"runtime or API error","2":"invalid CLI usage"}}) } + +#[cfg(test)] +mod tests { + use std::fs; + + use super::*; + use tempfile::tempdir; + + fn complete_candidate( + source: skill::SkillRootSource, + ) -> (tempfile::TempDir, skill::SkillRootCandidate) { + let dir = tempdir().unwrap(); + for relative in skill::SKILL_ARCHIVE_FILES { + let path = dir.path().join(relative); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, relative).unwrap(); + } + let candidate = skill::SkillRootCandidate { + root: dir.path().to_path_buf(), + sources: vec![source], + manifest: skill::validate_skill_root(dir.path()), + }; + (dir, candidate) + } + + fn authenticated_status() -> Value { + json!({ + "authenticated": true, + "project_id": "project-1", + "api_base_url": "https://api.example.test" + }) + } + + #[test] + fn auth_login_defaults_client_name_to_agent() { + let cli = Cli::try_parse_from(["webfetch-cli", "auth", "login"]).unwrap(); + let Command::Auth { + command: AuthCommand::Login { client_name, .. }, + } = cli.command + else { + panic!("expected auth login command"); + }; + + assert_eq!(client_name, auth::DEFAULT_CLIENT_NAME); + } + + #[test] + fn auth_login_parses_custom_client_name() { + let client_name = "Claude Code 中文"; + let cli = Cli::try_parse_from([ + "webfetch-cli", + "auth", + "login", + "--client-name", + client_name, + ]) + .unwrap(); + let Command::Auth { + command: + AuthCommand::Login { + client_name: parsed, + .. + }, + } = cli.command + else { + panic!("expected auth login command"); + }; + + assert_eq!(parsed, client_name); + } + + #[test] + fn auth_login_repair_uses_an_absolute_executable_and_argument_array() { + let executable = PathBuf::from("/tmp/Skill Root/bin/webfetch-cli"); + let connect_base_url = "https://browser.example.test"; + let invocation = auth_login_invocation(Some(executable.clone()), connect_base_url); + + assert_eq!(invocation["executable"], json!(executable)); + assert_eq!( + invocation["arguments"], + json!([ + "auth", + "login", + "--open", + "--connect-base-url", + connect_base_url + ]) + ); + } + + #[test] + fn doctor_accepts_complete_skills_from_all_three_agents() { + for source in [ + skill::SkillRootSource::CodexAgentsHome, + skill::SkillRootSource::ClaudeSkillDir, + skill::SkillRootSource::WorkBuddySkillDir, + skill::SkillRootSource::ClaudeConfigDir, + skill::SkillRootSource::CodexHome, + ] { + let (_dir, candidate) = complete_candidate(source); + let report = doctor_report(&authenticated_status(), vec![candidate]); + + assert_eq!(report["ok"], true); + assert_eq!(report["status"], "pass"); + assert_eq!(report["checks"][2]["name"], "agent_skill"); + assert_eq!(report["checks"][2]["status"], "pass"); + assert_eq!(report["checks"][2]["selected"]["sources"][0], json!(source)); + } + } + + #[test] + fn doctor_fails_when_the_skill_zip_is_incomplete() { + let (dir, mut candidate) = complete_candidate(skill::SkillRootSource::ClaudeSkillDir); + fs::remove_file(dir.path().join("scripts/doctor.sh")).unwrap(); + candidate.manifest = skill::validate_skill_root(dir.path()); + + let report = doctor_report(&authenticated_status(), vec![candidate]); + + assert_eq!(report["ok"], false); + assert_eq!(report["checks"][2]["status"], "fail"); + assert_eq!( + report["checks"][2]["candidates"][0]["missing_files"], + json!(["scripts/doctor.sh"]) + ); + } + + #[test] + fn doctor_does_not_hide_an_incomplete_active_skill_with_another_installation() { + for active_source in [ + skill::SkillRootSource::CurrentExecutable, + skill::SkillRootSource::ClaudeSkillDir, + skill::SkillRootSource::WorkBuddySkillDir, + ] { + let (_other_dir, other) = complete_candidate(skill::SkillRootSource::CodexAgentsHome); + let (active_dir, mut active) = complete_candidate(active_source); + fs::remove_file(active_dir.path().join("scripts/doctor.sh")).unwrap(); + active.manifest = skill::validate_skill_root(active_dir.path()); + + let report = doctor_report(&authenticated_status(), vec![other, active]); + + assert_eq!(report["ok"], false); + assert_eq!(report["checks"][2]["active_root"], json!(active_dir.path())); + assert_eq!(report["checks"][2]["selected"], Value::Null); + assert_eq!(report["checks"][2]["candidates"][0]["active"], false); + assert_eq!(report["checks"][2]["candidates"][1]["active"], true); + } + } + + #[test] + fn doctor_treats_configuration_roots_as_fallback_candidates() { + for fallback_source in [ + skill::SkillRootSource::ClaudeDefaultHome, + skill::SkillRootSource::ClaudeConfigDir, + skill::SkillRootSource::CodexHome, + ] { + let (_codex_dir, codex) = complete_candidate(skill::SkillRootSource::CodexAgentsHome); + let fallback_dir = tempdir().unwrap(); + let fallback = skill::SkillRootCandidate { + root: fallback_dir.path().to_path_buf(), + sources: vec![fallback_source], + manifest: skill::validate_skill_root(fallback_dir.path()), + }; + + let report = doctor_report(&authenticated_status(), vec![codex, fallback]); + + assert_eq!(report["ok"], true); + assert_eq!( + report["checks"][2]["selected"]["sources"][0], + json!(skill::SkillRootSource::CodexAgentsHome) + ); + assert_eq!(report["checks"][2]["active_root"], Value::Null); + } + } + + #[test] + fn doctor_fails_when_credentials_are_missing() { + let (_dir, candidate) = complete_candidate(skill::SkillRootSource::CodexAgentsHome); + let report = doctor_report( + &json!({ + "authenticated": false, + "error": "missing credentials", + "login_command": auth_login_invocation( + Some(PathBuf::from("/skill/bin/webfetch-cli")), + auth::DEFAULT_CONNECT_BASE_URL + ) + }), + vec![candidate], + ); + + assert_eq!(report["ok"], false); + assert_eq!(report["checks"][1]["status"], "fail"); + assert_eq!(report["checks"][2]["status"], "pass"); + } + + #[test] + fn legacy_skill_install_commands_are_not_exposed() { + assert!(Cli::try_parse_from(["webfetch-cli", "skill", "status"]).is_err()); + assert!(Cli::try_parse_from(["webfetch-cli", "skill", "install"]).is_err()); + } +} diff --git a/src/skill.rs b/src/skill.rs new file mode 100644 index 0000000..9f59860 --- /dev/null +++ b/src/skill.rs @@ -0,0 +1,435 @@ +use std::{ + collections::HashMap, + env, + ffi::OsString, + fs, + path::{Component, Path, PathBuf}, +}; + +use serde::Serialize; + +pub const SKILL_NAME: &str = "lexmount-webfetch"; + +/// The exact file manifest shipped in the SkillHub ZIP. +/// +/// Platform binaries are downloaded after installation and deliberately do not +/// belong in the ZIP manifest. +pub const SKILL_ARCHIVE_FILES: [&str; 8] = [ + "SKILL.md", + "references/authentication.md", + "references/commands.md", + "references/troubleshooting.md", + "scripts/bootstrap.ps1", + "scripts/bootstrap.sh", + "scripts/doctor.ps1", + "scripts/doctor.sh", +]; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DiscoveryInputs { + pub home_dir: Option, + pub codex_home: Option, + pub claude_skill_dir: Option, + pub claude_config_dir: Option, + pub codebuddy_skill_dir: Option, + pub current_exe: Option, +} + +impl DiscoveryInputs { + pub fn from_process() -> Self { + Self { + home_dir: dirs::home_dir(), + codex_home: env_path("CODEX_HOME"), + claude_skill_dir: env_path("CLAUDE_SKILL_DIR"), + claude_config_dir: env_path("CLAUDE_CONFIG_DIR"), + codebuddy_skill_dir: env_path("CODEBUDDY_SKILL_DIR"), + current_exe: env::current_exe().ok(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SkillRootSource { + CodexAgentsHome, + ClaudeSkillDir, + ClaudeConfigDir, + ClaudeDefaultHome, + WorkBuddySkillDir, + CodexHome, + CodexLegacyHome, + CurrentExecutable, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SkillManifestStatus { + pub complete: bool, + pub present_files: Vec, + pub missing_files: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SkillRootCandidate { + pub root: PathBuf, + pub sources: Vec, + pub manifest: SkillManifestStatus, +} + +impl SkillRootCandidate { + pub fn is_installed(&self) -> bool { + self.manifest.complete + } +} + +/// Discover every supported Agent Skill candidate from the current process. +pub fn discover_skill_roots_from_process() -> Vec { + discover_skill_roots(&DiscoveryInputs::from_process()) +} + +/// Discover supported Agent Skill roots while keeping all process state +/// injectable for deterministic tests. +/// +/// Candidates retain their first-seen order. If multiple hosts or compatibility +/// paths resolve to the same root, the root is returned once with every source +/// recorded in `sources`. +pub fn discover_skill_roots(inputs: &DiscoveryInputs) -> Vec { + let mut candidates = Vec::new(); + let mut indices = HashMap::::new(); + + if let Some(home) = non_empty_path(inputs.home_dir.as_ref()) { + push_candidate( + &mut candidates, + &mut indices, + home.join(".agents/skills").join(SKILL_NAME), + SkillRootSource::CodexAgentsHome, + ); + } + + if let Some(root) = non_empty_path(inputs.claude_skill_dir.as_ref()) { + push_candidate( + &mut candidates, + &mut indices, + root.to_path_buf(), + SkillRootSource::ClaudeSkillDir, + ); + } + + let claude_config = non_empty_path(inputs.claude_config_dir.as_ref()) + .map(|path| (path.to_path_buf(), SkillRootSource::ClaudeConfigDir)) + .or_else(|| { + non_empty_path(inputs.home_dir.as_ref()) + .map(|home| (home.join(".claude"), SkillRootSource::ClaudeDefaultHome)) + }); + if let Some((config_dir, source)) = claude_config { + push_candidate( + &mut candidates, + &mut indices, + config_dir.join("skills").join(SKILL_NAME), + source, + ); + } + + if let Some(root) = non_empty_path(inputs.codebuddy_skill_dir.as_ref()) { + push_candidate( + &mut candidates, + &mut indices, + root.to_path_buf(), + SkillRootSource::WorkBuddySkillDir, + ); + } + + if let Some(codex_home) = non_empty_path(inputs.codex_home.as_ref()) { + push_candidate( + &mut candidates, + &mut indices, + codex_home.join("skills").join(SKILL_NAME), + SkillRootSource::CodexHome, + ); + } + + if let Some(home) = non_empty_path(inputs.home_dir.as_ref()) { + push_candidate( + &mut candidates, + &mut indices, + home.join(".codex/skills").join(SKILL_NAME), + SkillRootSource::CodexLegacyHome, + ); + } + + if let Some(executable) = non_empty_path(inputs.current_exe.as_ref()) + && let Some(root) = skill_root_from_executable(executable) + { + push_candidate( + &mut candidates, + &mut indices, + root, + SkillRootSource::CurrentExecutable, + ); + } + + candidates +} + +/// Infer a Skill root only from a Skill-local executable layout: +/// `/bin/webfetch-cli[.exe]`. A root is recognized when it uses the +/// canonical Skill directory name or contains `SKILL.md`; this avoids treating +/// a global `/usr/local/bin`-style install as active while allowing hosts to +/// rename an installed Skill directory. +pub fn skill_root_from_executable(executable: &Path) -> Option { + let file_name = executable.file_name()?.to_string_lossy(); + if file_name != "webfetch-cli" && !file_name.eq_ignore_ascii_case("webfetch-cli.exe") { + return None; + } + + let bin_dir = executable.parent()?; + if !bin_dir + .file_name() + .is_some_and(|name| name.to_string_lossy().eq_ignore_ascii_case("bin")) + { + return None; + } + + let root = bin_dir.parent()?; + let canonical_name = root + .file_name() + .is_some_and(|name| name.to_string_lossy().eq_ignore_ascii_case(SKILL_NAME)); + (canonical_name || root.join("SKILL.md").is_file()).then(|| root.to_path_buf()) +} + +/// Validate the required ZIP payload files in an installed Skill root. +/// +/// Extra installed files are intentionally ignored because bootstrap adds +/// `bin/webfetch-cli[.exe]` after the ZIP is extracted. +pub fn validate_skill_root(root: &Path) -> SkillManifestStatus { + let mut present_files = Vec::new(); + let mut missing_files = Vec::new(); + + for relative in SKILL_ARCHIVE_FILES { + if root.join(relative).is_file() { + present_files.push(relative.to_owned()); + } else { + missing_files.push(relative.to_owned()); + } + } + + SkillManifestStatus { + complete: missing_files.is_empty(), + present_files, + missing_files, + } +} + +fn env_path(name: &str) -> Option { + env::var_os(name) + .and_then(non_empty_os_string) + .map(PathBuf::from) +} + +fn non_empty_os_string(value: OsString) -> Option { + (!value.is_empty()).then_some(value) +} + +fn non_empty_path(path: Option<&PathBuf>) -> Option<&Path> { + path.map(PathBuf::as_path) + .filter(|path| !path.as_os_str().is_empty()) +} + +fn push_candidate( + candidates: &mut Vec, + indices: &mut HashMap, + root: PathBuf, + source: SkillRootSource, +) { + let key = deduplication_key(&root); + if let Some(index) = indices.get(&key).copied() { + let sources = &mut candidates[index].sources; + if !sources.contains(&source) { + sources.push(source); + } + return; + } + + let index = candidates.len(); + let manifest = validate_skill_root(&root); + candidates.push(SkillRootCandidate { + root, + sources: vec![source], + manifest, + }); + indices.insert(key, index); +} + +fn deduplication_key(path: &Path) -> PathBuf { + fs::canonicalize(path).unwrap_or_else(|_| lexically_normalize(path)) +} + +fn lexically_normalize(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + if !normalized.pop() && !path.is_absolute() { + normalized.push(component.as_os_str()); + } + } + _ => normalized.push(component.as_os_str()), + } + } + normalized +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn candidate<'a>(candidates: &'a [SkillRootCandidate], root: &Path) -> &'a SkillRootCandidate { + candidates + .iter() + .find(|candidate| candidate.root == root) + .unwrap_or_else(|| panic!("missing candidate {}", root.display())) + } + + fn write_manifest(root: &Path) { + for relative in SKILL_ARCHIVE_FILES { + let path = root.join(relative); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, relative).unwrap(); + } + } + + #[test] + fn discovers_codex_personal_and_compatibility_roots() { + let home = PathBuf::from("/home/alice"); + let codex_home = PathBuf::from("/opt/codex-profile"); + let roots = discover_skill_roots(&DiscoveryInputs { + home_dir: Some(home.clone()), + codex_home: Some(codex_home.clone()), + ..DiscoveryInputs::default() + }); + + let agents = home.join(".agents/skills").join(SKILL_NAME); + assert_eq!( + candidate(&roots, &agents).sources, + vec![SkillRootSource::CodexAgentsHome] + ); + assert_eq!( + candidate(&roots, &codex_home.join("skills").join(SKILL_NAME)).sources, + vec![SkillRootSource::CodexHome] + ); + assert_eq!( + candidate(&roots, &home.join(".codex/skills").join(SKILL_NAME)).sources, + vec![SkillRootSource::CodexLegacyHome] + ); + assert_eq!( + candidate(&roots, &home.join(".claude/skills").join(SKILL_NAME)).sources, + vec![SkillRootSource::ClaudeDefaultHome] + ); + } + + #[test] + fn discovers_claude_direct_and_config_roots() { + let direct = PathBuf::from("/skills/lexmount-webfetch"); + let config = PathBuf::from("/profiles/claude"); + let roots = discover_skill_roots(&DiscoveryInputs { + claude_skill_dir: Some(direct.clone()), + claude_config_dir: Some(config.clone()), + ..DiscoveryInputs::default() + }); + + assert_eq!( + candidate(&roots, &direct).sources, + vec![SkillRootSource::ClaudeSkillDir] + ); + assert_eq!( + candidate(&roots, &config.join("skills").join(SKILL_NAME)).sources, + vec![SkillRootSource::ClaudeConfigDir] + ); + } + + #[test] + fn discovers_workbuddy_root() { + let root = PathBuf::from("/workbuddy/skills/lexmount-webfetch"); + let roots = discover_skill_roots(&DiscoveryInputs { + codebuddy_skill_dir: Some(root.clone()), + ..DiscoveryInputs::default() + }); + + assert_eq!(roots.len(), 1); + assert_eq!(roots[0].root, root); + assert_eq!(roots[0].sources, vec![SkillRootSource::WorkBuddySkillDir]); + } + + #[test] + fn discovers_skill_root_from_current_executable() { + let root = PathBuf::from("/agent/skills/lexmount-webfetch"); + let executable = root.join("bin/webfetch-cli.exe"); + let roots = discover_skill_roots(&DiscoveryInputs { + current_exe: Some(executable.clone()), + ..DiscoveryInputs::default() + }); + + assert_eq!(skill_root_from_executable(&executable), Some(root.clone())); + assert_eq!(roots.len(), 1); + assert_eq!(roots[0].root, root); + assert_eq!(roots[0].sources, vec![SkillRootSource::CurrentExecutable]); + assert_eq!( + skill_root_from_executable(Path::new("/tmp/debug/webfetch-cli")), + None + ); + assert_eq!( + skill_root_from_executable(Path::new("/usr/local/bin/webfetch-cli")), + None + ); + + let temp = tempdir().unwrap(); + let renamed_root = temp.path().join("renamed-skill"); + fs::create_dir_all(renamed_root.join("bin")).unwrap(); + fs::write(renamed_root.join("SKILL.md"), "---\nname: test\n---\n").unwrap(); + assert_eq!( + skill_root_from_executable(&renamed_root.join("bin/webfetch-cli")), + Some(renamed_root) + ); + } + + #[test] + fn deduplicates_roots_and_preserves_all_sources() { + let home = PathBuf::from("/home/alice"); + let root = home.join(".agents/skills").join(SKILL_NAME); + let roots = discover_skill_roots(&DiscoveryInputs { + home_dir: Some(home), + claude_skill_dir: Some(root.clone()), + codebuddy_skill_dir: Some(root.clone()), + current_exe: Some(root.join("bin/webfetch-cli")), + ..DiscoveryInputs::default() + }); + + let matches = roots + .iter() + .filter(|candidate| candidate.root == root) + .collect::>(); + assert_eq!(matches.len(), 1); + assert_eq!( + matches[0].sources, + vec![ + SkillRootSource::CodexAgentsHome, + SkillRootSource::ClaudeSkillDir, + SkillRootSource::WorkBuddySkillDir, + SkillRootSource::CurrentExecutable, + ] + ); + } + + #[test] + fn root_validation_reports_a_missing_manifest_file() { + let temp = tempdir().unwrap(); + write_manifest(temp.path()); + fs::remove_file(temp.path().join("scripts/doctor.ps1")).unwrap(); + + let status = validate_skill_root(temp.path()); + assert!(!status.complete); + assert_eq!(status.missing_files, vec!["scripts/doctor.ps1"]); + assert_eq!(status.present_files.len(), SKILL_ARCHIVE_FILES.len() - 1); + } +} diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..bc5e260 --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,125 @@ +use std::{fs, path::Path, process::Command}; + +use serde_json::Value; +use tempfile::tempdir; + +const SKILL_ARCHIVE_FILES: [&str; 8] = [ + "SKILL.md", + "references/authentication.md", + "references/commands.md", + "references/troubleshooting.md", + "scripts/bootstrap.ps1", + "scripts/bootstrap.sh", + "scripts/doctor.ps1", + "scripts/doctor.sh", +]; + +fn write_complete_skill(root: &Path) { + for relative in SKILL_ARCHIVE_FILES { + let path = root.join(relative); + fs::create_dir_all(path.parent().expect("manifest file has a parent")) + .expect("create Skill directory"); + fs::write(path, relative).expect("write Skill manifest file"); + } +} + +fn isolated_cli(home: &Path) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_webfetch-cli")); + command + .env("HOME", home) + .env_remove("CODEX_HOME") + .env_remove("CLAUDE_SKILL_DIR") + .env_remove("CLAUDE_CONFIG_DIR") + .env_remove("CODEBUDDY_SKILL_DIR") + .env_remove("LEXMOUNT_PROJECT_ID") + .env_remove("LEXMOUNT_API_KEY") + .env_remove("LEXMOUNT_WEBFETCH_BASE_URL") + .env_remove("LEXMOUNT_WEBFETCH_CONNECT_BASE_URL"); + command +} + +fn check<'a>(report: &'a Value, name: &str) -> &'a Value { + report["checks"] + .as_array() + .expect("doctor checks array") + .iter() + .find(|check| check["name"] == name) + .unwrap_or_else(|| panic!("missing doctor check {name}")) +} + +#[test] +fn doctor_cli_accepts_a_complete_claude_skill_and_environment_credentials() { + let temp = tempdir().expect("temporary directory"); + let skill_root = temp.path().join("claude-skill"); + write_complete_skill(&skill_root); + let credentials_path = temp.path().join("missing-credentials.json"); + + let output = isolated_cli(temp.path()) + .env("CLAUDE_SKILL_DIR", &skill_root) + .env("LEXMOUNT_WEBFETCH_CREDENTIALS_FILE", credentials_path) + .env("LEXMOUNT_PROJECT_ID", "project-integration") + .env("LEXMOUNT_API_KEY", "secret-integration") + .env("LEXMOUNT_WEBFETCH_BASE_URL", "https://api.example.test") + .args(["doctor", "--json"]) + .output() + .expect("run doctor"); + + assert!( + output.status.success(), + "doctor stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let report: Value = serde_json::from_slice(&output.stdout).expect("doctor JSON"); + assert_eq!(report["ok"], true); + assert_eq!(check(&report, "credentials")["status"], "pass"); + assert_eq!(check(&report, "agent_skill")["status"], "pass"); + assert_eq!( + check(&report, "agent_skill")["selected"]["sources"][0], + "claude_skill_dir" + ); +} + +#[test] +fn doctor_cli_reports_corrupt_credentials_as_json_with_a_skill_local_repair() { + let temp = tempdir().expect("temporary directory"); + let skill_root = temp.path().join("claude-skill"); + write_complete_skill(&skill_root); + let credentials_path = temp.path().join("credentials.json"); + fs::write(&credentials_path, b"{not-json").expect("write corrupt credentials"); + let connect_base_url = "https://browser.example.test"; + + let output = isolated_cli(temp.path()) + .env("CLAUDE_SKILL_DIR", &skill_root) + .env("LEXMOUNT_WEBFETCH_CREDENTIALS_FILE", &credentials_path) + .env("LEXMOUNT_WEBFETCH_CONNECT_BASE_URL", connect_base_url) + .args(["doctor", "--json"]) + .output() + .expect("run doctor"); + + assert_eq!(output.status.code(), Some(1)); + assert!(output.stderr.is_empty()); + let report: Value = serde_json::from_slice(&output.stdout).expect("doctor JSON"); + assert_eq!(report["ok"], false); + + let credentials = check(&report, "credentials"); + assert_eq!(credentials["status"], "fail"); + assert!( + credentials["message"] + .as_str() + .expect("credentials error") + .contains("Failed to load credentials file") + ); + let repair = &credentials["repair"]; + assert!(Path::new(repair["executable"].as_str().expect("repair executable")).is_absolute()); + assert_eq!( + repair["arguments"], + serde_json::json!([ + "auth", + "login", + "--open", + "--connect-base-url", + connect_base_url + ]) + ); + assert_eq!(check(&report, "agent_skill")["status"], "pass"); +}