@crievo/omni 0.1.1 → 0.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crievo/omni",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "OmniVideo no seu computador: o CLI omni, o studio local e as ferramentas do pipeline.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,6 +37,7 @@
37
37
  "recharts": "3.10.1",
38
38
  "voice-glow": "0.3.0",
39
39
  "zod": "4.6.5",
40
- "bun": "1.4.3"
40
+ "bun": "1.4.3",
41
+ "@puppeteer/browsers": "3.2.3"
41
42
  }
42
43
  }
@@ -0,0 +1,236 @@
1
+ /* The programs the pipeline runs, brought by the CLI itself into ~/.omni (docs/specs/phase-2-plugin-cli.md,
2
+ step 4): no Homebrew, no admin password, the same on macOS, Windows and Linux (x64 and arm64).
3
+
4
+ ffmpeg, ffprobe static builds from the ffmpeg-static project (FFMPEG_RELEASE), in ~/.omni/bin
5
+ chrome a Chrome, Chromium or Brave already installed, else Chrome Headless Shell (Google's
6
+ build for automation) through @puppeteer/browsers, in ~/.omni/browsers; the render
7
+ takes it from CHROME_PATH
8
+ python uv (UV_VERSION, in ~/.omni/bin) makes the app's .venv with a Python of its own and
9
+ installs requirements.txt into it
10
+
11
+ Each piece is checked by running it, and only fetched when it doesn't answer, so `omni up` can call
12
+ ensureDeps on every session. What was installed goes in ~/.omni/deps.json; depsEnv() is the PATH and
13
+ CHROME_PATH the service and `omni run` hand to every tool, so the 60-odd places that spawn "ffmpeg" by
14
+ name need no change. OMNI_DIR moves ~/.omni (tests). */
15
+
16
+ import { spawnSync } from "node:child_process";
17
+ import {
18
+ chmodSync,
19
+ existsSync,
20
+ mkdirSync,
21
+ mkdtempSync,
22
+ readFileSync,
23
+ readdirSync,
24
+ renameSync,
25
+ rmSync,
26
+ writeFileSync,
27
+ } from "node:fs";
28
+ import { homedir, tmpdir } from "node:os";
29
+ import { delimiter, join } from "node:path";
30
+ import { gunzipSync } from "node:zlib";
31
+
32
+ export const OMNI_DIR = process.env.OMNI_DIR || join(homedir(), ".omni");
33
+ export const BIN_DIR = join(OMNI_DIR, "bin");
34
+ const STATE = join(OMNI_DIR, "deps.json");
35
+ export const FFMPEG_RELEASE = "b6.1.1";
36
+ export const UV_VERSION = "0.12.21";
37
+ const PYTHON_VERSION = "3.12";
38
+
39
+ const win = process.platform === "win32";
40
+ const exe = (/** @type {string} */ name) => (win ? `${name}.exe` : name);
41
+ /* ffmpeg-static has no Windows arm64 build: the x64 one runs under emulation */
42
+ const arch = () => (process.arch === "arm64" && !win ? "arm64" : "x64");
43
+
44
+ /** the app's venv Python, as each OS lays a venv out @param {string} codeRoot */
45
+ export const venvPython = codeRoot =>
46
+ win ? join(codeRoot, ".venv", "Scripts", "python.exe") : join(codeRoot, ".venv", "bin", "python");
47
+
48
+ /** @returns {{ chrome?: string }} */
49
+ const readState = () => {
50
+ try {
51
+ return JSON.parse(readFileSync(STATE, "utf8"));
52
+ } catch {
53
+ return {};
54
+ }
55
+ };
56
+ const writeState = (/** @type {object} */ patch) => {
57
+ mkdirSync(OMNI_DIR, { recursive: true });
58
+ writeFileSync(STATE, `${JSON.stringify({ ...readState(), ...patch }, null, 2)}\n`);
59
+ };
60
+
61
+ /** the first line a program prints for these args, or null when it doesn't run @param {string} bin @param {string[]} args */
62
+ const answers = (bin, args) => {
63
+ const r = spawnSync(bin, args, { encoding: "utf8", timeout: 20_000 });
64
+ return r.status === 0 ? (r.stdout || r.stderr).trim().split("\n")[0] : null;
65
+ };
66
+
67
+ /** @param {string} url */
68
+ async function fetchBytes(url) {
69
+ const r = await fetch(url);
70
+ if (!r.ok) throw new Error(`download falhou (${r.status}): ${url}`);
71
+ return Buffer.from(await r.arrayBuffer());
72
+ }
73
+
74
+ /** writes a binary atomically and makes it executable @param {string} file @param {Buffer} bytes */
75
+ function install(file, bytes) {
76
+ mkdirSync(BIN_DIR, { recursive: true });
77
+ const tmp = `${file}.download`;
78
+ writeFileSync(tmp, bytes);
79
+ chmodSync(tmp, 0o755);
80
+ renameSync(tmp, file);
81
+ }
82
+
83
+ /* --- ffmpeg and ffprobe ------------------------------------------------------------------ */
84
+
85
+ export async function ensureFfmpeg() {
86
+ for (const name of ["ffmpeg", "ffprobe"]) {
87
+ const file = join(BIN_DIR, exe(name));
88
+ if (existsSync(file) && answers(file, ["-version"])) continue;
89
+ const url = `https://github.com/eugeneware/ffmpeg-static/releases/download/${FFMPEG_RELEASE}/${name}-${win ? "win32" : process.platform}-${arch()}.gz`;
90
+ install(file, gunzipSync(await fetchBytes(url)));
91
+ if (!answers(file, ["-version"])) throw new Error(`o ${name} baixado não roda neste computador`);
92
+ }
93
+ }
94
+
95
+ /* --- Chrome Headless Shell --------------------------------------------------------------- */
96
+
97
+ /** a Chromium browser already on this machine, which the render can drive just as well */
98
+ export function systemChrome() {
99
+ const pf = [process.env.PROGRAMFILES, process.env["PROGRAMFILES(X86)"], process.env.LOCALAPPDATA].filter(Boolean);
100
+ return [
101
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
102
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
103
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
104
+ "/usr/bin/google-chrome",
105
+ "/usr/bin/chromium",
106
+ "/usr/bin/chromium-browser",
107
+ ...pf.map(d => join(/** @type {string} */ (d), "Google", "Chrome", "Application", "chrome.exe")),
108
+ ].find(p => existsSync(p));
109
+ }
110
+
111
+ /** the Chrome the render will use: CHROME_PATH, the one fetched into ~/.omni, else one installed */
112
+ export function chromePath() {
113
+ const fetched = readState().chrome;
114
+ return [process.env.CHROME_PATH, fetched, systemChrome()].find(p => p && existsSync(p)) ?? null;
115
+ }
116
+
117
+ export async function ensureChrome() {
118
+ const known = chromePath();
119
+ if (known) return known;
120
+ /* the CLI package depends on it; a development checkout uses the Chrome it has */
121
+ const browsers = await import("@puppeteer/browsers");
122
+ const platform = browsers.detectBrowserPlatform();
123
+ if (!platform) throw new Error(`não há Chrome Headless Shell para ${process.platform}/${process.arch}`);
124
+ const browser = browsers.Browser.CHROMEHEADLESSSHELL;
125
+ const buildId = await browsers.resolveBuildId(browser, platform, "stable");
126
+ const got = await browsers.install({ browser, buildId, platform, cacheDir: join(OMNI_DIR, "browsers") });
127
+ writeState({ chrome: got.executablePath });
128
+ return got.executablePath;
129
+ }
130
+
131
+ /* --- uv and the Python venv -------------------------------------------------------------- */
132
+
133
+ async function ensureUv() {
134
+ const file = join(BIN_DIR, exe("uv"));
135
+ if (existsSync(file) && answers(file, ["--version"])) return file;
136
+ const triple = `${process.arch === "arm64" ? "aarch64" : "x86_64"}-${win ? "pc-windows-msvc" : process.platform === "darwin" ? "apple-darwin" : "unknown-linux-gnu"}`;
137
+ const archive = `uv-${triple}.${win ? "zip" : "tar.gz"}`;
138
+ const tmp = mkdtempSync(join(tmpdir(), "omni-uv-"));
139
+ try {
140
+ writeFileSync(
141
+ join(tmp, archive),
142
+ await fetchBytes(`https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/${archive}`),
143
+ );
144
+ /* tar reads both .tar.gz and .zip on macOS and Windows 10+, and .tar.gz on Linux */
145
+ const x = spawnSync("tar", ["-xf", archive], { cwd: tmp, encoding: "utf8" });
146
+ if (x.status !== 0) throw new Error(`não consegui abrir o ${archive}: ${x.stderr.trim()}`);
147
+ const found = readdirSync(tmp, { recursive: true, withFileTypes: true }).find(
148
+ e => e.isFile() && e.name === exe("uv"),
149
+ );
150
+ if (!found) throw new Error(`o ${archive} não tem o uv`);
151
+ install(file, readFileSync(join(found.parentPath, found.name)));
152
+ } finally {
153
+ rmSync(tmp, { recursive: true, force: true });
154
+ }
155
+ return file;
156
+ }
157
+
158
+ /** the venv with requirements.txt installed @param {string} codeRoot */
159
+ export async function ensurePython(codeRoot) {
160
+ const python = venvPython(codeRoot);
161
+ if (existsSync(python) && answers(python, ["-c", "import numpy, soundfile, requests; print('ok')"])) return python;
162
+ const uv = await ensureUv();
163
+ const env = {
164
+ ...process.env,
165
+ UV_PYTHON_INSTALL_DIR: join(OMNI_DIR, "python"),
166
+ UV_CACHE_DIR: join(OMNI_DIR, "uv-cache"),
167
+ };
168
+ const venv = spawnSync(uv, ["venv", "--python", PYTHON_VERSION, "--allow-existing", join(codeRoot, ".venv")], {
169
+ encoding: "utf8",
170
+ env,
171
+ });
172
+ if (venv.status !== 0) throw new Error(`uv venv falhou: ${venv.stderr.trim().split("\n").at(-1)}`);
173
+ const pip = spawnSync(uv, ["pip", "install", "--python", python, "-r", join(codeRoot, "requirements.txt")], {
174
+ encoding: "utf8",
175
+ env,
176
+ });
177
+ if (pip.status !== 0) throw new Error(`uv pip install falhou: ${pip.stderr.trim().split("\n").at(-1)}`);
178
+ return python;
179
+ }
180
+
181
+ /* --- together ---------------------------------------------------------------------------- */
182
+
183
+ /** the environment every tool runs with: ~/.omni/bin first on PATH, and the Chrome the render uses */
184
+ export function depsEnv(env = process.env) {
185
+ const chrome = chromePath();
186
+ return {
187
+ PATH: [BIN_DIR, env.PATH ?? ""].filter(Boolean).join(delimiter),
188
+ ...(chrome && !env.CHROME_PATH ? { CHROME_PATH: chrome } : {}),
189
+ };
190
+ }
191
+
192
+ /** what answers now: each program's version line, or null @param {string} codeRoot */
193
+ export function checkDeps(codeRoot) {
194
+ const env = depsEnv();
195
+ const on = (/** @type {string} */ name, /** @type {string[]} */ args) => {
196
+ const r = spawnSync(name, args, { encoding: "utf8", timeout: 20_000, env: { ...process.env, ...env } });
197
+ return r.status === 0 ? (r.stdout || r.stderr).trim().split("\n")[0] : null;
198
+ };
199
+ const python = venvPython(codeRoot);
200
+ return {
201
+ ffmpeg: on("ffmpeg", ["-version"]),
202
+ ffprobe: on("ffprobe", ["-version"]),
203
+ chrome: chromePath(),
204
+ python: existsSync(python)
205
+ ? answers(python, ["-c", "import numpy, soundfile, requests; print('ok')"]) && python
206
+ : null,
207
+ };
208
+ }
209
+
210
+ /**
211
+ * Fetches whatever doesn't answer, one piece at a time, and says how it went.
212
+ * @param {string} codeRoot @param {{ log?: (line: string) => void }} [o]
213
+ * @returns {Promise<{ ok: boolean, missing: string[], errors: string[] }>}
214
+ */
215
+ export async function ensureDeps(codeRoot, { log = () => {} } = {}) {
216
+ /** @type {string[]} */
217
+ const errors = [];
218
+ const steps = /** @type {const} */ ([
219
+ ["ffmpeg", () => ensureFfmpeg()],
220
+ ["Chrome sem janela", () => ensureChrome()],
221
+ ["Python", () => ensurePython(codeRoot)],
222
+ ]);
223
+ for (const [name, run] of steps) {
224
+ try {
225
+ log(`preparando ${name}…`);
226
+ await run();
227
+ } catch (e) {
228
+ errors.push(`${name}: ${/** @type {Error} */ (e).message}`);
229
+ }
230
+ }
231
+ const c = checkDeps(codeRoot);
232
+ const missing = Object.entries(c)
233
+ .filter(([, v]) => !v)
234
+ .map(([k]) => k);
235
+ return { ok: missing.length === 0, missing, errors };
236
+ }
@@ -1,7 +1,8 @@
1
1
  /* The studio kept on by launchd (macOS): `bun studio.ts` in <code root>/studio, started at login and again
2
2
  whenever it stops (30 s apart, so a studio already open in a terminal holding the port just waits its
3
3
  turn). PATH goes along because launchd starts with a minimal one, without claude, ffmpeg or the venv's
4
- python; OMNI_HOME goes along when the workspace is not the code root (tools/lib/paths.mjs).
4
+ python, with ~/.omni/bin (tools/lib/deps.mjs) first; OMNI_HOME goes along when the workspace is not the
5
+ code root (tools/lib/paths.mjs).
5
6
 
6
7
  Used by `tools/runner.mjs service` (this repo) and `omni up` (the installed CLI). Throws Errors in
7
8
  Portuguese; the callers print them. */
