Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,25 @@
"llm"
]
},
{
"name": "ai",
"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.",
"source": "./plugins/ai",
"category": "productivity",
"author": {
"name": "profullstack",
"url": "https://profullstack.com"
},
"homepage": "https://github.com/profullstack/cli-tools#ask-web",
"keywords": [
"search",
"citations",
"perplexity",
"tts",
"speech",
"elevenlabs"
]
},
{
"name": "bo",
"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.",
Expand Down
79 changes: 75 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ TypeScript, installed as executables on `PATH`.
| [`domainjson`](#domainjson) | whois-style, JSON-first name lookup |
| [`domainfree`](#domainfree) | Which of these domains you can actually register |
| [`blog-post`](#blog-post) | Publish to a plain-HTML blog without breaking the feed |
| [`ask-web`](#ask-web) | Answer a question from the live web, with its sources |
| [`tts`](#tts) | Read text aloud and keep the audio |

## Requirements

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

## API keys

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

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

`logicsrc teams pull` can only write a decrypted `.env` to a path, so the
plaintext exists for the length of one read: it goes to a `0700` temporary
Expand All @@ -143,6 +146,12 @@ carries the same masked previews, not the values.
| --- | --- | --- |
| `openai` | `OPENAI_API_KEY` | `generate-names` |
| `anthropic` | `ANTHROPIC_API_KEY` | `generate-names` |
| `perplexity` | `PERPLEXITY_API_KEY` | `ask-web` |
| `elevenlabs` | `ELEVENLABS_API_KEY` | `tts` |

A key earns a row here by being read by a command in this repository, not by
being a key the team owns. The vault holds more than twice as many; the rest
stay in it.

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

### `ask-web`

Answers a question from a live web search and prints the pages the answer came
from, numbered to match the `[n]` markers in the text:

```sh
ask-web "what is the latest Node LTS" --recency month
# The latest stable Node.js LTS version is v24.19.0.[3][5]
#
# Sources:
# [1] Node.js — https://endoflife.date/nodejs (2026-08-06)
# [3] Node.js 24.19.0 (LTS) — https://nodejs.org/en/blog/release/v24.19.0 (2026-08-03)
```

```sh
ask-web "…" --domains nodejs.org,github.com # only these hosts
ask-web "…" --model sonar-pro # search wider
ask-web "…" --bare # prose only, for piping
ask-web "…" --json # answer and sources as JSON
```

It is not named `ask` because that name is already taken on `PATH` here, and a
command that shadows another one silently is worse than a longer name.

**The sources are the feature.** An answer whose `[1]` resolves to nothing is
indistinguishable from an answer that was invented, so two fields of the
response are treated differently on purpose: `citations` is a positional URL
list whose order *is* the numbering, while `search_results` carries the titles in
whatever order it likes and is joined on by URL. Numbering from `search_results`
would mislabel every source. When the answer cites a marker no source backs,
that is reported on stderr rather than dropped.

Answers go to stdout and status to stderr, so `ask-web … | pbcopy` gets prose.

### `tts`

Reads text aloud with ElevenLabs and keeps the audio, printing the path it
wrote:

```sh
tts "the deploy finished" # → the-deploy-finished.mp3
mpv "$(tts 'build is green')"
cat post.md | tts --voice George --out post.mp3
tts --voices # the account's voices, by name and ID
```

The file is named after the text rather than a timestamp, because a directory of
`speech-1755794400.mp3` tells you nothing about which one it was.

A voice can be given as an ID, a full label, or just the human part of it —
the account's are called things like `River - Relaxed, Neutral, Informative`,
which nobody is going to type. An ambiguous prefix is an error naming the
candidates rather than a pick of the first match: choosing one would be a coin
flip that changes narrator the day the account gains a voice, with nothing on
screen to explain why. A voice given as an ID skips the lookup entirely, so
`--voice <id>` works on a key whose plan cannot list voices.

`--stability`, `--similarity` and `--style` take 0–1. Nothing is sent unless you
ask: a full settings object would override whatever the voice was tuned with in
the dashboard, on an account other people share. Synthesis spends characters from
that shared quota, and nothing here retries, so a failed call never costs twice.

## As a moshcode plugin

This repo is also a plugin marketplace:
Expand Down
124 changes: 124 additions & 0 deletions bin/ask-web.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
#!/usr/bin/env -S npx --yes tsx
/**
* ask-web — answer a question from the live web, with the sources attached.
*
* ask-web "what changed in the EU AI act this month" --recency month
*
* Not named `ask`: that name is already taken on PATH here, and a command that
* shadows another one silently is worse than a longer name.
*/

import { UsageError, csv, integer, parseArgs } from '../src/args.ts';
import {
DEFAULT_MODEL,
MODELS,
type Model,
RECENCY,
type Recency,
askWeb,
formatAnswer,
perplexityCaller,
} from '../src/ask-web.ts';
import { resolveCredentials } from '../src/credentials.ts';
import { isMain } from '../src/is-main.ts';

const USAGE = `Usage:
ask-web "<question>"
ask-web "what shipped in Node 24" --recency month --domains nodejs.org

Answers from a live web search and prints the pages it used, numbered to match
the [n] markers in the answer.

Options:
--model M ${MODELS.join(' | ')}
(default: ${DEFAULT_MODEL})
--recency R only pages from the last ${RECENCY.join(' | ')}
--domains A,B restrict the search to these hosts
--max-tokens N cap the answer length
--bare print the answer only, no source list
--json the whole answer as JSON, sources included
--timeout MS API timeout (default: 60000)
-h, --help show this help

Needs a Perplexity key. Store one once:

cli-tools config set perplexity # prompts, nothing echoed or logged
cli-tools config # what is set, and where it came from

kept 0600 in ~/.config/cli-tools/credentials.json. PERPLEXITY_API_KEY still
works and takes precedence over a stored key.

The answer goes to stdout and nothing else does, so it pipes cleanly.
`;

if (isMain(import.meta.url)) {
try {
const { flags, values, positional } = parseArgs(process.argv.slice(2), {
boolean: ['-h', '--help', '--bare', '--json'],
string: ['--model', '--recency', '--domains', '--max-tokens', '--timeout'],
});

if (flags.has('-h') || flags.has('--help') || positional.length === 0) {
process.stdout.write(USAGE);
process.exit(positional.length === 0 && !flags.has('-h') && !flags.has('--help') ? 1 : 0);
}

const question = positional.join(' ').trim();
if (question.length < 3) throw new UsageError('ask an actual question');

const model = values.get('--model') ?? DEFAULT_MODEL;
if (!MODELS.includes(model as Model)) {
throw new UsageError(`unknown model: ${model} (expected ${MODELS.join(', ')})`);
}

const recency = values.get('--recency');
if (recency !== undefined && !RECENCY.includes(recency as Recency)) {
throw new UsageError(`--recency must be ${RECENCY.join(', ')}, got "${recency}"`);
}

const timeout = integer(values, '--timeout', 60_000, { min: 1000, max: 600_000 });
const maxTokens = values.has('--max-tokens')
? integer(values, '--max-tokens', 0, { min: 1, max: 32_000 })
: undefined;

// Stored keys first, environment on top — see src/credentials.ts.
const credentials = resolveCredentials(process.env);
const apiKey = credentials['PERPLEXITY_API_KEY'];
if (!apiKey) {
throw new UsageError(
'no Perplexity key — run `cli-tools config set perplexity`, ' +
'or export PERPLEXITY_API_KEY',
);
}

const answer = await askWeb(question, perplexityCaller(apiKey, timeout), {
model,
...(recency ? { recency: recency as Recency } : {}),
domains: csv(values, '--domains'),
...(maxTokens !== undefined ? { maxTokens } : {}),
});

process.stdout.write(
flags.has('--json')
? `${JSON.stringify(answer, null, 2)}\n`
: formatAnswer(answer, { bare: flags.has('--bare') }),
);

// Status on stderr so it never lands in a pipe. The dangling-citation line
// is the one worth reading: it means the answer cited something the search
// did not return.
process.stderr.write(`${answer.sources.length} sources · ${answer.model}\n`);
if (answer.danglingCitations.length > 0) {
process.stderr.write(
`warning: cites [${answer.danglingCitations.join('], [')}] with no matching source\n`,
);
}
} catch (error) {
if (error instanceof UsageError) {
process.stderr.write(`ask-web: ${error.message}\n`);
process.exit(1);
}
process.stderr.write(`ask-web: ${error instanceof Error ? error.message : error}\n`);
process.exit(2);
}
}
Loading
Loading