@hushscript/cli 0.0.0-stage → 1.0.1

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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +199 -2
  3. package/dist/index.js +1028 -0
  4. package/package.json +52 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hushscript
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,200 @@
1
- # Temporary Holding Version
1
+ # Hushscript CLI
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The official command line client for [Hushscript](https://hushscript.com), a privacy-first,
4
+ pay-as-you-go transcription service. It is built on the official TypeScript SDK, `@hushscript/sdk`,
5
+ and needs Node 22 or newer.
6
+
7
+ ## Why Hushscript
8
+
9
+ - **No subscription.** You buy prepaid minute packs, and nothing recurs. Current prices are on the
10
+ [pricing page](https://hushscript.com/pricing).
11
+ - **The audio you send isn't kept.** The audio sent for transcription isn't kept after processing. Stored
12
+ transcripts are encrypted at rest, and you choose how long they stay.
13
+ - **Free speaker labels and AI insights.** Speaker labels carry no extra charge (translation costs
14
+ more). The first all-in AI insights run on a transcript (summaries, action items, quotes, chapters)
15
+ is free in the Hushscript app.
16
+ - **Around 99 languages.** Pick one with `--language` or let Hushscript detect it, and translate
17
+ the result.
18
+ - **A tool you can read.** It is MIT licensed, official, and keeps your token in a file only you can
19
+ read (mode `0600`).
20
+
21
+ ## What you can do with it
22
+
23
+ - Transcribe a recording from the terminal and print the transcript as `txt`, `srt`, `vtt` or
24
+ `json`, to stdout or to a file.
25
+ - Translate in the same run (`--translate es,de`) and print a translation or the AI-cleaned
26
+ body of any finished transcript.
27
+ - Pick up an interrupted run with `resume`, using the token the CLI prints, without paying twice.
28
+ - Check your balance, list transcripts and jobs, and see which minute packs you can buy.
29
+ - Buy a pack with your saved card, with the no-refund notice shown and a confirmation first.
30
+ - Script it: `--json` output, JSON Lines errors on stderr and fixed exit codes.
31
+
32
+ [Website](https://hushscript.com) · [Pricing](https://hushscript.com/pricing) · [SDK docs](https://hushscript.com/sdks) · [API reference](https://hushscript.com/developers)
33
+
34
+ ## Quick start
35
+
36
+ ```sh
37
+ npm install --global @hushscript/cli
38
+ hushscript login
39
+ hushscript transcribe meeting.mp3 --format srt --out meeting.srt
40
+ ```
41
+
42
+ ## Token
43
+
44
+ Create a token in the Hushscript app under Account, Connected apps. The CLI looks for it in this
45
+ order:
46
+
47
+ 1. `--token <token>`
48
+ 2. the `HUSHSCRIPT_TOKEN` environment variable
49
+ 3. the token stored by `hushscript login`
50
+
51
+ `hushscript login` asks for the token without echoing it (or reads one line from stdin when it is
52
+ piped) and keeps it in `$XDG_CONFIG_HOME/hushscript/config.json`, which is `~/.config/hushscript/config.json`
53
+ when `XDG_CONFIG_HOME` is not set and `%APPDATA%\hushscript\config.json` on Windows. The file is
54
+ created with mode `0600` in a `0700` folder. `hushscript logout` deletes it. Prefer the environment
55
+ variable in CI: a token on the command line is visible to other processes on the machine, and
56
+ `login --token` prints a warning for that reason; run plain `login` to type the token or pipe it on
57
+ stdin.
58
+
59
+ - If the stored file is readable by group or others, every command that reads it warns on stderr and
60
+ suggests `chmod 600`; the token is still used.
61
+ - If the config folder is a symbolic link, `login` refuses with exit code 1 instead of changing the
62
+ mode of whatever the link points at. Replace the link with a real folder, or point
63
+ `XDG_CONFIG_HOME` at one.
64
+ - A source that is empty or only whitespace counts as not set: `--token ""`, an empty
65
+ `HUSHSCRIPT_TOKEN`, an empty `token` in the stored file, and likewise `HUSHSCRIPT_BASE_URL`,
66
+ `XDG_CONFIG_HOME` and `APPDATA`. The CLI moves on to the next source; with none left it reports
67
+ "No Hushscript token." (exit code 1). `login --token ""` prompts as if the flag was absent, and
68
+ `logout` ignores `--token`.
69
+
70
+ ## Commands
71
+
72
+ | Command | What it does |
73
+ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
74
+ | `login` | Prompts for a token and stores it. |
75
+ | `logout` | Deletes the stored token. |
76
+ | `balance` | Shows the minutes you can spend. |
77
+ | `transcribe <file>` | Uploads a recording, waits, and prints the transcript. Flags: `--language`, `--translate es,de`, `--format srt\|vtt\|txt\|json`, `--out path`, `--key`. |
78
+ | `resume <token> <file>` | Finishes a transcription that `transcribe` left behind (see Interrupting). Flags: `--format`, `--out`. |
79
+ | `transcripts` | Lists finished transcripts, newest first. Flag: `--limit` (default 20). |
80
+ | `transcript <id>` | Prints one transcript. Flags: `--format`, `--lang` (a translation, or `cleaned`). |
81
+ | `jobs` | Lists jobs. Flag: `--active` for the ones that have not finished. |
82
+ | `packs` | Lists the minute packs you can buy, with prices. |
83
+ | `buy <pack>` | Buys a pack with your saved default card. Flags: `--yes`, `--key`. |
84
+
85
+ Global flags: `--json` and `--token`. The default format is `txt`. `--out` writes through a temporary
86
+ file and a rename, so the target is never half written. The temporary file is created with the mode
87
+ the target will have (an existing target keeps its mode, a new file gets `0644` minus the umask). A
88
+ symbolic link is followed, also a dangling one, and written through to the file it points at; before
89
+ anything is sent, `--out` is checked at the place the link leads to, so a link into a folder that
90
+ does not exist is a usage error (exit code 2) and no job is created.
91
+
92
+ ### Buying minutes
93
+
94
+ `buy` shows the no-refund notice on stderr and asks you to confirm; a missing token is reported
95
+ before it asks. With `--yes` the notice is still printed, before the purchase, also under `--json`.
96
+ Without a terminal (stdin and stderr both) and without `--yes` it exits with code 2 and sends nothing:
97
+
98
+ ```sh
99
+ hushscript buy 300min --yes
100
+ ```
101
+
102
+ A stop signal (SIGINT, SIGTERM or SIGHUP) while `buy` waits for your answer prints nothing and exits
103
+ with 130: the no-refund notice and the question are already on stderr, nothing was sent and nothing
104
+ was charged.
105
+
106
+ Every purchase has an idempotency key: the one from `--key`, or a fresh one the CLI makes. If a
107
+ purchase fails after it was sent, or the CLI is stopped by SIGINT, SIGTERM or SIGHUP once the
108
+ purchase is confirmed (or with `--yes`; exit code 130, also while the card is still being looked up),
109
+ it prints the key and says "The purchase may have been sent. Retry with --key <key>: the same key never
110
+ charges twice." A failure before any request was sent leaves the notice out, and so does a refusal that
111
+ proves nothing was charged (a 400, 402, 404 or 422 answer, or a 403 with numeric code 4021 or 4023); a 401 or 429 does not prove it, because the
112
+ server answers those before it replays a purchase that already went through. Retry with the same key and
113
+ it cannot be charged twice:
114
+
115
+ ```sh
116
+ hushscript buy 300min --yes --key <key>
117
+ ```
118
+
119
+ ### Interrupting
120
+
121
+ Ctrl-C (SIGINT), SIGTERM or SIGHUP during `transcribe` or `resume` stops the call, prints the resume
122
+ token of the job and exits with 130. A second signal exits at once, still printing the token (and the
123
+ idempotency key `transcribe` started with). A stop signal at any other moment, when nothing was sent
124
+ that a token could be about, ends the CLI with 130 and prints nothing. The message names the command
125
+ that continues the job:
126
+
127
+ ```sh
128
+ hushscript resume <token> meeting.mp3 --format srt --out meeting.srt
129
+ ```
130
+
131
+ When the run stopped before the API handed out a token, the message carries the idempotency key
132
+ instead and names the command that replays the same `transcribe` call, with the `--language`, `--translate`,
133
+ `--format` and `--out` you passed, so the replay asks for the same job; the same key never creates the
134
+ job twice:
135
+
136
+ ```sh
137
+ hushscript transcribe --key <key> meeting.mp3 --language en --format srt --out meeting.srt
138
+ ```
139
+
140
+ The resume command carries the `--format` and `--out` you passed (the flags you did not pass are left
141
+ out). `resume` takes the same recording and the same `--format`, `--out` and `--json` as `transcribe`; if
142
+ the job has finished meanwhile it just fetches the transcript.
143
+
144
+ ## Scripting
145
+
146
+ `--json` prints one JSON document on stdout and nothing else. `--help --json` prints `{"help": "..."}`
147
+ and `--version --json` prints `{"version": "..."}`.
148
+
149
+ Error and notice text on stderr is scrubbed of every token the CLI knows, as given and trimmed, whatever
150
+ its length or shape. Results on stdout are not scrubbed: they are your output, and a result that holds a
151
+ token (a newly issued one, for example) prints it.
152
+
153
+ Under `--json` stderr is JSON Lines: every line is one JSON object with a `type`. Notices and warnings
154
+ come first, as they happen, and the error object, if the command failed, comes last:
155
+
156
+ ```json
157
+ {"type":"notice","code":"no_refund","message":"..."}
158
+ {"type":"warning","code":"token_file_shared","message":"..."}
159
+ {
160
+ "type": "error",
161
+ "code": "insufficient_balance",
162
+ "numericCode": 4020,
163
+ "status": 402,
164
+ "message": "...",
165
+ "hint": "...",
166
+ "idempotencyKey": "...",
167
+ "resumeToken": "..."
168
+ }
169
+ ```
170
+
171
+ (The error object is one line on stderr; it is wrapped here to read.) `idempotencyKey` and `resumeToken`
172
+ appear only when the error has one. The notice codes are `no_refund` (the `buy` notice) and the warning
173
+ codes are `token_file_shared` (a stored token file other users can read) and `token_on_command_line`
174
+ (`login --token`). Progress bars are drawn on stderr only when it is a terminal and `--json` is off.
175
+ The one text that can still reach stderr under `--json` is a prompt (`login` asking for the token,
176
+ `buy` asking to confirm): prompts are only shown when both stdin and stderr are terminals; otherwise pass the flags (`--yes`, `--token`) or pipe the token.
177
+
178
+ | Exit code | Meaning |
179
+ | --------- | ------------------------------------------------------------------------------------------------------------------ |
180
+ | 0 | Success. |
181
+ | 1 | The API or the SDK reported an error (also a missing token, or a declined purchase). |
182
+ | 2 | Usage error: unknown command or flag, a bad value, `buy` without a terminal and without `--yes`. Nothing was sent. |
183
+ | 130 | Interrupted by SIGINT, SIGTERM or SIGHUP, at any moment of the run. |
184
+
185
+ ## Links
186
+
187
+ - Documentation: <https://hushscript.com/sdks>
188
+ - REST API reference: <https://hushscript.com/developers>
189
+ - Package on npm: <https://www.npmjs.com/package/@hushscript/cli>
190
+ - Source: <https://github.com/Hushscript/hushscript-cli>
191
+ - Other Hushscript SDKs: [TypeScript](https://github.com/Hushscript/hushscript-sdk-typescript), [Python](https://github.com/Hushscript/hushscript-sdk-python), [Go](https://github.com/Hushscript/hushscript-sdk-go), [PHP](https://github.com/Hushscript/hushscript-sdk-php)
192
+
193
+ ## Development
194
+
195
+ Everything runs in containers, through `just`: `just ci` runs the static checks, the tests and the
196
+ end-to-end run of the built binary against the conformance mock, on Node 22, 24, and 26.
197
+
198
+ ## License
199
+
200
+ MIT
package/dist/index.js ADDED
@@ -0,0 +1,1028 @@
1
+ #!/usr/bin/env node
2
+ import { homedir } from 'os';
3
+ import { HushscriptError, Hushscript } from '@hushscript/sdk';
4
+ import { createInterface } from 'readline';
5
+ import { Command, CommanderError, Option } from 'commander';
6
+ import { randomUUID, randomBytes } from 'crypto';
7
+ import { lstat, mkdir, chmod, open, rename, unlink, stat, readlink, readFile } from 'fs/promises';
8
+ import { dirname, join, resolve, basename } from 'path';
9
+
10
+ // src/errors.ts
11
+ var CliError = class extends Error {
12
+ code;
13
+ exitCode;
14
+ numericCode;
15
+ status;
16
+ hint;
17
+ idempotencyKey;
18
+ resumeToken;
19
+ constructor(init) {
20
+ super(init.message);
21
+ this.name = "CliError";
22
+ this.code = init.code;
23
+ this.exitCode = init.exitCode;
24
+ this.numericCode = init.numericCode ?? null;
25
+ this.status = init.status ?? null;
26
+ this.hint = init.hint ?? null;
27
+ this.idempotencyKey = init.idempotencyKey ?? null;
28
+ this.resumeToken = init.resumeToken ?? null;
29
+ }
30
+ };
31
+ function usageError(message, hint) {
32
+ return new CliError({ code: "usage_error", message, exitCode: 2, hint });
33
+ }
34
+ function interrupted(resumeToken, idempotencyKey = null, hint = null) {
35
+ return new CliError({
36
+ code: "interrupted",
37
+ message: "Interrupted.",
38
+ exitCode: 130,
39
+ resumeToken,
40
+ idempotencyKey,
41
+ hint
42
+ });
43
+ }
44
+
45
+ // src/prompt.ts
46
+ var CTRL_C = "";
47
+ var CTRL_D = "";
48
+ var BACKSPACE = /* @__PURE__ */ new Set(["\x7F", "\b"]);
49
+ async function readAll(input) {
50
+ let text = "";
51
+ input.setEncoding("utf8");
52
+ for await (const chunk of input) text += String(chunk);
53
+ return text;
54
+ }
55
+ async function readSecret(input, output, prompt) {
56
+ if (input.isTTY !== true || input.setRawMode === void 0) {
57
+ const text = await readAll(input);
58
+ return text.split(/\r?\n/, 1)[0] ?? "";
59
+ }
60
+ const setRawMode = input.setRawMode.bind(input);
61
+ output.write(prompt);
62
+ input.setEncoding("utf8");
63
+ setRawMode(true);
64
+ input.resume();
65
+ return await new Promise((resolve2, reject) => {
66
+ let typed = "";
67
+ const finish = (settle) => {
68
+ input.off("data", onData);
69
+ input.off("end", onEnd);
70
+ input.off("close", onEnd);
71
+ input.off("error", onError);
72
+ try {
73
+ setRawMode(false);
74
+ } catch {
75
+ }
76
+ input.pause();
77
+ output.write("\n");
78
+ settle();
79
+ };
80
+ const onEnd = () => {
81
+ finish(() => {
82
+ resolve2(typed);
83
+ });
84
+ };
85
+ const onError = (error) => {
86
+ finish(() => {
87
+ reject(
88
+ new CliError({
89
+ code: "input_failed",
90
+ message: `Reading from the terminal failed (${error.message}).`,
91
+ exitCode: 1
92
+ })
93
+ );
94
+ });
95
+ };
96
+ const onData = (chunk) => {
97
+ for (const char of String(chunk)) {
98
+ if (char === "\r" || char === "\n" || char === CTRL_D) {
99
+ finish(() => {
100
+ resolve2(typed);
101
+ });
102
+ return;
103
+ }
104
+ if (char === CTRL_C) {
105
+ finish(() => {
106
+ reject(interrupted(null));
107
+ });
108
+ return;
109
+ }
110
+ if (BACKSPACE.has(char)) typed = typed.slice(0, -1);
111
+ else if (char >= " ") typed += char;
112
+ }
113
+ };
114
+ input.on("data", onData);
115
+ input.once("end", onEnd);
116
+ input.once("close", onEnd);
117
+ input.once("error", onError);
118
+ });
119
+ }
120
+ async function confirm(input, output, prompt) {
121
+ if (input.destroyed || input.readableEnded) return false;
122
+ const lines = createInterface({ input, output, terminal: false });
123
+ return await new Promise((resolve2) => {
124
+ let answer = false;
125
+ const onInputClose = () => {
126
+ lines.close();
127
+ };
128
+ lines.once("close", () => {
129
+ input.off("close", onInputClose);
130
+ resolve2(answer);
131
+ });
132
+ input.once("close", onInputClose);
133
+ lines.once("error", onInputClose);
134
+ output.write(prompt);
135
+ lines.once("line", (line) => {
136
+ answer = /^\s*y(?:es)?\s*$/i.test(line);
137
+ lines.close();
138
+ });
139
+ });
140
+ }
141
+
142
+ // package.json
143
+ var package_default = {
144
+ version: "1.0.1"};
145
+
146
+ // src/shell.ts
147
+ var SAFE = /^[\w@%+=:,./-]+$/;
148
+ function shellWord(text) {
149
+ return SAFE.test(text) ? text : `'${text.replaceAll("'", `'\\''`)}'`;
150
+ }
151
+
152
+ // src/store.ts
153
+ function configPath({ env, platform, homedir: homedir2 }) {
154
+ if (platform === "win32") {
155
+ const base2 = valueOf(env["APPDATA"]) ?? join(homedir2, "AppData", "Roaming");
156
+ return join(base2, "hushscript", "config.json");
157
+ }
158
+ const base = valueOf(env["XDG_CONFIG_HOME"]) ?? join(homedir2, ".config");
159
+ return join(base, "hushscript", "config.json");
160
+ }
161
+ function valueOf(value) {
162
+ return value === void 0 || value === null || value.trim() === "" ? void 0 : value;
163
+ }
164
+ function isMissing(error) {
165
+ return error.code === "ENOENT";
166
+ }
167
+ function storeError(path, problem) {
168
+ return new CliError({
169
+ code: "config_invalid",
170
+ message: `The config file at ${path} ${problem}.`,
171
+ exitCode: 1,
172
+ hint: "Run `hushscript login` to write it again, or delete the file."
173
+ });
174
+ }
175
+ async function warnIfShared(path, { platform, warn }) {
176
+ if (platform === "win32") return;
177
+ const info = await stat(path).catch(() => null);
178
+ if (info === null || (info.mode & 63) === 0) return;
179
+ const mode = (info.mode & 511).toString(8).padStart(4, "0");
180
+ warn(`${path} can be read by other users (mode ${mode}). Run: chmod 600 ${shellWord(path)}`);
181
+ }
182
+ async function readStoredToken(path, options) {
183
+ let raw;
184
+ try {
185
+ raw = await readFile(path, "utf8");
186
+ } catch (error) {
187
+ if (isMissing(error)) return null;
188
+ throw storeError(path, "could not be read");
189
+ }
190
+ let parsed;
191
+ try {
192
+ parsed = JSON.parse(raw);
193
+ } catch {
194
+ throw storeError(path, "is not valid JSON");
195
+ }
196
+ const token = parsed?.token;
197
+ if (typeof token !== "string") throw storeError(path, "holds no token");
198
+ if (valueOf(token) === void 0) return null;
199
+ if (options !== void 0) await warnIfShared(path, options);
200
+ return token;
201
+ }
202
+ async function saveToken(path, token, platform) {
203
+ const dir = dirname(path);
204
+ if ((await lstat(dir).catch(() => null))?.isSymbolicLink() === true) {
205
+ throw new CliError({
206
+ code: "config_invalid",
207
+ message: `The config folder ${dir} is a symbolic link, so the token was not stored.`,
208
+ exitCode: 1,
209
+ hint: "Replace the link with a real folder, or point XDG_CONFIG_HOME at one."
210
+ });
211
+ }
212
+ await mkdir(dir, { recursive: true, mode: 448 });
213
+ const posix = platform !== "win32";
214
+ if (posix) await chmod(dir, 448);
215
+ const temp = join(dir, `.config.${String(process.pid)}.${randomBytes(6).toString("hex")}.tmp`);
216
+ try {
217
+ const handle = await open(temp, "wx", 384);
218
+ try {
219
+ await handle.writeFile(`${JSON.stringify({ token })}
220
+ `);
221
+ await handle.sync();
222
+ } finally {
223
+ await handle.close();
224
+ }
225
+ if (posix) await chmod(temp, 384);
226
+ await rename(temp, path);
227
+ } catch (error) {
228
+ await unlink(temp).catch(() => void 0);
229
+ throw error;
230
+ }
231
+ }
232
+ async function removeToken(path) {
233
+ try {
234
+ await unlink(path);
235
+ return true;
236
+ } catch (error) {
237
+ if (isMissing(error)) return false;
238
+ throw error;
239
+ }
240
+ }
241
+ async function resolveToken(sources) {
242
+ const fromFlag = valueOf(sources.flag);
243
+ if (fromFlag !== void 0) return fromFlag;
244
+ const fromEnv = valueOf(sources.env["HUSHSCRIPT_TOKEN"]);
245
+ if (fromEnv !== void 0) return fromEnv;
246
+ return await sources.stored();
247
+ }
248
+
249
+ // src/context.ts
250
+ function registerSecret(secrets, token) {
251
+ if (token.trim() === "") return;
252
+ for (const form of [token, token.trim()]) {
253
+ if (!secrets.includes(form)) secrets.push(form);
254
+ }
255
+ }
256
+ function storePath(deps2) {
257
+ return configPath(deps2);
258
+ }
259
+ async function openClient(rt) {
260
+ const { deps: deps2 } = rt;
261
+ const token = await resolveToken({
262
+ flag: rt.globals().token,
263
+ env: deps2.env,
264
+ stored: () => readStoredToken(storePath(deps2), {
265
+ platform: deps2.platform,
266
+ warn: (text) => {
267
+ rt.out.warning("token_file_shared", text);
268
+ }
269
+ })
270
+ });
271
+ if (token === null) {
272
+ throw new CliError({
273
+ code: "config_invalid",
274
+ message: "No Hushscript token.",
275
+ exitCode: 1,
276
+ hint: "Run `hushscript login`, set HUSHSCRIPT_TOKEN, or pass --token. Create a token in the Hushscript app under Account, Connected apps."
277
+ });
278
+ }
279
+ registerSecret(rt.secrets, token);
280
+ return deps2.createClient({ token, baseUrl: valueOf(deps2.env["HUSHSCRIPT_BASE_URL"]) });
281
+ }
282
+ var TOKEN_SHAPE = /\bhsr\w*:[\w:.-]+/g;
283
+ function scrub(text, secrets) {
284
+ let out = text;
285
+ for (const secret of secrets) {
286
+ if (secret.trim() !== "") out = out.replaceAll(secret, "[redacted]");
287
+ }
288
+ return out.replace(TOKEN_SHAPE, "[redacted]");
289
+ }
290
+ function describeError(error, secrets) {
291
+ const clean = (text) => scrub(text, secrets);
292
+ const cleanOrNull = (text) => text === null ? null : clean(text);
293
+ const base = (source) => {
294
+ const report = {
295
+ code: source.code,
296
+ numericCode: source.numericCode,
297
+ status: source.status,
298
+ message: clean(source.message),
299
+ hint: cleanOrNull(source.hint)
300
+ };
301
+ if (source.idempotencyKey !== null) report.idempotencyKey = source.idempotencyKey;
302
+ if (source.resumeToken !== null) report.resumeToken = source.resumeToken;
303
+ return report;
304
+ };
305
+ if (error instanceof CliError || error instanceof HushscriptError) return base(error);
306
+ const message = error instanceof Error ? error.message : "";
307
+ return base({
308
+ code: "unexpected_error",
309
+ numericCode: null,
310
+ status: null,
311
+ message: message === "" ? "The CLI failed unexpectedly." : `Unexpected error: ${message}`,
312
+ hint: null,
313
+ idempotencyKey: null,
314
+ resumeToken: null
315
+ });
316
+ }
317
+ function exitCodeOf(error) {
318
+ return error instanceof CliError ? error.exitCode : 1;
319
+ }
320
+ function endWithNewline(text) {
321
+ return text.endsWith("\n") ? text : `${text}
322
+ `;
323
+ }
324
+ var Output = class {
325
+ json;
326
+ #io;
327
+ #secrets;
328
+ constructor(io2, json, secrets) {
329
+ this.#io = io2;
330
+ this.json = json;
331
+ this.#secrets = secrets;
332
+ }
333
+ /** The command's result: `value` as JSON under --json, else `human`. */
334
+ document(value, human) {
335
+ this.#io.stdout(this.json ? `${JSON.stringify(value, null, 2)}
336
+ ` : endWithNewline(human));
337
+ }
338
+ /** Content that is already formatted (a transcript): written byte for byte, plus a final newline on a terminal so the prompt starts on its own line. */
339
+ content(text) {
340
+ this.#io.stdout(this.#io.stdoutIsTTY ? endWithNewline(text) : text);
341
+ }
342
+ /** Something worth knowing before the command goes on: a line of text, or under --json a `notice` object. */
343
+ notice(code2, message) {
344
+ this.#line("notice", code2, message, message);
345
+ }
346
+ /** A problem that does not stop the command: `Warning: ...`, or under --json a `warning` object. */
347
+ warning(code2, message) {
348
+ this.#line("warning", code2, message, `Warning: ${message}`);
349
+ }
350
+ #line(type, code2, message, human) {
351
+ const clean = scrub(message, this.#secrets);
352
+ if (this.json) this.#io.stderr(`${JSON.stringify({ type, code: code2, message: clean })}
353
+ `);
354
+ else this.#io.stderr(endWithNewline(scrub(human, this.#secrets)));
355
+ }
356
+ /** A line for a person on stderr; silent under --json. */
357
+ note(message) {
358
+ if (!this.json) this.#io.stderr(endWithNewline(message));
359
+ }
360
+ /** Prints the error and returns the exit code. */
361
+ fail(error) {
362
+ const report = describeError(error, this.#secrets);
363
+ if (this.json) {
364
+ this.#io.stderr(`${JSON.stringify({ type: "error", ...report })}
365
+ `);
366
+ } else {
367
+ const lines = [`Error: ${report.message}`];
368
+ if (report.hint !== null) lines.push(`Hint: ${report.hint}`);
369
+ if (report.idempotencyKey !== void 0)
370
+ lines.push(`Idempotency key: ${report.idempotencyKey}`);
371
+ if (report.resumeToken !== void 0) lines.push(`Resume token: ${report.resumeToken}`);
372
+ this.#io.stderr(`${lines.join("\n")}
373
+ `);
374
+ }
375
+ return exitCodeOf(error);
376
+ }
377
+ };
378
+ function renderTranscript(transcript, format, language) {
379
+ switch (format) {
380
+ case "srt":
381
+ return transcript.toSRT({ language });
382
+ case "vtt":
383
+ return transcript.toVTT({ language });
384
+ case "txt":
385
+ return transcript.toText({ language });
386
+ case "json":
387
+ return `${JSON.stringify(transcript.toJSON(), null, 2)}
388
+ `;
389
+ }
390
+ }
391
+ function formatDuration(seconds) {
392
+ const whole = Math.max(0, Math.round(seconds));
393
+ const hours = Math.floor(whole / 3600);
394
+ const minutes = Math.floor(whole % 3600 / 60);
395
+ const rest = whole % 60;
396
+ const pad = (value) => String(value).padStart(2, "0");
397
+ return hours > 0 ? `${String(hours)}:${pad(minutes)}:${pad(rest)}` : `${String(minutes)}:${pad(rest)}`;
398
+ }
399
+ function formatDate(date) {
400
+ return date.toISOString();
401
+ }
402
+
403
+ // src/commands/balance.ts
404
+ function registerBalance(program, rt) {
405
+ program.command("balance").description("Show the minutes you can spend").action(async () => {
406
+ const api = await openClient(rt);
407
+ const balance = await api.balance();
408
+ const lines = [`Balance: ${String(balance.minutes)} min (${String(balance.seconds)} s)`];
409
+ if (balance.heldSeconds > 0) {
410
+ lines.push(`Held for running jobs: ${String(Math.trunc(balance.heldSeconds / 60))} min`);
411
+ }
412
+ if (balance.expiresAt !== null) lines.push(`Expires: ${formatDate(balance.expiresAt)}`);
413
+ rt.out.document(balance, lines.join("\n"));
414
+ });
415
+ }
416
+
417
+ // src/validate.ts
418
+ var FORMATS = ["srt", "vtt", "txt", "json"];
419
+ var LOCALE = /^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/i;
420
+ var LANG_FLAG = /^[a-z][\w-]{0,31}$/i;
421
+ var ID = /^[\w.-]{1,128}$/;
422
+ var PACK = /^\d{1,6}min$/;
423
+ var KEY = /^[\w-]{1,128}$/;
424
+ var LIMIT = /^[1-9]\d{0,5}$/;
425
+ function parseLanguage(value) {
426
+ if (!LOCALE.test(value)) {
427
+ throw usageError(`--language must be a language code such as en or pt-BR, not "${value}".`);
428
+ }
429
+ return value;
430
+ }
431
+ function parseTranslate(value) {
432
+ const codes = value.split(",").map((code2) => code2.trim());
433
+ for (const code2 of codes) {
434
+ if (!LOCALE.test(code2)) {
435
+ throw usageError(
436
+ `--translate takes language codes separated by commas, such as es,de; "${code2}" is not one.`
437
+ );
438
+ }
439
+ }
440
+ return [...new Set(codes)];
441
+ }
442
+ function parseLang(value) {
443
+ if (!LANG_FLAG.test(value)) {
444
+ throw usageError(`--lang must be a translation language code or "cleaned", not "${value}".`);
445
+ }
446
+ return value;
447
+ }
448
+ function parseLimit(value) {
449
+ if (!LIMIT.test(value)) throw usageError("--limit must be a whole number from 1 to 999999.");
450
+ return Number(value);
451
+ }
452
+ function parseId(value, name) {
453
+ if (!ID.test(value) || value === "." || value === "..") {
454
+ throw usageError(`${name} must be 1 to 128 letters, digits, dots, dashes or underscores.`);
455
+ }
456
+ return value;
457
+ }
458
+ function parsePack(value) {
459
+ if (!PACK.test(value)) {
460
+ throw usageError(`The pack must look like 300min; run \`hushscript packs\` to list them.`);
461
+ }
462
+ return value;
463
+ }
464
+ function parseKey(value) {
465
+ if (!KEY.test(value)) {
466
+ throw usageError("--key must be 1 to 128 characters from A-Z, a-z, 0-9, _ and -.");
467
+ }
468
+ return value;
469
+ }
470
+ function resolveFormat(json, format) {
471
+ if (!json) return format ?? "txt";
472
+ if (format !== void 0 && format !== "json") {
473
+ throw usageError("--json prints the transcript as JSON; drop --format or use --format json.");
474
+ }
475
+ return "json";
476
+ }
477
+
478
+ // src/commands/buy.ts
479
+ var NO_REFUND_NOTICE = "Minutes are added to your account instantly after payment. All purchases are final and non-refundable.";
480
+ var NEVER_CHARGED = /* @__PURE__ */ new Set([400, 402, 404, 422]);
481
+ function chargeNotice(key) {
482
+ return `The purchase may have been sent. Retry with --key ${key}: the same key never charges twice.`;
483
+ }
484
+ function withChargeNotice(error) {
485
+ if (!(error instanceof HushscriptError)) return error;
486
+ const key = error.idempotencyKey;
487
+ const neverCharged = error.status !== null && NEVER_CHARGED.has(error.status) || error.status === 403 && (error.numericCode === 4021 || error.numericCode === 4023);
488
+ if (key === null || neverCharged) return error;
489
+ const notice = chargeNotice(key);
490
+ return new CliError({
491
+ code: error.code,
492
+ numericCode: error.numericCode,
493
+ status: error.status,
494
+ message: error.message,
495
+ hint: error.hint === null ? notice : `${error.hint} ${notice}`,
496
+ exitCode: 1,
497
+ idempotencyKey: key
498
+ });
499
+ }
500
+ function registerBuy(program, rt) {
501
+ program.command("buy").description("Buy a minute pack with your saved default card").argument("<pack>", "pack id, for example 300min (see `hushscript packs`)").option("--yes", "confirm the purchase without asking").option(
502
+ "--key <key>",
503
+ "idempotency key; pass the one a failed purchase printed to retry it safely"
504
+ ).action(async (packArgument, options) => {
505
+ const { deps: deps2, out } = rt;
506
+ const { io: io2 } = deps2;
507
+ const pack = parsePack(packArgument);
508
+ const key = options.key === void 0 ? randomUUID() : parseKey(options.key);
509
+ const confirmed = options.yes === true;
510
+ if (!confirmed && !(io2.stdinIsTTY && io2.stderrIsTTY)) {
511
+ if (!out.json) out.notice("no_refund", NO_REFUND_NOTICE);
512
+ throw usageError(
513
+ "Buying needs your confirmation, and there is no terminal to ask on. Nothing was sent.",
514
+ "Pass --yes to confirm the purchase."
515
+ );
516
+ }
517
+ const api = await openClient(rt);
518
+ out.notice("no_refund", NO_REFUND_NOTICE);
519
+ if (!confirmed && !await io2.confirm(`Buy ${pack} with your saved default card? [y/N] `)) {
520
+ throw new CliError({
521
+ code: "cancelled",
522
+ message: "Cancelled. Nothing was charged.",
523
+ exitCode: 1
524
+ });
525
+ }
526
+ const stop = io2.onInterrupt(() => {
527
+ io2.forceExit(out.fail(interrupted(null, key, chargeNotice(key))));
528
+ });
529
+ let purchase;
530
+ try {
531
+ purchase = await api.buy(pack, { acknowledgeNoRefund: true, idempotencyKey: key });
532
+ } catch (error) {
533
+ throw withChargeNotice(error);
534
+ } finally {
535
+ stop();
536
+ }
537
+ out.document(purchase, `Bought ${pack}. Balance: ${String(purchase.minutes)} min.`);
538
+ });
539
+ }
540
+
541
+ // src/commands/jobs.ts
542
+ function registerJobs(program, rt) {
543
+ program.command("jobs").description("List jobs, newest first").option("--active", "only jobs that have not finished").action(async (options) => {
544
+ const api = await openClient(rt);
545
+ const jobs = [];
546
+ for await (const job of api.jobs.list({ active: options.active === true })) jobs.push(job);
547
+ const lines = jobs.map((job) => {
548
+ const detail = job.error === null ? "" : ` ${job.error}`;
549
+ return `${job.id} ${job.state} ${formatDate(job.createdAt)} ${job.transcriptId ?? "-"}${detail}`;
550
+ });
551
+ rt.out.document({ jobs }, lines.length === 0 ? "No jobs." : lines.join("\n"));
552
+ });
553
+ }
554
+ function registerLogin(program, rt) {
555
+ program.command("login").description("Store a token for later commands (prompts without echo; reads stdin when piped)").action(async () => {
556
+ const { deps: deps2, out } = rt;
557
+ const supplied = rt.globals().token;
558
+ if (supplied === void 0 && deps2.io.stdinIsTTY && !deps2.io.stderrIsTTY) {
559
+ throw usageError(
560
+ "There is no terminal to ask for the token on. Nothing was stored.",
561
+ "Pipe the token on stdin, or run login in a terminal."
562
+ );
563
+ }
564
+ const typed = supplied ?? await deps2.io.readSecret("Hushscript token: ");
565
+ registerSecret(rt.secrets, typed);
566
+ const token = typed.trim();
567
+ if (token === "") throw usageError("No token was entered.");
568
+ if (supplied !== void 0) {
569
+ out.warning(
570
+ "token_on_command_line",
571
+ "a token on the command line can leak through shell history and the process list. Run `hushscript login` without --token to type it, or pipe it on stdin."
572
+ );
573
+ }
574
+ new Hushscript({ token });
575
+ const path = storePath(deps2);
576
+ await saveToken(path, token, deps2.platform);
577
+ out.document({ stored: true, path }, `Token saved to ${path}`);
578
+ });
579
+ }
580
+
581
+ // src/commands/logout.ts
582
+ function registerLogout(program, rt) {
583
+ program.command("logout").description("Delete the stored token").action(async () => {
584
+ const removed = await removeToken(storePath(rt.deps));
585
+ rt.out.document(
586
+ { removed },
587
+ removed ? "Logged out; the stored token was deleted." : "No stored token."
588
+ );
589
+ });
590
+ }
591
+
592
+ // src/commands/packs.ts
593
+ function registerPacks(program, rt) {
594
+ program.command("packs").description("List the minute packs you can buy").action(async () => {
595
+ const api = await openClient(rt);
596
+ const packs = await api.billing.packs();
597
+ const lines = packs.map((pack) => {
598
+ const bonus = pack.bonusMinutes > 0 ? `, +${String(pack.bonusMinutes)} min bonus` : "";
599
+ const locked = pack.available ? "" : " (not available to this account)";
600
+ return `${pack.id} ${String(pack.minutes)} min ${pack.price.formatted}${bonus}${locked}`;
601
+ });
602
+ rt.out.document({ packs }, lines.length === 0 ? "No packs." : lines.join("\n"));
603
+ });
604
+ }
605
+ async function statOrNull(path) {
606
+ try {
607
+ return await stat(path);
608
+ } catch {
609
+ return null;
610
+ }
611
+ }
612
+ async function checkInputFile(path) {
613
+ const info = await statOrNull(path);
614
+ if (info === null) throw usageError(`No such file: ${path}`);
615
+ if (!info.isFile()) throw usageError(`Not a file: ${path}`);
616
+ }
617
+ async function checkOutputPath(path) {
618
+ let target;
619
+ try {
620
+ target = await resolveLinks(path);
621
+ } catch {
622
+ throw usageError(`--out is a loop of symbolic links: ${path}`);
623
+ }
624
+ const folder = await statOrNull(dirname(resolve(target)));
625
+ if (folder?.isDirectory() !== true) {
626
+ throw usageError(`The folder for --out does not exist: ${dirname(target)}`);
627
+ }
628
+ if ((await statOrNull(target))?.isDirectory() === true) {
629
+ throw usageError(`--out is a folder, not a file: ${path}`);
630
+ }
631
+ }
632
+ var MAX_LINK_HOPS = 40;
633
+ var NEW_FILE_MODE = 420;
634
+ async function resolveLinks(path) {
635
+ let current = path;
636
+ for (let hops = 0; hops < MAX_LINK_HOPS; hops += 1) {
637
+ const info = await lstat(current).catch(() => null);
638
+ if (info?.isSymbolicLink() !== true) return current;
639
+ current = resolve(dirname(current), await readlink(current));
640
+ }
641
+ throw Object.assign(new Error("Too many levels of symbolic links."), { code: "ELOOP" });
642
+ }
643
+ async function writeFileAtomic(path, content, hint) {
644
+ let temp = null;
645
+ try {
646
+ const target = await resolveLinks(path);
647
+ const existing = await statOrNull(target);
648
+ temp = join(dirname(target), `.${basename(target)}.${randomBytes(6).toString("hex")}.tmp`);
649
+ const mode = existing === null ? NEW_FILE_MODE : Number(existing.mode) & 4095;
650
+ const handle = await open(temp, "wx", mode);
651
+ try {
652
+ if (existing !== null) await handle.chmod(mode);
653
+ await handle.writeFile(content);
654
+ } finally {
655
+ await handle.close();
656
+ }
657
+ await rename(temp, target);
658
+ } catch (error) {
659
+ if (temp !== null) await unlink(temp).catch(() => void 0);
660
+ const reason = error.code ?? "unknown error";
661
+ throw new CliError({
662
+ code: "write_failed",
663
+ message: `Could not write ${path} (${reason}).`,
664
+ exitCode: 1,
665
+ hint
666
+ });
667
+ }
668
+ }
669
+ var CLEAR_LINE = "\r\x1B[K";
670
+ function progressLine(progress) {
671
+ return progress.stage === "uploading" ? `Uploading ${String(progress.percent)}%` : `Processing (${progress.state})`;
672
+ }
673
+ function flagWords(passed, names) {
674
+ return names.flatMap((name) => {
675
+ const value = passed[name];
676
+ return value === void 0 ? [] : [` --${name} ${shellWord(value)}`];
677
+ }).join("");
678
+ }
679
+ function continueHint(token, key, job) {
680
+ const file = shellWord(job.file);
681
+ if (token !== null) {
682
+ return `Continue with: hushscript resume ${token} ${file}${flagWords(job.passed, ["format", "out"])}`;
683
+ }
684
+ if (key === null) return null;
685
+ const flags = flagWords(job.passed, ["language", "translate", "format", "out"]);
686
+ return `Retry with: hushscript transcribe --key ${key} ${file}${flags}`;
687
+ }
688
+ async function runJob(rt, job) {
689
+ const { deps: deps2, out } = rt;
690
+ const { io: io2 } = deps2;
691
+ const api = await openClient(rt);
692
+ const abort = new AbortController();
693
+ let resumeToken = job.resumeToken;
694
+ const drawing = { drawn: false };
695
+ const hooks = {
696
+ signal: abort.signal,
697
+ onProgress: void 0,
698
+ onResumeToken: (token) => {
699
+ resumeToken = token;
700
+ }
701
+ };
702
+ if (io2.stderrIsTTY && !out.json) {
703
+ hooks.onProgress = (progress) => {
704
+ drawing.drawn = true;
705
+ io2.stderr(`${CLEAR_LINE}${progressLine(progress)}`);
706
+ };
707
+ }
708
+ const wasInterrupted = () => abort.signal.aborted;
709
+ const stop = io2.onInterrupt((count) => {
710
+ if (count > 1) {
711
+ if (drawing.drawn) io2.stderr(CLEAR_LINE);
712
+ io2.forceExit(
713
+ out.fail(
714
+ interrupted(
715
+ resumeToken,
716
+ job.idempotencyKey,
717
+ continueHint(resumeToken, job.idempotencyKey, job)
718
+ )
719
+ )
720
+ );
721
+ return;
722
+ }
723
+ abort.abort();
724
+ });
725
+ let transcript;
726
+ try {
727
+ transcript = await job.start(api, hooks);
728
+ } catch (error) {
729
+ if (wasInterrupted()) {
730
+ const known = error instanceof HushscriptError ? error : null;
731
+ const token = known?.resumeToken ?? resumeToken;
732
+ const key = known?.idempotencyKey ?? job.idempotencyKey;
733
+ throw interrupted(token, key, continueHint(token, key, job));
734
+ }
735
+ throw error;
736
+ } finally {
737
+ stop();
738
+ if (drawing.drawn) io2.stderr(CLEAR_LINE);
739
+ }
740
+ const content = renderTranscript(transcript, job.format, void 0);
741
+ if (job.out === void 0) {
742
+ out.content(content);
743
+ } else {
744
+ await writeFileAtomic(
745
+ job.out,
746
+ content,
747
+ `The transcript was created; fetch it again with \`hushscript transcript ${transcript.id}\`.`
748
+ );
749
+ if (out.json) out.document({ id: transcript.id, format: job.format, out: job.out }, "");
750
+ else out.note(`Saved the ${job.format} transcript ${transcript.id} to ${job.out}`);
751
+ }
752
+ if (job.translateTo !== void 0) {
753
+ out.note(
754
+ `Translations are ready (${job.translateTo.join(", ")}). Print one with: hushscript transcript ${transcript.id} --lang ${job.translateTo[0] ?? ""}`
755
+ );
756
+ }
757
+ }
758
+
759
+ // src/commands/resume.ts
760
+ function registerResume(program, rt) {
761
+ program.command("resume").description("Finish a transcription that `transcribe` left behind when it was interrupted").argument("<token>", "the resume token `transcribe` printed").argument("<file>", "the same recording").addOption(new Option("--format <format>", "output format (default txt)").choices(FORMATS)).option("--out <path>", "write the transcript to a file instead of stdout").action(async (token, file, options) => {
762
+ const format = resolveFormat(rt.globals().json, options.format);
763
+ await checkInputFile(file);
764
+ if (options.out !== void 0) await checkOutputPath(options.out);
765
+ await runJob(rt, {
766
+ file,
767
+ passed: { format: options.format, out: options.out },
768
+ format,
769
+ out: options.out,
770
+ resumeToken: token,
771
+ idempotencyKey: null,
772
+ translateTo: void 0,
773
+ start: (api, hooks) => api.resume(token, file, { signal: hooks.signal, onProgress: hooks.onProgress })
774
+ });
775
+ });
776
+ }
777
+ function registerTranscribe(program, rt) {
778
+ program.command("transcribe").description("Upload a recording and print its transcript").argument("<file>", "audio or video file").option("--language <code>", "force the spoken language (default: detect it)").option("--translate <codes>", "also translate, for example es,de").addOption(new Option("--format <format>", "output format (default txt)").choices(FORMATS)).option("--out <path>", "write the transcript to a file instead of stdout").option(
779
+ "--key <key>",
780
+ "idempotency key; pass the one an interrupted run printed to replay it safely"
781
+ ).action(async (file, options) => {
782
+ const language = options.language === void 0 ? void 0 : parseLanguage(options.language);
783
+ const translateTo = options.translate === void 0 ? void 0 : parseTranslate(options.translate);
784
+ const format = resolveFormat(rt.globals().json, options.format);
785
+ const idempotencyKey = options.key === void 0 ? randomUUID() : parseKey(options.key);
786
+ await checkInputFile(file);
787
+ if (options.out !== void 0) await checkOutputPath(options.out);
788
+ await runJob(rt, {
789
+ file,
790
+ passed: {
791
+ language,
792
+ translate: translateTo?.join(","),
793
+ format: options.format,
794
+ out: options.out
795
+ },
796
+ format,
797
+ out: options.out,
798
+ resumeToken: null,
799
+ idempotencyKey,
800
+ translateTo,
801
+ start: (api, hooks) => {
802
+ const request = {
803
+ signal: hooks.signal,
804
+ onResumeToken: hooks.onResumeToken,
805
+ idempotencyKey
806
+ };
807
+ if (hooks.onProgress !== void 0) request.onProgress = hooks.onProgress;
808
+ if (language !== void 0) request.language = language;
809
+ if (translateTo !== void 0) request.translateTo = translateTo;
810
+ return api.transcribe(file, request);
811
+ }
812
+ });
813
+ });
814
+ }
815
+ function registerTranscript(program, rt) {
816
+ program.command("transcript").description("Print one finished transcript").argument("<id>", "transcript id").addOption(new Option("--format <format>", "output format (default txt)").choices(FORMATS)).option("--lang <code>", 'a translation language code, or "cleaned" for the cleaned body').action(async (idArgument, options) => {
817
+ const id = parseId(idArgument, "The transcript id");
818
+ const format = resolveFormat(rt.globals().json, options.format);
819
+ const lang = options.lang === void 0 ? void 0 : parseLang(options.lang);
820
+ if (lang !== void 0 && format === "json") {
821
+ throw usageError("--lang does not apply to JSON output, which holds every language.");
822
+ }
823
+ const api = await openClient(rt);
824
+ const transcript = await api.transcripts.get(id);
825
+ if (lang !== void 0 && lang !== "original") {
826
+ const known = lang === "cleaned" ? transcript.cleaned !== null : transcript.languages.includes(lang);
827
+ if (!known) {
828
+ const available = [
829
+ ...transcript.languages,
830
+ ...transcript.cleaned === null ? [] : ["cleaned"]
831
+ ];
832
+ throw usageError(
833
+ `This transcript has no "${lang}" version.`,
834
+ available.length === 0 ? "It has only the original." : `Available: ${available.join(", ")}.`
835
+ );
836
+ }
837
+ }
838
+ rt.out.content(renderTranscript(transcript, format, lang));
839
+ });
840
+ }
841
+
842
+ // src/commands/transcripts.ts
843
+ var DEFAULT_LIMIT = 20;
844
+ var MAX_PAGE_SIZE = 100;
845
+ function registerTranscripts(program, rt) {
846
+ program.command("transcripts").description("List finished transcripts, newest first").option("--limit <n>", `how many to list (default ${String(DEFAULT_LIMIT)})`).action(async (options) => {
847
+ const limit = options.limit === void 0 ? DEFAULT_LIMIT : parseLimit(options.limit);
848
+ const api = await openClient(rt);
849
+ const transcripts = [];
850
+ for await (const summary of api.transcripts.list({
851
+ pageSize: Math.min(limit, MAX_PAGE_SIZE)
852
+ })) {
853
+ transcripts.push(summary);
854
+ if (transcripts.length >= limit) break;
855
+ }
856
+ const lines = transcripts.map(
857
+ (row) => `${row.id} ${formatDate(row.createdAt)} ${formatDuration(row.durationSeconds)} ${row.title}`
858
+ );
859
+ rt.out.document({ transcripts }, lines.length === 0 ? "No transcripts." : lines.join("\n"));
860
+ });
861
+ }
862
+
863
+ // src/program.ts
864
+ var CLEAN_EXIT = /* @__PURE__ */ new Set(["commander.helpDisplayed", "commander.version"]);
865
+ function tokensIn(argv) {
866
+ const found = [];
867
+ argv.forEach((arg, index) => {
868
+ if (arg === "--token") {
869
+ const next = argv[index + 1];
870
+ if (next !== void 0) found.push(next);
871
+ } else if (arg.startsWith("--token=")) {
872
+ found.push(arg.slice("--token=".length));
873
+ }
874
+ });
875
+ return found;
876
+ }
877
+ function buildProgram(rt, program, shown) {
878
+ program.name("hushscript").description("Command line client for the Hushscript transcription API.").version(package_default.version).option("--json", "print one JSON document on stdout; stderr as JSON Lines").option("--token <token>", "API token (default: HUSHSCRIPT_TOKEN, then the one from `login`)").configureOutput({
879
+ // Under --json the help or version text is held back and printed as a JSON document instead.
880
+ writeOut: (text) => {
881
+ if (rt.out.json) shown.text += text;
882
+ else rt.deps.io.stdout(text);
883
+ },
884
+ // Under --json stderr carries only JSON; a bare `hushscript --json` reports its usage error that way.
885
+ writeErr: (text) => {
886
+ if (!rt.out.json) rt.deps.io.stderr(text);
887
+ },
888
+ outputError: () => void 0
889
+ }).exitOverride();
890
+ registerLogin(program, rt);
891
+ registerLogout(program, rt);
892
+ registerBalance(program, rt);
893
+ registerTranscribe(program, rt);
894
+ registerResume(program, rt);
895
+ registerTranscripts(program, rt);
896
+ registerTranscript(program, rt);
897
+ registerJobs(program, rt);
898
+ registerPacks(program, rt);
899
+ registerBuy(program, rt);
900
+ }
901
+ async function run(argv, deps2) {
902
+ const secrets = [];
903
+ for (const token of tokensIn(argv)) registerSecret(secrets, token);
904
+ registerSecret(secrets, deps2.env["HUSHSCRIPT_TOKEN"] ?? "");
905
+ const program = new Command();
906
+ const out = new Output(deps2.io, argv.includes("--json"), secrets);
907
+ const rt = {
908
+ deps: deps2,
909
+ out,
910
+ secrets,
911
+ globals: () => {
912
+ const options = program.opts();
913
+ const globals = { json: options.json === true, token: valueOf(options.token) };
914
+ return globals;
915
+ }
916
+ };
917
+ const shown = { text: "" };
918
+ buildProgram(rt, program, shown);
919
+ const printShown = (code2) => {
920
+ if (!out.json) return;
921
+ out.document(
922
+ code2 === "commander.version" ? { version: shown.text.trim() } : { help: shown.text },
923
+ ""
924
+ );
925
+ };
926
+ try {
927
+ await program.parseAsync(argv, { from: "user" });
928
+ return 0;
929
+ } catch (error) {
930
+ if (error instanceof CommanderError) {
931
+ if (CLEAN_EXIT.has(error.code)) {
932
+ printShown(error.code);
933
+ return 0;
934
+ }
935
+ if (error.code === "commander.help") {
936
+ if (error.exitCode === 0) {
937
+ printShown(error.code);
938
+ return 0;
939
+ }
940
+ if (!out.json) return 2;
941
+ return out.fail(
942
+ usageError("A command is required.", "Run `hushscript --help` to list them.")
943
+ );
944
+ }
945
+ return out.fail(usageError(error.message.replace(/^error: /, "")));
946
+ }
947
+ return out.fail(error);
948
+ }
949
+ }
950
+
951
+ // src/signals.ts
952
+ var STOP_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
953
+ var INTERRUPTED_EXIT = 130;
954
+ function installInterrupts(host) {
955
+ let open3 = null;
956
+ let forced = null;
957
+ const received = () => {
958
+ if (forced !== null) {
959
+ host.exit(forced);
960
+ return;
961
+ }
962
+ if (open3 === null) {
963
+ host.exit(INTERRUPTED_EXIT);
964
+ return;
965
+ }
966
+ open3.count += 1;
967
+ open3.handler(open3.count);
968
+ };
969
+ for (const signal of STOP_SIGNALS) host.listen(signal, received);
970
+ return {
971
+ onInterrupt: (handler) => {
972
+ const window = { handler, count: 0 };
973
+ open3 = window;
974
+ return () => {
975
+ if (open3 === window) open3 = null;
976
+ };
977
+ },
978
+ forceExit: (code2) => {
979
+ forced ??= code2;
980
+ const final = forced;
981
+ host.flushStderr(() => {
982
+ host.exit(final);
983
+ });
984
+ },
985
+ exiting: () => forced !== null,
986
+ finalCode: (code2) => forced ?? code2
987
+ };
988
+ }
989
+
990
+ // src/index.ts
991
+ function flush(stream) {
992
+ return new Promise(
993
+ (resolve2) => stream.write("", () => {
994
+ resolve2();
995
+ })
996
+ );
997
+ }
998
+ var interrupts = installInterrupts({
999
+ listen: (signal, listener) => process.on(signal, listener),
1000
+ flushStderr: (done) => process.stderr.write("", done),
1001
+ // eslint-disable-next-line n/no-process-exit -- an interrupted purchase and a second Ctrl-C must end the process at once
1002
+ exit: (code2) => process.exit(code2)
1003
+ });
1004
+ var io = {
1005
+ stdout: (text) => {
1006
+ if (!interrupts.exiting()) process.stdout.write(text);
1007
+ },
1008
+ stderr: (text) => {
1009
+ if (!interrupts.exiting()) process.stderr.write(text);
1010
+ },
1011
+ stdinIsTTY: process.stdin.isTTY,
1012
+ stdoutIsTTY: process.stdout.isTTY,
1013
+ stderrIsTTY: process.stderr.isTTY,
1014
+ readSecret: (prompt) => readSecret(process.stdin, process.stderr, prompt),
1015
+ confirm: (prompt) => confirm(process.stdin, process.stderr, prompt),
1016
+ onInterrupt: interrupts.onInterrupt,
1017
+ forceExit: interrupts.forceExit
1018
+ };
1019
+ var deps = {
1020
+ io,
1021
+ env: process.env,
1022
+ platform: process.platform,
1023
+ homedir: homedir(),
1024
+ createClient: ({ token, baseUrl }) => new Hushscript({ token, baseUrl })
1025
+ };
1026
+ var code = await run(process.argv.slice(2), deps);
1027
+ await Promise.all([flush(process.stdout), flush(process.stderr)]);
1028
+ process.exit(interrupts.finalCode(code));
package/package.json CHANGED
@@ -1,6 +1,54 @@
1
1
  {
2
2
  "name": "@hushscript/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.1",
4
+ "description": "Command line client for the Hushscript transcription API.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "homepage": "https://github.com/Hushscript/hushscript-cli#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/Hushscript/hushscript-cli.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/Hushscript/hushscript-cli/issues"
14
+ },
15
+ "engines": {
16
+ "node": ">=22"
17
+ },
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "bin": {
22
+ "hushscript": "dist/index.js"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public",
26
+ "provenance": true
27
+ },
28
+ "config": {
29
+ "conformanceRef": "v1.0.0"
30
+ },
31
+ "scripts": {
32
+ "build": "tsup"
33
+ },
34
+ "dependencies": {
35
+ "@hushscript/sdk": "^1.0.0",
36
+ "commander": "15.0.0"
37
+ },
38
+ "devDependencies": {
39
+ "@eslint/js": "10.0.1",
40
+ "@types/node": "22.20.5",
41
+ "@vitest/coverage-v8": "5.0.3",
42
+ "eslint": "10.12.0",
43
+ "eslint-plugin-n": "18.4.1",
44
+ "eslint-plugin-regexp": "3.3.1",
45
+ "globals": "17.13.0",
46
+ "knip": "6.39.0",
47
+ "prettier": "3.9.9",
48
+ "publint": "0.3.25",
49
+ "tsup": "8.5.1",
50
+ "typescript": "6.0.3",
51
+ "typescript-eslint": "8.71.1",
52
+ "vitest": "5.0.3"
53
+ }
54
+ }