diff --git a/skills/chaitin-cli/SKILL.md b/skills/chaitin-cli/SKILL.md index 54bce72..b4f63ae 100644 --- a/skills/chaitin-cli/SKILL.md +++ b/skills/chaitin-cli/SKILL.md @@ -10,31 +10,46 @@ tags: [chaitin-cli, safeline, xray, cloudwalker, tanswer, waf, security, chaitin > Unified CLI for Chaitin security products. Manage SafeLine WAF, X-Ray scanner, CloudWalker CWPP, and T-Answer through a single tool. +## No-Argument Behavior + +When `/chaitin-cli` is invoked without any arguments (empty `ARGUMENTS`): + +1. Greet the user and introduce this skill in a well-formatted way, based on the SKILL.md content. +2. Run `command -v chaitin-cli` to check if `chaitin-cli` is already installed. +3. If found, report the installed path (e.g. `chaitin-cli is installed at /opt/homebrew/bin/chaitin-cli`). +4. If not found, install it per platform: + - Windows: Tell the user to manually download the latest release from `https://github.com/chaitin/chaitin-cli/releases`, extract `chaitin-cli.exe`, and add it to PATH. Do not attempt automated installation on Windows. + - macOS, Linux: Run `bash scripts/install-chaitin-cli.sh`. The script outputs the installed binary path on stdout (last line). Remember this path — subsequent commands must use the full path (e.g. `/home/user/.local/bin/chaitin-cli`) because each Bash invocation starts a new shell and the install directory may not yet be in PATH. +5. After the setup check, briefly tell the user what they can do next — for example: "You can now use chaitin-cli to manage SafeLine, X-Ray, CloudWalker, or T-Answer. Tell me what you'd like to do, or run `chaitin-cli --help` to explore commands." + ## Tool Resolution When this skill needs `chaitin-cli`, do not run a preflight availability check before every command. 1. Run the requested `chaitin-cli ...` command directly. -2. Only if the shell reports `command not found`, `No such file or directory`, or exit code `127` because `chaitin-cli` is missing, run the platform installer: - - Windows / PowerShell: `powershell -ExecutionPolicy Bypass -File scripts/install-chaitin-cli.ps1` - - Windows / Git Bash, macOS, Linux: `bash scripts/install-chaitin-cli.sh` -3. After installation succeeds, retry the same `chaitin-cli ...` command once. +2. Only if the shell reports `command not found`, `No such file or directory`, or exit code `127` because `chaitin-cli` is missing, install it per platform: + - Windows: Tell the user to manually download the latest release from `https://github.com/chaitin/chaitin-cli/releases`, extract `chaitin-cli.exe`, and add it to PATH. Do not attempt automated installation on Windows. + - macOS, Linux: Run `bash scripts/install-chaitin-cli.sh`. The script outputs the installed binary path on stdout (last line, e.g. `/home/user/.local/bin/chaitin-cli`). Remember this path for the rest of the session. +3. After installation, run subsequent `chaitin-cli` commands using the full installed path (e.g. `/home/user/.local/bin/chaitin-cli safeline site list`) instead of bare `chaitin-cli`, because each Bash invocation starts a new shell and the install directory may not yet be in PATH. 4. If `chaitin-cli` already exists, do not query GitHub Releases, do not reinstall, and do not do version checks unless the user explicitly asks. -The bundled installers detect the current OS/architecture, download the latest matching `chaitin-cli` release archive from `https://github.com/chaitin/chaitin-cli/releases`, and install it as a directly runnable `chaitin-cli` / `chaitin-cli.exe`, preferring a system-wide install when possible and otherwise falling back to a user PATH directory. +The installer detects the current OS/architecture, downloads the latest matching `chaitin-cli` release archive from `https://github.com/chaitin/chaitin-cli/releases`, and installs it as a directly runnable `chaitin-cli`, preferring a user PATH directory and falling back to system-wide install when possible. + +> **Windows**: There is no automated installer for Windows. Download the latest release from `https://github.com/chaitin/chaitin-cli/releases`, extract `chaitin-cli.exe`, and add it to PATH manually. ## Install & Run ```bash # On-demand installer used only when `chaitin-cli` is missing -# macOS / Linux / Git Bash on Windows +# macOS / Linux bash scripts/install-chaitin-cli.sh -# Windows PowerShell -powershell -ExecutionPolicy Bypass -File scripts/install-chaitin-cli.ps1 +# The installer fetches the matching GitHub release package +# and installs the extracted binary as `chaitin-cli`. -# The installers fetch the matching GitHub release package -# and install the extracted binary as `chaitin-cli`. +# Windows: download the latest release manually from +# https://github.com/chaitin/chaitin-cli/releases +# Extract chaitin-cli.exe and add it to PATH. # Or build from source git clone https://github.com/chaitin/chaitin-cli.git diff --git a/skills/chaitin-cli/scripts/install-chaitin-cli.ps1 b/skills/chaitin-cli/scripts/install-chaitin-cli.ps1 deleted file mode 100644 index 529e727..0000000 --- a/skills/chaitin-cli/scripts/install-chaitin-cli.ps1 +++ /dev/null @@ -1,226 +0,0 @@ -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -$RepoSlug = 'chaitin/chaitin-cli' -$InstallName = 'chaitin-cli.exe' - -function Write-Log { - param([string]$Message) - - [Console]::Error.WriteLine($Message) -} - -function Get-IsAdmin { - $identity = [Security.Principal.WindowsIdentity]::GetCurrent() - $principal = New-Object Security.Principal.WindowsPrincipal($identity) - return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) -} - -function Get-GoArch { - $arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant() - - switch ($arch) { - 'x64' { return 'amd64' } - 'arm64' { return 'arm64' } - default { throw "unsupported architecture: $arch" } - } -} - -function Normalize-Tag { - param([string]$Version) - - if ([string]::IsNullOrWhiteSpace($Version)) { - throw 'release version is empty' - } - - if ($Version.StartsWith('v')) { - return $Version - } - - return "v$Version" -} - -function Get-LatestTag { - $headers = @{ 'User-Agent' = 'chaitin-cli-installer' } - $release = Invoke-RestMethod -Headers $headers -Uri "https://api.github.com/repos/$RepoSlug/releases/latest" - - if ([string]::IsNullOrWhiteSpace($release.tag_name)) { - throw 'failed to parse latest release tag' - } - - return $release.tag_name -} - -function Get-PathEntries { - param([string]$Value) - - if ([string]::IsNullOrWhiteSpace($Value)) { - return @() - } - - return @($Value -split ';' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -} - -function Test-PathContains { - param( - [string[]]$Entries, - [string]$Directory - ) - - $normalizedTarget = $Directory.TrimEnd('\\') - foreach ($entry in $Entries) { - if ($entry.TrimEnd('\\').Equals($normalizedTarget, [System.StringComparison]::OrdinalIgnoreCase)) { - return $true - } - } - - return $false -} - -function Add-ToPath { - param( - [string]$Directory, - [System.EnvironmentVariableTarget]$Scope - ) - - $persistentValue = [Environment]::GetEnvironmentVariable('Path', $Scope) - $persistentEntries = Get-PathEntries $persistentValue - if (-not (Test-PathContains -Entries $persistentEntries -Directory $Directory)) { - $newValue = if ([string]::IsNullOrWhiteSpace($persistentValue)) { - $Directory - } else { - "$persistentValue;$Directory" - } - - [Environment]::SetEnvironmentVariable('Path', $newValue, $Scope) - Write-Log "updated $Scope PATH" - } - - $sessionEntries = Get-PathEntries $env:Path - if (-not (Test-PathContains -Entries $sessionEntries -Directory $Directory)) { - $env:Path = if ([string]::IsNullOrWhiteSpace($env:Path)) { - $Directory - } else { - "$Directory;$env:Path" - } - } -} - -function Get-InstallTargets { - $targets = @() - - if (-not [string]::IsNullOrWhiteSpace($env:CHAITIN_CLI_INSTALL_DIR)) { - $targets += [pscustomobject]@{ - Directory = $env:CHAITIN_CLI_INSTALL_DIR - Scope = [System.EnvironmentVariableTarget]::User - } - } - - if ((Get-IsAdmin) -and -not [string]::IsNullOrWhiteSpace($env:ProgramFiles)) { - $targets += [pscustomobject]@{ - Directory = Join-Path $env:ProgramFiles 'chaitin-cli\bin' - Scope = [System.EnvironmentVariableTarget]::Machine - } - } - - if (-not [string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { - $targets += [pscustomobject]@{ - Directory = Join-Path $env:LOCALAPPDATA 'Programs\chaitin-cli\bin' - Scope = [System.EnvironmentVariableTarget]::User - } - } - - if (-not [string]::IsNullOrWhiteSpace($env:USERPROFILE)) { - $targets += [pscustomobject]@{ - Directory = Join-Path $env:USERPROFILE 'bin' - Scope = [System.EnvironmentVariableTarget]::User - } - } - - return $targets -} - -function Install-Binary { - param( - [string]$SourcePath, - [string]$DestinationDirectory, - [System.EnvironmentVariableTarget]$Scope - ) - - New-Item -ItemType Directory -Path $DestinationDirectory -Force | Out-Null - $destinationPath = Join-Path $DestinationDirectory $InstallName - Copy-Item -LiteralPath $SourcePath -Destination $destinationPath -Force - Add-ToPath -Directory $DestinationDirectory -Scope $Scope - return $destinationPath -} - -function Install-WithFallback { - param([string]$SourcePath) - - $errors = New-Object System.Collections.Generic.List[string] - foreach ($target in Get-InstallTargets) { - try { - return Install-Binary -SourcePath $SourcePath -DestinationDirectory $target.Directory -Scope $target.Scope - } catch { - $errors.Add("$($target.Directory): $($_.Exception.Message)") - } - } - - throw "failed to install chaitin-cli. attempts: $($errors -join '; ')" -} - -function Download-ReleaseBinary { - param( - [string]$GoArch, - [string]$WorkDir - ) - - $tag = if ([string]::IsNullOrWhiteSpace($env:CHAITIN_CLI_VERSION)) { - Normalize-Tag (Get-LatestTag) - } else { - Normalize-Tag $env:CHAITIN_CLI_VERSION - } - - $headers = @{ 'User-Agent' = 'chaitin-cli-installer' } - $assetName = "chaitin-cli_${tag}_windows_${GoArch}.zip" - $archivePath = Join-Path $WorkDir $assetName - $downloadUrl = "https://github.com/$RepoSlug/releases/download/$tag/$assetName" - - Write-Log "downloading $downloadUrl" - Invoke-WebRequest -Headers $headers -Uri $downloadUrl -OutFile $archivePath - - $extractDir = Join-Path $WorkDir 'extract' - Expand-Archive -LiteralPath $archivePath -DestinationPath $extractDir -Force - - $binary = Get-ChildItem -Path $extractDir -Recurse -File | Where-Object { $_.Name -ieq $InstallName } | Select-Object -First 1 - if ($null -eq $binary) { - throw "failed to locate $InstallName in downloaded archive" - } - - return $binary.FullName -} - -function Main { - $existing = Get-Command chaitin-cli -ErrorAction SilentlyContinue - if ($null -ne $existing) { - return $existing.Source - } - - $goArch = Get-GoArch - $workDir = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString('N')) - - try { - New-Item -ItemType Directory -Path $workDir -Force | Out-Null - $sourceBinary = Download-ReleaseBinary -GoArch $goArch -WorkDir $workDir - $installedPath = Install-WithFallback -SourcePath $sourceBinary - Write-Log "installed chaitin-cli to $installedPath" - return $installedPath - } finally { - if (Test-Path -LiteralPath $workDir) { - Remove-Item -LiteralPath $workDir -Recurse -Force - } - } -} - -$installed = Main -Write-Output $installed diff --git a/skills/chaitin-cli/scripts/install-chaitin-cli.sh b/skills/chaitin-cli/scripts/install-chaitin-cli.sh index e8915a8..55794f3 100755 --- a/skills/chaitin-cli/scripts/install-chaitin-cli.sh +++ b/skills/chaitin-cli/scripts/install-chaitin-cli.sh @@ -23,36 +23,6 @@ need_cmd() { have_cmd "$1" || fail "missing required command: $1" } -run_windows_powershell_installer() { - local ps_script="$script_dir/install-chaitin-cli.ps1" - local ps_path="$ps_script" - local ps_bin installed_path posix_path - - if have_cmd cygpath; then - ps_path="$(cygpath -w "$ps_script")" - fi - - if have_cmd powershell.exe; then - ps_bin="powershell.exe" - elif have_cmd pwsh; then - ps_bin="pwsh" - else - fail "missing required command: powershell.exe or pwsh" - fi - - installed_path="$($ps_bin -NoProfile -ExecutionPolicy Bypass -File "$ps_path")" - [ -n "$installed_path" ] || fail "windows installer finished without an output path" - - if have_cmd cygpath; then - posix_path="$(cygpath -u "$installed_path" 2>/dev/null || true)" - if [ -n "$posix_path" ]; then - export PATH="$(dirname "$posix_path"):${PATH:-}" - fi - fi - - printf '%s\n' "$installed_path" -} - detect_goos() { case "$(uname -s)" in Linux) @@ -85,13 +55,86 @@ detect_goarch() { } latest_tag() { - local api_url response tag + local tag need_cmd curl + + if tag="$(latest_tag_from_api)"; then + printf '%s\n' "$tag" + return 0 + fi + + log "warning: GitHub API lookup failed, falling back to the releases page" + if tag="$(latest_tag_from_redirect)"; then + printf '%s\n' "$tag" + return 0 + fi + + if tag="$(latest_tag_from_html)"; then + printf '%s\n' "$tag" + return 0 + fi + + fail "failed to determine latest release tag from GitHub" +} + +github_curl() { + local token="${GITHUB_TOKEN:-${GH_TOKEN:-}}" + local -a args + + args=( + -fsSL + -H "User-Agent: ${install_name}-installer" + ) + + if [ -n "$token" ]; then + args+=(-H "Authorization: Bearer ${token}") + fi + + curl "${args[@]}" "$@" +} + +latest_tag_from_api() { + local api_url response tag + api_url="https://api.github.com/repos/${repo_slug}/releases/latest" - response="$(curl -fsSL "$api_url")" || fail "failed to query latest release from ${api_url}" + response="$(github_curl -H 'Accept: application/vnd.github+json' "$api_url" 2>/dev/null)" || return 1 tag="$(printf '%s' "$response" | tr -d '\n' | sed -nE 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p')" - [ -n "$tag" ] || fail "failed to parse latest release tag" + [ -n "$tag" ] || return 1 + printf '%s\n' "$tag" +} + +latest_tag_from_redirect() { + local releases_url effective_url tag + + releases_url="https://github.com/${repo_slug}/releases/latest" + effective_url="$(github_curl -o /dev/null -w '%{url_effective}' "$releases_url" 2>/dev/null)" || return 1 + + case "$effective_url" in + *"/releases/tag/"*) + tag="${effective_url##*/releases/tag/}" + ;; + *"/tag/"*) + tag="${effective_url##*/tag/}" + ;; + *) + return 1 + ;; + esac + + tag="${tag%%[?#]*}" + tag="${tag%/}" + [ -n "$tag" ] || return 1 + printf '%s\n' "$tag" +} + +latest_tag_from_html() { + local releases_url response tag + + releases_url="https://github.com/${repo_slug}/releases/latest" + response="$(github_curl "$releases_url" 2>/dev/null)" || return 1 + tag="$(printf '%s' "$response" | grep -oE "/${repo_slug}/releases/tag/[^\"?#]+" | head -n 1 | sed 's#.*/tag/##')" || return 1 + [ -n "$tag" ] || return 1 printf '%s\n' "$tag" } @@ -114,7 +157,7 @@ append_unique() { local existing [ -n "$candidate" ] || return 0 - for existing in "${install_candidates[@]}"; do + for existing in "${install_candidates[@]+"${install_candidates[@]}"}"; do if [ "$existing" = "$candidate" ]; then return 0 fi @@ -150,23 +193,36 @@ install_file() { [ -n "$destination_dir" ] || return 1 if [ "$use_sudo" = "true" ]; then - sudo mkdir -p "$destination_dir" - if have_cmd install; then - sudo install -m 0755 "$source" "$destination_path" + sudo mkdir -p "$destination_dir" || return 1 + # Prefer cp+chmod over coreutils install — more portable across + # sandboxed, minimal, and container environments. + if sudo cp "$source" "$destination_path" 2>/dev/null && sudo chmod 0755 "$destination_path" 2>/dev/null; then + : else - sudo cp "$source" "$destination_path" - sudo chmod 0755 "$destination_path" + # Try coreutils install as fallback + if have_cmd install; then + sudo install -m 0755 "$source" "$destination_path" 2>/dev/null || return 1 + else + return 1 + fi fi else - mkdir -p "$destination_dir" - if have_cmd install; then - install -m 0755 "$source" "$destination_path" + mkdir -p "$destination_dir" || return 1 + # Prefer cp+chmod over coreutils install — more portable across + # sandboxed, minimal, and container environments. + if cp "$source" "$destination_path" 2>/dev/null && chmod 0755 "$destination_path" 2>/dev/null; then + : else - cp "$source" "$destination_path" - chmod 0755 "$destination_path" + # Try coreutils install as fallback + if have_cmd install; then + install -m 0755 "$source" "$destination_path" 2>/dev/null || return 1 + else + return 1 + fi fi fi + [ -f "$destination_path" ] || return 1 printf '%s\n' "$destination_path" } @@ -250,9 +306,9 @@ download_release_binary() { need_cmd curl version="${CHAITIN_CLI_VERSION:-}" if [ -z "$version" ]; then - version="$(latest_tag)" + version="$(latest_tag)" || fail "failed to resolve the latest release version" fi - tag="$(normalize_tag "$version")" + tag="$(normalize_tag "$version")" || fail "failed to normalize release version: ${version}" case "$goos" in windows) @@ -283,65 +339,62 @@ install_from_candidates() { local source_binary="$1" local destination_dir destination_path - for destination_dir in "${install_candidates[@]}"; do + # Pass 1: Try all candidates without sudo — $HOME dirs are listed first + for destination_dir in "${install_candidates[@]+"${install_candidates[@]}"}"; do [ -n "$destination_dir" ] || continue - case "$destination_dir" in - "$HOME"/*) - continue - ;; - esac + # Only attempt if the directory already exists and is writable, + # or if we can create it (parent exists and writable). if [ -d "$destination_dir" ] && [ -w "$destination_dir" ]; then - install_file "$source_binary" "$destination_dir" false - return 0 + destination_path="$(install_file "$source_binary" "$destination_dir" false)" || continue + if [ -f "$destination_path" ]; then + ensure_path_visible "$destination_dir" + printf '%s\n' "$destination_path" + return 0 + fi + elif [ -d "$(dirname "$destination_dir")" ] && [ -w "$(dirname "$destination_dir")" ]; then + destination_path="$(install_file "$source_binary" "$destination_dir" false)" || continue + if [ -f "$destination_path" ]; then + ensure_path_visible "$destination_dir" + printf '%s\n' "$destination_path" + return 0 + fi fi done + # Pass 2: Try system candidates with sudo if have_cmd sudo; then - for destination_dir in "${install_candidates[@]}"; do + for destination_dir in "${install_candidates[@]+"${install_candidates[@]}"}"; do [ -n "$destination_dir" ] || continue + # Skip $HOME dirs — already tried in pass 1 case "$destination_dir" in - "$HOME"/*) - continue - ;; + "$HOME"/*) continue ;; esac if destination_path="$(install_file "$source_binary" "$destination_dir" true 2>/dev/null)"; then - printf '%s\n' "$destination_path" - return 0 - fi - done - fi - - for destination_dir in "${install_candidates[@]}"; do - [ -n "$destination_dir" ] || continue - - case "$destination_dir" in - "$HOME"/*) - if [ -d "$destination_dir" ] && [ -w "$destination_dir" ]; then - install_file "$source_binary" "$destination_dir" false - ensure_path_visible "$destination_dir" - return 0 - fi - - if [ -d "$(dirname "$destination_dir")" ] && [ -w "$(dirname "$destination_dir")" ]; then - destination_path="$(install_file "$source_binary" "$destination_dir" false)" - ensure_path_visible "$destination_dir" + if [ -f "$destination_path" ]; then printf '%s\n' "$destination_path" return 0 fi - ;; - esac - done + log "warning: sudo install to ${destination_dir} reported success but file not found, trying next candidate" + fi + done + fi + # Pass 3: Guaranteed fallback — $HOME/.local/bin always works if $HOME is writable destination_dir="$HOME/.local/bin" - destination_path="$(install_file "$source_binary" "$destination_dir" false)" - ensure_path_visible "$destination_dir" - printf '%s\n' "$destination_path" + destination_path="$(install_file "$source_binary" "$destination_dir" false 2>/dev/null)" || true + if [ -n "$destination_path" ] && [ -f "$destination_path" ]; then + ensure_path_visible "$destination_dir" + printf '%s\n' "$destination_path" + return 0 + fi + + fail "could not install ${install_name} to any writable directory" } main() { - local goos goarch tmpdir source_binary installed_path + local goos goarch source_binary installed_path if have_cmd "$install_name"; then command -v "$install_name" @@ -350,14 +403,13 @@ main() { goos="$(detect_goos)" if [ "$goos" = "windows" ]; then - run_windows_powershell_installer - return 0 + fail "automated installation is not supported on Windows. Download the latest release from https://github.com/${repo_slug}/releases, extract chaitin-cli.exe, and add it to PATH manually." fi goarch="$(detect_goarch)" build_install_candidates tmpdir="$(mktemp -d)" - trap 'rm -rf "$tmpdir"' EXIT INT TERM + trap 'rm -rf "${tmpdir:-}"' EXIT INT TERM source_binary="$(download_release_binary "$goos" "$goarch" "$tmpdir")" installed_path="$(install_from_candidates "$source_binary")" @@ -368,4 +420,5 @@ main() { } install_candidates=() +tmpdir="" main "$@"