ossclip 0.1.4 → 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/editor-dist/assets/{index-B5H_dKAW.js → index-DZhkQB1i.js} +24 -24
- package/editor-dist/index.html +1 -1
- package/package.json +5 -4
- package/src/doctor.ts +32 -10
- package/src/edit.ts +12 -2
- package/src/index.ts +6 -237
- 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/open.ts +30 -0
- package/src/produce.ts +138 -16
- package/src/program.ts +401 -0
- 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,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
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
|
3
|
+
import { basename, isAbsolute, join } from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { CONFIG_DIR, loadConfig, saveConfigPatch, type OssclipConfig } from "@ossclip/core";
|
|
6
|
+
import { MODELS, WHISPER_BUILD_HINT, modelUrl, type BinaryAsset } from "./manifest";
|
|
7
|
+
import { formatPlan, managedBinDir, planSetup, type SetupProbes, type SetupStep } from "./plan";
|
|
8
|
+
import { download, progressLine } from "./download";
|
|
9
|
+
import { extractArchive, findFile, markExecutable } from "./extract";
|
|
10
|
+
import { promptForProvider } from "./provider";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `ossclip setup` — the whole install, one command (§90: the install cliff
|
|
14
|
+
* is the adoption ceiling; this is the ramp). Downloads pinned static
|
|
15
|
+
* ffmpeg and prebuilt whisper.cpp where upstream publishes them, brews on
|
|
16
|
+
* macOS, fetches the transcription model with resume + checksum, offers to
|
|
17
|
+
* save an LLM key, and records absolute paths in ~/.ossclip/config.json so
|
|
18
|
+
* nothing touches PATH — that last part is the entire Windows story.
|
|
19
|
+
*
|
|
20
|
+
* Setup fills gaps and never takes over a working install: whatever
|
|
21
|
+
* already runs is left exactly where it is. It ends by running the real
|
|
22
|
+
* doctor, because setup's definition of done is doctor's definition of
|
|
23
|
+
* healthy.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export interface SetupCliOptions {
|
|
27
|
+
model?: string;
|
|
28
|
+
skipLlm: boolean;
|
|
29
|
+
force: boolean;
|
|
30
|
+
yes: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const probeBin = (bin: string, arg: string): Promise<boolean> =>
|
|
34
|
+
new Promise((resolve) => {
|
|
35
|
+
// Existence is the question, not exit code (same contract as doctor).
|
|
36
|
+
const child = spawn(bin, [arg], { stdio: "ignore" });
|
|
37
|
+
child.on("error", () => resolve(false));
|
|
38
|
+
child.on("exit", () => resolve(true));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export async function setup(opts: SetupCliOptions): Promise<void> {
|
|
42
|
+
const cfg = loadConfig();
|
|
43
|
+
const model = opts.model ?? cfg.model;
|
|
44
|
+
const probes: SetupProbes = {
|
|
45
|
+
binRuns: probeBin,
|
|
46
|
+
exists: existsSync,
|
|
47
|
+
platform: process.platform,
|
|
48
|
+
arch: process.arch,
|
|
49
|
+
env: process.env,
|
|
50
|
+
};
|
|
51
|
+
const steps = await planSetup(cfg, probes, {
|
|
52
|
+
configDir: CONFIG_DIR,
|
|
53
|
+
model,
|
|
54
|
+
force: opts.force,
|
|
55
|
+
skipLlm: opts.skipLlm,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
console.log("ossclip setup — plan:\n");
|
|
59
|
+
console.log(formatPlan(steps));
|
|
60
|
+
console.log("");
|
|
61
|
+
|
|
62
|
+
const interactive = process.stdin.isTTY === true && !opts.yes;
|
|
63
|
+
const needsWork = steps.some((s) => s.status !== "satisfied");
|
|
64
|
+
const failures: string[] = [];
|
|
65
|
+
const rl = interactive
|
|
66
|
+
? createInterface({ input: process.stdin, output: process.stdout })
|
|
67
|
+
: null;
|
|
68
|
+
try {
|
|
69
|
+
if (needsWork && rl) {
|
|
70
|
+
const answer = (await rl.question("Proceed? [Y/n] ")).trim().toLowerCase();
|
|
71
|
+
if (answer === "n" || answer === "no") {
|
|
72
|
+
console.log("▸ nothing changed.");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const patch: Partial<OssclipConfig> = {};
|
|
78
|
+
for (const step of steps) {
|
|
79
|
+
// One step failing (a dropped download, a brew hiccup) must not throw
|
|
80
|
+
// away the others' work — record it, keep going, save what succeeded,
|
|
81
|
+
// and let the closing doctor run print the remaining fixes.
|
|
82
|
+
try {
|
|
83
|
+
await runStep(step);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
86
|
+
failures.push(`${step.kind}: ${msg}`);
|
|
87
|
+
console.error(`✗ ${step.kind} failed: ${msg}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function runStep(step: SetupStep): Promise<void> {
|
|
92
|
+
switch (step.kind) {
|
|
93
|
+
case "ffmpeg":
|
|
94
|
+
if (step.status === "download" && step.asset) {
|
|
95
|
+
const [ffmpegBin, ffprobeBin] = step.asset.bins;
|
|
96
|
+
if (!ffmpegBin || !ffprobeBin) throw new Error("manifest bug: ffmpeg asset needs two bins");
|
|
97
|
+
const dir = await provision("ffmpeg", step.asset);
|
|
98
|
+
patch.ffmpegPath = mustFind(dir, ffmpegBin);
|
|
99
|
+
patch.ffprobePath = mustFind(dir, ffprobeBin);
|
|
100
|
+
await verifyRuns(patch.ffmpegPath, "-version", "ffmpeg");
|
|
101
|
+
await verifyRuns(patch.ffprobePath, "-version", "ffprobe");
|
|
102
|
+
console.log(`▸ ffmpeg ready: ${patch.ffmpegPath}`);
|
|
103
|
+
} else if (step.status === "brew") {
|
|
104
|
+
await brewInstall(step.hint ?? "ffmpeg");
|
|
105
|
+
await verifyRuns("ffmpeg", "-version", "ffmpeg");
|
|
106
|
+
} else if (step.status === "manual") {
|
|
107
|
+
console.log(`✗ ffmpeg needs a manual step: ${step.hint}`);
|
|
108
|
+
}
|
|
109
|
+
break;
|
|
110
|
+
case "whisper":
|
|
111
|
+
if (step.status === "download" && step.asset) {
|
|
112
|
+
const [whisperBin] = step.asset.bins;
|
|
113
|
+
if (!whisperBin) throw new Error("manifest bug: whisper asset needs a bin");
|
|
114
|
+
const dir = await provision("whisper", step.asset);
|
|
115
|
+
const bin = mustFind(dir, whisperBin);
|
|
116
|
+
// The probe matters here more than anywhere: a prebuilt that
|
|
117
|
+
// doesn't run on this libc must fail NOW with the build recipe,
|
|
118
|
+
// not half-configured at first transcription.
|
|
119
|
+
await verifyRuns(bin, "--help", "whisper-cli", WHISPER_BUILD_HINT);
|
|
120
|
+
patch.whisperPath = bin;
|
|
121
|
+
console.log(`▸ whisper-cli ready: ${bin}`);
|
|
122
|
+
} else if (step.status === "brew") {
|
|
123
|
+
await brewInstall(step.hint ?? "whisper-cpp");
|
|
124
|
+
await verifyRuns("whisper-cli", "--help", "whisper-cli", WHISPER_BUILD_HINT);
|
|
125
|
+
} else if (step.status === "manual") {
|
|
126
|
+
console.log(`✗ whisper-cli needs a manual step: ${step.hint}`);
|
|
127
|
+
}
|
|
128
|
+
break;
|
|
129
|
+
case "model":
|
|
130
|
+
if (step.status === "download") {
|
|
131
|
+
const modelPath = isAbsolute(model)
|
|
132
|
+
? model
|
|
133
|
+
: join(cfg.modelDir, `ggml-${model}.bin`);
|
|
134
|
+
const info = MODELS[model];
|
|
135
|
+
if (!info) {
|
|
136
|
+
console.log(
|
|
137
|
+
`▸ ${model} isn't in the pinned table — downloading without a checksum.`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
console.log(`▸ downloading ggml-${model}.bin${info ? ` (~${info.sizeMB} MB)` : ""}…`);
|
|
141
|
+
await download(modelUrl(model), modelPath, {
|
|
142
|
+
sha1: info?.sha1,
|
|
143
|
+
onProgress: progressLine(`ggml-${model}.bin`),
|
|
144
|
+
});
|
|
145
|
+
console.log(`▸ model ready: ${modelPath}`);
|
|
146
|
+
if (opts.model && opts.model !== cfg.model) patch.model = opts.model;
|
|
147
|
+
} else if (step.status === "manual") {
|
|
148
|
+
console.log(`✗ model needs a manual step: ${step.hint}`);
|
|
149
|
+
}
|
|
150
|
+
break;
|
|
151
|
+
case "provider":
|
|
152
|
+
if (step.status === "prompt" && rl) {
|
|
153
|
+
await promptForProvider(
|
|
154
|
+
{ ask: (q) => rl.question(q), say: (l) => console.log(l) },
|
|
155
|
+
CONFIG_DIR,
|
|
156
|
+
);
|
|
157
|
+
} else if (step.status === "prompt") {
|
|
158
|
+
console.log(
|
|
159
|
+
"▸ no LLM provider configured (non-interactive run) — needed for --produce only; " +
|
|
160
|
+
"set ANTHROPIC_API_KEY or GEMINI_API_KEY when you want it.",
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (Object.keys(patch).length > 0) {
|
|
168
|
+
const path = saveConfigPatch(patch);
|
|
169
|
+
console.log(`▸ recorded in ${path}`);
|
|
170
|
+
}
|
|
171
|
+
} finally {
|
|
172
|
+
rl?.close();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Setup's exit criterion is doctor's: all green, or the exact fix per line.
|
|
176
|
+
console.log("");
|
|
177
|
+
const { runDoctor, formatDoctor, realProbes } = await import("../doctor");
|
|
178
|
+
const { resolveEditorPageDir } = await import("../edit");
|
|
179
|
+
const checks = await runDoctor(loadConfig(), realProbes(resolveEditorPageDir()));
|
|
180
|
+
console.log(formatDoctor(checks));
|
|
181
|
+
if (failures.length > 0) process.exitCode = 1;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Download an asset (with resume) and extract it under the managed bin dir. */
|
|
185
|
+
async function provision(kind: string, asset: BinaryAsset): Promise<string> {
|
|
186
|
+
const archive = join(CONFIG_DIR, "downloads", basename(new URL(asset.url).pathname));
|
|
187
|
+
const destDir = join(managedBinDir(CONFIG_DIR), `${kind}-${asset.version}`);
|
|
188
|
+
console.log(`▸ downloading ${basename(archive)} (~${asset.sizeMB} MB)…`);
|
|
189
|
+
await download(asset.url, archive, {
|
|
190
|
+
sha256: asset.sha256,
|
|
191
|
+
onProgress: progressLine(basename(archive)),
|
|
192
|
+
});
|
|
193
|
+
rmSync(destDir, { recursive: true, force: true });
|
|
194
|
+
mkdirSync(destDir, { recursive: true });
|
|
195
|
+
await extractArchive(archive, destDir);
|
|
196
|
+
for (const bin of asset.bins) {
|
|
197
|
+
const found = findFile(destDir, bin);
|
|
198
|
+
if (found) markExecutable(found);
|
|
199
|
+
}
|
|
200
|
+
// The archive did its job; ~160 MB of zip has no second life.
|
|
201
|
+
rmSync(archive, { force: true });
|
|
202
|
+
return destDir;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function mustFind(dir: string, bin: string): string {
|
|
206
|
+
const found = findFile(dir, bin);
|
|
207
|
+
if (!found) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`extracted archive doesn't contain ${bin} — upstream layout changed; ` +
|
|
210
|
+
"please open an issue with this output",
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
return found;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function verifyRuns(bin: string, arg: string, name: string, hint?: string): Promise<void> {
|
|
217
|
+
if (await probeBin(bin, arg)) return;
|
|
218
|
+
throw new Error(
|
|
219
|
+
`${name} was installed but doesn't run on this machine (${bin}).` +
|
|
220
|
+
(hint ? `\nFallback: ${hint}` : ""),
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function brewInstall(formula: string): Promise<void> {
|
|
225
|
+
console.log(`▸ brew install ${formula}…`);
|
|
226
|
+
return new Promise((resolve, reject) => {
|
|
227
|
+
const child = spawn("brew", ["install", formula], { stdio: "inherit" });
|
|
228
|
+
child.on("error", (e) => reject(new Error(`brew failed to start: ${e.message}`)));
|
|
229
|
+
child.on("exit", (code) =>
|
|
230
|
+
code === 0 ? resolve() : reject(new Error(`brew install ${formula} exited ${code}`)),
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
}
|