diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md
index 095152f9..a061b3d2 100644
--- a/docs/ABSTRACTIONS.md
+++ b/docs/ABSTRACTIONS.md
@@ -527,6 +527,11 @@ Per-vault settings stored locally and scoped by vault path:
- User can create a new empty vault, open an existing folder, or clone the public Getting Started vault into a chosen folder
- Welcome state tracked in localStorage (`tolaria_welcome_dismissed`, with legacy fallback)
+`useClaudeCodeOnboarding(enabled)` adds a separate first-launch agent step:
+- Reads a local dismissal flag for the Claude Code prompt
+- Only shows after vault onboarding has already resolved to a ready state
+- Persists dismissal locally once the user continues
+
### Remote Git Operations
Tolaria delegates remote auth to the user's system git setup:
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index b2e65530..25365075 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -432,6 +432,8 @@ On first launch, `useOnboarding` checks if the default vault exists. If not, it
- **Open an existing folder** → system file picker
- **Get started with a template** → pick a folder, then call `create_getting_started_vault()` to clone the public starter repo at runtime
+Once a vault is ready, `useClaudeCodeOnboarding` can show a one-time `ClaudeCodeOnboardingPrompt`. That prompt reuses `useClaudeCodeStatus` so first launch surfaces whether the `claude` CLI is installed, offers the install link when it is missing, and stores local dismissal so the prompt does not repeat on every launch.
+
The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` only holds the public starter repo URL and delegates the actual clone to the git backend.
### Remote Clone & Auth Model
diff --git a/src/App.test.tsx b/src/App.test.tsx
index 2052c785..1e0c7c34 100644
--- a/src/App.test.tsx
+++ b/src/App.test.tsx
@@ -123,6 +123,8 @@ vi.mock('@blocknote/mantine/style.css', () => ({}))
import App from './App'
+const CLAUDE_CODE_ONBOARDING_DISMISSED_KEY = 'tolaria:claude-code-onboarding-dismissed'
+
describe('App', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -133,6 +135,7 @@ describe('App', () => {
localStorage.removeItem('laputa-view-mode')
localStorage.removeItem('tolaria_welcome_dismissed')
localStorage.removeItem('laputa_welcome_dismissed')
+ localStorage.setItem(CLAUDE_CODE_ONBOARDING_DISMISSED_KEY, '1')
})
it('renders the four-panel layout', async () => {
diff --git a/src/App.tsx b/src/App.tsx
index 06c8f3e5..ba781798 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -16,10 +16,12 @@ import { StatusBar } from './components/StatusBar'
import { SettingsPanel } from './components/SettingsPanel'
import { CloneVaultModal } from './components/CloneVaultModal'
import { WelcomeScreen } from './components/WelcomeScreen'
+import { ClaudeCodeOnboardingPrompt } from './components/ClaudeCodeOnboardingPrompt'
import { TelemetryConsentDialog } from './components/TelemetryConsentDialog'
import { FeedbackDialog } from './components/FeedbackDialog'
import { useTelemetry } from './hooks/useTelemetry'
import { useMcpStatus } from './hooks/useMcpStatus'
+import { useClaudeCodeOnboarding } from './hooks/useClaudeCodeOnboarding'
import { useClaudeCodeStatus } from './hooks/useClaudeCodeStatus'
import { useVaultLoader } from './hooks/useVaultLoader'
import { useSettings } from './hooks/useSettings'
@@ -124,6 +126,8 @@ function App() {
})
const onboarding = useOnboarding(vaultSwitcher.vaultPath)
+ const { status: claudeCodeStatus, version: claudeCodeVersion } = useClaudeCodeStatus()
+ const claudeCodeOnboarding = useClaudeCodeOnboarding(onboarding.state.status === 'ready' && !noteWindowParams)
// When onboarding resolves to a different vault path, update the switcher
const resolvedPath = noteWindowParams?.vaultPath ?? (onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath)
@@ -174,7 +178,6 @@ function App() {
}
}, [vault.entries.length, gitRepoState, resolvedPath])
const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage)
- const { status: claudeCodeStatus, version: claudeCodeVersion } = useClaudeCodeStatus()
const autoSync = useAutoSync({
vaultPath: resolvedPath,
@@ -640,6 +643,15 @@ function App() {
return
}
+ if (!noteWindowParams && onboarding.state.status === 'ready' && claudeCodeOnboarding.showPrompt) {
+ return (
+
+ )
+ }
+
// Show git-required modal when vault has no git repo (skip for note windows)
if (!noteWindowParams && gitRepoState === 'required') {
return (
@@ -821,6 +833,20 @@ function WelcomeView({ onboarding, isOffline }: { onboarding: OnboardingState; i
)
}
+function ClaudeCodeOnboardingView({
+ status,
+ onContinue,
+}: {
+ status: ReturnType['status']
+ onContinue: () => void
+}) {
+ return (
+
+
+
+ )
+}
+
/** Loading spinner view - extracted from main App component */
function LoadingView() {
return (
diff --git a/src/components/ClaudeCodeOnboardingPrompt.test.tsx b/src/components/ClaudeCodeOnboardingPrompt.test.tsx
new file mode 100644
index 00000000..eaa08062
--- /dev/null
+++ b/src/components/ClaudeCodeOnboardingPrompt.test.tsx
@@ -0,0 +1,56 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ClaudeCodeOnboardingPrompt } from './ClaudeCodeOnboardingPrompt'
+
+const openExternalUrl = vi.fn()
+
+vi.mock('../utils/url', () => ({
+ openExternalUrl: (...args: unknown[]) => openExternalUrl(...args),
+}))
+
+describe('ClaudeCodeOnboardingPrompt', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('shows the detected state with a continue action', () => {
+ render()
+
+ expect(screen.getByText('Claude Code detected')).toBeInTheDocument()
+ expect(screen.getByTestId('claude-onboarding-continue')).toHaveTextContent('Continue')
+ expect(screen.queryByTestId('claude-onboarding-install')).not.toBeInTheDocument()
+ })
+
+ it('shows the install path when Claude Code is missing', () => {
+ render()
+
+ expect(screen.getByText('Claude Code not detected')).toBeInTheDocument()
+ expect(screen.getByText('Install Claude Code to enable AI-powered note management.')).toBeInTheDocument()
+ expect(screen.getByTestId('claude-onboarding-install')).toBeInTheDocument()
+ expect(screen.getByTestId('claude-onboarding-continue')).toHaveTextContent('Continue without it')
+ })
+
+ it('opens the Claude Code install page', () => {
+ render()
+
+ fireEvent.click(screen.getByTestId('claude-onboarding-install'))
+
+ expect(openExternalUrl).toHaveBeenCalledWith('https://docs.anthropic.com/en/docs/claude-code')
+ })
+
+ it('calls onContinue from the detected state', () => {
+ const onContinue = vi.fn()
+ render()
+
+ fireEvent.click(screen.getByTestId('claude-onboarding-continue'))
+
+ expect(onContinue).toHaveBeenCalledOnce()
+ })
+
+ it('disables continue while detection is still running', () => {
+ render()
+
+ expect(screen.getByText('Checking for Claude Code')).toBeInTheDocument()
+ expect(screen.getByTestId('claude-onboarding-continue')).toBeDisabled()
+ })
+})
diff --git a/src/components/ClaudeCodeOnboardingPrompt.tsx b/src/components/ClaudeCodeOnboardingPrompt.tsx
new file mode 100644
index 00000000..a5244081
--- /dev/null
+++ b/src/components/ClaudeCodeOnboardingPrompt.tsx
@@ -0,0 +1,101 @@
+import { ArrowUpRight, Bot, CheckCircle2, Loader2 } from 'lucide-react'
+import type { ClaudeCodeStatus } from '../hooks/useClaudeCodeStatus'
+import { openExternalUrl } from '../utils/url'
+import { Button } from './ui/button'
+import { Card, CardContent, CardFooter, CardHeader, CardTitle } from './ui/card'
+
+const CLAUDE_CODE_INSTALL_URL = 'https://docs.anthropic.com/en/docs/claude-code'
+
+interface ClaudeCodeOnboardingPromptProps {
+ status: ClaudeCodeStatus
+ onContinue: () => void
+}
+
+function getPromptCopy(status: ClaudeCodeStatus) {
+ if (status === 'installed') {
+ return {
+ accentClassName: 'bg-emerald-100 text-emerald-700',
+ description: "Tolaria's AI features are ready to use.",
+ icon: ,
+ title: 'Claude Code detected',
+ }
+ }
+
+ if (status === 'missing') {
+ return {
+ accentClassName: 'bg-amber-100 text-amber-700',
+ description: 'Tolaria works best with an AI coding agent installed.',
+ icon: ,
+ title: 'Claude Code not detected',
+ }
+ }
+
+ return {
+ accentClassName: 'bg-slate-100 text-slate-600',
+ description: 'Checking whether Claude Code is available on this machine.',
+ icon: ,
+ title: 'Checking for Claude Code',
+ }
+}
+
+export function ClaudeCodeOnboardingPrompt({
+ status,
+ onContinue,
+}: ClaudeCodeOnboardingPromptProps) {
+ const copy = getPromptCopy(status)
+
+ return (
+