ossclip 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ossclip",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
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/renderer": "0.1.9",
40
- "@ossclip/core": "0.1.9",
41
- "@ossclip/scenes": "0.1.9"
39
+ "@ossclip/core": "0.1.11",
40
+ "@ossclip/renderer": "0.1.11",
41
+ "@ossclip/scenes": "0.1.11"
42
42
  },
43
43
  "homepage": "https://github.com/AhsanAyaz/ossclip#readme",
44
44
  "bugs": {
package/src/edit.ts CHANGED
@@ -390,11 +390,22 @@ export async function startEditServer(
390
390
  );
391
391
  if (!parsed.success) return send(500, { error: `command.json is not valid: ${parsed.error.message}` });
392
392
  const cmd = parsed.data;
393
+ // §129: heal legacy records at replay. Before the fix, wizard and
394
+ // bare-path runs recorded process.argv — the ORIGINAL invocation,
395
+ // missing the `produce` literal the re-entered parse actually ran —
396
+ // so replaying them verbatim dies at commander's front door with
397
+ // "error: unknown option '--llm'". produce is the ONLY command that
398
+ // ever writes command.json, so an args array not starting with
399
+ // "produce" can only be that bug: prepend the literal to
400
+ // reconstruct the command that ran. A modern record — and a legacy
401
+ // directly-typed `ossclip produce …` — already starts with it and
402
+ // is untouched.
403
+ const args = cmd.args[0] === "produce" ? cmd.args : ["produce", ...cmd.args];
393
404
  renderLines = [];
394
405
  renderExit = null;
395
406
  renderStartedAt = Date.now();
396
407
  renderCancelled = false;
397
- const child = spawn(cmd.execPath, [...cmd.execArgv, cmd.script, ...cmd.args], {
408
+ const child = spawn(cmd.execPath, [...cmd.execArgv, cmd.script, ...args], {
398
409
  cwd: cmd.cwd,
399
410
  stdio: ["ignore", "pipe", "pipe"],
400
411
  });
@@ -50,29 +50,36 @@ export function extrasFor(graphics: boolean): (typeof EXTRAS)[number][] {
50
50
  return graphics ? [...EXTRAS] : EXTRAS.filter((e) => e.value !== "graphicsClip");
51
51
  }
52
52
 
53
- export async function produceWizard(cfg: { speaker?: string } = {}): Promise<string[]> {
53
+ export async function produceWizard(cfg: { speaker?: string; input?: string } = {}): Promise<string[]> {
54
54
  assertInteractive("produce wizard");
55
55
  intro("ossclip produce");
56
56
 
57
- const input = unwrap(
58
- await text({
59
- // Finding 1 (final-review fix wave): `ossclip produce <folder>` shipped
60
- // (folder-input-brief.md) but this prompt still rejected a directory —
61
- // the wizard was the only way in that couldn't do what the CLI could.
62
- // A folder is concatenated by name (codepoint order, like `ls`); --sort
63
- // mtime reorders it but stays a typed flag, not a wizard question (see
64
- // the file-level comment above for why).
65
- message: "Video file, or a folder of clips to concatenate (by name; --sort mtime is a typed flag)",
66
- placeholder: "./raw/take1.mp4",
67
- validate: (v) => {
68
- if (!v) return "a path is required";
69
- if (!existsSync(v)) return `no such path: ${v}`;
70
- const st = statSync(v);
71
- if (!st.isFile() && !st.isDirectory()) return `${v} is neither a video file nor a folder`;
72
- return undefined;
73
- },
74
- }),
75
- ) as string;
57
+ // Pre-supplied by bare `ossclip <path>` (0.1.9 first-contact, 2026-08-05):
58
+ // the user already TYPED the input on the command line, and the old flow
59
+ // dropped it and asked again — the re-ask is where "./Anyhropic c Compiler"
60
+ // became "./" (all of ~/Downloads). The router checks existence before the
61
+ // wizard ever opens, so a prefilled path skips the prompt entirely.
62
+ const input =
63
+ cfg.input ??
64
+ (unwrap(
65
+ await text({
66
+ // Finding 1 (final-review fix wave): `ossclip produce <folder>` shipped
67
+ // (folder-input-brief.md) but this prompt still rejected a directory —
68
+ // the wizard was the only way in that couldn't do what the CLI could.
69
+ // A folder is concatenated by name (codepoint order, like `ls`); --sort
70
+ // mtime reorders it but stays a typed flag, not a wizard question (see
71
+ // the file-level comment above for why).
72
+ message: "Video file, or a folder of clips to concatenate (by name; --sort mtime is a typed flag)",
73
+ placeholder: "./raw/take1.mp4",
74
+ validate: (v) => {
75
+ if (!v) return "a path is required";
76
+ if (!existsSync(v)) return `no such path: ${v}`;
77
+ const st = statSync(v);
78
+ if (!st.isFile() && !st.isDirectory()) return `${v} is neither a video file nor a folder`;
79
+ return undefined;
80
+ },
81
+ }),
82
+ ) as string);
76
83
 
77
84
  const aspect = unwrap(
78
85
  await select({
package/src/produce.ts CHANGED
@@ -98,6 +98,7 @@ import {
98
98
  } from "@ossclip/core";
99
99
  import { recordRecentProject } from "./edit";
100
100
  import { editHint } from "./interactive/edit-hint";
101
+ import { recordedProduceArgs } from "./replay-argv";
101
102
  import { renderCover, renderProduction } from "@ossclip/renderer";
102
103
  import {
103
104
  coverTextRect,
@@ -2092,18 +2093,24 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2092
2093
  // provider (R16 §75). Pin the RESOLVED choice into the recorded args —
2093
2094
  // never the key itself; secrets stay out of the workdir — so a replay
2094
2095
  // uses the same configuration or fails loudly asking for it.
2095
- const recordedArgs = process.argv.slice(2);
2096
- if (provider && !recordedArgs.includes("--llm")) {
2097
- recordedArgs.push("--llm", providerName);
2098
- }
2099
2096
  // §93g: pin the RESOLVED window, exactly as §75 pinned the provider. The
2100
2097
  // editor's Render replays this argv; if replay re-asked the model and got a
2101
2098
  // slightly different window, every saved override — anchored to scene ids
2102
2099
  // and word indices — would land on the wrong words. The word range, not
2103
2100
  // just `--clip 60`, is what makes replay deterministic with zero LLM calls.
2104
- if (clipWindow && !recordedArgs.includes("--clip-window")) {
2105
- recordedArgs.push("--clip-window", `${clipWindow.startWord}:${clipWindow.endWord}`);
2106
- }
2101
+ //
2102
+ // §129: NOT process.argv. A wizard or bare-path run re-enters commander
2103
+ // with a BUILT argv while process.argv still holds the original invocation
2104
+ // (`ossclip <path>`, no `produce` literal, none of the wizard's answers) —
2105
+ // recording process.argv shipped a command that replays as
2106
+ // `ossclip <path> --llm …` and dies on "unknown option '--llm'".
2107
+ // recordedProduceArgs prefers the argv the re-entry stashed and falls back
2108
+ // to process.argv for a directly typed `ossclip produce …`, which stays
2109
+ // byte-identical to what was always recorded.
2110
+ const recordedArgs = recordedProduceArgs({
2111
+ llm: provider ? providerName : undefined,
2112
+ clipWindow: clipWindow ? `${clipWindow.startWord}:${clipWindow.endWord}` : undefined,
2113
+ });
2107
2114
  await writeFile(
2108
2115
  join(work, "command.json"),
2109
2116
  JSON.stringify(
package/src/program.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { readFileSync } from "node:fs";
2
+ import { existsSync, readFileSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { Command, InvalidArgumentError } from "commander";
5
5
  import { z } from "zod/v4";
@@ -7,6 +7,7 @@ import { CleanupLevelSchema, SceneComponentIdSchema } from "@ossclip/core";
7
7
  import { STUDIO_ENTRY } from "@ossclip/renderer";
8
8
  import { loadEnvFiles } from "./env";
9
9
  import { produce } from "./produce";
10
+ import { setReplayArgv } from "./replay-argv";
10
11
 
11
12
  // Before anything reads a provider key (R16 §77) — including the auto-detect
12
13
  // order in `defaultProviderName`, which decides which model runs.
@@ -51,31 +52,95 @@ export function buildProgram(): Command {
51
52
  // Bare `ossclip` at a TTY opens the menu. Piped or in CI it prints help,
52
53
  // byte for byte what it printed before — a front door must not become a
53
54
  // hang for a script.
54
- program.action(async () => {
55
- const { isInteractive } = await import("./interactive/tty");
56
- if (!isInteractive()) {
57
- program.outputHelp();
58
- return;
59
- }
60
- const { chooseFromMenu, menuArgv } = await import("./interactive/menu");
61
- const choice = await chooseFromMenu();
62
- const direct = menuArgv(choice);
63
- const { renderCommand } = await import("./interactive/render");
64
- if (direct !== null) {
65
- // Echoed for the same reason the wizard echoes: the README promises
66
- // "every choice prints the equivalent command before it runs, so the
67
- // menu is also how you learn the flags", and three of the four entries
68
- // printed nothing. The menu's whole pedagogical point is this line.
69
- console.log(`\n▸ running:\n ${renderCommand(direct)}\n`);
70
- await program.parseAsync(["node", "ossclip", ...direct]);
71
- return;
72
- }
73
- const { produceWizard } = await import("./interactive/produce-wizard");
74
- const { loadConfig } = await import("@ossclip/core");
75
- const argv = await produceWizard({ speaker: loadConfig().speaker });
76
- console.log(`\n▸ running:\n ${renderCommand(argv)}\n`);
77
- await program.parseAsync(["node", "ossclip", ...argv]);
78
- });
55
+ //
56
+ // Bare `ossclip <path>` ROUTES (0.1.9 first-contact, 2026-08-05): with no
57
+ // argument declared here, commander's default allow-excess-args silently
58
+ // DROPPED the positional — `ossclip "./Anyhropic c Compiler"` opened the
59
+ // menu as if nothing had been typed, and the user re-answered the wizard's
60
+ // input prompt by hand, wrongly, with all of ~/Downloads. Commander
61
+ // dispatches registered subcommand names before this action ever runs, so
62
+ // `produce`/`edit`/… always win over path interpretation (a folder that
63
+ // happens to be NAMED `doctor` goes to the subcommand — acceptable);
64
+ // anything that lands here is a path or a typo, and a typo must be a loud
65
+ // error naming what was tried, never the menu.
66
+ program
67
+ .argument(
68
+ "[path]",
69
+ "an existing video file or clips folder — shorthand for `ossclip produce <path>`, " +
70
+ "with the wizard asking the remaining questions",
71
+ )
72
+ // Commander 12 allows excess args by default, which is the SECOND half of
73
+ // the field failure (review, Important): the report's path was typed
74
+ // UNQUOTED — `ossclip ./Anyhropic c Compiler` — and with excess args
75
+ // allowed, `c` and `Compiler` would vanish wordlessly the moment
76
+ // `./Anyhropic` happened to exist, producing the wrong scope. An unquoted
77
+ // multi-word path must be a loud error, never a partial run.
78
+ .allowExcessArguments(false)
79
+ .action(async (path: string | undefined) => {
80
+ const { isInteractive } = await import("./interactive/tty");
81
+ if (path !== undefined) {
82
+ if (!existsSync(path)) {
83
+ throw new Error(
84
+ `no such file or directory: ${path}\n` +
85
+ " bare `ossclip <path>` produces an existing video file or clips folder;\n" +
86
+ " run `ossclip --help` for the commands.",
87
+ );
88
+ }
89
+ const { renderCommand } = await import("./interactive/render");
90
+ // Both branches hand argv back through `program.parseAsync` — the same
91
+ // re-entry shape as the wizard paths below, for the same reason: the
92
+ // zod checks in the produce action must run on this input exactly as
93
+ // they do on a typed command line.
94
+ if (!isInteractive()) {
95
+ // No TTY means no wizard for the remaining questions, but the input
96
+ // IS supplied — a piped `ossclip <path>` is `ossclip produce <path>`.
97
+ const direct = ["produce", path];
98
+ console.log(`\n▸ running:\n ${renderCommand(direct)}\n`);
99
+ // §129: THIS argv — not process.argv, which still says `ossclip
100
+ // <path>` with no `produce` literal — is the invocation
101
+ // command.json must record for the editor's Render to replay.
102
+ // Every parseAsync re-entry below stashes for the same reason.
103
+ setReplayArgv(direct);
104
+ await program.parseAsync(["node", "ossclip", ...direct]);
105
+ return;
106
+ }
107
+ const { produceWizard } = await import("./interactive/produce-wizard");
108
+ const { loadConfig } = await import("@ossclip/core");
109
+ const argv = await produceWizard({ speaker: loadConfig().speaker, input: path });
110
+ console.log(`\n▸ running:\n ${renderCommand(argv)}\n`);
111
+ setReplayArgv(argv); // §129
112
+ await program.parseAsync(["node", "ossclip", ...argv]);
113
+ return;
114
+ }
115
+ if (!isInteractive()) {
116
+ program.outputHelp();
117
+ return;
118
+ }
119
+ const { chooseFromMenu, menuArgv } = await import("./interactive/menu");
120
+ const choice = await chooseFromMenu();
121
+ const direct = menuArgv(choice);
122
+ const { renderCommand } = await import("./interactive/render");
123
+ if (direct !== null) {
124
+ // Echoed for the same reason the wizard echoes: the README promises
125
+ // "every choice prints the equivalent command before it runs, so the
126
+ // menu is also how you learn the flags", and three of the four entries
127
+ // printed nothing. The menu's whole pedagogical point is this line.
128
+ console.log(`\n▸ running:\n ${renderCommand(direct)}\n`);
129
+ // §129: stashed even for non-produce choices — the invariant is that
130
+ // the stash always mirrors the parse being entered, and only
131
+ // produce's recording ever reads it (consume-on-read keeps a
132
+ // non-produce stash from leaking past this parse).
133
+ setReplayArgv(direct);
134
+ await program.parseAsync(["node", "ossclip", ...direct]);
135
+ return;
136
+ }
137
+ const { produceWizard } = await import("./interactive/produce-wizard");
138
+ const { loadConfig } = await import("@ossclip/core");
139
+ const argv = await produceWizard({ speaker: loadConfig().speaker });
140
+ console.log(`\n▸ running:\n ${renderCommand(argv)}\n`);
141
+ setReplayArgv(argv); // §129
142
+ await program.parseAsync(["node", "ossclip", ...argv]);
143
+ });
79
144
 
80
145
  program
81
146
  .command("produce")
@@ -211,6 +276,7 @@ export function buildProgram(): Command {
211
276
  console.log(`\n▸ running:\n ${renderCommand(argv)}\n`);
212
277
  // Re-entering the SAME parse the flags take: the zod checks below run
213
278
  // on wizard output exactly as they do on a typed command line.
279
+ setReplayArgv(argv); // §129
214
280
  await program.parseAsync(["node", "ossclip", ...argv]);
215
281
  return;
216
282
  }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * What command.json must record: the argv of the parse that ACTUALLY ran
3
+ * (§129).
4
+ *
5
+ * `produce` records its invocation into the workdir so the editor's Render
6
+ * button can replay it byte for byte. It used to record `process.argv`,
7
+ * which is the truth for exactly one entry point — a directly typed
8
+ * `ossclip produce …`. The wizard has always BUILT a produce argv and
9
+ * re-entered `program.parseAsync(["node", "ossclip", ...argv])`, and the
10
+ * bare-path route does the same; in both, process.argv still holds the
11
+ * ORIGINAL invocation — no `produce` literal, none of the wizard's answers —
12
+ * so every re-entered run recorded a command that replays as
13
+ * `ossclip <path> --llm …` and dies at commander's front door with
14
+ * "error: unknown option '--llm'" (§129's field artifact). The re-entry
15
+ * sites in program.ts stash the argv they are about to parse here; the
16
+ * recording prefers the stash and falls back to process.argv, which keeps
17
+ * the direct path byte-identical to what it always wrote.
18
+ */
19
+
20
+ let stashed: string[] | null = null;
21
+
22
+ /**
23
+ * Called by every parseAsync re-entry in program.ts, immediately before the
24
+ * parse, with the argv minus its ["node", "ossclip"] prefix. Copied so a
25
+ * caller reusing its array cannot retroactively edit the record.
26
+ */
27
+ export function setReplayArgv(argv: string[]): void {
28
+ stashed = [...argv];
29
+ }
30
+
31
+ /**
32
+ * Consume-on-read (§129): commander 12 keeps option state across parseAsync
33
+ * calls (see the bare-`produce` refusal in program.ts), and a stash kept
34
+ * across parses would be the same trap one layer up — a menu choice that
35
+ * never reaches `produce` must not leave its argv behind for a later
36
+ * recording in the same process to mistake for its own.
37
+ */
38
+ export function consumeReplayArgv(): string[] | null {
39
+ const argv = stashed;
40
+ stashed = null;
41
+ return argv;
42
+ }
43
+
44
+ /**
45
+ * The args `produce` writes into command.json: the argv of the parse that
46
+ * ran (stash for a re-entered wizard/bare-path run, process.argv for a
47
+ * directly typed one), plus the §75/§93g pins. The pins guard on
48
+ * `includes` so a flag the user actually typed — or a pin recorded by the
49
+ * run a replay is re-running — is never appended twice.
50
+ */
51
+ export function recordedProduceArgs(pins: { llm?: string; clipWindow?: string }): string[] {
52
+ const args = consumeReplayArgv() ?? process.argv.slice(2);
53
+ if (pins.llm !== undefined && !args.includes("--llm")) {
54
+ args.push("--llm", pins.llm);
55
+ }
56
+ if (pins.clipWindow !== undefined && !args.includes("--clip-window")) {
57
+ args.push("--clip-window", pins.clipWindow);
58
+ }
59
+ return args;
60
+ }