@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.
- package/docs/onboarding.md +69 -161
- package/omnivideo.config.example.json +1 -1
- package/package.json +1 -1
- package/reference/omnichat.md +5 -4
- package/reference/publish.md +9 -9
- package/reference/studio.md +1 -1
- package/studio/api/services/onboarding.ts +242 -21
- package/studio/api/services/settings.ts +4 -2
- package/studio/shared/types/api.ts +26 -1
- package/studio/web/hooks/useOnboarding.ts +24 -6
- package/studio/web/lib/alerts.ts +10 -0
- package/studio/web/pages/GettingStartedPage/components/ComputerCard.tsx +102 -0
- package/studio/web/pages/GettingStartedPage/index.tsx +84 -58
- package/studio/web/pages/SettingsPage/components/AgentAccounts.tsx +30 -3
- package/studio/web/styles/pages/getting-started.css +46 -0
- package/studio/web/styles/pages/settings/shell.css +4 -0
- package/tools/lib/agents.mjs +24 -13
- package/tools/lib/claude-settings.mjs +32 -3
- package/tools/lib/deps.mjs +24 -0
- package/tools/lib/platforms/instagram.mjs +1 -1
- package/tools/lib/up.mjs +5 -2
- package/tools/omni.mjs +7 -5
- package/tools/omnichat.mjs +5 -7
- package/tools/package.mjs +5 -3
- package/tools/publish.mjs +40 -137
- package/tools/reference.mjs +1 -1
- package/tools/setup.mjs +16 -15
|
@@ -1,50 +1,22 @@
|
|
|
1
|
-
/* Getting started: the docs/onboarding.md guide, one step per section,
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/* Getting started: the docs/onboarding.md guide, one step per section. On top, "Este computador": what
|
|
2
|
+
the CLI is still preparing on this Mac, live (the page asks again every 5 s until it's ready). Below,
|
|
3
|
+
the steps that keep the user busy meanwhile (voice, platforms, style, first video), each with its
|
|
4
|
+
state and the shortcut to the screen where it gets solved. The text is the guide's: changed there,
|
|
5
|
+
changes here. Opens on the first missing step. */
|
|
4
6
|
|
|
5
7
|
import { useEffect, useState, type ReactNode } from "react";
|
|
6
|
-
import { Link } from "@tanstack/react-router";
|
|
8
|
+
import { Link, useNavigate } from "@tanstack/react-router";
|
|
7
9
|
import { useOnboarding } from "../../hooks/useOnboarding";
|
|
10
|
+
import { useSettings } from "../../hooks/useSettings";
|
|
11
|
+
import { invalidate, queryKeys } from "../../lib/query-client";
|
|
8
12
|
import { Badge } from "../../components/ui/badge";
|
|
9
13
|
import { Skeleton } from "../../components/ui/skeleton";
|
|
10
14
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "../../components/ui/accordion";
|
|
11
15
|
import { Markdown } from "../../components/Markdown";
|
|
12
|
-
import {
|
|
16
|
+
import { CloneVoiceDialog } from "../../components/CloneVoiceDialog";
|
|
17
|
+
import { ArrowClockwise, Check, Gear, Microphone, Plug, Plus, Stack, X } from "../../components/icons";
|
|
13
18
|
import type { OnboardingCheck, OnboardingStep } from "../../types/api";
|
|
14
|
-
|
|
15
|
-
/* where each step gets solved in the studio */
|
|
16
|
-
const STEP_ACTIONS: Record<string, ReactNode> = {
|
|
17
|
-
claude: (
|
|
18
|
-
<Link to="/settings/$category" params={{ category: "agent" }} className="button">
|
|
19
|
-
<Sparkle className="ic" /> Configurações → Agente de IA
|
|
20
|
-
</Link>
|
|
21
|
-
),
|
|
22
|
-
voice: (
|
|
23
|
-
<>
|
|
24
|
-
<Link to="/settings/$category" params={{ category: "providers" }} className="button">
|
|
25
|
-
<Gear className="ic" /> Provedores de voz
|
|
26
|
-
</Link>
|
|
27
|
-
<Link to="/settings/$category" params={{ category: "voice" }} className="button">
|
|
28
|
-
<Microphone className="ic" /> Clonar a minha voz
|
|
29
|
-
</Link>
|
|
30
|
-
</>
|
|
31
|
-
),
|
|
32
|
-
reference: (
|
|
33
|
-
<Link to="/styles" className="button">
|
|
34
|
-
<Stack className="ic" /> Abrir Estilos visuais
|
|
35
|
-
</Link>
|
|
36
|
-
),
|
|
37
|
-
"first-video": (
|
|
38
|
-
<Link to="/new" className="button primary">
|
|
39
|
-
<Plus className="ic" /> Criar vídeo
|
|
40
|
-
</Link>
|
|
41
|
-
),
|
|
42
|
-
publish: (
|
|
43
|
-
<Link to="/settings/$category" params={{ category: "platforms" }} className="button">
|
|
44
|
-
<Plug className="ic" /> Conectar as plataformas
|
|
45
|
-
</Link>
|
|
46
|
-
),
|
|
47
|
-
};
|
|
19
|
+
import { ComputerCard } from "./components/ComputerCard";
|
|
48
20
|
|
|
49
21
|
const STEP_MARK: Record<OnboardingCheck["state"], ReactNode> = {
|
|
50
22
|
ok: <Check weight="bold" />,
|
|
@@ -54,8 +26,9 @@ const STEP_MARK: Record<OnboardingCheck["state"], ReactNode> = {
|
|
|
54
26
|
|
|
55
27
|
function stepBadge(s: OnboardingStep) {
|
|
56
28
|
if (s.state === "ok") return <Badge variant="ok">Pronto</Badge>;
|
|
29
|
+
if (s.optional) return <Badge variant="neutral">Opcional</Badge>;
|
|
57
30
|
if (s.state === "missing") return <Badge variant="error">Falta</Badge>;
|
|
58
|
-
if (s.state === "warning") return <Badge variant="warning">
|
|
31
|
+
if (s.state === "warning") return <Badge variant="warning">Atenção</Badge>;
|
|
59
32
|
return null;
|
|
60
33
|
}
|
|
61
34
|
|
|
@@ -64,17 +37,60 @@ const withoutImages = (md: string) => md.replace(/!\[[^\]]*\]\([^)]*\)\n?/g, "")
|
|
|
64
37
|
|
|
65
38
|
export function GettingStartedPage() {
|
|
66
39
|
const { onboarding: d, error: loadError, checking, reload } = useOnboarding();
|
|
40
|
+
const { settings, reload: reloadSettings } = useSettings();
|
|
41
|
+
const navigate = useNavigate();
|
|
42
|
+
const [cloning, setCloning] = useState(false);
|
|
67
43
|
const [open, setOpen] = useState<string>("");
|
|
44
|
+
const computerStep = d?.steps.find(s => s.id === "computer");
|
|
45
|
+
const steps = d?.steps.filter(s => s.id !== "computer") ?? [];
|
|
68
46
|
/* opens on the first missing step; checking again doesn't close what you opened */
|
|
69
47
|
useEffect(() => {
|
|
70
|
-
if (d)
|
|
48
|
+
if (d)
|
|
49
|
+
setOpen(o => o || (d.steps.find(s => s.id !== "computer" && !s.optional && s.state === "missing")?.id ?? ""));
|
|
71
50
|
}, [d]);
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
document.title = "Primeiros passos · Omni";
|
|
53
|
+
}, []);
|
|
72
54
|
const error = loadError ?? d?.error ?? null;
|
|
73
55
|
|
|
74
56
|
const required = d?.steps.filter(s => !s.optional) ?? [];
|
|
75
57
|
const ready = required.filter(s => s.state === "ok" || s.state === "warning").length;
|
|
76
58
|
const pct = required.length ? Math.round((ready / required.length) * 100) : 0;
|
|
77
59
|
|
|
60
|
+
/* where each step gets solved in the studio */
|
|
61
|
+
const actions: Record<string, ReactNode> = {
|
|
62
|
+
"voice-key": (
|
|
63
|
+
<Link to="/settings/$category" params={{ category: "providers" }} className="button">
|
|
64
|
+
<Gear className="ic" /> Provedores de voz
|
|
65
|
+
</Link>
|
|
66
|
+
),
|
|
67
|
+
"voice-clone": (
|
|
68
|
+
<button type="button" className="button" onClick={() => setCloning(true)} disabled={!settings}>
|
|
69
|
+
<Microphone className="ic" /> Clonar a minha voz
|
|
70
|
+
</button>
|
|
71
|
+
),
|
|
72
|
+
"voice-default": (
|
|
73
|
+
<Link to="/voice" className="button">
|
|
74
|
+
<Microphone className="ic" /> Perfil de voz
|
|
75
|
+
</Link>
|
|
76
|
+
),
|
|
77
|
+
platforms: (
|
|
78
|
+
<Link to="/settings/$category" params={{ category: "platforms" }} className="button">
|
|
79
|
+
<Plug className="ic" /> Conectar as plataformas
|
|
80
|
+
</Link>
|
|
81
|
+
),
|
|
82
|
+
style: (
|
|
83
|
+
<Link to="/styles" className="button">
|
|
84
|
+
<Stack className="ic" /> Abrir Estilos visuais
|
|
85
|
+
</Link>
|
|
86
|
+
),
|
|
87
|
+
"first-video": (
|
|
88
|
+
<Link to="/new" className="button primary">
|
|
89
|
+
<Plus className="ic" /> Criar vídeo
|
|
90
|
+
</Link>
|
|
91
|
+
),
|
|
92
|
+
};
|
|
93
|
+
|
|
78
94
|
return (
|
|
79
95
|
<div className="page onboarding">
|
|
80
96
|
<header className="header">
|
|
@@ -87,7 +103,7 @@ export function GettingStartedPage() {
|
|
|
87
103
|
</span>
|
|
88
104
|
)}
|
|
89
105
|
<span className="push-end" />
|
|
90
|
-
<button className="button" onClick={() => reload(true)} disabled={checking}>
|
|
106
|
+
<button type="button" className="button" onClick={() => reload(true)} disabled={checking}>
|
|
91
107
|
<ArrowClockwise className="ic" /> {checking ? "Conferindo…" : "Conferir de novo"}
|
|
92
108
|
</button>
|
|
93
109
|
</header>
|
|
@@ -106,37 +122,33 @@ export function GettingStartedPage() {
|
|
|
106
122
|
{!d && !error && (
|
|
107
123
|
<div
|
|
108
124
|
aria-busy="true"
|
|
109
|
-
aria-label="Conferindo
|
|
125
|
+
aria-label="Conferindo o computador"
|
|
110
126
|
style={{ display: "flex", flexDirection: "column", gap: 10 }}
|
|
111
127
|
>
|
|
112
128
|
<Skeleton className="row" style={{ width: "70%" }} />
|
|
113
|
-
<Skeleton
|
|
114
|
-
{Array.from({ length:
|
|
115
|
-
<Skeleton key={i} style={{ height: 48, width: "100%"
|
|
129
|
+
<Skeleton style={{ height: 180, width: "100%", marginTop: 6 }} />
|
|
130
|
+
{Array.from({ length: 6 }, (_, i) => (
|
|
131
|
+
<Skeleton key={i} style={{ height: 48, width: "100%" }} />
|
|
116
132
|
))}
|
|
117
133
|
</div>
|
|
118
134
|
)}
|
|
119
135
|
{d && (
|
|
120
136
|
<>
|
|
121
137
|
<Markdown text={withoutImages(d.intro)} />
|
|
138
|
+
{d.computer && <ComputerCard computer={d.computer} markdown={computerStep?.markdown ?? ""} />}
|
|
122
139
|
{d.ready && (
|
|
123
140
|
<div className="onboarding-ready">
|
|
124
141
|
<Check weight="bold" className="ic" />
|
|
125
142
|
<span>
|
|
126
|
-
<b>Tudo pronto para o primeiro vídeo.</b>
|
|
143
|
+
<b>Tudo pronto para o primeiro vídeo.</b> Conectar as redes pode ficar para depois.
|
|
127
144
|
</span>
|
|
128
145
|
<Link to="/new" className="button primary">
|
|
129
146
|
<Plus className="ic" /> Criar vídeo
|
|
130
147
|
</Link>
|
|
131
148
|
</div>
|
|
132
149
|
)}
|
|
133
|
-
{!d.checked && (
|
|
134
|
-
<p className="warning">
|
|
135
|
-
Não deu para conferir a máquina (tools/setup.mjs check). Os passos abaixo valem do mesmo jeito.
|
|
136
|
-
</p>
|
|
137
|
-
)}
|
|
138
150
|
<Accordion type="single" collapsible value={open} onValueChange={setOpen} className="onboarding-steps">
|
|
139
|
-
{
|
|
151
|
+
{steps.map((s, i) => (
|
|
140
152
|
<AccordionItem key={s.id} value={s.id} className={`onboarding-step ${s.state ?? ""}`}>
|
|
141
153
|
<AccordionTrigger>
|
|
142
154
|
<span className={`onboarding-mark ${s.state ?? ""}`}>
|
|
@@ -157,18 +169,32 @@ export function GettingStartedPage() {
|
|
|
157
169
|
))}
|
|
158
170
|
</ul>
|
|
159
171
|
)}
|
|
160
|
-
{
|
|
172
|
+
{actions[s.id] && <div className="actions onboarding-actions">{actions[s.id]}</div>}
|
|
161
173
|
<Markdown text={s.markdown} />
|
|
162
174
|
</AccordionContent>
|
|
163
175
|
</AccordionItem>
|
|
164
176
|
))}
|
|
165
177
|
</Accordion>
|
|
166
|
-
<p className="hint">
|
|
167
|
-
Este guia é o <code>docs/onboarding.md</code> do repositório. No terminal,{" "}
|
|
168
|
-
<code>node tools/setup.mjs check</code> faz a mesma conferência.
|
|
169
|
-
</p>
|
|
170
178
|
</>
|
|
171
179
|
)}
|
|
180
|
+
{cloning && settings && (
|
|
181
|
+
<CloneVoiceDialog
|
|
182
|
+
settings={settings}
|
|
183
|
+
reload={reloadSettings}
|
|
184
|
+
onClose={() => setCloning(false)}
|
|
185
|
+
onAlreadyHave={() => {
|
|
186
|
+
setCloning(false);
|
|
187
|
+
void navigate({ to: "/voice" });
|
|
188
|
+
}}
|
|
189
|
+
onCreate={warnings => {
|
|
190
|
+
setCloning(false);
|
|
191
|
+
reloadSettings();
|
|
192
|
+
void invalidate(queryKeys.onboarding);
|
|
193
|
+
/* a provider that didn't create the clone gets fixed in the voice's editor */
|
|
194
|
+
if (warnings.length) void navigate({ to: "/voice" });
|
|
195
|
+
}}
|
|
196
|
+
/>
|
|
197
|
+
)}
|
|
172
198
|
</div>
|
|
173
199
|
);
|
|
174
200
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/* The agent accounts (agents in the config, tools/lib/agents.mjs): N Claude dirs and N Codex homes in
|
|
2
2
|
rotation order. Each new session goes to the next free one; an account over its limit is set aside
|
|
3
|
-
until it resets and the session carries on in the next.
|
|
3
|
+
until it resets and the session carries on in the next. With no list saved, the one account is the Claude
|
|
4
|
+
Code this machine uses; when it has no login there, the screen asks for its folder. */
|
|
4
5
|
|
|
5
6
|
import { useEffect, useState } from "react";
|
|
6
7
|
import { useSaveAction } from "../../../hooks/useSaveAction";
|
|
@@ -16,7 +17,7 @@ import { Switch } from "../../../components/ui/switch";
|
|
|
16
17
|
type Draft = Pick<AgentAccount, "id" | "provider" | "dir" | "label" | "enabled">;
|
|
17
18
|
|
|
18
19
|
const PROVIDER: Record<AgentProvider, string> = { claude: "Claude", codex: "Codex" };
|
|
19
|
-
const SUGGESTED_DIR: Record<AgentProvider, string> = { claude: "~/.claude
|
|
20
|
+
const SUGGESTED_DIR: Record<AgentProvider, string> = { claude: "~/.claude", codex: "~/.codex" };
|
|
20
21
|
|
|
21
22
|
const at = (ms: number) =>
|
|
22
23
|
new Date(ms).toLocaleString("pt-BR", { weekday: "short", hour: "2-digit", minute: "2-digit" });
|
|
@@ -34,6 +35,7 @@ export function AgentAccounts({ agents, onSave }: { agents: AgentsSettings; onSa
|
|
|
34
35
|
const [list, setList] = useState(initial);
|
|
35
36
|
const [adding, setAdding] = useState<AgentProvider | null>(null);
|
|
36
37
|
const [newDir, setNewDir] = useState("");
|
|
38
|
+
const [claudeDir, setClaudeDir] = useState("");
|
|
37
39
|
const { result, saving, save } = useSaveAction(onSave);
|
|
38
40
|
const login = useSaveAction(onSave);
|
|
39
41
|
useEffect(() => setList(initial()), [JSON.stringify(agents.accounts)]);
|
|
@@ -78,6 +80,13 @@ export function AgentAccounts({ agents, onSave }: { agents: AgentsSettings; onSa
|
|
|
78
80
|
const ok = await store([...list, { id: "", provider: adding, dir: newDir.trim(), label: "", enabled: true }]);
|
|
79
81
|
if (ok) setAdding(null);
|
|
80
82
|
};
|
|
83
|
+
/* the default account without a login: the user's Claude Code lives in another folder */
|
|
84
|
+
const own = agents.accounts.length === 1 && agents.accounts[0].implicit && !agents.accounts[0].login;
|
|
85
|
+
const useClaudeDir = async () => {
|
|
86
|
+
if (!claudeDir.trim()) return;
|
|
87
|
+
const ok = await store([{ id: "", provider: "claude", dir: claudeDir.trim(), label: "", enabled: true }]);
|
|
88
|
+
if (ok) setClaudeDir("");
|
|
89
|
+
};
|
|
81
90
|
|
|
82
91
|
return (
|
|
83
92
|
<SettingRow
|
|
@@ -90,6 +99,24 @@ export function AgentAccounts({ agents, onSave }: { agents: AgentsSettings; onSa
|
|
|
90
99
|
</>
|
|
91
100
|
}
|
|
92
101
|
>
|
|
102
|
+
{own && (
|
|
103
|
+
<div className="setting-row agent-add agent-missing">
|
|
104
|
+
<small className="warning">
|
|
105
|
+
Não achei o login do Claude Code em <code>{agents.accounts[0].dir}</code>. Se ele usa outra pasta (a do{" "}
|
|
106
|
+
<code>CLAUDE_CONFIG_DIR</code>), coloque ela aqui; se é essa mesma, use Entrar.
|
|
107
|
+
</small>
|
|
108
|
+
<input
|
|
109
|
+
className="mono"
|
|
110
|
+
value={claudeDir}
|
|
111
|
+
onChange={e => setClaudeDir(e.target.value)}
|
|
112
|
+
aria-label="Pasta do Claude Code"
|
|
113
|
+
placeholder="~/.claude-outra"
|
|
114
|
+
/>
|
|
115
|
+
<button className="button primary" disabled={saving || !claudeDir.trim()} onClick={useClaudeDir}>
|
|
116
|
+
Usar esta pasta
|
|
117
|
+
</button>
|
|
118
|
+
</div>
|
|
119
|
+
)}
|
|
93
120
|
<div className="claude-options agent-accounts">
|
|
94
121
|
{agents.accounts.map((a, i) => {
|
|
95
122
|
const limited = a.limited_until && a.limited_until > Date.now();
|
|
@@ -204,7 +231,7 @@ export function AgentAccounts({ agents, onSave }: { agents: AgentsSettings; onSa
|
|
|
204
231
|
)}
|
|
205
232
|
<p className="hint">
|
|
206
233
|
Cada conta é uma pasta com o próprio login (<code>CLAUDE_CONFIG_DIR</code> no Claude, <code>CODEX_HOME</code> no
|
|
207
|
-
Codex).
|
|
234
|
+
Codex). Sem nenhuma conta salva, vale o Claude Code deste computador.
|
|
208
235
|
{!agents.codex.installed && " O Codex não está instalado: instale o app do ChatGPT ou o codex no PATH."}
|
|
209
236
|
</p>
|
|
210
237
|
<ResultMessage result={result ?? login.result} />
|
|
@@ -164,3 +164,49 @@
|
|
|
164
164
|
padding-left: 16px;
|
|
165
165
|
}
|
|
166
166
|
}
|
|
167
|
+
|
|
168
|
+
/* --- "Este computador": what the CLI is still preparing, live -------------------------------- */
|
|
169
|
+
.onboarding-computer {
|
|
170
|
+
margin: 16px 0 4px;
|
|
171
|
+
padding: 14px 16px 16px;
|
|
172
|
+
border-radius: var(--radius);
|
|
173
|
+
background: var(--surface);
|
|
174
|
+
}
|
|
175
|
+
.onboarding-computer.ok {
|
|
176
|
+
background: var(--ok-bg);
|
|
177
|
+
}
|
|
178
|
+
.onboarding-computer > header {
|
|
179
|
+
display: flex;
|
|
180
|
+
align-items: center;
|
|
181
|
+
gap: 10px;
|
|
182
|
+
margin-bottom: 10px;
|
|
183
|
+
}
|
|
184
|
+
.onboarding-computer h2 {
|
|
185
|
+
margin: 0;
|
|
186
|
+
font-size: 15px;
|
|
187
|
+
font-weight: 600;
|
|
188
|
+
}
|
|
189
|
+
.onboarding-computer > p {
|
|
190
|
+
margin: 0 0 12px;
|
|
191
|
+
font-size: 13px;
|
|
192
|
+
}
|
|
193
|
+
.onboarding-computer .badge .spinning {
|
|
194
|
+
width: 10px;
|
|
195
|
+
height: 10px;
|
|
196
|
+
border-width: 1.5px;
|
|
197
|
+
}
|
|
198
|
+
.onboarding-computer .onboarding-items li.preparing .onboarding-item-mark {
|
|
199
|
+
background: none;
|
|
200
|
+
}
|
|
201
|
+
.onboarding-computer .onboarding-items li.preparing .spinning {
|
|
202
|
+
width: 12px;
|
|
203
|
+
height: 12px;
|
|
204
|
+
}
|
|
205
|
+
.onboarding-ask {
|
|
206
|
+
padding: 10px 12px;
|
|
207
|
+
border-radius: var(--radius-sm);
|
|
208
|
+
background: var(--warning-bg);
|
|
209
|
+
}
|
|
210
|
+
.onboarding-ask q {
|
|
211
|
+
font-weight: 600;
|
|
212
|
+
}
|
package/tools/lib/agents.mjs
CHANGED
|
@@ -8,20 +8,28 @@
|
|
|
8
8
|
a session stays on the account it runs on while that one is free.
|
|
9
9
|
- Limit: an account that hits its usage limit is set aside until it resets
|
|
10
10
|
(`.accounts/state.json`), and the work goes on in the next free one.
|
|
11
|
-
-
|
|
12
|
-
|
|
11
|
+
- Empty list: the Claude Code this machine uses, the folder the plugin's hook
|
|
12
|
+
recorded (claude-settings.mjs), else ~/.claude. Without a login there the
|
|
13
|
+
studio says so and the user puts the right folder in Settings. A machine
|
|
14
|
+
that must stay off ~/.claude (a work account) lists its accounts.
|
|
15
|
+
- ~/.claude is Claude Code's own folder: its login is ~/.claude.json and the
|
|
16
|
+
child runs without CLAUDE_CONFIG_DIR (set to it, Claude Code looks for
|
|
17
|
+
another keychain entry and finds no login).
|
|
13
18
|
============================================================================ */
|
|
14
19
|
|
|
15
20
|
import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
16
21
|
import { homedir } from "node:os";
|
|
17
22
|
import { delimiter, join, resolve } from "node:path";
|
|
23
|
+
import { DEFAULT_CLAUDE_DIR, recordedClaudeDir } from "./claude-settings.mjs";
|
|
18
24
|
|
|
19
25
|
export const PROVIDERS = ["claude", "codex"];
|
|
20
26
|
export const PROVIDER_LABEL = { claude: "Claude", codex: "Codex" };
|
|
21
|
-
/* the
|
|
22
|
-
export const
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
/* with no list: the Claude Code this machine uses */
|
|
28
|
+
export const defaultAccounts = () => [
|
|
29
|
+
{ id: "claude", provider: "claude", dir: recordedClaudeDir() ?? DEFAULT_CLAUDE_DIR },
|
|
30
|
+
];
|
|
31
|
+
/* Claude Code's own folder, which runs without CLAUDE_CONFIG_DIR */
|
|
32
|
+
const isDefaultClaude = a => a.provider === "claude" && expandHome(a.dir) === DEFAULT_CLAUDE_DIR;
|
|
25
33
|
/* the Codex CLI the ChatGPT app ships, when there is no `codex` on the PATH */
|
|
26
34
|
export const CODEX_APP_BIN = "/Applications/ChatGPT.app/Contents/Resources/codex-cli/bin/codex";
|
|
27
35
|
export const CODEX_DEFAULTS = { model: "gpt-6-luna", effort: "medium", sandbox: "auto" };
|
|
@@ -70,8 +78,6 @@ export function accountProblem(a) {
|
|
|
70
78
|
if (!PROVIDERS.includes(a?.provider)) return `provedor desconhecido: ${a?.provider}`;
|
|
71
79
|
if (!String(a.dir ?? "").trim()) return "falta a pasta da conta";
|
|
72
80
|
const dir = expandHome(a.dir);
|
|
73
|
-
if (dir === FORBIDDEN_DIR || dir.startsWith(`${FORBIDDEN_DIR}/`))
|
|
74
|
-
return "o ~/.claude é a conta padrão do Claude Code (pode ser a do trabalho): o omnivideo não roda nela";
|
|
75
81
|
if (dir === homedir()) return "a pasta da conta não pode ser a home";
|
|
76
82
|
return null;
|
|
77
83
|
}
|
|
@@ -84,12 +90,13 @@ const slug = s =>
|
|
|
84
90
|
.replace(/^-|-$/g, "") || "conta";
|
|
85
91
|
|
|
86
92
|
/**
|
|
87
|
-
* The accounts in order, normalized: `dir` absolute, `id` unique, the
|
|
88
|
-
*
|
|
93
|
+
* The accounts in order, normalized: `dir` absolute, `id` unique, the invalid ones left out. `implicit`:
|
|
94
|
+
* the default account, from an empty list.
|
|
95
|
+
* @returns {{ id: string, provider: "claude"|"codex", dir: string, label: string, enabled: boolean, implicit: boolean }[]}
|
|
89
96
|
*/
|
|
90
97
|
export function accountsOf(cfg) {
|
|
91
|
-
const
|
|
92
|
-
|
|
98
|
+
const listed = Array.isArray(cfg?.agents?.accounts) && cfg.agents.accounts.length > 0;
|
|
99
|
+
const raw = listed ? cfg.agents.accounts : defaultAccounts();
|
|
93
100
|
const seen = new Set();
|
|
94
101
|
const out = [];
|
|
95
102
|
for (const a of raw) {
|
|
@@ -105,6 +112,7 @@ export function accountsOf(cfg) {
|
|
|
105
112
|
dir,
|
|
106
113
|
label: String(a.label ?? "").trim() || tildify(dir),
|
|
107
114
|
enabled: a.enabled !== false,
|
|
115
|
+
implicit: !listed,
|
|
108
116
|
});
|
|
109
117
|
}
|
|
110
118
|
return out;
|
|
@@ -151,7 +159,8 @@ const jwtPayload = token => {
|
|
|
151
159
|
*/
|
|
152
160
|
export function loginOf(a) {
|
|
153
161
|
if (a.provider === "claude") {
|
|
154
|
-
const
|
|
162
|
+
const file = isDefaultClaude(a) ? join(homedir(), ".claude.json") : join(a.dir, ".claude.json");
|
|
163
|
+
const o = readJson(file)?.oauthAccount;
|
|
155
164
|
if (!o) return null;
|
|
156
165
|
const org =
|
|
157
166
|
o.organizationName && o.organizationName !== `${o.emailAddress}'s Organization` ? o.organizationName : null;
|
|
@@ -171,6 +180,7 @@ export function loginOf(a) {
|
|
|
171
180
|
/* the command that logs an account in, for the screen and the CLI hints */
|
|
172
181
|
export function loginCommand(a, cfg) {
|
|
173
182
|
const dir = tildify(a.dir);
|
|
183
|
+
if (isDefaultClaude(a)) return "claude auth login";
|
|
174
184
|
return a.provider === "claude"
|
|
175
185
|
? `CLAUDE_CONFIG_DIR=${dir} claude auth login`
|
|
176
186
|
: `CODEX_HOME=${dir} ${codexBin(cfg) === "codex" ? "codex" : `"${codexBin(cfg)}"`} login`;
|
|
@@ -194,6 +204,7 @@ export const binOf = (a, cfg) => (a.provider === "claude" ? claudeBin(cfg) : cod
|
|
|
194
204
|
export function envFor(a, base = process.env, extra = {}) {
|
|
195
205
|
const env = { ...base, ...extra };
|
|
196
206
|
for (const k of INHERITED) delete env[k];
|
|
207
|
+
if (isDefaultClaude(a)) return env;
|
|
197
208
|
if (a.provider === "claude") env.CLAUDE_CONFIG_DIR = a.dir;
|
|
198
209
|
else env.CODEX_HOME = a.dir;
|
|
199
210
|
return env;
|
|
@@ -3,15 +3,44 @@
|
|
|
3
3
|
call `<cli> run …` through Bash, and their sibling files sit in a temporary folder outside the working
|
|
4
4
|
one. Written only with the user's yes (the MCP's omni_allow_skills, after Claude asks, or `omni setup`).
|
|
5
5
|
|
|
6
|
-
The settings file is
|
|
7
|
-
|
|
6
|
+
The settings file is settings.json in the Claude Code folder (claudeConfigDir). The rules are added to
|
|
7
|
+
permissions.allow; nothing else in the file changes.
|
|
8
|
+
|
|
9
|
+
The Claude Code folder is $CLAUDE_CONFIG_DIR, else the one `omni up --hook` recorded in ~/.omni/claude.json,
|
|
10
|
+
else ~/.claude. The hook runs inside the user's session, with its CLAUDE_CONFIG_DIR; the studio service
|
|
11
|
+
runs outside it and only knows the folder through that record. */
|
|
8
12
|
|
|
9
13
|
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
|
10
14
|
import { homedir, tmpdir } from "node:os";
|
|
11
15
|
import { dirname, join } from "node:path";
|
|
12
16
|
import { CLI_COMMAND } from "./cli.mjs";
|
|
17
|
+
import { OMNI_DIR } from "./deps.mjs";
|
|
18
|
+
|
|
19
|
+
/* Claude Code's own folder when CLAUDE_CONFIG_DIR is unset (its login then sits in ~/.claude.json) */
|
|
20
|
+
export const DEFAULT_CLAUDE_DIR = join(homedir(), ".claude");
|
|
21
|
+
const RECORD = join(OMNI_DIR, "claude.json");
|
|
22
|
+
|
|
23
|
+
/** the folder the hook recorded, or null @returns {string | null} */
|
|
24
|
+
export function recordedClaudeDir() {
|
|
25
|
+
try {
|
|
26
|
+
const dir = JSON.parse(readFileSync(RECORD, "utf8")).config_dir;
|
|
27
|
+
return typeof dir === "string" && dir ? dir : null;
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** records the session's Claude Code folder (on the hook only: a terminal may lack the variable)
|
|
34
|
+
* @returns {boolean} whether it changed */
|
|
35
|
+
export function recordClaudeDir(dir = process.env.CLAUDE_CONFIG_DIR || DEFAULT_CLAUDE_DIR) {
|
|
36
|
+
if (recordedClaudeDir() === dir) return false;
|
|
37
|
+
mkdirSync(OMNI_DIR, { recursive: true });
|
|
38
|
+
writeFileSync(RECORD, `${JSON.stringify({ config_dir: dir }, null, 2)}\n`);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
13
41
|
|
|
14
|
-
export const
|
|
42
|
+
export const claudeConfigDir = () => process.env.CLAUDE_CONFIG_DIR || recordedClaudeDir() || DEFAULT_CLAUDE_DIR;
|
|
43
|
+
export const settingsFile = () => join(claudeConfigDir(), "settings.json");
|
|
15
44
|
|
|
16
45
|
/** the rules, for this CLI command and this machine's temporary folder (both spellings of it on macOS) */
|
|
17
46
|
export function skillRules(cli = CLI_COMMAND) {
|
package/tools/lib/deps.mjs
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
takes it from CHROME_PATH
|
|
8
8
|
python uv (UV_VERSION, in ~/.omni/bin) makes the app's .venv with a Python of its own and
|
|
9
9
|
installs requirements.txt into it
|
|
10
|
+
yt-dlp the project's standalone build (YTDLP_RELEASE), in ~/.omni/bin: a visual style from a
|
|
11
|
+
link (tools/reference.mjs). Sites change often, so bump the release when a download breaks
|
|
10
12
|
|
|
11
13
|
Each piece is checked by running it, and only fetched when it doesn't answer, so `omni up` can call
|
|
12
14
|
ensureDeps on every session. What was installed goes in ~/.omni/deps.json; depsEnv() is the PATH and
|
|
@@ -34,6 +36,7 @@ export const BIN_DIR = join(OMNI_DIR, "bin");
|
|
|
34
36
|
const STATE = join(OMNI_DIR, "deps.json");
|
|
35
37
|
export const FFMPEG_RELEASE = "b6.1.1";
|
|
36
38
|
export const UV_VERSION = "0.12.21";
|
|
39
|
+
export const YTDLP_RELEASE = "2026.08.19";
|
|
37
40
|
const PYTHON_VERSION = "3.12";
|
|
38
41
|
|
|
39
42
|
const win = process.platform === "win32";
|
|
@@ -128,6 +131,25 @@ export async function ensureChrome() {
|
|
|
128
131
|
return got.executablePath;
|
|
129
132
|
}
|
|
130
133
|
|
|
134
|
+
/* --- yt-dlp ----------------------------------------------------------------------------- */
|
|
135
|
+
|
|
136
|
+
/** the release asset for this machine: one universal macOS build, Linux per arch, Windows per arch */
|
|
137
|
+
export function ytdlpAsset(platform = process.platform, cpu = process.arch) {
|
|
138
|
+
if (platform === "darwin") return "yt-dlp_macos";
|
|
139
|
+
if (platform === "win32") return cpu === "arm64" ? "yt-dlp_arm64.exe" : "yt-dlp.exe";
|
|
140
|
+
return cpu === "arm64" ? "yt-dlp_linux_aarch64" : "yt-dlp_linux";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function ensureYtdlp() {
|
|
144
|
+
/* one already on the PATH (a development checkout's Homebrew) serves as well */
|
|
145
|
+
if (answers("yt-dlp", ["--version"])) return;
|
|
146
|
+
const file = join(BIN_DIR, exe("yt-dlp"));
|
|
147
|
+
if (existsSync(file) && answers(file, ["--version"])) return;
|
|
148
|
+
const url = `https://github.com/yt-dlp/yt-dlp/releases/download/${YTDLP_RELEASE}/${ytdlpAsset()}`;
|
|
149
|
+
install(file, await fetchBytes(url));
|
|
150
|
+
if (!answers(file, ["--version"])) throw new Error("o yt-dlp baixado não roda neste computador");
|
|
151
|
+
}
|
|
152
|
+
|
|
131
153
|
/* --- uv and the Python venv -------------------------------------------------------------- */
|
|
132
154
|
|
|
133
155
|
async function ensureUv() {
|
|
@@ -204,6 +226,7 @@ export function checkDeps(codeRoot) {
|
|
|
204
226
|
python: existsSync(python)
|
|
205
227
|
? answers(python, ["-c", "import numpy, soundfile, requests; print('ok')"]) && python
|
|
206
228
|
: null,
|
|
229
|
+
ytdlp: on("yt-dlp", ["--version"]),
|
|
207
230
|
};
|
|
208
231
|
}
|
|
209
232
|
|
|
@@ -219,6 +242,7 @@ export async function ensureDeps(codeRoot, { log = () => {} } = {}) {
|
|
|
219
242
|
["ffmpeg", () => ensureFfmpeg()],
|
|
220
243
|
["Chrome sem janela", () => ensureChrome()],
|
|
221
244
|
["Python", () => ensurePython(codeRoot)],
|
|
245
|
+
["yt-dlp", () => ensureYtdlp()],
|
|
222
246
|
]);
|
|
223
247
|
for (const [name, run] of steps) {
|
|
224
248
|
try {
|
|
@@ -355,7 +355,7 @@ export function instagramClient({ token, apiVersion, ...deps }) {
|
|
|
355
355
|
/**
|
|
356
356
|
* Publishes a Reel from a public video URL (and an optional public cover URL).
|
|
357
357
|
* With Instagram login Meta downloads the file itself: the caller provides the
|
|
358
|
-
* URLs (
|
|
358
|
+
* URLs (R2 behind the cloud Worker's /media, for the CLI and the cloud alike) and `release`,
|
|
359
359
|
* called once Instagram has finished downloading.
|
|
360
360
|
* @param {{ client: InstagramClient, userId: string, videoUrl: string, coverUrl?: string | null,
|
|
361
361
|
* cover?: { version: string } | null, caption: string, shareToFeed: boolean, coverAtS?: number | null,
|
package/tools/lib/up.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
tools ffmpeg, the Chrome without a window and Python in ~/.omni (tools/lib/deps.mjs; in the
|
|
9
9
|
background on a hook)
|
|
10
10
|
login none yet: the browser opens on the approval page (at most once an hour)
|
|
11
|
+
claude on a hook, the session's Claude Code folder into ~/.omni/claude.json (claude-settings.mjs)
|
|
11
12
|
service the studio as a launchd agent, from the app, so the hosted studio reaches this Mac
|
|
12
13
|
|
|
13
14
|
In this repo (a git checkout) the app, the tools and the service are left to `tools/setup.mjs` and
|
|
@@ -16,7 +17,7 @@
|
|
|
16
17
|
import { spawn, spawnSync } from "node:child_process";
|
|
17
18
|
import { copyFileSync, existsSync, mkdirSync, openSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
18
19
|
import { join } from "node:path";
|
|
19
|
-
import { missingRules } from "./claude-settings.mjs";
|
|
20
|
+
import { missingRules, recordClaudeDir } from "./claude-settings.mjs";
|
|
20
21
|
import { CLI_COMMAND, PACKAGE, STUDIO_URL } from "./cli.mjs";
|
|
21
22
|
import { OMNI_DIR, checkDeps, ensureDeps } from "./deps.mjs";
|
|
22
23
|
import { readTokens } from "./oauth-client.mjs";
|
|
@@ -145,6 +146,8 @@ function devServiceRoot() {
|
|
|
145
146
|
*/
|
|
146
147
|
export async function up({ hook = false, log = () => {} } = {}) {
|
|
147
148
|
const notes = [];
|
|
149
|
+
/* the session's Claude Code folder, for the studio service: its settings and its default agent account */
|
|
150
|
+
if (hook) recordClaudeDir();
|
|
148
151
|
if (ensureWorkspace()) notes.push(`workspace criado em ${DATA_ROOT}`);
|
|
149
152
|
const codeRoot = ensureApp(log);
|
|
150
153
|
const dev = isDev(codeRoot);
|
|
@@ -155,7 +158,7 @@ export async function up({ hook = false, log = () => {} } = {}) {
|
|
|
155
158
|
if (hook) {
|
|
156
159
|
const tools = ensureTools(codeRoot, { background: true });
|
|
157
160
|
if (tools === "started" || tools === "running")
|
|
158
|
-
notes.push("baixando o ffmpeg, o Chrome sem janela e o
|
|
161
|
+
notes.push("baixando o ffmpeg, o Chrome sem janela, o Python e o yt-dlp (alguns minutos)");
|
|
159
162
|
} else {
|
|
160
163
|
const r = await ensureDeps(codeRoot, { log });
|
|
161
164
|
if (!r.ok) notes.push(`faltou preparar ${r.missing.join(", ")}: ${r.errors.join("; ")}`);
|
package/tools/omni.mjs
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
omni logout signs this computer out in the cloud and here
|
|
13
13
|
omni allow [--yes] the Claude Code permission rules the skills need (asks first)
|
|
14
14
|
omni setup login, the rules and up, for whoever starts from a terminal
|
|
15
|
-
omni doctor [--json] whether ffmpeg, ffprobe, the Chrome without a window and
|
|
15
|
+
omni doctor [--json] whether ffmpeg, ffprobe, the Chrome without a window, Python and yt-dlp answer
|
|
16
16
|
omni deps install fetches whichever of them doesn't answer into ~/.omni (tools/lib/deps.mjs)
|
|
17
17
|
omni skill <nome> [--v <versão>] [--part i/n]
|
|
18
18
|
one part of a skill's body from the registry (tools/lib/skill-client.mjs)
|
|
@@ -93,7 +93,7 @@ async function statusText(url) {
|
|
|
93
93
|
lines.push(
|
|
94
94
|
missing.length
|
|
95
95
|
? `Ainda preparando os programas do pipeline (faltam: ${missing.join(", ")}); o omni up baixa sozinho, ou chame-o de novo se demorar mais de 10 minutos.`
|
|
96
|
-
: "O computador está pronto: ffmpeg, ffprobe, o Chrome sem janela e o
|
|
96
|
+
: "O computador está pronto: ffmpeg, ffprobe, o Chrome sem janela, o Python e o yt-dlp respondem.",
|
|
97
97
|
);
|
|
98
98
|
const rules = missingRules();
|
|
99
99
|
lines.push(
|
|
@@ -105,11 +105,12 @@ async function statusText(url) {
|
|
|
105
105
|
return lines.join("\n");
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
/** which of the pipeline's programs don't answer (tools/lib/deps.mjs) */
|
|
108
|
+
/** which of the pipeline's programs don't answer (tools/lib/deps.mjs), by the name the user knows */
|
|
109
|
+
const DEP_NAMES = { chrome: "Chrome sem janela", python: "Python", ytdlp: "yt-dlp" };
|
|
109
110
|
const depsMissing = () =>
|
|
110
111
|
Object.entries(checkDeps(CODE_ROOT))
|
|
111
112
|
.filter(([, v]) => !v)
|
|
112
|
-
.map(([k]) => k);
|
|
113
|
+
.map(([k]) => DEP_NAMES[/** @type {keyof typeof DEP_NAMES} */ (k)] ?? k);
|
|
113
114
|
|
|
114
115
|
/** the plugin's MCP tools @param {string} url */
|
|
115
116
|
const mcpTools = url => [
|
|
@@ -221,6 +222,7 @@ export async function main(args) {
|
|
|
221
222
|
ffprobe: "ffprobe",
|
|
222
223
|
chrome: "Chrome sem janela",
|
|
223
224
|
python: "Python das ferramentas",
|
|
225
|
+
ytdlp: "yt-dlp (estilo visual por link)",
|
|
224
226
|
};
|
|
225
227
|
for (const [k, v] of Object.entries(c))
|
|
226
228
|
console.log(`${v ? "ok " : "falta"} ${names[/** @type {keyof typeof names} */ (k)]}${v ? `: ${v}` : ""}`);
|
|
@@ -234,7 +236,7 @@ export async function main(args) {
|
|
|
234
236
|
if (args.includes("--unlock")) rmSync(join(OMNI_DIR, "deps.lock"), { force: true });
|
|
235
237
|
console.log(
|
|
236
238
|
r.ok
|
|
237
|
-
? "Tudo pronto: ffmpeg, ffprobe, o Chrome sem janela e o
|
|
239
|
+
? "Tudo pronto: ffmpeg, ffprobe, o Chrome sem janela, o Python e o yt-dlp respondem."
|
|
238
240
|
: `Faltou: ${r.errors.join("; ")}`,
|
|
239
241
|
);
|
|
240
242
|
if (!r.ok) process.exit(1);
|