ossclip 0.1.5 → 0.1.6
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 +5 -4
- package/src/doctor.ts +1 -1
- package/src/edit.ts +12 -2
- package/src/index.ts +6 -289
- package/src/interactive/edit-hint.ts +20 -0
- package/src/interactive/menu.ts +32 -0
- package/src/interactive/offer-editor.ts +71 -0
- package/src/interactive/pick-workdir.ts +21 -0
- package/src/interactive/prefs.ts +51 -0
- package/src/interactive/produce-argv.ts +57 -0
- package/src/interactive/produce-wizard.ts +180 -0
- package/src/interactive/prompts.ts +6 -0
- package/src/interactive/render.ts +30 -0
- package/src/interactive/resolve-workdir.ts +115 -0
- package/src/interactive/tty.ts +62 -0
- package/src/interactive/workdir-probe.ts +70 -0
- package/src/produce.ts +17 -2
- package/src/program.ts +401 -0
package/src/program.ts
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { Command, InvalidArgumentError } from "commander";
|
|
5
|
+
import { z } from "zod/v4";
|
|
6
|
+
import { CleanupLevelSchema, SceneComponentIdSchema } from "@ossclip/core";
|
|
7
|
+
import { STUDIO_ENTRY } from "@ossclip/renderer";
|
|
8
|
+
import { loadEnvFiles } from "./env";
|
|
9
|
+
import { produce } from "./produce";
|
|
10
|
+
|
|
11
|
+
// Before anything reads a provider key (R16 §77) — including the auto-detect
|
|
12
|
+
// order in `defaultProviderName`, which decides which model runs.
|
|
13
|
+
const envFiles = loadEnvFiles();
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Every command this CLI has, built onto a fresh instance.
|
|
17
|
+
*
|
|
18
|
+
* Exported so the wizard's drift guard (test/produce-argv-roundtrip.test.ts)
|
|
19
|
+
* parses wizard argv against the REAL program. It used to parse against a
|
|
20
|
+
* hand-declared replica of thirteen options, which is exactly how
|
|
21
|
+
* `--whisper-model` gets renamed here, keeps being accepted there, and ships
|
|
22
|
+
* a wizard that teaches a flag the CLI no longer has — the drift the argv
|
|
23
|
+
* architecture was chosen to prevent.
|
|
24
|
+
*
|
|
25
|
+
* Every action closes over the LOCAL instance: the wizard hands its argv back
|
|
26
|
+
* through `program.parseAsync`, and that re-entry has to land on the program
|
|
27
|
+
* that is actually running, not on a module-level one.
|
|
28
|
+
*/
|
|
29
|
+
export function buildProgram(): Command {
|
|
30
|
+
const program = new Command();
|
|
31
|
+
|
|
32
|
+
program
|
|
33
|
+
.name("ossclip")
|
|
34
|
+
.description(
|
|
35
|
+
"local-first video producer: cuts silence and fillers, word-timed captions, " +
|
|
36
|
+
"face-aware framing, LLM-planned code-rendered graphics",
|
|
37
|
+
)
|
|
38
|
+
// Read from the manifest, never hardcoded (R22 §113): a literal here said
|
|
39
|
+
// "0.1.0" for every release after it, so `--version` reported the number a
|
|
40
|
+
// developer typed rather than the one npm installed — the exact field a
|
|
41
|
+
// bug report is judged by. npm always packs package.json regardless of
|
|
42
|
+
// `files`, so this resolves in a published install too.
|
|
43
|
+
.version(
|
|
44
|
+
(
|
|
45
|
+
JSON.parse(
|
|
46
|
+
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
|
47
|
+
) as { version: string }
|
|
48
|
+
).version,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
// Bare `ossclip` at a TTY opens the menu. Piped or in CI it prints help,
|
|
52
|
+
// byte for byte what it printed before — a front door must not become a
|
|
53
|
+
// hang for a script.
|
|
54
|
+
program.action(async () => {
|
|
55
|
+
const { isInteractive } = await import("./interactive/tty");
|
|
56
|
+
if (!isInteractive()) {
|
|
57
|
+
program.outputHelp();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const { chooseFromMenu, menuArgv } = await import("./interactive/menu");
|
|
61
|
+
const choice = await chooseFromMenu();
|
|
62
|
+
const direct = menuArgv(choice);
|
|
63
|
+
const { renderCommand } = await import("./interactive/render");
|
|
64
|
+
if (direct !== null) {
|
|
65
|
+
// Echoed for the same reason the wizard echoes: the README promises
|
|
66
|
+
// "every choice prints the equivalent command before it runs, so the
|
|
67
|
+
// menu is also how you learn the flags", and three of the four entries
|
|
68
|
+
// printed nothing. The menu's whole pedagogical point is this line.
|
|
69
|
+
console.log(`\n▸ running:\n ${renderCommand(direct)}\n`);
|
|
70
|
+
await program.parseAsync(["node", "ossclip", ...direct]);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const { produceWizard } = await import("./interactive/produce-wizard");
|
|
74
|
+
const { loadConfig } = await import("@ossclip/core");
|
|
75
|
+
const argv = await produceWizard({ speaker: loadConfig().speaker });
|
|
76
|
+
console.log(`\n▸ running:\n ${renderCommand(argv)}\n`);
|
|
77
|
+
await program.parseAsync(["node", "ossclip", ...argv]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
program
|
|
81
|
+
.command("produce")
|
|
82
|
+
.description("transcribe → analyze → cut → captions → render")
|
|
83
|
+
// OPTIONAL so a bare `ossclip produce` at a TTY opens the wizard instead of
|
|
84
|
+
// printing a usage error at somebody who does not yet know the flags. A
|
|
85
|
+
// non-interactive run still gets commander's "missing required argument".
|
|
86
|
+
.argument("[input]", "input video file")
|
|
87
|
+
.option("-o, --out <path>", "output video path (default: <input>.ossclip.mp4)")
|
|
88
|
+
.option("--cleanup <level>", "exact | light | standard | aggressive", "standard")
|
|
89
|
+
.option("--transcript <path>", "inject a transcript JSON instead of running whisper")
|
|
90
|
+
.option("--no-render", "stop after writing production.json / render props")
|
|
91
|
+
.option(
|
|
92
|
+
"--no-mezzanine",
|
|
93
|
+
"render straight from the source instead of a dense-keyframe mezzanine " +
|
|
94
|
+
"(also makes the source's folder the render server's public dir)",
|
|
95
|
+
)
|
|
96
|
+
.option("--noise-db <db>", "override the measured silence threshold, e.g. -30", parseFloat)
|
|
97
|
+
.option("--workdir <dir>", "cache/work directory (default: <input dir>/.ossclip)")
|
|
98
|
+
.option(
|
|
99
|
+
"--aspect <ratio>",
|
|
100
|
+
"output shape: 9:16 (vertical, default) or 16:9 (landscape, 1920x1080)",
|
|
101
|
+
"9:16",
|
|
102
|
+
)
|
|
103
|
+
.option("--produce", "run the LLM producer brain to plan title cards & graphics", false)
|
|
104
|
+
.option(
|
|
105
|
+
"--clip <seconds>",
|
|
106
|
+
"produce only the strongest ~N-second window of a long take (requires --produce; " +
|
|
107
|
+
"a source already at or under the target is produced whole)",
|
|
108
|
+
(v: string) => {
|
|
109
|
+
// §93a: reject rather than coerce — `--clip 0`, negatives and typos must
|
|
110
|
+
// not silently become "no clip" or NaN-length windows.
|
|
111
|
+
const n = Number.parseFloat(v);
|
|
112
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
113
|
+
throw new InvalidArgumentError(`--clip wants a positive number of seconds, got "${v}"`);
|
|
114
|
+
}
|
|
115
|
+
return n;
|
|
116
|
+
},
|
|
117
|
+
)
|
|
118
|
+
.option(
|
|
119
|
+
"--clip-window <start:end>",
|
|
120
|
+
"internal: the resolved highlight's word range, recorded into command.json by --clip " +
|
|
121
|
+
"runs so the editor's Render replays the same window without an LLM call",
|
|
122
|
+
)
|
|
123
|
+
.option("--intent <text>", "what the video should be ('educational video about agents…')")
|
|
124
|
+
.option(
|
|
125
|
+
"--llm <provider>",
|
|
126
|
+
"claude | claude-cli | gemini | mock. Default: claude if ANTHROPIC_API_KEY is set, " +
|
|
127
|
+
"else claude-cli (your logged-in Claude Code — Pro/Max subscription, no API charges)",
|
|
128
|
+
)
|
|
129
|
+
.option("--llm-model <id>", "override the provider's default model")
|
|
130
|
+
.option(
|
|
131
|
+
"--llm-fast-model <id>",
|
|
132
|
+
"model for mechanical calls (repair, scene props); 'same' disables tiering",
|
|
133
|
+
)
|
|
134
|
+
.option(
|
|
135
|
+
"--speaker <who>",
|
|
136
|
+
'who is on camera, e.g. "Ahsan, host of Code with Ahsan" — helps repair recognise mangled names',
|
|
137
|
+
)
|
|
138
|
+
.option("--scenes <path>", "hand-authored scenes JSON (Scene[]) — no LLM in the loop")
|
|
139
|
+
.option(
|
|
140
|
+
"--no-repair",
|
|
141
|
+
"skip the ASR mishearing repair pass (captions then show the raw transcription)",
|
|
142
|
+
)
|
|
143
|
+
.option("--whisper-model <name>", "transcription model for this run, e.g. base.en | small.en | medium.en")
|
|
144
|
+
.option(
|
|
145
|
+
"--force-component <id>",
|
|
146
|
+
"debug: render every graphic with this component (e.g. FlowDiagram) to exercise it on real copy",
|
|
147
|
+
)
|
|
148
|
+
.option(
|
|
149
|
+
"--source-fit <mode>",
|
|
150
|
+
"cover | contain. cover (default) crops the source to fill the vertical " +
|
|
151
|
+
"frame; contain shows the WHOLE frame inset against the backdrop — the " +
|
|
152
|
+
"answer for a landscape take whose content matters beyond the speaker",
|
|
153
|
+
"cover",
|
|
154
|
+
)
|
|
155
|
+
.option(
|
|
156
|
+
"--source-is-edited",
|
|
157
|
+
"the source is already an edited reel with burned-in text — keep ossclip's graphics off it without waiting on detection",
|
|
158
|
+
)
|
|
159
|
+
.option(
|
|
160
|
+
"--blooper-marker <word>",
|
|
161
|
+
"cut the flubbed take whenever you say this word out loud (e.g. blooper): " +
|
|
162
|
+
"removal runs back to the start of the sentence it spoiled. Off unless given",
|
|
163
|
+
)
|
|
164
|
+
.option("--no-cover", "skip the cover image written beside the video")
|
|
165
|
+
.option("--cover <path>", "cover image output path (default: <out>.cover.jpg)")
|
|
166
|
+
.option("--open-editor", "open the editor when the run finishes")
|
|
167
|
+
.option(
|
|
168
|
+
"--no-open-editor",
|
|
169
|
+
"don't open the editor, and don't ask (overrides openEditorAfterProduce)",
|
|
170
|
+
)
|
|
171
|
+
.option("--editor-port <n>", "port for the editor started by --open-editor",
|
|
172
|
+
(v) => Number.parseInt(v, 10), 5174)
|
|
173
|
+
.action(async (input: string | undefined, opts, command: Command) => {
|
|
174
|
+
if (input === undefined) {
|
|
175
|
+
// commander 12's parseAsync does not reset option state between calls,
|
|
176
|
+
// so a flag typed alongside a bare `produce` would survive into the
|
|
177
|
+
// wizard's own parse and silently override the answer the user just
|
|
178
|
+
// gave — while `▸ running:` printed a command without it. Refusing is
|
|
179
|
+
// the only shape where the printed command is always the run.
|
|
180
|
+
const typedFlags = command.options.some(
|
|
181
|
+
(o) => command.getOptionValueSource(o.attributeName()) === "cli",
|
|
182
|
+
);
|
|
183
|
+
if (typedFlags) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
"pass the input file when you pass flags — bare `ossclip produce` opens the wizard instead",
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
const { isInteractive } = await import("./interactive/tty");
|
|
189
|
+
if (!isInteractive()) {
|
|
190
|
+
throw new Error("missing required argument 'input' — the video file to produce");
|
|
191
|
+
}
|
|
192
|
+
const { produceWizard } = await import("./interactive/produce-wizard");
|
|
193
|
+
const { renderCommand } = await import("./interactive/render");
|
|
194
|
+
const { loadConfig } = await import("@ossclip/core");
|
|
195
|
+
const argv = await produceWizard({ speaker: loadConfig().speaker });
|
|
196
|
+
console.log(`\n▸ running:\n ${renderCommand(argv)}\n`);
|
|
197
|
+
// Re-entering the SAME parse the flags take: the zod checks below run
|
|
198
|
+
// on wizard output exactly as they do on a typed command line.
|
|
199
|
+
await program.parseAsync(["node", "ossclip", ...argv]);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
// Say which keys came from a file — never the keys themselves. A run that
|
|
203
|
+
// picks a provider from a `.env` should say where that came from.
|
|
204
|
+
if (envFiles.length > 0) console.log(`▸ env: ${envFiles.join(", ")}`);
|
|
205
|
+
const cleanup = CleanupLevelSchema.parse(opts.cleanup);
|
|
206
|
+
const provider = opts.llm
|
|
207
|
+
? z.enum(["claude", "claude-cli", "gemini", "mock"]).parse(opts.llm)
|
|
208
|
+
: undefined;
|
|
209
|
+
const forceComponent = opts.forceComponent
|
|
210
|
+
? SceneComponentIdSchema.parse(opts.forceComponent)
|
|
211
|
+
: undefined;
|
|
212
|
+
// Parsed, not coerced: a typo'd `--source-fit containn` silently falling
|
|
213
|
+
// back to cover is exactly the crop the flag exists to prevent.
|
|
214
|
+
const sourceFit = z.enum(["cover", "contain"]).parse(opts.sourceFit);
|
|
215
|
+
const result = await produce(input, {
|
|
216
|
+
out: opts.out,
|
|
217
|
+
cleanup,
|
|
218
|
+
transcript: opts.transcript,
|
|
219
|
+
render: opts.render,
|
|
220
|
+
mezzanine: opts.mezzanine,
|
|
221
|
+
workdir: opts.workdir,
|
|
222
|
+
aspect: opts.aspect === "16:9" ? "16:9" : "9:16",
|
|
223
|
+
noiseDb: opts.noiseDb,
|
|
224
|
+
produce: opts.produce,
|
|
225
|
+
intent: opts.intent,
|
|
226
|
+
provider,
|
|
227
|
+
llmModel: opts.llmModel,
|
|
228
|
+
llmFastModel: opts.llmFastModel,
|
|
229
|
+
speaker: opts.speaker,
|
|
230
|
+
scenes: opts.scenes,
|
|
231
|
+
repair: opts.repair,
|
|
232
|
+
whisperModel: opts.whisperModel,
|
|
233
|
+
forceComponent,
|
|
234
|
+
// commander gives `--no-cover` as cover:false and `--cover <path>` as a
|
|
235
|
+
// string on the same key.
|
|
236
|
+
sourceIsEdited: opts.sourceIsEdited === true,
|
|
237
|
+
blooperMarker: opts.blooperMarker,
|
|
238
|
+
sourceFit,
|
|
239
|
+
cover: opts.cover !== false,
|
|
240
|
+
coverPath: typeof opts.cover === "string" ? opts.cover : undefined,
|
|
241
|
+
clip: opts.clip,
|
|
242
|
+
clipWindow: opts.clipWindow,
|
|
243
|
+
});
|
|
244
|
+
const { offerEditor } = await import("./interactive/offer-editor");
|
|
245
|
+
await offerEditor(result, { flag: opts.openEditor, port: opts.editorPort });
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
program
|
|
249
|
+
.command("transcribe")
|
|
250
|
+
.description("run the pipeline up to the transcript and cut report, no render")
|
|
251
|
+
.argument("<input>", "input video file")
|
|
252
|
+
.option("--cleanup <level>", "exact | light | standard | aggressive", "standard")
|
|
253
|
+
.option("--transcript <path>", "inject a transcript JSON instead of running whisper")
|
|
254
|
+
.option("--noise-db <db>", "override the measured silence threshold, e.g. -30", parseFloat)
|
|
255
|
+
.option("--workdir <dir>", "cache/work directory")
|
|
256
|
+
.option("--whisper-model <name>", "transcription model for this run, e.g. base.en | small.en | medium.en")
|
|
257
|
+
.action(async (input: string, opts) => {
|
|
258
|
+
const cleanup = CleanupLevelSchema.parse(opts.cleanup);
|
|
259
|
+
await produce(input, {
|
|
260
|
+
cleanup,
|
|
261
|
+
transcript: opts.transcript,
|
|
262
|
+
render: false,
|
|
263
|
+
mezzanine: false,
|
|
264
|
+
workdir: opts.workdir,
|
|
265
|
+
noiseDb: opts.noiseDb,
|
|
266
|
+
whisperModel: opts.whisperModel,
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
program
|
|
271
|
+
.command("studio")
|
|
272
|
+
.description("open Remotion Studio on a produced composition (visual debugging)")
|
|
273
|
+
.argument("<renderProps>", "path to a work dir's render-props.json")
|
|
274
|
+
.option("--video-dir <dir>", "directory containing the source video (public dir)")
|
|
275
|
+
.action(async (renderProps: string, opts) => {
|
|
276
|
+
const propsPath = resolve(renderProps);
|
|
277
|
+
const publicDir = opts.videoDir ? resolve(opts.videoDir) : dirname(propsPath);
|
|
278
|
+
// Resolve Remotion's CLI through module resolution instead of spawning
|
|
279
|
+
// `pnpm` — a global `npm i -g ossclip` has no pnpm and no workspace, and
|
|
280
|
+
// Windows would need the .cmd shim. `@remotion/cli` is a dependency of
|
|
281
|
+
// @ossclip/renderer, so resolving from THERE works in both a clone and a
|
|
282
|
+
// published install, on every OS, run via the node that's running us.
|
|
283
|
+
const { createRequire } = await import("node:module");
|
|
284
|
+
let remotionCliJs: string;
|
|
285
|
+
try {
|
|
286
|
+
const require = createRequire(import.meta.url);
|
|
287
|
+
const rendererDir = dirname(require.resolve("@ossclip/renderer/package.json"));
|
|
288
|
+
const fromRenderer = createRequire(join(rendererDir, "package.json"));
|
|
289
|
+
const cliPkgPath = fromRenderer.resolve("@remotion/cli/package.json");
|
|
290
|
+
const cliPkg = JSON.parse(readFileSync(cliPkgPath, "utf8")) as {
|
|
291
|
+
bin: string | Record<string, string>;
|
|
292
|
+
};
|
|
293
|
+
const binRel = typeof cliPkg.bin === "string" ? cliPkg.bin : cliPkg.bin.remotion;
|
|
294
|
+
if (!binRel) throw new Error("no remotion bin entry");
|
|
295
|
+
remotionCliJs = join(dirname(cliPkgPath), binRel);
|
|
296
|
+
} catch {
|
|
297
|
+
throw new Error(
|
|
298
|
+
"couldn't resolve @remotion/cli — in a clone, run `pnpm install` first",
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
const child = spawn(
|
|
302
|
+
process.execPath,
|
|
303
|
+
[remotionCliJs, "studio", STUDIO_ENTRY, `--props=${propsPath}`, `--public-dir=${publicDir}`],
|
|
304
|
+
{ stdio: "inherit" },
|
|
305
|
+
);
|
|
306
|
+
child.on("error", (e) => {
|
|
307
|
+
console.error(`✗ failed to start Remotion Studio: ${e.message}`);
|
|
308
|
+
process.exit(1);
|
|
309
|
+
});
|
|
310
|
+
child.on("exit", (code) => process.exit(code ?? 0));
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
program
|
|
314
|
+
.command("edit")
|
|
315
|
+
.description("open the editing page on a produced workdir")
|
|
316
|
+
// OPTIONAL since R17 §83: with no argument the editor opens on a project
|
|
317
|
+
// picker — recent produce runs plus a folder browser — and the top bar's
|
|
318
|
+
// Open button switches projects without restarting the server.
|
|
319
|
+
.argument("[workdir]", "a work directory containing render-props.json")
|
|
320
|
+
.option("--port <n>", "port to listen on", (v) => Number.parseInt(v, 10), 5174)
|
|
321
|
+
.option("--no-open", "do not open a browser")
|
|
322
|
+
.action(async (workdir: string | undefined, opts) => {
|
|
323
|
+
const { startEditServer, resolveEditorPageDir } = await import("./edit");
|
|
324
|
+
// An npm install ships the page prebuilt (editor-dist/); a clone builds
|
|
325
|
+
// it once with `pnpm build`. A server that starts fine but 404s every
|
|
326
|
+
// page request is the worst version of missing — fail loudly with the
|
|
327
|
+
// fix instead.
|
|
328
|
+
const pageDir = resolveEditorPageDir();
|
|
329
|
+
if (pageDir === null) {
|
|
330
|
+
throw new Error(
|
|
331
|
+
"editor UI isn't built yet — run `pnpm build` " +
|
|
332
|
+
"(or `pnpm --filter @ossclip/editor build`) once, then re-run `ossclip edit`.",
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// With no argument the editor opens on its own project picker (R17 §83).
|
|
337
|
+
// With one, resolve what the user MEANT: `ossclip edit <video folder>`
|
|
338
|
+
// was the reported failure, and produce's output lives one level down.
|
|
339
|
+
let target: string | undefined = workdir;
|
|
340
|
+
if (workdir !== undefined) {
|
|
341
|
+
const { probeWorkdir } = await import("./interactive/workdir-probe");
|
|
342
|
+
const { resolveWorkdir, candidateListMessage } = await import("./interactive/resolve-workdir");
|
|
343
|
+
const { isInteractive } = await import("./interactive/tty");
|
|
344
|
+
const { dir, probe } = await probeWorkdir(workdir);
|
|
345
|
+
const resolution = resolveWorkdir(dir, probe);
|
|
346
|
+
if (resolution.kind === "none") throw new Error(resolution.message);
|
|
347
|
+
if (resolution.kind === "choose") {
|
|
348
|
+
if (!isInteractive()) {
|
|
349
|
+
throw new Error(candidateListMessage(dir, resolution.candidates));
|
|
350
|
+
}
|
|
351
|
+
const { pickWorkdir } = await import("./interactive/pick-workdir");
|
|
352
|
+
target = await pickWorkdir(resolution.candidates);
|
|
353
|
+
} else {
|
|
354
|
+
target = resolution.workdir;
|
|
355
|
+
// Say so when the path was not the one typed — a silent redirect
|
|
356
|
+
// leaves the user with the wrong mental model of where things live.
|
|
357
|
+
if (resolution.via === "nested") console.log(`▸ resolved ${workdir} → ${target}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const server = await startEditServer(target, { port: opts.port, pageDir });
|
|
362
|
+
console.log(`▸ editor at ${server.url}`);
|
|
363
|
+
if (opts.open) {
|
|
364
|
+
const { openInBrowser } = await import("./open");
|
|
365
|
+
openInBrowser(server.url);
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
program
|
|
370
|
+
.command("setup")
|
|
371
|
+
.description(
|
|
372
|
+
"install everything ossclip needs (ffmpeg, whisper.cpp, the transcription model) " +
|
|
373
|
+
"into ~/.ossclip — the one-command onboarding on macOS, Linux, and Windows",
|
|
374
|
+
)
|
|
375
|
+
.option("--model <name>", "transcription model to download (default: config, i.e. small.en)")
|
|
376
|
+
.option("--skip-llm", "don't ask about an LLM provider (only --produce needs one)", false)
|
|
377
|
+
.option("--force", "re-download the pieces setup manages, even if present", false)
|
|
378
|
+
.option("-y, --yes", "no questions — accept the plan and skip the provider prompt", false)
|
|
379
|
+
.action(async (opts) => {
|
|
380
|
+
if (envFiles.length > 0) console.log(`▸ env: ${envFiles.join(", ")}`);
|
|
381
|
+
const { setup } = await import("./setup/setup");
|
|
382
|
+
await setup({ model: opts.model, skipLlm: opts.skipLlm, force: opts.force, yes: opts.yes });
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
program
|
|
386
|
+
.command("doctor")
|
|
387
|
+
.description("check every prerequisite and print the exact fix for anything missing")
|
|
388
|
+
.action(async () => {
|
|
389
|
+
// Env files are loaded at module top (R16 §77) — BEFORE this runs — so a
|
|
390
|
+
// provider key living in a `.env` is visible here, not a false negative.
|
|
391
|
+
if (envFiles.length > 0) console.log(`▸ env: ${envFiles.join(", ")}`);
|
|
392
|
+
const { runDoctor, formatDoctor, realProbes } = await import("./doctor");
|
|
393
|
+
const { resolveEditorPageDir } = await import("./edit");
|
|
394
|
+
const { loadConfig } = await import("@ossclip/core");
|
|
395
|
+
const checks = await runDoctor(loadConfig(), realProbes(resolveEditorPageDir()));
|
|
396
|
+
console.log(formatDoctor(checks));
|
|
397
|
+
if (checks.some((c) => !c.ok)) process.exit(1);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
return program;
|
|
401
|
+
}
|