ossclip 0.1.4 → 0.1.5
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/editor-dist/assets/{index-B5H_dKAW.js → index-DZhkQB1i.js} +24 -24
- package/editor-dist/index.html +1 -1
- package/package.json +4 -4
- package/src/doctor.ts +31 -9
- package/src/index.ts +56 -4
- package/src/open.ts +30 -0
- package/src/produce.ts +121 -14
- package/src/setup/download.ts +122 -0
- package/src/setup/extract.ts +91 -0
- package/src/setup/manifest.ts +159 -0
- package/src/setup/plan.ts +211 -0
- package/src/setup/provider.ts +51 -0
- package/src/setup/setup.ts +233 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { chmodSync, readdirSync, type Dirent } from "node:fs";
|
|
3
|
+
import { join, win32 } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Archive extraction without a single npm dependency: every platform we
|
|
7
|
+
* download for ships a `tar` that reads everything we download. GNU tar
|
|
8
|
+
* covers .tar.gz/.tar.xz on Linux; bsdtar (macOS, and Windows 10+ as
|
|
9
|
+
* %SystemRoot%\System32\tar.exe) additionally reads .zip.
|
|
10
|
+
*
|
|
11
|
+
* The Windows subtlety that cost a CI run (§117): a bare `tar` there is
|
|
12
|
+
* whichever one PATH finds first, and any machine with Git for Windows —
|
|
13
|
+
* every GitHub runner, and most developer boxes — puts MSYS **GNU** tar
|
|
14
|
+
* ahead of the system bsdtar. GNU tar cannot read a zip and exits 128. So
|
|
15
|
+
* on win32 the system bsdtar is tried by absolute path FIRST, and every
|
|
16
|
+
* candidate that fails for ANY reason falls through to the next: the
|
|
17
|
+
* original code only fell back when `spawn` itself errored, which a
|
|
18
|
+
* nonzero exit is not.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Extractors to try, in order. Pure, so the ordering is unit-testable. */
|
|
22
|
+
export function tarCandidates(
|
|
23
|
+
platform: NodeJS.Platform = process.platform,
|
|
24
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
25
|
+
): string[] {
|
|
26
|
+
if (platform !== "win32") return ["tar"];
|
|
27
|
+
const systemRoot = env.SystemRoot ?? env.SYSTEMROOT ?? "C:\\Windows";
|
|
28
|
+
// `win32.join`, not `join`: this builds a Windows path and the planner is
|
|
29
|
+
// pure over an injected platform, so it must not pick separators from
|
|
30
|
+
// whatever host the tests happen to run on.
|
|
31
|
+
// Absolute bsdtar first, then whatever PATH has (an unusual box may only
|
|
32
|
+
// have one of them).
|
|
33
|
+
return [win32.join(systemRoot, "System32", "tar.exe"), "tar"];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const spawnOk = (bin: string, args: string[]): Promise<boolean> =>
|
|
37
|
+
new Promise((resolve) => {
|
|
38
|
+
const child = spawn(bin, args, { stdio: "ignore" });
|
|
39
|
+
child.on("error", () => resolve(false));
|
|
40
|
+
child.on("exit", (code) => resolve(code === 0));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export async function extractArchive(
|
|
44
|
+
archivePath: string,
|
|
45
|
+
destDir: string,
|
|
46
|
+
platform: NodeJS.Platform = process.platform,
|
|
47
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
48
|
+
): Promise<void> {
|
|
49
|
+
for (const bin of tarCandidates(platform, env)) {
|
|
50
|
+
if (await spawnOk(bin, ["-xf", archivePath, "-C", destDir])) return;
|
|
51
|
+
}
|
|
52
|
+
if (platform === "win32" && archivePath.endsWith(".zip")) {
|
|
53
|
+
const ok = await spawnOk("powershell", [
|
|
54
|
+
"-NoProfile",
|
|
55
|
+
"-Command",
|
|
56
|
+
`Expand-Archive -LiteralPath '${archivePath}' -DestinationPath '${destDir}' -Force`,
|
|
57
|
+
]);
|
|
58
|
+
if (ok) return;
|
|
59
|
+
throw new Error(`neither tar nor Expand-Archive could extract ${archivePath}`);
|
|
60
|
+
}
|
|
61
|
+
throw new Error(`tar could not extract ${archivePath}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Find `basename` under `dir`, recursively. Release archives put binaries at
|
|
66
|
+
* different depths (`Release/whisper-cli.exe`, `<verdir>/bin/ffmpeg`) and
|
|
67
|
+
* those layouts are upstream's to change — searching beats hardcoding them.
|
|
68
|
+
*/
|
|
69
|
+
export function findFile(dir: string, basename: string): string | null {
|
|
70
|
+
let entries: Dirent[];
|
|
71
|
+
try {
|
|
72
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
for (const e of entries) {
|
|
77
|
+
if (e.isFile() && e.name === basename) return join(dir, e.name);
|
|
78
|
+
}
|
|
79
|
+
for (const e of entries) {
|
|
80
|
+
if (e.isDirectory()) {
|
|
81
|
+
const hit = findFile(join(dir, e.name), basename);
|
|
82
|
+
if (hit) return hit;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** POSIX archives usually preserve the execute bit; make sure of it. */
|
|
89
|
+
export function markExecutable(path: string, platform: NodeJS.Platform = process.platform): void {
|
|
90
|
+
if (platform !== "win32") chmodSync(path, 0o755);
|
|
91
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The acquisition table `ossclip setup` provisions from — pure data.
|
|
3
|
+
*
|
|
4
|
+
* Every entry is pinned to an exact upstream release and checksum, verified
|
|
5
|
+
* at pin time; setup refuses a download whose hash doesn't match. Nothing
|
|
6
|
+
* here ships inside the npm package — the GPL ffmpeg builds and the
|
|
7
|
+
* whisper.cpp binaries are downloaded onto the user's machine at the user's
|
|
8
|
+
* request, which keeps the MIT package's dependency graph clean.
|
|
9
|
+
*
|
|
10
|
+
* Bumping a pin: update url/version/sha256 together (BtbN publishes
|
|
11
|
+
* `checksums.sha256` per release; ggml-org assets are hashed by hand), and
|
|
12
|
+
* re-run the setup-e2e workflow. The manifest test asserts every supported
|
|
13
|
+
* platform×arch resolves to either an asset or an explicit manual hint.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface BinaryAsset {
|
|
17
|
+
url: string;
|
|
18
|
+
sha256: string;
|
|
19
|
+
/** Extracted with `tar -xf` everywhere; win32 falls back to Expand-Archive for zips. */
|
|
20
|
+
archive: "zip" | "tar.gz" | "tar.xz";
|
|
21
|
+
/** Binary basenames to locate (recursively) after extraction. */
|
|
22
|
+
bins: string[];
|
|
23
|
+
/** Download size, for up-front disclosure. */
|
|
24
|
+
sizeMB: number;
|
|
25
|
+
license: "GPL" | "MIT";
|
|
26
|
+
version: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const BTBN =
|
|
30
|
+
"https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-29-13-36";
|
|
31
|
+
const FFMPEG_VER = "n8.1.2-31-g8c9502e9b0";
|
|
32
|
+
|
|
33
|
+
const WHISPER =
|
|
34
|
+
"https://github.com/ggml-org/whisper.cpp/releases/download/v1.9.1";
|
|
35
|
+
const WHISPER_VER = "v1.9.1";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Static ffmpeg+ffprobe per platform. macOS returns null: BtbN publishes no
|
|
39
|
+
* darwin assets, and brew is near-universal there — the planner falls back
|
|
40
|
+
* to it.
|
|
41
|
+
*/
|
|
42
|
+
export function ffmpegAsset(platform: NodeJS.Platform, arch: string): BinaryAsset | null {
|
|
43
|
+
const bins =
|
|
44
|
+
platform === "win32" ? ["ffmpeg.exe", "ffprobe.exe"] : ["ffmpeg", "ffprobe"];
|
|
45
|
+
const common = { bins, license: "GPL" as const, version: FFMPEG_VER };
|
|
46
|
+
if (platform === "win32" && arch === "x64") {
|
|
47
|
+
return {
|
|
48
|
+
...common,
|
|
49
|
+
url: `${BTBN}/ffmpeg-${FFMPEG_VER}-win64-gpl-8.1.zip`,
|
|
50
|
+
sha256: "106d3f8e72b70e29f83983dbaa65efdfc5355716a5df675dc846e441929f7890",
|
|
51
|
+
archive: "zip",
|
|
52
|
+
sizeMB: 160,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (platform === "win32" && arch === "arm64") {
|
|
56
|
+
return {
|
|
57
|
+
...common,
|
|
58
|
+
url: `${BTBN}/ffmpeg-${FFMPEG_VER}-winarm64-gpl-8.1.zip`,
|
|
59
|
+
sha256: "ac46bdb0c9c619b107c7281a0cc6932a9419c4d6c3c8c36a259550f7fcee1a1a",
|
|
60
|
+
archive: "zip",
|
|
61
|
+
sizeMB: 107,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (platform === "linux" && arch === "x64") {
|
|
65
|
+
return {
|
|
66
|
+
...common,
|
|
67
|
+
url: `${BTBN}/ffmpeg-${FFMPEG_VER}-linux64-gpl-8.1.tar.xz`,
|
|
68
|
+
sha256: "9fb60ff01e6574258dc76efdf94f901a651582da67b8edcfd10e8860233b7ef4",
|
|
69
|
+
archive: "tar.xz",
|
|
70
|
+
sizeMB: 120,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (platform === "linux" && arch === "arm64") {
|
|
74
|
+
return {
|
|
75
|
+
...common,
|
|
76
|
+
url: `${BTBN}/ffmpeg-${FFMPEG_VER}-linuxarm64-gpl-8.1.tar.xz`,
|
|
77
|
+
sha256: "d8f9598a885db3deabd06af7f0f70c8565af27d29fadbcf746598c9306a0c3fa",
|
|
78
|
+
archive: "tar.xz",
|
|
79
|
+
sizeMB: 102,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Prebuilt whisper.cpp `whisper-cli` per platform. The Windows zip carries
|
|
87
|
+
* its DLLs beside the exe and the Ubuntu tarballs link their .so files via
|
|
88
|
+
* an `$ORIGIN` runpath, so both run straight out of the extracted directory
|
|
89
|
+
* (verified at pin time). macOS returns null — upstream ships no darwin CLI
|
|
90
|
+
* binary; brew's `whisper-cpp` covers it.
|
|
91
|
+
*
|
|
92
|
+
* Windows-on-ARM gets the x64 build: upstream publishes no arm64 zip, and
|
|
93
|
+
* Windows 11 runs x64 binaries under emulation.
|
|
94
|
+
*/
|
|
95
|
+
export function whisperAsset(platform: NodeJS.Platform, arch: string): BinaryAsset | null {
|
|
96
|
+
if (platform === "win32") {
|
|
97
|
+
// The BLAS build — meaningfully faster on small.en, worth the extra DLL.
|
|
98
|
+
return {
|
|
99
|
+
url: `${WHISPER}/whisper-blas-bin-x64.zip`,
|
|
100
|
+
sha256: "3c319eab3e87f85883e1ff3d14426c0a1986c661c5eb5985e8af431ed9c4f71f",
|
|
101
|
+
archive: "zip",
|
|
102
|
+
bins: ["whisper-cli.exe"],
|
|
103
|
+
sizeMB: 20,
|
|
104
|
+
license: "MIT",
|
|
105
|
+
version: WHISPER_VER,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
if (platform === "linux" && arch === "x64") {
|
|
109
|
+
return {
|
|
110
|
+
url: `${WHISPER}/whisper-bin-ubuntu-x64.tar.gz`,
|
|
111
|
+
sha256: "f3bf3b4369a99b54665b0f19b88483b30de27f25963b0414235dea03198515c5",
|
|
112
|
+
archive: "tar.gz",
|
|
113
|
+
bins: ["whisper-cli"],
|
|
114
|
+
sizeMB: 9,
|
|
115
|
+
license: "MIT",
|
|
116
|
+
version: WHISPER_VER,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
if (platform === "linux" && arch === "arm64") {
|
|
120
|
+
return {
|
|
121
|
+
url: `${WHISPER}/whisper-bin-ubuntu-arm64.tar.gz`,
|
|
122
|
+
sha256: "e0b66cd551ff6f2a28fabe3c6e89691eea037bb76833493abb9a71ca788994b3",
|
|
123
|
+
archive: "tar.gz",
|
|
124
|
+
bins: ["whisper-cli"],
|
|
125
|
+
sizeMB: 5,
|
|
126
|
+
license: "MIT",
|
|
127
|
+
version: WHISPER_VER,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* ggml transcription models. Sizes and SHA-1 hashes come from upstream's
|
|
135
|
+
* models/README.md — upstream publishes SHA-1, so that's what we verify;
|
|
136
|
+
* it's an integrity check against truncated downloads, not a security
|
|
137
|
+
* boundary (the download is already pinned to a host and path over HTTPS).
|
|
138
|
+
*/
|
|
139
|
+
export interface ModelInfo {
|
|
140
|
+
sizeMB: number;
|
|
141
|
+
sha1: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export const MODELS: Record<string, ModelInfo> = {
|
|
145
|
+
"tiny.en": { sizeMB: 75, sha1: "c78c86eb1a8faa21b369bcd33207cc90d64ae9df" },
|
|
146
|
+
"base.en": { sizeMB: 142, sha1: "137c40403d78fd54d454da0f9bd998f78703390c" },
|
|
147
|
+
"small.en": { sizeMB: 466, sha1: "db8a495a91d927739e50b3fc1cc4c6b8f6c2d022" },
|
|
148
|
+
"medium.en": { sizeMB: 1536, sha1: "8c30f0e44ce9560643ebd10bbe50cd20eafd3723" },
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export function modelUrl(name: string): string {
|
|
152
|
+
return `https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-${name}.bin`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The exact build recipe printed when no prebuilt fits — one copy, not four. */
|
|
156
|
+
export const WHISPER_BUILD_HINT =
|
|
157
|
+
"build whisper.cpp from source: git clone https://github.com/ggml-org/whisper.cpp && " +
|
|
158
|
+
"cd whisper.cpp && cmake -B build && cmake --build build -j --config Release " +
|
|
159
|
+
"(then point OSSCLIP_WHISPER at build/bin/whisper-cli)";
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { isAbsolute, join } from "node:path";
|
|
2
|
+
import type { OssclipConfig } from "@ossclip/core";
|
|
3
|
+
import { type BinaryAsset, MODELS, ffmpegAsset, modelUrl, whisperAsset } from "./manifest";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The planning half of `ossclip setup` — pure over injected probes, like
|
|
7
|
+
* doctor (R18 §90a), so every branch is unit-testable without a network or
|
|
8
|
+
* a second OS. The IO half (download/extract/brew/prompt) lives in setup.ts.
|
|
9
|
+
*
|
|
10
|
+
* Ground rules:
|
|
11
|
+
* - Anything that already works is `satisfied` and never touched. A user's
|
|
12
|
+
* own ffmpeg on PATH, a hand-set OSSCLIP_WHISPER, a model already on
|
|
13
|
+
* disk — setup's job is to fill gaps, not to take over working installs.
|
|
14
|
+
* - `--force` re-provisions only what setup itself manages (paths under
|
|
15
|
+
* `<configDir>/bin`, or bare names that were never resolved) — it must
|
|
16
|
+
* never clobber a path the user pointed elsewhere on purpose.
|
|
17
|
+
* - No platform gets silence: where no strategy applies, the step is
|
|
18
|
+
* `manual` with the exact commands to run.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export type StepKind = "ffmpeg" | "whisper" | "model" | "provider";
|
|
22
|
+
export type StepStatus = "satisfied" | "download" | "brew" | "manual" | "prompt";
|
|
23
|
+
|
|
24
|
+
export interface SetupStep {
|
|
25
|
+
kind: StepKind;
|
|
26
|
+
status: StepStatus;
|
|
27
|
+
/** What was found, or what will happen — one line for the plan table. */
|
|
28
|
+
detail: string;
|
|
29
|
+
/** Set when status === "download". */
|
|
30
|
+
asset?: BinaryAsset;
|
|
31
|
+
/** Download size when known (binary asset or known model). */
|
|
32
|
+
sizeMB?: number;
|
|
33
|
+
/** brew formula when status === "brew"; the manual fix when "manual". */
|
|
34
|
+
hint?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface SetupProbes {
|
|
38
|
+
binRuns(bin: string, arg: string): Promise<boolean>;
|
|
39
|
+
exists(path: string): boolean;
|
|
40
|
+
platform: NodeJS.Platform;
|
|
41
|
+
arch: string;
|
|
42
|
+
env: NodeJS.ProcessEnv;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SetupOptions {
|
|
46
|
+
/** Resolved `~/.ossclip` (injected so tests don't touch the real home). */
|
|
47
|
+
configDir: string;
|
|
48
|
+
model: string;
|
|
49
|
+
force: boolean;
|
|
50
|
+
skipLlm: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const managedBinDir = (configDir: string): string => join(configDir, "bin");
|
|
54
|
+
|
|
55
|
+
/** A path setup owns and may re-provision under --force. */
|
|
56
|
+
const isManaged = (path: string, configDir: string): boolean =>
|
|
57
|
+
!isAbsolute(path) || path.startsWith(managedBinDir(configDir));
|
|
58
|
+
|
|
59
|
+
export async function planSetup(
|
|
60
|
+
cfg: OssclipConfig,
|
|
61
|
+
p: SetupProbes,
|
|
62
|
+
opts: SetupOptions,
|
|
63
|
+
): Promise<SetupStep[]> {
|
|
64
|
+
const steps: SetupStep[] = [];
|
|
65
|
+
const brewAvailable =
|
|
66
|
+
p.platform === "darwin" ? await p.binRuns("brew", "--version") : false;
|
|
67
|
+
|
|
68
|
+
// ffmpeg + ffprobe travel together: one archive provides both, and a
|
|
69
|
+
// machine with one but not the other is a broken install either way.
|
|
70
|
+
const ffmpegOk =
|
|
71
|
+
(await p.binRuns(cfg.ffmpegPath, "-version")) &&
|
|
72
|
+
(await p.binRuns(cfg.ffprobePath, "-version"));
|
|
73
|
+
const ffmpegForceable = isManaged(cfg.ffmpegPath, opts.configDir);
|
|
74
|
+
if (ffmpegOk && !(opts.force && ffmpegForceable)) {
|
|
75
|
+
steps.push({ kind: "ffmpeg", status: "satisfied", detail: cfg.ffmpegPath });
|
|
76
|
+
} else {
|
|
77
|
+
const asset = ffmpegAsset(p.platform, p.arch);
|
|
78
|
+
if (asset) {
|
|
79
|
+
steps.push({
|
|
80
|
+
kind: "ffmpeg",
|
|
81
|
+
status: "download",
|
|
82
|
+
detail: `static ffmpeg + ffprobe ${asset.version} (${asset.license} build)`,
|
|
83
|
+
asset,
|
|
84
|
+
sizeMB: asset.sizeMB,
|
|
85
|
+
});
|
|
86
|
+
} else if (brewAvailable) {
|
|
87
|
+
steps.push({
|
|
88
|
+
kind: "ffmpeg",
|
|
89
|
+
status: "brew",
|
|
90
|
+
detail: "ffmpeg + ffprobe via Homebrew",
|
|
91
|
+
hint: "ffmpeg",
|
|
92
|
+
});
|
|
93
|
+
} else {
|
|
94
|
+
steps.push({
|
|
95
|
+
kind: "ffmpeg",
|
|
96
|
+
status: "manual",
|
|
97
|
+
detail: "no automated path for this platform",
|
|
98
|
+
hint:
|
|
99
|
+
p.platform === "darwin"
|
|
100
|
+
? "install Homebrew (https://brew.sh) then `brew install ffmpeg`, or set OSSCLIP_FFMPEG"
|
|
101
|
+
: "install ffmpeg from https://ffmpeg.org and set OSSCLIP_FFMPEG",
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const whisperOk = await p.binRuns(cfg.whisperPath, "--help");
|
|
107
|
+
const whisperForceable = isManaged(cfg.whisperPath, opts.configDir);
|
|
108
|
+
if (whisperOk && !(opts.force && whisperForceable)) {
|
|
109
|
+
steps.push({ kind: "whisper", status: "satisfied", detail: cfg.whisperPath });
|
|
110
|
+
} else {
|
|
111
|
+
const asset = whisperAsset(p.platform, p.arch);
|
|
112
|
+
if (asset) {
|
|
113
|
+
steps.push({
|
|
114
|
+
kind: "whisper",
|
|
115
|
+
status: "download",
|
|
116
|
+
detail: `prebuilt whisper.cpp ${asset.version} (whisper-cli)`,
|
|
117
|
+
asset,
|
|
118
|
+
sizeMB: asset.sizeMB,
|
|
119
|
+
});
|
|
120
|
+
} else if (brewAvailable) {
|
|
121
|
+
steps.push({
|
|
122
|
+
kind: "whisper",
|
|
123
|
+
status: "brew",
|
|
124
|
+
detail: "whisper.cpp via Homebrew",
|
|
125
|
+
hint: "whisper-cpp",
|
|
126
|
+
});
|
|
127
|
+
} else {
|
|
128
|
+
steps.push({
|
|
129
|
+
kind: "whisper",
|
|
130
|
+
status: "manual",
|
|
131
|
+
detail: "no automated path for this platform",
|
|
132
|
+
hint:
|
|
133
|
+
p.platform === "darwin"
|
|
134
|
+
? "install Homebrew (https://brew.sh) then `brew install whisper-cpp`, or set OSSCLIP_WHISPER"
|
|
135
|
+
: "see https://github.com/ggml-org/whisper.cpp — build from source, then set OSSCLIP_WHISPER",
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// The model: same resolution produce and doctor use — absolute is a file
|
|
141
|
+
// path, a bare name lives in modelDir as ggml-<name>.bin. `--force` never
|
|
142
|
+
// re-downloads a present model; a corrupt one is deleted by hand.
|
|
143
|
+
const model = opts.model;
|
|
144
|
+
const modelPath = isAbsolute(model) ? model : join(cfg.modelDir, `ggml-${model}.bin`);
|
|
145
|
+
const known = MODELS[model];
|
|
146
|
+
if (p.exists(modelPath)) {
|
|
147
|
+
steps.push({ kind: "model", status: "satisfied", detail: modelPath });
|
|
148
|
+
} else if (isAbsolute(model)) {
|
|
149
|
+
steps.push({
|
|
150
|
+
kind: "model",
|
|
151
|
+
status: "manual",
|
|
152
|
+
detail: `configured model is an absolute path that doesn't exist: ${model}`,
|
|
153
|
+
hint: "put the file there, or set model to a name like small.en for setup to download",
|
|
154
|
+
});
|
|
155
|
+
} else {
|
|
156
|
+
steps.push({
|
|
157
|
+
kind: "model",
|
|
158
|
+
status: "download",
|
|
159
|
+
detail: `${modelUrl(model)} → ${modelPath}`,
|
|
160
|
+
sizeMB: known?.sizeMB,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Provider, in doctor's detection order. Setup can save a key, but only
|
|
165
|
+
// ever interactively — never invented, never required (--skip-llm).
|
|
166
|
+
const provider = p.env.GEMINI_API_KEY
|
|
167
|
+
? "gemini (GEMINI_API_KEY is set)"
|
|
168
|
+
: p.env.ANTHROPIC_API_KEY
|
|
169
|
+
? "claude (ANTHROPIC_API_KEY is set)"
|
|
170
|
+
: (await p.binRuns("claude", "--version"))
|
|
171
|
+
? "claude-cli (logged-in Claude Code)"
|
|
172
|
+
: null;
|
|
173
|
+
if (opts.skipLlm) {
|
|
174
|
+
steps.push({
|
|
175
|
+
kind: "provider",
|
|
176
|
+
status: "satisfied",
|
|
177
|
+
detail: provider ?? "skipped (--skip-llm) — needed for --produce only",
|
|
178
|
+
});
|
|
179
|
+
} else if (provider) {
|
|
180
|
+
steps.push({ kind: "provider", status: "satisfied", detail: provider });
|
|
181
|
+
} else {
|
|
182
|
+
steps.push({
|
|
183
|
+
kind: "provider",
|
|
184
|
+
status: "prompt",
|
|
185
|
+
detail: "no LLM provider found — setup will ask (Enter skips; only --produce needs one)",
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return steps;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function formatPlan(steps: SetupStep[]): string {
|
|
193
|
+
const lines = steps.map((s) => {
|
|
194
|
+
const mark = s.status === "satisfied" ? "✓" : "▸";
|
|
195
|
+
const size = s.sizeMB ? ` (~${s.sizeMB} MB)` : "";
|
|
196
|
+
const action =
|
|
197
|
+
s.status === "satisfied"
|
|
198
|
+
? s.detail
|
|
199
|
+
: s.status === "download"
|
|
200
|
+
? `download${size}: ${s.detail}`
|
|
201
|
+
: s.status === "brew"
|
|
202
|
+
? `brew install ${s.hint}`
|
|
203
|
+
: s.status === "prompt"
|
|
204
|
+
? s.detail
|
|
205
|
+
: `manual: ${s.hint}`;
|
|
206
|
+
return `${mark} ${s.kind.padEnd(10)} ${action}`;
|
|
207
|
+
});
|
|
208
|
+
const totalMB = steps.reduce((n, s) => n + (s.status === "download" ? (s.sizeMB ?? 0) : 0), 0);
|
|
209
|
+
if (totalMB > 0) lines.push(`\n total download ~${totalMB} MB → everything lands under ~/.ossclip`);
|
|
210
|
+
return lines.join("\n");
|
|
211
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The LLM-provider step of `ossclip setup`.
|
|
6
|
+
*
|
|
7
|
+
* A key is only ever taken interactively and saved to `~/.ossclip/.env` —
|
|
8
|
+
* the file `loadEnvFiles` (R16 §77) already reads last, so a shell export
|
|
9
|
+
* or a project `.env` still wins. Secrets stay out of config.json, which
|
|
10
|
+
* people paste into issues.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface ProviderIO {
|
|
14
|
+
/** Ask one question, return the trimmed answer ("" for just-Enter). */
|
|
15
|
+
ask(question: string): Promise<string>;
|
|
16
|
+
say(line: string): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function promptForProvider(io: ProviderIO, configDir: string): Promise<void> {
|
|
20
|
+
io.say("");
|
|
21
|
+
io.say("An LLM provider is only needed for `--produce` (the graphics planner).");
|
|
22
|
+
io.say("Cutting + captions run fully local without one.");
|
|
23
|
+
io.say(" 1) I have an Anthropic API key");
|
|
24
|
+
io.say(" 2) I have a Google Gemini API key");
|
|
25
|
+
io.say(" 3) I use Claude Code (already logged in — no key needed)");
|
|
26
|
+
io.say(" Enter) skip for now");
|
|
27
|
+
const choice = (await io.ask("Choice: ")).trim();
|
|
28
|
+
if (choice === "3") {
|
|
29
|
+
io.say("▸ nothing to save — ossclip finds the claude CLI on PATH by itself.");
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const envKey = choice === "1" ? "ANTHROPIC_API_KEY" : choice === "2" ? "GEMINI_API_KEY" : null;
|
|
33
|
+
if (!envKey) {
|
|
34
|
+
io.say("▸ skipped — `ossclip doctor` will remind you what --produce needs.");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const value = (await io.ask(`${envKey}=`)).trim();
|
|
38
|
+
if (!value) {
|
|
39
|
+
io.say("▸ empty — skipped.");
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const envPath = saveProviderKey(configDir, envKey, value);
|
|
43
|
+
io.say(`▸ saved to ${envPath} (delete that line to revoke)`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function saveProviderKey(configDir: string, key: string, value: string): string {
|
|
47
|
+
const envPath = join(configDir, ".env");
|
|
48
|
+
mkdirSync(dirname(envPath), { recursive: true });
|
|
49
|
+
appendFileSync(envPath, `${key}=${value}\n`, { mode: 0o600 });
|
|
50
|
+
return envPath;
|
|
51
|
+
}
|