diff --git a/apps/desktop/src/components/onboarding/activate-step.tsx b/apps/desktop/src/components/onboarding/activate-step.tsx deleted file mode 100644 index e23b98fc..00000000 --- a/apps/desktop/src/components/onboarding/activate-step.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { format } from "date-fns"; -import { CircleCheck, Gift } from "lucide-react"; -import type { LicenseState } from "@godmode/shared"; -import { ActivatePanel, KeyDivider, LicenseKeyForm } from "@/components/license/activate"; -import { Button } from "@/components/ui/button"; -import { useLicense } from "@/lib/hooks"; -import { SubmitButton } from "./auth-layout"; -import { StepCard, StepFooter, StepHeader } from "./step-kit"; - -const READY: LicenseState["status"][] = ["active", "trial", "past_due", "unverified"]; - -/** Start the trial or enter a key. Skippable while nothing is refused yet (free week, builds from source). */ -export function ActivateStep({ onBack, onDone }: { onBack: () => void; onDone: (state: LicenseState | null) => void }) { - const { data: state } = useLicense(); - const ready = !!state && !!state.keyHint && READY.includes(state.status) && !state.blocked; - const skippable = !!state && !state.blocked; - const freeUntil = state?.enforced && state.status === "grace" && state.graceEndsAt ? new Date(state.graceEndsAt) : null; - - if (freeUntil) { - return ( -
- - -
- - - Free until {format(freeUntil, "EEEE, MMMM d")}. - -
- - - onDone(next)} /> - - - onDone(state ?? null)} className="w-auto px-6"> - Continue - - -
-
- ); - } - - return ( -
- - - {ready ? ( -
- - - {state.status === "trial" && state.trialEndsAt - ? `Your free trial runs until ${format(new Date(state.trialEndsAt), "MMMM d")}.` - : "Godmode is activated on this computer."} - - …{state.keyHint} -
- ) : ( - onDone(next)} /> - )} - - - {ready ? ( - onDone(state)} className="w-auto px-6"> - Continue - - ) : ( - skippable && ( - - ) - )} - -
-
- ); -} diff --git a/apps/desktop/src/components/onboarding/browser-step.tsx b/apps/desktop/src/components/onboarding/browser-step.tsx deleted file mode 100644 index ef391c29..00000000 --- a/apps/desktop/src/components/onboarding/browser-step.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import { useState } from "react"; -import { useMutation, useQuery } from "@tanstack/react-query"; -import { AnimatePresence, motion } from "motion/react"; -import { AppWindow, CircleCheck, Cookie, Info, UserRound } from "lucide-react"; -import type { ChromeImportResult, LocalChromeProfile } from "@godmode/shared"; -import { Button } from "@/components/ui/button"; -import { Skeleton } from "@/components/ui/skeleton"; -import { api, errorMessage } from "@/lib/api"; -import { isMac } from "@/lib/desktop"; -import { qk } from "@/lib/queryKeys"; -import { cn } from "@/lib/utils"; -import { NoChromeProfiles } from "@/components/browser/no-chrome-profiles"; -import { DrawCheck } from "@/components/aicss/Motion"; -import { FormError, SubmitButton } from "./auth-layout"; -import { StepCard, StepFooter, StepHeader } from "./step-kit"; - -export function BrowserStep({ onDone, onBack }: { onDone: (imported: number | null) => void; onBack: () => void }) { - const chrome = useQuery({ queryKey: qk.chromeProfiles, queryFn: api.browser.chromeProfiles, retry: false }); - const [selected, setSelected] = useState(null); - const [result, setResult] = useState(null); - - const importMut = useMutation({ - mutationFn: async (profile: LocalChromeProfile) => { - const profiles = await api.browser.profiles(); - const target = profiles.find((p) => p.isDefault) ?? profiles.find((p) => !p.workspaceId) ?? profiles[0]; - if (!target) throw new Error("No Godmode browser profile exists yet — try again from the Browser page later."); - return api.browser.import(target.id, { sourcePath: profile.path }); - }, - onSuccess: setResult, - }); - - const profiles = chrome.data ?? []; - const chosen = profiles.find((p) => p.path === selected) ?? null; - - return ( -
- - - - {result ? ( - -
- - - -
-

- Imported {result.imported.toLocaleString()} cookies from {result.domains.length.toLocaleString()} sites -

- {result.skipped > 0 &&

{result.skipped.toLocaleString()} expired or unsupported cookies skipped.

} -
-
- {result.domains.length > 0 && ( -
- {result.domains.slice(0, 18).map((d) => ( - - {d} - - ))} - {result.domains.length > 18 && +{result.domains.length - 18} more} -
- )} -
- ) : ( - - {chrome.isLoading ? ( -
- - -
- ) : chrome.isError ? ( -
- -
-

Couldn't look for Chrome profiles

-

{errorMessage(chrome.error)} You can import sessions later from the Browser page.

-
-
- ) : profiles.length === 0 ? ( - chrome.refetch()} retrying={chrome.isFetching} footer="Or skip — you can import sessions later from the Browser page." /> - ) : ( -
- {profiles.map((p, i) => { - const active = p.path === selected; - return ( - setSelected(p.path)} - className={cn( - "flex items-center gap-3 rounded-lg border bg-card p-3.5 text-left transition hover:border-foreground/20 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none", - active && "border-foreground/40 bg-paper-2 ring-[3px] ring-foreground/[0.06]", - )} - > -
- -
-
-

{p.name}

-

- {p.browser} - {p.email ? ` · ${p.email}` : ` · ${p.profileDir}`} -

-
- {active && } -
- ); - })} -
- )} -
- - - Cookies are copied into Godmode's browser on this machine and never uploaded. - {isMac && " macOS may ask for Keychain access — allow it so Chrome's cookies can be decrypted."} - -
- {importMut.isError && ( -
- -
- )} -
- )} -
- - - {result ? ( - onDone(result.imported)} className="w-auto px-6"> - Continue - - ) : ( - <> - - chosen && importMut.mutate(chosen)} - className="w-auto px-6" - > - {importMut.isPending ? "Importing…" : "Import sessions"} - - - )} - -
-
- ); -} diff --git a/apps/desktop/src/components/onboarding/claude-sign-in.tsx b/apps/desktop/src/components/onboarding/claude-sign-in.tsx index 858085cf..48b3e102 100644 --- a/apps/desktop/src/components/onboarding/claude-sign-in.tsx +++ b/apps/desktop/src/components/onboarding/claude-sign-in.tsx @@ -1,11 +1,12 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useId, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ExternalLink } from "lucide-react"; +import { Check, Copy, ExternalLink } from "lucide-react"; import { toast } from "sonner"; import type { ClaudeLoginStatus } from "@godmode/shared"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Spinner } from "@/components/ui/spinner"; +import { writeClipboard } from "@/components/vault/clipboard"; import { api, errorMessage } from "@/lib/api"; import { cloudContext, isTauri } from "@/lib/core"; import { openExternal } from "@/lib/desktop"; @@ -20,6 +21,23 @@ export const signsInFromAfar = !!cloudContext || (!isTauri && typeof window !== /** Who may sign Claude in: on Godmode Cloud only the computer's owner. */ export const maySignIn = !cloudContext || cloudContext.role === "owner"; +/** + * Puts the sign-in link on the clipboard. On this computer the core writes it: the desktop webview only lets the page + * copy during a click, and the link arrives seconds after it. From afar only this device's clipboard is of any use. + */ +async function copySignInLink(url: string, clicked: boolean): Promise { + const viaCore = async () => !signsInFromAfar && !!(await api.doctor.copyClaudeLoginLink().catch(() => null))?.ok; + const viaPage = () => + writeClipboard(url).then( + () => true, + () => false, + ); + return clicked ? (await viaPage()) || viaCore() : (await viaCore()) || viaPage(); +} + +/** The last link copied without a click: every hook on screen sees it arrive, one copy is enough. */ +let autoCopied: string | null = null; + /** * Claude Code's sign-in on this computer: its state (also whether runs found it signed out) and the steps to sign in. * `onSignedIn` runs when a sign-in finishes while the component is on screen, wherever it was started. @@ -71,80 +89,118 @@ export function useClaudeLogin({ onSignedIn }: { onSignedIn?: () => void } = {}) prev.current = state; }, [state, onSignedIn]); - return { status: data, state, mine: mine && state === "waiting", start, cancel, submit }; + // The sign-in this component started copies its link as soon as it arrives. + const url = state === "waiting" ? (data?.url ?? null) : null; + const [copied, setCopied] = useState<{ url: string; ok: boolean } | null>(null); + const ownsLink = mine && state === "waiting"; + useEffect(() => { + if (!ownsLink || !url || autoCopied === url) return; + autoCopied = url; + void copySignInLink(url, false).then((ok) => setCopied({ url, ok })); + }, [ownsLink, url]); + const copy = async () => { + if (!url) return; + const ok = await copySignInLink(url, true); + setCopied({ url, ok }); + if (ok) toast.success("Sign-in link copied", { id: "claude-link-copied" }); + else toast.error("Could not copy the link", { description: "Open the sign-in page instead." }); + }; + const link = { url, copied: copied?.url === url ? copied.ok : null, copy }; + + return { status: data, state, mine: ownsLink, start, cancel, submit, link }; } export type ClaudeLogin = ReturnType; -/** While the sign-in runs: finish it in the browser, or (no browser window, a phone) open the page and paste its code. */ +/** + * While the sign-in runs: approve it in the browser that opened. The link is on the clipboard for when no window + * opened (or on a phone): that page shows a code to paste back here. + */ export function ClaudeSignInSteps({ login, className }: { login: ClaudeLogin; className?: string }) { + const id = useId(); const [code, setCode] = useState(""); - const [manual, setManual] = useState(signsInFromAfar); - const url = login.status?.url ?? null; + const { url, copied, copy } = login.link; const send = () => { if (!code.trim()) return; login.submit.mutate(code.trim(), { onSuccess: () => setCode("") }); }; const openPage = () => { - if (!url) return; - setManual(true); - void openExternal(url); + if (url) void openExternal(url); }; return ( -
-
+
+
{signsInFromAfar ? "Sign in on this device" : "Finish signing in in your browser"} -
- {signsInFromAfar ? ( -
    -
  1. - -
  2. -
  3. - Sign in with your Claude account and click Authorize. -
  4. -
  5. Paste the code the page shows:
  6. -
- ) : ( -

- Sign in with your Claude account and click Authorize. This updates by itself. - {url && !manual && ( - <> - {" "} - - - )} -

- )} - {manual && ( +

+ {signsInFromAfar ? "Open the sign-in page, sign in with your Claude account and click " : "Sign in with your Claude account and click "} + Authorize.{signsInFromAfar ? " Then paste the code it shows below." : " This updates by itself."} +

+ +
+ {!url ? ( +

+ Getting the sign-in link… +

+ ) : ( + <> + {copied ? ( +

+ + + Sign-in link copied.{" "} + + {signsInFromAfar ? "Paste it into a browser on this device if the page did not open." : "No browser window opened? Paste it into your browser."} + + +

+ ) : ( +

+ {signsInFromAfar ? "Open the page here, or copy the link into another browser." : "No browser window opened? Open the page, or copy the link into your browser."} +

+ )} +
+ + +
+ + )} +
+ + {url && (
{ e.preventDefault(); send(); }} > - setCode(e.target.value)} - placeholder="Paste the code" - aria-label="Code from the Claude sign-in page" - className="h-8 bg-card font-mono text-xs" - autoComplete="off" - spellCheck={false} - /> - + +
+ setCode(e.target.value)} + placeholder="Paste the code" + className="h-8 bg-card font-mono text-xs" + autoComplete="off" + spellCheck={false} + /> + +
)}
diff --git a/apps/desktop/src/components/onboarding/doctor-checklist.tsx b/apps/desktop/src/components/onboarding/doctor-checklist.tsx index 073ecb10..3eabeeac 100644 --- a/apps/desktop/src/components/onboarding/doctor-checklist.tsx +++ b/apps/desktop/src/components/onboarding/doctor-checklist.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AnimatePresence, motion } from "motion/react"; import { ChevronDown, CircleAlert, CircleCheck, CircleX, Download, LogIn, RefreshCw, Terminal } from "lucide-react"; @@ -26,9 +26,6 @@ const DEFAULT_HINTS: Partial> = { git: "Optional — agent memory is versioned with a built-in git implementation.", }; -/** Installed one after another without a click (uv before what needs it). */ -const AUTO_INSTALL: DependencyId[] = ["claude", "uv", "browser-use", "chrome"]; - export function useDoctor() { const qc = useQueryClient(); const query = useQuery({ queryKey: qk.doctor, queryFn: () => api.doctor.get(false), staleTime: 60_000 }); @@ -40,28 +37,20 @@ export function useDoctor() { return { ...query, refresh }; } -/** - * Dependency checklist (claude, claude-auth, uv, browser-use, chrome, git) with one-click installs. - * Used by onboarding (system check step) and Settings → System. - * With `auto`, missing required tools install by themselves and the Claude sign-in starts once Claude Code is there. - */ +/** Dependency checklist (claude, claude-auth, uv, browser-use, chrome, git) with one-click installs, for Settings → System. */ export function DoctorChecklist({ ids, hints, className, onReport, - auto = false, }: { /** Order + filter. Defaults to every dependency in the report. */ ids?: DependencyId[]; hints?: Partial>; className?: string; onReport?: (report: DoctorReport) => void; - auto?: boolean; }) { const { data, isLoading, isError, error, refresh, refetch } = useDoctor(); - const [tried, setTried] = useState([]); - const attempt = (id: DependencyId) => setTried((t) => (t.includes(id) ? t : [...t, id])); useEffect(() => { if (data) onReport?.(data); }, [data, onReport]); @@ -87,10 +76,6 @@ export function DoctorChecklist({ const deps = ids ? ids.map((id) => data.dependencies.find((d) => d.id === id)).filter((d): d is DependencyStatus => !!d) : data.dependencies; const mergedHints = { ...DEFAULT_HINTS, ...hints }; - const missing = (id: DependencyId) => data.dependencies.find((d) => d.id === id && d.required && !d.ok); - const autoTarget = - auto && !refresh.isPending ? AUTO_INSTALL.find((id) => missing(id)?.installable && !tried.includes(id) && deps.some((d) => d.id === id)) : undefined; - const autoSignIn = auto && !refresh.isPending && !autoTarget && !missing("claude") && !tried.includes("claude-auth"); return (
@@ -104,13 +89,7 @@ export function DoctorChecklist({
{deps.map((dep, i) => ( - refresh.mutate()} - auto={dep.id === "claude-auth" ? autoSignIn : dep.id === autoTarget} - onAuto={() => attempt(dep.id)} - /> + refresh.mutate()} /> ))}
@@ -121,15 +100,10 @@ function DependencyRow({ dep, hint, onInstalled, - auto = false, - onAuto, }: { dep: DependencyStatus; hint?: string; onInstalled: () => void; - /** Start the install (or the Claude sign-in) without a click. */ - auto?: boolean; - onAuto?: () => void; }) { const [log, setLog] = useState<{ ok: boolean; output: string } | null>(null); const [logOpen, setLogOpen] = useState(false); @@ -148,14 +122,6 @@ function DependencyRow({ onInstalled(); }, }); - const autoStarted = useRef(false); - useEffect(() => { - if (!auto || autoStarted.current || dep.id === "claude-auth" || dep.ok) return; - autoStarted.current = true; - onAuto?.(); - install.mutate(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [auto]); const state: "ok" | "warn" | "error" = dep.ok ? "ok" : dep.required ? "error" : "warn"; const Icon = state === "ok" ? CircleCheck : state === "error" ? CircleX : CircleAlert; @@ -190,7 +156,7 @@ function DependencyRow({
{dep.detail &&

{dep.detail}

} {dep.path && dep.ok &&

{dep.path}

} - {!dep.ok && dep.id === "claude-auth" && } + {!dep.ok && dep.id === "claude-auth" && } {!dep.ok && dep.id !== "claude-auth" && (dep.installHint || FIX_FALLBACK[dep.id]) && (

@@ -240,17 +206,9 @@ function DependencyRow({ } /** Signs Claude Code in from Godmode: the browser opens, the person approves, the row turns green by itself. */ -function ClaudeSignIn({ onDone, auto = false, onAuto }: { onDone: () => void; auto?: boolean; onAuto?: () => void }) { +function ClaudeSignIn({ onDone }: { onDone: () => void }) { const login = useClaudeLogin({ onSignedIn: onDone }); const { status: data, state, start } = login; - const autoStarted = useRef(false); - useEffect(() => { - if (!auto || !data || autoStarted.current) return; - autoStarted.current = true; - onAuto?.(); - if (data.state === "idle" && maySignIn) start.mutate(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [auto, data]); if (state === "done") return ( diff --git a/apps/desktop/src/components/onboarding/done-step.tsx b/apps/desktop/src/components/onboarding/done-step.tsx index 5854fdfb..0ad0cfaf 100644 --- a/apps/desktop/src/components/onboarding/done-step.tsx +++ b/apps/desktop/src/components/onboarding/done-step.tsx @@ -1,26 +1,35 @@ -import { useState } from "react"; +import { useState, type ReactNode } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { format } from "date-fns"; -import { motion } from "motion/react"; -import { ArrowRight, Bot, CircleCheck, CircleDashed, KeyRound, ShieldCheck } from "lucide-react"; -import { MASCOT_CHARACTER, MASCOT_COLOR, type LicenseState } from "@godmode/shared"; +import { AnimatePresence, motion } from "motion/react"; +import { ArrowRight, Bot, ChevronDown, CircleCheck, CircleDashed, Cookie, KeyRound, ShieldCheck } from "lucide-react"; +import { MASCOT_CHARACTER, MASCOT_COLOR, type ChromeImportResult, type LicenseState, type LocalChromeProfile } from "@godmode/shared"; import { Character } from "@/components/character"; +import { ActivatePanel } from "@/components/license/activate"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { api, errorMessage } from "@/lib/api"; +import { isMac } from "@/lib/desktop"; +import { useLicense } from "@/lib/hooks"; +import { qk } from "@/lib/queryKeys"; +import { cn } from "@/lib/utils"; import { FormError, SubmitButton } from "./auth-layout"; -import { Confetti, StepCard } from "./step-kit"; +import { Confetti, StepCard, StepFooter } from "./step-kit"; export interface OnboardingSummary { doctorOk: boolean | null; vault: "created" | "unlocked" | "existing" | null; - importedCookies: number | null; dashboardPassword: boolean | null; - license: LicenseState | null; } const NEXT = [ { to: "/agents/new", icon: , title: "Hire your first agent", body: "Pick a template or describe the job." }, - { to: "/vault/logins", icon: , title: "Save your logins", body: "Agents sign in like you, 2FA included. No connectors needed." }, + { to: "/vault/logins", icon: , title: "Save your logins", body: "Agents sign in like you, 2FA included." }, { to: "/vault/2fa?import=1", icon: , title: "Import 2FA codes", body: "Scan Google Authenticator exports." }, ]; +const LICENSED: LicenseState["status"][] = ["active", "trial", "past_due", "unverified"]; + export function DoneStep({ userName, summary, @@ -34,99 +43,109 @@ export function DoneStep({ }) { const [busy, setBusy] = useState(null); const [error, setError] = useState(null); + const { data: license, isLoading: licenseLoading } = useLicense(); + const licensed = !!license?.keyHint && !license.blocked && LICENSED.includes(license.status); + const freeUntil = license?.enforced && license.status === "grace" && license.graceEndsAt ? new Date(license.graceEndsAt) : null; + const needsKey = !!license && !licensed && !freeUntil; + const [activateOpen, setActivateOpen] = useState(false); + const blocked = !!license?.blocked; const first = userName.trim().split(/\s+/)[0]; const finish = async (to: string) => { - if (busy) return; + if (busy || blocked || licenseLoading) return; setBusy(to); setError(null); try { await onFinish(to); } catch (e) { - setError(e instanceof Error ? e.message : String(e)); + setError(errorMessage(e)); setBusy(null); } }; - const items: { label: string; ok: boolean | null; detail?: string }[] = [ - { label: "System check", ok: summary.doctorOk, detail: summary.doctorOk === false ? "Some tools still missing — see Settings → System" : undefined }, - { - label: "Encrypted vault", - ok: summary.vault !== null, - detail: summary.vault === "created" ? "Created" : summary.vault ? "Unlocked" : undefined, - }, - { - label: "Browser sessions", - ok: summary.importedCookies !== null ? true : null, - detail: summary.importedCookies !== null ? `${summary.importedCookies.toLocaleString()} cookies imported` : "Skipped — import any time from Browser", - }, - ]; - const license = summary.license; - const licensed = !!license?.keyHint && !license.blocked && ["active", "trial", "past_due", "unverified"].includes(license.status); - const freeUntil = license?.enforced && license.status === "grace" && license.graceEndsAt ? format(new Date(license.graceEndsAt), "MMM d") : null; - items.push({ - label: "Godmode Pro", - ok: licensed || freeUntil ? true : license?.blocked ? false : null, - detail: licensed ? (license!.status === "trial" ? "Free trial active" : "Activated") : freeUntil ? `Free until ${freeUntil}` : "Skipped", - }); - if (serverMode) - items.push({ - label: "Dashboard password", - ok: summary.dashboardPassword, - detail: summary.dashboardPassword ? "Set" : "Not set — sign in with access tokens", - }); + const licenseDetail = licensed + ? license!.status === "trial" && license!.trialEndsAt + ? `Free trial until ${format(new Date(license!.trialEndsAt), "MMM d")}` + : "Activated" + : freeUntil + ? `Free until ${format(freeUntil, "MMM d")}` + : license && !license.enforced + ? "Free in this build" + : blocked + ? "Needs a licence key" + : "Not activated yet"; return (

- + {!blocked && }
- + -

Setup complete

-

+

You're all set{first ? `, ${first}` : ""}. Your coworker is ready.

- Tell it what you need in plain words — it will sign in, browse and report back. + Tell it what you need in plain words. It signs in, browses and reports back.

    - {items.map((it, i) => ( - - {it.ok ? ( - - ) : it.ok === false ? ( - - ) : ( - - )} - {it.label} - {it.detail && {it.detail}} - - ))} + + + + setActivateOpen((o) => !o)} aria-expanded={activateOpen || blocked}> + Activate + + ) : undefined + } + /> + {serverMode && ( + + )}
+ + {needsKey && (activateOpen || blocked) && ( + +
+ {blocked && license?.message &&

{license.message}

} + setActivateOpen(false)} /> +
+
+ )} +
+

Next up

{NEXT.map((n, i) => ( finish(n.to)} initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} @@ -149,12 +168,90 @@ export function DoneStep({
)} -
- finish("/")} className="w-auto px-8"> + + {blocked && Activate Godmode to start working.} + finish("/")} className="w-auto px-8"> Start working -
+
); } + +function SummaryRow({ i, ok, label, detail, action }: { i: number; ok: boolean | null; label: string; detail?: string; action?: ReactNode }) { + return ( + + {ok ? ( + + ) : ( + + )} + {label} + {detail && {detail}} + {action && {action}} + + ); +} + +/** One click brings Chrome's sign-ins into Godmode's browser; hidden when this computer has no Chrome profile. */ +function ChromeImportRow({ i }: { i: number }) { + const chrome = useQuery({ queryKey: qk.chromeProfiles, queryFn: api.browser.chromeProfiles, retry: false }); + const profiles = chrome.data ?? []; + const [selected, setSelected] = useState(null); + const chosen = profiles.find((p) => p.path === selected) ?? profiles[0] ?? null; + const [result, setResult] = useState(null); + const importMut = useMutation({ + mutationFn: async (profile: LocalChromeProfile) => { + const targets = await api.browser.profiles(); + const target = targets.find((p) => p.isDefault) ?? targets.find((p) => !p.workspaceId) ?? targets[0]; + if (!target) throw new Error("No Godmode browser profile exists yet. Import from the Browser page later."); + return api.browser.import(target.id, { sourcePath: profile.path }); + }, + onSuccess: setResult, + }); + + if (result) + return ; + if (chrome.isLoading || chrome.isError || !chosen) + return ; + + return ( + +
+ + + Chrome sign-ins + Optional. Agents start already logged in to the sites you use. + + {profiles.length > 1 && ( + + )} + +
+ {importMut.isError &&

{errorMessage(importMut.error)}

} + {!importMut.isError && isMac && importMut.isPending && ( +

macOS may ask for Keychain access. Allow it so Chrome's cookies can be read.

+ )} +
+ ); +} diff --git a/apps/desktop/src/components/onboarding/setup-step.tsx b/apps/desktop/src/components/onboarding/setup-step.tsx new file mode 100644 index 00000000..98ecb673 --- /dev/null +++ b/apps/desktop/src/components/onboarding/setup-step.tsx @@ -0,0 +1,490 @@ +import { useCallback, useRef, useState, type ReactNode } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { AnimatePresence, motion } from "motion/react"; +import { ChevronDown, CircleAlert, Globe, LogIn, RotateCw, Sparkles, UserRound } from "lucide-react"; +import { MASCOT_CHARACTER, MASCOT_COLOR, type DependencyId, type DependencyStatus, type DoctorReport, type InterfaceMode } from "@godmode/shared"; +import { Character, SpeechBubble } from "@/components/character"; +import { DrawCheck } from "@/components/aicss/Motion"; +import { MacPermissionsSummary } from "@/components/permissions/mac-permissions"; +import { ModePicker } from "@/components/settings/mode-picker"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Spinner } from "@/components/ui/spinner"; +import { api, errorMessage } from "@/lib/api"; +import { qk } from "@/lib/queryKeys"; +import { cn } from "@/lib/utils"; +import { SubmitButton } from "./auth-layout"; +import { ClaudeSignInSteps, maySignIn, useClaudeLogin } from "./claude-sign-in"; +import { InlineCode, useDoctor } from "./doctor-checklist"; +import { StepCard, StepFooter } from "./step-kit"; + +/** Installed in this order: uv first, the browser tools run on it. */ +const INSTALL_ORDER: DependencyId[] = ["claude", "uv", "browser-use", "chrome"]; +const BROWSER_IDS: DependencyId[] = ["uv", "browser-use", "chrome"]; +const ROW_IDS: DependencyId[] = ["claude", "claude-auth", ...BROWSER_IDS]; +const DETAIL_IDS: DependencyId[] = ["claude", "claude-auth", "uv", "browser-use", "chrome", "git"]; + +type Job = { state: "installing" | "failed" | "done"; output?: string }; +type RowState = "idle" | "waiting" | "active" | "action" | "done" | "failed"; + +const find = (report: DoctorReport | undefined, id: DependencyId) => report?.dependencies.find((d) => d.id === id); +const missing = (dep: DependencyStatus | undefined) => !!dep && dep.required && !dep.ok; + +/** The line of an installer's output that says what went wrong. */ +function reason(output: string | undefined): string { + const lines = (output ?? "") + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + const line = lines.findLast((l) => /error|fail|denied|not found|timed out|unable|cannot|could not/i.test(l)) ?? lines.at(-1) ?? ""; + return line.length > 160 ? `${line.slice(0, 157)}…` : line || "The installer stopped without saying why."; +} + +export function SetupStep({ + userName, + onUserName, + mode, + onMode, + onNext, +}: { + userName: string; + onUserName: (v: string) => void; + mode: InterfaceMode; + onMode: (mode: InterfaceMode) => void; + onNext: (doctorOk: boolean) => void; +}) { + const qc = useQueryClient(); + const doctor = useDoctor(); + const report = doctor.data; + const [started, setStarted] = useState(false); + const [running, setRunning] = useState(false); + const [jobs, setJobs] = useState>>({}); + const [error, setError] = useState(null); + const [details, setDetails] = useState(false); + const busy = useRef(false); + const signInTried = useRef(false); + + const refresh = useCallback(async () => { + const next = await api.doctor.get(true); + qc.setQueryData(qk.doctor, next); + return next; + }, [qc]); + const login = useClaudeLogin({ onSignedIn: () => void refresh().catch(() => undefined) }); + + const signIn = (r: DoctorReport) => { + if (signInTried.current || !maySignIn || !find(r, "claude")?.ok || !missing(find(r, "claude-auth"))) return; + signInTried.current = true; + if (login.state !== "waiting") login.start.mutate(); + }; + + const run = async () => { + if (busy.current) return; + busy.current = true; + setStarted(true); + setRunning(true); + setError(null); + try { + let r = await refresh(); + for (const id of INSTALL_ORDER) { + // Claude Code is there: the sign-in runs in the browser while the rest installs. + if (id !== "claude") signIn(r); + const dep = find(r, id); + if (!missing(dep)) continue; + if (!dep!.installable) { + setJobs((j) => ({ ...j, [id]: { state: "failed", output: dep!.installHint || dep!.detail } })); + continue; + } + setJobs((j) => ({ ...j, [id]: { state: "installing" } })); + const res = await api.doctor.install(id).catch((e) => ({ ok: false, output: errorMessage(e) })); + let ok = res.ok; + try { + r = await refresh(); + // A check that started before the install finished may still say it is missing. + if (res.ok && !find(r, id)?.ok) r = await refresh(); + ok = find(r, id)?.ok ?? res.ok; + } finally { + setJobs((j) => ({ ...j, [id]: { state: ok ? "done" : "failed", output: res.output } })); + } + } + signIn(r); + } catch (e) { + setError(errorMessage(e)); + } finally { + busy.current = false; + setRunning(false); + } + }; + + const allOk = !!report && ROW_IDS.every((id) => !missing(find(report, id))); + const showBrowser = BROWSER_IDS.some((id) => find(report, id)?.required); + + const installRow = (ids: DependencyId[]): { state: RowState; job?: Job & { id: DependencyId } } => { + const deps = ids.map((id) => find(report, id)).filter((d): d is DependencyStatus => !!d && d.required); + if (deps.every((d) => d.ok)) return { state: "done" }; + const installing = ids.find((id) => jobs[id]?.state === "installing"); + if (installing) return { state: "active", job: { ...jobs[installing]!, id: installing } }; + const failed = ids.find((id) => jobs[id]?.state === "failed" && missing(find(report, id))); + if (failed) return { state: "failed", job: { ...jobs[failed]!, id: failed } }; + return { state: started && running ? "waiting" : started ? "failed" : "idle" }; + }; + + const claudeRow = installRow(["claude"]); + const browserRow = installRow(BROWSER_IDS); + const authDep = find(report, "claude-auth"); + const claudeOk = !!find(report, "claude")?.ok; + const accountState: RowState = + authDep?.ok || login.state === "done" + ? "done" + : login.state === "waiting" + ? "active" + : login.state === "failed" + ? "failed" + : !started + ? "idle" + : login.start.isPending || (running && maySignIn && (!claudeOk || !signInTried.current)) + ? "waiting" + : claudeOk && maySignIn + ? "action" + : "failed"; + + const rows = [claudeRow.state, accountState, ...(showBrowser ? [browserRow.state] : [])]; + const doneCount = rows.filter((s) => s === "done").length; + const finished = !!report && (allOk || (started && !running && login.state !== "waiting" && !login.start.isPending)); + const first = userName.trim().split(/\s+/)[0]; + const mood = allOk ? "happy" : started && !running && !finished ? "attention" : running || login.state === "waiting" ? "working" : "idle"; + + const submit = () => { + if (!report) return; + if (allOk) onNext(true); + else void run(); + }; + + return ( +
+
+ + + + {allOk && started + ? "All set. I'm ready to work." + : running || login.state === "waiting" + ? "Getting myself ready. This takes a few minutes." + : first + ? `Hi ${first}, I'm Godmode, your new coworker.` + : "Hi, I'm Godmode, your new coworker."} + + +

+ Let's get Godmode ready. + One click, a few minutes. +

+

+ {allOk && !started + ? "Everything Godmode needs is already on this computer. Pick how it should look and carry on." + : "Godmode sets up everything it needs on this computer by itself. You only approve the Claude sign-in in your browser."} +

+
+ + +
+ +
+ + onUserName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !started) submit(); + }} + placeholder="Your first name" + className="h-10 pl-9 text-[15px]" + maxLength={60} + /> +
+
+ +
+ {!report ? ( + doctor.isError ? ( +
+

Could not look at this computer

+

{errorMessage(doctor.error)}

+ +
+ ) : ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) + ) : ( + <> + {started && ( +
+

+ {allOk ? "Godmode is ready to work." : finished ? "Almost there." : "Setting things up…"} +

+ + {doneCount} of {rows.length} + +
+ +
+
+ )} +
    + } + title="Claude Code" + body="The AI that does the work." + state={claudeRow.state} + activeText="Installing…" + doneText={find(report, "claude")?.version ? `Installed · ${find(report, "claude")!.version}` : "Installed"} + failure={claudeRow.job ? { output: claudeRow.job.output, hint: find(report, "claude")?.installHint } : undefined} + onRetry={running ? undefined : () => void run()} + /> + } + title="Your Claude account" + body="Sign in with a Claude Pro or Max plan." + state={accountState} + activeText="Waiting for you in the browser" + waitingText={claudeOk ? "Starting the sign-in…" : "Starts once Claude Code is installed"} + doneText={authDep?.ok ? authDep.detail || "Signed in" : "Signed in"} + actionText="Not signed in yet. It opens in your browser." + failureText={ + login.state === "failed" + ? login.status?.message || "The sign-in did not finish." + : !claudeOk + ? "Needs Claude Code first." + : "Only the owner of this computer can sign Claude in." + } + retryLabel={login.state === "failed" ? "Try again" : "Sign in"} + onRetry={claudeOk && maySignIn ? () => login.start.mutate() : undefined} + retrying={login.start.isPending} + > + {accountState === "active" && } + + {showBrowser && ( + } + title="Web browser" + body="So your agents can open websites and sign in." + state={browserRow.state} + activeText={browserRow.job?.id === "chrome" ? "Downloading the browser…" : "Installing…"} + doneText="Installed" + failure={browserRow.job ? { output: browserRow.job.output, hint: find(report, browserRow.job.id)?.installHint } : undefined} + onRetry={running ? undefined : () => void run()} + /> + )} +
+ + )} + {error &&

{error}

} +
+ + {report && ( +
+ + + {details && ( + + + + + )} + +
+ )} + + {(started || allOk) && ( + +

{running || login.state === "waiting" ? "While you wait: how should Godmode look?" : "How should Godmode look?"}

+ +

Same features either way. You can switch any time in Settings.

+
+ )} + + {!started && !allOk ? ( +
+ + Set up Godmode + +

Installs everything by itself, then opens the Claude sign-in.

+
+ ) : ( + + {allOk ? ( + onNext(true)} className="w-auto px-6"> + Continue + + ) : finished ? ( + + ) : ( +

You can keep this window open, it finishes by itself.

+ )} +
+ )} + {finished && !allOk && ( +

Agents start working once everything above is green. You can finish this later in Settings, System.

+ )} +
+
+ ); +} + +function SetupRow({ + icon, + title, + body, + state, + activeText, + waitingText = "Waiting", + doneText, + actionText, + failure, + failureText = "Did not install.", + retryLabel = "Retry", + onRetry, + retrying, + children, +}: { + icon: ReactNode; + title: string; + body: string; + state: RowState; + activeText?: string; + waitingText?: string; + doneText: string; + actionText?: string; + failure?: { output?: string; hint?: string }; + failureText?: string; + retryLabel?: string; + onRetry?: () => void; + retrying?: boolean; + children?: ReactNode; +}) { + const [log, setLog] = useState(false); + const status = + state === "done" + ? doneText + : state === "active" + ? activeText + : state === "waiting" + ? waitingText + : state === "action" + ? actionText + : state === "failed" + ? failure + ? reason(failure.output) + : failureText + : body; + return ( +
  • +
    +
    + {state === "done" ? : state === "active" ? : state === "failed" ? : icon} +
    +
    +

    {title}

    +

    + {status} +

    +
    + {(state === "failed" || state === "action") && onRetry && ( + + )} +
    + {state === "failed" && failure && ( +
    + + + {log && ( + + {failure.hint && ( +

    + To install it by hand: +

    + )} +
    +                  {failure.output || "(no output)"}
    +                
    +
    + )} +
    +
    + )} + {children &&
    {children}
    } +
  • + ); +} + +function TechDetails({ report }: { report: DoctorReport }) { + const deps = DETAIL_IDS.map((id) => find(report, id)).filter((d): d is DependencyStatus => !!d); + return ( +
    +

    + {report.platform} · {report.arch} +

    +
      + {deps.map((d) => ( +
    • + + {d.name} + + {d.ok ? [d.version, d.path].filter(Boolean).join(" ") || d.detail : d.detail} + {!d.required && " (optional)"} + +
    • + ))} +
    +
    + ); +} diff --git a/apps/desktop/src/components/onboarding/system-step.tsx b/apps/desktop/src/components/onboarding/system-step.tsx deleted file mode 100644 index aef52be7..00000000 --- a/apps/desktop/src/components/onboarding/system-step.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { useCallback, useState } from "react"; -import { motion } from "motion/react"; -import { CircleAlert, CircleCheck } from "lucide-react"; -import type { DoctorReport } from "@godmode/shared"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; -import { SubmitButton } from "./auth-layout"; -import { MacPermissionsSummary } from "@/components/permissions/mac-permissions"; -import { DoctorChecklist } from "./doctor-checklist"; -import { StepCard, StepFooter, StepHeader } from "./step-kit"; - -export function SystemStep({ onNext, onBack }: { onNext: () => void; onBack: () => void }) { - const [report, setReport] = useState(null); - const onReport = useCallback((r: DoctorReport) => setReport(r), []); - const missingRequired = report?.dependencies.filter((d) => d.required && !d.ok) ?? []; - const allGood = !!report && missingRequired.length === 0; - - return ( -
    - - - {report && ( - - {allGood ? : } -

    - {allGood ? ( - Everything your coworker needs is in place. - ) : ( - <> - - {missingRequired.length} required {missingRequired.length === 1 ? "item needs" : "items need"} attention. - {" "} - You can continue and fix this later in Settings → System — agents won't run until it's resolved. - - )} -

    -
    - )} - - - - {allGood ? ( - - Continue - - ) : ( - - )} - -
    -
    - ); -} diff --git a/apps/desktop/src/components/onboarding/welcome-step.tsx b/apps/desktop/src/components/onboarding/welcome-step.tsx deleted file mode 100644 index 39e46a5a..00000000 --- a/apps/desktop/src/components/onboarding/welcome-step.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import type { FormEvent } from "react"; -import { motion } from "motion/react"; -import { Bot, KeyRound, Workflow } from "lucide-react"; -import { MASCOT_CHARACTER, MASCOT_COLOR, type InterfaceMode } from "@godmode/shared"; -import { ModePicker } from "@/components/settings/mode-picker"; -import { Character, SpeechBubble } from "@/components/character"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { SubmitButton } from "./auth-layout"; -import { StepCard, StepFooter } from "./step-kit"; - -const FEATURES = [ - { - icon: , - title: "Works like a teammate", - body: "Claude Code runs on your machine with its own memory, tools and a real browser.", - }, - { - icon: , - title: "Logs in for you", - body: "Passwords and 2FA codes live in an encrypted vault. Agents use them without ever seeing them.", - }, - { - icon: , - title: "Keeps going", - body: "Automations start work on a schedule or when something happens, agents delegate to each other and only ping you when they need you.", - }, -]; - -export function WelcomeStep({ - userName, - onUserName, - mode, - onMode, - onNext, -}: { - userName: string; - onUserName: (v: string) => void; - mode: InterfaceMode; - onMode: (mode: InterfaceMode) => void; - onNext: () => void; -}) { - const first = userName.trim().split(/\s+/)[0]; - const submit = (e: FormEvent) => { - e.preventDefault(); - onNext(); - }; - return ( -
    -
    - - - - {first ? `Hi ${first}, I'm Godmode 👋` : "Hi, I'm Godmode 👋"} — your new coworker. Let's get me set up. - - -

    Setup · about a minute

    -

    - Welcome to Godmode. - Your AI coworker, set up in a minute. -

    -

    - Godmode turns Claude into a teammate that can sign in, browse and get real work done across your tools — while you stay in - control of every secret. -

    -
    - - -
    - {FEATURES.map((f, i) => ( - -
    {f.icon}
    -

    {f.title}

    -

    {f.body}

    -
    - ))} -
    - -
    - - onUserName(e.target.value)} - placeholder="Your first name" - className="h-10 text-[15px]" - maxLength={60} - /> -
    - -
    -

    How should Godmode look?

    - -

    Same features either way. You can switch any time in Settings.

    -
    - - - - {first ? `Let's go, ${first}` : "Get started"} - - -
    -
    - ); -} diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 6a432310..1e2c87f9 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -414,6 +414,7 @@ export const api = { startClaudeLogin: () => post("/api/doctor/claude-login"), submitClaudeLoginCode: (code: string) => post("/api/doctor/claude-login/code", { code }), cancelClaudeLogin: () => del("/api/doctor/claude-login"), + copyClaudeLoginLink: () => post<{ ok: boolean }>("/api/doctor/claude-login/copy"), claudeUpdate: (refresh = false) => get("/api/doctor/claude-update", { refresh: refresh ? 1 : undefined }), updateClaude: () => post("/api/doctor/claude-update"), permissions: () => get("/api/doctor/permissions"), diff --git a/apps/desktop/src/pages/onboarding/onboarding.tsx b/apps/desktop/src/pages/onboarding/onboarding.tsx index 14202e5a..1e9243ef 100644 --- a/apps/desktop/src/pages/onboarding/onboarding.tsx +++ b/apps/desktop/src/pages/onboarding/onboarding.tsx @@ -1,33 +1,27 @@ -import { useState, type ReactNode } from "react"; +import { useRef, useState, type ReactNode } from "react"; import { useNavigate } from "react-router"; import { useQueryClient } from "@tanstack/react-query"; import { AnimatePresence, motion } from "motion/react"; -import { Globe2, HandHeart, KeySquare, LockKeyhole, PartyPopper, Stethoscope, AppWindow } from "lucide-react"; +import { Globe2, LockKeyhole, PartyPopper, Sparkles } from "lucide-react"; import type { Bootstrap, InterfaceMode } from "@godmode/shared"; import { Backdrop, Wordmark } from "@/components/brand"; -import { ActivateStep } from "@/components/onboarding/activate-step"; -import { BrowserStep } from "@/components/onboarding/browser-step"; import { DoneStep, type OnboardingSummary } from "@/components/onboarding/done-step"; import { RemoteStep } from "@/components/onboarding/remote-step"; +import { SetupStep } from "@/components/onboarding/setup-step"; import { StepProgress, StepRail, type StepMeta } from "@/components/onboarding/step-kit"; -import { SystemStep } from "@/components/onboarding/system-step"; import { VaultStep } from "@/components/onboarding/vault-step"; -import { WelcomeStep } from "@/components/onboarding/welcome-step"; import { api } from "@/lib/api"; import { isTauri, storageKey } from "@/lib/core"; import { isMac } from "@/lib/desktop"; import { qk } from "@/lib/queryKeys"; import { cn } from "@/lib/utils"; -type StepId = "welcome" | "system" | "vault" | "browser" | "remote" | "activate" | "done"; +type StepId = "setup" | "vault" | "remote" | "done"; const STEP_META: Record> = { - welcome: { title: "Welcome", hint: "Say hello", icon: }, - system: { title: "System check", hint: "Claude Code, browser & tools", icon: }, - vault: { title: "Vault", hint: "Encrypted logins & 2FA", icon: }, - browser: { title: "Browser sessions", hint: "Import Chrome sign-ins", icon: , optional: true }, + setup: { title: "Set up", hint: "Installs and signs in by itself", icon: }, + vault: { title: "Vault", hint: "Keeps your logins safe", icon: }, remote: { title: "Remote dashboard", hint: "Password for web access", icon: }, - activate: { title: "Activate", hint: "Free trial or licence key", icon: }, done: { title: "Done", hint: "Start working", icon: }, }; @@ -62,11 +56,10 @@ export function OnboardingPage({ bootstrap }: { bootstrap: Bootstrap }) { // The step list is fixed on mount — bootstrap changes mid-flow (e.g. vault created) must not shift indices. const [steps] = useState(() => { - const list: StepId[] = ["welcome", "system"]; + const list: StepId[] = ["setup"]; if (!(bootstrap.vault.initialized && bootstrap.vault.unlocked)) list.push("vault"); - list.push("browser"); if (serverMode) list.push("remote"); - list.push("activate", "done"); + list.push("done"); return list; }); const [index, setIndex] = useState(() => { @@ -83,13 +76,13 @@ export function OnboardingPage({ bootstrap }: { bootstrap: Bootstrap }) { const [summary, setSummary] = useState({ doctorOk: null, vault: bootstrap.vault.initialized && bootstrap.vault.unlocked ? "existing" : null, - importedCookies: null, dashboardPassword: bootstrap.settings.server.hasDashboardPassword, - license: null, }); + const scroller = useRef(null); const go = (to: number) => { const next = Math.max(0, Math.min(steps.length - 1, to)); + scroller.current?.scrollTo({ top: 0 }); setDirection(next >= index ? 1 : -1); setIndex(next); saveSession({ step: steps[next], userName, mode }); @@ -113,16 +106,15 @@ export function OnboardingPage({ bootstrap }: { bootstrap: Bootstrap }) { let content: ReactNode; switch (stepId) { - case "welcome": - content = ; - break; - case "system": + case "setup": content = ( - { - const report = qc.getQueryData<{ dependencies: { required: boolean; ok: boolean }[] }>(qk.doctor); - setSummary((s) => ({ ...s, doctorOk: report ? report.dependencies.every((d) => !d.required || d.ok) : null })); + { + setSummary((s) => ({ ...s, doctorOk })); next(); }} /> @@ -141,17 +133,6 @@ export function OnboardingPage({ bootstrap }: { bootstrap: Bootstrap }) { /> ); break; - case "browser": - content = ( - { - setSummary((s) => ({ ...s, importedCookies })); - next(); - }} - /> - ); - break; case "remote": content = ( ); break; - case "activate": - content = ( - { - setSummary((s) => ({ ...s, license: state })); - next(); - }} - /> - ); - break; case "done": content = ; break; @@ -202,7 +172,7 @@ export function OnboardingPage({ bootstrap }: { bootstrap: Bootstrap }) { Everything stays on this machine. You can change any of this later in Settings.

    -
    +
    diff --git a/docs/screenshots/one-click-setup/01-setup-start.png b/docs/screenshots/one-click-setup/01-setup-start.png new file mode 100644 index 00000000..8ed47e7a Binary files /dev/null and b/docs/screenshots/one-click-setup/01-setup-start.png differ diff --git a/docs/screenshots/one-click-setup/02-installing.png b/docs/screenshots/one-click-setup/02-installing.png new file mode 100644 index 00000000..e8c1e04a Binary files /dev/null and b/docs/screenshots/one-click-setup/02-installing.png differ diff --git a/docs/screenshots/one-click-setup/03-sign-in-link-copied.png b/docs/screenshots/one-click-setup/03-sign-in-link-copied.png new file mode 100644 index 00000000..95577f37 Binary files /dev/null and b/docs/screenshots/one-click-setup/03-sign-in-link-copied.png differ diff --git a/docs/screenshots/one-click-setup/04-install-failed.png b/docs/screenshots/one-click-setup/04-install-failed.png new file mode 100644 index 00000000..5862945d Binary files /dev/null and b/docs/screenshots/one-click-setup/04-install-failed.png differ diff --git a/docs/screenshots/one-click-setup/05-ready.png b/docs/screenshots/one-click-setup/05-ready.png new file mode 100644 index 00000000..765011b3 Binary files /dev/null and b/docs/screenshots/one-click-setup/05-ready.png differ diff --git a/docs/screenshots/one-click-setup/06-vault.png b/docs/screenshots/one-click-setup/06-vault.png new file mode 100644 index 00000000..7fd26a45 Binary files /dev/null and b/docs/screenshots/one-click-setup/06-vault.png differ diff --git a/docs/screenshots/one-click-setup/07-done.png b/docs/screenshots/one-click-setup/07-done.png new file mode 100644 index 00000000..15849c1c Binary files /dev/null and b/docs/screenshots/one-click-setup/07-done.png differ diff --git a/docs/screenshots/one-click-setup/08-banner-sign-in.png b/docs/screenshots/one-click-setup/08-banner-sign-in.png new file mode 100644 index 00000000..34fb3e77 Binary files /dev/null and b/docs/screenshots/one-click-setup/08-banner-sign-in.png differ diff --git a/docs/screenshots/one-click-setup/09-dark-sign-in.png b/docs/screenshots/one-click-setup/09-dark-sign-in.png new file mode 100644 index 00000000..92644f0e Binary files /dev/null and b/docs/screenshots/one-click-setup/09-dark-sign-in.png differ diff --git a/docs/screenshots/one-click-setup/10-dark-sign-in-cancelled.png b/docs/screenshots/one-click-setup/10-dark-sign-in-cancelled.png new file mode 100644 index 00000000..869182fa Binary files /dev/null and b/docs/screenshots/one-click-setup/10-dark-sign-in-cancelled.png differ diff --git a/packages/core/src/cloud/scope.ts b/packages/core/src/cloud/scope.ts index 6469b9ed..d97ee9c8 100644 --- a/packages/core/src/cloud/scope.ts +++ b/packages/core/src/cloud/scope.ts @@ -70,6 +70,8 @@ const RULES: [methods: string, path: string, rule: Rule][] = [ ["GET", "/api/doctor/claude-login", A], ["POST|DELETE", "/api/doctor/claude-login", "owner"], ["POST", "/api/doctor/claude-login/code", "owner"], + // Copies the link to the computer's clipboard, which is out of reach from afar. + ["POST", "/api/doctor/claude-login/copy", refused(COMPUTER_ONLY)], // Settings → System: reading the state is fine from anywhere; changing macOS permissions or the machine's tools // happens on the computer itself. ["GET", "/api/doctor/permissions", A], diff --git a/packages/core/src/server/routes/system.ts b/packages/core/src/server/routes/system.ts index 2db05dfb..5bda7e1a 100644 --- a/packages/core/src/server/routes/system.ts +++ b/packages/core/src/server/routes/system.ts @@ -12,7 +12,7 @@ import { listNotifications, markRead, clearNotifications, unreadCount } from ".. import { audit, listAudit } from "../../services/audit"; import { listAttention } from "../../services/attention"; import { runDoctor, installDependency } from "../../services/doctor"; -import { cancelClaudeLogin, claudeLoginStatus, recheckClaudeSignedOut, startClaudeLogin, submitClaudeLoginCode } from "../../services/claudeLogin"; +import { cancelClaudeLogin, claudeLoginStatus, copyClaudeLoginLink, recheckClaudeSignedOut, startClaudeLogin, submitClaudeLoginCode } from "../../services/claudeLogin"; import { claudeUpdateStatus, updateClaude } from "../../services/claudeUpdate"; import { PERMISSION_IDS, PRIVACY_IDS, checkPermissions, fixPermission, openPrivacySettings, resetPrivacy } from "../../services/permissions"; import { TOOL_IDS, checkUpdates } from "../../services/updates"; @@ -113,6 +113,7 @@ export function registerSystemRoutes(app: Hono) { const { code } = await body(c, z.object({ code: z.string().min(1).max(2000) })); return c.json(submitClaudeLoginCode(code)); }); + app.post("/api/doctor/claude-login/copy", async (c) => c.json(await copyClaudeLoginLink())); app.delete("/api/doctor/claude-login", (c) => c.json(cancelClaudeLogin())); app.get("/api/doctor/claude-update", async (c) => c.json(await claudeUpdateStatus(c.req.query("refresh") === "1"))); app.post("/api/doctor/claude-update", async (c) => { diff --git a/packages/core/src/services/claudeLogin.ts b/packages/core/src/services/claudeLogin.ts index f044b256..1539a83b 100644 --- a/packages/core/src/services/claudeLogin.ts +++ b/packages/core/src/services/claudeLogin.ts @@ -258,6 +258,33 @@ export function submitClaudeLoginCode(code: string): ClaudeLoginStatus { return claudeLoginStatus(); } +const CLIPBOARD: string[][] = + process.platform === "darwin" + ? [["pbcopy"]] + : process.platform === "win32" + ? [["clip"]] + : [["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]]; + +/** Puts the waiting sign-in's link on this computer's clipboard: the desktop webview may only write it during a click. */ +export async function copyClaudeLoginLink(): Promise<{ ok: boolean }> { + const url = session ? status.url : null; + if (!url) return { ok: false }; + for (const [name, ...args] of CLIPBOARD) { + const bin = Bun.which(name!, { PATH: process.env.PATH ?? process.env.Path ?? "" }); + if (!bin) continue; + try { + const proc = Bun.spawn([bin, ...args], { stdin: new Blob([url]), stdout: "ignore", stderr: "ignore", windowsHide: true } as Parameters[1]); + const timer = setTimeout(() => proc.kill(), 5_000); + const code = await proc.exited; + clearTimeout(timer); + if (code === 0) return { ok: true }; + } catch { + /* not installed */ + } + } + return { ok: false }; +} + export function cancelClaudeLogin(): ClaudeLoginStatus { if (session) finish(session, null, "Cancelled."); status = { state: "idle", url: null, message: null }; diff --git a/packages/core/test/claude-login.test.ts b/packages/core/test/claude-login.test.ts index e797e100..532834bd 100644 --- a/packages/core/test/claude-login.test.ts +++ b/packages/core/test/claude-login.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { chmodSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { loadConfig } from "../src/config"; @@ -9,6 +9,7 @@ import { resetSettingsCache, updateSettings } from "../src/services/settings"; import { cancelClaudeLogin, claudeLoginStatus, + copyClaudeLoginLink, noteAnthropicKeyChanged, noteClaudeSignedOut, noteClaudeWorks, @@ -85,6 +86,28 @@ describe.skipIf(process.platform === "win32")("claude login", () => { expect(claudeLoginStatus().message).toContain("Invalid code"); }); + test("copies the waiting sign-in's link to the clipboard", async () => { + const bin = join(dataDir, "clipboard-bin"); + const out = join(dataDir, "clipboard.txt"); + mkdirSync(bin, { recursive: true }); + for (const name of ["pbcopy", "wl-copy"]) { + writeFileSync(join(bin, name), `#!/bin/sh\ncat > "${out}"\n`); + chmodSync(join(bin, name), 0o755); + } + const savedPath = process.env.PATH; + process.env.PATH = `${bin}:${savedPath}`; + try { + expect(await copyClaudeLoginLink()).toEqual({ ok: false }); + startClaudeLogin(); + await until(() => !!claudeLoginStatus().url); + expect(await copyClaudeLoginLink()).toEqual({ ok: true }); + expect(readFileSync(out, "utf8")).toBe("https://claude.com/cai/oauth/authorize?code=true&state=abc"); + } finally { + process.env.PATH = savedPath; + cancelClaudeLogin(); + } + }); + test("cancel stops a waiting sign-in", async () => { startClaudeLogin(); await until(() => !!claudeLoginStatus().url);