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/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 0ec81ce..c8d266e 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.3.1", + "@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.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/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..9b8c4a5 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.3.1", + "@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..a689adf --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,79 @@ +import { createLatch } from "@celados/argc-latch"; +import { addGlobalDefs, 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, + }); + +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: 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 = { apiKey: v.optionalAsync(ProviderKey) }; + +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,15 +158,22 @@ 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: { 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. @@ -173,14 +182,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 +201,9 @@ 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 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 fe575f3..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 = {}) { - // Provider keys come only from what a test passes; the latch source is off under bun test. +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 }, @@ -51,9 +57,16 @@ 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:"); + // 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://"); }); test("@skill prints the embedded guide", async () => { @@ -81,13 +94,61 @@ 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 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"); + + 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("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 () => { 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..b5401d5 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 { file, ref, 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,8 @@ const hear = c }) .input( s( - v.object({ + v.objectAsync({ + ...lazyAuth, file: v.pipe(v.string(), v.minLength(1)), model: hearVendor, vocab: v.optional(v.string()), @@ -151,7 +154,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 }))), sync: c .meta({ description: @@ -160,7 +163,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,22 +178,27 @@ 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: |, or a file with text: { ref: 'file:…' }", 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", + "vox say \"{ text: { ref: 'file:script.txt' }, voice: 'Cherry' }\"", ], }) .input( s( - v.object({ - text: v.pipe(v.string(), v.minLength(1)), + v.objectAsync({ + ...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()), @@ -204,7 +213,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 }))), record: c .meta({ description: "Enroll a cloned voice from a file, or record from the mic on a TTY", @@ -212,7 +223,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 +234,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" - ); -}