Skip to content

Commit 8082ea5

Browse files
ralyodioclaude
andauthored
Add ask-web and tts: the two AI tools we already pay for (#17)
Working from a list of 131 AI tools, the question was which of them are actually ours. The team vault answers it: of the thirteen keys in profullstack-sharable-keys--prod, five appear on that list, and two of those five had no command here. ask-web Perplexity — a question answered from the live web, with the pages it came from tts ElevenLabs — text read aloud in a named voice, audio kept Not named `ask` or `say`: both already resolve to something else on PATH, and a command that shadows another one silently is worse than a longer name. No pit alias for the same reason. `ask-web` numbers its source list from the response's positional `citations` field, whose order *is* the [n] numbering, and joins titles on by URL from `search_results`, which arrives in its own order — numbering from the titles would mislabel every source, quietly. A marker the answer cites with no source behind it is a warning on stderr rather than a line dropped. `tts` resolves a voice by ID, full label, or the human part of it, since the account's are called things like "River - Relaxed, Neutral, Informative". An ambiguous prefix is an error naming the candidates: a silent pick would change narrator the day the account gains a voice. Voice settings are omitted unless asked for, so a call cannot overwrite what a shared account tuned in the dashboard. KNOWN_KEYS gains perplexity and elevenlabs. The rule that kept it at two still holds — a key earns its place by being read by a command here, not by being one the team owns — and the vault's remaining nine stay in the vault. Gemini was the third candidate and is deliberately absent: the vault's GEMINI_API_KEY returns 400 "User location is not supported for the API use" from here, so a provider built on it would ship broken. Both verified against the live APIs. Suite is 224 tests. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 61a9b01 commit 8082ea5

15 files changed

Lines changed: 1438 additions & 6 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,25 @@
6363
"llm"
6464
]
6565
},
66+
{
67+
"name": "ai",
68+
"description": "Ask a question and get an answer from the live web with the pages it came from, numbered to match the markers in the text, and read text aloud in a named voice. The paid APIs worth a command: a grounded answer you can check, and speech you can keep.",
69+
"source": "./plugins/ai",
70+
"category": "productivity",
71+
"author": {
72+
"name": "profullstack",
73+
"url": "https://profullstack.com"
74+
},
75+
"homepage": "https://github.com/profullstack/cli-tools#ask-web",
76+
"keywords": [
77+
"search",
78+
"citations",
79+
"perplexity",
80+
"tts",
81+
"speech",
82+
"elevenlabs"
83+
]
84+
},
6685
{
6786
"name": "bo",
6887
"description": "BufferOverride from the terminal: wrap a failing command, keep what it printed with the environment that produced it, strip the secrets, and find the existing answer before asking. Answers declare the versions they are valid for, who or what wrote them, and how many people reproduced them.",

README.md

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ TypeScript, installed as executables on `PATH`.
1313
| [`domainjson`](#domainjson) | whois-style, JSON-first name lookup |
1414
| [`domainfree`](#domainfree) | Which of these domains you can actually register |
1515
| [`blog-post`](#blog-post) | Publish to a plain-HTML blog without breaking the feed |
16+
| [`ask-web`](#ask-web) | Answer a question from the live web, with its sources |
17+
| [`tts`](#tts) | Read text aloud and keep the audio |
1618

1719
## Requirements
1820

@@ -93,8 +95,9 @@ ln -sf ~/scripts/bin/gh-prs-merge ~/.local/bin/gh-prs-merge # and so on
9395

9496
## API keys
9597

96-
`generate-names` needs an OpenAI or Anthropic key. Store one once, and nothing
97-
has to carry it in an environment again:
98+
Four commands here call a paid API: `generate-names` (OpenAI or Anthropic),
99+
`ask-web` (Perplexity) and `tts` (ElevenLabs). Store the keys once, and nothing
100+
has to carry them in an environment again:
98101

99102
```sh
100103
cli-tools config pull # import them from the logicsrc team vault
@@ -125,8 +128,8 @@ obscurely.
125128
**It imports only the keys these commands read, and leaves the rest in the
126129
vault.** Copying a whole vault down would make the local file a second copy of
127130
every team secret that nobody remembers to invalidate — which is the thing the
128-
vault exists to avoid. The vault stays the authority; this is a cache of the two
129-
or three keys `generate-names` actually needs.
131+
vault exists to avoid. The vault stays the authority; this is a cache of the
132+
handful of keys these commands actually read.
130133

131134
`logicsrc teams pull` can only write a decrypted `.env` to a path, so the
132135
plaintext exists for the length of one read: it goes to a `0700` temporary
@@ -143,6 +146,12 @@ carries the same masked previews, not the values.
143146
| --- | --- | --- |
144147
| `openai` | `OPENAI_API_KEY` | `generate-names` |
145148
| `anthropic` | `ANTHROPIC_API_KEY` | `generate-names` |
149+
| `perplexity` | `PERPLEXITY_API_KEY` | `ask-web` |
150+
| `elevenlabs` | `ELEVENLABS_API_KEY` | `tts` |
151+
152+
A key earns a row here by being read by a command in this repository, not by
153+
being a key the team owns. The vault holds more than twice as many; the rest
154+
stay in it.
146155

147156
**The environment wins over the file.** A key exported in your shell or injected
148157
by CI overrides a stored one, so a one-off `OPENAI_API_KEY=… generate-names …`
@@ -421,6 +430,68 @@ What it refuses to do:
421430
`check` reports missing, unparseable and future dates, empty descriptions and a
422431
missing `<h1>`, and exits non-zero, so it works as a pre-publish gate.
423432

433+
### `ask-web`
434+
435+
Answers a question from a live web search and prints the pages the answer came
436+
from, numbered to match the `[n]` markers in the text:
437+
438+
```sh
439+
ask-web "what is the latest Node LTS" --recency month
440+
# The latest stable Node.js LTS version is v24.19.0.[3][5]
441+
#
442+
# Sources:
443+
# [1] Node.js — https://endoflife.date/nodejs (2026-08-06)
444+
# [3] Node.js 24.19.0 (LTS) — https://nodejs.org/en/blog/release/v24.19.0 (2026-08-03)
445+
```
446+
447+
```sh
448+
ask-web "" --domains nodejs.org,github.com # only these hosts
449+
ask-web "" --model sonar-pro # search wider
450+
ask-web "" --bare # prose only, for piping
451+
ask-web "" --json # answer and sources as JSON
452+
```
453+
454+
It is not named `ask` because that name is already taken on `PATH` here, and a
455+
command that shadows another one silently is worse than a longer name.
456+
457+
**The sources are the feature.** An answer whose `[1]` resolves to nothing is
458+
indistinguishable from an answer that was invented, so two fields of the
459+
response are treated differently on purpose: `citations` is a positional URL
460+
list whose order *is* the numbering, while `search_results` carries the titles in
461+
whatever order it likes and is joined on by URL. Numbering from `search_results`
462+
would mislabel every source. When the answer cites a marker no source backs,
463+
that is reported on stderr rather than dropped.
464+
465+
Answers go to stdout and status to stderr, so `ask-web … | pbcopy` gets prose.
466+
467+
### `tts`
468+
469+
Reads text aloud with ElevenLabs and keeps the audio, printing the path it
470+
wrote:
471+
472+
```sh
473+
tts "the deploy finished" # → the-deploy-finished.mp3
474+
mpv "$(tts 'build is green')"
475+
cat post.md | tts --voice George --out post.mp3
476+
tts --voices # the account's voices, by name and ID
477+
```
478+
479+
The file is named after the text rather than a timestamp, because a directory of
480+
`speech-1755794400.mp3` tells you nothing about which one it was.
481+
482+
A voice can be given as an ID, a full label, or just the human part of it —
483+
the account's are called things like `River - Relaxed, Neutral, Informative`,
484+
which nobody is going to type. An ambiguous prefix is an error naming the
485+
candidates rather than a pick of the first match: choosing one would be a coin
486+
flip that changes narrator the day the account gains a voice, with nothing on
487+
screen to explain why. A voice given as an ID skips the lookup entirely, so
488+
`--voice <id>` works on a key whose plan cannot list voices.
489+
490+
`--stability`, `--similarity` and `--style` take 0–1. Nothing is sent unless you
491+
ask: a full settings object would override whatever the voice was tuned with in
492+
the dashboard, on an account other people share. Synthesis spends characters from
493+
that shared quota, and nothing here retries, so a failed call never costs twice.
494+
424495
## As a moshcode plugin
425496

426497
This repo is also a plugin marketplace:

bin/ask-web.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
#!/usr/bin/env -S npx --yes tsx
2+
/**
3+
* ask-web — answer a question from the live web, with the sources attached.
4+
*
5+
* ask-web "what changed in the EU AI act this month" --recency month
6+
*
7+
* Not named `ask`: that name is already taken on PATH here, and a command that
8+
* shadows another one silently is worse than a longer name.
9+
*/
10+
11+
import { UsageError, csv, integer, parseArgs } from '../src/args.ts';
12+
import {
13+
DEFAULT_MODEL,
14+
MODELS,
15+
type Model,
16+
RECENCY,
17+
type Recency,
18+
askWeb,
19+
formatAnswer,
20+
perplexityCaller,
21+
} from '../src/ask-web.ts';
22+
import { resolveCredentials } from '../src/credentials.ts';
23+
import { isMain } from '../src/is-main.ts';
24+
25+
const USAGE = `Usage:
26+
ask-web "<question>"
27+
ask-web "what shipped in Node 24" --recency month --domains nodejs.org
28+
29+
Answers from a live web search and prints the pages it used, numbered to match
30+
the [n] markers in the answer.
31+
32+
Options:
33+
--model M ${MODELS.join(' | ')}
34+
(default: ${DEFAULT_MODEL})
35+
--recency R only pages from the last ${RECENCY.join(' | ')}
36+
--domains A,B restrict the search to these hosts
37+
--max-tokens N cap the answer length
38+
--bare print the answer only, no source list
39+
--json the whole answer as JSON, sources included
40+
--timeout MS API timeout (default: 60000)
41+
-h, --help show this help
42+
43+
Needs a Perplexity key. Store one once:
44+
45+
cli-tools config set perplexity # prompts, nothing echoed or logged
46+
cli-tools config # what is set, and where it came from
47+
48+
kept 0600 in ~/.config/cli-tools/credentials.json. PERPLEXITY_API_KEY still
49+
works and takes precedence over a stored key.
50+
51+
The answer goes to stdout and nothing else does, so it pipes cleanly.
52+
`;
53+
54+
if (isMain(import.meta.url)) {
55+
try {
56+
const { flags, values, positional } = parseArgs(process.argv.slice(2), {
57+
boolean: ['-h', '--help', '--bare', '--json'],
58+
string: ['--model', '--recency', '--domains', '--max-tokens', '--timeout'],
59+
});
60+
61+
if (flags.has('-h') || flags.has('--help') || positional.length === 0) {
62+
process.stdout.write(USAGE);
63+
process.exit(positional.length === 0 && !flags.has('-h') && !flags.has('--help') ? 1 : 0);
64+
}
65+
66+
const question = positional.join(' ').trim();
67+
if (question.length < 3) throw new UsageError('ask an actual question');
68+
69+
const model = values.get('--model') ?? DEFAULT_MODEL;
70+
if (!MODELS.includes(model as Model)) {
71+
throw new UsageError(`unknown model: ${model} (expected ${MODELS.join(', ')})`);
72+
}
73+
74+
const recency = values.get('--recency');
75+
if (recency !== undefined && !RECENCY.includes(recency as Recency)) {
76+
throw new UsageError(`--recency must be ${RECENCY.join(', ')}, got "${recency}"`);
77+
}
78+
79+
const timeout = integer(values, '--timeout', 60_000, { min: 1000, max: 600_000 });
80+
const maxTokens = values.has('--max-tokens')
81+
? integer(values, '--max-tokens', 0, { min: 1, max: 32_000 })
82+
: undefined;
83+
84+
// Stored keys first, environment on top — see src/credentials.ts.
85+
const credentials = resolveCredentials(process.env);
86+
const apiKey = credentials['PERPLEXITY_API_KEY'];
87+
if (!apiKey) {
88+
throw new UsageError(
89+
'no Perplexity key — run `cli-tools config set perplexity`, ' +
90+
'or export PERPLEXITY_API_KEY',
91+
);
92+
}
93+
94+
const answer = await askWeb(question, perplexityCaller(apiKey, timeout), {
95+
model,
96+
...(recency ? { recency: recency as Recency } : {}),
97+
domains: csv(values, '--domains'),
98+
...(maxTokens !== undefined ? { maxTokens } : {}),
99+
});
100+
101+
process.stdout.write(
102+
flags.has('--json')
103+
? `${JSON.stringify(answer, null, 2)}\n`
104+
: formatAnswer(answer, { bare: flags.has('--bare') }),
105+
);
106+
107+
// Status on stderr so it never lands in a pipe. The dangling-citation line
108+
// is the one worth reading: it means the answer cited something the search
109+
// did not return.
110+
process.stderr.write(`${answer.sources.length} sources · ${answer.model}\n`);
111+
if (answer.danglingCitations.length > 0) {
112+
process.stderr.write(
113+
`warning: cites [${answer.danglingCitations.join('], [')}] with no matching source\n`,
114+
);
115+
}
116+
} catch (error) {
117+
if (error instanceof UsageError) {
118+
process.stderr.write(`ask-web: ${error.message}\n`);
119+
process.exit(1);
120+
}
121+
process.stderr.write(`ask-web: ${error instanceof Error ? error.message : error}\n`);
122+
process.exit(2);
123+
}
124+
}

0 commit comments

Comments
 (0)