From 27ee2d93b2136cbd31513fbb3e0531608940728c Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Wed, 3 Jun 2026 11:54:00 +0100 Subject: [PATCH] feat(dashboard): add "Connect Claude Code to cix" onboarding card to home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a collapsible step-by-step card on the dashboard home page that walks a new user from zero to seeing the cix skills in Claude Code: 1. Install the cix CLI (curl installer) 2. Connect the CLI to this server (links to API Keys, shows the live per-host connect command) 3. Add the plugin marketplace + install in Claude Code 4. (Optional) index a local project — skippable for workspace / external- project-only users; cix status alone still verifies the connection 5. See the cix slash commands / skills appear Collapse state persists in localStorage (expanded by default). Commands use one-click copy buttons with the secure-context clipboard + execCommand fallback for HTTP deploys. Extract the per-host alias / connect-command derivation into lib/cixServer.ts (cixServerAlias, cixConnectCommand) and reuse it in both the card and the API-key popup so the two never drift. Add a reusable lib/useCopy hook for the new components. Co-Authored-By: Claude Opus 4.8 --- server/dashboard/src/lib/cixServer.ts | 31 +++ server/dashboard/src/lib/useCopy.ts | 67 ++++++ .../components/CreateApiKeyDialog.tsx | 31 +-- .../src/modules/home/CommandBlock.tsx | 38 +++ .../modules/home/ConnectClaudeCodeCard.tsx | 216 ++++++++++++++++++ .../dashboard/src/modules/home/HomePage.tsx | 3 + 6 files changed, 363 insertions(+), 23 deletions(-) create mode 100644 server/dashboard/src/lib/cixServer.ts create mode 100644 server/dashboard/src/lib/useCopy.ts create mode 100644 server/dashboard/src/modules/home/CommandBlock.tsx create mode 100644 server/dashboard/src/modules/home/ConnectClaudeCodeCard.tsx diff --git a/server/dashboard/src/lib/cixServer.ts b/server/dashboard/src/lib/cixServer.ts new file mode 100644 index 0000000..f7a8d04 --- /dev/null +++ b/server/dashboard/src/lib/cixServer.ts @@ -0,0 +1,31 @@ +// Derive a cix CLI server alias from the browser host. The CLI stores each +// server as one entry under `server.` and parses that config key by +// splitting on dots, so the alias must be dot-free (and whitespace-free, and +// non-empty) — see validateServerName / parseServerKey in the CLI. We fold the +// host (including any non-default port, so distinct ports get distinct aliases) +// to [a-z0-9-]: "cix.example.com" -> "cix-example-com", +// "localhost:21847" -> "localhost-21847". +// +// Shared by the API-key "Connect the cix CLI" popup and the home-page +// onboarding card so the two never drift. +export function cixServerAlias(host: string): string { + const alias = host + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + return alias || 'default'; +} + +// Build the one-paste command that registers a server (URL + key as a single +// `server.` entry) in the cix CLI config and makes it the default. +// Values are shell-safe (a URL, a `cix_` key, an [a-z0-9-] alias), so no +// quoting is needed — matching the CLI README's unquoted examples. `key` +// defaults to a `` placeholder for previews where no secret is revealed. +export function cixConnectCommand(origin: string, host: string, key = ''): string { + const alias = cixServerAlias(host); + return ( + `cix config set server.${alias}.url ${origin} && ` + + `cix config set server.${alias}.key ${key} && ` + + `cix config set default_server ${alias}` + ); +} diff --git a/server/dashboard/src/lib/useCopy.ts b/server/dashboard/src/lib/useCopy.ts new file mode 100644 index 0000000..fa9db82 --- /dev/null +++ b/server/dashboard/src/lib/useCopy.ts @@ -0,0 +1,67 @@ +import { useCallback, useRef, useState } from 'react'; +import { toast } from 'sonner'; + +// Last-resort copy when the async Clipboard API isn't available — happens on +// plain HTTP deploys (non-localhost) and inside some embedded webviews. +// document.execCommand('copy') is deprecated but universally implemented as of +// 2026; keeping it as a fallback turns "no way to copy" into "always works". +function legacyCopy(text: string): boolean { + if (typeof document === 'undefined') return false; + const ta = document.createElement('textarea'); + ta.value = text; + ta.setAttribute('readonly', ''); + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + let ok = false; + try { + ok = document.execCommand('copy'); + } catch { + ok = false; + } + document.body.removeChild(ta); + return ok; +} + +// useCopy — one-click clipboard with a transient "copied" flag and graceful +// fallback. navigator.clipboard requires a secure context (HTTPS or localhost); +// on bare-IP / HTTP deploys it throws, so we fall back to document.execCommand +// through a transient textarea. On total failure we surface a toast telling the +// user to copy manually. `copied` flips true for `resetMs` after a success so +// callers can show a checkmark without re-implementing the timer. +export function useCopy(resetMs = 2000): { + copied: boolean; + copy: (text: string) => Promise; +} { + const [copied, setCopied] = useState(false); + const timer = useRef(null); + + const copy = useCallback( + async (text: string): Promise => { + let ok = false; + try { + if (window.isSecureContext && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + ok = true; + } else { + ok = legacyCopy(text); + if (!ok) throw new Error('legacy copy failed'); + } + } catch { + toast.error('Could not copy automatically.', { + description: 'Select the text, ⌘A / Ctrl-A, then copy.', + }); + return false; + } + setCopied(true); + if (timer.current) window.clearTimeout(timer.current); + timer.current = window.setTimeout(() => setCopied(false), resetMs); + return ok; + }, + [resetMs] + ); + + return { copied, copy }; +} diff --git a/server/dashboard/src/modules/api-keys/components/CreateApiKeyDialog.tsx b/server/dashboard/src/modules/api-keys/components/CreateApiKeyDialog.tsx index 8ad90fc..0f05d8c 100644 --- a/server/dashboard/src/modules/api-keys/components/CreateApiKeyDialog.tsx +++ b/server/dashboard/src/modules/api-keys/components/CreateApiKeyDialog.tsx @@ -15,6 +15,7 @@ import { } from '@/ui/dialog'; import { Input } from '@/ui/input'; import { Label } from '@/ui/label'; +import { cixConnectCommand } from '@/lib/cixServer'; import { useCreateApiKey } from '../hooks'; // Last-resort copy when the async Clipboard API isn't available — happens @@ -41,21 +42,6 @@ function legacyCopy(text: string): boolean { return ok; } -// Derive a cix CLI server alias from the browser host. The CLI stores each -// server as one entry under `server.` and parses that config key by -// splitting on dots, so the alias must be dot-free (and whitespace-free, and -// non-empty) — see validateServerName / parseServerKey in the CLI. We fold the -// host (including any non-default port, so distinct ports get distinct aliases) -// to [a-z0-9-]: "cix.example.com" -> "cix-example-com", -// "localhost:21847" -> "localhost-21847". -function cixServerAlias(host: string): string { - const alias = host - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); - return alias || 'default'; -} - // Two-stage dialog: collect a name, then reveal the freshly minted key once. // Once revealed, the dialog refuses outside-click and Escape — accidental // dismissal would lose the unrecoverable secret. Only the explicit "I've @@ -121,14 +107,13 @@ export function CreateApiKeyDialog() { // One-paste command that registers THIS server (URL + the freshly minted key, // as a single `server.` entry) in the cix CLI config and makes it the // default. The dashboard is served same-origin from cix-server, so - // window.location.origin is exactly the base URL the CLI must talk to. Values - // are shell-safe (a URL, a `cix_` key, an [a-z0-9-] alias), so no quoting - // is needed — matching the CLI README's unquoted examples. - const alias = cixServerAlias(window.location.host); - const connectCmd = - `cix config set server.${alias}.url ${window.location.origin} && ` + - `cix config set server.${alias}.key ${revealed ?? ''} && ` + - `cix config set default_server ${alias}`; + // window.location.origin is exactly the base URL the CLI must talk to. Shared + // with the home-page onboarding card via cixConnectCommand so they never drift. + const connectCmd = cixConnectCommand( + window.location.origin, + window.location.host, + revealed ?? '' + ); async function copyKey() { if (!revealed) return; diff --git a/server/dashboard/src/modules/home/CommandBlock.tsx b/server/dashboard/src/modules/home/CommandBlock.tsx new file mode 100644 index 0000000..420c7e6 --- /dev/null +++ b/server/dashboard/src/modules/home/CommandBlock.tsx @@ -0,0 +1,38 @@ +import { Check, Copy } from 'lucide-react'; +import { Button } from '@/ui/button'; +import { useCopy } from '@/lib/useCopy'; + +// A read-only, wrapping mono command box with a one-click Copy button — same +// look as the API-key popup's connect-command block. `command` is the literal +// text copied to the clipboard; render it verbatim so what the user sees is +// exactly what they paste. +export function CommandBlock({ command }: { command: string }) { + const { copied, copy } = useCopy(); + return ( +
+
+        {command}
+      
+ +
+ ); +} diff --git a/server/dashboard/src/modules/home/ConnectClaudeCodeCard.tsx b/server/dashboard/src/modules/home/ConnectClaudeCodeCard.tsx new file mode 100644 index 0000000..bbca91b --- /dev/null +++ b/server/dashboard/src/modules/home/ConnectClaudeCodeCard.tsx @@ -0,0 +1,216 @@ +import { useState, type ReactNode } from 'react'; +import { Link } from 'react-router-dom'; +import { ChevronDown, ChevronRight, KeyRound, Sparkles } from 'lucide-react'; +import { Card } from '@/ui/card'; +import { Badge } from '@/ui/badge'; +import { cn } from '@/lib/cn'; +import { cixConnectCommand } from '@/lib/cixServer'; +import { CommandBlock } from './CommandBlock'; + +// Collapse state lives in localStorage so a returning user who dismissed the +// onboarding card keeps it dismissed. Expanded by default (key absent → false). +const COLLAPSE_KEY = 'cix.home.connect-claude.collapsed'; + +function readCollapsed(): boolean { + if (typeof window === 'undefined') return false; + try { + return window.localStorage.getItem(COLLAPSE_KEY) === '1'; + } catch { + return false; + } +} + +function writeCollapsed(v: boolean): void { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(COLLAPSE_KEY, v ? '1' : '0'); + } catch { + /* swallow — privacy mode; state just resets on reload */ + } +} + +// The cix CLI one-line installer (README §3 "Install the CLI"). +const INSTALL_CMD = + 'curl -fsSL https://raw.githubusercontent.com/dvcdsys/code-index/main/install.sh | bash'; + +// Claude Code marketplace + plugin install (README "Agent Integration"). +const PLUGIN_CMDS = [ + '/plugin marketplace add dvcdsys/code-index', + '/plugin install cix@code-index', + '/reload-plugins', +].join('\n'); + +// Optional local-project indexing + connection check. +const INDEX_CMDS = ['cd /path/to/your/project', 'cix init', 'cix status # wait for: Status: ✓ Indexed'].join( + '\n' +); + +// User-facing skills/commands the plugin exposes — the "success" checklist. +const SKILL_COMMANDS = [ + '/cix:search', + '/cix:def', + '/cix:refs', + '/cix:init', + '/cix:status', + '/cix:summary', +]; + +export function ConnectClaudeCodeCard() { + const [collapsed, setCollapsed] = useState(readCollapsed); + + function toggle() { + setCollapsed((prev) => { + const next = !prev; + writeCollapsed(next); + return next; + }); + } + + // Live, per-host connect command so the alias + URL match this exact + // deployment — the same derivation the API-key popup uses. The real key is + // shown once in that popup; here we keep the `` placeholder. + const connectCmd = cixConnectCommand(window.location.origin, window.location.host); + + return ( + + + + {!collapsed && ( +
+ + + + + + Open the{' '} + + + API Keys + {' '} + page, create a key, then copy the “Connect the cix CLI” command from the popup. + It looks like this for this server (with your real key filled in): + + } + > + + + + + + + + Optional} + hint={ + <> + Skip this if you only work with{' '} + + workspaces + {' '} + or server-side{' '} + + external projects + + . cix status alone + still confirms the CLI reached the server. + + } + > + + + + +
+ {SKILL_COMMANDS.map((c) => ( + + {c} + + ))} +
+
+
+ )} +
+ ); +} + +function Step({ + n, + title, + hint, + badge, + children, +}: { + n: number; + title: string; + hint: ReactNode; + badge?: ReactNode; + children: ReactNode; +}) { + return ( +
+
+ {n} +
+
+
+

{title}

+ {badge} +
+

{hint}

+ {children} +
+
+ ); +} diff --git a/server/dashboard/src/modules/home/HomePage.tsx b/server/dashboard/src/modules/home/HomePage.tsx index b119798..b20866b 100644 --- a/server/dashboard/src/modules/home/HomePage.tsx +++ b/server/dashboard/src/modules/home/HomePage.tsx @@ -4,6 +4,7 @@ import { useServerStatus } from '@/lib/useServerStatus'; import { Card, CardDescription, CardHeader, CardTitle } from '@/ui/card'; import { cn } from '@/lib/cn'; import { MODULES } from '../registry'; +import { ConnectClaudeCodeCard } from './ConnectClaudeCodeCard'; // One-line pitch per module — kept here (not on the Module type) so the // sidebar stays terse and only the landing page carries the prose. @@ -54,6 +55,8 @@ export default function HomePage() { )} + +

Modules