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/ diff --git a/databricks-tools-core/databricks_tools_core/auth.py b/databricks-tools-core/databricks_tools_core/auth.py index 21913983..c3db9fb4 100644 --- a/databricks-tools-core/databricks_tools_core/auth.py +++ b/databricks-tools-core/databricks_tools_core/auth.py @@ -160,9 +160,7 @@ 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, auth_type="pat", **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. # Setting auth_type="oauth-m2m" prevents the SDK from also reading @@ -185,9 +183,7 @@ def get_workspace_client() -> WorkspaceClient: # Development mode: use explicit token if provided if host and token: - return tag_client( - WorkspaceClient(host=host, token=token, auth_type="pat", **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 d1b661c6..42137ba5 100644 --- a/databricks-tools-core/tests/unit/test_sql.py +++ b/databricks-tools-core/tests/unit/test_sql.py @@ -121,8 +121,7 @@ def test_executor_without_query_tags_omits_from_api(self, mock_get_client): assert "query_tags" not in call_kwargs -def _make_warehouse(id, name, state, creator_name="other@example.com", - enable_serverless_compute=False): +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 @@ -141,33 +140,29 @@ class TestSortWithinTier: 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) + 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") + 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") + 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" @@ -177,8 +172,7 @@ def test_empty_list(self): 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) + 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" @@ -186,14 +180,12 @@ def test_no_current_user(self): 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_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) + 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 @@ -201,14 +193,12 @@ def test_prefers_serverless_within_running_shared(self, mock_client_fn, mock_use 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_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) + 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 @@ -216,14 +206,12 @@ def test_prefers_serverless_within_running_other(self, mock_client_fn, mock_user 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_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) + 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 diff --git a/install.ps1 b/install.ps1 index eecc5ab1..9b1d9db4 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 "" @@ -644,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 { @@ -655,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 } } @@ -734,6 +837,304 @@ 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 (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) { + 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 + + # 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 + + $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 + } + + if ($selected -match '\ball\b') { + $script:SkillsProfile = "all" + return + } + + if ($selected -match '\bcustom\b') { + Invoke-PromptCustomSkills -PreselectedProfiles $selected + return + } + + $script:SkillsProfile = ($selectedProfiles -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,67 +1157,127 @@ 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" + + # 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 + # 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 } + $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 } # 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 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 -> $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 {} + } + $manifestEntries += "$dir|$skill" + } 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 {} + } + $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" } - } 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 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: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:StateDir ".skills-profile") -Value $profileValue -Encoding UTF8 } } @@ -1322,6 +1783,27 @@ 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" + 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 +1822,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..e83b5df8 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,287 @@ 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 (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") + 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}" + + # 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 + 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 @@ -624,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 @@ -705,49 +1101,101 @@ 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" + + # 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 + # 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 + # 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 (always write to scope-local state dir) + manifest="$STATE_DIR/.installed-skills" + mkdir -p "$STATE_DIR" + : > "$manifest.tmp" + 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" + echo "$dir|$skill" >> "$manifest.tmp" 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 + echo "$dir|$skill" >> "$manifest.tmp" + 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 + 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 + done + ok "APX skills ($apx_count) → ${dir#$HOME/}" + fi done + + # Save manifest of installed skills (for cleanup on profile change) + mv "$manifest.tmp" "$manifest" + + # Save selected profile for future reinstalls (scope-local) + if [ -n "$USER_SKILLS" ]; then + echo "custom:$USER_SKILLS" > "$STATE_DIR/.skills-profile" + else + echo "${SKILLS_PROFILE:-all}" > "$STATE_DIR/.skills-profile" + fi } # Write MCP configs @@ -1201,13 +1649,35 @@ main() { ok "Scope: $SCOPE" fi - # ── Step 4: Interactive MCP path ── + # 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" + 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 +1686,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 +1709,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