ossclip 0.1.14 → 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.
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>ossclip editor</title>
7
- <script type="module" crossorigin src="/assets/index-CKpBGMBB.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-CdwzWN3j.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="/assets/index-C8IPo60X.css">
9
9
  </head>
10
10
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ossclip",
3
- "version": "0.1.14",
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.14",
40
- "@ossclip/scenes": "0.1.14",
41
- "@ossclip/renderer": "0.1.14"
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 (gemini → claude →
148
- // claude-cli). Needed for --produce only — the cut+captions path never
149
- // touches an LLM — but doctor's contract is "ready for the whole thing".
150
- const provider = p.env.GEMINI_API_KEY
151
- ? "gemini (GEMINI_API_KEY is set)"
152
- : p.env.ANTHROPIC_API_KEY
153
- ? "claude (ANTHROPIC_API_KEY is set)"
154
- : (await p.binRuns("claude", "--version"))
155
- ? "claude-cli (logged-in Claude Code)"
156
- : null;
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: provider ?? "no key set and no claude CLI on PATH — needed for --produce; cut+captions works without",
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
- llm?: "claude" | "claude-cli" | "gemini" | "mock";
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 { workdir: work, rendered: false };
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 { workdir: work, out: outPath, rendered: true };
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(
@@ -212,8 +226,16 @@ export function buildProgram(): Command {
212
226
  .option("--intent <text>", "what the video should be ('educational video about agents…')")
213
227
  .option(
214
228
  "--llm <provider>",
215
- "claude | claude-cli | gemini | mock. Default: claude if ANTHROPIC_API_KEY is set, " +
216
- "else claude-cli (your logged-in Claude Code — Pro/Max subscription, no API charges)",
229
+ // Must state `defaultProviderName`'s real order — the old text omitted
230
+ // the GEMINI-first branch and promised claude-first (field report
231
+ // 2026-08-07); the drift test in llm-help.test.ts pins the agreement.
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",
217
239
  )
218
240
  .option("--llm-model <id>", "override the provider's default model")
219
241
  .option(
@@ -333,7 +355,7 @@ export function buildProgram(): Command {
333
355
  if (envFiles.length > 0) console.log(`▸ env: ${envFiles.join(", ")}`);
334
356
  const cleanup = CleanupLevelSchema.parse(opts.cleanup);
335
357
  const provider = opts.llm
336
- ? z.enum(["claude", "claude-cli", "gemini", "mock"]).parse(opts.llm)
358
+ ? z.enum(["antigravity", "claude", "claude-cli", "gemini", "mock"]).parse(opts.llm)
337
359
  : undefined;
338
360
  const forceComponent = opts.forceComponent
339
361
  ? SceneComponentIdSchema.parse(opts.forceComponent)
@@ -356,46 +378,86 @@ export function buildProgram(): Command {
356
378
  opts.whisperLanguage !== undefined
357
379
  ? z.string().trim().min(1, "--whisper-language needs a code, e.g. ur").parse(opts.whisperLanguage)
358
380
  : undefined;
359
- const result = await produce(input, {
360
- out: opts.out,
361
- cleanup,
362
- transcript: opts.transcript,
363
- render: opts.render,
364
- mezzanine: opts.mezzanine,
365
- workdir: opts.workdir,
366
- sort,
367
- sortExplicit,
368
- aspect: opts.aspect === "16:9" ? "16:9" : "9:16",
369
- noiseDb: opts.noiseDb,
370
- produce: opts.produce,
371
- intent: opts.intent,
372
- provider,
373
- llmModel: opts.llmModel,
374
- llmFastModel: opts.llmFastModel,
375
- speaker: opts.speaker,
376
- scenes: opts.scenes,
377
- repair: opts.repair,
378
- whisperModel: opts.whisperModel,
379
- whisperLanguage,
380
- forceComponent,
381
- // commander gives `--no-cover` as cover:false and `--cover <path>` as a
382
- // string on the same key.
383
- sourceIsEdited: opts.sourceIsEdited === true,
384
- blooperMarker: opts.blooperMarker,
385
- collapseRetakes: opts.collapseRetakes,
386
- sourceFit,
387
- // undefined = "not typed", so produce can let the config decide.
388
- watermark: opts.watermark,
389
- // undefined = "not typed" here too — the default (ON) is applied at
390
- // the pin site, not coerced in transit.
391
- captions: opts.captions,
392
- cover: opts.cover !== false,
393
- coverPath: typeof opts.cover === "string" ? opts.cover : undefined,
394
- clip: opts.clip,
395
- clipWindow: opts.clipWindow,
396
- });
397
- const { offerEditor } = await import("./interactive/offer-editor");
398
- await offerEditor(result, { flag: opts.openEditor, port: opts.editorPort });
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
+ }
399
461
  });
400
462
 
401
463
  program
@@ -413,7 +475,7 @@ export function buildProgram(): Command {
413
475
  )
414
476
  .action(async (input: string, opts) => {
415
477
  const cleanup = CleanupLevelSchema.parse(opts.cleanup);
416
- await produce(input, {
478
+ const result = await produce(input, {
417
479
  cleanup,
418
480
  transcript: opts.transcript,
419
481
  render: false,
@@ -427,6 +489,11 @@ export function buildProgram(): Command {
427
489
  ? z.string().trim().min(1, "--whisper-language needs a code, e.g. ur").parse(opts.whisperLanguage)
428
490
  : undefined,
429
491
  });
492
+ telemetry.record("transcribe_completed", {
493
+ cleanup_level: cleanup,
494
+ source_duration_bucket: durationBucket(result.sourceDurationSec),
495
+ });
496
+ await telemetry.flush();
430
497
  });
431
498
 
432
499
  program
@@ -526,6 +593,11 @@ export function buildProgram(): Command {
526
593
  const { openInBrowser } = await import("./open");
527
594
  openInBrowser(server.url);
528
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();
529
601
  });
530
602
 
531
603
  program
@@ -541,7 +613,13 @@ export function buildProgram(): Command {
541
613
  .action(async (opts) => {
542
614
  if (envFiles.length > 0) console.log(`▸ env: ${envFiles.join(", ")}`);
543
615
  const { setup } = await import("./setup/setup");
544
- 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();
545
623
  });
546
624
 
547
625
  program
@@ -556,8 +634,61 @@ export function buildProgram(): Command {
556
634
  const { loadConfig } = await import("@ossclip/core");
557
635
  const checks = await runDoctor(loadConfig(), realProbes(resolveEditorPageDir()));
558
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();
559
644
  if (checks.some((c) => !c.ok)) process.exit(1);
560
645
  });
561
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
+
562
693
  return program;
563
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. 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;
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",
@@ -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.");
@@ -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(opts: SetupCliOptions): Promise<void> {
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
- "set ANTHROPIC_API_KEY or GEMINI_API_KEY when you want it.",
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. */