ossclip 0.1.15 → 0.1.16
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/README.md +7 -1
- package/package.json +4 -4
- package/src/doctor.ts +21 -12
- package/src/interactive/produce-argv.ts +6 -1
- package/src/interactive/produce-wizard.ts +3 -0
- package/src/llm-detect.ts +62 -0
- package/src/produce.ts +32 -8
- package/src/program.ts +173 -46
- package/src/setup/plan.ts +13 -9
- package/src/setup/provider.ts +5 -0
- package/src/setup/setup.ts +13 -3
- package/src/telemetry.ts +468 -0
package/README.md
CHANGED
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
**A local-first CLI that turns a talking-head take into a finished short.** It cuts silence and filler words, writes word-timed kinetic captions, frames on the measured face, and has an LLM plan **code-rendered on-screen graphics** — title cards, stat cards, diagrams, terminal and chat mockups — from what was actually said.
|
|
4
4
|
|
|
5
|
-
Transcription is local (whisper.cpp), rendering is local (Remotion); the only network calls are the LLM planning ones, on your own key or your existing Claude Code subscription. Vertical 9:16 by default, landscape 16:9 with `--aspect`.
|
|
5
|
+
Transcription is local (whisper.cpp), rendering is local (Remotion); the only network calls are the LLM planning ones, on your own key or your existing Claude Code / Google Antigravity subscription. Vertical 9:16 by default, landscape 16:9 with `--aspect`.
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
*Left: the raw take. Right: the same take after `ossclip produce` — cut down, reframed on the measured face, word-timed kinetic captions.*
|
|
6
10
|
|
|
7
11
|