@@ -10,6 +11,7 @@ import { spawnSync } from "node:child_process";
10
11
  import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
11
12
  import { homedir } from "node:os";
12
13
  import { dirname, join } from "node:path";
14
+ import { BIN_DIR } from "./deps.mjs";
13
15
  import { CODE_ROOT, DATA_ROOT, homeEnvPlist } from "./paths.mjs";
14
16
 
15
17
  export const AGENT = "com.omnivideo.studio";
@@ -44,6 +46,7 @@ function plist(/** @type {string} */ bun, /** @type {string} */ codeRoot) {
44
46
  const x = (/** @type {string} */ v) => v.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
45
47
  const path = [
46
48
  ...new Set([
49
+ BIN_DIR,
47
50
  dirname(bun),
48
51
  dirname(process.execPath),
49
52
  "/opt/homebrew/bin",
package/tools/lib/up.mjs CHANGED
@@ -5,24 +5,24 @@
5
5
  workspace ~/OmniVideo (OMNI_HOME): videos/, omnivideo.config.json pointing at the hosted studio, .env
6
6
  app a stable copy of the package in ~/.omni/app/<version>/ (npx's cache can be wiped), and
7
7
  ~/.omni/bin/omni
8
- python the venv of the voice and timing tools, in the app's .venv (in the background on a hook)
8
+ tools ffmpeg, the Chrome without a window and Python in ~/.omni (tools/lib/deps.mjs; in the
9
+ background on a hook)
9
10
  login none yet: the browser opens on the approval page (at most once an hour)
10
11
  service the studio as a launchd agent, from the app, so the hosted studio reaches this Mac
11
12
 
12
- In this repo (a git checkout) the app, the venv and the service are left to `tools/setup.mjs` and
13
+ In this repo (a git checkout) the app, the tools and the service are left to `tools/setup.mjs` and
13
14
  `tools/runner.mjs service`: `up` never rewires a development machine. */
14
15
 
15
16
  import { spawn, spawnSync } from "node:child_process";
16
- import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
17
- import { homedir } from "node:os";
17
+ import { copyFileSync, existsSync, mkdirSync, openSync, readFileSync, statSync, writeFileSync } from "node:fs";
18
18
  import { join } from "node:path";
19
19
  import { missingRules } from "./claude-settings.mjs";
20
20
  import { CLI_COMMAND, PACKAGE, STUDIO_URL } from "./cli.mjs";
21
+ import { OMNI_DIR, checkDeps, ensureDeps } from "./deps.mjs";
21
22
  import { readTokens } from "./oauth-client.mjs";
22
23
  import { CODE_ROOT, DATA_ROOT } from "./paths.mjs";
23
24
  import { PLIST, service, serviceInstall } from "./service.mjs";
24
25
 
25
- export const OMNI_DIR = join(homedir(), ".omni");
26
26
  const LOGIN_EVERY_MS = 60 * 60_000;
27
27
 
28
28
  const isDev = (/** @type {string} */ codeRoot) => existsSync(join(codeRoot, ".git"));
@@ -82,26 +82,31 @@ export function ensureApp(log = () => {}, codeRoot = CODE_ROOT) {
82
82
  return installed;
83
83
  }
84
84
 
85
- /** the venv of the Python tools, inside the app; on a hook in the background, logging to ~/.omni/venv.log
86
- * @returns {"ok" | "started" | "creating" | "failed"} */
87
- export function ensureVenv(codeRoot = CODE_ROOT, { background = false } = {}) {
88
- if (existsSync(join(codeRoot, ".venv", "bin", "python"))) return "ok";
89
- const lock = join(OMNI_DIR, "venv.lock");
85
+ /** the programs the pipeline runs (tools/lib/deps.mjs), fetched by a process of its own on a hook so the
86
+ * session never waits; logs in ~/.omni/deps.log
87
+ * @returns {"ok" | "started" | "running" | string} "ok", or what is still missing */
88
+ export function ensureTools(codeRoot = CODE_ROOT, { background = false } = {}) {
89
+ const c = checkDeps(codeRoot);
90
+ const missing = Object.entries(c)
91
+ .filter(([, v]) => !v)
92
+ .map(([k]) => k);
93
+ if (!missing.length) return "ok";
90
94
  mkdirSync(OMNI_DIR, { recursive: true });
95
+ const lock = join(OMNI_DIR, "deps.lock");
91
96
  try {
92
- if (Date.now() - statSync(lock).mtimeMs < 15 * 60_000) return "creating";
97
+ if (Date.now() - statSync(lock).mtimeMs < 15 * 60_000) return "running";
93
98
  } catch {
94
- /* no one is creating it */
99
+ /* no one is fetching them */
95
100
  }
101
+ if (!background) return missing.join(", ");
96
102
  writeFileSync(lock, String(process.pid));
97
- const script = `python3 -m venv .venv && .venv/bin/python -m pip install -q -r requirements.txt; rm -f "${lock}"`;
98
- if (background) {
99
- const out = join(OMNI_DIR, "venv.log");
100
- spawn("/bin/sh", ["-c", `${script} >> "${out}" 2>&1`], { cwd: codeRoot, detached: true, stdio: "ignore" }).unref();
101
- return "started";
102
- }
103
- const r = spawnSync("/bin/sh", ["-c", script], { cwd: codeRoot, stdio: "inherit" });
104
- return r.status === 0 ? "ok" : "failed";
103
+ const out = openSync(join(OMNI_DIR, "deps.log"), "a");
104
+ spawn(process.execPath, [join(codeRoot, "tools", "omni.mjs"), "deps", "install", "--unlock"], {
105
+ detached: true,
106
+ stdio: ["ignore", out, out],
107
+ env: { ...process.env, OMNI_HOME: DATA_ROOT },
108
+ }).unref();
109
+ return "started";
105
110
  }
106
111
 
107
112
  /** opens the browser on the approval page in a process of its own, at most once an hour
@@ -145,10 +150,16 @@ export async function up({ hook = false, log = () => {} } = {}) {
145
150
  const dev = isDev(codeRoot);
146
151
  const url = studioUrlOf();
147
152
 
153
+ /* ffmpeg, the Chrome without a window and Python, in ~/.omni: in the background on a hook, here otherwise */
148
154
  if (!dev) {
149
- const venv = ensureVenv(codeRoot, { background: hook });
150
- if (venv === "started" || venv === "creating") notes.push("preparando o Python das ferramentas de voz");
151
- if (venv === "failed") notes.push("o Python das ferramentas não instalou (veja ~/.omni/venv.log)");
155
+ if (hook) {
156
+ const tools = ensureTools(codeRoot, { background: true });
157
+ if (tools === "started" || tools === "running")
158
+ notes.push("baixando o ffmpeg, o Chrome sem janela e o Python (alguns minutos)");
159
+ } else {
160
+ const r = await ensureDeps(codeRoot, { log });
161
+ if (!r.ok) notes.push(`faltou preparar ${r.missing.join(", ")}: ${r.errors.join("; ")}`);
162
+ }
152
163
  }
153
164
 
154
165
  const loggedIn = !!readTokens(url);
package/tools/omni.mjs CHANGED
@@ -12,7 +12,8 @@
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 the system programs and keys (tools/setup.mjs check)
15
+ omni doctor [--json] whether ffmpeg, ffprobe, the Chrome without a window and Python answer
16
+ omni deps install fetches whichever of them doesn't answer into ~/.omni (tools/lib/deps.mjs)
16
17
  omni skill <nome> [--v <versão>] [--part i/n]
17
18
  one part of a skill's body from the registry (tools/lib/skill-client.mjs)
18
19
  omni run <ferramenta> [args…]
@@ -21,12 +22,13 @@
21
22
  ============================================================================ */
22
23
 
23
24
  import { spawnSync } from "node:child_process";
24
- import { existsSync, readFileSync } from "node:fs";
25
+ import { existsSync, readFileSync, rmSync } from "node:fs";
25
26
  import { hostname } from "node:os";
26
27
  import { join } from "node:path";
27
28
  import { createInterface } from "node:readline/promises";
28
29
  import { allowSkillRules, missingRules, settingsFile } from "./lib/claude-settings.mjs";
29
30
  import { CLI_COMMAND, STUDIO_URL } from "./lib/cli.mjs";
31
+ import { OMNI_DIR, checkDeps, depsEnv, ensureDeps } from "./lib/deps.mjs";
30
32
  import { serveMcp } from "./lib/mcp-server.mjs";
31
33
  import { accessToken, login, logout, readTokens } from "./lib/oauth-client.mjs";
32
34
  import { CODE_ROOT, DATA_ROOT, isMainModule } from "./lib/paths.mjs";
@@ -87,6 +89,12 @@ async function statusText(url) {
87
89
  ? "O studio deste computador está ligado e atende o studio hospedado."
88
90
  : "O studio deste computador está desligado.",
89
91
  );
92
+ const missing = depsMissing();
93
+ lines.push(
94
+ missing.length
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 Python respondem.",
97
+ );
90
98
  const rules = missingRules();
91
99
  lines.push(
92
100
  rules.length
@@ -97,6 +105,12 @@ async function statusText(url) {
97
105
  return lines.join("\n");
98
106
  }
99
107
 
108
+ /** which of the pipeline's programs don't answer (tools/lib/deps.mjs) */
109
+ const depsMissing = () =>
110
+ Object.entries(checkDeps(CODE_ROOT))
111
+ .filter(([, v]) => !v)
112
+ .map(([k]) => k);
113
+
100
114
  /** the plugin's MCP tools @param {string} url */
101
115
  const mcpTools = url => [
102
116
  {
@@ -194,17 +208,37 @@ export async function main(args) {
194
208
  if (!bin) fail(`não existe a ferramenta ${tool}`);
195
209
  const r = spawnSync(/** @type {string} */ (bin), [/** @type {string} */ (script), ...rest], {
196
210
  stdio: "inherit",
197
- env: { ...process.env, OMNI_HOME: DATA_ROOT },
211
+ env: { ...process.env, ...depsEnv(), OMNI_HOME: DATA_ROOT },
198
212
  });
199
213
  process.exit(r.status ?? 1);
200
214
  }
201
215
 
202
216
  if (cmd === "doctor") {
203
- const r = spawnSync(process.execPath, [join(CODE_ROOT, "tools", "setup.mjs"), "check"], {
204
- stdio: "inherit",
205
- env: { ...process.env, OMNI_HOME: DATA_ROOT },
206
- });
207
- process.exit(r.status ?? 1);
217
+ const c = checkDeps(CODE_ROOT);
218
+ if (args.includes("--json")) return console.log(JSON.stringify(c));
219
+ const names = {
220
+ ffmpeg: "ffmpeg",
221
+ ffprobe: "ffprobe",
222
+ chrome: "Chrome sem janela",
223
+ python: "Python das ferramentas",
224
+ };
225
+ for (const [k, v] of Object.entries(c))
226
+ console.log(`${v ? "ok " : "falta"} ${names[/** @type {keyof typeof names} */ (k)]}${v ? `: ${v}` : ""}`);
227
+ if (Object.values(c).some(v => !v)) console.log(`Para baixar o que falta: ${CLI_COMMAND} deps install`);
228
+ return;
229
+ }
230
+
231
+ if (cmd === "deps" && args[1] === "install") {
232
+ const r = await ensureDeps(CODE_ROOT, { log: line => console.log(line) });
233
+ /* the background run of `omni up --hook` holds this lock while it fetches */
234
+ if (args.includes("--unlock")) rmSync(join(OMNI_DIR, "deps.lock"), { force: true });
235
+ console.log(
236
+ r.ok
237
+ ? "Tudo pronto: ffmpeg, ffprobe, o Chrome sem janela e o Python respondem."
238
+ : `Faltou: ${r.errors.join("; ")}`,
239
+ );
240
+ if (!r.ok) process.exit(1);
241
+ return;
208
242
  }
209
243
 
210
244
  const url = studioUrl();
@@ -291,7 +325,7 @@ export async function main(args) {
291
325
  }
292
326
 
293
327
  fail(
294
- "uso: omni up [--hook] | login | status [--json] | logout | allow [--yes] | setup | doctor | skill <nome> | run <ferramenta> … | mcp",
328
+ "uso: omni up [--hook] | login | status [--json] | logout | allow [--yes] | setup | doctor | deps install | skill <nome> | run <ferramenta> … | mcp",
295
329
  );
296
330
  }
297
331
 
package/tools/package.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  this repo into .release/npm/ and packed as a .tgz. It carries the code a user's machine runs (the CLI,
5
5
  the pipeline tools, the templates and guides, the studio's API, runner and web) and nothing of the
6
6
  skills, which come from the registry. The studio runs on the `bun` dependency, so a machine needs Node,
7
- not Bun; Python's venv is made by `omni up`.
7
+ not Bun; ffmpeg, the Chrome without a window and Python are fetched by `omni up` (tools/lib/deps.mjs).
8
8
 
9
9
  node tools/package.mjs [--version <x.y.z>] builds and packs; prints the .tgz path
10
10
  node tools/package.mjs publish [--version …] builds, packs and publishes to npm with the account in
@@ -93,8 +93,8 @@ function build() {
93
93
  os: ["darwin"],
94
94
  license: "UNLICENSED",
95
95
  homepage: "https://omni.crievo.com",
96
- /* the studio's runtime libraries, plus the bun that runs it */
97
- dependencies: { ...studio.dependencies, bun: "1.4.3" },
96
+ /* the studio's runtime libraries, the bun that runs it, and what fetches the Chrome without a window */
97
+ dependencies: { ...studio.dependencies, bun: "1.4.3", "@puppeteer/browsers": "3.2.3" },
98
98
  };
99
99
  writeFileSync(join(PKG, "package.json"), `${JSON.stringify(pkg, null, 2)}\n`);
100
100
  writeFileSync(
package/tools/render.mjs CHANGED
@@ -79,9 +79,20 @@ const TMP = join(tmpdir(), `render-${process.pid}`);
79
79
  const GL = has("gl") || /webgl|VoiceAvatar/i.test(readFileSync(SRC, "utf8"));
80
80
 
81
81
  /* --- Chrome ---------------------------------------------------------------- */
82
+ /* the Chrome Headless Shell `omni up` fetched into ~/.omni (tools/lib/deps.mjs) */
83
+ function omniChrome() {
84
+ try {
85
+ const dir = process.env.OMNI_DIR || join(process.env.HOME ?? "", ".omni");
86
+ return JSON.parse(readFileSync(join(dir, "deps.json"), "utf8")).chrome;
87
+ } catch {
88
+ return undefined;
89
+ }
90
+ }
91
+
82
92
  async function findChrome() {
83
93
  const fixed = [
84
94
  process.env.CHROME_PATH,
95
+ omniChrome(),
85
96
  "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
86
97
  "/Applications/Chromium.app/Contents/MacOS/Chromium",
87
98
  "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",