From f506ad55820880e4b728043d6fb1617d4fe08bc7 Mon Sep 17 00:00:00 2001 From: malcoln-dandaro_data Date: Mon, 9 Mar 2026 17:21:45 -0300 Subject: [PATCH 1/8] feat: add skill profile selection to install scripts Add persona-based skill profiles (Data Engineer, Business Analyst, AI/ML Engineer, App Developer) so users can install only relevant skills instead of all 34. Core skills (config, docs, python-sdk, unity-catalog) are always installed. New features: - --skills-profile flag for profile-based selection (multi-select) - --skills flag for individual skill selection - --list-skills flag to display available profiles and skills - Interactive multi-select prompt in installer UI - Custom mode for individual skill picking - Profile persistence across reinstalls (.skills-profile file) - Default remains "install all" for backward compatibility Co-Authored-By: Claude Opus 4.6 --- install.ps1 | 391 +++++++++++++++++++++++++++++++++++++++++++----- install.sh | 416 +++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 733 insertions(+), 74 deletions(-) diff --git a/install.ps1 b/install.ps1 index eecc5ab1..86d6bb85 100644 --- a/install.ps1 +++ b/install.ps1 @@ -73,6 +73,9 @@ $script:Tools = "" $script:UserMcpPath = "" $script:Pkg = "" $script:ProfileProvided = $false +$script:SkillsProfile = "" +$script:UserSkills = "" +$script:ListSkills = $false # Databricks skills (bundled in repo) $script:Skills = @( @@ -97,6 +100,87 @@ $MlflowRawUrl = "https://raw.githubusercontent.com/mlflow/skills/main" $script:ApxSkills = @("databricks-app-apx") $ApxRawUrl = "https://raw.githubusercontent.com/databricks-solutions/apx/main/skills/apx" +# ─── Skill profiles ────────────────────────────────────────── +$script:CoreSkills = @("databricks-config", "databricks-docs", "databricks-python-sdk", "databricks-unity-catalog") + +$script:ProfileDataEngineer = @( + "databricks-spark-declarative-pipelines", "databricks-spark-structured-streaming", + "databricks-jobs", "databricks-asset-bundles", "databricks-dbsql", "databricks-iceberg", + "databricks-zerobus-ingest", "spark-python-data-source", "databricks-metric-views", + "databricks-synthetic-data-gen" +) +$script:ProfileAnalyst = @( + "databricks-aibi-dashboards", "databricks-dbsql", "databricks-genie", "databricks-metric-views" +) +$script:ProfileAiMlEngineer = @( + "databricks-agent-bricks", "databricks-vector-search", "databricks-model-serving", + "databricks-genie", "databricks-parsing", "databricks-unstructured-pdf-generation", + "databricks-mlflow-evaluation", "databricks-synthetic-data-gen", "databricks-jobs" +) +$script:ProfileAiMlMlflow = @( + "agent-evaluation", "analyze-mlflow-chat-session", "analyze-mlflow-trace", + "instrumenting-with-mlflow-tracing", "mlflow-onboarding", "querying-mlflow-metrics", + "retrieving-mlflow-traces", "searching-mlflow-docs" +) +$script:ProfileAppDeveloper = @( + "databricks-app-python", "databricks-app-apx", "databricks-lakebase-autoscale", + "databricks-lakebase-provisioned", "databricks-model-serving", "databricks-dbsql", + "databricks-jobs", "databricks-asset-bundles" +) + +# Selected skills (populated during profile selection) +$script:SelectedSkills = @() +$script:SelectedMlflowSkills = @() +$script:SelectedApxSkills = @() + +# ─── --list-skills handler ──────────────────────────────────── +if ($script:ListSkills) { + Write-Host "" + Write-Host "Available Skill Profiles" -ForegroundColor White + Write-Host "--------------------------------" + Write-Host "" + Write-Host " all " -ForegroundColor White -NoNewline; Write-Host "All 34 skills (default)" + Write-Host " data-engineer " -ForegroundColor White -NoNewline; Write-Host "Pipelines, Spark, Jobs, Streaming (14 skills)" + Write-Host " analyst " -ForegroundColor White -NoNewline; Write-Host "Dashboards, SQL, Genie, Metrics (8 skills)" + Write-Host " ai-ml-engineer " -ForegroundColor White -NoNewline; Write-Host "Agents, RAG, Vector Search, MLflow (17 skills)" + Write-Host " app-developer " -ForegroundColor White -NoNewline; Write-Host "Apps, Lakebase, Deployment (10 skills)" + Write-Host "" + Write-Host "Core Skills (always installed)" -ForegroundColor White + Write-Host "--------------------------------" + foreach ($s in $script:CoreSkills) { Write-Host " " -NoNewline; Write-Host "v" -ForegroundColor Green -NoNewline; Write-Host " $s" } + Write-Host "" + Write-Host "Data Engineer" -ForegroundColor White + Write-Host "--------------------------------" + foreach ($s in $script:ProfileDataEngineer) { Write-Host " $s" } + Write-Host "" + Write-Host "Business Analyst" -ForegroundColor White + Write-Host "--------------------------------" + foreach ($s in $script:ProfileAnalyst) { Write-Host " $s" } + Write-Host "" + Write-Host "AI/ML Engineer" -ForegroundColor White + Write-Host "--------------------------------" + foreach ($s in $script:ProfileAiMlEngineer) { Write-Host " $s" } + Write-Host " + MLflow skills:" -ForegroundColor DarkGray + foreach ($s in $script:ProfileAiMlMlflow) { Write-Host " $s" } + Write-Host "" + Write-Host "App Developer" -ForegroundColor White + Write-Host "--------------------------------" + foreach ($s in $script:ProfileAppDeveloper) { Write-Host " $s" } + Write-Host "" + Write-Host "MLflow Skills (from mlflow/skills repo)" -ForegroundColor White + Write-Host "--------------------------------" + foreach ($s in $script:MlflowSkills) { Write-Host " $s" } + Write-Host "" + Write-Host "APX Skills (from databricks-solutions/apx repo)" -ForegroundColor White + Write-Host "--------------------------------" + foreach ($s in $script:ApxSkills) { Write-Host " $s" } + Write-Host "" + Write-Host "Usage: .\install.ps1 --skills-profile data-engineer,ai-ml-engineer" -ForegroundColor DarkGray + Write-Host " .\install.ps1 --skills databricks-jobs,databricks-dbsql" -ForegroundColor DarkGray + Write-Host "" + return +} + # ─── Ensure tools are in PATH ──────────────────────────────── # Chocolatey-installed tools may not be in PATH for SSH sessions $machinePath = [System.Environment]::GetEnvironmentVariable("Path", "Machine") @@ -132,6 +216,9 @@ while ($i -lt $args.Count) { { $_ -in "--mcp-path", "-McpPath" } { $script:UserMcpPath = $args[$i + 1]; $i += 2 } { $_ -in "--silent", "-Silent" } { $script:Silent = $true; $i++ } { $_ -in "--tools", "-Tools" } { $script:UserTools = $args[$i + 1]; $i += 2 } + { $_ -in "--skills-profile", "-SkillsProfile" } { $script:SkillsProfile = $args[$i + 1]; $i += 2 } + { $_ -in "--skills", "-Skills" } { $script:UserSkills = $args[$i + 1]; $i += 2 } + { $_ -in "--list-skills", "-ListSkills" } { $script:ListSkills = $true; $i++ } { $_ -in "-f", "--force", "-Force" } { $script:Force = $true; $i++ } { $_ -in "-h", "--help", "-Help" } { Write-Host "Databricks AI Dev Kit Installer (Windows)" @@ -147,6 +234,9 @@ while ($i -lt $args.Count) { Write-Host " --mcp-path PATH Path to MCP server installation" Write-Host " --silent Silent mode (no output except errors)" Write-Host " --tools LIST Comma-separated: claude,cursor,copilot,codex,gemini" + Write-Host " --skills-profile LIST Comma-separated profiles: all,data-engineer,analyst,ai-ml-engineer,app-developer" + Write-Host " --skills LIST Comma-separated skill names to install (overrides profile)" + Write-Host " --list-skills List available skills and profiles, then exit" Write-Host " -f, --force Force reinstall" Write-Host " -h, --help Show this help" Write-Host "" @@ -734,6 +824,187 @@ function Install-McpServer { } } +# ─── Skill profile selection ────────────────────────────────── +function Resolve-Skills { + # Priority 1: Explicit --skills flag + if (-not [string]::IsNullOrWhiteSpace($script:UserSkills)) { + $userList = $script:UserSkills -split ',' + $dbSkills = @() + $script:CoreSkills + $mlflowSkills = @() + $apxSkills = @() + foreach ($skill in $userList) { + $skill = $skill.Trim() + if ($script:MlflowSkills -contains $skill) { + $mlflowSkills += $skill + } elseif ($script:ApxSkills -contains $skill) { + $apxSkills += $skill + } else { + $dbSkills += $skill + } + } + $script:SelectedSkills = $dbSkills | Select-Object -Unique + $script:SelectedMlflowSkills = $mlflowSkills | Select-Object -Unique + $script:SelectedApxSkills = $apxSkills | Select-Object -Unique + return + } + + # Priority 2: --skills-profile flag or interactive selection + if ([string]::IsNullOrWhiteSpace($script:SkillsProfile) -or $script:SkillsProfile -eq "all") { + $script:SelectedSkills = $script:Skills + $script:SelectedMlflowSkills = $script:MlflowSkills + $script:SelectedApxSkills = $script:ApxSkills + return + } + + # Build union of selected profiles + $dbSkills = @() + $script:CoreSkills + $mlflowSkills = @() + $apxSkills = @() + + foreach ($profile in ($script:SkillsProfile -split ',')) { + $profile = $profile.Trim() + switch ($profile) { + "all" { + $script:SelectedSkills = $script:Skills + $script:SelectedMlflowSkills = $script:MlflowSkills + $script:SelectedApxSkills = $script:ApxSkills + return + } + "data-engineer" { $dbSkills += $script:ProfileDataEngineer } + "analyst" { $dbSkills += $script:ProfileAnalyst } + "ai-ml-engineer" { + $dbSkills += $script:ProfileAiMlEngineer + $mlflowSkills += $script:ProfileAiMlMlflow + } + "app-developer" { + $dbSkills += $script:ProfileAppDeveloper + $apxSkills += $script:ApxSkills + } + default { Write-Warn "Unknown skill profile: $profile (ignored)" } + } + } + + $script:SelectedSkills = $dbSkills | Select-Object -Unique + $script:SelectedMlflowSkills = $mlflowSkills | Select-Object -Unique + $script:SelectedApxSkills = $apxSkills | Select-Object -Unique +} + +function Invoke-PromptSkillsProfile { + # If provided via --skills or --skills-profile, skip interactive prompt + if (-not [string]::IsNullOrWhiteSpace($script:UserSkills) -or -not [string]::IsNullOrWhiteSpace($script:SkillsProfile)) { + return + } + + # Skip in silent mode + if ($script:Silent) { + $script:SkillsProfile = "all" + return + } + + # Check for previous selection + $profileFile = Join-Path $script:InstallDir ".skills-profile" + if (Test-Path $profileFile) { + $prevProfile = (Get-Content $profileFile -Raw).Trim() + if (-not $script:Force) { + Write-Host "" + $displayProfile = $prevProfile -replace ',', ', ' + $keep = Read-Prompt -PromptText "Previous skill profile: $displayProfile. Keep? (Y/n)" -Default "y" + if ($keep -in @("y", "Y", "yes", "")) { + $script:SkillsProfile = $prevProfile + return + } + } + } + + Write-Host "" + Write-Host " Select skill profile(s)" -ForegroundColor White + + $items = @( + @{ Label = "All Skills"; Value = "all"; State = $true; Hint = "Install everything (34 skills)" } + @{ Label = "Data Engineer"; Value = "data-engineer"; State = $false; Hint = "Pipelines, Spark, Jobs, Streaming (14 skills)" } + @{ Label = "Business Analyst"; Value = "analyst"; State = $false; Hint = "Dashboards, SQL, Genie, Metrics (8 skills)" } + @{ Label = "AI/ML Engineer"; Value = "ai-ml-engineer"; State = $false; Hint = "Agents, RAG, Vector Search, MLflow (17 skills)" } + @{ Label = "App Developer"; Value = "app-developer"; State = $false; Hint = "Apps, Lakebase, Deployment (10 skills)" } + @{ Label = "Custom"; Value = "custom"; State = $false; Hint = "Pick individual skills" } + ) + + $selected = Select-Checkbox -Items $items + + if ([string]::IsNullOrWhiteSpace($selected)) { + $script:SkillsProfile = "all" + return + } + + # Check if "all" is selected + if ($selected -match '\ball\b') { + $script:SkillsProfile = "all" + return + } + + # Check if "custom" is selected + if ($selected -match '\bcustom\b') { + Invoke-PromptCustomSkills -PreselectedProfiles $selected + return + } + + $script:SkillsProfile = ($selected -split ' ') -join ',' +} + +function Invoke-PromptCustomSkills { + param([string]$PreselectedProfiles) + + # Build pre-selection set from any profiles that were also checked + $preselected = @() + foreach ($profile in ($PreselectedProfiles -split ' ')) { + switch ($profile) { + "data-engineer" { $preselected += $script:ProfileDataEngineer } + "analyst" { $preselected += $script:ProfileAnalyst } + "ai-ml-engineer" { $preselected += $script:ProfileAiMlEngineer + $script:ProfileAiMlMlflow } + "app-developer" { $preselected += $script:ProfileAppDeveloper + $script:ApxSkills } + } + } + + Write-Host "" + Write-Host " Select individual skills" -ForegroundColor White + Write-Host " Core skills (config, docs, python-sdk, unity-catalog) are always installed" -ForegroundColor DarkGray + + $items = @( + @{ Label = "Spark Pipelines"; Value = "databricks-spark-declarative-pipelines"; State = ($preselected -contains "databricks-spark-declarative-pipelines"); Hint = "SDP/LDP, CDC, SCD Type 2" } + @{ Label = "Streaming"; Value = "databricks-spark-structured-streaming"; State = ($preselected -contains "databricks-spark-structured-streaming"); Hint = "Real-time streaming" } + @{ Label = "Jobs & Workflows"; Value = "databricks-jobs"; State = ($preselected -contains "databricks-jobs"); Hint = "Multi-task orchestration" } + @{ Label = "Asset Bundles"; Value = "databricks-asset-bundles"; State = ($preselected -contains "databricks-asset-bundles"); Hint = "DABs deployment" } + @{ Label = "Databricks SQL"; Value = "databricks-dbsql"; State = ($preselected -contains "databricks-dbsql"); Hint = "SQL warehouse queries" } + @{ Label = "Iceberg"; Value = "databricks-iceberg"; State = ($preselected -contains "databricks-iceberg"); Hint = "Apache Iceberg tables" } + @{ Label = "Zerobus Ingest"; Value = "databricks-zerobus-ingest"; State = ($preselected -contains "databricks-zerobus-ingest"); Hint = "Streaming ingestion" } + @{ Label = "Python Data Src"; Value = "spark-python-data-source"; State = ($preselected -contains "spark-python-data-source"); Hint = "Custom Spark data sources" } + @{ Label = "Metric Views"; Value = "databricks-metric-views"; State = ($preselected -contains "databricks-metric-views"); Hint = "Metric definitions" } + @{ Label = "AI/BI Dashboards"; Value = "databricks-aibi-dashboards"; State = ($preselected -contains "databricks-aibi-dashboards"); Hint = "Dashboard creation" } + @{ Label = "Genie"; Value = "databricks-genie"; State = ($preselected -contains "databricks-genie"); Hint = "Natural language SQL" } + @{ Label = "Agent Bricks"; Value = "databricks-agent-bricks"; State = ($preselected -contains "databricks-agent-bricks"); Hint = "Build AI agents" } + @{ Label = "Vector Search"; Value = "databricks-vector-search"; State = ($preselected -contains "databricks-vector-search"); Hint = "Similarity search" } + @{ Label = "Model Serving"; Value = "databricks-model-serving"; State = ($preselected -contains "databricks-model-serving"); Hint = "Deploy models/agents" } + @{ Label = "MLflow Evaluation"; Value = "databricks-mlflow-evaluation"; State = ($preselected -contains "databricks-mlflow-evaluation"); Hint = "Model evaluation" } + @{ Label = "Parsing"; Value = "databricks-parsing"; State = ($preselected -contains "databricks-parsing"); Hint = "Document parsing for RAG" } + @{ Label = "Unstructured PDF"; Value = "databricks-unstructured-pdf-generation"; State = ($preselected -contains "databricks-unstructured-pdf-generation"); Hint = "Synthetic PDFs for RAG" } + @{ Label = "Synthetic Data"; Value = "databricks-synthetic-data-gen"; State = ($preselected -contains "databricks-synthetic-data-gen"); Hint = "Generate test data" } + @{ Label = "Lakebase Autoscale"; Value = "databricks-lakebase-autoscale"; State = ($preselected -contains "databricks-lakebase-autoscale"); Hint = "Managed PostgreSQL" } + @{ Label = "Lakebase Provisioned"; Value = "databricks-lakebase-provisioned"; State = ($preselected -contains "databricks-lakebase-provisioned"); Hint = "Provisioned PostgreSQL" } + @{ Label = "App Python"; Value = "databricks-app-python"; State = ($preselected -contains "databricks-app-python"); Hint = "Dash, Streamlit, Flask" } + @{ Label = "App APX"; Value = "databricks-app-apx"; State = ($preselected -contains "databricks-app-apx"); Hint = "FastAPI + React" } + @{ Label = "MLflow Onboarding"; Value = "mlflow-onboarding"; State = ($preselected -contains "mlflow-onboarding"); Hint = "Getting started" } + @{ Label = "Agent Evaluation"; Value = "agent-evaluation"; State = ($preselected -contains "agent-evaluation"); Hint = "Evaluate AI agents" } + @{ Label = "MLflow Tracing"; Value = "instrumenting-with-mlflow-tracing"; State = ($preselected -contains "instrumenting-with-mlflow-tracing"); Hint = "Instrument with tracing" } + @{ Label = "Analyze Traces"; Value = "analyze-mlflow-trace"; State = ($preselected -contains "analyze-mlflow-trace"); Hint = "Analyze trace data" } + @{ Label = "Retrieve Traces"; Value = "retrieving-mlflow-traces"; State = ($preselected -contains "retrieving-mlflow-traces"); Hint = "Search & retrieve traces" } + @{ Label = "Analyze Chat"; Value = "analyze-mlflow-chat-session"; State = ($preselected -contains "analyze-mlflow-chat-session"); Hint = "Chat session analysis" } + @{ Label = "Query Metrics"; Value = "querying-mlflow-metrics"; State = ($preselected -contains "querying-mlflow-metrics"); Hint = "MLflow metrics queries" } + @{ Label = "Search MLflow Docs"; Value = "searching-mlflow-docs"; State = ($preselected -contains "searching-mlflow-docs"); Hint = "MLflow documentation" } + ) + + $selected = Select-Checkbox -Items $items + $script:UserSkills = ($selected -split ' ') -join ',' +} + # ─── Install skills ────────────────────────────────────────── function Install-Skills { param([string]$BaseDir) @@ -756,12 +1027,19 @@ function Install-Skills { } $dirs = $dirs | Select-Object -Unique + # Count selected skills for display + $dbCount = $script:SelectedSkills.Count + $mlflowCount = $script:SelectedMlflowSkills.Count + $apxCount = $script:SelectedApxSkills.Count + $totalCount = $dbCount + $mlflowCount + $apxCount + Write-Msg "Installing $totalCount skills" + foreach ($dir in $dirs) { if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } # Install Databricks skills from repo - foreach ($skill in $script:Skills) { + foreach ($skill in $script:SelectedSkills) { $src = Join-Path $script:RepoDir "databricks-skills\$skill" if (-not (Test-Path $src)) { continue } $dest = Join-Path $dir $skill @@ -769,54 +1047,67 @@ function Install-Skills { Copy-Item -Recurse $src $dest } $shortDir = $dir -replace [regex]::Escape($env:USERPROFILE), '~' - Write-Ok "Databricks skills -> $shortDir" + Write-Ok "Databricks skills ($dbCount) -> $shortDir" # Install MLflow skills from mlflow/skills repo - $prevEAP = $ErrorActionPreference; $ErrorActionPreference = "Continue" - foreach ($skill in $script:MlflowSkills) { - $destDir = Join-Path $dir $skill - if (-not (Test-Path $destDir)) { - New-Item -ItemType Directory -Path $destDir -Force | Out-Null - } - $url = "$MlflowRawUrl/$skill/SKILL.md" - try { - Invoke-WebRequest -Uri $url -OutFile (Join-Path $destDir "SKILL.md") -UseBasicParsing -ErrorAction Stop - # Try optional reference files - foreach ($ref in @("reference.md", "examples.md", "api.md")) { - try { - Invoke-WebRequest -Uri "$MlflowRawUrl/$skill/$ref" -OutFile (Join-Path $destDir $ref) -UseBasicParsing -ErrorAction Stop - } catch {} + if ($script:SelectedMlflowSkills.Count -gt 0) { + $prevEAP = $ErrorActionPreference; $ErrorActionPreference = "Continue" + foreach ($skill in $script:SelectedMlflowSkills) { + $destDir = Join-Path $dir $skill + if (-not (Test-Path $destDir)) { + New-Item -ItemType Directory -Path $destDir -Force | Out-Null + } + $url = "$MlflowRawUrl/$skill/SKILL.md" + try { + Invoke-WebRequest -Uri $url -OutFile (Join-Path $destDir "SKILL.md") -UseBasicParsing -ErrorAction Stop + foreach ($ref in @("reference.md", "examples.md", "api.md")) { + try { + Invoke-WebRequest -Uri "$MlflowRawUrl/$skill/$ref" -OutFile (Join-Path $destDir $ref) -UseBasicParsing -ErrorAction Stop + } catch {} + } + } catch { + Remove-Item -Recurse -Force $destDir -ErrorAction SilentlyContinue } - } catch { - Remove-Item -Recurse -Force $destDir -ErrorAction SilentlyContinue } + $ErrorActionPreference = $prevEAP + Write-Ok "MLflow skills ($mlflowCount) -> $shortDir" } - $ErrorActionPreference = $prevEAP - Write-Ok "MLflow skills -> $shortDir" # Install APX skills from databricks-solutions/apx repo - $prevEAP2 = $ErrorActionPreference; $ErrorActionPreference = "Continue" - foreach ($skill in $script:ApxSkills) { - $destDir = Join-Path $dir $skill - if (-not (Test-Path $destDir)) { - New-Item -ItemType Directory -Path $destDir -Force | Out-Null - } - $url = "$ApxRawUrl/SKILL.md" - try { - Invoke-WebRequest -Uri $url -OutFile (Join-Path $destDir "SKILL.md") -UseBasicParsing -ErrorAction Stop - # Try optional reference files - foreach ($ref in @("backend-patterns.md", "frontend-patterns.md")) { - try { - Invoke-WebRequest -Uri "$ApxRawUrl/$ref" -OutFile (Join-Path $destDir $ref) -UseBasicParsing -ErrorAction Stop - } catch {} + if ($script:SelectedApxSkills.Count -gt 0) { + $prevEAP2 = $ErrorActionPreference; $ErrorActionPreference = "Continue" + foreach ($skill in $script:SelectedApxSkills) { + $destDir = Join-Path $dir $skill + if (-not (Test-Path $destDir)) { + New-Item -ItemType Directory -Path $destDir -Force | Out-Null + } + $url = "$ApxRawUrl/SKILL.md" + try { + Invoke-WebRequest -Uri $url -OutFile (Join-Path $destDir "SKILL.md") -UseBasicParsing -ErrorAction Stop + foreach ($ref in @("backend-patterns.md", "frontend-patterns.md")) { + try { + Invoke-WebRequest -Uri "$ApxRawUrl/$ref" -OutFile (Join-Path $destDir $ref) -UseBasicParsing -ErrorAction Stop + } catch {} + } + } catch { + Remove-Item $destDir -ErrorAction SilentlyContinue + Write-Warning "Could not install APX skill '$skill' - consider removing $destDir if it is no longer needed" } - } catch { - Remove-Item $destDir -ErrorAction SilentlyContinue - Write-Warning "Could not install APX skill '$skill' - consider removing $destDir if it is no longer needed" } + $ErrorActionPreference = $prevEAP2 + Write-Ok "APX skills ($apxCount) -> $shortDir" } - $ErrorActionPreference = $prevEAP2 - Write-Ok "APX skills -> $shortDir" + } + + # Save selected profile for future reinstalls + if (-not (Test-Path $script:InstallDir)) { + New-Item -ItemType Directory -Path $script:InstallDir -Force | Out-Null + } + if (-not [string]::IsNullOrWhiteSpace($script:UserSkills)) { + Set-Content -Path (Join-Path $script:InstallDir ".skills-profile") -Value "custom:$($script:UserSkills)" -Encoding UTF8 + } else { + $profileValue = if ([string]::IsNullOrWhiteSpace($script:SkillsProfile)) { "all" } else { $script:SkillsProfile } + Set-Content -Path (Join-Path $script:InstallDir ".skills-profile") -Value $profileValue -Encoding UTF8 } } @@ -1322,6 +1613,20 @@ function Invoke-Main { Write-Ok "Scope: $($script:Scope)" } + # Skill profile selection + if ($script:InstallSkills) { + Write-Step "Skill profiles" + Invoke-PromptSkillsProfile + Resolve-Skills + $skCount = $script:SelectedSkills.Count + $script:SelectedMlflowSkills.Count + $script:SelectedApxSkills.Count + if (-not [string]::IsNullOrWhiteSpace($script:UserSkills)) { + Write-Ok "Custom selection ($skCount skills)" + } else { + $profileDisplay = if ([string]::IsNullOrWhiteSpace($script:SkillsProfile)) { "all" } else { $script:SkillsProfile } + Write-Ok "Profile: $profileDisplay ($skCount skills)" + } + } + # MCP path if ($script:InstallMcp) { Invoke-PromptMcpPath @@ -1340,7 +1645,13 @@ function Invoke-Main { Write-Host " MCP server: " -NoNewline; Write-Host $script:InstallDir -ForegroundColor Green } if ($script:InstallSkills) { - Write-Host " Skills: " -NoNewline; Write-Host "yes" -ForegroundColor Green + $skTotal = $script:SelectedSkills.Count + $script:SelectedMlflowSkills.Count + $script:SelectedApxSkills.Count + if (-not [string]::IsNullOrWhiteSpace($script:UserSkills)) { + Write-Host " Skills: " -NoNewline; Write-Host "custom selection ($skTotal skills)" -ForegroundColor Green + } else { + $profileDisplay = if ([string]::IsNullOrWhiteSpace($script:SkillsProfile)) { "all" } else { $script:SkillsProfile } + Write-Host " Skills: " -NoNewline; Write-Host "$profileDisplay ($skTotal skills)" -ForegroundColor Green + } } if ($script:InstallMcp) { Write-Host " MCP config: " -NoNewline; Write-Host "yes" -ForegroundColor Green diff --git a/install.sh b/install.sh index 868f2adb..39e50d1b 100755 --- a/install.sh +++ b/install.sh @@ -22,6 +22,18 @@ # # Skills only (skip MCP server) # bash <(curl -sL https://raw.githubusercontent.com/databricks-solutions/ai-dev-kit/main/install.sh) --skills-only # +# # Install skills for a specific profile +# bash <(curl -sL https://raw.githubusercontent.com/databricks-solutions/ai-dev-kit/main/install.sh) --skills-profile data-engineer +# +# # Install multiple profiles +# bash <(curl -sL https://raw.githubusercontent.com/databricks-solutions/ai-dev-kit/main/install.sh) --skills-profile data-engineer,ai-ml-engineer +# +# # Install specific skills only +# bash <(curl -sL https://raw.githubusercontent.com/databricks-solutions/ai-dev-kit/main/install.sh) --skills databricks-jobs,databricks-dbsql +# +# # List available skills and profiles +# bash <(curl -sL https://raw.githubusercontent.com/databricks-solutions/ai-dev-kit/main/install.sh) --list-skills +# # Alternative: Use environment variables # DEVKIT_TOOLS=cursor curl -sL https://raw.githubusercontent.com/databricks-solutions/ai-dev-kit/main/install.sh | bash # DEVKIT_FORCE=true DEVKIT_PROFILE=DEFAULT curl -sL https://raw.githubusercontent.com/databricks-solutions/ai-dev-kit/main/install.sh | bash @@ -39,6 +51,8 @@ SILENT="${DEVKIT_SILENT:-false}" TOOLS="${DEVKIT_TOOLS:-}" USER_TOOLS="" USER_MCP_PATH="${DEVKIT_MCP_PATH:-}" +SKILLS_PROFILE="${DEVKIT_SKILLS_PROFILE:-}" +USER_SKILLS="${DEVKIT_SKILLS:-}" # Convert string booleans from env vars to actual booleans [ "$FORCE" = "true" ] || [ "$FORCE" = "1" ] && FORCE=true || FORCE=false @@ -84,6 +98,22 @@ MLFLOW_RAW_URL="https://raw.githubusercontent.com/mlflow/skills/main" APX_SKILLS="databricks-app-apx" APX_RAW_URL="https://raw.githubusercontent.com/databricks-solutions/apx/main/skills/apx" +# ─── Skill profiles ────────────────────────────────────────── +# Core skills always installed regardless of profile selection +CORE_SKILLS="databricks-config databricks-docs databricks-python-sdk databricks-unity-catalog" + +# Profile definitions (non-core skills only — core skills are always added) +PROFILE_DATA_ENGINEER="databricks-spark-declarative-pipelines databricks-spark-structured-streaming databricks-jobs databricks-asset-bundles databricks-dbsql databricks-iceberg databricks-zerobus-ingest spark-python-data-source databricks-metric-views databricks-synthetic-data-gen" +PROFILE_ANALYST="databricks-aibi-dashboards databricks-dbsql databricks-genie databricks-metric-views" +PROFILE_AIML_ENGINEER="databricks-agent-bricks databricks-vector-search databricks-model-serving databricks-genie databricks-parsing databricks-unstructured-pdf-generation databricks-mlflow-evaluation databricks-synthetic-data-gen databricks-jobs" +PROFILE_AIML_MLFLOW="agent-evaluation analyze-mlflow-chat-session analyze-mlflow-trace instrumenting-with-mlflow-tracing mlflow-onboarding querying-mlflow-metrics retrieving-mlflow-traces searching-mlflow-docs" +PROFILE_APP_DEVELOPER="databricks-app-python databricks-app-apx databricks-lakebase-autoscale databricks-lakebase-provisioned databricks-model-serving databricks-dbsql databricks-jobs databricks-asset-bundles" + +# Selected skills (populated during profile selection) +SELECTED_SKILLS="" +SELECTED_MLFLOW_SKILLS="" +SELECTED_APX_SKILLS="" + # Output helpers msg() { [ "$SILENT" = true ] || echo -e " $*"; } ok() { [ "$SILENT" = true ] || echo -e " ${G}✓${N} $*"; } @@ -100,6 +130,9 @@ while [ $# -gt 0 ]; do --skills-only) INSTALL_MCP=false; shift ;; --mcp-only) INSTALL_SKILLS=false; shift ;; --mcp-path) USER_MCP_PATH="$2"; shift 2 ;; + --skills-profile) SKILLS_PROFILE="$2"; shift 2 ;; + --skills) USER_SKILLS="$2"; shift 2 ;; + --list-skills) LIST_SKILLS=true; shift ;; --silent) SILENT=true; shift ;; --tools) USER_TOOLS="$2"; shift 2 ;; -f|--force) FORCE=true; shift ;; @@ -117,6 +150,9 @@ while [ $# -gt 0 ]; do echo " --mcp-path PATH Path to MCP server installation (default: ~/.ai-dev-kit)" echo " --silent Silent mode (no output except errors)" echo " --tools LIST Comma-separated: claude,cursor,copilot,codex,gemini" + echo " --skills-profile LIST Comma-separated profiles: all,data-engineer,analyst,ai-ml-engineer,app-developer" + echo " --skills LIST Comma-separated skill names to install (overrides profile)" + echo " --list-skills List available skills and profiles, then exit" echo " -f, --force Force reinstall" echo " -h, --help Show this help" echo "" @@ -127,6 +163,8 @@ while [ $# -gt 0 ]; do echo " DEVKIT_TOOLS Comma-separated list of tools" echo " DEVKIT_FORCE Set to 'true' to force reinstall" echo " DEVKIT_MCP_PATH Path to MCP server installation" + echo " DEVKIT_SKILLS_PROFILE Comma-separated skill profiles" + echo " DEVKIT_SKILLS Comma-separated skill names" echo " DEVKIT_SILENT Set to 'true' for silent mode" echo " AIDEVKIT_HOME Installation directory (default: ~/.ai-dev-kit)" echo "" @@ -139,6 +177,70 @@ while [ $# -gt 0 ]; do esac done +# ─── --list-skills handler ───────────────────────────────────── +if [ "${LIST_SKILLS:-false}" = true ]; then + echo "" + echo -e "${B}Available Skill Profiles${N}" + echo "────────────────────────────────" + echo "" + echo -e " ${B}all${N} All 34 skills (default)" + echo -e " ${B}data-engineer${N} Pipelines, Spark, Jobs, Streaming (14 skills)" + echo -e " ${B}analyst${N} Dashboards, SQL, Genie, Metrics (8 skills)" + echo -e " ${B}ai-ml-engineer${N} Agents, RAG, Vector Search, MLflow (17 skills)" + echo -e " ${B}app-developer${N} Apps, Lakebase, Deployment (10 skills)" + echo "" + echo -e "${B}Core Skills${N} (always installed)" + echo "────────────────────────────────" + for skill in $CORE_SKILLS; do + echo -e " ${G}✓${N} $skill" + done + echo "" + echo -e "${B}Data Engineer${N}" + echo "────────────────────────────────" + for skill in $PROFILE_DATA_ENGINEER; do + echo -e " $skill" + done + echo "" + echo -e "${B}Business Analyst${N}" + echo "────────────────────────────────" + for skill in $PROFILE_ANALYST; do + echo -e " $skill" + done + echo "" + echo -e "${B}AI/ML Engineer${N}" + echo "────────────────────────────────" + for skill in $PROFILE_AIML_ENGINEER; do + echo -e " $skill" + done + echo -e " ${D}+ MLflow skills:${N}" + for skill in $PROFILE_AIML_MLFLOW; do + echo -e " $skill" + done + echo "" + echo -e "${B}App Developer${N}" + echo "────────────────────────────────" + for skill in $PROFILE_APP_DEVELOPER; do + echo -e " $skill" + done + echo "" + echo -e "${B}MLflow Skills${N} (from mlflow/skills repo)" + echo "────────────────────────────────" + for skill in $MLFLOW_SKILLS; do + echo -e " $skill" + done + echo "" + echo -e "${B}APX Skills${N} (from databricks-solutions/apx repo)" + echo "────────────────────────────────" + for skill in $APX_SKILLS; do + echo -e " $skill" + done + echo "" + echo -e "${D}Usage: bash install.sh --skills-profile data-engineer,ai-ml-engineer${N}" + echo -e "${D} bash install.sh --skills databricks-jobs,databricks-dbsql${N}" + echo "" + exit 0 +fi + # Set configuration URLs after parsing branch argument REPO_URL="https://github.com/databricks-solutions/ai-dev-kit.git" RAW_URL="https://raw.githubusercontent.com/databricks-solutions/ai-dev-kit/${BRANCH}" @@ -550,6 +652,209 @@ prompt_mcp_path() { MCP_ENTRY="$REPO_DIR/databricks-mcp-server/run_server.py" } +# ─── Skill profile selection ────────────────────────────────── +# Resolve selected skills from profile names or explicit skill list +resolve_skills() { + local db_skills="" mlflow_skills="" apx_skills="" + + # Priority 1: Explicit --skills flag (comma-separated skill names) + if [ -n "$USER_SKILLS" ]; then + local user_list + user_list=$(echo "$USER_SKILLS" | tr ',' ' ') + # Separate into DB, MLflow, and APX buckets, always include core + db_skills="$CORE_SKILLS" + for skill in $user_list; do + if echo "$MLFLOW_SKILLS" | grep -qw "$skill"; then + mlflow_skills="${mlflow_skills:+$mlflow_skills }$skill" + elif echo "$APX_SKILLS" | grep -qw "$skill"; then + apx_skills="${apx_skills:+$apx_skills }$skill" + else + db_skills="${db_skills:+$db_skills }$skill" + fi + done + # Deduplicate + SELECTED_SKILLS=$(echo "$db_skills" | tr ' ' '\n' | sort -u | tr '\n' ' ') + SELECTED_MLFLOW_SKILLS=$(echo "$mlflow_skills" | tr ' ' '\n' | sort -u | tr '\n' ' ') + SELECTED_APX_SKILLS=$(echo "$apx_skills" | tr ' ' '\n' | sort -u | tr '\n' ' ') + return + fi + + # Priority 2: --skills-profile flag or interactive selection + if [ -z "$SKILLS_PROFILE" ] || [ "$SKILLS_PROFILE" = "all" ]; then + SELECTED_SKILLS="$SKILLS" + SELECTED_MLFLOW_SKILLS="$MLFLOW_SKILLS" + SELECTED_APX_SKILLS="$APX_SKILLS" + return + fi + + # Build union of selected profiles (comma-separated) + db_skills="$CORE_SKILLS" + mlflow_skills="" + apx_skills="" + + local profiles + profiles=$(echo "$SKILLS_PROFILE" | tr ',' ' ') + for profile in $profiles; do + case $profile in + all) + SELECTED_SKILLS="$SKILLS" + SELECTED_MLFLOW_SKILLS="$MLFLOW_SKILLS" + SELECTED_APX_SKILLS="$APX_SKILLS" + return + ;; + data-engineer) + db_skills="$db_skills $PROFILE_DATA_ENGINEER" + ;; + analyst) + db_skills="$db_skills $PROFILE_ANALYST" + ;; + ai-ml-engineer) + db_skills="$db_skills $PROFILE_AIML_ENGINEER" + mlflow_skills="$mlflow_skills $PROFILE_AIML_MLFLOW" + ;; + app-developer) + db_skills="$db_skills $PROFILE_APP_DEVELOPER" + apx_skills="$apx_skills $APX_SKILLS" + ;; + *) + warn "Unknown skill profile: $profile (ignored)" + ;; + esac + done + + # Deduplicate + SELECTED_SKILLS=$(echo "$db_skills" | tr ' ' '\n' | sort -u | tr '\n' ' ') + SELECTED_MLFLOW_SKILLS=$(echo "$mlflow_skills" | tr ' ' '\n' | sort -u | tr '\n' ' ') + SELECTED_APX_SKILLS=$(echo "$apx_skills" | tr ' ' '\n' | sort -u | tr '\n' ' ') +} + +# Interactive skill profile selection (multi-select) +prompt_skills_profile() { + # If provided via --skills or --skills-profile, skip interactive prompt + if [ -n "$USER_SKILLS" ] || [ -n "$SKILLS_PROFILE" ]; then + return + fi + + # Skip in silent mode or non-interactive + if [ "$SILENT" = true ] || [ ! -e /dev/tty ]; then + SKILLS_PROFILE="all" + return + fi + + # Check for previous selection + local profile_file="$INSTALL_DIR/.skills-profile" + if [ -f "$profile_file" ]; then + local prev_profile + prev_profile=$(cat "$profile_file") + if [ "$FORCE" != true ]; then + echo "" + local display_profile + display_profile=$(echo "$prev_profile" | tr ',' ', ') + local keep + keep=$(prompt "Previous skill profile: ${B}${display_profile}${N}. Keep? ${D}(Y/n)${N}" "y") + if [ "$keep" = "y" ] || [ "$keep" = "Y" ] || [ "$keep" = "yes" ] || [ -z "$keep" ]; then + SKILLS_PROFILE="$prev_profile" + return + fi + fi + fi + + echo "" + echo -e " ${B}Select skill profile(s)${N}" + + local selected + selected=$(checkbox_select \ + "All Skills|all|on|Install everything (34 skills)" \ + "Data Engineer|data-engineer|off|Pipelines, Spark, Jobs, Streaming (14 skills)" \ + "Business Analyst|analyst|off|Dashboards, SQL, Genie, Metrics (8 skills)" \ + "AI/ML Engineer|ai-ml-engineer|off|Agents, RAG, Vector Search, MLflow (17 skills)" \ + "App Developer|app-developer|off|Apps, Lakebase, Deployment (10 skills)" \ + "Custom|custom|off|Pick individual skills" \ + ) + + # Handle empty selection — default to all + if [ -z "$selected" ]; then + SKILLS_PROFILE="all" + return + fi + + # Check if "all" is selected + if echo "$selected" | grep -qw "all"; then + SKILLS_PROFILE="all" + return + fi + + # Check if "custom" is selected — show individual skill picker + if echo "$selected" | grep -qw "custom"; then + prompt_custom_skills "$selected" + return + fi + + # Store comma-separated profile names + SKILLS_PROFILE=$(echo "$selected" | tr ' ' ',') +} + +# Custom individual skill picker +prompt_custom_skills() { + local preselected_profiles="$1" + + # Build pre-selection set from any profiles that were also checked + local preselected="" + for profile in $preselected_profiles; do + case $profile in + data-engineer) preselected="$preselected $PROFILE_DATA_ENGINEER" ;; + analyst) preselected="$preselected $PROFILE_ANALYST" ;; + ai-ml-engineer) preselected="$preselected $PROFILE_AIML_ENGINEER $PROFILE_AIML_MLFLOW" ;; + app-developer) preselected="$preselected $PROFILE_APP_DEVELOPER $APX_SKILLS" ;; + esac + done + + _is_preselected() { + echo "$preselected" | grep -qw "$1" && echo "on" || echo "off" + } + + echo "" + echo -e " ${B}Select individual skills${N}" + echo -e " ${D}Core skills (config, docs, python-sdk, unity-catalog) are always installed${N}" + + local selected + selected=$(checkbox_select \ + "Spark Pipelines|databricks-spark-declarative-pipelines|$(_is_preselected databricks-spark-declarative-pipelines)|SDP/LDP, CDC, SCD Type 2" \ + "Structured Streaming|databricks-spark-structured-streaming|$(_is_preselected databricks-spark-structured-streaming)|Real-time streaming" \ + "Jobs & Workflows|databricks-jobs|$(_is_preselected databricks-jobs)|Multi-task orchestration" \ + "Asset Bundles|databricks-asset-bundles|$(_is_preselected databricks-asset-bundles)|DABs deployment" \ + "Databricks SQL|databricks-dbsql|$(_is_preselected databricks-dbsql)|SQL warehouse queries" \ + "Iceberg|databricks-iceberg|$(_is_preselected databricks-iceberg)|Apache Iceberg tables" \ + "Zerobus Ingest|databricks-zerobus-ingest|$(_is_preselected databricks-zerobus-ingest)|Streaming ingestion" \ + "Python Data Source|spark-python-data-source|$(_is_preselected spark-python-data-source)|Custom Spark data sources" \ + "Metric Views|databricks-metric-views|$(_is_preselected databricks-metric-views)|Metric definitions" \ + "AI/BI Dashboards|databricks-aibi-dashboards|$(_is_preselected databricks-aibi-dashboards)|Dashboard creation" \ + "Genie|databricks-genie|$(_is_preselected databricks-genie)|Natural language SQL" \ + "Agent Bricks|databricks-agent-bricks|$(_is_preselected databricks-agent-bricks)|Build AI agents" \ + "Vector Search|databricks-vector-search|$(_is_preselected databricks-vector-search)|Similarity search" \ + "Model Serving|databricks-model-serving|$(_is_preselected databricks-model-serving)|Deploy models/agents" \ + "MLflow Evaluation|databricks-mlflow-evaluation|$(_is_preselected databricks-mlflow-evaluation)|Model evaluation" \ + "Parsing|databricks-parsing|$(_is_preselected databricks-parsing)|Document parsing for RAG" \ + "Unstructured PDF|databricks-unstructured-pdf-generation|$(_is_preselected databricks-unstructured-pdf-generation)|Synthetic PDFs for RAG" \ + "Synthetic Data|databricks-synthetic-data-gen|$(_is_preselected databricks-synthetic-data-gen)|Generate test data" \ + "Lakebase Autoscale|databricks-lakebase-autoscale|$(_is_preselected databricks-lakebase-autoscale)|Managed PostgreSQL" \ + "Lakebase Provisioned|databricks-lakebase-provisioned|$(_is_preselected databricks-lakebase-provisioned)|Provisioned PostgreSQL" \ + "App Python|databricks-app-python|$(_is_preselected databricks-app-python)|Dash, Streamlit, Flask" \ + "App APX|databricks-app-apx|$(_is_preselected databricks-app-apx)|FastAPI + React" \ + "MLflow Onboarding|mlflow-onboarding|$(_is_preselected mlflow-onboarding)|Getting started" \ + "Agent Evaluation|agent-evaluation|$(_is_preselected agent-evaluation)|Evaluate AI agents" \ + "MLflow Tracing|instrumenting-with-mlflow-tracing|$(_is_preselected instrumenting-with-mlflow-tracing)|Instrument with tracing" \ + "Analyze Traces|analyze-mlflow-trace|$(_is_preselected analyze-mlflow-trace)|Analyze trace data" \ + "Retrieve Traces|retrieving-mlflow-traces|$(_is_preselected retrieving-mlflow-traces)|Search & retrieve traces" \ + "Analyze Chat Session|analyze-mlflow-chat-session|$(_is_preselected analyze-mlflow-chat-session)|Chat session analysis" \ + "Query Metrics|querying-mlflow-metrics|$(_is_preselected querying-mlflow-metrics)|MLflow metrics queries" \ + "Search MLflow Docs|searching-mlflow-docs|$(_is_preselected searching-mlflow-docs)|MLflow documentation" \ + ) + + # Use explicit skills list — set USER_SKILLS so resolve_skills handles it + USER_SKILLS=$(echo "$selected" | tr ' ' ',') +} + # Compare semantic versions (returns 0 if $1 >= $2) version_gte() { printf '%s\n%s' "$2" "$1" | sort -V -C @@ -705,49 +1010,69 @@ install_skills() { done < <(printf '%s\n' "${dirs[@]}" | sort -u) dirs=("${unique[@]}") + # Count selected skills for display + local db_count=0 mlflow_count=0 apx_count=0 + for _ in $SELECTED_SKILLS; do db_count=$((db_count + 1)); done + for _ in $SELECTED_MLFLOW_SKILLS; do mlflow_count=$((mlflow_count + 1)); done + for _ in $SELECTED_APX_SKILLS; do apx_count=$((apx_count + 1)); done + local total_count=$((db_count + mlflow_count + apx_count)) + msg "Installing ${B}${total_count}${N} skills" + for dir in "${dirs[@]}"; do mkdir -p "$dir" # Install Databricks skills from repo - for skill in $SKILLS; do + for skill in $SELECTED_SKILLS; do local src="$REPO_DIR/databricks-skills/$skill" [ ! -d "$src" ] && continue rm -rf "$dir/$skill" cp -r "$src" "$dir/$skill" done - ok "Databricks skills → ${dir#$HOME/}" + ok "Databricks skills ($db_count) → ${dir#$HOME/}" # Install MLflow skills from mlflow/skills repo - for skill in $MLFLOW_SKILLS; do - local dest_dir="$dir/$skill" - mkdir -p "$dest_dir" - local url="$MLFLOW_RAW_URL/$skill/SKILL.md" - if curl -fsSL "$url" -o "$dest_dir/SKILL.md" 2>/dev/null; then - # Try to fetch optional reference files - for ref in reference.md examples.md api.md; do - curl -fsSL "$MLFLOW_RAW_URL/$skill/$ref" -o "$dest_dir/$ref" 2>/dev/null || true - done - else - rm -rf "$dest_dir" - fi - done - ok "MLflow skills → ${dir#$HOME/}" + if [ -n "$SELECTED_MLFLOW_SKILLS" ]; then + for skill in $SELECTED_MLFLOW_SKILLS; do + local dest_dir="$dir/$skill" + mkdir -p "$dest_dir" + local url="$MLFLOW_RAW_URL/$skill/SKILL.md" + if curl -fsSL "$url" -o "$dest_dir/SKILL.md" 2>/dev/null; then + # Try to fetch optional reference files + for ref in reference.md examples.md api.md; do + curl -fsSL "$MLFLOW_RAW_URL/$skill/$ref" -o "$dest_dir/$ref" 2>/dev/null || true + done + else + rm -rf "$dest_dir" + fi + done + ok "MLflow skills ($mlflow_count) → ${dir#$HOME/}" + fi # Install APX skills from databricks-solutions/apx repo - for skill in $APX_SKILLS; do - local dest_dir="$dir/$skill" - mkdir -p "$dest_dir" - local url="$APX_RAW_URL/SKILL.md" - if curl -fsSL "$url" -o "$dest_dir/SKILL.md" 2>/dev/null; then - # Try to fetch optional reference files - for ref in backend-patterns.md frontend-patterns.md; do - curl -fsSL "$APX_RAW_URL/$ref" -o "$dest_dir/$ref" 2>/dev/null || true - done - else - rmdir "$dest_dir" 2>/dev/null || warn "Could not install APX skill '$skill' — consider removing $dest_dir if it is no longer needed" - fi - done - ok "APX skills → ${dir#$HOME/}" + if [ -n "$SELECTED_APX_SKILLS" ]; then + for skill in $SELECTED_APX_SKILLS; do + local dest_dir="$dir/$skill" + mkdir -p "$dest_dir" + local url="$APX_RAW_URL/SKILL.md" + if curl -fsSL "$url" -o "$dest_dir/SKILL.md" 2>/dev/null; then + # Try to fetch optional reference files + for ref in backend-patterns.md frontend-patterns.md; do + curl -fsSL "$APX_RAW_URL/$ref" -o "$dest_dir/$ref" 2>/dev/null || true + done + else + rmdir "$dest_dir" 2>/dev/null || warn "Could not install APX skill '$skill' — consider removing $dest_dir if it is no longer needed" + fi + done + ok "APX skills ($apx_count) → ${dir#$HOME/}" + fi done + + # Save selected profile for future reinstalls + mkdir -p "$INSTALL_DIR" + if [ -n "$USER_SKILLS" ]; then + echo "custom:$USER_SKILLS" > "$INSTALL_DIR/.skills-profile" + else + echo "${SKILLS_PROFILE:-all}" > "$INSTALL_DIR/.skills-profile" + fi } # Write MCP configs @@ -1201,13 +1526,28 @@ main() { ok "Scope: $SCOPE" fi - # ── Step 4: Interactive MCP path ── + # ── Step 4: Skill profile selection ── + if [ "$INSTALL_SKILLS" = true ]; then + step "Skill profiles" + prompt_skills_profile + resolve_skills + # Count for display + local sk_count=0 + for _ in $SELECTED_SKILLS $SELECTED_MLFLOW_SKILLS $SELECTED_APX_SKILLS; do sk_count=$((sk_count + 1)); done + if [ -n "$USER_SKILLS" ]; then + ok "Custom selection ($sk_count skills)" + else + ok "Profile: ${SKILLS_PROFILE:-all} ($sk_count skills)" + fi + fi + + # ── Step 5: Interactive MCP path ── if [ "$INSTALL_MCP" = true ]; then prompt_mcp_path ok "MCP path: $INSTALL_DIR" fi - # ── Step 5: Confirm before proceeding ── + # ── Step 6: Confirm before proceeding ── if [ "$SILENT" = false ]; then echo "" echo -e " ${B}Summary${N}" @@ -1216,7 +1556,15 @@ main() { echo -e " Profile: ${G}${PROFILE}${N}" echo -e " Scope: ${G}${SCOPE}${N}" [ "$INSTALL_MCP" = true ] && echo -e " MCP server: ${G}${INSTALL_DIR}${N}" - [ "$INSTALL_SKILLS" = true ] && echo -e " Skills: ${G}yes${N}" + if [ "$INSTALL_SKILLS" = true ]; then + if [ -n "$USER_SKILLS" ]; then + echo -e " Skills: ${G}custom selection${N}" + else + local sk_total=0 + for _ in $SELECTED_SKILLS $SELECTED_MLFLOW_SKILLS $SELECTED_APX_SKILLS; do sk_total=$((sk_total + 1)); done + echo -e " Skills: ${G}${SKILLS_PROFILE:-all} ($sk_total skills)${N}" + fi + fi [ "$INSTALL_MCP" = true ] && echo -e " MCP config: ${G}yes${N}" echo "" fi @@ -1231,7 +1579,7 @@ main() { fi fi - # ── Step 6: Version check (may exit early if up to date) ── + # ── Step 7: Version check (may exit early if up to date) ── check_version # Determine base directory From b46faaefe85b7233116b7222207954ac7e5d607d Mon Sep 17 00:00:00 2001 From: malcoln-dandaro_data Date: Mon, 9 Mar 2026 18:00:06 -0300 Subject: [PATCH 2/8] feat: auto-deselect All when individual profiles chosen and vice versa Replace generic checkbox_select with custom skill profile selector that has mutual exclusion logic: selecting "All" deselects individual profiles, selecting any individual profile deselects "All". Provides immediate visual feedback during selection. Co-Authored-By: Claude Opus 4.6 --- install.ps1 | 138 +++++++++++++++++++++++++++++++++++++++++++++++----- install.sh | 95 ++++++++++++++++++++++++++++++++---- 2 files changed, 212 insertions(+), 21 deletions(-) diff --git a/install.ps1 b/install.ps1 index 86d6bb85..901a1113 100644 --- a/install.ps1 +++ b/install.ps1 @@ -919,35 +919,149 @@ function Invoke-PromptSkillsProfile { Write-Host "" Write-Host " Select skill profile(s)" -ForegroundColor White - $items = @( - @{ Label = "All Skills"; Value = "all"; State = $true; Hint = "Install everything (34 skills)" } - @{ Label = "Data Engineer"; Value = "data-engineer"; State = $false; Hint = "Pipelines, Spark, Jobs, Streaming (14 skills)" } - @{ Label = "Business Analyst"; Value = "analyst"; State = $false; Hint = "Dashboards, SQL, Genie, Metrics (8 skills)" } - @{ Label = "AI/ML Engineer"; Value = "ai-ml-engineer"; State = $false; Hint = "Agents, RAG, Vector Search, MLflow (17 skills)" } - @{ Label = "App Developer"; Value = "app-developer"; State = $false; Hint = "Apps, Lakebase, Deployment (10 skills)" } - @{ Label = "Custom"; Value = "custom"; State = $false; Hint = "Pick individual skills" } - ) + # Custom checkbox with mutual exclusion: "All" deselects others, others deselect "All" + $pLabels = @("All Skills", "Data Engineer", "Business Analyst", "AI/ML Engineer", "App Developer", "Custom") + $pValues = @("all", "data-engineer", "analyst", "ai-ml-engineer", "app-developer", "custom") + $pHints = @("Install everything (34 skills)", "Pipelines, Spark, Jobs, Streaming (14 skills)", "Dashboards, SQL, Genie, Metrics (8 skills)", "Agents, RAG, Vector Search, MLflow (17 skills)", "Apps, Lakebase, Deployment (10 skills)", "Pick individual skills") + $pStates = @($true, $false, $false, $false, $false, $false) + $pCount = 6 + $pCursor = 0 + $pTotalRows = $pCount + 2 - $selected = Select-Checkbox -Items $items + $isInteractive = Test-Interactive + + if (-not $isInteractive) { + # Fallback: numbered list + Write-Host "" + for ($j = 0; $j -lt $pCount; $j++) { + $mark = if ($pStates[$j]) { "[X]" } else { "[ ]" } + Write-Host " $($j + 1). $mark $($pLabels[$j]) ($($pHints[$j]))" + } + Write-Host "" + Write-Host " Enter numbers to toggle (e.g. 2,4), or press Enter for All: " -NoNewline + $input_ = Read-Host + if (-not [string]::IsNullOrWhiteSpace($input_)) { + for ($j = 0; $j -lt $pCount; $j++) { $pStates[$j] = $false } + $nums = $input_ -split ',' | ForEach-Object { $_.Trim() } + foreach ($n in $nums) { + $idx = [int]$n - 1 + if ($idx -ge 0 -and $idx -lt $pCount) { $pStates[$idx] = $true } + } + } + } else { + Write-Host "" + Write-Host " Up/Down navigate, Space toggle, Enter on Confirm to finish" -ForegroundColor DarkGray + Write-Host "" + + try { [Console]::CursorVisible = $false } catch {} + + $drawProfiles = { + [Console]::SetCursorPosition(0, [Math]::Max(0, [Console]::CursorTop - $pTotalRows)) + for ($j = 0; $j -lt $pCount; $j++) { + if ($j -eq $pCursor) { + Write-Host " " -NoNewline; Write-Host ">" -ForegroundColor Blue -NoNewline; Write-Host " " -NoNewline + } else { + Write-Host " " -NoNewline + } + if ($pStates[$j]) { + Write-Host "[" -NoNewline; Write-Host "v" -ForegroundColor Green -NoNewline; Write-Host "]" -NoNewline + } else { + Write-Host "[ ]" -NoNewline + } + $padLabel = $pLabels[$j].PadRight(20) + Write-Host " $padLabel " -NoNewline + if ($pStates[$j]) { + Write-Host $pHints[$j] -ForegroundColor Green -NoNewline + } else { + Write-Host $pHints[$j] -ForegroundColor DarkGray -NoNewline + } + $pos = [Console]::CursorLeft + $remaining = [Console]::WindowWidth - $pos - 1 + if ($remaining -gt 0) { Write-Host (' ' * $remaining) -NoNewline } + Write-Host "" + } + Write-Host (' ' * ([Console]::WindowWidth - 1)) + if ($pCursor -eq $pCount) { + Write-Host " " -NoNewline; Write-Host ">" -ForegroundColor Blue -NoNewline + Write-Host " " -NoNewline; Write-Host "[ Confirm ]" -ForegroundColor Green -NoNewline + } else { + Write-Host " " -NoNewline; Write-Host "[ Confirm ]" -ForegroundColor DarkGray -NoNewline + } + $pos = [Console]::CursorLeft + $remaining = [Console]::WindowWidth - $pos - 1 + if ($remaining -gt 0) { Write-Host (' ' * $remaining) -NoNewline } + Write-Host "" + } + + for ($j = 0; $j -lt $pTotalRows; $j++) { Write-Host "" } + & $drawProfiles + + while ($true) { + $key = $host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") + + switch ($key.VirtualKeyCode) { + 38 { if ($pCursor -gt 0) { $pCursor-- } } + 40 { if ($pCursor -lt $pCount) { $pCursor++ } } + 32 { # Space + if ($pCursor -lt $pCount) { + $pStates[$pCursor] = -not $pStates[$pCursor] + if ($pStates[$pCursor]) { + if ($pCursor -eq 0) { + # Selected "All" → deselect others + for ($j = 1; $j -lt $pCount; $j++) { $pStates[$j] = $false } + } else { + # Selected individual → deselect "All" + $pStates[0] = $false + } + } + } + } + 13 { # Enter + if ($pCursor -lt $pCount) { + $pStates[$pCursor] = -not $pStates[$pCursor] + if ($pStates[$pCursor]) { + if ($pCursor -eq 0) { + for ($j = 1; $j -lt $pCount; $j++) { $pStates[$j] = $false } + } else { + $pStates[0] = $false + } + } + } else { + & $drawProfiles + break + } + } + } + if ($key.VirtualKeyCode -eq 13 -and $pCursor -eq $pCount) { break } + & $drawProfiles + } + + try { [Console]::CursorVisible = $true } catch {} + } + + # Build result from states + $selectedProfiles = @() + for ($j = 0; $j -lt $pCount; $j++) { + if ($pStates[$j]) { $selectedProfiles += $pValues[$j] } + } + $selected = $selectedProfiles -join ' ' if ([string]::IsNullOrWhiteSpace($selected)) { $script:SkillsProfile = "all" return } - # Check if "all" is selected if ($selected -match '\ball\b') { $script:SkillsProfile = "all" return } - # Check if "custom" is selected if ($selected -match '\bcustom\b') { Invoke-PromptCustomSkills -PreselectedProfiles $selected return } - $script:SkillsProfile = ($selected -split ' ') -join ',' + $script:SkillsProfile = ($selectedProfiles -join ',') } function Invoke-PromptCustomSkills { diff --git a/install.sh b/install.sh index 39e50d1b..06f96df5 100755 --- a/install.sh +++ b/install.sh @@ -762,15 +762,92 @@ prompt_skills_profile() { echo "" echo -e " ${B}Select skill profile(s)${N}" - local selected - selected=$(checkbox_select \ - "All Skills|all|on|Install everything (34 skills)" \ - "Data Engineer|data-engineer|off|Pipelines, Spark, Jobs, Streaming (14 skills)" \ - "Business Analyst|analyst|off|Dashboards, SQL, Genie, Metrics (8 skills)" \ - "AI/ML Engineer|ai-ml-engineer|off|Agents, RAG, Vector Search, MLflow (17 skills)" \ - "App Developer|app-developer|off|Apps, Lakebase, Deployment (10 skills)" \ - "Custom|custom|off|Pick individual skills" \ - ) + # Custom checkbox with mutual exclusion: "All" deselects others, others deselect "All" + local -a p_labels=("All Skills" "Data Engineer" "Business Analyst" "AI/ML Engineer" "App Developer" "Custom") + local -a p_values=("all" "data-engineer" "analyst" "ai-ml-engineer" "app-developer" "custom") + local -a p_hints=("Install everything (34 skills)" "Pipelines, Spark, Jobs, Streaming (14 skills)" "Dashboards, SQL, Genie, Metrics (8 skills)" "Agents, RAG, Vector Search, MLflow (17 skills)" "Apps, Lakebase, Deployment (10 skills)" "Pick individual skills") + local -a p_states=(1 0 0 0 0 0) # "All" selected by default + local p_count=6 + local p_cursor=0 + local p_total_rows=$((p_count + 2)) + + _profile_draw() { + local i + for i in $(seq 0 $((p_count - 1))); do + local check=" " + [ "${p_states[$i]}" = "1" ] && check="\033[0;32m✓\033[0m" + local arrow=" " + [ "$i" = "$p_cursor" ] && arrow="\033[0;34m❯\033[0m " + local hint_style="\033[2m" + [ "${p_states[$i]}" = "1" ] && hint_style="\033[0;32m" + printf "\033[2K %b[%b] %-20s %b%s\033[0m\n" "$arrow" "$check" "${p_labels[$i]}" "$hint_style" "${p_hints[$i]}" > /dev/tty + done + printf "\033[2K\n" > /dev/tty + if [ "$p_cursor" = "$p_count" ]; then + printf "\033[2K \033[0;34m❯\033[0m \033[1;32m[ Confirm ]\033[0m\n" > /dev/tty + else + printf "\033[2K \033[2m[ Confirm ]\033[0m\n" > /dev/tty + fi + } + + printf "\n \033[2m↑/↓ navigate · space/enter select · enter on Confirm to finish\033[0m\n\n" > /dev/tty + printf "\033[?25l" > /dev/tty + trap 'printf "\033[?25h" > /dev/tty 2>/dev/null' EXIT + + _profile_draw + + while true; do + printf "\033[%dA" "$p_total_rows" > /dev/tty + _profile_draw + + local key="" + IFS= read -rsn1 key < /dev/tty 2>/dev/null + + if [ "$key" = $'\x1b' ]; then + local s1="" s2="" + read -rsn1 s1 < /dev/tty 2>/dev/null + read -rsn1 s2 < /dev/tty 2>/dev/null + if [ "$s1" = "[" ]; then + case "$s2" in + A) [ "$p_cursor" -gt 0 ] && p_cursor=$((p_cursor - 1)) ;; + B) [ "$p_cursor" -lt "$p_count" ] && p_cursor=$((p_cursor + 1)) ;; + esac + fi + elif [ "$key" = " " ] || [ "$key" = "" ]; then + if [ "$p_cursor" -lt "$p_count" ]; then + # Toggle the current item + if [ "${p_states[$p_cursor]}" = "1" ]; then + p_states[$p_cursor]=0 + else + p_states[$p_cursor]=1 + # Mutual exclusion: "All" (index 0) vs individual profiles (1-5) + if [ "$p_cursor" = "0" ]; then + # Selected "All" → deselect all others + for j in $(seq 1 $((p_count - 1))); do p_states[$j]=0; done + else + # Selected an individual profile → deselect "All" + p_states[0]=0 + fi + fi + else + # On Confirm — done + printf "\033[%dA" "$p_total_rows" > /dev/tty + _profile_draw + break + fi + fi + done + + printf "\033[?25h" > /dev/tty + trap - EXIT + + # Build result + local selected="" + for i in $(seq 0 $((p_count - 1))); do + if [ "${p_states[$i]}" = "1" ]; then + selected="${selected:+$selected }${p_values[$i]}" + fi + done # Handle empty selection — default to all if [ -z "$selected" ]; then From d44145af55ce7e224b1168aeda78c9aa692b6bec Mon Sep 17 00:00:00 2001 From: malcoln-dandaro_data Date: Tue, 10 Mar 2026 11:48:34 -0300 Subject: [PATCH 3/8] feat: clean up deselected skills on profile change during upgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track installed skills in a manifest file (.installed-skills) so that when users change their skill profile on upgrade, skills that are no longer selected get removed. Only removes skills we installed — never touches user-created skill directories. Co-Authored-By: Claude Opus 4.6 --- install.ps1 | 37 ++++++++++++++++++++++++++++++++++++- install.sh | 31 ++++++++++++++++++++++++++++++- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/install.ps1 b/install.ps1 index 901a1113..5888072b 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1148,6 +1148,35 @@ function Install-Skills { $totalCount = $dbCount + $mlflowCount + $apxCount Write-Msg "Installing $totalCount skills" + # Build set of all skills being installed now + $allNewSkills = @() + $allNewSkills += $script:SelectedSkills + $allNewSkills += $script:SelectedMlflowSkills + $allNewSkills += $script:SelectedApxSkills + + # Clean up previously installed skills that are no longer selected + $manifest = Join-Path $script:InstallDir ".installed-skills" + if (Test-Path $manifest) { + foreach ($line in (Get-Content $manifest)) { + if ([string]::IsNullOrWhiteSpace($line)) { continue } + $parts = $line -split '\|', 2 + if ($parts.Count -ne 2) { continue } + $prevDir = $parts[0] + $prevSkill = $parts[1] + # Skip if this skill is still selected + if ($allNewSkills -contains $prevSkill) { continue } + # Only remove if the directory exists + $prevPath = Join-Path $prevDir $prevSkill + if (Test-Path $prevPath) { + Remove-Item -Recurse -Force $prevPath + Write-Msg "Removed deselected skill: $prevSkill" + } + } + } + + # Start fresh manifest + $manifestEntries = @() + foreach ($dir in $dirs) { if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null @@ -1159,6 +1188,7 @@ function Install-Skills { $dest = Join-Path $dir $skill if (Test-Path $dest) { Remove-Item -Recurse -Force $dest } Copy-Item -Recurse $src $dest + $manifestEntries += "$dir|$skill" } $shortDir = $dir -replace [regex]::Escape($env:USERPROFILE), '~' Write-Ok "Databricks skills ($dbCount) -> $shortDir" @@ -1179,6 +1209,7 @@ function Install-Skills { Invoke-WebRequest -Uri "$MlflowRawUrl/$skill/$ref" -OutFile (Join-Path $destDir $ref) -UseBasicParsing -ErrorAction Stop } catch {} } + $manifestEntries += "$dir|$skill" } catch { Remove-Item -Recurse -Force $destDir -ErrorAction SilentlyContinue } @@ -1203,6 +1234,7 @@ function Install-Skills { Invoke-WebRequest -Uri "$ApxRawUrl/$ref" -OutFile (Join-Path $destDir $ref) -UseBasicParsing -ErrorAction Stop } catch {} } + $manifestEntries += "$dir|$skill" } catch { Remove-Item $destDir -ErrorAction SilentlyContinue Write-Warning "Could not install APX skill '$skill' - consider removing $destDir if it is no longer needed" @@ -1213,10 +1245,13 @@ function Install-Skills { } } - # Save selected profile for future reinstalls + # Save manifest of installed skills (for cleanup on profile change) if (-not (Test-Path $script:InstallDir)) { New-Item -ItemType Directory -Path $script:InstallDir -Force | Out-Null } + Set-Content -Path $manifest -Value ($manifestEntries -join "`n") -Encoding UTF8 + + # Save selected profile for future reinstalls if (-not [string]::IsNullOrWhiteSpace($script:UserSkills)) { Set-Content -Path (Join-Path $script:InstallDir ".skills-profile") -Value "custom:$($script:UserSkills)" -Encoding UTF8 } else { diff --git a/install.sh b/install.sh index 06f96df5..539a5710 100755 --- a/install.sh +++ b/install.sh @@ -1095,6 +1095,29 @@ install_skills() { local total_count=$((db_count + mlflow_count + apx_count)) msg "Installing ${B}${total_count}${N} skills" + # Build set of all skills being installed now + local all_new_skills="$SELECTED_SKILLS $SELECTED_MLFLOW_SKILLS $SELECTED_APX_SKILLS" + + # Clean up previously installed skills that are no longer selected + local manifest="$INSTALL_DIR/.installed-skills" + if [ -f "$manifest" ]; then + while IFS='|' read -r prev_dir prev_skill; do + [ -z "$prev_skill" ] && continue + # Skip if this skill is still selected + if echo " $all_new_skills " | grep -qw "$prev_skill"; then + continue + fi + # Only remove if the directory exists + if [ -d "$prev_dir/$prev_skill" ]; then + rm -rf "$prev_dir/$prev_skill" + msg "${D}Removed deselected skill: $prev_skill${N}" + fi + done < "$manifest" + fi + + # Start fresh manifest + : > "$manifest.tmp" + for dir in "${dirs[@]}"; do mkdir -p "$dir" # Install Databricks skills from repo @@ -1103,6 +1126,7 @@ install_skills() { [ ! -d "$src" ] && continue rm -rf "$dir/$skill" cp -r "$src" "$dir/$skill" + echo "$dir|$skill" >> "$manifest.tmp" done ok "Databricks skills ($db_count) → ${dir#$HOME/}" @@ -1117,6 +1141,7 @@ install_skills() { for ref in reference.md examples.md api.md; do curl -fsSL "$MLFLOW_RAW_URL/$skill/$ref" -o "$dest_dir/$ref" 2>/dev/null || true done + echo "$dir|$skill" >> "$manifest.tmp" else rm -rf "$dest_dir" fi @@ -1135,6 +1160,7 @@ install_skills() { for ref in backend-patterns.md frontend-patterns.md; do curl -fsSL "$APX_RAW_URL/$ref" -o "$dest_dir/$ref" 2>/dev/null || true done + echo "$dir|$skill" >> "$manifest.tmp" else rmdir "$dest_dir" 2>/dev/null || warn "Could not install APX skill '$skill' — consider removing $dest_dir if it is no longer needed" fi @@ -1143,8 +1169,11 @@ install_skills() { fi done - # Save selected profile for future reinstalls + # Save manifest of installed skills (for cleanup on profile change) mkdir -p "$INSTALL_DIR" + mv "$manifest.tmp" "$manifest" + + # Save selected profile for future reinstalls if [ -n "$USER_SKILLS" ]; then echo "custom:$USER_SKILLS" > "$INSTALL_DIR/.skills-profile" else From 3aed34af3d2a861cdcc2131e990df90b1350b4cf Mon Sep 17 00:00:00 2001 From: malcoln-dandaro_data Date: Tue, 10 Mar 2026 12:59:44 -0300 Subject: [PATCH 4/8] feat: scope-aware skill profile and manifest storage Store .skills-profile and .installed-skills per-scope so project-level installs can have different skill profiles than global installs. Falls back to global ~/.ai-dev-kit/ when reading, for backward compat with users upgrading from older versions that only stored state globally. Co-Authored-By: Claude Opus 4.6 --- install.ps1 | 31 +++++++++++++++++++++++-------- install.sh | 27 +++++++++++++++++++-------- 2 files changed, 42 insertions(+), 16 deletions(-) diff --git a/install.ps1 b/install.ps1 index 5888072b..41a6f0a1 100644 --- a/install.ps1 +++ b/install.ps1 @@ -901,8 +901,11 @@ function Invoke-PromptSkillsProfile { return } - # Check for previous selection - $profileFile = Join-Path $script:InstallDir ".skills-profile" + # Check for previous selection (scope-local first, then global fallback for upgrades) + $profileFile = Join-Path $script:StateDir ".skills-profile" + if (-not (Test-Path $profileFile) -and $script:Scope -eq "project") { + $profileFile = Join-Path $script:InstallDir ".skills-profile" + } if (Test-Path $profileFile) { $prevProfile = (Get-Content $profileFile -Raw).Trim() if (-not $script:Force) { @@ -1155,7 +1158,11 @@ function Install-Skills { $allNewSkills += $script:SelectedApxSkills # Clean up previously installed skills that are no longer selected - $manifest = Join-Path $script:InstallDir ".installed-skills" + # Check scope-local manifest first, fall back to global for upgrades from older versions + $manifest = Join-Path $script:StateDir ".installed-skills" + if (-not (Test-Path $manifest) -and $script:Scope -eq "project" -and (Test-Path (Join-Path $script:InstallDir ".installed-skills"))) { + $manifest = Join-Path $script:InstallDir ".installed-skills" + } if (Test-Path $manifest) { foreach ($line in (Get-Content $manifest)) { if ([string]::IsNullOrWhiteSpace($line)) { continue } @@ -1245,18 +1252,19 @@ function Install-Skills { } } - # Save manifest of installed skills (for cleanup on profile change) - if (-not (Test-Path $script:InstallDir)) { - New-Item -ItemType Directory -Path $script:InstallDir -Force | Out-Null + # Save manifest and profile to scope-local state directory + if (-not (Test-Path $script:StateDir)) { + New-Item -ItemType Directory -Path $script:StateDir -Force | Out-Null } + $manifest = Join-Path $script:StateDir ".installed-skills" Set-Content -Path $manifest -Value ($manifestEntries -join "`n") -Encoding UTF8 # Save selected profile for future reinstalls if (-not [string]::IsNullOrWhiteSpace($script:UserSkills)) { - Set-Content -Path (Join-Path $script:InstallDir ".skills-profile") -Value "custom:$($script:UserSkills)" -Encoding UTF8 + Set-Content -Path (Join-Path $script:StateDir ".skills-profile") -Value "custom:$($script:UserSkills)" -Encoding UTF8 } else { $profileValue = if ([string]::IsNullOrWhiteSpace($script:SkillsProfile)) { "all" } else { $script:SkillsProfile } - Set-Content -Path (Join-Path $script:InstallDir ".skills-profile") -Value $profileValue -Encoding UTF8 + Set-Content -Path (Join-Path $script:StateDir ".skills-profile") -Value $profileValue -Encoding UTF8 } } @@ -1762,6 +1770,13 @@ function Invoke-Main { Write-Ok "Scope: $($script:Scope)" } + # Set state directory based on scope (for profile/manifest storage) + if ($script:Scope -eq "global") { + $script:StateDir = $script:InstallDir + } else { + $script:StateDir = Join-Path (Get-Location) ".ai-dev-kit" + } + # Skill profile selection if ($script:InstallSkills) { Write-Step "Skill profiles" diff --git a/install.sh b/install.sh index 539a5710..0714639a 100755 --- a/install.sh +++ b/install.sh @@ -741,8 +741,9 @@ prompt_skills_profile() { return fi - # Check for previous selection - local profile_file="$INSTALL_DIR/.skills-profile" + # Check for previous selection (scope-local first, then global fallback for upgrades) + local profile_file="$STATE_DIR/.skills-profile" + [ ! -f "$profile_file" ] && [ "$SCOPE" = "project" ] && profile_file="$INSTALL_DIR/.skills-profile" if [ -f "$profile_file" ]; then local prev_profile prev_profile=$(cat "$profile_file") @@ -1099,7 +1100,9 @@ install_skills() { local all_new_skills="$SELECTED_SKILLS $SELECTED_MLFLOW_SKILLS $SELECTED_APX_SKILLS" # Clean up previously installed skills that are no longer selected - local manifest="$INSTALL_DIR/.installed-skills" + # Check scope-local manifest first, fall back to global for upgrades from older versions + local manifest="$STATE_DIR/.installed-skills" + [ ! -f "$manifest" ] && [ "$SCOPE" = "project" ] && [ -f "$INSTALL_DIR/.installed-skills" ] && manifest="$INSTALL_DIR/.installed-skills" if [ -f "$manifest" ]; then while IFS='|' read -r prev_dir prev_skill; do [ -z "$prev_skill" ] && continue @@ -1115,7 +1118,9 @@ install_skills() { done < "$manifest" fi - # Start fresh manifest + # Start fresh manifest (always write to scope-local state dir) + manifest="$STATE_DIR/.installed-skills" + mkdir -p "$STATE_DIR" : > "$manifest.tmp" for dir in "${dirs[@]}"; do @@ -1170,14 +1175,13 @@ install_skills() { done # Save manifest of installed skills (for cleanup on profile change) - mkdir -p "$INSTALL_DIR" mv "$manifest.tmp" "$manifest" - # Save selected profile for future reinstalls + # Save selected profile for future reinstalls (scope-local) if [ -n "$USER_SKILLS" ]; then - echo "custom:$USER_SKILLS" > "$INSTALL_DIR/.skills-profile" + echo "custom:$USER_SKILLS" > "$STATE_DIR/.skills-profile" else - echo "${SKILLS_PROFILE:-all}" > "$INSTALL_DIR/.skills-profile" + echo "${SKILLS_PROFILE:-all}" > "$STATE_DIR/.skills-profile" fi } @@ -1632,6 +1636,13 @@ main() { ok "Scope: $SCOPE" fi + # Set state directory based on scope (for profile/manifest storage) + if [ "$SCOPE" = "global" ]; then + STATE_DIR="$INSTALL_DIR" + else + STATE_DIR="$(pwd)/.ai-dev-kit" + fi + # ── Step 4: Skill profile selection ── if [ "$INSTALL_SKILLS" = true ]; then step "Skill profiles" From 6d91e833070651a49aeb13513bfa3a3c7a6f7172 Mon Sep 17 00:00:00 2001 From: malcoln-dandaro_data Date: Tue, 10 Mar 2026 13:03:09 -0300 Subject: [PATCH 5/8] feat: skip version gate when user requests a different skill profile Allow users to change their skill profile without --force by bypassing the "already up to date" exit when --skills-profile or --skills differs from the saved profile. Also updated the hint message to mention --skills-profile as an alternative to --force. Co-Authored-By: Claude Opus 4.6 --- install.ps1 | 15 ++++++++++++++- install.sh | 19 ++++++++++++++++--- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/install.ps1 b/install.ps1 index 41a6f0a1..9b1d9db4 100644 --- a/install.ps1 +++ b/install.ps1 @@ -734,6 +734,19 @@ function Test-Version { if (-not (Test-Path $verFile)) { return } if ($script:Force) { return } + # Skip version gate if user explicitly wants a different skill profile + if (-not [string]::IsNullOrWhiteSpace($script:SkillsProfile) -or -not [string]::IsNullOrWhiteSpace($script:UserSkills)) { + $savedProfileFile = Join-Path $script:StateDir ".skills-profile" + if (-not (Test-Path $savedProfileFile) -and $script:Scope -eq "project") { + $savedProfileFile = Join-Path $script:InstallDir ".skills-profile" + } + if (Test-Path $savedProfileFile) { + $savedProfile = (Get-Content $savedProfileFile -Raw).Trim() + $requested = if (-not [string]::IsNullOrWhiteSpace($script:UserSkills)) { "custom:$($script:UserSkills)" } else { $script:SkillsProfile } + if ($savedProfile -ne $requested) { return } + } + } + $localVer = (Get-Content $verFile -Raw).Trim() try { @@ -745,7 +758,7 @@ function Test-Version { if ($remoteVer -and $remoteVer -notmatch '(404|Not Found|error)') { if ($localVer -eq $remoteVer) { Write-Ok "Already up to date (v$localVer)" - Write-Msg "Use --force to reinstall" + Write-Msg "Use --force to reinstall or --skills-profile to change profiles" exit 0 } } diff --git a/install.sh b/install.sh index 0714639a..e83b5df8 100755 --- a/install.sh +++ b/install.sh @@ -1007,16 +1007,29 @@ check_version() { [ ! -f "$ver_file" ] && return [ "$FORCE" = true ] && return - + + # Skip version gate if user explicitly wants a different skill profile + if [ -n "$SKILLS_PROFILE" ] || [ -n "$USER_SKILLS" ]; then + local saved_profile_file="$STATE_DIR/.skills-profile" + [ ! -f "$saved_profile_file" ] && [ "$SCOPE" = "project" ] && saved_profile_file="$INSTALL_DIR/.skills-profile" + if [ -f "$saved_profile_file" ]; then + local saved_profile + saved_profile=$(cat "$saved_profile_file") + local requested="${USER_SKILLS:+custom:$USER_SKILLS}" + [ -z "$requested" ] && requested="$SKILLS_PROFILE" + [ "$saved_profile" != "$requested" ] && return + fi + fi + local local_ver=$(cat "$ver_file") # Use -f to fail on HTTP errors (like 404) local remote_ver=$(curl -fsSL "$RAW_URL/VERSION" 2>/dev/null || echo "") - + # Validate remote version format (should not contain "404" or other error text) if [ -n "$remote_ver" ] && [[ ! "$remote_ver" =~ (404|Not Found|error) ]]; then if [ "$local_ver" = "$remote_ver" ]; then ok "Already up to date (v${local_ver})" - msg "${D}Use --force to reinstall${N}" + msg "${D}Use --force to reinstall or --skills-profile to change profiles${N}" exit 0 fi fi From e00ae52424df2fae8363da94b55993366e129794 Mon Sep 17 00:00:00 2001 From: malcoln-dandaro_data Date: Tue, 10 Mar 2026 13:39:46 -0300 Subject: [PATCH 6/8] fix: pin ruff version in CI to avoid cross-platform formatting drift The unpinned `uvx ruff` was resolving to different formatter behavior on the CI runner (linux-ubuntu-latest) vs local dev machines, causing persistent format check failures that couldn't be reproduced locally. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 447177f4..57b1f509 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: - name: Run ruff check run: | - uvx ruff check \ + uvx ruff@0.11.0 check \ --select=E,F,B,PIE \ --ignore=E401,E402,F401,F403,B017,B904,ANN,TCH \ --line-length=120 \ @@ -26,7 +26,7 @@ jobs: - name: Check formatting run: | - uvx ruff format --check \ + uvx ruff@0.11.0 format --check \ --line-length=120 \ --target-version=py311 \ databricks-tools-core/ databricks-mcp-server/ .test/src/ From 7a093ad82a424cf34fbf0ff69690485ec8238446 Mon Sep 17 00:00:00 2001 From: malcoln-dandaro_data Date: Tue, 10 Mar 2026 13:41:56 -0300 Subject: [PATCH 7/8] debug: show ruff format diff in CI to diagnose formatting failure Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57b1f509..9abef9f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: - name: Check formatting run: | - uvx ruff@0.11.0 format --check \ + uvx ruff@0.11.0 format --diff \ --line-length=120 \ --target-version=py311 \ databricks-tools-core/ databricks-mcp-server/ .test/src/ From 77633218f2302f4bec1f92f11edf3f8c613c19a3 Mon Sep 17 00:00:00 2001 From: malcoln-dandaro_data Date: Tue, 10 Mar 2026 13:44:00 -0300 Subject: [PATCH 8/8] fix: format auth.py and test_sql.py to pass CI lint check Applied ruff formatting to files from upstream main that had multi-line function calls fitting within 120 chars. Also reverted CI debug change. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 2 +- .../databricks_tools_core/auth.py | 12 ++- databricks-tools-core/tests/unit/test_sql.py | 102 +++++++++++++++++- 3 files changed, 109 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9abef9f5..57b1f509 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: - name: Check formatting run: | - uvx ruff@0.11.0 format --diff \ + uvx ruff@0.11.0 format --check \ --line-length=120 \ --target-version=py311 \ databricks-tools-core/ databricks-mcp-server/ .test/src/ diff --git a/databricks-tools-core/databricks_tools_core/auth.py b/databricks-tools-core/databricks_tools_core/auth.py index 26cea848..c3db9fb4 100644 --- a/databricks-tools-core/databricks_tools_core/auth.py +++ b/databricks-tools-core/databricks_tools_core/auth.py @@ -160,28 +160,30 @@ def get_workspace_client() -> WorkspaceClient: # Cross-workspace: explicit token overrides env OAuth so tool operations # target the caller-specified workspace instead of the app's own workspace if force and host and token: - return tag_client(WorkspaceClient(host=host, token=token, **product_kwargs)) + return tag_client(WorkspaceClient(host=host, token=token, auth_type="pat", **product_kwargs)) - # In Databricks Apps (OAuth credentials in env), explicitly use OAuth M2M - # This prevents the SDK from detecting other auth methods like PAT or config file + # In Databricks Apps (OAuth credentials in env), explicitly use OAuth M2M. + # Setting auth_type="oauth-m2m" prevents the SDK from also reading + # DATABRICKS_TOKEN from os.environ and raising a "more than one + # authorization method configured" validation error. if _has_oauth_credentials(): oauth_host = host or os.environ.get("DATABRICKS_HOST", "") client_id = os.environ.get("DATABRICKS_CLIENT_ID", "") client_secret = os.environ.get("DATABRICKS_CLIENT_SECRET", "") - # Explicitly configure OAuth M2M to prevent auth conflicts return tag_client( WorkspaceClient( host=oauth_host, client_id=client_id, client_secret=client_secret, + auth_type="oauth-m2m", **product_kwargs, ) ) # Development mode: use explicit token if provided if host and token: - return tag_client(WorkspaceClient(host=host, token=token, **product_kwargs)) + return tag_client(WorkspaceClient(host=host, token=token, auth_type="pat", **product_kwargs)) if host: return tag_client(WorkspaceClient(host=host, **product_kwargs)) diff --git a/databricks-tools-core/tests/unit/test_sql.py b/databricks-tools-core/tests/unit/test_sql.py index 6e7b5653..42137ba5 100644 --- a/databricks-tools-core/tests/unit/test_sql.py +++ b/databricks-tools-core/tests/unit/test_sql.py @@ -3,10 +3,11 @@ from unittest import mock import pytest -from databricks.sdk.service.sql import StatementState +from databricks.sdk.service.sql import State, StatementState from databricks_tools_core.sql import execute_sql, execute_sql_multi from databricks_tools_core.sql.sql_utils import SQLExecutor +from databricks_tools_core.sql.warehouse import _sort_within_tier, get_best_warehouse class TestExecuteSQLQueryTags: @@ -118,3 +119,102 @@ def test_executor_without_query_tags_omits_from_api(self, mock_get_client): call_kwargs = mock_client.statement_execution.execute_statement.call_args.kwargs assert "query_tags" not in call_kwargs + + +def _make_warehouse(id, name, state, creator_name="other@example.com", enable_serverless_compute=False): + """Helper to create a mock warehouse object.""" + w = mock.Mock() + w.id = id + w.name = name + w.state = state + w.creator_name = creator_name + w.enable_serverless_compute = enable_serverless_compute + w.cluster_size = "Small" + w.auto_stop_mins = 10 + return w + + +class TestSortWithinTier: + """Tests for _sort_within_tier serverless and user-owned preference.""" + + def test_serverless_first(self): + """Serverless warehouses should come before classic ones.""" + classic = _make_warehouse("c1", "Classic WH", State.RUNNING) + serverless = _make_warehouse("s1", "Serverless WH", State.RUNNING, enable_serverless_compute=True) + result = _sort_within_tier([classic, serverless], current_user=None) + assert result[0].id == "s1" + assert result[1].id == "c1" + + def test_serverless_before_user_owned(self): + """Serverless should be preferred over user-owned classic.""" + classic_owned = _make_warehouse("c1", "My WH", State.RUNNING, creator_name="me@example.com") + serverless_other = _make_warehouse( + "s1", "Other WH", State.RUNNING, creator_name="other@example.com", enable_serverless_compute=True + ) + result = _sort_within_tier([classic_owned, serverless_other], current_user="me@example.com") + assert result[0].id == "s1" + + def test_serverless_user_owned_first(self): + """Among serverless, user-owned should come first.""" + serverless_other = _make_warehouse( + "s1", "Other Serverless", State.RUNNING, creator_name="other@example.com", enable_serverless_compute=True + ) + serverless_owned = _make_warehouse( + "s2", "My Serverless", State.RUNNING, creator_name="me@example.com", enable_serverless_compute=True + ) + result = _sort_within_tier([serverless_other, serverless_owned], current_user="me@example.com") + assert result[0].id == "s2" + assert result[1].id == "s1" + + def test_empty_list(self): + assert _sort_within_tier([], current_user="me@example.com") == [] + + def test_no_current_user(self): + """Without a current user, only serverless preference applies.""" + classic = _make_warehouse("c1", "Classic", State.RUNNING) + serverless = _make_warehouse("s1", "Serverless", State.RUNNING, enable_serverless_compute=True) + result = _sort_within_tier([classic, serverless], current_user=None) + assert result[0].id == "s1" + + +class TestGetBestWarehouseServerless: + """Tests for serverless preference in get_best_warehouse.""" + + @mock.patch("databricks_tools_core.sql.warehouse.get_current_username", return_value="me@example.com") + @mock.patch("databricks_tools_core.sql.warehouse.get_workspace_client") + def test_prefers_serverless_within_running_shared(self, mock_client_fn, mock_user): + """Among running shared warehouses, serverless should be picked.""" + classic_shared = _make_warehouse("c1", "Shared WH", State.RUNNING) + serverless_shared = _make_warehouse("s1", "Shared Serverless", State.RUNNING, enable_serverless_compute=True) + mock_client = mock.Mock() + mock_client.warehouses.list.return_value = [classic_shared, serverless_shared] + mock_client_fn.return_value = mock_client + + result = get_best_warehouse() + assert result == "s1" + + @mock.patch("databricks_tools_core.sql.warehouse.get_current_username", return_value="me@example.com") + @mock.patch("databricks_tools_core.sql.warehouse.get_workspace_client") + def test_prefers_serverless_within_running_other(self, mock_client_fn, mock_user): + """Among running non-shared warehouses, serverless should be picked.""" + classic = _make_warehouse("c1", "My WH", State.RUNNING) + serverless = _make_warehouse("s1", "Fast WH", State.RUNNING, enable_serverless_compute=True) + mock_client = mock.Mock() + mock_client.warehouses.list.return_value = [classic, serverless] + mock_client_fn.return_value = mock_client + + result = get_best_warehouse() + assert result == "s1" + + @mock.patch("databricks_tools_core.sql.warehouse.get_current_username", return_value="me@example.com") + @mock.patch("databricks_tools_core.sql.warehouse.get_workspace_client") + def test_tier_order_preserved_over_serverless(self, mock_client_fn, mock_user): + """A running shared classic should still beat a stopped serverless.""" + running_shared_classic = _make_warehouse("c1", "Shared WH", State.RUNNING) + stopped_serverless = _make_warehouse("s1", "Fast WH", State.STOPPED, enable_serverless_compute=True) + mock_client = mock.Mock() + mock_client.warehouses.list.return_value = [stopped_serverless, running_shared_classic] + mock_client_fn.return_value = mock_client + + result = get_best_warehouse() + assert result == "c1"