|
|
8
12
|
|
|
@@ -31,6 +35,8 @@ ossclip edit "<work directory>"
|
|
|
31
35
|
|
|
32
36
|
AI can make mistakes: the cut, the captions and every graphic are generated — review the output before publishing.
|
|
33
37
|
|
|
38
|
+
**Telemetry:** ossclip sends anonymous usage events (counts, durations, provider name) — never footage, transcripts, file names or paths. Turn it off any time with `ossclip telemetry off`, `OSSCLIP_TELEMETRY=0`, or `DO_NOT_TRACK=1`. Full detail in the repo README's Telemetry section.
|
|
39
|
+
|
|
34
40
|
**Full documentation, flags, keybinds and the findings log:** [github.com/AhsanAyaz/ossclip](https://github.com/AhsanAyaz/ossclip)
|
|
35
41
|
|
|
36
42
|
## Licence
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ossclip",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
4
4
|
"description": "Local-first CLI video producer: cuts silence and fillers, word-timed captions, face-aware framing, and LLM-planned code-rendered graphics — transcription and rendering never leave your machine",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
"commander": "^12.1.0",
|
|
37
37
|
"tsx": "^4.19.0",
|
|
38
38
|
"zod": "^3.25.76",
|
|
39
|
-
"@ossclip/core": "0.1.
|
|
40
|
-
"@ossclip/renderer": "0.1.
|
|
41
|
-
"@ossclip/scenes": "0.1.
|
|
39
|
+
"@ossclip/core": "0.1.16",
|
|
40
|
+
"@ossclip/renderer": "0.1.16",
|
|
41
|
+
"@ossclip/scenes": "0.1.16"
|
|
42
42
|
},
|
|
43
43
|
"homepage": "https://github.com/AhsanAyaz/ossclip#readme",
|
|
44
44
|
"bugs": {
|
package/src/doctor.ts
CHANGED
|
@@ -144,27 +144,36 @@ export async function runDoctor(cfg: OssclipConfig, p: DoctorProbes): Promise<Do
|
|
|
144
144
|
}),
|
|
145
145
|
});
|
|
146
146
|
|
|
147
|
-
// Provider, in the same order auto-detection uses (
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
147
|
+
// Provider, in the same order auto-detection uses (agy → claude CLI →
|
|
148
|
+
// gemini key → anthropic key): subscription CLIs beat ambient env keys
|
|
149
|
+
// since 2026-08 — a logged-in CLI is an explicit, already-paid choice
|
|
150
|
+
// (FINDINGS §132, antigravity provider). Bin overrides are honored so
|
|
151
|
+
// doctor probes the same binary produce would spawn. Needed for --produce
|
|
152
|
+
// only — the cut+captions path never touches an LLM — but doctor's
|
|
153
|
+
// contract is "ready for the whole thing".
|
|
154
|
+
const provider = (await p.binRuns(p.env.OSSCLIP_AGY_BIN ?? "agy", "--version"))
|
|
155
|
+
? "antigravity (agy CLI on PATH)"
|
|
156
|
+
: (await p.binRuns(p.env.OSSCLIP_CLAUDE_BIN ?? "claude", "--version"))
|
|
157
|
+
? "claude-cli (logged-in Claude Code)"
|
|
158
|
+
: p.env.GEMINI_API_KEY
|
|
159
|
+
? "gemini (GEMINI_API_KEY is set)"
|
|
160
|
+
: p.env.ANTHROPIC_API_KEY
|
|
161
|
+
? "claude (ANTHROPIC_API_KEY is set)"
|
|
162
|
+
: null;
|
|
157
163
|
checks.push({
|
|
158
164
|
name: "LLM provider",
|
|
159
165
|
ok: provider !== null,
|
|
160
|
-
detail:
|
|
166
|
+
detail:
|
|
167
|
+
provider ??
|
|
168
|
+
"no agy or claude CLI on PATH and no key set — needed for --produce; cut+captions works without",
|
|
161
169
|
...(provider !== null
|
|
162
170
|
? {}
|
|
163
171
|
: {
|
|
164
172
|
fix:
|
|
165
173
|
"run `ossclip setup` (it can save a key for you), or " +
|
|
166
174
|
"export ANTHROPIC_API_KEY or GEMINI_API_KEY (a .env file works — see README), " +
|
|
167
|
-
"or install Claude Code (https://claude.com/claude-code) and log in"
|
|
175
|
+
"or install Claude Code (https://claude.com/claude-code) and log in, " +
|
|
176
|
+
"or install Google Antigravity (https://antigravity.google) and log in",
|
|
168
177
|
}),
|
|
169
178
|
});
|
|
170
179
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ProviderName } from "@ossclip/core";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Wizard answers → the argv a user could have typed.
|
|
3
5
|
*
|
|
@@ -24,7 +26,10 @@ export interface ProduceExtras {
|
|
|
24
26
|
* the positive `--captions` stays flags-only — it exists for replay
|
|
25
27
|
* pinning, and emitting it here would restate the default. */
|
|
26
28
|
captions?: boolean;
|
|
27
|
-
|
|
29
|
+
/** core's ProviderName, not an inline union — a provider added there must
|
|
30
|
+
* not be silently unofferable here (the pre-§132 union had already
|
|
31
|
+
* drifted: it never listed "antigravity"). */
|
|
32
|
+
llm?: ProviderName;
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
export interface ProduceAnswers {
|
|
@@ -310,6 +310,9 @@ export async function produceWizard(
|
|
|
310
310
|
await select({
|
|
311
311
|
message: "LLM provider",
|
|
312
312
|
options: [
|
|
313
|
+
// Listed in auto-detection's own order (FINDINGS §132): the menu
|
|
314
|
+
// teaching a different ranking than a bare run uses would be drift.
|
|
315
|
+
{ value: "antigravity", label: "antigravity", hint: "your logged-in Google Antigravity (agy), no API charges" },
|
|
313
316
|
{ value: "claude-cli", label: "claude-cli", hint: "your logged-in Claude Code, no API charges" },
|
|
314
317
|
{ value: "claude", label: "claude", hint: "needs ANTHROPIC_API_KEY" },
|
|
315
318
|
{ value: "gemini", label: "gemini", hint: "needs GEMINI_API_KEY" },
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { delimiter, join } from "node:path";
|
|
3
|
+
import type { ProviderName } from "@ossclip/core";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Provider auto-detection helpers (FINDINGS §132, antigravity provider):
|
|
7
|
+
* `binOnPath` is the real `hasBin` the CLI injects into core's
|
|
8
|
+
* `defaultProviderName`, and `detectionLine` is the one place the "which
|
|
9
|
+
* provider and why" message lives — pure, total over every ProviderName, so
|
|
10
|
+
* the drift test can pin all five lines. The old inline ternary in produce.ts
|
|
11
|
+
* covered only two providers and printed the ANTHROPIC line for a
|
|
12
|
+
* gemini-detected run.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Is this binary reachable? Sync, and a PATH scan with `existsSync` rather
|
|
17
|
+
* than a `spawnSync(bin, ["--version"])` probe: this runs on produce's
|
|
18
|
+
* startup path, and spawning agy + claude just to detect them costs
|
|
19
|
+
* ~100ms × 2 on every run. Existence-not-runnability is the same trade
|
|
20
|
+
* doctor's `binRuns` makes in the other direction — doctor is diagnostics
|
|
21
|
+
* and can afford the spawn; startup can't.
|
|
22
|
+
*/
|
|
23
|
+
export function binOnPath(
|
|
24
|
+
bin: string,
|
|
25
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
26
|
+
platform: NodeJS.Platform = process.platform,
|
|
27
|
+
): boolean {
|
|
28
|
+
// A path-ish override (OSSCLIP_AGY_BIN=/opt/agy) names a file, not a PATH
|
|
29
|
+
// entry — check it verbatim. `\` counts on win32 only; it is a legal
|
|
30
|
+
// filename character on posix.
|
|
31
|
+
if (bin.includes("/") || (platform === "win32" && bin.includes("\\"))) {
|
|
32
|
+
return existsSync(bin);
|
|
33
|
+
}
|
|
34
|
+
const pathEntries = (env.PATH ?? "").split(delimiter).filter(Boolean);
|
|
35
|
+
// On Windows a bare `agy` resolves through PATHEXT (agy.cmd, agy.exe…);
|
|
36
|
+
// the extensionless name is still tried first for shims that have none.
|
|
37
|
+
const suffixes =
|
|
38
|
+
platform === "win32"
|
|
39
|
+
? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)]
|
|
40
|
+
: [""];
|
|
41
|
+
return pathEntries.some((dir) => suffixes.some((ext) => existsSync(join(dir, bin + ext))));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The "▸ which provider and why" line for an auto-detected run. Each line
|
|
46
|
+
* names its own trigger so a surprised user can see exactly which check won
|
|
47
|
+
* — the point of the §132 order change is visible, not silent.
|
|
48
|
+
*/
|
|
49
|
+
export function detectionLine(name: ProviderName): string {
|
|
50
|
+
switch (name) {
|
|
51
|
+
case "antigravity":
|
|
52
|
+
return "▸ agy CLI found — using Google Antigravity (subscription auth)";
|
|
53
|
+
case "claude-cli":
|
|
54
|
+
return "▸ using the Claude Code CLI (subscription auth)";
|
|
55
|
+
case "gemini":
|
|
56
|
+
return "▸ GEMINI_API_KEY found — using the Gemini API";
|
|
57
|
+
case "claude":
|
|
58
|
+
return "▸ ANTHROPIC_API_KEY found — using the Claude API";
|
|
59
|
+
case "mock":
|
|
60
|
+
return "▸ using the mock provider (no LLM)";
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/produce.ts
CHANGED
|
@@ -98,6 +98,7 @@ import {
|
|
|
98
98
|
type Transcript,
|
|
99
99
|
} from "@ossclip/core";
|
|
100
100
|
import { recordRecentProject } from "./edit";
|
|
101
|
+
import { binOnPath, detectionLine } from "./llm-detect";
|
|
101
102
|
import {
|
|
102
103
|
strandedOverrideSiblings,
|
|
103
104
|
strandedPointerLine,
|
|
@@ -122,6 +123,16 @@ export interface ProduceResult {
|
|
|
122
123
|
workdir: string;
|
|
123
124
|
out?: string;
|
|
124
125
|
rendered: boolean;
|
|
126
|
+
/**
|
|
127
|
+
* Telemetry inputs (FINDINGS §134), surfaced here so the command layer can
|
|
128
|
+
* build the event without produce() knowing telemetry exists. The duration
|
|
129
|
+
* is only ever SENT as a bucket, and the provider is the resolved NAME —
|
|
130
|
+
* never a key, never a path.
|
|
131
|
+
*/
|
|
132
|
+
sourceDurationSec: number;
|
|
133
|
+
sceneCount: number;
|
|
134
|
+
/** Resolved provider name when the LLM ran; undefined without --produce. */
|
|
135
|
+
llmProvider?: string;
|
|
125
136
|
}
|
|
126
137
|
|
|
127
138
|
/**
|
|
@@ -848,16 +859,16 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
848
859
|
// LLM ran. Everything downstream that a viewer READS — captions, scene copy,
|
|
849
860
|
// the grounding check — uses the repaired transcript instead, so a
|
|
850
861
|
// mishearing can't reach the screen twice in two different spellings.
|
|
851
|
-
const providerName = opts.provider ?? defaultProviderName();
|
|
862
|
+
const providerName = opts.provider ?? defaultProviderName(process.env, binOnPath);
|
|
852
863
|
let provider: LlmProvider | null = null;
|
|
853
864
|
const needsLlm = opts.produce === true;
|
|
854
865
|
if (needsLlm) {
|
|
866
|
+
// Only when auto-detected: a typed --llm needs no explanation. The line
|
|
867
|
+
// itself lives in llm-detect.ts so a drift test covers every provider —
|
|
868
|
+
// the inline ternary this replaces printed the ANTHROPIC line for a
|
|
869
|
+
// gemini-detected run (FINDINGS §132, antigravity provider).
|
|
855
870
|
if (!opts.provider) {
|
|
856
|
-
console.log(
|
|
857
|
-
providerName === "claude-cli"
|
|
858
|
-
? "▸ no ANTHROPIC_API_KEY — using the Claude Code CLI (subscription auth)"
|
|
859
|
-
: "▸ ANTHROPIC_API_KEY found — using the Claude API",
|
|
860
|
-
);
|
|
871
|
+
console.log(detectionLine(providerName));
|
|
861
872
|
}
|
|
862
873
|
provider = createTieredProvider(providerName, {
|
|
863
874
|
model: opts.llmModel,
|
|
@@ -2196,7 +2207,13 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2196
2207
|
if (!opts.render) {
|
|
2197
2208
|
console.log(`▸ skipping render (--no-render). Props at ${join(work, "render-props.json")}`);
|
|
2198
2209
|
console.log(editHint(work));
|
|
2199
|
-
return {
|
|
2210
|
+
return {
|
|
2211
|
+
workdir: work,
|
|
2212
|
+
rendered: false,
|
|
2213
|
+
sourceDurationSec: sourceProbe.duration,
|
|
2214
|
+
sceneCount: scenes.length,
|
|
2215
|
+
llmProvider: provider ? providerName : undefined,
|
|
2216
|
+
};
|
|
2200
2217
|
}
|
|
2201
2218
|
|
|
2202
2219
|
const outPath = resolve(opts.out ?? defaultOutPath(originalInput));
|
|
@@ -2379,5 +2396,12 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2379
2396
|
await recordRecentProject(work);
|
|
2380
2397
|
console.log(`✓ done → ${outPath}`);
|
|
2381
2398
|
console.log(editHint(work));
|
|
2382
|
-
return {
|
|
2399
|
+
return {
|
|
2400
|
+
workdir: work,
|
|
2401
|
+
out: outPath,
|
|
2402
|
+
rendered: true,
|
|
2403
|
+
sourceDurationSec: sourceProbe.duration,
|
|
2404
|
+
sceneCount: scenes.length,
|
|
2405
|
+
llmProvider: provider ? providerName : undefined,
|
|
2406
|
+
};
|
|
2383
2407
|
}
|
package/src/program.ts
CHANGED
|
@@ -8,6 +8,14 @@ import { STUDIO_ENTRY } from "@ossclip/renderer";
|
|
|
8
8
|
import { loadEnvFiles } from "./env";
|
|
9
9
|
import { produce } from "./produce";
|
|
10
10
|
import { setReplayArgv } from "./replay-argv";
|
|
11
|
+
import {
|
|
12
|
+
bootstrapTelemetry,
|
|
13
|
+
durationBucket,
|
|
14
|
+
loadState,
|
|
15
|
+
maybeAskRating,
|
|
16
|
+
saveState,
|
|
17
|
+
telemetryOffReason,
|
|
18
|
+
} from "./telemetry";
|
|
11
19
|
|
|
12
20
|
// Before anything reads a provider key (R16 §77) — including the auto-detect
|
|
13
21
|
// order in `defaultProviderName`, which decides which model runs.
|
|
@@ -30,6 +38,12 @@ const envFiles = loadEnvFiles();
|
|
|
30
38
|
export function buildProgram(): Command {
|
|
31
39
|
const program = new Command();
|
|
32
40
|
|
|
41
|
+
// Before dispatch, so the one-time first-run notice precedes any command's
|
|
42
|
+
// own output. Inert in this repo's tests by construction: while POSTHOG_KEY
|
|
43
|
+
// is the placeholder, bootstrap touches no disk and sends nothing (FINDINGS
|
|
44
|
+
// §134) — which is what lets every test build the real program unmocked.
|
|
45
|
+
const telemetry = bootstrapTelemetry();
|
|
46
|
+
|
|
33
47
|
program
|
|
34
48
|
.name("ossclip")
|
|
35
49
|
.description(
|
|
@@ -215,9 +229,13 @@ export function buildProgram(): Command {
|
|
|
215
229
|
// Must state `defaultProviderName`'s real order — the old text omitted
|
|
216
230
|
// the GEMINI-first branch and promised claude-first (field report
|
|
217
231
|
// 2026-08-07); the drift test in llm-help.test.ts pins the agreement.
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
232
|
+
// Order changed 2026-08: subscription CLIs beat ambient env keys
|
|
233
|
+
// (FINDINGS §132, antigravity provider).
|
|
234
|
+
"antigravity | claude | claude-cli | gemini | mock. Default: antigravity if the agy " +
|
|
235
|
+
"CLI is installed (your logged-in Google Antigravity, no API charges), else " +
|
|
236
|
+
"claude-cli if the claude CLI is installed (your logged-in Claude Code — Pro/Max " +
|
|
237
|
+
"subscription, no API charges), else gemini if GEMINI_API_KEY is set, else claude " +
|
|
238
|
+
"if ANTHROPIC_API_KEY is set, else claude-cli",
|
|
221
239
|
)
|
|
222
240
|
.option("--llm-model <id>", "override the provider's default model")
|
|
223
241
|
.option(
|
|
@@ -337,7 +355,7 @@ export function buildProgram(): Command {
|
|
|
337
355
|
if (envFiles.length > 0) console.log(`▸ env: ${envFiles.join(", ")}`);
|
|
338
356
|
const cleanup = CleanupLevelSchema.parse(opts.cleanup);
|
|
339
357
|
const provider = opts.llm
|
|
340
|
-
? z.enum(["claude", "claude-cli", "gemini", "mock"]).parse(opts.llm)
|
|
358
|
+
? z.enum(["antigravity", "claude", "claude-cli", "gemini", "mock"]).parse(opts.llm)
|
|
341
359
|
: undefined;
|
|
342
360
|
const forceComponent = opts.forceComponent
|
|
343
361
|
? SceneComponentIdSchema.parse(opts.forceComponent)
|
|
@@ -360,46 +378,86 @@ export function buildProgram(): Command {
|
|
|
360
378
|
opts.whisperLanguage !== undefined
|
|
361
379
|
? z.string().trim().min(1, "--whisper-language needs a code, e.g. ur").parse(opts.whisperLanguage)
|
|
362
380
|
: undefined;
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
381
|
+
// Wall clock around produce() only — the editor offer below can sit at
|
|
382
|
+
// an interactive prompt for as long as the user thinks, and think-time
|
|
383
|
+
// would poison the duration metric (FINDINGS §134).
|
|
384
|
+
const startedMs = Date.now();
|
|
385
|
+
try {
|
|
386
|
+
const result = await produce(input, {
|
|
387
|
+
out: opts.out,
|
|
388
|
+
cleanup,
|
|
389
|
+
transcript: opts.transcript,
|
|
390
|
+
render: opts.render,
|
|
391
|
+
mezzanine: opts.mezzanine,
|
|
392
|
+
workdir: opts.workdir,
|
|
393
|
+
sort,
|
|
394
|
+
sortExplicit,
|
|
395
|
+
aspect: opts.aspect === "16:9" ? "16:9" : "9:16",
|
|
396
|
+
noiseDb: opts.noiseDb,
|
|
397
|
+
produce: opts.produce,
|
|
398
|
+
intent: opts.intent,
|
|
399
|
+
provider,
|
|
400
|
+
llmModel: opts.llmModel,
|
|
401
|
+
llmFastModel: opts.llmFastModel,
|
|
402
|
+
speaker: opts.speaker,
|
|
403
|
+
scenes: opts.scenes,
|
|
404
|
+
repair: opts.repair,
|
|
405
|
+
whisperModel: opts.whisperModel,
|
|
406
|
+
whisperLanguage,
|
|
407
|
+
forceComponent,
|
|
408
|
+
// commander gives `--no-cover` as cover:false and `--cover <path>` as a
|
|
409
|
+
// string on the same key.
|
|
410
|
+
sourceIsEdited: opts.sourceIsEdited === true,
|
|
411
|
+
blooperMarker: opts.blooperMarker,
|
|
412
|
+
collapseRetakes: opts.collapseRetakes,
|
|
413
|
+
sourceFit,
|
|
414
|
+
// undefined = "not typed", so produce can let the config decide.
|
|
415
|
+
watermark: opts.watermark,
|
|
416
|
+
// undefined = "not typed" here too — the default (ON) is applied at
|
|
417
|
+
// the pin site, not coerced in transit.
|
|
418
|
+
captions: opts.captions,
|
|
419
|
+
cover: opts.cover !== false,
|
|
420
|
+
coverPath: typeof opts.cover === "string" ? opts.cover : undefined,
|
|
421
|
+
clip: opts.clip,
|
|
422
|
+
clipWindow: opts.clipWindow,
|
|
423
|
+
});
|
|
424
|
+
// Counts, buckets and names only — the duration crosses the wire as a
|
|
425
|
+
// bucket, and nothing here can carry a path (assertSafeProps enforces
|
|
426
|
+
// it). Inert while POSTHOG_KEY is the placeholder (FINDINGS §134).
|
|
427
|
+
telemetry.record("produce_completed", {
|
|
428
|
+
duration_ms: Date.now() - startedMs,
|
|
429
|
+
llm_provider: result.llmProvider ?? "none",
|
|
430
|
+
produced: opts.produce === true,
|
|
431
|
+
aspect: opts.aspect === "16:9" ? "16:9" : "9:16",
|
|
432
|
+
clip: opts.clip !== undefined,
|
|
433
|
+
render: opts.render !== false,
|
|
434
|
+
source_duration_bucket: durationBucket(result.sourceDurationSec),
|
|
435
|
+
scenes: result.sceneCount,
|
|
436
|
+
});
|
|
437
|
+
if (!telemetry.disabled) {
|
|
438
|
+
telemetry.state.produceCount += 1;
|
|
439
|
+
try {
|
|
440
|
+
saveState(telemetry.state);
|
|
441
|
+
} catch {
|
|
442
|
+
// A read-only home dir must never fail the produce that just
|
|
443
|
+
// succeeded — the rating gate simply advances a run later.
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
const { offerEditor } = await import("./interactive/offer-editor");
|
|
447
|
+
await offerEditor(result, { flag: opts.openEditor, port: opts.editorPort });
|
|
448
|
+
// Deliberately LAST — after the render summary and the editor offer —
|
|
449
|
+
// so the one question ossclip ever asks is the last thing on screen.
|
|
450
|
+
await maybeAskRating(telemetry);
|
|
451
|
+
} catch (err) {
|
|
452
|
+
// The constructor NAME only, never the message: error messages
|
|
453
|
+
// routinely quote the input path, the exact thing §134 forbids.
|
|
454
|
+
telemetry.record("produce_failed", {
|
|
455
|
+
error_class: err instanceof Error ? err.constructor.name : "NonError",
|
|
456
|
+
});
|
|
457
|
+
throw err;
|
|
458
|
+
} finally {
|
|
459
|
+
await telemetry.flush();
|
|
460
|
+
}
|
|
403
461
|
});
|
|
404
462
|
|
|
405
463
|
program
|
|
@@ -417,7 +475,7 @@ export function buildProgram(): Command {
|
|
|
417
475
|
)
|
|
418
476
|
.action(async (input: string, opts) => {
|
|
419
477
|
const cleanup = CleanupLevelSchema.parse(opts.cleanup);
|
|
420
|
-
await produce(input, {
|
|
478
|
+
const result = await produce(input, {
|
|
421
479
|
cleanup,
|
|
422
480
|
transcript: opts.transcript,
|
|
423
481
|
render: false,
|
|
@@ -431,6 +489,11 @@ export function buildProgram(): Command {
|
|
|
431
489
|
? z.string().trim().min(1, "--whisper-language needs a code, e.g. ur").parse(opts.whisperLanguage)
|
|
432
490
|
: undefined,
|
|
433
491
|
});
|
|
492
|
+
telemetry.record("transcribe_completed", {
|
|
493
|
+
cleanup_level: cleanup,
|
|
494
|
+
source_duration_bucket: durationBucket(result.sourceDurationSec),
|
|
495
|
+
});
|
|
496
|
+
await telemetry.flush();
|
|
434
497
|
});
|
|
435
498
|
|
|
436
499
|
program
|
|
@@ -530,6 +593,11 @@ export function buildProgram(): Command {
|
|
|
530
593
|
const { openInBrowser } = await import("./open");
|
|
531
594
|
openInBrowser(server.url);
|
|
532
595
|
}
|
|
596
|
+
// Fire-and-forget on purpose: the server keeps the process alive for
|
|
597
|
+
// the request's lifetime, and awaiting here would put a metrics POST
|
|
598
|
+
// between the user and their browser opening (FINDINGS §134).
|
|
599
|
+
telemetry.record("editor_opened", {});
|
|
600
|
+
void telemetry.flush();
|
|
533
601
|
});
|
|
534
602
|
|
|
535
603
|
program
|
|
@@ -545,7 +613,13 @@ export function buildProgram(): Command {
|
|
|
545
613
|
.action(async (opts) => {
|
|
546
614
|
if (envFiles.length > 0) console.log(`▸ env: ${envFiles.join(", ")}`);
|
|
547
615
|
const { setup } = await import("./setup/setup");
|
|
548
|
-
await setup({ model: opts.model, skipLlm: opts.skipLlm, force: opts.force, yes: opts.yes });
|
|
616
|
+
const summary = await setup({ model: opts.model, skipLlm: opts.skipLlm, force: opts.force, yes: opts.yes });
|
|
617
|
+
telemetry.record("setup_completed", {
|
|
618
|
+
steps_total: summary.stepsTotal,
|
|
619
|
+
steps_satisfied: summary.stepsSatisfied,
|
|
620
|
+
steps_failed: summary.stepsFailed,
|
|
621
|
+
});
|
|
622
|
+
await telemetry.flush();
|
|
549
623
|
});
|
|
550
624
|
|
|
551
625
|
program
|
|
@@ -560,8 +634,61 @@ export function buildProgram(): Command {
|
|
|
560
634
|
const { loadConfig } = await import("@ossclip/core");
|
|
561
635
|
const checks = await runDoctor(loadConfig(), realProbes(resolveEditorPageDir()));
|
|
562
636
|
console.log(formatDoctor(checks));
|
|
637
|
+
const passedCount = checks.filter((c) => c.ok).length;
|
|
638
|
+
telemetry.record("doctor_run", {
|
|
639
|
+
checks_total: checks.length,
|
|
640
|
+
checks_passed: passedCount,
|
|
641
|
+
checks_failed: checks.length - passedCount,
|
|
642
|
+
});
|
|
643
|
+
await telemetry.flush();
|
|
563
644
|
if (checks.some((c) => !c.ok)) process.exit(1);
|
|
564
645
|
});
|
|
565
646
|
|
|
647
|
+
program
|
|
648
|
+
.command("telemetry")
|
|
649
|
+
.description("show or change anonymous usage telemetry — on | off | status")
|
|
650
|
+
.argument("[action]", "on | off | status (default: status)")
|
|
651
|
+
.action(async (action: string | undefined) => {
|
|
652
|
+
// Parsed, not coerced (§93a shape): `ossclip telemetry offf` must be a
|
|
653
|
+
// loud error naming the typo, never a silent status print.
|
|
654
|
+
const parsed = z.enum(["on", "off", "status"]).parse(action ?? "status");
|
|
655
|
+
// A fresh load, not the bootstrap instance's state: with the
|
|
656
|
+
// placeholder key bootstrap never reads the file, but an EXPLICIT
|
|
657
|
+
// on/off is the user asking for a persisted preference — it must land
|
|
658
|
+
// in ~/.ossclip/telemetry.json either way, ready for a keyed build.
|
|
659
|
+
const state = loadState();
|
|
660
|
+
if (parsed === "off") {
|
|
661
|
+
state.enabled = false;
|
|
662
|
+
saveState(state);
|
|
663
|
+
console.log("✓ telemetry off — nothing will be sent (saved in ~/.ossclip/telemetry.json)");
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
if (parsed === "on") {
|
|
667
|
+
state.enabled = true;
|
|
668
|
+
saveState(state);
|
|
669
|
+
console.log(
|
|
670
|
+
"✓ telemetry on — anonymous usage events only; see the README's Telemetry section",
|
|
671
|
+
);
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
const reason = telemetryOffReason(process.env, state);
|
|
675
|
+
if (reason === null) {
|
|
676
|
+
console.log("telemetry: enabled (anonymous usage events only)");
|
|
677
|
+
} else {
|
|
678
|
+
// Name the switch that WON, not just the state — three different
|
|
679
|
+
// offs (env, standard env, config) all look identical otherwise, and
|
|
680
|
+
// "why is it still off?" needs the answer.
|
|
681
|
+
const why: Record<typeof reason, string> = {
|
|
682
|
+
"placeholder-key":
|
|
683
|
+
"this build ships without a telemetry key — nothing is ever sent or stored",
|
|
684
|
+
env: `OSSCLIP_TELEMETRY=${process.env.OSSCLIP_TELEMETRY} in the environment`,
|
|
685
|
+
"do-not-track": `DO_NOT_TRACK=${process.env.DO_NOT_TRACK} in the environment`,
|
|
686
|
+
config: "`ossclip telemetry off` (saved in ~/.ossclip/telemetry.json)",
|
|
687
|
+
};
|
|
688
|
+
console.log(`telemetry: disabled — ${why[reason]}`);
|
|
689
|
+
}
|
|
690
|
+
console.log(`anonymous id: ${state.anonymousId}`);
|
|
691
|
+
});
|
|
692
|
+
|
|
566
693
|
return program;
|
|
567
694
|
}
|
package/src/setup/plan.ts
CHANGED
|
@@ -161,15 +161,19 @@ export async function planSetup(
|
|
|
161
161
|
});
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
// Provider, in doctor's detection order
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
164
|
+
// Provider, in doctor's detection order (agy → claude CLI → gemini key →
|
|
165
|
+
// anthropic key; subscription CLIs beat ambient keys — FINDINGS §132,
|
|
166
|
+
// antigravity provider). Setup can save a key, but only ever
|
|
167
|
+
// interactively — never invented, never required (--skip-llm).
|
|
168
|
+
const provider = (await p.binRuns(p.env.OSSCLIP_AGY_BIN ?? "agy", "--version"))
|
|
169
|
+
? "antigravity (agy CLI on PATH)"
|
|
170
|
+
: (await p.binRuns(p.env.OSSCLIP_CLAUDE_BIN ?? "claude", "--version"))
|
|
171
|
+
? "claude-cli (logged-in Claude Code)"
|
|
172
|
+
: p.env.GEMINI_API_KEY
|
|
173
|
+
? "gemini (GEMINI_API_KEY is set)"
|
|
174
|
+
: p.env.ANTHROPIC_API_KEY
|
|
175
|
+
? "claude (ANTHROPIC_API_KEY is set)"
|
|
176
|
+
: null;
|
|
173
177
|
if (opts.skipLlm) {
|
|
174
178
|
steps.push({
|
|
175
179
|
kind: "provider",
|
package/src/setup/provider.ts
CHANGED
|
@@ -23,12 +23,17 @@ export async function promptForProvider(io: ProviderIO, configDir: string): Prom
|
|
|
23
23
|
io.say(" 1) I have an Anthropic API key");
|
|
24
24
|
io.say(" 2) I have a Google Gemini API key");
|
|
25
25
|
io.say(" 3) I use Claude Code (already logged in — no key needed)");
|
|
26
|
+
io.say(" 4) I use Google Antigravity (agy — already logged in, no key needed)");
|
|
26
27
|
io.say(" Enter) skip for now");
|
|
27
28
|
const choice = (await io.ask("Choice: ")).trim();
|
|
28
29
|
if (choice === "3") {
|
|
29
30
|
io.say("▸ nothing to save — ossclip finds the claude CLI on PATH by itself.");
|
|
30
31
|
return;
|
|
31
32
|
}
|
|
33
|
+
if (choice === "4") {
|
|
34
|
+
io.say("▸ nothing to save — ossclip finds the agy CLI on PATH by itself.");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
32
37
|
const envKey = choice === "1" ? "ANTHROPIC_API_KEY" : choice === "2" ? "GEMINI_API_KEY" : null;
|
|
33
38
|
if (!envKey) {
|
|
34
39
|
io.say("▸ skipped — `ossclip doctor` will remind you what --produce needs.");
|
package/src/setup/setup.ts
CHANGED
|
@@ -38,7 +38,9 @@ const probeBin = (bin: string, arg: string): Promise<boolean> =>
|
|
|
38
38
|
child.on("exit", () => resolve(true));
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
-
export async function setup(
|
|
41
|
+
export async function setup(
|
|
42
|
+
opts: SetupCliOptions,
|
|
43
|
+
): Promise<{ stepsTotal: number; stepsSatisfied: number; stepsFailed: number }> {
|
|
42
44
|
const cfg = loadConfig();
|
|
43
45
|
const model = opts.model ?? cfg.model;
|
|
44
46
|
const probes: SetupProbes = {
|
|
@@ -70,7 +72,7 @@ export async function setup(opts: SetupCliOptions): Promise<void> {
|
|
|
70
72
|
const answer = (await rl.question("Proceed? [Y/n] ")).trim().toLowerCase();
|
|
71
73
|
if (answer === "n" || answer === "no") {
|
|
72
74
|
console.log("▸ nothing changed.");
|
|
73
|
-
return;
|
|
75
|
+
return { stepsTotal: steps.length, stepsSatisfied: 0, stepsFailed: 0 };
|
|
74
76
|
}
|
|
75
77
|
}
|
|
76
78
|
|
|
@@ -157,7 +159,8 @@ export async function setup(opts: SetupCliOptions): Promise<void> {
|
|
|
157
159
|
} else if (step.status === "prompt") {
|
|
158
160
|
console.log(
|
|
159
161
|
"▸ no LLM provider configured (non-interactive run) — needed for --produce only; " +
|
|
160
|
-
"
|
|
162
|
+
"a logged-in Claude Code or Google Antigravity (agy) CLI is picked up automatically, " +
|
|
163
|
+
"or set ANTHROPIC_API_KEY or GEMINI_API_KEY when you want it.",
|
|
161
164
|
);
|
|
162
165
|
}
|
|
163
166
|
break;
|
|
@@ -179,6 +182,13 @@ export async function setup(opts: SetupCliOptions): Promise<void> {
|
|
|
179
182
|
const checks = await runDoctor(loadConfig(), realProbes(resolveEditorPageDir()));
|
|
180
183
|
console.log(formatDoctor(checks));
|
|
181
184
|
if (failures.length > 0) process.exitCode = 1;
|
|
185
|
+
// The caller (program.ts) owns telemetry; this module stays network-free
|
|
186
|
+
// beyond its own downloads. It just reports what happened.
|
|
187
|
+
return {
|
|
188
|
+
stepsTotal: steps.length,
|
|
189
|
+
stepsSatisfied: steps.filter((s) => s.status === "satisfied").length,
|
|
190
|
+
stepsFailed: failures.length,
|
|
191
|
+
};
|
|
182
192
|
}
|
|
183
193
|
|
|
184
194
|
/** Download an asset (with resume) and extract it under the managed bin dir. */
|
package/src/telemetry.ts
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { z } from "zod/v4";
|
|
5
|
+
import { CONFIG_DIR } from "@ossclip/core";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Anonymous opt-out usage telemetry (FINDINGS §134).
|
|
9
|
+
*
|
|
10
|
+
* The layering follows the house split: everything above the "I/O" divider is
|
|
11
|
+
* pure — testable without a filesystem, a TTY or a network — and the thin I/O
|
|
12
|
+
* below it (state file, PostHog POST, readline) is injectable where a test
|
|
13
|
+
* needs to see through it.
|
|
14
|
+
*
|
|
15
|
+
* Privacy floor, non-negotiable: no event ever carries a file path, a file
|
|
16
|
+
* name, transcript text, `--intent` text, a prompt, or a key. `assertSafeProps`
|
|
17
|
+
* is the drift guard that keeps FUTURE events honest about it, not just the
|
|
18
|
+
* ones written today.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The baked-in default is the project's WRITE-ONLY ingest key — public by
|
|
23
|
+
* design (every shipped analytics client carries one; it can only capture,
|
|
24
|
+
* never read), and baked rather than env-read because ossclip is a
|
|
25
|
+
* distributed CLI: an end user who installed from npm has no POSTHOG_API_KEY
|
|
26
|
+
* in their environment, so an env-only key would mean telemetry that never
|
|
27
|
+
* reports from exactly the machines it exists to count (§134). The env var
|
|
28
|
+
* still OVERRIDES the default for development against another project.
|
|
29
|
+
*
|
|
30
|
+
* With the key real, the test suite's hermeticity comes from vitest.config.ts
|
|
31
|
+
* exporting OSSCLIP_TELEMETRY=0 into every test process — see the
|
|
32
|
+
* hermetic-suite tests in telemetry.test.ts. Typed `string`, not the literal,
|
|
33
|
+
* so the `=== POSTHOG_PLACEHOLDER` checks stay ordinary comparisons.
|
|
34
|
+
*/
|
|
35
|
+
export const POSTHOG_PLACEHOLDER = "phc_REPLACE_ME";
|
|
36
|
+
export const POSTHOG_KEY: string =
|
|
37
|
+
process.env.POSTHOG_API_KEY ?? "phc_B8y7hMMmHYVEmkUfiLfuBcWoWM5GjbnaT9oBZLZnyPB3";
|
|
38
|
+
export const POSTHOG_HOST: string = process.env.POSTHOG_HOST ?? "https://eu.i.posthog.com";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The whole latency budget telemetry is allowed to cost a run: one POST,
|
|
42
|
+
* aborted at this cap, no retries (§134). A metrics request must never be
|
|
43
|
+
* the slowest part of somebody's render.
|
|
44
|
+
*/
|
|
45
|
+
export const FLUSH_TIMEOUT_MS = 2500;
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Pure
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
const freshDefaults = () => ({
|
|
52
|
+
anonymousId: randomUUID(),
|
|
53
|
+
enabled: true,
|
|
54
|
+
noticeShown: false,
|
|
55
|
+
produceCount: 0,
|
|
56
|
+
ratingAsked: 0,
|
|
57
|
+
ratingDone: false,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Per-field `.catch` so a hand-edited or truncated telemetry.json degrades a
|
|
62
|
+
* FIELD at a time (a corrupt `enabled` must not throw away the anonymousId),
|
|
63
|
+
* and an outer `.catch` so a document that isn't even an object resets whole
|
|
64
|
+
* — a corrupt state file must never crash the CLI it is riding in.
|
|
65
|
+
*/
|
|
66
|
+
export const TelemetryStateSchema = z
|
|
67
|
+
.object({
|
|
68
|
+
anonymousId: z.string().min(1).catch(() => randomUUID()),
|
|
69
|
+
enabled: z.boolean().catch(true),
|
|
70
|
+
noticeShown: z.boolean().catch(false),
|
|
71
|
+
produceCount: z.number().int().min(0).catch(0),
|
|
72
|
+
ratingAsked: z.number().int().min(0).catch(0),
|
|
73
|
+
ratingDone: z.boolean().catch(false),
|
|
74
|
+
})
|
|
75
|
+
.catch(freshDefaults);
|
|
76
|
+
export type TelemetryState = z.infer<typeof TelemetryStateSchema>;
|
|
77
|
+
|
|
78
|
+
export function defaultTelemetryState(): TelemetryState {
|
|
79
|
+
return freshDefaults();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type TelemetryOffReason = "placeholder-key" | "env" | "do-not-track" | "config";
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Why telemetry is off, or null when it is on. Precedence, most global first:
|
|
86
|
+
* a keyless build can never send (§134's hard-off invariant), an exported env
|
|
87
|
+
* var is the user's word in THIS shell, DO_NOT_TRACK is the ecosystem-wide
|
|
88
|
+
* spelling of the same word, and the config file is the persisted preference.
|
|
89
|
+
* The reason (not just the boolean) exists for `ossclip telemetry status`,
|
|
90
|
+
* which must name the switch that won.
|
|
91
|
+
*/
|
|
92
|
+
export function telemetryOffReason(
|
|
93
|
+
env: NodeJS.ProcessEnv,
|
|
94
|
+
state: { enabled: boolean },
|
|
95
|
+
apiKey: string = POSTHOG_KEY,
|
|
96
|
+
): TelemetryOffReason | null {
|
|
97
|
+
if (apiKey === POSTHOG_PLACEHOLDER) return "placeholder-key";
|
|
98
|
+
if (["0", "false", "off"].includes(env.OSSCLIP_TELEMETRY ?? "")) return "env";
|
|
99
|
+
if (["1", "true"].includes(env.DO_NOT_TRACK ?? "")) return "do-not-track";
|
|
100
|
+
if (state.enabled === false) return "config";
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function telemetryDisabled(
|
|
105
|
+
env: NodeJS.ProcessEnv,
|
|
106
|
+
state: { enabled: boolean },
|
|
107
|
+
apiKey: string = POSTHOG_KEY,
|
|
108
|
+
): boolean {
|
|
109
|
+
return telemetryOffReason(env, state, apiKey) !== null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Substrings no event prop key may contain, case-insensitively. This is the
|
|
114
|
+
* privacy promise as code: a future `source_path` or `intentText` prop fails
|
|
115
|
+
* loudly in the unit tests instead of quietly shipping user data. "text" and
|
|
116
|
+
* "hook" cover the producer's copy fields; "key" covers credentials.
|
|
117
|
+
*/
|
|
118
|
+
export const FORBIDDEN_PROP_KEYS = [
|
|
119
|
+
"path",
|
|
120
|
+
"file",
|
|
121
|
+
"dir",
|
|
122
|
+
"transcript",
|
|
123
|
+
"intent",
|
|
124
|
+
"prompt",
|
|
125
|
+
"key",
|
|
126
|
+
"hook",
|
|
127
|
+
"text",
|
|
128
|
+
] as const;
|
|
129
|
+
|
|
130
|
+
export function assertSafeProps(props: Record<string, unknown>): void {
|
|
131
|
+
for (const key of Object.keys(props)) {
|
|
132
|
+
const lower = key.toLowerCase();
|
|
133
|
+
for (const forbidden of FORBIDDEN_PROP_KEYS) {
|
|
134
|
+
if (lower.includes(forbidden)) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`telemetry prop "${key}" contains forbidden substring "${forbidden}" — ` +
|
|
137
|
+
"event props must never carry paths, names, text or keys (FINDINGS §134)",
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface EventContext {
|
|
145
|
+
anonymousId: string;
|
|
146
|
+
version: string;
|
|
147
|
+
platform: string;
|
|
148
|
+
arch: string;
|
|
149
|
+
nodeMajor: number;
|
|
150
|
+
ci: boolean;
|
|
151
|
+
/** Injectable for tests; production always rides POSTHOG_KEY. */
|
|
152
|
+
apiKey?: string;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The PostHog capture body — https://posthog.com/docs/api/capture */
|
|
156
|
+
export interface CaptureEvent {
|
|
157
|
+
api_key: string;
|
|
158
|
+
event: string;
|
|
159
|
+
distinct_id: string;
|
|
160
|
+
timestamp: string;
|
|
161
|
+
properties: Record<string, unknown>;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function buildEvent(
|
|
165
|
+
name: string,
|
|
166
|
+
props: Record<string, unknown>,
|
|
167
|
+
ctx: EventContext,
|
|
168
|
+
): CaptureEvent {
|
|
169
|
+
assertSafeProps(props);
|
|
170
|
+
return {
|
|
171
|
+
api_key: ctx.apiKey ?? POSTHOG_KEY,
|
|
172
|
+
event: name,
|
|
173
|
+
distinct_id: ctx.anonymousId,
|
|
174
|
+
timestamp: new Date().toISOString(),
|
|
175
|
+
properties: {
|
|
176
|
+
...props,
|
|
177
|
+
app_version: ctx.version,
|
|
178
|
+
os: ctx.platform,
|
|
179
|
+
arch: ctx.arch,
|
|
180
|
+
node_major: ctx.nodeMajor,
|
|
181
|
+
ci: ctx.ci,
|
|
182
|
+
// Anonymous events, never person profiles — the PostHog-side half of
|
|
183
|
+
// the anonymity promise (and the cheaper event class, incidentally).
|
|
184
|
+
$process_person_profile: false,
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The exact seconds never leave the machine — a duration is close to a
|
|
191
|
+
* fingerprint of a specific take, and a bucket answers the only question we
|
|
192
|
+
* have ("short-form or long-form users?") without carrying one.
|
|
193
|
+
*/
|
|
194
|
+
export function durationBucket(seconds: number): "<1m" | "1-5m" | "5-15m" | ">15m" {
|
|
195
|
+
if (seconds < 60) return "<1m";
|
|
196
|
+
if (seconds <= 300) return "1-5m";
|
|
197
|
+
if (seconds <= 900) return "5-15m";
|
|
198
|
+
return ">15m";
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Ask after the 3rd successful produce (someone still around at three runs
|
|
203
|
+
* has an opinion worth having), never again after an answer, and never more
|
|
204
|
+
* than twice — two Enter-skips is an answer too.
|
|
205
|
+
*/
|
|
206
|
+
export function shouldAskRating(state: TelemetryState): boolean {
|
|
207
|
+
return !state.ratingDone && state.ratingAsked < 2 && state.produceCount >= 3;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Parsed, not coerced (§93a shape): only a lone 1-5 counts. "3.5", "great"
|
|
212
|
+
* and "" are all a skip, never a Number()-mangled score.
|
|
213
|
+
*/
|
|
214
|
+
const RatingSchema = z
|
|
215
|
+
.string()
|
|
216
|
+
.trim()
|
|
217
|
+
.regex(/^[1-5]$/)
|
|
218
|
+
.transform(Number);
|
|
219
|
+
|
|
220
|
+
export function parseRating(line: string): number | null {
|
|
221
|
+
const parsed = RatingSchema.safeParse(line);
|
|
222
|
+
return parsed.success ? parsed.data : null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The one-time first-run notice. LOUD by design — an opt-out default is only
|
|
227
|
+
* honest if the opt-out is impossible to miss — and it states the privacy
|
|
228
|
+
* floor in the same breath.
|
|
229
|
+
*/
|
|
230
|
+
export const NOTICE = [
|
|
231
|
+
"▸ ossclip collects anonymous usage events — command counts, durations, the LLM",
|
|
232
|
+
" provider name. It NEVER sends your footage, transcripts, file names, paths,",
|
|
233
|
+
" or prompts. Turn it off any time: `ossclip telemetry off` or OSSCLIP_TELEMETRY=0.",
|
|
234
|
+
' Details and the full event list: README, "Telemetry".',
|
|
235
|
+
].join("\n");
|
|
236
|
+
|
|
237
|
+
export const RATING_PROMPT = "Rate ossclip so far? 1-5, Enter to skip: ";
|
|
238
|
+
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
// I/O
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
export function telemetryStatePath(configDir: string = CONFIG_DIR): string {
|
|
244
|
+
return join(configDir, "telemetry.json");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Read-only: a load never writes. Persistence happens only at the explicit
|
|
249
|
+
* saveState call sites (notice shown, produce counted, rating answered,
|
|
250
|
+
* on/off toggled) — which is what lets the placeholder-key build guarantee
|
|
251
|
+
* "no state writes" by simply never reaching those sites (§134).
|
|
252
|
+
*/
|
|
253
|
+
export function loadState(configDir: string = CONFIG_DIR): TelemetryState {
|
|
254
|
+
let raw: string;
|
|
255
|
+
try {
|
|
256
|
+
raw = readFileSync(telemetryStatePath(configDir), "utf8");
|
|
257
|
+
} catch {
|
|
258
|
+
return defaultTelemetryState(); // no file yet — first run
|
|
259
|
+
}
|
|
260
|
+
let parsed: unknown;
|
|
261
|
+
try {
|
|
262
|
+
parsed = JSON.parse(raw);
|
|
263
|
+
} catch {
|
|
264
|
+
parsed = undefined; // corrupt file → the schema's outer catch resets it
|
|
265
|
+
}
|
|
266
|
+
return TelemetryStateSchema.parse(parsed);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function saveState(state: TelemetryState, configDir: string = CONFIG_DIR): void {
|
|
270
|
+
mkdirSync(configDir, { recursive: true });
|
|
271
|
+
// 0600: the anonymousId is not a secret, but a state file only its owner
|
|
272
|
+
// can read costs nothing and forecloses the question.
|
|
273
|
+
writeFileSync(telemetryStatePath(configDir), `${JSON.stringify(state, null, 2)}\n`, {
|
|
274
|
+
mode: 0o600,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function cliVersion(): string {
|
|
279
|
+
// Same manifest-read as program.ts's .version() (R22 §113) — a literal here
|
|
280
|
+
// would report a stale number for the life of every release after it.
|
|
281
|
+
try {
|
|
282
|
+
return (
|
|
283
|
+
JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as {
|
|
284
|
+
version: string;
|
|
285
|
+
}
|
|
286
|
+
).version;
|
|
287
|
+
} catch {
|
|
288
|
+
return "0.0.0";
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function isCi(env: NodeJS.ProcessEnv): boolean {
|
|
293
|
+
return env.CI !== undefined && !["", "0", "false"].includes(env.CI);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* One instance per CLI invocation. `record` buffers, `flush` sends one batch;
|
|
298
|
+
* both are internally safe — telemetry must never break, slow (beyond
|
|
299
|
+
* FLUSH_TIMEOUT_MS) or noisy-up a run, so errors are swallowed HERE rather
|
|
300
|
+
* than try/caught at every call site.
|
|
301
|
+
*/
|
|
302
|
+
export class Telemetry {
|
|
303
|
+
private readonly events: CaptureEvent[] = [];
|
|
304
|
+
private readonly ctx: EventContext;
|
|
305
|
+
|
|
306
|
+
constructor(
|
|
307
|
+
private readonly env: NodeJS.ProcessEnv,
|
|
308
|
+
readonly state: TelemetryState,
|
|
309
|
+
private readonly fetchImpl: typeof fetch = fetch,
|
|
310
|
+
ctx?: Partial<EventContext>,
|
|
311
|
+
) {
|
|
312
|
+
this.ctx = {
|
|
313
|
+
anonymousId: ctx?.anonymousId ?? state.anonymousId,
|
|
314
|
+
version: ctx?.version ?? cliVersion(),
|
|
315
|
+
platform: ctx?.platform ?? process.platform,
|
|
316
|
+
arch: ctx?.arch ?? process.arch,
|
|
317
|
+
nodeMajor: ctx?.nodeMajor ?? Number.parseInt(process.versions.node, 10),
|
|
318
|
+
ci: ctx?.ci ?? isCi(env),
|
|
319
|
+
apiKey: ctx?.apiKey,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
get disabled(): boolean {
|
|
324
|
+
return telemetryDisabled(this.env, this.state, this.ctx.apiKey ?? POSTHOG_KEY);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
record(name: string, props: Record<string, unknown> = {}): void {
|
|
328
|
+
try {
|
|
329
|
+
if (this.disabled) return;
|
|
330
|
+
this.events.push(buildEvent(name, props, this.ctx));
|
|
331
|
+
} catch {
|
|
332
|
+
// Fail CLOSED: a prop key tripping assertSafeProps means the event
|
|
333
|
+
// would have carried something the privacy floor forbids — dropping it
|
|
334
|
+
// is the correct production outcome (no data beats wrong data), and
|
|
335
|
+
// the unit tests on buildEvent are where the throw is seen and fixed.
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async flush(): Promise<void> {
|
|
340
|
+
const batch = this.events.splice(0);
|
|
341
|
+
if (batch.length === 0) return;
|
|
342
|
+
const ac = new AbortController();
|
|
343
|
+
const timer = setTimeout(() => ac.abort(), FLUSH_TIMEOUT_MS);
|
|
344
|
+
try {
|
|
345
|
+
// Raced against our own abort, not just handed the signal: an injected
|
|
346
|
+
// fetch (tests) or a polyfill that ignores `signal` would otherwise
|
|
347
|
+
// hang this await past the cap — the exact promise flush exists to keep.
|
|
348
|
+
await Promise.race([
|
|
349
|
+
this.fetchImpl(`${POSTHOG_HOST}/batch/`, {
|
|
350
|
+
method: "POST",
|
|
351
|
+
headers: { "content-type": "application/json" },
|
|
352
|
+
body: JSON.stringify({ api_key: this.ctx.apiKey ?? POSTHOG_KEY, batch }),
|
|
353
|
+
signal: ac.signal,
|
|
354
|
+
}),
|
|
355
|
+
new Promise<void>((resolveRace) => {
|
|
356
|
+
ac.signal.addEventListener("abort", () => resolveRace(), { once: true });
|
|
357
|
+
}),
|
|
358
|
+
]);
|
|
359
|
+
} catch {
|
|
360
|
+
// Swallow everything — refused, offline, DNS, 4xx/5xx, abort. No
|
|
361
|
+
// retries either: a metrics batch is not worth a second attempt's
|
|
362
|
+
// latency, and the next run sends fresh events anyway.
|
|
363
|
+
} finally {
|
|
364
|
+
clearTimeout(timer);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Prints the first-run notice once and persists the flag. */
|
|
370
|
+
export function maybeShowNotice(
|
|
371
|
+
state: TelemetryState,
|
|
372
|
+
out: (s: string) => void,
|
|
373
|
+
configDir: string = CONFIG_DIR,
|
|
374
|
+
): void {
|
|
375
|
+
if (state.noticeShown) return;
|
|
376
|
+
out(NOTICE);
|
|
377
|
+
state.noticeShown = true;
|
|
378
|
+
saveState(state, configDir);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* The per-invocation entry point program.ts calls before dispatch. With the
|
|
383
|
+
* placeholder key this returns an inert instance WITHOUT touching disk —
|
|
384
|
+
* shipping keyless must be silent (§134), and it is also what keeps every
|
|
385
|
+
* `buildProgram()` in the test suite from writing ~/.ossclip.
|
|
386
|
+
*/
|
|
387
|
+
export function bootstrapTelemetry(
|
|
388
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
389
|
+
out: (s: string) => void = console.log,
|
|
390
|
+
): Telemetry {
|
|
391
|
+
if (POSTHOG_KEY === POSTHOG_PLACEHOLDER) {
|
|
392
|
+
return new Telemetry(env, defaultTelemetryState());
|
|
393
|
+
}
|
|
394
|
+
// Env-level off (OSSCLIP_TELEMETRY / DO_NOT_TRACK) returns BEFORE any disk
|
|
395
|
+
// touch: a run the user switched off must not read or create ~/.ossclip
|
|
396
|
+
// state — and it is also what keeps the test suite (which exports
|
|
397
|
+
// OSSCLIP_TELEMETRY=0 from vitest.config.ts) out of the real home dir.
|
|
398
|
+
if (telemetryDisabled(env, { enabled: true })) {
|
|
399
|
+
return new Telemetry(env, defaultTelemetryState());
|
|
400
|
+
}
|
|
401
|
+
let telemetry: Telemetry;
|
|
402
|
+
try {
|
|
403
|
+
const state = loadState();
|
|
404
|
+
telemetry = new Telemetry(env, state);
|
|
405
|
+
if (!telemetry.disabled && !state.noticeShown) {
|
|
406
|
+
maybeShowNotice(state, out);
|
|
407
|
+
// Piggybacks on noticeShown — one flag, sent exactly once. Flushed
|
|
408
|
+
// fire-and-forget because the first command may be one that never
|
|
409
|
+
// flushes itself (doctor, setup); the request rides while it runs.
|
|
410
|
+
telemetry.record("cli_first_run", {});
|
|
411
|
+
void telemetry.flush();
|
|
412
|
+
}
|
|
413
|
+
} catch {
|
|
414
|
+
// A read-only home dir or a hostile state file must never take the CLI
|
|
415
|
+
// down — telemetry degrades to inert, the run proceeds.
|
|
416
|
+
telemetry = new Telemetry(env, defaultTelemetryState());
|
|
417
|
+
}
|
|
418
|
+
return telemetry;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* The one-time rating ask, AFTER the run's own output so it is the last thing
|
|
423
|
+
* on screen. TTY-only and CI-excluded: a prompt into a pipe is a hang, and a
|
|
424
|
+
* prompt at a CI log is noise. Internally safe like the class methods.
|
|
425
|
+
*/
|
|
426
|
+
export async function maybeAskRating(
|
|
427
|
+
telemetry: Telemetry,
|
|
428
|
+
opts: {
|
|
429
|
+
isTTY?: boolean;
|
|
430
|
+
ci?: boolean;
|
|
431
|
+
ask?: () => Promise<string | null>;
|
|
432
|
+
configDir?: string;
|
|
433
|
+
} = {},
|
|
434
|
+
): Promise<void> {
|
|
435
|
+
try {
|
|
436
|
+
if (telemetry.disabled) return;
|
|
437
|
+
if (!shouldAskRating(telemetry.state)) return;
|
|
438
|
+
const tty = opts.isTTY ?? (process.stdout.isTTY === true && process.stdin.isTTY === true);
|
|
439
|
+
if (!tty) return;
|
|
440
|
+
if (opts.ci ?? isCi(process.env)) return;
|
|
441
|
+
const line = await (opts.ask ?? askRatingOnce)();
|
|
442
|
+
const score = line === null ? null : parseRating(line);
|
|
443
|
+
if (score !== null) {
|
|
444
|
+
telemetry.record("rating_submitted", { score });
|
|
445
|
+
telemetry.state.ratingDone = true;
|
|
446
|
+
} else {
|
|
447
|
+
// Empty, invalid, or timed out — all a skip; two skips end the asking.
|
|
448
|
+
telemetry.state.ratingAsked += 1;
|
|
449
|
+
}
|
|
450
|
+
saveState(telemetry.state, opts.configDir);
|
|
451
|
+
} catch {
|
|
452
|
+
// A rating prompt failing must never fail the produce that preceded it.
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function askRatingOnce(): Promise<string | null> {
|
|
457
|
+
const { createInterface } = await import("node:readline/promises");
|
|
458
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
459
|
+
try {
|
|
460
|
+
// 30s and gone: an unattended terminal must not hold the process open —
|
|
461
|
+
// a timeout is just Enter pressed by nobody.
|
|
462
|
+
return await rl.question(RATING_PROMPT, { signal: AbortSignal.timeout(30_000) });
|
|
463
|
+
} catch {
|
|
464
|
+
return null;
|
|
465
|
+
} finally {
|
|
466
|
+
rl.close();
|
|
467
|
+
}
|
|
468
|
+
}
|