From 2517958d2d2fe4deaddb58e15a4b3f0105ffe806 Mon Sep 17 00:00:00 2001 From: EthanHuo Date: Thu, 12 Nov 2026 21:33:22 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat!:=20argc=2010=20=E2=80=94=20provider?= =?UTF-8?q?=20keys=20as=20secret=20apiKey=20fields,=20YAML=20whole=20input?= =?UTF-8?q?=20(3.0.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Provider keys are `secret()` `apiKey` fields (`@celados/argc/ref`) with the default chain env → `bw://` via `@celados/argc-latch` `createLatch()`. Commands that always call DashScope (vocab.sync/prune, voice.record/remove) resolve it during validation; cache-aware commands (hear, say, vocab.list, voice.list) take an optional override and read the chain only when a provider is called, so stored runs and cached audio stay offline. - `defineValueSource`, `cli({ values })`, `@celados/latch-source` removed; `value_unavailable` → `INVALID_INPUT` on `apiKey` or `key_unavailable`. - Field-level `--text -` / `--text @file` and the `say` argv rewrite removed (src/text-body.ts); multi-line text goes through `vox say - <<'EOF'` YAML. - valibot ^1.5, @valibot/to-json-schema ^1.8 (argc 10 peers). --- AGENTS.md | 5 ++-- bun.lock | 18 +++++++----- package.json | 10 +++---- src/auth.ts | 73 ++++++++++++++++++++++++++++++++++++++++++++++ src/cli-error.ts | 1 + src/handlers.ts | 51 ++++++++++++++++---------------- src/hear.ts | 5 ++-- src/index.md | 34 +++++++++++++--------- src/main.test.ts | 45 +++++++++++++++++++++++----- src/main.ts | 6 +--- src/schema.ts | 33 ++++++++++++++------- src/text-body.ts | 76 ------------------------------------------------ src/tts.ts | 44 ++++++++++++++++------------ src/values.ts | 38 ------------------------ 14 files changed, 228 insertions(+), 211 deletions(-) create mode 100644 src/auth.ts delete mode 100644 src/text-body.ts delete mode 100644 src/values.ts diff --git a/AGENTS.md b/AGENTS.md index 2459653..432075c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,7 @@ Playback uses `afplay` (macOS) or `ffplay`/`aplay`. Microphone enrollment uses ## Testing `bun test` is offline: store, vocab, export, schema, keyless CLI paths. Provider -keys come from `src/values.ts` (env, then latch `celados-agent`); tests pass env -vars and never call latch. Live DashScope calls are not mocked — run `vox hear` / `vox say` by hand +keys are `secret()` `apiKey` fields from `src/auth.ts` (`@celados/argc/ref`; +default chain env, then latch `celados-agent` via `@celados/argc-latch`); tests +pass env vars or `{ ref: 'env:…' }` and never call latch. Live DashScope calls are not mocked — run `vox hear` / `vox say` by hand against a real key. diff --git a/bun.lock b/bun.lock index 0ec81ce..212ffdd 100644 --- a/bun.lock +++ b/bun.lock @@ -5,10 +5,10 @@ "": { "name": "vox", "dependencies": { - "@celados/argc": "^9.0.0", - "@celados/latch-source": "^0.1.0", - "@valibot/to-json-schema": "^1.7.1", - "valibot": "^1.4.2", + "@celados/argc": "^10.0.0", + "@celados/argc-latch": "^1.0.0", + "@valibot/to-json-schema": "^1.8.0", + "valibot": "^1.5.0", "yaml": "^2.9.0", }, "devDependencies": { @@ -19,9 +19,9 @@ }, }, "packages": { - "@celados/argc": ["@celados/argc@9.0.0", "https://npm.celados.com/@celados/argc/-/@celados/argc-9.0.0.tgz", { "dependencies": { "yaml": "2.9.0" }, "peerDependencies": { "@standard-schema/spec": "^1.0.0" } }, "sha512-shfwtc+ZAyXBxtxPnG3CapcUL0c7sUJu+pbcQAe5e190lVmtyRBCG1+OCVLn9UgKvTx2g0x7fFlu9Y7jR6LPnQ=="], + "@celados/argc": ["@celados/argc@10.0.0", "https://npm.celados.com/@celados/argc/-/@celados/argc-10.0.0.tgz", { "dependencies": { "yaml": "^2.9.1" }, "peerDependencies": { "@standard-schema/spec": "^1.0.0", "@valibot/to-json-schema": "^1.8.0", "valibot": "^1.5.0" } }, "sha512-5PyPq2Ix8iXx9iHg+NIej2o27TqaIWg4l0vxKijoOPMckqP3YU9QwjN+8J+QVqDZsBCSfCNOhzHU0kJXE5LRrg=="], - "@celados/latch-source": ["@celados/latch-source@0.1.0", "https://npm.celados.com/@celados/latch-source/-/@celados/latch-source-0.1.0.tgz", { "peerDependencies": { "@celados/argc": ">=8.1.0" } }, "sha512-RjPJV+DdgouKKr/DIMHIa/WMx62kzWezfbrnQgVQcbseSoZAb4cFV1LIgmKhzg1TflO6G5RHDtBk3mHzDUBqyQ=="], + "@celados/argc-latch": ["@celados/argc-latch@1.0.0", "https://npm.celados.com/@celados/argc-latch/-/@celados/argc-latch-1.0.0.tgz", { "peerDependencies": { "@celados/argc": ">=10.0.0" } }, "sha512-Ewch1u44WzWQKhzqZ6i19B4dI0d01YDzBaL3kencCOr63Sm8xwIcqeMxS0/BKInsQKm1fWoHnntQn56HDDoFug=="], "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.64.0", "", { "os": "android", "cpu": "arm" }, "sha512-o6uzh/jTOQeAY5TdkAeXdqv7MBRcPxiRA08zrcBtkKj5cSu/FMu0Hl7Q6Fi1KCKyCWZ6lJVjBzdsJvsKltUsGQ=="], @@ -107,7 +107,7 @@ "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], - "@valibot/to-json-schema": ["@valibot/to-json-schema@1.7.1", "", { "peerDependencies": { "valibot": "^1.4.0" } }, "sha512-3qkmU6KXWh8GIThEAW3kuRHPQBMjWkKy+Ppz3WkUucx53DTpOa6siMn4xDGSOhlVyMrDaJTCTMLYPZVAIk1P0A=="], + "@valibot/to-json-schema": ["@valibot/to-json-schema@1.8.0", "", { "peerDependencies": { "valibot": "^1.5.0" } }, "sha512-a0M+uwCuQZEPAo65NYkFSJ14O5c213KoSZmPdoCfuFvUhfULi4T2Z6Tpjv6lTVMW9RoLm74RpRAjNEa43KcN+g=="], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], @@ -119,8 +119,10 @@ "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], - "valibot": ["valibot@1.4.2", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg=="], + "valibot": ["valibot@1.5.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-nil6AkP2TChWL43Z5uJ6GTxX01CUA+g8LWUM+N/rB9NBbkUMaUsi9PUNzlUPgoKASgmx9f7eGOYpJ04/fSa6FQ=="], "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + + "@celados/argc/yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="], } } diff --git a/package.json b/package.json index ddd8fab..c61747c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vox", - "version": "2.0.1", + "version": "3.0.0", "description": "Agent-facing speech to text and text to speech over Alibaba Model Studio.", "repository": "https://github.com/celados/vox", "bin": { @@ -18,10 +18,10 @@ "build": "bun build src/main.ts --outfile=dist/vox --target=bun --minify && chmod +x dist/vox" }, "dependencies": { - "@celados/argc": "^9.0.0", - "@celados/latch-source": "^0.1.0", - "@valibot/to-json-schema": "^1.7.1", - "valibot": "^1.4.2", + "@celados/argc": "^10.0.0", + "@celados/argc-latch": "^1.0.0", + "@valibot/to-json-schema": "^1.8.0", + "valibot": "^1.5.0", "yaml": "^2.9.0" }, "devDependencies": { diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..c485782 --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,73 @@ +import { createLatch } from "@celados/argc-latch"; +import { env, secret } from "@celados/argc/ref"; +import * as v from "valibot"; + +import { voxError } from "./cli-error.ts"; + +/** + * Provider keys are `secret()` input fields (argc 10, values by reference). + * Callers pass a literal or `{ ref: 'env:…' | 'bw://…' }`; omitted, the default + * chain runs: env var first (CI, one-off overrides), then the celados-agent + * vault item. Only the command being executed ever reads a source. + */ +const resolvers = [env(), createLatch()]; +const key = v.pipe(v.string(), v.trim(), v.minLength(1)); + +export const providers = { + dashscope: { + label: "DashScope API key", + description: "Alibaba Model Studio (DashScope) API key — ASR fun/qwen, TTS, voices, vocab", + default: ["env:DASHSCOPE_API_KEY", "bw://7c38804e-df94-444c-8897-efcde8a77339/凭据"], + }, + mimo: { + label: "MiMo API key", + description: "Xiaomi MiMo API key — ASR model mimo", + default: ["env:MIMO_API_KEY", "bw://52f369e9-e50d-4e49-89e7-a8901c944f41/api key"], + }, +} as const; +export type Provider = keyof typeof providers; + +const keyField = (provider: Provider) => + secret(key, resolvers, { + description: providers[provider].description, + default: providers[provider].default, + }); + +/** Commands that always call DashScope: the key resolves during validation. */ +export const dashscopeAuth = { apiKey: keyField("dashscope") }; + +/** + * Commands that may not need a key (stored run, cached audio, keyless listing) + * take an optional override and resolve the default chain only when a provider + * is actually called — a stored replay stays offline and never waits on the vault. + */ +export const lazyAuth = (scope: string, keys: readonly Provider[] = ["dashscope"]) => ({ + apiKey: v.optionalAsync( + secret(key, resolvers, { + description: `${scope}. Omitted → read only when a provider is called: ${keys + .map((name) => `${name} ${providers[name].default.join(" → ")}`) + .join("; ")}`, + }), + ), +}); + +export type KeyProbe = { available: true; value: string } | { available: false; tried: string }; + +/** Try one provider's default chain. Messages name references, never values. */ +export async function probeKey(provider: Provider): Promise { + const result = await v.safeParseAsync(keyField(provider), undefined); + if (result.success) return { available: true, value: result.output }; + return { available: false, tried: result.issues.map((issue) => issue.message).join("; ") }; +} + +/** The caller's key, else the provider's default chain; a miss is `key_unavailable`. */ +export async function requireKey(provider: Provider, given: string | undefined): Promise { + if (given !== undefined) return given; + const probe = await probeKey(provider); + if (probe.available) return probe.value; + throw voxError( + "key_unavailable", + `${providers[provider].label}: ${probe.tried}`, + "vox status shows every provider; or pass apiKey: { ref: 'env:NAME' }", + ); +} diff --git a/src/cli-error.ts b/src/cli-error.ts index 609f590..620233e 100644 --- a/src/cli-error.ts +++ b/src/cli-error.ts @@ -13,6 +13,7 @@ export type VoxErrorCode = | "session_not_found" | "session_ambiguous" | "api_error" + | "key_unavailable" | "invalid_usage" | "io_error"; diff --git a/src/handlers.ts b/src/handlers.ts index 37d042c..e8e0f65 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -1,7 +1,8 @@ import { writeFile } from "node:fs/promises"; +import { probeKey, providers } from "./auth.ts"; import { voxError } from "./cli-error.ts"; -import { loadConfig, type AppConfig } from "./config.ts"; +import { loadConfig } from "./config.ts"; import { DashScopeClient, resolveModel, VOCABULARY_QUOTA } from "./dashscope.ts"; import { render } from "./export.ts"; import { hear } from "./hear.ts"; @@ -15,7 +16,6 @@ import { } from "./speakers.ts"; import { listRuns, loadRun, newStore, removeAllRuns, removeRun, resolveSid } from "./run-store.ts"; import type { AppHandlers } from "./schema.ts"; -import { isValueUnavailable, secret } from "./values.ts"; import { cacheClear, cacheStatus, listVoices, recordVoice, removeVoice, say } from "./tts.ts"; import { describeIndex, @@ -51,18 +51,18 @@ export const handlers: AppHandlers = { }, export: async (options) => await exportRun(options.input), vocab: { - list: async () => await vocabList(), + list: async (options) => await vocabList(options.input.apiKey), sync: async (options) => await vocabSync(options.input), - prune: async (options) => await vocabPrune(options.input.dryRun ?? false), + prune: async (options) => await vocabPrune(options.input.apiKey, options.input.dryRun ?? false), }, say: async (options) => { const app = await loadConfig(); return await say(app, options.input); }, voice: { - list: async () => { + list: async (options) => { const app = await loadConfig(); - return await listVoices(app); + return await listVoices(app, options.input.apiKey); }, record: async (options) => { const app = await loadConfig(); @@ -70,7 +70,7 @@ export const handlers: AppHandlers = { }, remove: async (options) => { const app = await loadConfig(); - return await removeVoice(app, options.input.voiceId); + return await removeVoice(app, options.input.apiKey, options.input.voiceId); }, }, cache: { @@ -86,16 +86,13 @@ export const handlers: AppHandlers = { }; async function providerStatus(): Promise> { - const providers = { dashscope: "DASHSCOPE_API_KEY", mimo: "MIMO_API_KEY" } as const; const entries = await Promise.all( - Object.entries(providers).map(async ([provider, value]) => { - try { - await secret[value]; - return [provider, { available: true }] as const; - } catch (error) { - if (!isValueUnavailable(error)) throw error; - return [provider, { available: false, tried: error.envelope.tried }] as const; - } + (Object.keys(providers) as Array).map(async (provider) => { + const probe = await probeKey(provider); + return [ + provider, + probe.available ? { available: true } : { available: false, tried: probe.tried }, + ] as const; }), ); return Object.fromEntries(entries); @@ -162,7 +159,7 @@ async function exportRun(input: { return rendered; } -async function vocabList(): Promise { +async function vocabList(apiKey: string | undefined): Promise { const app = await loadConfig(); const names = await vocabNames(app.dir); const index = await loadIndex(app.dir); @@ -180,27 +177,32 @@ async function vocabList(): Promise { } vocabularies.push(entry); } - return { quota: await quotaUsage(app), vocabularies }; + return { quota: await quotaUsage(apiKey), vocabularies }; } -async function quotaUsage(app: AppConfig): Promise { +async function quotaUsage(given: string | undefined): Promise { + let apiKey = given; + if (apiKey === undefined) { + const probe = await probeKey("dashscope"); + if (!probe.available) return "unknown (DashScope API key unavailable — vox status shows why)"; + apiKey = probe.value; + } try { - const remote = await new DashScopeClient(await secret.DASHSCOPE_API_KEY).listVocabularies(); + const remote = await new DashScopeClient(apiKey).listVocabularies(); return `${remote.length}/${VOCABULARY_QUOTA}`; } catch (error) { - if (isValueUnavailable(error)) return "unknown (DASHSCOPE_API_KEY unavailable)"; return `unknown (${error instanceof Error ? error.message : String(error)})`; } } async function vocabSync(input: { + apiKey: string; name?: string; all?: boolean; model?: string; force?: boolean; }): Promise { const app = await loadConfig(); - const apiKey = await secret.DASHSCOPE_API_KEY; const vendor = input.model ?? "fun"; const model = resolveModel(vendor); if (!model) throw voxError("invalid_usage", `unknown model vendor "${vendor}"`); @@ -215,7 +217,7 @@ async function vocabSync(input: { ); } - const client = new DashScopeClient(apiKey); + const client = new DashScopeClient(input.apiKey); const targets = input.all ? await listVocabularies(app.dir) : [await loadVocabulary(app.dir, input.name!)]; @@ -233,9 +235,8 @@ async function vocabSync(input: { return { items }; } -async function vocabPrune(dryRun: boolean): Promise { +async function vocabPrune(apiKey: string, dryRun: boolean): Promise { const app = await loadConfig(); - const apiKey = await secret.DASHSCOPE_API_KEY; const orphans = await pruneVocabularies(new DashScopeClient(apiKey), app.dir, dryRun); return { orphans, dryRun }; } diff --git a/src/hear.ts b/src/hear.ts index 813aac4..edb3e58 100644 --- a/src/hear.ts +++ b/src/hear.ts @@ -1,5 +1,6 @@ import { extname, resolve } from "node:path"; +import { requireKey } from "./auth.ts"; import { durationSeconds, isSupportedFormat, supportedFormats } from "./audio.ts"; import { VoxError, voxError } from "./cli-error.ts"; import { type AppConfig } from "./config.ts"; @@ -12,7 +13,6 @@ import { } from "./dashscope.ts"; import { MimoClient, MODEL_MIMO_ASR, mimoAudioData, mimoLanguage } from "./mimo.ts"; import { expandHome, tilde } from "./paths.ts"; -import { secret } from "./values.ts"; import { digestOf, loadRun, @@ -32,6 +32,7 @@ export const FOLD_TOKENS = 2000; export const PREVIEW_RUNES = 60; export type HearInput = { + apiKey?: string; file: string; model?: string; vocab?: string; @@ -114,7 +115,7 @@ export async function transcribeRun(app: AppConfig, input: HearInput): Promise/' }`. +`vox status` resolves both chains and shows, per provider, `available` or what +it `tried` — never key material. Stored runs, `export`, `session.*`, +`speaker.*` and cached `say` read no key. ## Speech to text @@ -156,9 +158,12 @@ Weight is 1–5; 50 is a super hotword that only `qwen` honours. ```bash vox say --text 'Hello world' --voice Cherry -vox say --text - <<'TXT' -Slow and clear. -TXT +vox say - <<'EOF' +voice: Cherry +text: | + First line. + Slow and clear. +EOF vox say --text 'Save this' --output ~/Desktop/out.wav vox voice.list vox voice.record --file ~/sample.wav --name myvoice @@ -173,14 +178,16 @@ System voices: Cherry, Ethan, Chelsie, Serena (zh/en), Dylan (Beijing), Jada Domain refusals carry `error: DOMAIN_ERROR` and a matchable `code`. Every failure exits 1 — branch on `code`, not the process status or the detail prose: -`value_unavailable` · `audio_too_large` · `audio_unsupported` · +`key_unavailable` · `audio_too_large` · `audio_unsupported` · `vocab_not_found` · `vocab_quota_exceeded` · `vocab_model_mismatch` · `vocab_index_corrupt` · `session_not_found` · `session_ambiguous` · `invalid_usage` · `io_error` · `api_error` · `speaker_not_found` · `speaker_labels_missing` · `speaker_runtime_unavailable` -`value_unavailable` names the key and every source tried; report it to a person -rather than hunting for the key yourself. `session_not_found` → `vox session.list`. +A key the chain cannot find fails as `INVALID_INPUT` on `apiKey` (commands that +always call the provider) or `key_unavailable` (commands that read the key only +when a provider is called); both name every reference tried. Report it to a +person rather than hunting for the key yourself. `session_not_found` → `vox session.list`. ## Anti-patterns @@ -190,7 +197,8 @@ rather than hunting for the key yourself. `session_not_found` → `vox session.l | Re-read `hear` output for the full hour | `vox export --sid --format md` | The envelope folds on purpose | | Treat the envelope as a subtitle file | `export --format srt` or `txt` | Index and content never share stdout | | Invent a create/edit vocab command | Write `~/.vox/vocabulary/.yaml` | The YAML is the source of truth | -| Put multi-line TTS in a JSON object | `vox say --text - <<'TXT'` | Escaping burns tokens and breaks | +| Put multi-line TTS in a JSON object | `vox say - <<'EOF'` with `text: \|` | Escaping burns tokens and breaks | +| Paste a key literal into `apiKey` | `apiKey: { ref: 'env:NAME' }` | Literals land in shell history and transcripts | ## Feedback diff --git a/src/main.test.ts b/src/main.test.ts index fe575f3..3842cdb 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -8,7 +8,7 @@ import packageJson from "../package.json" with { type: "json" }; const ENTRY = join(import.meta.dir, "main.ts"); async function run(args: string[], home?: string, env: Record = {}) { - // Provider keys come only from what a test passes; the latch source is off under bun test. + // Provider keys come only from what a test passes; the latch resolver is off under bun test. const { DASHSCOPE_API_KEY: _d, MIMO_API_KEY: _m, ...inherited } = process.env; const proc = Bun.spawn(["bun", "run", ENTRY, ...args], { stdout: "pipe", @@ -51,9 +51,10 @@ test("@schema exposes the dotted command surface", async () => { } expect(stdout).not.toContain("dashscope("); expect(stdout).not.toContain("auth:"); - expect(stdout).toContain("values:"); - expect(stdout).toContain("DASHSCOPE_API_KEY"); - expect(stdout).toContain("MIMO_API_KEY"); + expect(stdout).not.toContain("values:"); + expect(stdout).toContain("apiKey?: string | { ref: string }"); + expect(stdout).toContain("env:DASHSCOPE_API_KEY → bw://"); + expect(stdout).toContain("env:MIMO_API_KEY → bw://"); }); test("@skill prints the embedded guide", async () => { @@ -81,13 +82,41 @@ test("schema exposes MiMo as an ASR provider", async () => { expect(stdout).toContain('model?: "fun" | "qwen" | "mimo"'); }); -test("a command needing a missing key fails with value_unavailable", async () => { +test("a command needing a missing key fails validation naming each reference", async () => { const home = await mkdtemp(join(tmpdir(), "vox-home-")); const { stderr, exitCode } = await run(["vocab.prune", "--dryRun"], home); expect(exitCode).toBe(1); - expect(stderr).toContain("error: DOMAIN_ERROR"); - expect(stderr).toContain("code: value_unavailable"); - expect(stderr).toContain("DASHSCOPE_API_KEY"); + expect(stderr).toContain("INVALID_INPUT"); + expect(stderr).toContain("env:DASHSCOPE_API_KEY (not set)"); + expect(stderr).toContain("disabled_in_test"); +}); + +test("an explicit env ref supplies the key; a secret is never echoed", async () => { + const home = await mkdtemp(join(tmpdir(), "vox-home-")); + // Validation resolves the ref; the handler then stops before any network call. + const ok = await run(["vocab.sync", "{ apiKey: { ref: 'env:VOX_TEST_KEY' } }"], home, { + VOX_TEST_KEY: "sk-ref-test-key", + }); + expect(ok.exitCode).toBe(1); + expect(ok.stderr).toContain("code: invalid_usage"); + expect(ok.stderr).toContain("no vocabulary name given"); + expect(ok.stdout + ok.stderr).not.toContain("sk-ref-test-key"); + + const bad = await run(["vocab.prune", "{ apiKey: ' ' }"], home); + expect(bad.exitCode).toBe(1); + expect(bad.stderr).toContain("value hidden"); + + const foreign = await run(["vocab.prune", "{ apiKey: { ref: 'file:/etc/passwd' } }"], home); + expect(foreign.exitCode).toBe(1); + expect(foreign.stderr).toContain("not a reference this field accepts"); +}); + +test("say treats --text - as literal text, not a stdin read", async () => { + const home = await mkdtemp(join(tmpdir(), "vox-home-")); + const { stderr, exitCode } = await run(["say", "--text", "-"], home); + // `-` is a literal value now; with no key the provider call fails, not a stdin read. + expect(exitCode).toBe(1); + expect(stderr).toContain("key_unavailable"); }); test("session.remove rejects path traversal before touching the filesystem", async () => { diff --git a/src/main.ts b/src/main.ts index 379fcab..64053b6 100755 --- a/src/main.ts +++ b/src/main.ts @@ -7,16 +7,12 @@ import { withDomainErrors } from "./cli-error.ts"; import { handlers } from "./handlers.ts"; import { schema } from "./schema.ts"; import { embedSkill } from "./skill.embed.ts" with { type: "macro" }; -import { rewriteSayTextArgv } from "./text-body.ts"; -import { secret } from "./values.ts"; const app = cli(schema, { name: "vox", version: packageJson.version, description: "Agent-facing speech to text and text to speech over Alibaba Model Studio.", skill: embedSkill(), - values: secret, }); -const argv = rewriteSayTextArgv(process.argv.slice(2)); -await app.run({ handlers: withDomainErrors(handlers) }, argv); +await app.run({ handlers: withDomainErrors(handlers) }); diff --git a/src/schema.ts b/src/schema.ts index 7d7c4e3..3787114 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -1,9 +1,11 @@ import type { InferHandlers } from "@celados/argc"; -import { toStandardJsonSchema } from "@valibot/to-json-schema"; import { c, group } from "@celados/argc"; +import { toStandardJsonSchema } from "@celados/argc/ref"; import * as v from "valibot"; +import { dashscopeAuth, lazyAuth } from "./auth.ts"; + const s = toStandardJsonSchema; const hearVendor = v.optional(v.picklist(["fun", "qwen", "mimo"]), "fun"); const dashscopeVendor = v.optional(v.picklist(["fun", "qwen"]), "fun"); @@ -27,7 +29,11 @@ const hear = c }) .input( s( - v.object({ + v.objectAsync({ + ...lazyAuth("Key for the chosen model (DashScope for fun/qwen, MiMo for mimo)", [ + "dashscope", + "mimo", + ]), file: v.pipe(v.string(), v.minLength(1)), model: hearVendor, vocab: v.optional(v.string()), @@ -151,7 +157,7 @@ const vocab = group( { list: c .meta({ description: "List local vocabularies, sync state, and remote quota" }) - .input(s(v.object({}))), + .input(s(v.objectAsync({ ...lazyAuth("DashScope key for the remote quota") }))), sync: c .meta({ description: @@ -160,7 +166,8 @@ const vocab = group( }) .input( s( - v.object({ + v.objectAsync({ + ...dashscopeAuth, name: v.optional(v.string()), all: v.optional(v.boolean(), false), model: dashscopeVendor, @@ -174,21 +181,22 @@ const vocab = group( description: "Delete server-side lists no local YAML claims", examples: ["vox vocab.prune --dryRun"], }) - .input(s(v.object({ dryRun: v.optional(v.boolean(), false) }))), + .input(s(v.objectAsync({ ...dashscopeAuth, dryRun: v.optional(v.boolean(), false) }))), }, ); const say = c .meta({ - description: "Speak text with TTS. Prefer --text - for multi-line input", + description: "Speak text with TTS. Multi-line text: whole input as YAML with text: |", examples: [ "vox say --text 'Hello world' --voice Cherry", - "vox say --text - <<'TXT'\nSlow and clear.\nTXT", + "vox say - <<'EOF'\nvoice: Cherry\ntext: |\n First line.\n Slow and clear.\nEOF", ], }) .input( s( - v.object({ + v.objectAsync({ + ...lazyAuth("DashScope key; cached audio needs none"), text: v.pipe(v.string(), v.minLength(1)), voice: v.optional(v.string()), lang: v.optional(v.string(), "auto"), @@ -204,7 +212,9 @@ const say = c const voice = group( { description: "List system voices and manage cloned voices" }, { - list: c.meta({ description: "List system and cloned voices" }).input(s(v.object({}))), + list: c + .meta({ description: "List system and cloned voices" }) + .input(s(v.objectAsync({ ...lazyAuth("DashScope key for cloned voices") }))), record: c .meta({ description: "Enroll a cloned voice from a file, or record from the mic on a TTY", @@ -212,7 +222,8 @@ const voice = group( }) .input( s( - v.object({ + v.objectAsync({ + ...dashscopeAuth, name: v.optional(v.string()), file: v.optional(v.string()), lang: v.optional(v.string()), @@ -222,7 +233,7 @@ const voice = group( ), remove: c .meta({ description: "Delete a cloned voice by id" }) - .input(s(v.object({ voiceId: v.pipe(v.string(), v.minLength(1)) }))) + .input(s(v.objectAsync({ ...dashscopeAuth, voiceId: v.pipe(v.string(), v.minLength(1)) }))) .positional("voiceId"), }, ); diff --git a/src/text-body.ts b/src/text-body.ts deleted file mode 100644 index d1cef63..0000000 --- a/src/text-body.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { existsSync } from "node:fs"; -import { readFile } from "node:fs/promises"; -import { resolve } from "node:path"; - -import { voxError } from "./cli-error.ts"; - -/** Field-level sources for long free text: inline | `-` | `@path` (if it exists). */ -export async function resolveTextField( - raw: string | undefined, - flag = "--text", -): Promise { - if (raw === undefined) return undefined; - const trimmed = raw.trim(); - if (trimmed === "-") { - const body = await readStdin(); - if (!body.trim()) { - throw voxError( - "invalid_usage", - `text from stdin is empty. Use: vox say ${flag} - <<'TXT' … TXT`, - ); - } - return body; - } - const filePath = parseFileRef(trimmed); - if (filePath !== undefined) { - try { - return await readFile(filePath, "utf8"); - } catch (error) { - throw voxError( - "io_error", - `cannot read ${filePath}: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - return raw; -} - -export function parseFileRef(value: string, cwd = process.cwd()): string | undefined { - const trimmed = value.trim(); - if (!trimmed.startsWith("@")) return undefined; - const path = trimmed.slice(1).trim(); - if (!path) return undefined; - const absolute = resolve(cwd, path); - return existsSync(absolute) ? absolute : undefined; -} - -/** - * `vox say --voice Cherry @script.txt` looks like a file body, but argc treats - * a bare `@` as whole-command JSON. Rewrite only when flags are already present. - * Leave `vox say @payload.json` (sole token) alone. - */ -export function rewriteSayTextArgv(argv: string[]): string[] { - if (argv[0] !== "say") return argv; - const rest = argv.slice(1); - if (rest.length < 2) return argv; - if (rest.includes("--text")) return argv; - const out = ["say"]; - let rewritten = false; - for (const token of rest) { - if (!rewritten && token.startsWith("@") && parseFileRef(token)) { - out.push("--text", token); - rewritten = true; - continue; - } - out.push(token); - } - return out; -} - -async function readStdin(): Promise { - const chunks: Buffer[] = []; - for await (const chunk of process.stdin) { - chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); - } - return Buffer.concat(chunks).toString("utf8"); -} diff --git a/src/tts.ts b/src/tts.ts index 0e49200..d53d4e4 100644 --- a/src/tts.ts +++ b/src/tts.ts @@ -10,6 +10,7 @@ import { wrapPcmAsWav, writeWav, } from "./audio.ts"; +import { probeKey, requireKey } from "./auth.ts"; import { voxError } from "./cli-error.ts"; import { saveState, type AppConfig } from "./config.ts"; import { @@ -20,10 +21,9 @@ import { SYSTEM_VOICES, } from "./dashscope.ts"; import { streamTts } from "./realtime.ts"; -import { resolveTextField } from "./text-body.ts"; -import { isValueUnavailable, secret } from "./values.ts"; export type SayInput = { + apiKey?: string; text: string; voice?: string; lang?: string; @@ -34,8 +34,8 @@ export type SayInput = { }; export async function say(app: AppConfig, input: SayInput): Promise { - const text = await resolveTextField(input.text); - if (!text?.trim()) throw voxError("invalid_usage", "say requires text"); + const text = input.text; + if (!text.trim()) throw voxError("invalid_usage", "say requires text"); let voice = input.voice || app.state.last_voice || "Cherry"; let model = modelForVoice(voice); @@ -64,7 +64,7 @@ export async function say(app: AppConfig, input: SayInput): Promise { const chunks: Uint8Array[] = []; const t0 = Date.now(); let first = false; - const apiKey = await secret.DASHSCOPE_API_KEY; + const apiKey = await requireKey("dashscope", input.apiKey); try { await streamTts( apiKey, @@ -109,15 +109,27 @@ export async function say(app: AppConfig, input: SayInput): Promise { return out; } -export async function listVoices(app: AppConfig): Promise { +export async function listVoices(app: AppConfig, given: string | undefined): Promise { const system = SYSTEM_VOICES.map((voice) => ({ id: voice.id, gender: voice.gender, language: voice.language, })); + let apiKey = given; + if (apiKey === undefined) { + const probe = await probeKey("dashscope"); + if (!probe.available) { + return { + system, + cloned: [], + $hints: ["DashScope API key unavailable — vox status shows why"], + }; + } + apiKey = probe.value; + } let cloned: Array<{ name: string; id: string; language?: string; model?: string }> = []; try { - const voices = await new DashScopeClient(await secret.DASHSCOPE_API_KEY).listVoices(0, 50); + const voices = await new DashScopeClient(apiKey).listVoices(0, 50); cloned = voices.map((voice) => ({ name: extractNameFromVoiceId(voice.voice), id: voice.voice, @@ -125,13 +137,6 @@ export async function listVoices(app: AppConfig): Promise { model: voice.target_model, })); } catch (error) { - if (isValueUnavailable(error)) { - return { - system, - cloned: [], - $hints: ["DASHSCOPE_API_KEY unavailable — vox status shows why"], - }; - } console.error( `failed to fetch cloned voices: ${error instanceof Error ? error.message : String(error)}`, ); @@ -146,6 +151,7 @@ const SAMPLE_TEXTS: Record = { }; export type VoiceRecordInput = { + apiKey: string; name?: string; file?: string; lang?: string; @@ -153,7 +159,6 @@ export type VoiceRecordInput = { }; export async function recordVoice(app: AppConfig, input: VoiceRecordInput): Promise { - const apiKey = await secret.DASHSCOPE_API_KEY; const lang = input.lang || (await detectSystemLang()); const duration = input.duration ?? 15; @@ -183,7 +188,7 @@ export async function recordVoice(app: AppConfig, input: VoiceRecordInput): Prom console.error(`enrolling ${name}...`); let voiceId: string; try { - voiceId = await new DashScopeClient(apiKey).enrollVoice( + voiceId = await new DashScopeClient(input.apiKey).enrollVoice( name, Buffer.from(wavData).toString("base64"), ); @@ -200,11 +205,14 @@ export async function recordVoice(app: AppConfig, input: VoiceRecordInput): Prom }; } -export async function removeVoice(app: AppConfig, voiceId: string): Promise { +export async function removeVoice( + app: AppConfig, + apiKey: string, + voiceId: string, +): Promise { if (isSystemVoice(voiceId)) { throw voxError("invalid_usage", `cannot delete system voice: ${voiceId}`); } - const apiKey = await secret.DASHSCOPE_API_KEY; try { await new DashScopeClient(apiKey).deleteVoice(voiceId); } catch (error) { diff --git a/src/values.ts b/src/values.ts deleted file mode 100644 index 7f7333f..0000000 --- a/src/values.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { defineValueSource, env } from "@celados/argc/values"; -import { latch } from "@celados/latch-source"; -import * as v from "valibot"; - -/** - * Provider keys, resolved on first await: env var first (CI, one-off overrides), - * then the celados-agent vault item. Commands that never call a provider never - * touch either source. - */ -export const secret = defineValueSource({ - DASHSCOPE_API_KEY: { - schema: v.pipe(v.string(), v.trim(), v.minLength(1)), - description: "Alibaba Model Studio (DashScope) API key — ASR fun/qwen, TTS, voices, vocab", - get: [ - env("DASHSCOPE_API_KEY"), - latch("7c38804e-df94-444c-8897-efcde8a77339/凭据", { item: "bailian-api-key" }), - ], - }, - MIMO_API_KEY: { - schema: v.pipe(v.string(), v.trim(), v.minLength(1)), - description: "Xiaomi MiMo API key — ASR model mimo", - get: [ - env("MIMO_API_KEY"), - latch("52f369e9-e50d-4e49-89e7-a8901c944f41/api key", { item: "xiaomi-mimo / api-key" }), - ], - }, -}); - -/** argc's DOMAIN_ERROR for a value no source could supply. */ -export function isValueUnavailable( - error: unknown, -): error is Error & { envelope: { tried: unknown } } { - return ( - error instanceof Error && - "envelope" in error && - (error as { envelope?: { code?: unknown } }).envelope?.code === "value_unavailable" - ); -} From f06fb99f4f9deb799a85726798608d632ae758ea Mon Sep 17 00:00:00 2001 From: EthanHuo Date: Thu, 12 Nov 2026 22:12:16 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20argc=2010.2=20=E2=80=94=20named=20k?= =?UTF-8?q?ey=20types,=20say=20text=20from=20{=20ref:=20'file:=E2=80=A6'?= =?UTF-8?q?=20}?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @celados/argc ^10.2.0. - Key fields named once with addGlobalDefs: DashScopeKey (default chain, commands that always call DashScope) and ProviderKey (lazy override); @schema declares each type once instead of repeating it per command. - say.text is ref(string, [file()]): a script on disk is text: { ref: 'file:script.txt' }, restoring reading text from a file. - Tests: argv secret literal is refused (10.1); the whitespace-key check goes through stdin. --- README.md | 1 + bun.lock | 4 ++-- package.json | 2 +- src/auth.ts | 28 +++++++++++++++++----------- src/index.md | 5 +++++ src/main.test.ts | 38 +++++++++++++++++++++++++++++++++++--- src/schema.ts | 21 +++++++++++---------- 7 files changed, 72 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 27d5044..758535c 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,7 @@ The account cap is 10 lists shared across all models. ```bash vox say --text '你好世界' --voice Cherry --speed 1.2 vox say --text 'Save this' --output out.wav +vox say "{ text: { ref: 'file:script.txt' } }" # read the text from a file vox voice.list vox voice.record --file sample.wav --name myvoice vox cache.status diff --git a/bun.lock b/bun.lock index 212ffdd..f7790fe 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "vox", "dependencies": { - "@celados/argc": "^10.0.0", + "@celados/argc": "^10.2.0", "@celados/argc-latch": "^1.0.0", "@valibot/to-json-schema": "^1.8.0", "valibot": "^1.5.0", @@ -19,7 +19,7 @@ }, }, "packages": { - "@celados/argc": ["@celados/argc@10.0.0", "https://npm.celados.com/@celados/argc/-/@celados/argc-10.0.0.tgz", { "dependencies": { "yaml": "^2.9.1" }, "peerDependencies": { "@standard-schema/spec": "^1.0.0", "@valibot/to-json-schema": "^1.8.0", "valibot": "^1.5.0" } }, "sha512-5PyPq2Ix8iXx9iHg+NIej2o27TqaIWg4l0vxKijoOPMckqP3YU9QwjN+8J+QVqDZsBCSfCNOhzHU0kJXE5LRrg=="], + "@celados/argc": ["@celados/argc@10.2.0", "https://npm.celados.com/@celados/argc/-/@celados/argc-10.2.0.tgz", { "dependencies": { "yaml": "^2.9.1" }, "peerDependencies": { "@standard-schema/spec": "^1.0.0", "@valibot/to-json-schema": "^1.8.0", "valibot": "^1.5.0" } }, "sha512-cRjltgtnTDmM9WAUQ3Xgm/076R5qns6F+vkzmGxzOnZbY+HiPYAR2nXBsjSbWvBdsmi6gK/oe44NQHjXANBbQw=="], "@celados/argc-latch": ["@celados/argc-latch@1.0.0", "https://npm.celados.com/@celados/argc-latch/-/@celados/argc-latch-1.0.0.tgz", { "peerDependencies": { "@celados/argc": ">=10.0.0" } }, "sha512-Ewch1u44WzWQKhzqZ6i19B4dI0d01YDzBaL3kencCOr63Sm8xwIcqeMxS0/BKInsQKm1fWoHnntQn56HDDoFug=="], diff --git a/package.json b/package.json index c61747c..9fcd0fe 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "build": "bun build src/main.ts --outfile=dist/vox --target=bun --minify && chmod +x dist/vox" }, "dependencies": { - "@celados/argc": "^10.0.0", + "@celados/argc": "^10.2.0", "@celados/argc-latch": "^1.0.0", "@valibot/to-json-schema": "^1.8.0", "valibot": "^1.5.0", diff --git a/src/auth.ts b/src/auth.ts index c485782..a689adf 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,5 +1,5 @@ import { createLatch } from "@celados/argc-latch"; -import { env, secret } from "@celados/argc/ref"; +import { addGlobalDefs, env, secret } from "@celados/argc/ref"; import * as v from "valibot"; import { voxError } from "./cli-error.ts"; @@ -33,23 +33,29 @@ const keyField = (provider: Provider) => default: providers[provider].default, }); +const chains = (keys: readonly Provider[]) => + keys.map((name) => `${name} ${providers[name].default.join(" → ")}`).join("; "); + +/** + * Both key fields are named once (`addGlobalDefs`): `@schema` declares + * `type DashScopeKey` / `type ProviderKey` with grammar and default chain one + * time, and each command shows `apiKey?: DashScopeKey | ProviderKey`. + */ +const DashScopeKey = keyField("dashscope"); +const ProviderKey = secret(key, resolvers, { + description: `Key for the provider the command calls (DashScope; MiMo for hear model mimo). Omitted → read only when a provider is actually called — stored runs and cached audio need none: ${chains(["dashscope", "mimo"])}`, +}); +addGlobalDefs({ DashScopeKey, ProviderKey }); + /** Commands that always call DashScope: the key resolves during validation. */ -export const dashscopeAuth = { apiKey: keyField("dashscope") }; +export const dashscopeAuth = { apiKey: DashScopeKey }; /** * Commands that may not need a key (stored run, cached audio, keyless listing) * take an optional override and resolve the default chain only when a provider * is actually called — a stored replay stays offline and never waits on the vault. */ -export const lazyAuth = (scope: string, keys: readonly Provider[] = ["dashscope"]) => ({ - apiKey: v.optionalAsync( - secret(key, resolvers, { - description: `${scope}. Omitted → read only when a provider is called: ${keys - .map((name) => `${name} ${providers[name].default.join(" → ")}`) - .join("; ")}`, - }), - ), -}); +export const lazyAuth = { apiKey: v.optionalAsync(ProviderKey) }; export type KeyProbe = { available: true; value: string } | { available: false; tried: string }; diff --git a/src/index.md b/src/index.md index bbd47bb..b6cad98 100644 --- a/src/index.md +++ b/src/index.md @@ -164,12 +164,16 @@ text: | First line. Slow and clear. EOF +vox say "{ text: { ref: 'file:script.txt' }, voice: 'Cherry' }" vox say --text 'Save this' --output ~/Desktop/out.wav vox voice.list vox voice.record --file ~/sample.wav --name myvoice vox voice.remove --voiceId ``` +A script already on disk is `text: { ref: 'file:' }` (read verbatim, +relative to the working directory, `~/` is home) — don't paste it back. + System voices: Cherry, Ethan, Chelsie, Serena (zh/en), Dylan (Beijing), Jada (Shanghai), Sunny (Sichuan). `--output` writes a WAV and does not play. @@ -198,6 +202,7 @@ person rather than hunting for the key yourself. `session_not_found` → `vox se | Treat the envelope as a subtitle file | `export --format srt` or `txt` | Index and content never share stdout | | Invent a create/edit vocab command | Write `~/.vox/vocabulary/.yaml` | The YAML is the source of truth | | Put multi-line TTS in a JSON object | `vox say - <<'EOF'` with `text: \|` | Escaping burns tokens and breaks | +| Paste a script file into `text` | `text: { ref: 'file:script.txt' }` | Read as is; no copy in the transcript | | Paste a key literal into `apiKey` | `apiKey: { ref: 'env:NAME' }` | Literals land in shell history and transcripts | ## Feedback diff --git a/src/main.test.ts b/src/main.test.ts index 3842cdb..00e48e7 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -7,10 +7,16 @@ import packageJson from "../package.json" with { type: "json" }; const ENTRY = join(import.meta.dir, "main.ts"); -async function run(args: string[], home?: string, env: Record = {}) { +async function run( + args: string[], + home?: string, + env: Record = {}, + stdin?: string, +) { // Provider keys come only from what a test passes; the latch resolver is off under bun test. const { DASHSCOPE_API_KEY: _d, MIMO_API_KEY: _m, ...inherited } = process.env; const proc = Bun.spawn(["bun", "run", ENTRY, ...args], { + stdin: stdin === undefined ? "ignore" : new TextEncoder().encode(stdin), stdout: "pipe", stderr: "pipe", env: { ...inherited, NODE_ENV: "test", HOME: home ?? process.env.HOME, ...env }, @@ -52,7 +58,13 @@ test("@schema exposes the dotted command surface", async () => { expect(stdout).not.toContain("dashscope("); expect(stdout).not.toContain("auth:"); expect(stdout).not.toContain("values:"); - expect(stdout).toContain("apiKey?: string | { ref: string }"); + // Key fields are named once (addGlobalDefs), not repeated per command. + expect(stdout).toContain( + "type DashScopeKey = string | { ref: `env:${string}` | `bw://${string}/${string}` }", + ); + expect(stdout).toContain("prune(input: { apiKey?: DashScopeKey;"); + expect(stdout).toContain("list(input: { apiKey?: ProviderKey })"); + expect(stdout).toContain("text: string | { ref: `file:${string}` }"); expect(stdout).toContain("env:DASHSCOPE_API_KEY → bw://"); expect(stdout).toContain("env:MIMO_API_KEY → bw://"); }); @@ -102,7 +114,12 @@ test("an explicit env ref supplies the key; a secret is never echoed", async () expect(ok.stderr).toContain("no vocabulary name given"); expect(ok.stdout + ok.stderr).not.toContain("sk-ref-test-key"); - const bad = await run(["vocab.prune", "{ apiKey: ' ' }"], home); + const argv = await run(["vocab.prune", "{ apiKey: 'sk-argv-literal' }"], home); + expect(argv.exitCode).toBe(1); + expect(argv.stderr).toContain("a literal secret on the command line"); + expect(argv.stdout + argv.stderr).not.toContain("sk-argv-literal"); + + const bad = await run(["vocab.prune", "-"], home, {}, "apiKey: ' '\n"); expect(bad.exitCode).toBe(1); expect(bad.stderr).toContain("value hidden"); @@ -119,6 +136,21 @@ test("say treats --text - as literal text, not a stdin read", async () => { expect(stderr).toContain("key_unavailable"); }); +test("say reads text from a file reference", async () => { + const home = await mkdtemp(join(tmpdir(), "vox-home-")); + const script = join(home, "script.txt"); + await Bun.write(script, "第一行。\nSecond line.\n"); + // The file resolves during validation; with no key the provider call fails next. + const ok = await run(["say", `{ text: { ref: 'file:${script}' } }`], home); + expect(ok.exitCode).toBe(1); + expect(ok.stderr).toContain("key_unavailable"); + + const missing = await run(["say", `{ text: { ref: 'file:${join(home, "nope.txt")}' } }`], home); + expect(missing.exitCode).toBe(1); + expect(missing.stderr).toContain("INVALID_INPUT"); + expect(missing.stderr).not.toContain("key_unavailable"); +}); + test("session.remove rejects path traversal before touching the filesystem", async () => { const home = await mkdtemp(join(tmpdir(), "vox-home-")); const { stderr, exitCode } = await run(["session.remove", "--sid", ".."], home); diff --git a/src/schema.ts b/src/schema.ts index 3787114..b5401d5 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -1,7 +1,7 @@ import type { InferHandlers } from "@celados/argc"; import { c, group } from "@celados/argc"; -import { toStandardJsonSchema } from "@celados/argc/ref"; +import { file, ref, toStandardJsonSchema } from "@celados/argc/ref"; import * as v from "valibot"; import { dashscopeAuth, lazyAuth } from "./auth.ts"; @@ -30,10 +30,7 @@ const hear = c .input( s( v.objectAsync({ - ...lazyAuth("Key for the chosen model (DashScope for fun/qwen, MiMo for mimo)", [ - "dashscope", - "mimo", - ]), + ...lazyAuth, file: v.pipe(v.string(), v.minLength(1)), model: hearVendor, vocab: v.optional(v.string()), @@ -157,7 +154,7 @@ const vocab = group( { list: c .meta({ description: "List local vocabularies, sync state, and remote quota" }) - .input(s(v.objectAsync({ ...lazyAuth("DashScope key for the remote quota") }))), + .input(s(v.objectAsync({ ...lazyAuth }))), sync: c .meta({ description: @@ -187,17 +184,21 @@ const vocab = group( const say = c .meta({ - description: "Speak text with TTS. Multi-line text: whole input as YAML with text: |", + description: + "Speak text with TTS. Multi-line text: whole input as YAML with text: |, or a file with text: { ref: 'file:…' }", examples: [ "vox say --text 'Hello world' --voice Cherry", "vox say - <<'EOF'\nvoice: Cherry\ntext: |\n First line.\n Slow and clear.\nEOF", + "vox say \"{ text: { ref: 'file:script.txt' }, voice: 'Cherry' }\"", ], }) .input( s( v.objectAsync({ - ...lazyAuth("DashScope key; cached audio needs none"), - text: v.pipe(v.string(), v.minLength(1)), + ...lazyAuth, + text: ref(v.pipe(v.string(), v.minLength(1)), [file()], { + description: "Text to speak, or a text file read verbatim", + }), voice: v.optional(v.string()), lang: v.optional(v.string(), "auto"), instruct: v.optional(v.string()), @@ -214,7 +215,7 @@ const voice = group( { list: c .meta({ description: "List system and cloned voices" }) - .input(s(v.objectAsync({ ...lazyAuth("DashScope key for cloned voices") }))), + .input(s(v.objectAsync({ ...lazyAuth }))), record: c .meta({ description: "Enroll a cloned voice from a file, or record from the mic on a TTY", From 6cf12b21bc9b2307f3547684909bfa999edc8234 Mon Sep 17 00:00:00 2001 From: EthanHuo Date: Thu, 12 Nov 2026 23:11:46 +0800 Subject: [PATCH 3/3] chore: lock @celados/argc 10.3.1 --- bun.lock | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index f7790fe..c8d266e 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "vox", "dependencies": { - "@celados/argc": "^10.2.0", + "@celados/argc": "^10.3.1", "@celados/argc-latch": "^1.0.0", "@valibot/to-json-schema": "^1.8.0", "valibot": "^1.5.0", @@ -19,7 +19,7 @@ }, }, "packages": { - "@celados/argc": ["@celados/argc@10.2.0", "https://npm.celados.com/@celados/argc/-/@celados/argc-10.2.0.tgz", { "dependencies": { "yaml": "^2.9.1" }, "peerDependencies": { "@standard-schema/spec": "^1.0.0", "@valibot/to-json-schema": "^1.8.0", "valibot": "^1.5.0" } }, "sha512-cRjltgtnTDmM9WAUQ3Xgm/076R5qns6F+vkzmGxzOnZbY+HiPYAR2nXBsjSbWvBdsmi6gK/oe44NQHjXANBbQw=="], + "@celados/argc": ["@celados/argc@10.3.1", "https://npm.celados.com/@celados/argc/-/@celados/argc-10.3.1.tgz", { "dependencies": { "yaml": "^2.9.1" }, "peerDependencies": { "@standard-schema/spec": "^1.0.0", "@valibot/to-json-schema": "^1.8.0", "valibot": "^1.5.0" } }, "sha512-rIkiVqoICQVAdmz+G2EpqwKeU2UfvF3R2Y7+kmOTlnO4JaxR4rYndcYCj2k3f+w3f7FR/aldKCXFcKrTMg1ljg=="], "@celados/argc-latch": ["@celados/argc-latch@1.0.0", "https://npm.celados.com/@celados/argc-latch/-/@celados/argc-latch-1.0.0.tgz", { "peerDependencies": { "@celados/argc": ">=10.0.0" } }, "sha512-Ewch1u44WzWQKhzqZ6i19B4dI0d01YDzBaL3kencCOr63Sm8xwIcqeMxS0/BKInsQKm1fWoHnntQn56HDDoFug=="], diff --git a/package.json b/package.json index 9fcd0fe..9b8c4a5 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "build": "bun build src/main.ts --outfile=dist/vox --target=bun --minify && chmod +x dist/vox" }, "dependencies": { - "@celados/argc": "^10.2.0", + "@celados/argc": "^10.3.1", "@celados/argc-latch": "^1.0.0", "@valibot/to-json-schema": "^1.8.0", "valibot": "^1.5.0",