@crievo/omni 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,40 +1,247 @@
1
1
  /* Getting started (/getting-started): the guide in docs/onboarding.md, one step per section
2
- (`<!-- step: id -->`), with each step's state measured by `tools/setup.mjs
3
- check --json`. The guide owns the text: the UI repeats none of it. */
2
+ (`<!-- step: id -->`), each with its state measured here. The guide owns the text: the UI repeats
3
+ none of it.
4
4
 
5
+ The first step is this computer: the four programs the CLI (`omni up`) downloads into ~/.omni in the
6
+ background (checkDeps, from tools/lib/deps.mjs) and the Claude Code permission rules the plugin's
7
+ skills need (missingRules, from tools/lib/claude-settings.mjs; Claude writes them through the
8
+ plugin's MCP, never the studio). The page polls while something is missing, so checkDeps is cached:
9
+ briefly while the download runs, longer once everything answers. The other steps read the
10
+ workspace (.env, config, styles library, videos/): nothing there runs a program.
11
+
12
+ `tools/setup.mjs check` stays the development repository's terminal check; the studio no longer
13
+ runs it. */
14
+
15
+ import { existsSync, readdirSync, statSync } from "node:fs";
5
16
  import { join, resolve, sep } from "node:path";
6
- import { CODE_ROOT, NODE, TOOLS_DIR, run, readText } from "./root";
17
+ import { CODE_ROOT, readText } from "./root";
18
+ import { providerKey, readConfig, readEnv } from "./settings";
19
+ import { LIBRARY_DIR } from "./references";
20
+ import { videoDirs } from "./videos";
21
+ import { checkDeps } from "../../../tools/lib/deps.mjs";
22
+ import { missingRules } from "../../../tools/lib/claude-settings.mjs";
23
+ import { accountsOf, loginOf, tildify } from "../../../tools/lib/agents.mjs";
7
24
 
8
25
  export const DOCS_DIR = join(CODE_ROOT, "docs");
9
26
  const GUIDE = join(DOCS_DIR, "onboarding.md");
10
27
 
11
- type Check = { id: string; title: string; state: "ok" | "missing" | "warning"; detail: string };
12
- type CheckResult = {
13
- ready: boolean;
14
- steps: { id: string; state: Check["state"]; optional?: boolean; items: Check[] }[];
15
- };
28
+ type State = "ok" | "missing" | "warning";
29
+ type Check = { id: string; title: string; state: State; detail: string };
30
+ type StepCheck = { optional?: boolean; items: Check[] };
16
31
  export type OnboardingStep = {
17
32
  id: string;
18
33
  title: string;
19
34
  markdown: string;
20
- state: Check["state"] | null;
35
+ state: State | null;
21
36
  optional: boolean;
22
37
  items: Check[];
23
38
  };
39
+ export type Computer = {
40
+ ffmpeg: string | null;
41
+ ffprobe: string | null;
42
+ chrome: string | null;
43
+ python: string | null;
44
+ /** only the visual style from a link needs it: not part of `ready` */
45
+ ytdlp: string | null;
46
+ /** the four programs answer */
47
+ ready: boolean;
48
+ /** the permission rules still missing in the Claude Code settings (empty = ok) */
49
+ missing_rules: string[];
50
+ /** a development checkout (the code root has .git, as `omni up` tells it): Claude runs the repository's
51
+ own skills, so the plugin's rules don't block anything */
52
+ dev: boolean;
53
+ /** the agent account the sessions run on: logged in (the e-mail) or where the login was looked for */
54
+ agent: { ok: boolean; detail: string };
55
+ };
56
+
57
+ const item = (id: string, title: string, state: State, detail: string): Check => ({ id, title, state, detail });
58
+ const stateOf = (items: Check[]): State =>
59
+ items.some(i => i.state === "missing") ? "missing" : items.some(i => i.state === "warning") ? "warning" : "ok";
60
+
61
+ /* --- this computer ------------------------------------------------------------------- */
24
62
 
25
- /* the check runs programs (bun, ffmpeg, python…): cache it briefly so the overview doesn't pay on every visit */
26
- let cached: { at: number; r: CheckResult } | null = null;
27
- async function runChecks(fresh: boolean): Promise<CheckResult | null> {
28
- if (!fresh && cached && Date.now() - cached.at < 60_000) return cached.r;
29
- const r = await run([NODE, join(TOOLS_DIR, "setup.mjs"), "check", "--json"]);
63
+ /* checkDeps runs each program (spawnSync): one run serves every poll for a while */
64
+ let deps: { at: number; value: ReturnType<typeof checkDeps> } | null = null;
65
+ const DEPS_TTL_PREPARING = 4_000;
66
+ const DEPS_TTL_READY = 5 * 60_000;
67
+ const DEV = existsSync(join(CODE_ROOT, ".git"));
68
+
69
+ export function getComputer(fresh = false): Computer {
70
+ const ready = (d: ReturnType<typeof checkDeps>) => !!(d.ffmpeg && d.ffprobe && d.chrome && d.python);
71
+ const ttl = deps && ready(deps.value) ? DEPS_TTL_READY : DEPS_TTL_PREPARING;
72
+ if (fresh || !deps || Date.now() - deps.at > ttl) deps = { at: Date.now(), value: checkDeps(CODE_ROOT) };
73
+ const d = deps.value;
74
+ let rules: string[];
30
75
  try {
31
- cached = { at: Date.now(), r: JSON.parse(r.stdout) };
32
- return cached.r;
76
+ rules = missingRules();
33
77
  } catch {
34
- return null;
78
+ rules = [];
35
79
  }
80
+ return {
81
+ agent: agentAccount(),
82
+ ffmpeg: d.ffmpeg || null,
83
+ ffprobe: d.ffprobe || null,
84
+ chrome: d.chrome || null,
85
+ python: d.python || null,
86
+ ytdlp: d.ytdlp || null,
87
+ ready: ready(d),
88
+ missing_rules: rules,
89
+ dev: DEV,
90
+ };
91
+ }
92
+
93
+ /* the studio's sessions need one enabled account with a login (tools/lib/agents.mjs): with no list, the
94
+ Claude Code of this machine, which may live in another folder than the one looked at */
95
+ function agentAccount(): Computer["agent"] {
96
+ const on = accountsOf(readConfig()).filter(a => a.enabled);
97
+ const logged = on.find(a => loginOf(a));
98
+ if (logged) return { ok: true, detail: loginOf(logged)?.email ?? tildify(logged.dir) };
99
+ return {
100
+ ok: false,
101
+ detail: on.length ? `sem login em ${on.map(a => tildify(a.dir)).join(", ")}` : "nenhuma conta ligada",
102
+ };
103
+ }
104
+
105
+ const PREPARING = "preparando…";
106
+
107
+ function computerStep(c: Computer): StepCheck {
108
+ const program = (id: keyof Computer, title: string) => {
109
+ const v = c[id] as string | null;
110
+ return item(id, title, v ? "ok" : "missing", v ?? PREPARING);
111
+ };
112
+ return {
113
+ items: [
114
+ program("ffmpeg", "ffmpeg"),
115
+ program("ffprobe", "ffprobe"),
116
+ program("chrome", "Chrome sem janela"),
117
+ program("python", "Python das ferramentas"),
118
+ /* optional: a warning while missing, so it never holds up the computer step */
119
+ item("ytdlp", "yt-dlp", c.ytdlp ? "ok" : "warning", c.ytdlp ?? PREPARING),
120
+ item("agent", "Conta do Claude Code", c.agent.ok ? "ok" : "missing", c.agent.detail),
121
+ item(
122
+ "skills",
123
+ "Permissão das skills no Claude Code",
124
+ c.missing_rules.length && !c.dev ? "missing" : "ok",
125
+ !c.missing_rules.length
126
+ ? "liberadas"
127
+ : c.dev
128
+ ? "repositório de desenvolvimento: as skills são as dele"
129
+ : `faltam ${c.missing_rules.length} regras`,
130
+ ),
131
+ ],
132
+ };
36
133
  }
37
134
 
135
+ /* --- the steps that keep the user busy meanwhile -------------------------------------- */
136
+
137
+ const PROVIDERS = [
138
+ ["fish", "Fish Audio"],
139
+ ["cartesia", "Cartesia"],
140
+ ["elevenlabs", "ElevenLabs"],
141
+ ] as const;
142
+
143
+ function voiceSteps(): Record<"voice-key" | "voice-clone" | "voice-default", StepCheck> {
144
+ const voice = readConfig()?.voice ?? {};
145
+ const withKey = PROVIDERS.filter(([id]) => providerKey(id));
146
+ const profiles: { id: string; name?: string; [provider: string]: unknown }[] = voice.profiles ?? [];
147
+ const nameOf = (p: (typeof profiles)[number]) => String(p.name ?? p.id);
148
+ const usable = profiles.filter(p => withKey.some(([id]) => p[id]));
149
+ const active = profiles.find(p => p.id === voice.default_profile);
150
+ return {
151
+ "voice-key": {
152
+ items: [
153
+ item(
154
+ "voice-key",
155
+ "Chave de um provider de voz",
156
+ withKey.length ? "ok" : "missing",
157
+ withKey.length ? withKey.map(([, name]) => name).join(", ") : "nenhuma chave da Fish Audio ou da Cartesia",
158
+ ),
159
+ ],
160
+ },
161
+ "voice-clone": {
162
+ items: [
163
+ item(
164
+ "voice-profile",
165
+ "Uma voz clonada",
166
+ usable.length ? "ok" : "missing",
167
+ usable.length
168
+ ? usable.map(nameOf).join(", ")
169
+ : profiles.length
170
+ ? "a voz criada não tem o ID de um provider com chave"
171
+ : "nenhuma voz ainda",
172
+ ),
173
+ ],
174
+ },
175
+ "voice-default": {
176
+ items: [
177
+ item(
178
+ "voice-active",
179
+ "Voz padrão do vídeo novo",
180
+ active ? "ok" : "missing",
181
+ active ? nameOf(active) : profiles.length ? "nenhuma voz marcada como padrão" : "clone uma voz antes",
182
+ ),
183
+ ],
184
+ },
185
+ };
186
+ }
187
+
188
+ function platformsStep(): StepCheck {
189
+ const env = readEnv();
190
+ /* the same tokens Configurações → Plataformas reads */
191
+ const on = (id: string, title: string, connected: boolean) =>
192
+ item(id, title, connected ? "ok" : "warning", connected ? "conectado" : "não conectado");
193
+ return {
194
+ optional: true,
195
+ items: [
196
+ on("youtube", "YouTube", !!env.YOUTUBE_REFRESH_TOKEN),
197
+ on("instagram", "Instagram", !!env.INSTAGRAM_TOKEN),
198
+ on("tiktok", "TikTok", !!env.TIKTOK_REFRESH_TOKEN),
199
+ ],
200
+ };
201
+ }
202
+
203
+ function styleStep(): StepCheck {
204
+ let n = 0;
205
+ try {
206
+ n = readdirSync(LIBRARY_DIR).filter(name => {
207
+ if (name.startsWith(".")) return false;
208
+ const dir = join(LIBRARY_DIR, name);
209
+ return statSync(dir).isDirectory() && !existsSync(join(dir, ".disabled"));
210
+ }).length;
211
+ } catch {
212
+ /* no library yet */
213
+ }
214
+ return {
215
+ items: [
216
+ item(
217
+ "style",
218
+ "Um estilo visual",
219
+ n ? "ok" : "missing",
220
+ n ? `${n} na biblioteca de estilos visuais` : "a biblioteca está vazia",
221
+ ),
222
+ ],
223
+ };
224
+ }
225
+
226
+ function firstVideoStep(): StepCheck {
227
+ let done: string | null = null;
228
+ for (const [slug, dir] of videoDirs()) {
229
+ try {
230
+ if (readdirSync(join(dir, "export")).some(f => f.endsWith(".mp4"))) {
231
+ done = slug;
232
+ break;
233
+ }
234
+ } catch {
235
+ /* not exported */
236
+ }
237
+ }
238
+ return {
239
+ items: [item("video", "Um vídeo exportado", done ? "ok" : "missing", done ?? "nenhum ainda")],
240
+ };
241
+ }
242
+
243
+ /* --- the guide --------------------------------------------------------------------------- */
244
+
38
245
  /* an image relative to the guide (onboarding/x.png) becomes the studio's /docs/… route; a link to another
39
246
  repo file becomes the path in code (in the studio it has nowhere to go) */
40
247
  const withImages = (md: string) =>
@@ -44,10 +251,21 @@ const withImages = (md: string) =>
44
251
  text.includes("`") ? text : `${text} (\`${target}\`)`,
45
252
  );
46
253
 
254
+ function checks(computer: Computer): Record<string, StepCheck> {
255
+ return {
256
+ computer: computerStep(computer),
257
+ ...voiceSteps(),
258
+ platforms: platformsStep(),
259
+ style: styleStep(),
260
+ "first-video": firstVideoStep(),
261
+ };
262
+ }
263
+
47
264
  export async function getOnboarding(fresh = false) {
48
265
  const text = readText(GUIDE);
49
266
  if (!text) return { error: "docs/onboarding.md não existe" };
50
- const checks = await runChecks(fresh);
267
+ const computer = getComputer(fresh);
268
+ const all = checks(computer);
51
269
  const [head, ...sections] = text.split(/<!--\s*step:\s*([\w-]+)\s*-->/);
52
270
  /* split interleaves: id, body, id, body… */
53
271
  const steps: OnboardingStep[] = [];
@@ -55,18 +273,21 @@ export async function getOnboarding(fresh = false) {
55
273
  const id = sections[i];
56
274
  const body = sections[i + 1].trim();
57
275
  const m = body.match(/^##\s+(.+)\n/);
58
- const c = checks?.steps.find(p => p.id === id);
276
+ const c = all[id];
59
277
  steps.push({
60
278
  id,
61
279
  title: m ? m[1].trim() : id,
62
280
  markdown: withImages(m ? body.slice(m[0].length) : body).trim(),
63
- state: c?.state ?? null,
281
+ state: c ? stateOf(c.items) : null,
64
282
  optional: !!c?.optional,
65
283
  items: c?.items ?? [],
66
284
  });
67
285
  }
286
+ /* ready for the first video: every required step before it with nothing missing */
287
+ const upTo = steps.findIndex(s => s.id === "first-video");
288
+ const ready = steps.slice(0, upTo < 0 ? steps.length : upTo).every(s => s.optional || s.state !== "missing");
68
289
  const intro = withImages(head.replace(/^#\s+.+\n/, "")).trim();
69
- return { ready: checks?.ready ?? false, checked: !!checks, intro, steps };
290
+ return { ready, checked: true, intro, steps, computer };
70
291
  }
71
292
 
72
293
  /* /docs/<path>: the guide's images */
@@ -78,7 +78,7 @@ export const ENV_KEYS = [
78
78
  ...PUBLISH_ENV_KEYS,
79
79
  ];
80
80
 
81
- function readEnv(): Record<string, string> {
81
+ export function readEnv(): Record<string, string> {
82
82
  const vals: Record<string, string> = {};
83
83
  for (const line of (readText(ENV_FILE) ?? "").split("\n")) {
84
84
  const l = line.trim();
@@ -562,7 +562,8 @@ export function setCartesiaPlan(plan: unknown, quota: unknown, day: unknown): Re
562
562
 
563
563
  /* --- agent accounts --------------------------------------------------------------------
564
564
  `agents.accounts` (N Claude config dirs, N Codex homes) and `agents.codex` (model, effort,
565
- sandbox); the rotation and the limits live in tools/lib/agents.mjs. ~/.claude is refused there. */
565
+ sandbox); the rotation and the limits live in tools/lib/agents.mjs. With no list, the one account is the
566
+ Claude Code this machine uses (~/.claude, or the folder the plugin's hook recorded). */
566
567
  const SANDBOXES = ["auto", "full"];
567
568
 
568
569
  function agentsSettings(cfg: any) {
@@ -579,6 +580,7 @@ function agentsSettings(cfg: any) {
579
580
  limited_until: limitedUntil(ROOT, a.id) || null,
580
581
  login_command: loginCommand(a, cfg),
581
582
  logging_in: logins[a.id] ?? null,
583
+ implicit: a.implicit,
582
584
  })),
583
585
  /* who takes the next new session */
584
586
  next: pickAccount(ROOT, cfg, { advance: false })?.id ?? null,
@@ -1042,6 +1042,8 @@ export type AgentAccount = {
1042
1042
  limited_until: number | null;
1043
1043
  login_command: string;
1044
1044
  logging_in: { state: "waiting" | "ok" | "error"; url: string | null; output: string; at: number } | null;
1045
+ /* the default account (no list in the config): the Claude Code this machine uses */
1046
+ implicit: boolean;
1045
1047
  };
1046
1048
  export type AgentsSettings = {
1047
1049
  accounts: AgentAccount[];
@@ -1067,7 +1069,30 @@ export type OnboardingStep = {
1067
1069
  optional: boolean;
1068
1070
  items: OnboardingCheck[];
1069
1071
  };
1070
- export type Onboarding = { ready: boolean; checked: boolean; intro: string; steps: OnboardingStep[]; error?: string };
1072
+ /* this computer: what the CLI downloads into ~/.omni (tools/lib/deps.mjs checkDeps, a version line or
1073
+ path, null while it isn't there yet) and the skills' permission rules (tools/lib/claude-settings.mjs) */
1074
+ export type OnboardingComputer = {
1075
+ ffmpeg: string | null;
1076
+ ffprobe: string | null;
1077
+ chrome: string | null;
1078
+ python: string | null;
1079
+ /* only the visual style from a link needs it: not part of `ready` */
1080
+ ytdlp: string | null;
1081
+ ready: boolean;
1082
+ missing_rules: string[];
1083
+ /* a development checkout: the repository's own skills, the plugin's rules don't block */
1084
+ dev: boolean;
1085
+ /* the agent account the sessions run on: its e-mail, or where no login was found */
1086
+ agent: { ok: boolean; detail: string };
1087
+ };
1088
+ export type Onboarding = {
1089
+ ready: boolean;
1090
+ checked: boolean;
1091
+ intro: string;
1092
+ steps: OnboardingStep[];
1093
+ computer?: OnboardingComputer;
1094
+ error?: string;
1095
+ };
1071
1096
 
1072
1097
  /* --- analytics (tools/analysis.mjs context; metrics from tools/metrics.mjs) ----------------- */
1073
1098
  export type MetricsSnapshot = {
@@ -1,15 +1,27 @@
1
- /* Getting started (/api/onboarding): the docs/onboarding.md guide in steps, each with the
2
- tools/setup.mjs check. `reload(true)` asks for a fresh check (?fresh=1). */
1
+ /* Getting started (/api/onboarding): the docs/onboarding.md guide in steps, each with its state, and
2
+ this computer (what the CLI is still downloading, the skills' permission). While the computer isn't
3
+ ready the page asks again every 5 s, so "preparando…" turns into "Computador pronto" on its own.
4
+ `reload(true)` asks for a fresh check (?fresh=1). */
3
5
 
4
6
  import { useCallback, useState } from "react";
7
+ import { useQuery } from "@tanstack/react-query";
5
8
  import { api, errorText, read } from "../lib/api";
6
9
  import { queryClient, queryKeys } from "../lib/query-client";
7
10
  import type { Onboarding } from "../types/api";
8
- import { useLiveQuery } from "./useLiveQuery";
11
+
12
+ const POLL_MS = 5_000;
13
+
14
+ /** the computer still has something to answer for (a program downloading, the skills' rules) */
15
+ export const computerPending = (o: Onboarding | null | undefined) =>
16
+ !!o?.computer && (!o.computer.ready || (!o.computer.dev && o.computer.missing_rules.length > 0));
9
17
 
10
18
  export function useOnboarding() {
11
- const { data, error, isFetching } = useLiveQuery(queryKeys.onboarding, () => api.onboarding.$get({ query: {} }));
12
- const onboarding: Onboarding | null = data;
19
+ const q = useQuery({
20
+ queryKey: queryKeys.onboarding,
21
+ queryFn: (): Promise<Onboarding> => read(api.onboarding.$get({ query: {} })),
22
+ refetchInterval: query => (computerPending(query.state.data) ? POLL_MS : false),
23
+ });
24
+ const onboarding: Onboarding | null = q.data ?? null;
13
25
  const [freshError, setFreshError] = useState<string | null>(null);
14
26
  const [checking, setChecking] = useState(false);
15
27
  const reload = useCallback(async (fresh = false) => {
@@ -23,5 +35,11 @@ export function useOnboarding() {
23
35
  }
24
36
  setChecking(false);
25
37
  }, []);
26
- return { onboarding, error: freshError ?? error, checking: checking || isFetching, reload };
38
+ return {
39
+ onboarding,
40
+ error: freshError ?? (q.error ? errorText(q.error) : null),
41
+ /* the 5 s poll doesn't count as "Conferindo…" */
42
+ checking,
43
+ reload,
44
+ };
27
45
  }
@@ -119,6 +119,16 @@ function agentAlerts(agents: AgentsSettings, now: number): Alert[] {
119
119
  if (on.some(a => a.login && !limited(a))) return [];
120
120
  const until = on.filter(a => a.login && limited(a)).map(a => a.limited_until!);
121
121
  if (!until.length) {
122
+ /* the default account (~/.claude) without a login: the Claude Code may live in another folder */
123
+ if (on.length === 1 && on[0].implicit && on[0].provider === "claude")
124
+ return [
125
+ {
126
+ key: "agents:no-claude",
127
+ label: "Não achei o login do Claude Code",
128
+ message: `Em ${on[0].dir}: entre, ou coloque a pasta dele em Configurações`,
129
+ link,
130
+ },
131
+ ];
122
132
  return [
123
133
  {
124
134
  key: "agents:no-login",
@@ -0,0 +1,102 @@
1
+ /* "Este computador", on top of Getting started: what `omni up` is still preparing on this Mac, live.
2
+ Being on the page already means the login and the studio connection work (the hosted studio only
3
+ answers through this computer's runner). The four programs come from checkDeps: while one is
4
+ missing it reads "preparando…" (the CLI is downloading it; nothing for the user to do). The skills'
5
+ permission rules are written by Claude, on the user's yes, through the plugin's MCP: the studio only
6
+ says what to ask for. The Claude Code account is looked for where the plugin's hook found it, else
7
+ ~/.claude; without a login there, the user enters or gives the folder in Settings. */
8
+
9
+ import { Link } from "@tanstack/react-router";
10
+ import { Badge } from "../../../components/ui/badge";
11
+ import { Markdown } from "../../../components/Markdown";
12
+ import { Check, X } from "../../../components/icons";
13
+ import { useRunner } from "../../../hooks/useRunner";
14
+ import type { OnboardingComputer } from "../../../types/api";
15
+
16
+ type Row = { id: string; title: string; value: string | null; detail?: string };
17
+
18
+ export function ComputerCard({ computer: c, markdown }: { computer: OnboardingComputer; markdown: string }) {
19
+ const { hosted, name } = useRunner();
20
+ const rulesOk = c.dev || c.missing_rules.length === 0;
21
+ const programs: Row[] = [
22
+ { id: "ffmpeg", title: "ffmpeg", value: c.ffmpeg },
23
+ { id: "ffprobe", title: "ffprobe", value: c.ffprobe },
24
+ { id: "chrome", title: "Chrome sem janela", value: c.chrome },
25
+ { id: "python", title: "Python das ferramentas", value: c.python },
26
+ { id: "ytdlp", title: "yt-dlp", value: c.ytdlp },
27
+ ];
28
+ const state = !c.ready ? "preparing" : !c.agent.ok ? "agent" : rulesOk ? "ok" : "rules";
29
+ return (
30
+ <section className={`onboarding-computer ${state}`} aria-live="polite" aria-labelledby="computer-title">
31
+ <header>
32
+ <h2 id="computer-title">Este computador</h2>
33
+ {state === "ok" && <Badge variant="ok">Computador pronto</Badge>}
34
+ {state === "rules" && <Badge variant="warning">Falta liberar as skills</Badge>}
35
+ {state === "agent" && <Badge variant="warning">Falta a conta do Claude Code</Badge>}
36
+ {state === "preparing" && (
37
+ <Badge variant="running">
38
+ <span className="spinning" aria-hidden="true" /> Preparando…
39
+ </Badge>
40
+ )}
41
+ </header>
42
+ {state === "preparing" && (
43
+ <p className="subtle">
44
+ O OmniVideo está baixando o que falta. Leva alguns minutos e não precisa fazer nada: siga pelos passos abaixo
45
+ enquanto isso.
46
+ </p>
47
+ )}
48
+ <ul className="onboarding-items">
49
+ <li className="ok">
50
+ <span className="onboarding-item-mark">
51
+ <Check weight="bold" />
52
+ </span>
53
+ <b>Login e studio conectados</b>
54
+ <span className="subtle">
55
+ {hosted ? (name ? `conectado pelo ${name}` : "conectado") : "studio aberto neste computador"}
56
+ </span>
57
+ </li>
58
+ {programs.map(p => (
59
+ <li key={p.id} className={p.value ? "ok" : "preparing"}>
60
+ <span className="onboarding-item-mark">
61
+ {p.value ? <Check weight="bold" /> : <span className="spinning" aria-hidden="true" />}
62
+ </span>
63
+ <b>{p.title}</b>
64
+ <span className="subtle">{p.value ?? "preparando…"}</span>
65
+ </li>
66
+ ))}
67
+ <li className={c.agent.ok ? "ok" : "missing"}>
68
+ <span className="onboarding-item-mark">{c.agent.ok ? <Check weight="bold" /> : <X weight="bold" />}</span>
69
+ <b>Conta do Claude Code</b>
70
+ <span className="subtle">{c.agent.detail}</span>
71
+ </li>
72
+ <li className={rulesOk ? "ok" : "missing"}>
73
+ <span className="onboarding-item-mark">{rulesOk ? <Check weight="bold" /> : <X weight="bold" />}</span>
74
+ <b>Permissão das skills no Claude Code</b>
75
+ <span className="subtle">
76
+ {c.missing_rules.length === 0
77
+ ? "liberadas"
78
+ : c.dev
79
+ ? "repositório de desenvolvimento: as skills são as dele"
80
+ : "ainda não liberadas"}
81
+ </span>
82
+ </li>
83
+ </ul>
84
+ {!c.agent.ok && (
85
+ <p className="onboarding-ask">
86
+ Não achei o login do seu Claude Code. Se ele usa outra pasta, coloque ela em{" "}
87
+ <Link to="/settings/$category" params={{ category: "agent" }}>
88
+ Configurações → Agente de IA
89
+ </Link>
90
+ ; se é essa mesma, entre por lá.
91
+ </p>
92
+ )}
93
+ {!rulesOk && (
94
+ <p className="onboarding-ask">
95
+ No Claude Code, diga: <q>libere as skills do OmniVideo</q>. O Claude mostra as regras, pede o seu ok e grava.
96
+ Esta tela atualiza sozinha.
97
+ </p>
98
+ )}
99
+ {markdown && <Markdown text={markdown} />}
100
+ </section>
101
+ );
102
+ }